forked from auracaster/bumble_mirror
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fe7931d7d | |||
| cbd46adbcf | |||
| af466c2970 | |||
| 931e2de854 | |||
| 55eb7eb237 | |||
| bade4502f9 | |||
| 9f952f202f | |||
| 5a477eb391 | |||
| 86cda8771d | |||
| c1ea0ddd35 | |||
| f567711a6c | |||
| 509df4c676 | |||
| b375ed07b4 | |||
| 69d62d3dd1 | |||
| fe3fa3d505 | |||
| 27fcd43224 | |||
| c3b2bb19d5 | |||
| 34287177b9 | |||
| d238dd4059 | |||
| 865f3a249f | |||
| 7324d322fe | |||
| af148b476d | |||
| 80d60aaf15 | |||
| c80f89d20f | |||
| a27f55a588 | |||
| 62e4670a39 | |||
| 99695bb264 | |||
| eb54898106 | |||
| 4f5ee204d2 | |||
| 2552e21db1 | |||
| 6168f87e2f | |||
| ca7d2ca4df | |||
| 60723323e9 | |||
| 3ce7b9255b | |||
| 97fcfc2fa0 | |||
| 19674e3758 | |||
| 1130e1db8f | |||
| 37c7f3a58a | |||
| 0a12b2bf2e | |||
| d014acbe63 | |||
| 07f9997a49 | |||
| b9f91f695a | |||
| 082d55af10 | |||
| 4c3fd5688d | |||
| 9d3d5495ce | |||
| b3869f267c | |||
| 8715333706 | |||
| b57096abe2 | |||
| 48685c8587 | |||
| 100bea6b41 | |||
| 63819bf9dd | |||
| 6e55390930 | |||
| e3fdab4175 | |||
| bbcd14dbf0 | |||
| 01dc0d574b | |||
| 5e959d638e | |||
| 8d908288c8 | |||
| c88b32a406 | |||
| 5a72eefb89 | |||
| 430046944b | |||
| 21d23320eb | |||
| d0990ee04d | |||
| 2d88e853e8 | |||
| a060a70fba | |||
| a06394ad4a | |||
| a1414c2b5b | |||
| b2864dac2d | |||
| b78f895143 | |||
| c4e9726828 | |||
| d4b8e8348a | |||
| 19debaa52e | |||
| 73fe564321 | |||
| a00abd65b3 | |||
| f169ceaebb | |||
| 528af0d338 | |||
| 4b25eed869 | |||
| fcd6bd7136 | |||
| 2bed50b353 | |||
| 1fe3778a74 | |||
| 5e31bcf23d | |||
| fe429cb2eb | |||
| c91695c23a | |||
| 55f99e6887 | |||
| b190069f48 |
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13.0"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install ".[build,test,development,pandora]"
|
python -m pip install ".[build,test,development]"
|
||||||
- name: Check
|
- name: Check
|
||||||
run: |
|
run: |
|
||||||
invoke project.pre-commit
|
invoke project.pre-commit
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
- name: Install
|
- name: Install
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install .[avatar,pandora]
|
python -m pip install .[avatar]
|
||||||
- name: Rootcanal
|
- name: Rootcanal
|
||||||
run: nohup python -m rootcanal > rootcanal.log &
|
run: nohup python -m rootcanal > rootcanal.log &
|
||||||
- name: Test
|
- name: Test
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
rust-version: [ "1.76.0", "stable" ]
|
rust-version: [ "1.76.0", "stable" ]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
Vendored
+7
@@ -14,9 +14,12 @@
|
|||||||
"ASHA",
|
"ASHA",
|
||||||
"asyncio",
|
"asyncio",
|
||||||
"ATRAC",
|
"ATRAC",
|
||||||
|
"auracast",
|
||||||
"avctp",
|
"avctp",
|
||||||
"avdtp",
|
"avdtp",
|
||||||
"avrcp",
|
"avrcp",
|
||||||
|
"biginfo",
|
||||||
|
"bigs",
|
||||||
"bitpool",
|
"bitpool",
|
||||||
"bitstruct",
|
"bitstruct",
|
||||||
"BSCP",
|
"BSCP",
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
"deregistration",
|
"deregistration",
|
||||||
"dhkey",
|
"dhkey",
|
||||||
"diversifier",
|
"diversifier",
|
||||||
|
"ediv",
|
||||||
"endianness",
|
"endianness",
|
||||||
"ESCO",
|
"ESCO",
|
||||||
"Fitbit",
|
"Fitbit",
|
||||||
@@ -47,6 +51,7 @@
|
|||||||
"libc",
|
"libc",
|
||||||
"liblc",
|
"liblc",
|
||||||
"libusb",
|
"libusb",
|
||||||
|
"maxs",
|
||||||
"MITM",
|
"MITM",
|
||||||
"MSBC",
|
"MSBC",
|
||||||
"NDIS",
|
"NDIS",
|
||||||
@@ -54,8 +59,10 @@
|
|||||||
"NONBLOCK",
|
"NONBLOCK",
|
||||||
"NONCONN",
|
"NONCONN",
|
||||||
"OXIMETER",
|
"OXIMETER",
|
||||||
|
"PDUS",
|
||||||
"popleft",
|
"popleft",
|
||||||
"PRAND",
|
"PRAND",
|
||||||
|
"prefs",
|
||||||
"protobuf",
|
"protobuf",
|
||||||
"psms",
|
"psms",
|
||||||
"pyee",
|
"pyee",
|
||||||
|
|||||||
+383
-75
@@ -1,4 +1,4 @@
|
|||||||
# Copyright 2024 Google LLC
|
# Copyright 2025 Google LLC
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -16,25 +16,35 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import wave
|
||||||
|
import itertools
|
||||||
from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple
|
from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import pyee
|
import pyee
|
||||||
|
|
||||||
|
try:
|
||||||
|
import lc3 # type: ignore # pylint: disable=E0401
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
import bumble.company_ids
|
from bumble import company_ids
|
||||||
import bumble.core
|
from bumble import core
|
||||||
|
from bumble import gatt
|
||||||
|
from bumble import hci
|
||||||
|
from bumble.profiles import bap
|
||||||
|
from bumble.profiles import le_audio
|
||||||
|
from bumble.profiles import pbp
|
||||||
|
from bumble.profiles import bass
|
||||||
import bumble.device
|
import bumble.device
|
||||||
import bumble.gatt
|
|
||||||
import bumble.hci
|
|
||||||
import bumble.profiles.bap
|
|
||||||
import bumble.profiles.bass
|
|
||||||
import bumble.profiles.pbp
|
|
||||||
import bumble.transport
|
import bumble.transport
|
||||||
import bumble.utils
|
import bumble.utils
|
||||||
|
|
||||||
@@ -49,7 +59,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Constants
|
# Constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
AURACAST_DEFAULT_DEVICE_NAME = 'Bumble Auracast'
|
AURACAST_DEFAULT_DEVICE_NAME = 'Bumble Auracast'
|
||||||
AURACAST_DEFAULT_DEVICE_ADDRESS = bumble.hci.Address('F0:F1:F2:F3:F4:F5')
|
AURACAST_DEFAULT_DEVICE_ADDRESS = hci.Address('F0:F1:F2:F3:F4:F5')
|
||||||
AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
|
AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
|
||||||
AURACAST_DEFAULT_ATT_MTU = 256
|
AURACAST_DEFAULT_ATT_MTU = 256
|
||||||
|
|
||||||
@@ -60,19 +70,14 @@ AURACAST_DEFAULT_ATT_MTU = 256
|
|||||||
class BroadcastScanner(pyee.EventEmitter):
|
class BroadcastScanner(pyee.EventEmitter):
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class Broadcast(pyee.EventEmitter):
|
class Broadcast(pyee.EventEmitter):
|
||||||
name: str
|
name: str | None
|
||||||
sync: bumble.device.PeriodicAdvertisingSync
|
sync: bumble.device.PeriodicAdvertisingSync
|
||||||
|
broadcast_id: int
|
||||||
rssi: int = 0
|
rssi: int = 0
|
||||||
public_broadcast_announcement: Optional[
|
public_broadcast_announcement: Optional[pbp.PublicBroadcastAnnouncement] = None
|
||||||
bumble.profiles.pbp.PublicBroadcastAnnouncement
|
broadcast_audio_announcement: Optional[bap.BroadcastAudioAnnouncement] = None
|
||||||
] = None
|
basic_audio_announcement: Optional[bap.BasicAudioAnnouncement] = None
|
||||||
broadcast_audio_announcement: Optional[
|
appearance: Optional[core.Appearance] = None
|
||||||
bumble.profiles.bap.BroadcastAudioAnnouncement
|
|
||||||
] = None
|
|
||||||
basic_audio_announcement: Optional[
|
|
||||||
bumble.profiles.bap.BasicAudioAnnouncement
|
|
||||||
] = None
|
|
||||||
appearance: Optional[bumble.core.Appearance] = None
|
|
||||||
biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
|
biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
|
||||||
manufacturer_data: Optional[Tuple[str, bytes]] = None
|
manufacturer_data: Optional[Tuple[str, bytes]] = None
|
||||||
|
|
||||||
@@ -86,42 +91,36 @@ class BroadcastScanner(pyee.EventEmitter):
|
|||||||
def update(self, advertisement: bumble.device.Advertisement) -> None:
|
def update(self, advertisement: bumble.device.Advertisement) -> None:
|
||||||
self.rssi = advertisement.rssi
|
self.rssi = advertisement.rssi
|
||||||
for service_data in advertisement.data.get_all(
|
for service_data in advertisement.data.get_all(
|
||||||
bumble.core.AdvertisingData.SERVICE_DATA
|
core.AdvertisingData.SERVICE_DATA
|
||||||
):
|
):
|
||||||
assert isinstance(service_data, tuple)
|
assert isinstance(service_data, tuple)
|
||||||
service_uuid, data = service_data
|
service_uuid, data = service_data
|
||||||
assert isinstance(data, bytes)
|
assert isinstance(data, bytes)
|
||||||
|
|
||||||
if (
|
if service_uuid == gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE:
|
||||||
service_uuid
|
|
||||||
== bumble.gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE
|
|
||||||
):
|
|
||||||
self.public_broadcast_announcement = (
|
self.public_broadcast_announcement = (
|
||||||
bumble.profiles.pbp.PublicBroadcastAnnouncement.from_bytes(data)
|
pbp.PublicBroadcastAnnouncement.from_bytes(data)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if (
|
if service_uuid == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE:
|
||||||
service_uuid
|
|
||||||
== bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
|
|
||||||
):
|
|
||||||
self.broadcast_audio_announcement = (
|
self.broadcast_audio_announcement = (
|
||||||
bumble.profiles.bap.BroadcastAudioAnnouncement.from_bytes(data)
|
bap.BroadcastAudioAnnouncement.from_bytes(data)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.appearance = advertisement.data.get( # type: ignore[assignment]
|
self.appearance = advertisement.data.get( # type: ignore[assignment]
|
||||||
bumble.core.AdvertisingData.APPEARANCE
|
core.AdvertisingData.APPEARANCE
|
||||||
)
|
)
|
||||||
|
|
||||||
if manufacturer_data := advertisement.data.get(
|
if manufacturer_data := advertisement.data.get(
|
||||||
bumble.core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
|
core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
|
||||||
):
|
):
|
||||||
assert isinstance(manufacturer_data, tuple)
|
assert isinstance(manufacturer_data, tuple)
|
||||||
company_id = cast(int, manufacturer_data[0])
|
company_id = cast(int, manufacturer_data[0])
|
||||||
data = cast(bytes, manufacturer_data[1])
|
data = cast(bytes, manufacturer_data[1])
|
||||||
self.manufacturer_data = (
|
self.manufacturer_data = (
|
||||||
bumble.company_ids.COMPANY_IDENTIFIERS.get(
|
company_ids.COMPANY_IDENTIFIERS.get(
|
||||||
company_id, f'0x{company_id:04X}'
|
company_id, f'0x{company_id:04X}'
|
||||||
),
|
),
|
||||||
data,
|
data,
|
||||||
@@ -135,7 +134,8 @@ class BroadcastScanner(pyee.EventEmitter):
|
|||||||
self.sync.advertiser_address,
|
self.sync.advertiser_address,
|
||||||
color(self.sync.state.name, 'green'),
|
color(self.sync.state.name, 'green'),
|
||||||
)
|
)
|
||||||
print(f' {color("Name", "cyan")}: {self.name}')
|
if self.name is not None:
|
||||||
|
print(f' {color("Name", "cyan")}: {self.name}')
|
||||||
if self.appearance:
|
if self.appearance:
|
||||||
print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
|
print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
|
||||||
print(f' {color("RSSI", "cyan")}: {self.rssi}')
|
print(f' {color("RSSI", "cyan")}: {self.rssi}')
|
||||||
@@ -174,7 +174,7 @@ class BroadcastScanner(pyee.EventEmitter):
|
|||||||
print(color(' Codec ID:', 'yellow'))
|
print(color(' Codec ID:', 'yellow'))
|
||||||
print(
|
print(
|
||||||
color(' Coding Format: ', 'green'),
|
color(' Coding Format: ', 'green'),
|
||||||
subgroup.codec_id.coding_format.name,
|
subgroup.codec_id.codec_id.name,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
color(' Company ID: ', 'green'),
|
color(' Company ID: ', 'green'),
|
||||||
@@ -231,15 +231,15 @@ class BroadcastScanner(pyee.EventEmitter):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for service_data in advertisement.data.get_all(
|
for service_data in advertisement.data.get_all(
|
||||||
bumble.core.AdvertisingData.SERVICE_DATA
|
core.AdvertisingData.SERVICE_DATA
|
||||||
):
|
):
|
||||||
assert isinstance(service_data, tuple)
|
assert isinstance(service_data, tuple)
|
||||||
service_uuid, data = service_data
|
service_uuid, data = service_data
|
||||||
assert isinstance(data, bytes)
|
assert isinstance(data, bytes)
|
||||||
|
|
||||||
if service_uuid == bumble.gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
|
if service_uuid == gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
|
||||||
self.basic_audio_announcement = (
|
self.basic_audio_announcement = (
|
||||||
bumble.profiles.bap.BasicAudioAnnouncement.from_bytes(data)
|
bap.BasicAudioAnnouncement.from_bytes(data)
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ class BroadcastScanner(pyee.EventEmitter):
|
|||||||
self.device = device
|
self.device = device
|
||||||
self.filter_duplicates = filter_duplicates
|
self.filter_duplicates = filter_duplicates
|
||||||
self.sync_timeout = sync_timeout
|
self.sync_timeout = sync_timeout
|
||||||
self.broadcasts: Dict[bumble.hci.Address, BroadcastScanner.Broadcast] = {}
|
self.broadcasts = dict[hci.Address, BroadcastScanner.Broadcast]()
|
||||||
device.on('advertisement', self.on_advertisement)
|
device.on('advertisement', self.on_advertisement)
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -274,24 +274,46 @@ class BroadcastScanner(pyee.EventEmitter):
|
|||||||
await self.device.stop_scanning()
|
await self.device.stop_scanning()
|
||||||
|
|
||||||
def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
|
def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
|
||||||
if (
|
if not (
|
||||||
broadcast_name := advertisement.data.get(
|
ads := advertisement.data.get_all(
|
||||||
bumble.core.AdvertisingData.BROADCAST_NAME
|
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID
|
||||||
)
|
)
|
||||||
) is None:
|
) or not (
|
||||||
|
broadcast_audio_announcement := next(
|
||||||
|
(
|
||||||
|
ad
|
||||||
|
for ad in ads
|
||||||
|
if isinstance(ad, tuple)
|
||||||
|
and ad[0] == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
):
|
||||||
return
|
return
|
||||||
assert isinstance(broadcast_name, str)
|
|
||||||
|
broadcast_name = advertisement.data.get(core.AdvertisingData.BROADCAST_NAME)
|
||||||
|
assert isinstance(broadcast_name, str) or broadcast_name is None
|
||||||
|
assert isinstance(broadcast_audio_announcement[1], bytes)
|
||||||
|
|
||||||
if broadcast := self.broadcasts.get(advertisement.address):
|
if broadcast := self.broadcasts.get(advertisement.address):
|
||||||
broadcast.update(advertisement)
|
broadcast.update(advertisement)
|
||||||
return
|
return
|
||||||
|
|
||||||
bumble.utils.AsyncRunner.spawn(
|
bumble.utils.AsyncRunner.spawn(
|
||||||
self.on_new_broadcast(broadcast_name, advertisement)
|
self.on_new_broadcast(
|
||||||
|
broadcast_name,
|
||||||
|
advertisement,
|
||||||
|
bap.BroadcastAudioAnnouncement.from_bytes(
|
||||||
|
broadcast_audio_announcement[1]
|
||||||
|
).broadcast_id,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def on_new_broadcast(
|
async def on_new_broadcast(
|
||||||
self, name: str, advertisement: bumble.device.Advertisement
|
self,
|
||||||
|
name: str | None,
|
||||||
|
advertisement: bumble.device.Advertisement,
|
||||||
|
broadcast_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
|
periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
|
||||||
advertiser_address=advertisement.address,
|
advertiser_address=advertisement.address,
|
||||||
@@ -299,10 +321,7 @@ class BroadcastScanner(pyee.EventEmitter):
|
|||||||
sync_timeout=self.sync_timeout,
|
sync_timeout=self.sync_timeout,
|
||||||
filter_duplicates=self.filter_duplicates,
|
filter_duplicates=self.filter_duplicates,
|
||||||
)
|
)
|
||||||
broadcast = self.Broadcast(
|
broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
|
||||||
name,
|
|
||||||
periodic_advertising_sync,
|
|
||||||
)
|
|
||||||
broadcast.update(advertisement)
|
broadcast.update(advertisement)
|
||||||
self.broadcasts[advertisement.address] = broadcast
|
self.broadcasts[advertisement.address] = broadcast
|
||||||
periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
|
periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
|
||||||
@@ -314,10 +333,11 @@ class BroadcastScanner(pyee.EventEmitter):
|
|||||||
self.emit('broadcast_loss', broadcast)
|
self.emit('broadcast_loss', broadcast)
|
||||||
|
|
||||||
|
|
||||||
class PrintingBroadcastScanner:
|
class PrintingBroadcastScanner(pyee.EventEmitter):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
|
self, device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
|
||||||
) -> None:
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
self.scanner = BroadcastScanner(device, filter_duplicates, sync_timeout)
|
self.scanner = BroadcastScanner(device, filter_duplicates, sync_timeout)
|
||||||
self.scanner.on('new_broadcast', self.on_new_broadcast)
|
self.scanner.on('new_broadcast', self.on_new_broadcast)
|
||||||
self.scanner.on('broadcast_loss', self.on_broadcast_loss)
|
self.scanner.on('broadcast_loss', self.on_broadcast_loss)
|
||||||
@@ -452,24 +472,26 @@ async def run_assist(
|
|||||||
await peer.request_mtu(mtu)
|
await peer.request_mtu(mtu)
|
||||||
|
|
||||||
# Get the BASS service
|
# Get the BASS service
|
||||||
bass = await peer.discover_service_and_create_proxy(
|
bass_client = await peer.discover_service_and_create_proxy(
|
||||||
bumble.profiles.bass.BroadcastAudioScanServiceProxy
|
bass.BroadcastAudioScanServiceProxy
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check that the service was found
|
# Check that the service was found
|
||||||
if not bass:
|
if not bass_client:
|
||||||
print(color('!!! Broadcast Audio Scan Service not found', 'red'))
|
print(color('!!! Broadcast Audio Scan Service not found', 'red'))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Subscribe to and read the broadcast receive state characteristics
|
# Subscribe to and read the broadcast receive state characteristics
|
||||||
for i, broadcast_receive_state in enumerate(bass.broadcast_receive_states):
|
for i, broadcast_receive_state in enumerate(
|
||||||
|
bass_client.broadcast_receive_states
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
await broadcast_receive_state.subscribe(
|
await broadcast_receive_state.subscribe(
|
||||||
lambda value, i=i: print(
|
lambda value, i=i: print(
|
||||||
f"{color(f'Broadcast Receive State Update [{i}]:', 'green')} {value}"
|
f"{color(f'Broadcast Receive State Update [{i}]:', 'green')} {value}"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except bumble.core.ProtocolError as error:
|
except core.ProtocolError as error:
|
||||||
print(
|
print(
|
||||||
color(
|
color(
|
||||||
f'!!! Failed to subscribe to Broadcast Receive State characteristic:',
|
f'!!! Failed to subscribe to Broadcast Receive State characteristic:',
|
||||||
@@ -488,7 +510,7 @@ async def run_assist(
|
|||||||
|
|
||||||
if command == 'add-source':
|
if command == 'add-source':
|
||||||
# Find the requested broadcast
|
# Find the requested broadcast
|
||||||
await bass.remote_scan_started()
|
await bass_client.remote_scan_started()
|
||||||
if broadcast_name:
|
if broadcast_name:
|
||||||
print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
|
print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
|
||||||
else:
|
else:
|
||||||
@@ -508,15 +530,15 @@ async def run_assist(
|
|||||||
|
|
||||||
# Add the source
|
# Add the source
|
||||||
print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
|
print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
|
||||||
await bass.add_source(
|
await bass_client.add_source(
|
||||||
broadcast.sync.advertiser_address,
|
broadcast.sync.advertiser_address,
|
||||||
broadcast.sync.sid,
|
broadcast.sync.sid,
|
||||||
broadcast.broadcast_audio_announcement.broadcast_id,
|
broadcast.broadcast_audio_announcement.broadcast_id,
|
||||||
bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
|
bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
|
||||||
0xFFFF,
|
0xFFFF,
|
||||||
[
|
[
|
||||||
bumble.profiles.bass.SubgroupInfo(
|
bass.SubgroupInfo(
|
||||||
bumble.profiles.bass.SubgroupInfo.ANY_BIS,
|
bass.SubgroupInfo.ANY_BIS,
|
||||||
bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
|
bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
@@ -526,7 +548,7 @@ async def run_assist(
|
|||||||
await broadcast.sync.transfer(peer.connection)
|
await broadcast.sync.transfer(peer.connection)
|
||||||
|
|
||||||
# Notify the sink that we're done scanning.
|
# Notify the sink that we're done scanning.
|
||||||
await bass.remote_scan_stopped()
|
await bass_client.remote_scan_stopped()
|
||||||
|
|
||||||
await peer.sustain()
|
await peer.sustain()
|
||||||
return
|
return
|
||||||
@@ -537,7 +559,7 @@ async def run_assist(
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Find the requested broadcast
|
# Find the requested broadcast
|
||||||
await bass.remote_scan_started()
|
await bass_client.remote_scan_started()
|
||||||
if broadcast_name:
|
if broadcast_name:
|
||||||
print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
|
print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
|
||||||
else:
|
else:
|
||||||
@@ -560,13 +582,13 @@ async def run_assist(
|
|||||||
color('Modifying source:', 'blue'),
|
color('Modifying source:', 'blue'),
|
||||||
source_id,
|
source_id,
|
||||||
)
|
)
|
||||||
await bass.modify_source(
|
await bass_client.modify_source(
|
||||||
source_id,
|
source_id,
|
||||||
bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
|
bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
|
||||||
0xFFFF,
|
0xFFFF,
|
||||||
[
|
[
|
||||||
bumble.profiles.bass.SubgroupInfo(
|
bass.SubgroupInfo(
|
||||||
bumble.profiles.bass.SubgroupInfo.ANY_BIS,
|
bass.SubgroupInfo.ANY_BIS,
|
||||||
bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
|
bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
@@ -581,7 +603,7 @@ async def run_assist(
|
|||||||
|
|
||||||
# Remove the source
|
# Remove the source
|
||||||
print(color('Removing source:', 'blue'), source_id)
|
print(color('Removing source:', 'blue'), source_id)
|
||||||
await bass.remove_source(source_id)
|
await bass_client.remove_source(source_id)
|
||||||
await peer.sustain()
|
await peer.sustain()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -601,14 +623,242 @@ async def run_pair(transport: str, address: str) -> None:
|
|||||||
print("+++ Paired")
|
print("+++ Paired")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_receive(
|
||||||
|
transport: str,
|
||||||
|
broadcast_id: int,
|
||||||
|
broadcast_code: str | None,
|
||||||
|
sync_timeout: float,
|
||||||
|
subgroup_index: int,
|
||||||
|
) -> None:
|
||||||
|
async with create_device(transport) as device:
|
||||||
|
if not device.supports_le_periodic_advertising:
|
||||||
|
print(color('Periodic advertising not supported', 'red'))
|
||||||
|
return
|
||||||
|
|
||||||
|
scanner = BroadcastScanner(device, False, sync_timeout)
|
||||||
|
scan_result: asyncio.Future[BroadcastScanner.Broadcast] = (
|
||||||
|
asyncio.get_running_loop().create_future()
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
|
||||||
|
if scan_result.done():
|
||||||
|
return
|
||||||
|
if broadcast.broadcast_id == broadcast_id:
|
||||||
|
scan_result.set_result(broadcast)
|
||||||
|
|
||||||
|
scanner.on('new_broadcast', on_new_broadcast)
|
||||||
|
await scanner.start()
|
||||||
|
print('Start scanning...')
|
||||||
|
broadcast = await scan_result
|
||||||
|
print('Advertisement found:')
|
||||||
|
broadcast.print()
|
||||||
|
basic_audio_announcement_scanned = asyncio.Event()
|
||||||
|
|
||||||
|
def on_change() -> None:
|
||||||
|
if (
|
||||||
|
broadcast.basic_audio_announcement
|
||||||
|
and not basic_audio_announcement_scanned.is_set()
|
||||||
|
):
|
||||||
|
basic_audio_announcement_scanned.set()
|
||||||
|
|
||||||
|
broadcast.on('change', on_change)
|
||||||
|
if not broadcast.basic_audio_announcement:
|
||||||
|
print('Wait for Basic Audio Announcement...')
|
||||||
|
await basic_audio_announcement_scanned.wait()
|
||||||
|
print('Basic Audio Announcement found')
|
||||||
|
broadcast.print()
|
||||||
|
print('Stop scanning')
|
||||||
|
await scanner.stop()
|
||||||
|
print('Start sync to BIG')
|
||||||
|
|
||||||
|
assert broadcast.basic_audio_announcement
|
||||||
|
subgroup = broadcast.basic_audio_announcement.subgroups[subgroup_index]
|
||||||
|
configuration = subgroup.codec_specific_configuration
|
||||||
|
assert configuration
|
||||||
|
assert (sampling_frequency := configuration.sampling_frequency)
|
||||||
|
assert (frame_duration := configuration.frame_duration)
|
||||||
|
|
||||||
|
big_sync = await device.create_big_sync(
|
||||||
|
broadcast.sync,
|
||||||
|
bumble.device.BigSyncParameters(
|
||||||
|
big_sync_timeout=0x4000,
|
||||||
|
bis=[bis.index for bis in subgroup.bis],
|
||||||
|
broadcast_code=(
|
||||||
|
bytes.fromhex(broadcast_code) if broadcast_code else None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
num_bis = len(big_sync.bis_links)
|
||||||
|
decoder = lc3.Decoder(
|
||||||
|
frame_duration_us=frame_duration.us,
|
||||||
|
sample_rate_hz=sampling_frequency.hz,
|
||||||
|
num_channels=num_bis,
|
||||||
|
)
|
||||||
|
sdus = [b''] * num_bis
|
||||||
|
subprocess = await asyncio.create_subprocess_shell(
|
||||||
|
f'stdbuf -i0 ffplay -ar {sampling_frequency.hz} -ac {num_bis} -f f32le pipe:0',
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
for i, bis_link in enumerate(big_sync.bis_links):
|
||||||
|
print(f'Setup ISO for BIS {bis_link.handle}')
|
||||||
|
|
||||||
|
def sink(index: int, packet: hci.HCI_IsoDataPacket):
|
||||||
|
nonlocal sdus
|
||||||
|
sdus[index] = packet.iso_sdu_fragment
|
||||||
|
if all(sdus) and subprocess.stdin:
|
||||||
|
subprocess.stdin.write(decoder.decode(b''.join(sdus)).tobytes())
|
||||||
|
sdus = [b''] * num_bis
|
||||||
|
|
||||||
|
bis_link.sink = functools.partial(sink, i)
|
||||||
|
await bis_link.setup_data_path(
|
||||||
|
direction=bis_link.Direction.CONTROLLER_TO_HOST
|
||||||
|
)
|
||||||
|
|
||||||
|
terminated = asyncio.Event()
|
||||||
|
big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
|
||||||
|
await terminated.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_broadcast(
|
||||||
|
transport: str, broadcast_id: int, broadcast_code: str | None, wav_file_path: str
|
||||||
|
) -> None:
|
||||||
|
async with create_device(transport) as device:
|
||||||
|
if not device.supports_le_periodic_advertising:
|
||||||
|
print(color('Periodic advertising not supported', 'red'))
|
||||||
|
return
|
||||||
|
|
||||||
|
with wave.open(wav_file_path, 'rb') as wav:
|
||||||
|
print('Encoding wav file into lc3...')
|
||||||
|
encoder = lc3.Encoder(
|
||||||
|
frame_duration_us=10000,
|
||||||
|
sample_rate_hz=48000,
|
||||||
|
num_channels=2,
|
||||||
|
input_sample_rate_hz=wav.getframerate(),
|
||||||
|
)
|
||||||
|
frames = list[bytes]()
|
||||||
|
while pcm := wav.readframes(encoder.get_frame_samples()):
|
||||||
|
frames.append(
|
||||||
|
encoder.encode(pcm, num_bytes=200, bit_depth=wav.getsampwidth() * 8)
|
||||||
|
)
|
||||||
|
del encoder
|
||||||
|
print('Encoding complete.')
|
||||||
|
|
||||||
|
basic_audio_announcement = bap.BasicAudioAnnouncement(
|
||||||
|
presentation_delay=40000,
|
||||||
|
subgroups=[
|
||||||
|
bap.BasicAudioAnnouncement.Subgroup(
|
||||||
|
codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
|
||||||
|
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
||||||
|
sampling_frequency=bap.SamplingFrequency.FREQ_48000,
|
||||||
|
frame_duration=bap.FrameDuration.DURATION_10000_US,
|
||||||
|
octets_per_codec_frame=100,
|
||||||
|
),
|
||||||
|
metadata=le_audio.Metadata(
|
||||||
|
[
|
||||||
|
le_audio.Metadata.Entry(
|
||||||
|
tag=le_audio.Metadata.Tag.LANGUAGE, data=b'eng'
|
||||||
|
),
|
||||||
|
le_audio.Metadata.Entry(
|
||||||
|
tag=le_audio.Metadata.Tag.PROGRAM_INFO, data=b'Disco'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
bis=[
|
||||||
|
bap.BasicAudioAnnouncement.BIS(
|
||||||
|
index=1,
|
||||||
|
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
||||||
|
audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bap.BasicAudioAnnouncement.BIS(
|
||||||
|
index=2,
|
||||||
|
codec_specific_configuration=bap.CodecSpecificConfiguration(
|
||||||
|
audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(broadcast_id)
|
||||||
|
print('Start Advertising')
|
||||||
|
advertising_set = await device.create_advertising_set(
|
||||||
|
advertising_parameters=bumble.device.AdvertisingParameters(
|
||||||
|
advertising_event_properties=bumble.device.AdvertisingEventProperties(
|
||||||
|
is_connectable=False
|
||||||
|
),
|
||||||
|
primary_advertising_interval_min=100,
|
||||||
|
primary_advertising_interval_max=200,
|
||||||
|
),
|
||||||
|
advertising_data=(
|
||||||
|
broadcast_audio_announcement.get_advertising_data()
|
||||||
|
+ bytes(
|
||||||
|
core.AdvertisingData(
|
||||||
|
[(core.AdvertisingData.BROADCAST_NAME, b'Bumble Auracast')]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
|
||||||
|
periodic_advertising_interval_min=80,
|
||||||
|
periodic_advertising_interval_max=160,
|
||||||
|
),
|
||||||
|
periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
|
||||||
|
auto_restart=True,
|
||||||
|
auto_start=True,
|
||||||
|
)
|
||||||
|
print('Start Periodic Advertising')
|
||||||
|
await advertising_set.start_periodic()
|
||||||
|
print('Setup BIG')
|
||||||
|
big = await device.create_big(
|
||||||
|
advertising_set,
|
||||||
|
parameters=bumble.device.BigParameters(
|
||||||
|
num_bis=2,
|
||||||
|
sdu_interval=10000,
|
||||||
|
max_sdu=100,
|
||||||
|
max_transport_latency=65,
|
||||||
|
rtn=4,
|
||||||
|
broadcast_code=(
|
||||||
|
bytes.fromhex(broadcast_code) if broadcast_code else None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print('Setup ISO Data Path')
|
||||||
|
|
||||||
|
def on_flow(packet_queue):
|
||||||
|
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
|
||||||
|
big.bis_links[0].write(frame[:mid])
|
||||||
|
big.bis_links[1].write(frame[mid:])
|
||||||
|
await asyncio.sleep(0.009)
|
||||||
|
|
||||||
|
|
||||||
def run_async(async_command: Coroutine) -> None:
|
def run_async(async_command: Coroutine) -> None:
|
||||||
try:
|
try:
|
||||||
asyncio.run(async_command)
|
asyncio.run(async_command)
|
||||||
except bumble.core.ProtocolError as error:
|
except core.ProtocolError as error:
|
||||||
if error.error_namespace == 'att' and error.error_code in list(
|
if error.error_namespace == 'att' and error.error_code in list(
|
||||||
bumble.profiles.bass.ApplicationError
|
bass.ApplicationError
|
||||||
):
|
):
|
||||||
message = bumble.profiles.bass.ApplicationError(error.error_code).name
|
message = bass.ApplicationError(error.error_code).name
|
||||||
else:
|
else:
|
||||||
message = str(error)
|
message = str(error)
|
||||||
|
|
||||||
@@ -622,9 +872,7 @@ def run_async(async_command: Coroutine) -> None:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@click.group()
|
@click.group()
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def auracast(
|
def auracast(ctx):
|
||||||
ctx,
|
|
||||||
):
|
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -682,6 +930,66 @@ def pair(ctx, transport, address):
|
|||||||
run_async(run_pair(transport, address))
|
run_async(run_pair(transport, address))
|
||||||
|
|
||||||
|
|
||||||
|
@auracast.command('receive')
|
||||||
|
@click.argument('transport')
|
||||||
|
@click.argument('broadcast_id', type=int)
|
||||||
|
@click.option(
|
||||||
|
'--broadcast-code',
|
||||||
|
metavar='BROADCAST_CODE',
|
||||||
|
type=str,
|
||||||
|
help='Broadcast encryption code in hex format',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--sync-timeout',
|
||||||
|
metavar='SYNC_TIMEOUT',
|
||||||
|
type=float,
|
||||||
|
default=AURACAST_DEFAULT_SYNC_TIMEOUT,
|
||||||
|
help='Sync timeout (in seconds)',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--subgroup',
|
||||||
|
metavar='SUBGROUP',
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help='Index of Subgroup',
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def receive(ctx, transport, broadcast_id, broadcast_code, sync_timeout, subgroup):
|
||||||
|
"""Receive a broadcast source"""
|
||||||
|
run_async(
|
||||||
|
run_receive(transport, broadcast_id, broadcast_code, sync_timeout, subgroup)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@auracast.command('broadcast')
|
||||||
|
@click.argument('transport')
|
||||||
|
@click.argument('wav_file_path', type=str)
|
||||||
|
@click.option(
|
||||||
|
'--broadcast-id',
|
||||||
|
metavar='BROADCAST_ID',
|
||||||
|
type=int,
|
||||||
|
default=123456,
|
||||||
|
help='Broadcast ID',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--broadcast-code',
|
||||||
|
metavar='BROADCAST_CODE',
|
||||||
|
type=str,
|
||||||
|
help='Broadcast encryption code in hex format',
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def broadcast(ctx, transport, broadcast_id, broadcast_code, wav_file_path):
|
||||||
|
"""Start a broadcast as a source."""
|
||||||
|
run_async(
|
||||||
|
run_broadcast(
|
||||||
|
transport=transport,
|
||||||
|
broadcast_id=broadcast_id,
|
||||||
|
broadcast_code=broadcast_code,
|
||||||
|
wav_file_path=wav_file_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||||
auracast()
|
auracast()
|
||||||
|
|||||||
+139
-93
@@ -19,6 +19,7 @@ import asyncio
|
|||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import statistics
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -194,17 +195,19 @@ def make_sdp_records(channel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def log_stats(title, stats):
|
def log_stats(title, stats, precision=2):
|
||||||
stats_min = min(stats)
|
stats_min = min(stats)
|
||||||
stats_max = max(stats)
|
stats_max = max(stats)
|
||||||
stats_avg = sum(stats) / len(stats)
|
stats_avg = statistics.mean(stats)
|
||||||
|
stats_stdev = statistics.stdev(stats) if len(stats) >= 2 else 0
|
||||||
logging.info(
|
logging.info(
|
||||||
color(
|
color(
|
||||||
(
|
(
|
||||||
f'### {title} stats: '
|
f'### {title} stats: '
|
||||||
f'min={stats_min:.2f}, '
|
f'min={stats_min:.{precision}f}, '
|
||||||
f'max={stats_max:.2f}, '
|
f'max={stats_max:.{precision}f}, '
|
||||||
f'average={stats_avg:.2f}'
|
f'average={stats_avg:.{precision}f}, '
|
||||||
|
f'stdev={stats_stdev:.{precision}f}'
|
||||||
),
|
),
|
||||||
'cyan',
|
'cyan',
|
||||||
)
|
)
|
||||||
@@ -448,9 +451,9 @@ class Ping:
|
|||||||
self.repeat_delay = repeat_delay
|
self.repeat_delay = repeat_delay
|
||||||
self.pace = pace
|
self.pace = pace
|
||||||
self.done = asyncio.Event()
|
self.done = asyncio.Event()
|
||||||
self.current_packet_index = 0
|
self.ping_times = []
|
||||||
self.ping_sent_time = 0.0
|
self.rtts = []
|
||||||
self.latencies = []
|
self.next_expected_packet_index = 0
|
||||||
self.min_stats = []
|
self.min_stats = []
|
||||||
self.max_stats = []
|
self.max_stats = []
|
||||||
self.avg_stats = []
|
self.avg_stats = []
|
||||||
@@ -465,6 +468,7 @@ class Ping:
|
|||||||
|
|
||||||
for run in range(self.repeat + 1):
|
for run in range(self.repeat + 1):
|
||||||
self.done.clear()
|
self.done.clear()
|
||||||
|
self.ping_times = []
|
||||||
|
|
||||||
if run > 0 and self.repeat and self.repeat_delay:
|
if run > 0 and self.repeat and self.repeat_delay:
|
||||||
logging.info(color(f'*** Repeat delay: {self.repeat_delay}', 'green'))
|
logging.info(color(f'*** Repeat delay: {self.repeat_delay}', 'green'))
|
||||||
@@ -477,60 +481,57 @@ class Ping:
|
|||||||
logging.info(color('=== Sending RESET', 'magenta'))
|
logging.info(color('=== Sending RESET', 'magenta'))
|
||||||
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
||||||
|
|
||||||
self.current_packet_index = 0
|
packet_interval = self.pace / 1000
|
||||||
self.latencies = []
|
start_time = time.time()
|
||||||
await self.send_next_ping()
|
self.next_expected_packet_index = 0
|
||||||
|
for i in range(self.tx_packet_count):
|
||||||
|
target_time = start_time + (i * packet_interval)
|
||||||
|
now = time.time()
|
||||||
|
if now < target_time:
|
||||||
|
await asyncio.sleep(target_time - now)
|
||||||
|
|
||||||
|
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)
|
||||||
|
logging.info(color(f'Sending packet {i}', 'yellow'))
|
||||||
|
self.ping_times.append(time.time())
|
||||||
|
await self.packet_io.send_packet(packet)
|
||||||
|
|
||||||
await self.done.wait()
|
await self.done.wait()
|
||||||
|
|
||||||
min_latency = min(self.latencies)
|
min_rtt = min(self.rtts)
|
||||||
max_latency = max(self.latencies)
|
max_rtt = max(self.rtts)
|
||||||
avg_latency = sum(self.latencies) / len(self.latencies)
|
avg_rtt = statistics.mean(self.rtts)
|
||||||
|
stdev_rtt = statistics.stdev(self.rtts)
|
||||||
logging.info(
|
logging.info(
|
||||||
color(
|
color(
|
||||||
'@@@ Latencies: '
|
'@@@ RTTs: '
|
||||||
f'min={min_latency:.2f}, '
|
f'min={min_rtt:.2f}, '
|
||||||
f'max={max_latency:.2f}, '
|
f'max={max_rtt:.2f}, '
|
||||||
f'average={avg_latency:.2f}'
|
f'average={avg_rtt:.2f}, '
|
||||||
|
f'stdev={stdev_rtt:.2f}'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
self.min_stats.append(min_latency)
|
self.min_stats.append(min_rtt)
|
||||||
self.max_stats.append(max_latency)
|
self.max_stats.append(max_rtt)
|
||||||
self.avg_stats.append(avg_latency)
|
self.avg_stats.append(avg_rtt)
|
||||||
|
|
||||||
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
|
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
|
||||||
logging.info(color(f'=== {run_counter} Done!', 'magenta'))
|
logging.info(color(f'=== {run_counter} Done!', 'magenta'))
|
||||||
|
|
||||||
if self.repeat:
|
if self.repeat:
|
||||||
log_stats('Min Latency', self.min_stats)
|
log_stats('Min RTT', self.min_stats)
|
||||||
log_stats('Max Latency', self.max_stats)
|
log_stats('Max RTT', self.max_stats)
|
||||||
log_stats('Average Latency', self.avg_stats)
|
log_stats('Average RTT', self.avg_stats)
|
||||||
|
|
||||||
if self.repeat:
|
if self.repeat:
|
||||||
logging.info(color('--- End of runs', 'blue'))
|
logging.info(color('--- End of runs', 'blue'))
|
||||||
|
|
||||||
async def send_next_ping(self):
|
|
||||||
if self.pace:
|
|
||||||
await asyncio.sleep(self.pace / 1000)
|
|
||||||
|
|
||||||
packet = struct.pack(
|
|
||||||
'>bbI',
|
|
||||||
PacketType.SEQUENCE,
|
|
||||||
(
|
|
||||||
PACKET_FLAG_LAST
|
|
||||||
if self.current_packet_index == self.tx_packet_count - 1
|
|
||||||
else 0
|
|
||||||
),
|
|
||||||
self.current_packet_index,
|
|
||||||
) + bytes(self.tx_packet_size - 6)
|
|
||||||
logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow'))
|
|
||||||
self.ping_sent_time = time.time()
|
|
||||||
await self.packet_io.send_packet(packet)
|
|
||||||
|
|
||||||
def on_packet_received(self, packet):
|
def on_packet_received(self, packet):
|
||||||
elapsed = time.time() - self.ping_sent_time
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
packet_type, packet_data = parse_packet(packet)
|
packet_type, packet_data = parse_packet(packet)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -542,21 +543,23 @@ class Ping:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if packet_type == PacketType.ACK:
|
if packet_type == PacketType.ACK:
|
||||||
latency = elapsed * 1000
|
elapsed = time.time() - self.ping_times[packet_index]
|
||||||
self.latencies.append(latency)
|
rtt = elapsed * 1000
|
||||||
|
self.rtts.append(rtt)
|
||||||
logging.info(
|
logging.info(
|
||||||
color(
|
color(
|
||||||
f'<<< Received ACK [{packet_index}], latency={latency:.2f}ms',
|
f'<<< Received ACK [{packet_index}], RTT={rtt:.2f}ms',
|
||||||
'green',
|
'green',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if packet_index == self.current_packet_index:
|
if packet_index == self.next_expected_packet_index:
|
||||||
self.current_packet_index += 1
|
self.next_expected_packet_index += 1
|
||||||
else:
|
else:
|
||||||
logging.info(
|
logging.info(
|
||||||
color(
|
color(
|
||||||
f'!!! Unexpected packet, expected {self.current_packet_index} '
|
f'!!! Unexpected packet, '
|
||||||
|
f'expected {self.next_expected_packet_index} '
|
||||||
f'but received {packet_index}'
|
f'but received {packet_index}'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -565,8 +568,6 @@ class Ping:
|
|||||||
self.done.set()
|
self.done.set()
|
||||||
return
|
return
|
||||||
|
|
||||||
AsyncRunner.spawn(self.send_next_ping())
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Pong
|
# Pong
|
||||||
@@ -583,8 +584,11 @@ class Pong:
|
|||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
self.expected_packet_index = 0
|
self.expected_packet_index = 0
|
||||||
|
self.receive_times = []
|
||||||
|
|
||||||
def on_packet_received(self, packet):
|
def on_packet_received(self, packet):
|
||||||
|
self.receive_times.append(time.time())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
packet_type, packet_data = parse_packet(packet)
|
packet_type, packet_data = parse_packet(packet)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -599,10 +603,16 @@ class Pong:
|
|||||||
packet_flags, packet_index = parse_packet_sequence(packet_data)
|
packet_flags, packet_index = parse_packet_sequence(packet_data)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
|
interval = (
|
||||||
|
self.receive_times[-1] - self.receive_times[-2]
|
||||||
|
if len(self.receive_times) >= 2
|
||||||
|
else 0
|
||||||
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
color(
|
color(
|
||||||
f'<<< Received packet {packet_index}: '
|
f'<<< Received packet {packet_index}: '
|
||||||
f'flags=0x{packet_flags:02X}, {len(packet)} bytes',
|
f'flags=0x{packet_flags:02X}, {len(packet)} bytes, '
|
||||||
|
f'interval={interval:.4f}',
|
||||||
'green',
|
'green',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -623,8 +633,35 @@ class Pong:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if packet_flags & PACKET_FLAG_LAST and not self.linger:
|
if packet_flags & PACKET_FLAG_LAST:
|
||||||
self.done.set()
|
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 not self.linger:
|
||||||
|
self.done.set()
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
await self.done.wait()
|
await self.done.wait()
|
||||||
@@ -942,9 +979,12 @@ class RfcommClient(StreamedPacketIO):
|
|||||||
channel = await bumble.rfcomm.find_rfcomm_channel_with_uuid(
|
channel = await bumble.rfcomm.find_rfcomm_channel_with_uuid(
|
||||||
connection, self.uuid
|
connection, self.uuid
|
||||||
)
|
)
|
||||||
logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
|
if channel:
|
||||||
if channel == 0:
|
logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
|
||||||
logging.info(color('!!! No RFComm service with this UUID found', 'red'))
|
else:
|
||||||
|
logging.warning(
|
||||||
|
color('!!! No RFComm service with this UUID found', 'red')
|
||||||
|
)
|
||||||
await connection.disconnect()
|
await connection.disconnect()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1054,6 +1094,8 @@ class RfcommServer(StreamedPacketIO):
|
|||||||
if self.credits_threshold is not None:
|
if self.credits_threshold is not None:
|
||||||
dlc.rx_credits_threshold = self.credits_threshold
|
dlc.rx_credits_threshold = self.credits_threshold
|
||||||
|
|
||||||
|
self.ready.set()
|
||||||
|
|
||||||
async def drain(self):
|
async def drain(self):
|
||||||
assert self.dlc
|
assert self.dlc
|
||||||
await self.dlc.drain()
|
await self.dlc.drain()
|
||||||
@@ -1068,7 +1110,7 @@ class Central(Connection.Listener):
|
|||||||
transport,
|
transport,
|
||||||
peripheral_address,
|
peripheral_address,
|
||||||
classic,
|
classic,
|
||||||
role_factory,
|
scenario_factory,
|
||||||
mode_factory,
|
mode_factory,
|
||||||
connection_interval,
|
connection_interval,
|
||||||
phy,
|
phy,
|
||||||
@@ -1081,7 +1123,7 @@ class Central(Connection.Listener):
|
|||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.peripheral_address = peripheral_address
|
self.peripheral_address = peripheral_address
|
||||||
self.classic = classic
|
self.classic = classic
|
||||||
self.role_factory = role_factory
|
self.scenario_factory = scenario_factory
|
||||||
self.mode_factory = mode_factory
|
self.mode_factory = mode_factory
|
||||||
self.authenticate = authenticate
|
self.authenticate = authenticate
|
||||||
self.encrypt = encrypt or authenticate
|
self.encrypt = encrypt or authenticate
|
||||||
@@ -1134,7 +1176,7 @@ class Central(Connection.Listener):
|
|||||||
DEFAULT_CENTRAL_NAME, central_address, hci_source, hci_sink
|
DEFAULT_CENTRAL_NAME, central_address, hci_source, hci_sink
|
||||||
)
|
)
|
||||||
mode = self.mode_factory(self.device)
|
mode = self.mode_factory(self.device)
|
||||||
role = self.role_factory(mode)
|
scenario = self.scenario_factory(mode)
|
||||||
self.device.classic_enabled = self.classic
|
self.device.classic_enabled = self.classic
|
||||||
|
|
||||||
# Set up a pairing config factory with minimal requirements.
|
# Set up a pairing config factory with minimal requirements.
|
||||||
@@ -1215,7 +1257,7 @@ class Central(Connection.Listener):
|
|||||||
|
|
||||||
await mode.on_connection(self.connection)
|
await mode.on_connection(self.connection)
|
||||||
|
|
||||||
await role.run()
|
await scenario.run()
|
||||||
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
||||||
await self.connection.disconnect()
|
await self.connection.disconnect()
|
||||||
|
|
||||||
@@ -1246,7 +1288,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
transport,
|
transport,
|
||||||
role_factory,
|
scenario_factory,
|
||||||
mode_factory,
|
mode_factory,
|
||||||
classic,
|
classic,
|
||||||
extended_data_length,
|
extended_data_length,
|
||||||
@@ -1254,11 +1296,11 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
):
|
):
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.classic = classic
|
self.classic = classic
|
||||||
self.role_factory = role_factory
|
self.scenario_factory = scenario_factory
|
||||||
self.mode_factory = mode_factory
|
self.mode_factory = mode_factory
|
||||||
self.extended_data_length = extended_data_length
|
self.extended_data_length = extended_data_length
|
||||||
self.role_switch = role_switch
|
self.role_switch = role_switch
|
||||||
self.role = None
|
self.scenario = None
|
||||||
self.mode = None
|
self.mode = None
|
||||||
self.device = None
|
self.device = None
|
||||||
self.connection = None
|
self.connection = None
|
||||||
@@ -1278,7 +1320,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
)
|
)
|
||||||
self.device.listener = self
|
self.device.listener = self
|
||||||
self.mode = self.mode_factory(self.device)
|
self.mode = self.mode_factory(self.device)
|
||||||
self.role = self.role_factory(self.mode)
|
self.scenario = self.scenario_factory(self.mode)
|
||||||
self.device.classic_enabled = self.classic
|
self.device.classic_enabled = self.classic
|
||||||
|
|
||||||
# Set up a pairing config factory with minimal requirements.
|
# Set up a pairing config factory with minimal requirements.
|
||||||
@@ -1315,7 +1357,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
print_connection(self.connection)
|
print_connection(self.connection)
|
||||||
|
|
||||||
await self.mode.on_connection(self.connection)
|
await self.mode.on_connection(self.connection)
|
||||||
await self.role.run()
|
await self.scenario.run()
|
||||||
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
await asyncio.sleep(DEFAULT_LINGER_TIME)
|
||||||
|
|
||||||
def on_connection(self, connection):
|
def on_connection(self, connection):
|
||||||
@@ -1344,7 +1386,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|||||||
def on_disconnection(self, reason):
|
def on_disconnection(self, reason):
|
||||||
logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
|
logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
|
||||||
self.connection = None
|
self.connection = None
|
||||||
self.role.reset()
|
self.scenario.reset()
|
||||||
|
|
||||||
if self.classic:
|
if self.classic:
|
||||||
AsyncRunner.spawn(self.device.set_discoverable(True))
|
AsyncRunner.spawn(self.device.set_discoverable(True))
|
||||||
@@ -1426,13 +1468,13 @@ def create_mode_factory(ctx, default_mode):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def create_role_factory(ctx, default_role):
|
def create_scenario_factory(ctx, default_scenario):
|
||||||
role = ctx.obj['role']
|
scenario = ctx.obj['scenario']
|
||||||
if role is None:
|
if scenario is None:
|
||||||
role = default_role
|
scenarion = default_scenario
|
||||||
|
|
||||||
def create_role(packet_io):
|
def create_scenario(packet_io):
|
||||||
if role == 'sender':
|
if scenario == 'send':
|
||||||
return Sender(
|
return Sender(
|
||||||
packet_io,
|
packet_io,
|
||||||
start_delay=ctx.obj['start_delay'],
|
start_delay=ctx.obj['start_delay'],
|
||||||
@@ -1443,10 +1485,10 @@ def create_role_factory(ctx, default_role):
|
|||||||
packet_count=ctx.obj['packet_count'],
|
packet_count=ctx.obj['packet_count'],
|
||||||
)
|
)
|
||||||
|
|
||||||
if role == 'receiver':
|
if scenario == 'receive':
|
||||||
return Receiver(packet_io, ctx.obj['linger'])
|
return Receiver(packet_io, ctx.obj['linger'])
|
||||||
|
|
||||||
if role == 'ping':
|
if scenario == 'ping':
|
||||||
return Ping(
|
return Ping(
|
||||||
packet_io,
|
packet_io,
|
||||||
start_delay=ctx.obj['start_delay'],
|
start_delay=ctx.obj['start_delay'],
|
||||||
@@ -1457,12 +1499,12 @@ def create_role_factory(ctx, default_role):
|
|||||||
packet_count=ctx.obj['packet_count'],
|
packet_count=ctx.obj['packet_count'],
|
||||||
)
|
)
|
||||||
|
|
||||||
if role == 'pong':
|
if scenario == 'pong':
|
||||||
return Pong(packet_io, ctx.obj['linger'])
|
return Pong(packet_io, ctx.obj['linger'])
|
||||||
|
|
||||||
raise ValueError('invalid role')
|
raise ValueError('invalid scenario')
|
||||||
|
|
||||||
return create_role
|
return create_scenario
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -1470,7 +1512,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@click.group()
|
@click.group()
|
||||||
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
|
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
|
||||||
@click.option('--role', type=click.Choice(['sender', 'receiver', 'ping', 'pong']))
|
@click.option('--scenario', type=click.Choice(['send', 'receive', 'ping', 'pong']))
|
||||||
@click.option(
|
@click.option(
|
||||||
'--mode',
|
'--mode',
|
||||||
type=click.Choice(
|
type=click.Choice(
|
||||||
@@ -1503,7 +1545,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
'--rfcomm-channel',
|
'--rfcomm-channel',
|
||||||
type=int,
|
type=int,
|
||||||
default=DEFAULT_RFCOMM_CHANNEL,
|
default=DEFAULT_RFCOMM_CHANNEL,
|
||||||
help='RFComm channel to use',
|
help='RFComm channel to use (specify 0 for channel discovery via SDP)',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--rfcomm-uuid',
|
'--rfcomm-uuid',
|
||||||
@@ -1565,7 +1607,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
metavar='SIZE',
|
metavar='SIZE',
|
||||||
type=click.IntRange(8, 8192),
|
type=click.IntRange(8, 8192),
|
||||||
default=500,
|
default=500,
|
||||||
help='Packet size (client or ping role)',
|
help='Packet size (send or ping scenario)',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--packet-count',
|
'--packet-count',
|
||||||
@@ -1573,7 +1615,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
metavar='COUNT',
|
metavar='COUNT',
|
||||||
type=int,
|
type=int,
|
||||||
default=10,
|
default=10,
|
||||||
help='Packet count (client or ping role)',
|
help='Packet count (send or ping scenario)',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--start-delay',
|
'--start-delay',
|
||||||
@@ -1581,7 +1623,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
metavar='SECONDS',
|
metavar='SECONDS',
|
||||||
type=int,
|
type=int,
|
||||||
default=1,
|
default=1,
|
||||||
help='Start delay (client or ping role)',
|
help='Start delay (send or ping scenario)',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--repeat',
|
'--repeat',
|
||||||
@@ -1589,7 +1631,7 @@ def create_role_factory(ctx, default_role):
|
|||||||
type=int,
|
type=int,
|
||||||
default=0,
|
default=0,
|
||||||
help=(
|
help=(
|
||||||
'Repeat the run N times (client and ping roles)'
|
'Repeat the run N times (send and ping scenario)'
|
||||||
'(0, which is the fault, to run just once) '
|
'(0, which is the fault, to run just once) '
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1613,13 +1655,13 @@ def create_role_factory(ctx, default_role):
|
|||||||
@click.option(
|
@click.option(
|
||||||
'--linger',
|
'--linger',
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
help="Don't exit at the end of a run (server and pong roles)",
|
help="Don't exit at the end of a run (receive and pong scenarios)",
|
||||||
)
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def bench(
|
def bench(
|
||||||
ctx,
|
ctx,
|
||||||
device_config,
|
device_config,
|
||||||
role,
|
scenario,
|
||||||
mode,
|
mode,
|
||||||
att_mtu,
|
att_mtu,
|
||||||
extended_data_length,
|
extended_data_length,
|
||||||
@@ -1645,7 +1687,7 @@ def bench(
|
|||||||
):
|
):
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj['device_config'] = device_config
|
ctx.obj['device_config'] = device_config
|
||||||
ctx.obj['role'] = role
|
ctx.obj['scenario'] = scenario
|
||||||
ctx.obj['mode'] = mode
|
ctx.obj['mode'] = mode
|
||||||
ctx.obj['att_mtu'] = att_mtu
|
ctx.obj['att_mtu'] = att_mtu
|
||||||
ctx.obj['rfcomm_channel'] = rfcomm_channel
|
ctx.obj['rfcomm_channel'] = rfcomm_channel
|
||||||
@@ -1699,7 +1741,7 @@ def central(
|
|||||||
ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
|
ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
|
||||||
):
|
):
|
||||||
"""Run as a central (initiates the connection)"""
|
"""Run as a central (initiates the connection)"""
|
||||||
role_factory = create_role_factory(ctx, 'sender')
|
scenario_factory = create_scenario_factory(ctx, 'send')
|
||||||
mode_factory = create_mode_factory(ctx, 'gatt-client')
|
mode_factory = create_mode_factory(ctx, 'gatt-client')
|
||||||
classic = ctx.obj['classic']
|
classic = ctx.obj['classic']
|
||||||
|
|
||||||
@@ -1708,7 +1750,7 @@ def central(
|
|||||||
transport,
|
transport,
|
||||||
peripheral_address,
|
peripheral_address,
|
||||||
classic,
|
classic,
|
||||||
role_factory,
|
scenario_factory,
|
||||||
mode_factory,
|
mode_factory,
|
||||||
connection_interval,
|
connection_interval,
|
||||||
phy,
|
phy,
|
||||||
@@ -1726,13 +1768,13 @@ def central(
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
def peripheral(ctx, transport):
|
def peripheral(ctx, transport):
|
||||||
"""Run as a peripheral (waits for a connection)"""
|
"""Run as a peripheral (waits for a connection)"""
|
||||||
role_factory = create_role_factory(ctx, 'receiver')
|
scenario_factory = create_scenario_factory(ctx, 'receive')
|
||||||
mode_factory = create_mode_factory(ctx, 'gatt-server')
|
mode_factory = create_mode_factory(ctx, 'gatt-server')
|
||||||
|
|
||||||
async def run_peripheral():
|
async def run_peripheral():
|
||||||
await Peripheral(
|
await Peripheral(
|
||||||
transport,
|
transport,
|
||||||
role_factory,
|
scenario_factory,
|
||||||
mode_factory,
|
mode_factory,
|
||||||
ctx.obj['classic'],
|
ctx.obj['classic'],
|
||||||
ctx.obj['extended_data_length'],
|
ctx.obj['extended_data_length'],
|
||||||
@@ -1743,7 +1785,11 @@ def peripheral(ctx, transport):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
logging.basicConfig(
|
||||||
|
level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper(),
|
||||||
|
format="[%(asctime)s.%(msecs)03d] %(levelname)s:%(name)s:%(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
bench()
|
bench()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+23
-7
@@ -37,6 +37,8 @@ from bumble.hci import (
|
|||||||
HCI_Command_Status_Event,
|
HCI_Command_Status_Event,
|
||||||
HCI_READ_BUFFER_SIZE_COMMAND,
|
HCI_READ_BUFFER_SIZE_COMMAND,
|
||||||
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_BD_ADDR_Command,
|
HCI_Read_BD_ADDR_Command,
|
||||||
HCI_READ_LOCAL_NAME_COMMAND,
|
HCI_READ_LOCAL_NAME_COMMAND,
|
||||||
@@ -75,7 +77,7 @@ async def get_classic_info(host: Host) -> None:
|
|||||||
if command_succeeded(response):
|
if command_succeeded(response):
|
||||||
print()
|
print()
|
||||||
print(
|
print(
|
||||||
color('Classic Address:', 'yellow'),
|
color('Public Address:', 'yellow'),
|
||||||
response.return_parameters.bd_addr.to_string(False),
|
response.return_parameters.bd_addr.to_string(False),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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()
|
print()
|
||||||
|
|
||||||
if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
|
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}',
|
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(
|
response = await host.send_command(
|
||||||
HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
color('LE ACL Flow Control:', 'yellow'),
|
color('LE ACL Flow Control:', 'yellow'),
|
||||||
f'{response.return_parameters.hc_total_num_le_acl_data_packets} '
|
f'{response.return_parameters.total_num_le_acl_data_packets} '
|
||||||
f'packets of size {response.return_parameters.hc_le_acl_data_packet_length}',
|
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
|
# Get the LE info
|
||||||
await get_le_info(host)
|
await get_le_info(host)
|
||||||
|
|
||||||
# Print the ACL flow control info
|
# Print the flow control info
|
||||||
await get_acl_flow_control_info(host)
|
await get_flow_control_info(host)
|
||||||
|
|
||||||
# Get codec info
|
# Get codec info
|
||||||
await get_codecs_info(host)
|
await get_codecs_info(host)
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ async def async_main():
|
|||||||
return_parameters=bytes([hci.HCI_SUCCESS]),
|
return_parameters=bytes([hci.HCI_SUCCESS]),
|
||||||
)
|
)
|
||||||
# Return a packet with 'respond to sender' set to True
|
# Return a packet with 'respond to sender' set to True
|
||||||
return (response.to_bytes(), True)
|
return (bytes(response), True)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+84
-206
@@ -16,23 +16,22 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import enum
|
|
||||||
import functools
|
import functools
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
from typing import Optional, List, cast
|
|
||||||
import weakref
|
import weakref
|
||||||
import struct
|
import wave
|
||||||
|
|
||||||
import ctypes
|
try:
|
||||||
import wasmtime
|
import lc3 # type: ignore # pylint: disable=E0401
|
||||||
import wasmtime.loader
|
except ImportError as e:
|
||||||
import liblc3 # type: ignore
|
raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import aiohttp.web
|
import aiohttp.web
|
||||||
@@ -40,11 +39,12 @@ import aiohttp.web
|
|||||||
import bumble
|
import bumble
|
||||||
from bumble.core import AdvertisingData
|
from bumble.core import AdvertisingData
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device, DeviceConfiguration, AdvertisingParameters
|
from bumble.device import Device, DeviceConfiguration, AdvertisingParameters, CisLink
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
from bumble.profiles import ascs, bap, pacs
|
from bumble.profiles import ascs, bap, pacs
|
||||||
from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
|
from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -54,6 +54,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Constants
|
# Constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
DEFAULT_UI_PORT = 7654
|
DEFAULT_UI_PORT = 7654
|
||||||
|
DEFAULT_PCM_BYTES_PER_SAMPLE = 2
|
||||||
|
|
||||||
|
|
||||||
def _sink_pac_record() -> pacs.PacRecord:
|
def _sink_pac_record() -> pacs.PacRecord:
|
||||||
@@ -100,153 +101,8 @@ def _source_pac_record() -> pacs.PacRecord:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
decoder: lc3.Decoder | None = None
|
||||||
# WASM - liblc3
|
encoding_config: bap.CodecSpecificConfiguration | None = None
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
store = wasmtime.loader.store
|
|
||||||
_memory = cast(wasmtime.Memory, liblc3.memory)
|
|
||||||
STACK_POINTER = _memory.data_len(store)
|
|
||||||
_memory.grow(store, 1)
|
|
||||||
# Mapping wasmtime memory to linear address
|
|
||||||
memory = (ctypes.c_ubyte * _memory.data_len(store)).from_address(
|
|
||||||
ctypes.addressof(_memory.data_ptr(store).contents) # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Liblc3PcmFormat(enum.IntEnum):
|
|
||||||
S16 = 0
|
|
||||||
S24 = 1
|
|
||||||
S24_3LE = 2
|
|
||||||
FLOAT = 3
|
|
||||||
|
|
||||||
|
|
||||||
MAX_DECODER_SIZE = liblc3.lc3_decoder_size(10000, 48000)
|
|
||||||
MAX_ENCODER_SIZE = liblc3.lc3_encoder_size(10000, 48000)
|
|
||||||
|
|
||||||
DECODER_STACK_POINTER = STACK_POINTER
|
|
||||||
ENCODER_STACK_POINTER = DECODER_STACK_POINTER + MAX_DECODER_SIZE * 2
|
|
||||||
DECODE_BUFFER_STACK_POINTER = ENCODER_STACK_POINTER + MAX_ENCODER_SIZE * 2
|
|
||||||
ENCODE_BUFFER_STACK_POINTER = DECODE_BUFFER_STACK_POINTER + 8192
|
|
||||||
DEFAULT_PCM_SAMPLE_RATE = 48000
|
|
||||||
DEFAULT_PCM_FORMAT = Liblc3PcmFormat.S16
|
|
||||||
DEFAULT_PCM_BYTES_PER_SAMPLE = 2
|
|
||||||
|
|
||||||
|
|
||||||
encoders: List[int] = []
|
|
||||||
decoders: List[int] = []
|
|
||||||
|
|
||||||
|
|
||||||
def setup_encoders(
|
|
||||||
sample_rate_hz: int, frame_duration_us: int, num_channels: int
|
|
||||||
) -> None:
|
|
||||||
logger.info(
|
|
||||||
f"setup_encoders {sample_rate_hz}Hz {frame_duration_us}us {num_channels}channels"
|
|
||||||
)
|
|
||||||
encoders[:num_channels] = [
|
|
||||||
liblc3.lc3_setup_encoder(
|
|
||||||
frame_duration_us,
|
|
||||||
sample_rate_hz,
|
|
||||||
DEFAULT_PCM_SAMPLE_RATE, # Input sample rate
|
|
||||||
ENCODER_STACK_POINTER + MAX_ENCODER_SIZE * i,
|
|
||||||
)
|
|
||||||
for i in range(num_channels)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def setup_decoders(
|
|
||||||
sample_rate_hz: int, frame_duration_us: int, num_channels: int
|
|
||||||
) -> None:
|
|
||||||
logger.info(
|
|
||||||
f"setup_decoders {sample_rate_hz}Hz {frame_duration_us}us {num_channels}channels"
|
|
||||||
)
|
|
||||||
decoders[:num_channels] = [
|
|
||||||
liblc3.lc3_setup_decoder(
|
|
||||||
frame_duration_us,
|
|
||||||
sample_rate_hz,
|
|
||||||
DEFAULT_PCM_SAMPLE_RATE, # Output sample rate
|
|
||||||
DECODER_STACK_POINTER + MAX_DECODER_SIZE * i,
|
|
||||||
)
|
|
||||||
for i in range(num_channels)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def decode(
|
|
||||||
frame_duration_us: int,
|
|
||||||
num_channels: int,
|
|
||||||
input_bytes: bytes,
|
|
||||||
) -> bytes:
|
|
||||||
if not input_bytes:
|
|
||||||
return b''
|
|
||||||
|
|
||||||
input_buffer_offset = DECODE_BUFFER_STACK_POINTER
|
|
||||||
input_buffer_size = len(input_bytes)
|
|
||||||
input_bytes_per_frame = input_buffer_size // num_channels
|
|
||||||
|
|
||||||
# Copy into wasm
|
|
||||||
memory[input_buffer_offset : input_buffer_offset + input_buffer_size] = input_bytes # type: ignore
|
|
||||||
|
|
||||||
output_buffer_offset = input_buffer_offset + input_buffer_size
|
|
||||||
output_buffer_size = (
|
|
||||||
liblc3.lc3_frame_samples(frame_duration_us, DEFAULT_PCM_SAMPLE_RATE)
|
|
||||||
* DEFAULT_PCM_BYTES_PER_SAMPLE
|
|
||||||
* num_channels
|
|
||||||
)
|
|
||||||
|
|
||||||
for i in range(num_channels):
|
|
||||||
res = liblc3.lc3_decode(
|
|
||||||
decoders[i],
|
|
||||||
input_buffer_offset + input_bytes_per_frame * i,
|
|
||||||
input_bytes_per_frame,
|
|
||||||
DEFAULT_PCM_FORMAT,
|
|
||||||
output_buffer_offset + i * DEFAULT_PCM_BYTES_PER_SAMPLE,
|
|
||||||
num_channels, # Stride
|
|
||||||
)
|
|
||||||
|
|
||||||
if res != 0:
|
|
||||||
logging.error(f"Parsing failed, res={res}")
|
|
||||||
|
|
||||||
# Extract decoded data from the output buffer
|
|
||||||
return bytes(
|
|
||||||
memory[output_buffer_offset : output_buffer_offset + output_buffer_size]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def encode(
|
|
||||||
sdu_length: int,
|
|
||||||
num_channels: int,
|
|
||||||
stride: int,
|
|
||||||
input_bytes: bytes,
|
|
||||||
) -> bytes:
|
|
||||||
if not input_bytes:
|
|
||||||
return b''
|
|
||||||
|
|
||||||
input_buffer_offset = ENCODE_BUFFER_STACK_POINTER
|
|
||||||
input_buffer_size = len(input_bytes)
|
|
||||||
|
|
||||||
# Copy into wasm
|
|
||||||
memory[input_buffer_offset : input_buffer_offset + input_buffer_size] = input_bytes # type: ignore
|
|
||||||
|
|
||||||
output_buffer_offset = input_buffer_offset + input_buffer_size
|
|
||||||
output_buffer_size = sdu_length
|
|
||||||
output_frame_size = output_buffer_size // num_channels
|
|
||||||
|
|
||||||
for i in range(num_channels):
|
|
||||||
res = liblc3.lc3_encode(
|
|
||||||
encoders[i],
|
|
||||||
DEFAULT_PCM_FORMAT,
|
|
||||||
input_buffer_offset + DEFAULT_PCM_BYTES_PER_SAMPLE * i,
|
|
||||||
stride,
|
|
||||||
output_frame_size,
|
|
||||||
output_buffer_offset + output_frame_size * i,
|
|
||||||
)
|
|
||||||
|
|
||||||
if res != 0:
|
|
||||||
logging.error(f"Parsing failed, res={res}")
|
|
||||||
|
|
||||||
# Extract decoded data from the output buffer
|
|
||||||
return bytes(
|
|
||||||
memory[output_buffer_offset : output_buffer_offset + output_buffer_size]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def lc3_source_task(
|
async def lc3_source_task(
|
||||||
@@ -254,44 +110,49 @@ async def lc3_source_task(
|
|||||||
sdu_length: int,
|
sdu_length: int,
|
||||||
frame_duration_us: int,
|
frame_duration_us: int,
|
||||||
device: Device,
|
device: Device,
|
||||||
cis_handle: int,
|
cis_link: CisLink,
|
||||||
) -> None:
|
) -> None:
|
||||||
with open(filename, 'rb') as f:
|
logger.info(
|
||||||
header = f.read(44)
|
"lc3_source_task filename=%s, sdu_length=%d, frame_duration=%.1f",
|
||||||
assert header[8:12] == b'WAVE'
|
filename,
|
||||||
|
sdu_length,
|
||||||
|
frame_duration_us / 1000,
|
||||||
|
)
|
||||||
|
with wave.open(filename, 'rb') as wav:
|
||||||
|
bits_per_sample = wav.getsampwidth() * 8
|
||||||
|
|
||||||
pcm_num_channel, pcm_sample_rate, _byte_rate, _block_align, bits_per_sample = (
|
encoder: lc3.Encoder | None = None
|
||||||
struct.unpack("<HIIHH", header[22:36])
|
|
||||||
)
|
|
||||||
assert pcm_sample_rate == DEFAULT_PCM_SAMPLE_RATE
|
|
||||||
assert bits_per_sample == DEFAULT_PCM_BYTES_PER_SAMPLE * 8
|
|
||||||
|
|
||||||
frame_bytes = (
|
|
||||||
liblc3.lc3_frame_samples(frame_duration_us, DEFAULT_PCM_SAMPLE_RATE)
|
|
||||||
* DEFAULT_PCM_BYTES_PER_SAMPLE
|
|
||||||
)
|
|
||||||
packet_sequence_number = 0
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
next_round = datetime.datetime.now() + datetime.timedelta(
|
next_round = datetime.datetime.now() + datetime.timedelta(
|
||||||
microseconds=frame_duration_us
|
microseconds=frame_duration_us
|
||||||
)
|
)
|
||||||
pcm_data = f.read(frame_bytes)
|
if not encoder:
|
||||||
sdu = encode(sdu_length, pcm_num_channel, pcm_num_channel, pcm_data)
|
if (
|
||||||
|
encoding_config
|
||||||
|
and (frame_duration := encoding_config.frame_duration)
|
||||||
|
and (sampling_frequency := encoding_config.sampling_frequency)
|
||||||
|
and (
|
||||||
|
audio_channel_allocation := encoding_config.audio_channel_allocation
|
||||||
|
)
|
||||||
|
):
|
||||||
|
logger.info("Use %s", encoding_config)
|
||||||
|
encoder = lc3.Encoder(
|
||||||
|
frame_duration_us=frame_duration.us,
|
||||||
|
sample_rate_hz=sampling_frequency.hz,
|
||||||
|
num_channels=audio_channel_allocation.channel_count,
|
||||||
|
input_sample_rate_hz=wav.getframerate(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sdu = encoder.encode(
|
||||||
|
pcm=wav.readframes(encoder.get_frame_samples()),
|
||||||
|
num_bytes=sdu_length,
|
||||||
|
bit_depth=bits_per_sample,
|
||||||
|
)
|
||||||
|
cis_link.write(sdu)
|
||||||
|
|
||||||
iso_packet = HCI_IsoDataPacket(
|
|
||||||
connection_handle=cis_handle,
|
|
||||||
data_total_length=sdu_length + 4,
|
|
||||||
packet_sequence_number=packet_sequence_number,
|
|
||||||
pb_flag=0b10,
|
|
||||||
packet_status_flag=0,
|
|
||||||
iso_sdu_length=sdu_length,
|
|
||||||
iso_sdu_fragment=sdu,
|
|
||||||
)
|
|
||||||
device.host.send_hci_packet(iso_packet)
|
|
||||||
packet_sequence_number += 1
|
|
||||||
sleep_time = next_round - datetime.datetime.now()
|
sleep_time = next_round - datetime.datetime.now()
|
||||||
await asyncio.sleep(sleep_time.total_seconds())
|
await asyncio.sleep(sleep_time.total_seconds() * 0.9)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -410,7 +271,7 @@ class Speaker:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device_config_path: Optional[str],
|
device_config_path: str | None,
|
||||||
ui_port: int,
|
ui_port: int,
|
||||||
transport: str,
|
transport: str,
|
||||||
lc3_input_file_path: str,
|
lc3_input_file_path: str,
|
||||||
@@ -437,6 +298,7 @@ class Speaker:
|
|||||||
advertising_interval_min=25,
|
advertising_interval_min=25,
|
||||||
advertising_interval_max=25,
|
advertising_interval_max=25,
|
||||||
address=Address('F1:F2:F3:F4:F5:F6'),
|
address=Address('F1:F2:F3:F4:F5:F6'),
|
||||||
|
identity_address_type=Address.RANDOM_DEVICE_ADDRESS,
|
||||||
)
|
)
|
||||||
|
|
||||||
device_config.le_enabled = True
|
device_config.le_enabled = True
|
||||||
@@ -486,20 +348,31 @@ class Speaker:
|
|||||||
|
|
||||||
def on_pdu(pdu: HCI_IsoDataPacket, ase: ascs.AseStateMachine):
|
def on_pdu(pdu: HCI_IsoDataPacket, ase: ascs.AseStateMachine):
|
||||||
codec_config = ase.codec_specific_configuration
|
codec_config = ase.codec_specific_configuration
|
||||||
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
if (
|
||||||
pcm = decode(
|
not isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||||
codec_config.frame_duration.us,
|
or codec_config.frame_duration is None
|
||||||
codec_config.audio_channel_allocation.channel_count,
|
or codec_config.audio_channel_allocation is None
|
||||||
pdu.iso_sdu_fragment,
|
or decoder is None
|
||||||
|
or not pdu.iso_sdu_fragment
|
||||||
|
):
|
||||||
|
return
|
||||||
|
pcm = decoder.decode(
|
||||||
|
pdu.iso_sdu_fragment, bit_depth=DEFAULT_PCM_BYTES_PER_SAMPLE * 8
|
||||||
)
|
)
|
||||||
self.device.abort_on('disconnection', self.ui_server.send_audio(pcm))
|
self.device.abort_on('disconnection', self.ui_server.send_audio(pcm))
|
||||||
|
|
||||||
def on_ase_state_change(ase: ascs.AseStateMachine) -> None:
|
def on_ase_state_change(ase: ascs.AseStateMachine) -> None:
|
||||||
|
codec_config = ase.codec_specific_configuration
|
||||||
if ase.state == ascs.AseStateMachine.State.STREAMING:
|
if ase.state == ascs.AseStateMachine.State.STREAMING:
|
||||||
codec_config = ase.codec_specific_configuration
|
|
||||||
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
|
||||||
assert ase.cis_link
|
|
||||||
if ase.role == ascs.AudioRole.SOURCE:
|
if ase.role == ascs.AudioRole.SOURCE:
|
||||||
|
if (
|
||||||
|
not isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||||
|
or ase.cis_link is None
|
||||||
|
or codec_config.octets_per_codec_frame is None
|
||||||
|
or codec_config.frame_duration is None
|
||||||
|
or codec_config.codec_frames_per_sdu is None
|
||||||
|
):
|
||||||
|
return
|
||||||
ase.cis_link.abort_on(
|
ase.cis_link.abort_on(
|
||||||
'disconnection',
|
'disconnection',
|
||||||
lc3_source_task(
|
lc3_source_task(
|
||||||
@@ -510,25 +383,30 @@ class Speaker:
|
|||||||
),
|
),
|
||||||
frame_duration_us=codec_config.frame_duration.us,
|
frame_duration_us=codec_config.frame_duration.us,
|
||||||
device=self.device,
|
device=self.device,
|
||||||
cis_handle=ase.cis_link.handle,
|
cis_link=ase.cis_link,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
if not ase.cis_link:
|
||||||
|
return
|
||||||
ase.cis_link.sink = functools.partial(on_pdu, ase=ase)
|
ase.cis_link.sink = functools.partial(on_pdu, ase=ase)
|
||||||
elif ase.state == ascs.AseStateMachine.State.CODEC_CONFIGURED:
|
elif ase.state == ascs.AseStateMachine.State.CODEC_CONFIGURED:
|
||||||
codec_config = ase.codec_specific_configuration
|
if (
|
||||||
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
not isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||||
|
or codec_config.sampling_frequency is None
|
||||||
|
or codec_config.frame_duration is None
|
||||||
|
or codec_config.audio_channel_allocation is None
|
||||||
|
):
|
||||||
|
return
|
||||||
if ase.role == ascs.AudioRole.SOURCE:
|
if ase.role == ascs.AudioRole.SOURCE:
|
||||||
setup_encoders(
|
global encoding_config
|
||||||
codec_config.sampling_frequency.hz,
|
encoding_config = codec_config
|
||||||
codec_config.frame_duration.us,
|
|
||||||
codec_config.audio_channel_allocation.channel_count,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
setup_decoders(
|
global decoder
|
||||||
codec_config.sampling_frequency.hz,
|
decoder = lc3.Decoder(
|
||||||
codec_config.frame_duration.us,
|
frame_duration_us=codec_config.frame_duration.us,
|
||||||
codec_config.audio_channel_allocation.channel_count,
|
sample_rate_hz=codec_config.sampling_frequency.hz,
|
||||||
|
num_channels=codec_config.audio_channel_allocation.channel_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
for ase in ascs_service.ase_state_machines.values():
|
for ase in ascs_service.ase_state_machines.values():
|
||||||
@@ -567,7 +445,7 @@ def speaker(ui_port: int, device_config: str, transport: str, lc3_file: str) ->
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||||
speaker()
|
speaker()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+13
-8
@@ -373,7 +373,9 @@ async def pair(
|
|||||||
shared_data = (
|
shared_data = (
|
||||||
None
|
None
|
||||||
if oob == '-'
|
if oob == '-'
|
||||||
else OobData.from_ad(AdvertisingData.from_bytes(bytes.fromhex(oob)))
|
else OobData.from_ad(
|
||||||
|
AdvertisingData.from_bytes(bytes.fromhex(oob))
|
||||||
|
).shared_data
|
||||||
)
|
)
|
||||||
legacy_context = OobLegacyContext()
|
legacy_context = OobLegacyContext()
|
||||||
oob_contexts = PairingConfig.OobConfig(
|
oob_contexts = PairingConfig.OobConfig(
|
||||||
@@ -381,16 +383,19 @@ async def pair(
|
|||||||
peer_data=shared_data,
|
peer_data=shared_data,
|
||||||
legacy_context=legacy_context,
|
legacy_context=legacy_context,
|
||||||
)
|
)
|
||||||
oob_data = OobData(
|
|
||||||
address=device.random_address,
|
|
||||||
shared_data=shared_data,
|
|
||||||
legacy_context=legacy_context,
|
|
||||||
)
|
|
||||||
print(color('@@@-----------------------------------', 'yellow'))
|
print(color('@@@-----------------------------------', 'yellow'))
|
||||||
print(color('@@@ OOB Data:', 'yellow'))
|
print(color('@@@ OOB Data:', 'yellow'))
|
||||||
print(color(f'@@@ {our_oob_context.share()}', 'yellow'))
|
if shared_data is None:
|
||||||
|
oob_data = OobData(
|
||||||
|
address=device.random_address, shared_data=our_oob_context.share()
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f'@@@ SHARE: {bytes(oob_data.to_ad()).hex()}',
|
||||||
|
'yellow',
|
||||||
|
)
|
||||||
|
)
|
||||||
print(color(f'@@@ TK={legacy_context.tk.hex()}', 'yellow'))
|
print(color(f'@@@ TK={legacy_context.tk.hex()}', 'yellow'))
|
||||||
print(color(f'@@@ HEX: ({bytes(oob_data.to_ad()).hex()})', 'yellow'))
|
|
||||||
print(color('@@@-----------------------------------', 'yellow'))
|
print(color('@@@-----------------------------------', 'yellow'))
|
||||||
else:
|
else:
|
||||||
oob_contexts = None
|
oob_contexts = None
|
||||||
|
|||||||
+10
-1
@@ -237,6 +237,7 @@ class ClientBridge:
|
|||||||
address: str,
|
address: str,
|
||||||
tcp_host: str,
|
tcp_host: str,
|
||||||
tcp_port: int,
|
tcp_port: int,
|
||||||
|
authenticate: bool,
|
||||||
encrypt: bool,
|
encrypt: bool,
|
||||||
):
|
):
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
@@ -245,6 +246,7 @@ class ClientBridge:
|
|||||||
self.address = address
|
self.address = address
|
||||||
self.tcp_host = tcp_host
|
self.tcp_host = tcp_host
|
||||||
self.tcp_port = tcp_port
|
self.tcp_port = tcp_port
|
||||||
|
self.authenticate = authenticate
|
||||||
self.encrypt = encrypt
|
self.encrypt = encrypt
|
||||||
self.device: Optional[Device] = None
|
self.device: Optional[Device] = None
|
||||||
self.connection: Optional[Connection] = None
|
self.connection: Optional[Connection] = None
|
||||||
@@ -274,6 +276,11 @@ class ClientBridge:
|
|||||||
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
|
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
|
||||||
self.connection.on("disconnection", self.on_disconnection)
|
self.connection.on("disconnection", self.on_disconnection)
|
||||||
|
|
||||||
|
if self.authenticate:
|
||||||
|
print(color("@@@ Authenticating Bluetooth connection", "blue"))
|
||||||
|
await self.connection.authenticate()
|
||||||
|
print(color("@@@ Bluetooth connection authenticated", "blue"))
|
||||||
|
|
||||||
if self.encrypt:
|
if self.encrypt:
|
||||||
print(color("@@@ Encrypting Bluetooth connection", "blue"))
|
print(color("@@@ Encrypting Bluetooth connection", "blue"))
|
||||||
await self.connection.encrypt()
|
await self.connection.encrypt()
|
||||||
@@ -491,8 +498,9 @@ def server(context, tcp_host, tcp_port):
|
|||||||
@click.argument("bluetooth-address")
|
@click.argument("bluetooth-address")
|
||||||
@click.option("--tcp-host", help="TCP host", default="_")
|
@click.option("--tcp-host", help="TCP host", default="_")
|
||||||
@click.option("--tcp-port", help="TCP port", default=DEFAULT_CLIENT_TCP_PORT)
|
@click.option("--tcp-port", help="TCP port", default=DEFAULT_CLIENT_TCP_PORT)
|
||||||
|
@click.option("--authenticate", is_flag=True, help="Authenticate the connection")
|
||||||
@click.option("--encrypt", is_flag=True, help="Encrypt the connection")
|
@click.option("--encrypt", is_flag=True, help="Encrypt the connection")
|
||||||
def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
|
def client(context, bluetooth_address, tcp_host, tcp_port, authenticate, encrypt):
|
||||||
bridge = ClientBridge(
|
bridge = ClientBridge(
|
||||||
context.obj["channel"],
|
context.obj["channel"],
|
||||||
context.obj["uuid"],
|
context.obj["uuid"],
|
||||||
@@ -500,6 +508,7 @@ def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
|
|||||||
bluetooth_address,
|
bluetooth_address,
|
||||||
tcp_host,
|
tcp_host,
|
||||||
tcp_port,
|
tcp_port,
|
||||||
|
authenticate,
|
||||||
encrypt,
|
encrypt,
|
||||||
)
|
)
|
||||||
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
|
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
|
||||||
|
|||||||
+6
-6
@@ -144,18 +144,18 @@ class Printer:
|
|||||||
help='Format of the input file',
|
help='Format of the input file',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--vendors',
|
'--vendor',
|
||||||
type=click.Choice(['android', 'zephyr']),
|
type=click.Choice(['android', 'zephyr']),
|
||||||
multiple=True,
|
multiple=True,
|
||||||
help='Support vendor-specific commands (list one or more)',
|
help='Support vendor-specific commands (list one or more)',
|
||||||
)
|
)
|
||||||
@click.argument('filename')
|
@click.argument('filename')
|
||||||
# pylint: disable=redefined-builtin
|
# pylint: disable=redefined-builtin
|
||||||
def main(format, vendors, filename):
|
def main(format, vendor, filename):
|
||||||
for vendor in vendors:
|
for vendor_name in vendor:
|
||||||
if vendor == 'android':
|
if vendor_name == 'android':
|
||||||
import bumble.vendor.android.hci
|
import bumble.vendor.android.hci
|
||||||
elif vendor == 'zephyr':
|
elif vendor_name == 'zephyr':
|
||||||
import bumble.vendor.zephyr.hci
|
import bumble.vendor.zephyr.hci
|
||||||
|
|
||||||
input = open(filename, 'rb')
|
input = open(filename, 'rb')
|
||||||
@@ -180,7 +180,7 @@ def main(format, vendors, filename):
|
|||||||
else:
|
else:
|
||||||
printer.print(color("[TRUNCATED]", "red"))
|
printer.print(color("[TRUNCATED]", "red"))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.exception()
|
logger.exception('')
|
||||||
print(color(f'!!! {error}', 'red'))
|
print(color(f'!!! {error}', 'red'))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-15
@@ -57,6 +57,7 @@ if TYPE_CHECKING:
|
|||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
|
|
||||||
ATT_CID = 0x04
|
ATT_CID = 0x04
|
||||||
|
ATT_PSM = 0x001F
|
||||||
|
|
||||||
ATT_ERROR_RESPONSE = 0x01
|
ATT_ERROR_RESPONSE = 0x01
|
||||||
ATT_EXCHANGE_MTU_REQUEST = 0x02
|
ATT_EXCHANGE_MTU_REQUEST = 0x02
|
||||||
@@ -291,9 +292,6 @@ class ATT_PDU:
|
|||||||
def init_from_bytes(self, pdu, offset):
|
def init_from_bytes(self, pdu, offset):
|
||||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||||
|
|
||||||
def to_bytes(self):
|
|
||||||
return self.pdu
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_command(self):
|
def is_command(self):
|
||||||
return ((self.op_code >> 6) & 1) == 1
|
return ((self.op_code >> 6) & 1) == 1
|
||||||
@@ -303,7 +301,7 @@ class ATT_PDU:
|
|||||||
return ((self.op_code >> 7) & 1) == 1
|
return ((self.op_code >> 7) & 1) == 1
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self):
|
||||||
return self.to_bytes()
|
return self.pdu
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
result = color(self.name, 'yellow')
|
result = color(self.name, 'yellow')
|
||||||
@@ -759,13 +757,13 @@ class AttributeValue:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
read: Union[
|
read: Union[
|
||||||
Callable[[Optional[Connection]], bytes],
|
Callable[[Optional[Connection]], Any],
|
||||||
Callable[[Optional[Connection]], Awaitable[bytes]],
|
Callable[[Optional[Connection]], Awaitable[Any]],
|
||||||
None,
|
None,
|
||||||
] = None,
|
] = None,
|
||||||
write: Union[
|
write: Union[
|
||||||
Callable[[Optional[Connection], bytes], None],
|
Callable[[Optional[Connection], Any], None],
|
||||||
Callable[[Optional[Connection], bytes], Awaitable[None]],
|
Callable[[Optional[Connection], Any], Awaitable[None]],
|
||||||
None,
|
None,
|
||||||
] = None,
|
] = None,
|
||||||
):
|
):
|
||||||
@@ -824,13 +822,13 @@ class Attribute(EventEmitter):
|
|||||||
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
|
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
|
||||||
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
|
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
|
||||||
|
|
||||||
value: Union[bytes, AttributeValue]
|
value: Any
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
attribute_type: Union[str, bytes, UUID],
|
attribute_type: Union[str, bytes, UUID],
|
||||||
permissions: Union[str, Attribute.Permissions],
|
permissions: Union[str, Attribute.Permissions],
|
||||||
value: Union[str, bytes, AttributeValue] = b'',
|
value: Any = b'',
|
||||||
) -> None:
|
) -> None:
|
||||||
EventEmitter.__init__(self)
|
EventEmitter.__init__(self)
|
||||||
self.handle = 0
|
self.handle = 0
|
||||||
@@ -848,11 +846,7 @@ class Attribute(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
self.type = attribute_type
|
self.type = attribute_type
|
||||||
|
|
||||||
# Convert the value to a byte array
|
self.value = value
|
||||||
if isinstance(value, str):
|
|
||||||
self.value = bytes(value, 'utf-8')
|
|
||||||
else:
|
|
||||||
self.value = value
|
|
||||||
|
|
||||||
def encode_value(self, value: Any) -> bytes:
|
def encode_value(self, value: Any) -> bytes:
|
||||||
return value
|
return value
|
||||||
@@ -895,6 +889,8 @@ class Attribute(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
value = self.value
|
value = self.value
|
||||||
|
|
||||||
|
self.emit('read', connection, value)
|
||||||
|
|
||||||
return self.encode_value(value)
|
return self.encode_value(value)
|
||||||
|
|
||||||
async def write_value(self, connection: Connection, value_bytes: bytes) -> None:
|
async def write_value(self, connection: Connection, value_bytes: bytes) -> None:
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ class Frame:
|
|||||||
opcode_offset = 3
|
opcode_offset = 3
|
||||||
elif subunit_id == 6:
|
elif subunit_id == 6:
|
||||||
raise core.InvalidPacketError("reserved subunit ID")
|
raise core.InvalidPacketError("reserved subunit ID")
|
||||||
|
else:
|
||||||
|
raise core.InvalidPacketError("invalid subunit ID")
|
||||||
|
|
||||||
opcode = Frame.OperationCode(data[opcode_offset])
|
opcode = Frame.OperationCode(data[opcode_offset])
|
||||||
operands = data[opcode_offset + 1 :]
|
operands = data[opcode_offset + 1 :]
|
||||||
|
|||||||
+82
-11
@@ -154,15 +154,17 @@ class Controller:
|
|||||||
'0000000060000000'
|
'0000000060000000'
|
||||||
) # BR/EDR Not Supported, LE Supported (Controller)
|
) # BR/EDR Not Supported, LE Supported (Controller)
|
||||||
self.manufacturer_name = 0xFFFF
|
self.manufacturer_name = 0xFFFF
|
||||||
self.hc_data_packet_length = 27
|
self.acl_data_packet_length = 27
|
||||||
self.hc_total_num_data_packets = 64
|
self.total_num_acl_data_packets = 64
|
||||||
self.hc_le_data_packet_length = 27
|
self.le_acl_data_packet_length = 27
|
||||||
self.hc_total_num_le_data_packets = 64
|
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 = 0
|
||||||
self.event_mask_page_2 = 0
|
self.event_mask_page_2 = 0
|
||||||
self.supported_commands = bytes.fromhex(
|
self.supported_commands = bytes.fromhex(
|
||||||
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
|
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
|
||||||
'30f0f9ff01008004000000000000000000000000000000000000000000000000'
|
'30f0f9ff01008004002000000000000000000000000000000000000000000000'
|
||||||
)
|
)
|
||||||
self.le_event_mask = 0
|
self.le_event_mask = 0
|
||||||
self.advertising_parameters = None
|
self.advertising_parameters = None
|
||||||
@@ -314,7 +316,7 @@ class Controller:
|
|||||||
f'{color("CONTROLLER -> HOST", "green")}: {packet}'
|
f'{color("CONTROLLER -> HOST", "green")}: {packet}'
|
||||||
)
|
)
|
||||||
if self.host:
|
if self.host:
|
||||||
self.host.on_packet(packet.to_bytes())
|
self.host.on_packet(bytes(packet))
|
||||||
|
|
||||||
# This method allows the controller to emulate the same API as a transport source
|
# This method allows the controller to emulate the same API as a transport source
|
||||||
async def wait_for_termination(self):
|
async def wait_for_termination(self):
|
||||||
@@ -1181,9 +1183,9 @@ class Controller:
|
|||||||
return struct.pack(
|
return struct.pack(
|
||||||
'<BHBHH',
|
'<BHBHH',
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
self.hc_data_packet_length,
|
self.acl_data_packet_length,
|
||||||
0,
|
0,
|
||||||
self.hc_total_num_data_packets,
|
self.total_num_acl_data_packets,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1192,7 +1194,7 @@ class Controller:
|
|||||||
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
|
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
|
||||||
'''
|
'''
|
||||||
bd_addr = (
|
bd_addr = (
|
||||||
self._public_address.to_bytes()
|
bytes(self._public_address)
|
||||||
if self._public_address is not None
|
if self._public_address is not None
|
||||||
else bytes(6)
|
else bytes(6)
|
||||||
)
|
)
|
||||||
@@ -1212,8 +1214,21 @@ class Controller:
|
|||||||
return struct.pack(
|
return struct.pack(
|
||||||
'<BHB',
|
'<BHB',
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
self.hc_le_data_packet_length,
|
self.le_acl_data_packet_length,
|
||||||
self.hc_total_num_le_data_packets,
|
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):
|
def on_hci_le_read_local_supported_features_command(self, _command):
|
||||||
@@ -1543,6 +1558,41 @@ class Controller:
|
|||||||
}
|
}
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
|
def on_hci_le_set_advertising_set_random_address_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.52 LE Set Advertising Set Random Address
|
||||||
|
Command
|
||||||
|
'''
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
|
def on_hci_le_set_extended_advertising_parameters_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.53 LE Set Extended Advertising Parameters
|
||||||
|
Command
|
||||||
|
'''
|
||||||
|
return bytes([HCI_SUCCESS, 0])
|
||||||
|
|
||||||
|
def on_hci_le_set_extended_advertising_data_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.54 LE Set Extended Advertising Data
|
||||||
|
Command
|
||||||
|
'''
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
|
def on_hci_le_set_extended_scan_response_data_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.55 LE Set Extended Scan Response Data
|
||||||
|
Command
|
||||||
|
'''
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
|
def on_hci_le_set_extended_advertising_enable_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.56 LE Set Extended Advertising Enable
|
||||||
|
Command
|
||||||
|
'''
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_maximum_advertising_data_length_command(self, _command):
|
def on_hci_le_read_maximum_advertising_data_length_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 4, Part E - 7.8.57 LE Read Maximum Advertising Data
|
See Bluetooth spec Vol 4, Part E - 7.8.57 LE Read Maximum Advertising Data
|
||||||
@@ -1557,6 +1607,27 @@ class Controller:
|
|||||||
'''
|
'''
|
||||||
return struct.pack('<BB', HCI_SUCCESS, 0xF0)
|
return struct.pack('<BB', HCI_SUCCESS, 0xF0)
|
||||||
|
|
||||||
|
def on_hci_le_set_periodic_advertising_parameters_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.61 LE Set Periodic Advertising Parameters
|
||||||
|
Command
|
||||||
|
'''
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
|
def on_hci_le_set_periodic_advertising_data_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.62 LE Set Periodic Advertising Data
|
||||||
|
Command
|
||||||
|
'''
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
|
def on_hci_le_set_periodic_advertising_enable_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.63 LE Set Periodic Advertising Enable
|
||||||
|
Command
|
||||||
|
'''
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_transmit_power_command(self, _command):
|
def on_hci_le_read_transmit_power_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
|
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
|
||||||
|
|||||||
+4
-1
@@ -1501,7 +1501,10 @@ class AdvertisingData:
|
|||||||
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
||||||
elif ad_type == AdvertisingData.COMPLETE_LOCAL_NAME:
|
elif ad_type == AdvertisingData.COMPLETE_LOCAL_NAME:
|
||||||
ad_type_str = 'Complete Local Name'
|
ad_type_str = 'Complete Local Name'
|
||||||
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
try:
|
||||||
|
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
ad_data_str = ad_data.hex()
|
||||||
elif ad_type == AdvertisingData.TX_POWER_LEVEL:
|
elif ad_type == AdvertisingData.TX_POWER_LEVEL:
|
||||||
ad_type_str = 'TX Power Level'
|
ad_type_str = 'TX Power Level'
|
||||||
ad_data_str = str(ad_data[0])
|
ad_data_str = str(ad_data[0])
|
||||||
|
|||||||
+917
-521
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@ Common types for drivers.
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import abc
|
import abc
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Classes
|
# Classes
|
||||||
|
|||||||
+594
-25
@@ -11,18 +11,33 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
"""
|
||||||
|
Support for Intel USB controllers.
|
||||||
|
Loosely based on the Fuchsia OS implementation.
|
||||||
|
"""
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import collections
|
||||||
|
import dataclasses
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import platform
|
||||||
|
import struct
|
||||||
|
from typing import Any, Deque, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
from bumble.drivers import common
|
from bumble.drivers import common
|
||||||
from bumble.hci import (
|
from bumble import hci
|
||||||
hci_vendor_command_op_code, # type: ignore
|
from bumble import utils
|
||||||
HCI_Command,
|
|
||||||
HCI_Reset_Command,
|
if TYPE_CHECKING:
|
||||||
)
|
from bumble.host import Host
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -34,39 +49,328 @@ logger = logging.getLogger(__name__)
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
INTEL_USB_PRODUCTS = {
|
INTEL_USB_PRODUCTS = {
|
||||||
# Intel AX210
|
(0x8087, 0x0032), # AX210
|
||||||
(0x8087, 0x0032),
|
(0x8087, 0x0036), # BE200
|
||||||
# Intel BE200
|
|
||||||
(0x8087, 0x0036),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
INTEL_FW_IMAGE_NAMES = [
|
||||||
|
"ibt-0040-0041",
|
||||||
|
"ibt-0040-1020",
|
||||||
|
"ibt-0040-1050",
|
||||||
|
"ibt-0040-2120",
|
||||||
|
"ibt-0040-4150",
|
||||||
|
"ibt-0041-0041",
|
||||||
|
"ibt-0180-0041",
|
||||||
|
"ibt-0180-1050",
|
||||||
|
"ibt-0180-4150",
|
||||||
|
"ibt-0291-0291",
|
||||||
|
"ibt-1040-0041",
|
||||||
|
"ibt-1040-1020",
|
||||||
|
"ibt-1040-1050",
|
||||||
|
"ibt-1040-2120",
|
||||||
|
"ibt-1040-4150",
|
||||||
|
]
|
||||||
|
|
||||||
|
INTEL_FIRMWARE_DIR_ENV = "BUMBLE_INTEL_FIRMWARE_DIR"
|
||||||
|
INTEL_LINUX_FIRMWARE_DIR = "/lib/firmware/intel"
|
||||||
|
|
||||||
|
_MAX_FRAGMENT_SIZE = 252
|
||||||
|
_POST_RESET_DELAY = 0.2
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# HCI Commands
|
# HCI Commands
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
HCI_INTEL_DDC_CONFIG_WRITE_COMMAND = hci_vendor_command_op_code(0xFC8B) # type: ignore
|
HCI_INTEL_WRITE_DEVICE_CONFIG_COMMAND = hci.hci_vendor_command_op_code(0x008B)
|
||||||
HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD = [0x03, 0xE4, 0x02, 0x00]
|
HCI_INTEL_READ_VERSION_COMMAND = hci.hci_vendor_command_op_code(0x0005)
|
||||||
|
HCI_INTEL_RESET_COMMAND = hci.hci_vendor_command_op_code(0x0001)
|
||||||
|
HCI_INTEL_SECURE_SEND_COMMAND = hci.hci_vendor_command_op_code(0x0009)
|
||||||
|
HCI_INTEL_WRITE_BOOT_PARAMS_COMMAND = hci.hci_vendor_command_op_code(0x000E)
|
||||||
|
|
||||||
HCI_Command.register_commands(globals())
|
hci.HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
@HCI_Command.command( # type: ignore
|
@hci.HCI_Command.command(
|
||||||
fields=[("params", "*")],
|
fields=[
|
||||||
|
("param0", 1),
|
||||||
|
],
|
||||||
return_parameters_fields=[
|
return_parameters_fields=[
|
||||||
("params", "*"),
|
("status", hci.STATUS_SPEC),
|
||||||
|
("tlv", "*"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class Hci_Intel_DDC_Config_Write_Command(HCI_Command):
|
class HCI_Intel_Read_Version_Command(hci.HCI_Command):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_Command.command(
|
||||||
|
fields=[("data_type", 1), ("data", "*")],
|
||||||
|
return_parameters_fields=[
|
||||||
|
("status", 1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class Hci_Intel_Secure_Send_Command(hci.HCI_Command):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_Command.command(
|
||||||
|
fields=[
|
||||||
|
("reset_type", 1),
|
||||||
|
("patch_enable", 1),
|
||||||
|
("ddc_reload", 1),
|
||||||
|
("boot_option", 1),
|
||||||
|
("boot_address", 4),
|
||||||
|
],
|
||||||
|
return_parameters_fields=[
|
||||||
|
("data", "*"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class HCI_Intel_Reset_Command(hci.HCI_Command):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_Command.command(
|
||||||
|
fields=[("data", "*")],
|
||||||
|
return_parameters_fields=[
|
||||||
|
("status", hci.STATUS_SPEC),
|
||||||
|
("params", "*"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class Hci_Intel_Write_Device_Config_Command(hci.HCI_Command):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Functions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def intel_firmware_dir() -> pathlib.Path:
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
A path to a subdir of the project data dir for Intel firmware.
|
||||||
|
The directory is created if it doesn't exist.
|
||||||
|
"""
|
||||||
|
from bumble.drivers import project_data_dir
|
||||||
|
|
||||||
|
p = project_data_dir() / "firmware" / "intel"
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _find_binary_path(file_name: str) -> pathlib.Path | None:
|
||||||
|
# First check if an environment variable is set
|
||||||
|
if INTEL_FIRMWARE_DIR_ENV in os.environ:
|
||||||
|
if (
|
||||||
|
path := pathlib.Path(os.environ[INTEL_FIRMWARE_DIR_ENV]) / file_name
|
||||||
|
).is_file():
|
||||||
|
logger.debug(f"{file_name} found in env dir")
|
||||||
|
return path
|
||||||
|
|
||||||
|
# When the environment variable is set, don't look elsewhere
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Then, look where the firmware download tool writes by default
|
||||||
|
if (path := intel_firmware_dir() / file_name).is_file():
|
||||||
|
logger.debug(f"{file_name} found in project data dir")
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Then, look in the package's driver directory
|
||||||
|
if (path := pathlib.Path(__file__).parent / "intel_fw" / file_name).is_file():
|
||||||
|
logger.debug(f"{file_name} found in package dir")
|
||||||
|
return path
|
||||||
|
|
||||||
|
# On Linux, check the system's FW directory
|
||||||
|
if (
|
||||||
|
platform.system() == "Linux"
|
||||||
|
and (path := pathlib.Path(INTEL_LINUX_FIRMWARE_DIR) / file_name).is_file()
|
||||||
|
):
|
||||||
|
logger.debug(f"{file_name} found in Linux system FW dir")
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Finally look in the current directory
|
||||||
|
if (path := pathlib.Path.cwd() / file_name).is_file():
|
||||||
|
logger.debug(f"{file_name} found in CWD")
|
||||||
|
return path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tlv(data: bytes) -> list[tuple[ValueType, Any]]:
|
||||||
|
result: list[tuple[ValueType, Any]] = []
|
||||||
|
while len(data) >= 2:
|
||||||
|
value_type = ValueType(data[0])
|
||||||
|
value_length = data[1]
|
||||||
|
value = data[2 : 2 + value_length]
|
||||||
|
typed_value: Any
|
||||||
|
|
||||||
|
if value_type == ValueType.END:
|
||||||
|
break
|
||||||
|
|
||||||
|
if value_type in (ValueType.CNVI, ValueType.CNVR):
|
||||||
|
(v,) = struct.unpack("<I", value)
|
||||||
|
typed_value = (
|
||||||
|
(((v >> 0) & 0xF) << 12)
|
||||||
|
| (((v >> 4) & 0xF) << 0)
|
||||||
|
| (((v >> 8) & 0xF) << 4)
|
||||||
|
| (((v >> 24) & 0xF) << 8)
|
||||||
|
)
|
||||||
|
elif value_type == ValueType.HARDWARE_INFO:
|
||||||
|
(v,) = struct.unpack("<I", value)
|
||||||
|
typed_value = HardwareInfo(
|
||||||
|
HardwarePlatform((v >> 8) & 0xFF), HardwareVariant((v >> 16) & 0x3F)
|
||||||
|
)
|
||||||
|
elif value_type in (
|
||||||
|
ValueType.USB_VENDOR_ID,
|
||||||
|
ValueType.USB_PRODUCT_ID,
|
||||||
|
ValueType.DEVICE_REVISION,
|
||||||
|
):
|
||||||
|
(typed_value,) = struct.unpack("<H", value)
|
||||||
|
elif value_type == ValueType.CURRENT_MODE_OF_OPERATION:
|
||||||
|
typed_value = ModeOfOperation(value[0])
|
||||||
|
elif value_type in (
|
||||||
|
ValueType.BUILD_TYPE,
|
||||||
|
ValueType.BUILD_NUMBER,
|
||||||
|
ValueType.SECURE_BOOT,
|
||||||
|
ValueType.OTP_LOCK,
|
||||||
|
ValueType.API_LOCK,
|
||||||
|
ValueType.DEBUG_LOCK,
|
||||||
|
ValueType.SECURE_BOOT_ENGINE_TYPE,
|
||||||
|
):
|
||||||
|
typed_value = value[0]
|
||||||
|
elif value_type == ValueType.TIMESTAMP:
|
||||||
|
typed_value = Timestamp(value[0], value[1])
|
||||||
|
elif value_type == ValueType.FIRMWARE_BUILD:
|
||||||
|
typed_value = FirmwareBuild(value[0], Timestamp(value[1], value[2]))
|
||||||
|
elif value_type == ValueType.BLUETOOTH_ADDRESS:
|
||||||
|
typed_value = hci.Address(
|
||||||
|
value, address_type=hci.Address.PUBLIC_DEVICE_ADDRESS
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
typed_value = value
|
||||||
|
|
||||||
|
result.append((value_type, typed_value))
|
||||||
|
data = data[2 + value_length :]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Classes
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class DriverError(core.BaseBumbleError):
|
||||||
|
def __init__(self, message: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"IntelDriverError({self.message})"
|
||||||
|
|
||||||
|
|
||||||
|
class ValueType(utils.OpenIntEnum):
|
||||||
|
END = 0x00
|
||||||
|
CNVI = 0x10
|
||||||
|
CNVR = 0x11
|
||||||
|
HARDWARE_INFO = 0x12
|
||||||
|
DEVICE_REVISION = 0x16
|
||||||
|
CURRENT_MODE_OF_OPERATION = 0x1C
|
||||||
|
USB_VENDOR_ID = 0x17
|
||||||
|
USB_PRODUCT_ID = 0x18
|
||||||
|
TIMESTAMP = 0x1D
|
||||||
|
BUILD_TYPE = 0x1E
|
||||||
|
BUILD_NUMBER = 0x1F
|
||||||
|
SECURE_BOOT = 0x28
|
||||||
|
OTP_LOCK = 0x2A
|
||||||
|
API_LOCK = 0x2B
|
||||||
|
DEBUG_LOCK = 0x2C
|
||||||
|
FIRMWARE_BUILD = 0x2D
|
||||||
|
SECURE_BOOT_ENGINE_TYPE = 0x2F
|
||||||
|
BLUETOOTH_ADDRESS = 0x30
|
||||||
|
|
||||||
|
|
||||||
|
class HardwarePlatform(utils.OpenIntEnum):
|
||||||
|
INTEL_37 = 0x37
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareVariant(utils.OpenIntEnum):
|
||||||
|
# This is a just a partial list.
|
||||||
|
# Add other constants here as new hardware is encountered and tested.
|
||||||
|
TYPHOON_PEAK = 0x17
|
||||||
|
GALE_PEAK = 0x1C
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HardwareInfo:
|
||||||
|
platform: HardwarePlatform
|
||||||
|
variant: HardwareVariant
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Timestamp:
|
||||||
|
week: int
|
||||||
|
year: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class FirmwareBuild:
|
||||||
|
build_number: int
|
||||||
|
timestamp: Timestamp
|
||||||
|
|
||||||
|
|
||||||
|
class ModeOfOperation(utils.OpenIntEnum):
|
||||||
|
BOOTLOADER = 0x01
|
||||||
|
INTERMEDIATE = 0x02
|
||||||
|
OPERATIONAL = 0x03
|
||||||
|
|
||||||
|
|
||||||
|
class SecureBootEngineType(utils.OpenIntEnum):
|
||||||
|
RSA = 0x00
|
||||||
|
ECDSA = 0x01
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class BootParams:
|
||||||
|
css_header_offset: int
|
||||||
|
css_header_size: int
|
||||||
|
pki_offset: int
|
||||||
|
pki_size: int
|
||||||
|
sig_offset: int
|
||||||
|
sig_size: int
|
||||||
|
write_offset: int
|
||||||
|
|
||||||
|
|
||||||
|
_BOOT_PARAMS = {
|
||||||
|
SecureBootEngineType.RSA: BootParams(0, 128, 128, 256, 388, 256, 964),
|
||||||
|
SecureBootEngineType.ECDSA: BootParams(644, 128, 772, 96, 868, 96, 964),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Driver(common.Driver):
|
class Driver(common.Driver):
|
||||||
def __init__(self, host):
|
def __init__(self, host: Host) -> None:
|
||||||
self.host = host
|
self.host = host
|
||||||
|
self.max_in_flight_firmware_load_commands = 1
|
||||||
|
self.pending_firmware_load_commands: Deque[hci.HCI_Command] = (
|
||||||
|
collections.deque()
|
||||||
|
)
|
||||||
|
self.can_send_firmware_load_command = asyncio.Event()
|
||||||
|
self.can_send_firmware_load_command.set()
|
||||||
|
self.firmware_load_complete = asyncio.Event()
|
||||||
|
self.reset_complete = asyncio.Event()
|
||||||
|
|
||||||
|
# Parse configuration options from the driver name.
|
||||||
|
self.ddc_addon: Optional[bytes] = None
|
||||||
|
self.ddc_override: Optional[bytes] = None
|
||||||
|
driver = host.hci_metadata.get("driver")
|
||||||
|
if driver is not None and driver.startswith("intel/"):
|
||||||
|
for key, value in [
|
||||||
|
key_eq_value.split(":") for key_eq_value in driver[6:].split("+")
|
||||||
|
]:
|
||||||
|
if key == "ddc_addon":
|
||||||
|
self.ddc_addon = bytes.fromhex(value)
|
||||||
|
elif key == "ddc_override":
|
||||||
|
self.ddc_override = bytes.fromhex(value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check(host):
|
def check(host: Host) -> bool:
|
||||||
driver = host.hci_metadata.get("driver")
|
driver = host.hci_metadata.get("driver")
|
||||||
if driver == "intel":
|
if driver == "intel" or driver is not None and driver.startswith("intel/"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
vendor_id = host.hci_metadata.get("vendor_id")
|
vendor_id = host.hci_metadata.get("vendor_id")
|
||||||
@@ -85,18 +389,283 @@ class Driver(common.Driver):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def for_host(cls, host, force=False): # type: ignore
|
async def for_host(cls, host: Host, force: bool = False):
|
||||||
# Only instantiate this driver if explicitly selected
|
# Only instantiate this driver if explicitly selected
|
||||||
if not force and not cls.check(host):
|
if not force and not cls.check(host):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return cls(host)
|
return cls(host)
|
||||||
|
|
||||||
async def init_controller(self):
|
def on_packet(self, packet: bytes) -> None:
|
||||||
|
"""Handler for event packets that are received from an ACL channel"""
|
||||||
|
event = hci.HCI_Event.from_bytes(packet)
|
||||||
|
|
||||||
|
if not isinstance(event, hci.HCI_Command_Complete_Event):
|
||||||
|
self.host.on_hci_event_packet(event)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not event.return_parameters == hci.HCI_SUCCESS:
|
||||||
|
raise DriverError("HCI_Command_Complete_Event error")
|
||||||
|
|
||||||
|
if self.max_in_flight_firmware_load_commands != event.num_hci_command_packets:
|
||||||
|
logger.debug(
|
||||||
|
"max_in_flight_firmware_load_commands update: "
|
||||||
|
f"{event.num_hci_command_packets}"
|
||||||
|
)
|
||||||
|
self.max_in_flight_firmware_load_commands = event.num_hci_command_packets
|
||||||
|
logger.debug(f"event: {event}")
|
||||||
|
self.pending_firmware_load_commands.popleft()
|
||||||
|
in_flight = len(self.pending_firmware_load_commands)
|
||||||
|
logger.debug(f"event received, {in_flight} still in flight")
|
||||||
|
if in_flight < self.max_in_flight_firmware_load_commands:
|
||||||
|
self.can_send_firmware_load_command.set()
|
||||||
|
|
||||||
|
async def send_firmware_load_command(self, command: hci.HCI_Command) -> None:
|
||||||
|
# Wait until we can send.
|
||||||
|
await self.can_send_firmware_load_command.wait()
|
||||||
|
|
||||||
|
# Send the command and adjust counters.
|
||||||
|
self.host.send_hci_packet(command)
|
||||||
|
self.pending_firmware_load_commands.append(command)
|
||||||
|
in_flight = len(self.pending_firmware_load_commands)
|
||||||
|
if in_flight >= self.max_in_flight_firmware_load_commands:
|
||||||
|
logger.debug(f"max commands in flight reached [{in_flight}]")
|
||||||
|
self.can_send_firmware_load_command.clear()
|
||||||
|
|
||||||
|
async def send_firmware_data(self, data_type: int, data: bytes) -> None:
|
||||||
|
while data:
|
||||||
|
fragment_size = min(len(data), _MAX_FRAGMENT_SIZE)
|
||||||
|
fragment = data[:fragment_size]
|
||||||
|
data = data[fragment_size:]
|
||||||
|
|
||||||
|
await self.send_firmware_load_command(
|
||||||
|
Hci_Intel_Secure_Send_Command(data_type=data_type, data=fragment)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def load_firmware(self) -> None:
|
||||||
self.host.ready = True
|
self.host.ready = True
|
||||||
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
device_info = await self.read_device_info()
|
||||||
await self.host.send_command(
|
logger.debug(
|
||||||
Hci_Intel_DDC_Config_Write_Command(
|
"device info: \n%s",
|
||||||
params=HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD
|
"\n".join(
|
||||||
|
[
|
||||||
|
f" {value_type.name}: {value}"
|
||||||
|
for value_type, value in device_info.items()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if the firmware is already loaded.
|
||||||
|
if (
|
||||||
|
device_info.get(ValueType.CURRENT_MODE_OF_OPERATION)
|
||||||
|
== ModeOfOperation.OPERATIONAL
|
||||||
|
):
|
||||||
|
logger.debug("firmware already loaded")
|
||||||
|
return
|
||||||
|
|
||||||
|
# We only support some platforms and variants.
|
||||||
|
hardware_info = device_info.get(ValueType.HARDWARE_INFO)
|
||||||
|
if hardware_info is None:
|
||||||
|
raise DriverError("hardware info missing")
|
||||||
|
if hardware_info.platform != HardwarePlatform.INTEL_37:
|
||||||
|
raise DriverError("hardware platform not supported")
|
||||||
|
if hardware_info.variant not in (
|
||||||
|
HardwareVariant.TYPHOON_PEAK,
|
||||||
|
HardwareVariant.GALE_PEAK,
|
||||||
|
):
|
||||||
|
raise DriverError("hardware variant not supported")
|
||||||
|
|
||||||
|
# Compute the firmware name.
|
||||||
|
if ValueType.CNVI not in device_info or ValueType.CNVR not in device_info:
|
||||||
|
raise DriverError("insufficient device info, missing CNVI or CNVR")
|
||||||
|
|
||||||
|
firmware_base_name = (
|
||||||
|
"ibt-"
|
||||||
|
f"{device_info[ValueType.CNVI]:04X}-"
|
||||||
|
f"{device_info[ValueType.CNVR]:04X}"
|
||||||
|
)
|
||||||
|
logger.debug(f"FW base name: {firmware_base_name}")
|
||||||
|
|
||||||
|
firmware_name = f"{firmware_base_name}.sfi"
|
||||||
|
firmware_path = _find_binary_path(firmware_name)
|
||||||
|
if not firmware_path:
|
||||||
|
logger.warning(f"Firmware file {firmware_name} not found")
|
||||||
|
logger.warning("See https://google.github.io/bumble/drivers/intel.html")
|
||||||
|
return None
|
||||||
|
logger.debug(f"loading firmware from {firmware_path}")
|
||||||
|
firmware_image = firmware_path.read_bytes()
|
||||||
|
|
||||||
|
engine_type = device_info.get(ValueType.SECURE_BOOT_ENGINE_TYPE)
|
||||||
|
if engine_type is None:
|
||||||
|
raise DriverError("secure boot engine type missing")
|
||||||
|
if engine_type not in _BOOT_PARAMS:
|
||||||
|
raise DriverError("secure boot engine type not supported")
|
||||||
|
|
||||||
|
boot_params = _BOOT_PARAMS[engine_type]
|
||||||
|
if len(firmware_image) < boot_params.write_offset:
|
||||||
|
raise DriverError("firmware image too small")
|
||||||
|
|
||||||
|
# Register to receive vendor events.
|
||||||
|
def on_vendor_event(event: hci.HCI_Vendor_Event):
|
||||||
|
logger.debug(f"vendor event: {event}")
|
||||||
|
event_type = event.parameters[0]
|
||||||
|
if event_type == 0x02:
|
||||||
|
# Boot event
|
||||||
|
logger.debug("boot complete")
|
||||||
|
self.reset_complete.set()
|
||||||
|
elif event_type == 0x06:
|
||||||
|
# Firmware load event
|
||||||
|
logger.debug("download complete")
|
||||||
|
self.firmware_load_complete.set()
|
||||||
|
else:
|
||||||
|
logger.debug(f"ignoring vendor event type {event_type}")
|
||||||
|
|
||||||
|
self.host.on("vendor_event", on_vendor_event)
|
||||||
|
|
||||||
|
# We need to temporarily intercept packets from the controller,
|
||||||
|
# because they are formatted as HCI event packets but are received
|
||||||
|
# on the ACL channel, so the host parser would get confused.
|
||||||
|
saved_on_packet = self.host.on_packet
|
||||||
|
self.host.on_packet = self.on_packet # type: ignore
|
||||||
|
self.firmware_load_complete.clear()
|
||||||
|
|
||||||
|
# Send the CSS header
|
||||||
|
data = firmware_image[
|
||||||
|
boot_params.css_header_offset : boot_params.css_header_offset
|
||||||
|
+ boot_params.css_header_size
|
||||||
|
]
|
||||||
|
await self.send_firmware_data(0x00, data)
|
||||||
|
|
||||||
|
# Send the PKI header
|
||||||
|
data = firmware_image[
|
||||||
|
boot_params.pki_offset : boot_params.pki_offset + boot_params.pki_size
|
||||||
|
]
|
||||||
|
await self.send_firmware_data(0x03, data)
|
||||||
|
|
||||||
|
# Send the Signature header
|
||||||
|
data = firmware_image[
|
||||||
|
boot_params.sig_offset : boot_params.sig_offset + boot_params.sig_size
|
||||||
|
]
|
||||||
|
await self.send_firmware_data(0x02, data)
|
||||||
|
|
||||||
|
# Send the rest of the image.
|
||||||
|
# The payload consists of command objects, which are sent when they add up
|
||||||
|
# to a multiple of 4 bytes.
|
||||||
|
boot_address = 0
|
||||||
|
offset = boot_params.write_offset
|
||||||
|
fragment_size = 0
|
||||||
|
while offset + 3 < len(firmware_image):
|
||||||
|
(command_opcode,) = struct.unpack_from(
|
||||||
|
"<H", firmware_image, offset + fragment_size
|
||||||
|
)
|
||||||
|
command_size = firmware_image[offset + fragment_size + 2]
|
||||||
|
if command_opcode == HCI_INTEL_WRITE_BOOT_PARAMS_COMMAND:
|
||||||
|
(boot_address,) = struct.unpack_from(
|
||||||
|
"<I", firmware_image, offset + fragment_size + 3
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"found HCI_INTEL_WRITE_BOOT_PARAMS_COMMAND, "
|
||||||
|
f"boot_address={boot_address}"
|
||||||
|
)
|
||||||
|
fragment_size += 3 + command_size
|
||||||
|
if fragment_size % 4 == 0:
|
||||||
|
await self.send_firmware_data(
|
||||||
|
0x01, firmware_image[offset : offset + fragment_size]
|
||||||
|
)
|
||||||
|
logger.debug(f"sent {fragment_size} bytes")
|
||||||
|
offset += fragment_size
|
||||||
|
fragment_size = 0
|
||||||
|
|
||||||
|
# Wait for the firmware loading to be complete.
|
||||||
|
logger.debug("waiting for firmware to be loaded")
|
||||||
|
await self.firmware_load_complete.wait()
|
||||||
|
logger.debug("firmware loaded")
|
||||||
|
|
||||||
|
# Restore the original packet handler.
|
||||||
|
self.host.on_packet = saved_on_packet # type: ignore
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
self.reset_complete.clear()
|
||||||
|
self.host.send_hci_packet(
|
||||||
|
HCI_Intel_Reset_Command(
|
||||||
|
reset_type=0x00,
|
||||||
|
patch_enable=0x01,
|
||||||
|
ddc_reload=0x00,
|
||||||
|
boot_option=0x01,
|
||||||
|
boot_address=boot_address,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
logger.debug("waiting for reset completion")
|
||||||
|
await self.reset_complete.wait()
|
||||||
|
logger.debug("reset complete")
|
||||||
|
|
||||||
|
# Load the device config if there is one.
|
||||||
|
if self.ddc_override:
|
||||||
|
logger.debug("loading overridden DDC")
|
||||||
|
await self.load_device_config(self.ddc_override)
|
||||||
|
else:
|
||||||
|
ddc_name = f"{firmware_base_name}.ddc"
|
||||||
|
ddc_path = _find_binary_path(ddc_name)
|
||||||
|
if ddc_path:
|
||||||
|
logger.debug(f"loading DDC from {ddc_path}")
|
||||||
|
ddc_data = ddc_path.read_bytes()
|
||||||
|
await self.load_device_config(ddc_data)
|
||||||
|
if self.ddc_addon:
|
||||||
|
logger.debug("loading DDC addon")
|
||||||
|
await self.load_device_config(self.ddc_addon)
|
||||||
|
|
||||||
|
async def load_device_config(self, ddc_data: bytes) -> None:
|
||||||
|
while ddc_data:
|
||||||
|
ddc_len = 1 + ddc_data[0]
|
||||||
|
ddc_payload = ddc_data[:ddc_len]
|
||||||
|
await self.host.send_command(
|
||||||
|
Hci_Intel_Write_Device_Config_Command(data=ddc_payload)
|
||||||
|
)
|
||||||
|
ddc_data = ddc_data[ddc_len:]
|
||||||
|
|
||||||
|
async def reboot_bootloader(self) -> None:
|
||||||
|
self.host.send_hci_packet(
|
||||||
|
HCI_Intel_Reset_Command(
|
||||||
|
reset_type=0x01,
|
||||||
|
patch_enable=0x01,
|
||||||
|
ddc_reload=0x01,
|
||||||
|
boot_option=0x00,
|
||||||
|
boot_address=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(_POST_RESET_DELAY)
|
||||||
|
|
||||||
|
async def read_device_info(self) -> dict[ValueType, Any]:
|
||||||
|
self.host.ready = True
|
||||||
|
response = await self.host.send_command(hci.HCI_Reset_Command())
|
||||||
|
if not (
|
||||||
|
isinstance(response, hci.HCI_Command_Complete_Event)
|
||||||
|
and response.return_parameters
|
||||||
|
in (hci.HCI_UNKNOWN_HCI_COMMAND_ERROR, hci.HCI_SUCCESS)
|
||||||
|
):
|
||||||
|
# When the controller is in operational mode, the response is a
|
||||||
|
# successful response.
|
||||||
|
# When the controller is in bootloader mode,
|
||||||
|
# HCI_UNKNOWN_HCI_COMMAND_ERROR is the expected response. Anything
|
||||||
|
# else is a failure.
|
||||||
|
logger.warning(f"unexpected response: {response}")
|
||||||
|
raise DriverError("unexpected HCI response")
|
||||||
|
|
||||||
|
# Read the firmware version.
|
||||||
|
response = await self.host.send_command(
|
||||||
|
HCI_Intel_Read_Version_Command(param0=0xFF)
|
||||||
|
)
|
||||||
|
if not isinstance(response, hci.HCI_Command_Complete_Event):
|
||||||
|
raise DriverError("unexpected HCI response")
|
||||||
|
|
||||||
|
if response.return_parameters.status != 0: # type: ignore
|
||||||
|
raise DriverError("HCI_Intel_Read_Version_Command error")
|
||||||
|
|
||||||
|
tlvs = _parse_tlv(response.return_parameters.tlv) # type: ignore
|
||||||
|
|
||||||
|
# Convert the list to a dict. That's Ok here because we only expect each type
|
||||||
|
# to appear just once.
|
||||||
|
return dict(tlvs)
|
||||||
|
|
||||||
|
async def init_controller(self):
|
||||||
|
await self.load_firmware()
|
||||||
|
|||||||
+40
-7
@@ -28,12 +28,15 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import (
|
from typing import (
|
||||||
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
Dict,
|
Dict,
|
||||||
Iterable,
|
Iterable,
|
||||||
List,
|
List,
|
||||||
Optional,
|
Optional,
|
||||||
Sequence,
|
Sequence,
|
||||||
|
SupportsBytes,
|
||||||
|
Type,
|
||||||
Union,
|
Union,
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
@@ -41,6 +44,7 @@ from typing import (
|
|||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import BaseBumbleError, UUID
|
from bumble.core import BaseBumbleError, UUID
|
||||||
from bumble.att import Attribute, AttributeValue
|
from bumble.att import Attribute, AttributeValue
|
||||||
|
from bumble.utils import ByteSerializable
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.gatt_client import AttributeProxy
|
from bumble.gatt_client import AttributeProxy
|
||||||
@@ -275,6 +279,13 @@ GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCC, 'Sou
|
|||||||
GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, 'Available Audio Contexts')
|
GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, 'Available Audio Contexts')
|
||||||
GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, 'Supported Audio Contexts')
|
GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, 'Supported Audio Contexts')
|
||||||
|
|
||||||
|
# Gaming Audio Service (GMAS)
|
||||||
|
GATT_GMAP_ROLE_CHARACTERISTIC = UUID.from_16_bits(0x2C00, 'GMAP Role')
|
||||||
|
GATT_UGG_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2C01, 'UGG Features')
|
||||||
|
GATT_UGT_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2C02, 'UGT Features')
|
||||||
|
GATT_BGS_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2C03, 'BGS Features')
|
||||||
|
GATT_BGR_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2C04, 'BGR Features')
|
||||||
|
|
||||||
# Hearing Access Service
|
# Hearing Access Service
|
||||||
GATT_HEARING_AID_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2BDA, 'Hearing Aid Features')
|
GATT_HEARING_AID_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2BDA, 'Hearing Aid Features')
|
||||||
GATT_HEARING_AID_PRESET_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BDB, 'Hearing Aid Preset Control Point')
|
GATT_HEARING_AID_PRESET_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BDB, 'Hearing Aid Preset Control Point')
|
||||||
@@ -343,7 +354,7 @@ class Service(Attribute):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
uuid: Union[str, UUID],
|
uuid: Union[str, UUID],
|
||||||
characteristics: List[Characteristic],
|
characteristics: Iterable[Characteristic],
|
||||||
primary=True,
|
primary=True,
|
||||||
included_services: Iterable[Service] = (),
|
included_services: Iterable[Service] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -362,7 +373,7 @@ class Service(Attribute):
|
|||||||
)
|
)
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
self.included_services = list(included_services)
|
self.included_services = list(included_services)
|
||||||
self.characteristics = characteristics[:]
|
self.characteristics = list(characteristics)
|
||||||
self.primary = primary
|
self.primary = primary
|
||||||
|
|
||||||
def get_advertising_data(self) -> Optional[bytes]:
|
def get_advertising_data(self) -> Optional[bytes]:
|
||||||
@@ -393,7 +404,7 @@ class TemplateService(Service):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
characteristics: List[Characteristic],
|
characteristics: Iterable[Characteristic],
|
||||||
primary: bool = True,
|
primary: bool = True,
|
||||||
included_services: Iterable[Service] = (),
|
included_services: Iterable[Service] = (),
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -410,7 +421,7 @@ class IncludedServiceDeclaration(Attribute):
|
|||||||
|
|
||||||
def __init__(self, service: Service) -> None:
|
def __init__(self, service: Service) -> None:
|
||||||
declaration_bytes = struct.pack(
|
declaration_bytes = struct.pack(
|
||||||
'<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
|
'<HH2s', service.handle, service.end_group_handle, bytes(service.uuid)
|
||||||
)
|
)
|
||||||
super().__init__(
|
super().__init__(
|
||||||
GATT_INCLUDE_ATTRIBUTE_TYPE, Attribute.READABLE, declaration_bytes
|
GATT_INCLUDE_ATTRIBUTE_TYPE, Attribute.READABLE, declaration_bytes
|
||||||
@@ -490,7 +501,7 @@ class Characteristic(Attribute):
|
|||||||
uuid: Union[str, bytes, UUID],
|
uuid: Union[str, bytes, UUID],
|
||||||
properties: Characteristic.Properties,
|
properties: Characteristic.Properties,
|
||||||
permissions: Union[str, Attribute.Permissions],
|
permissions: Union[str, Attribute.Permissions],
|
||||||
value: Union[str, bytes, CharacteristicValue] = b'',
|
value: Any = b'',
|
||||||
descriptors: Sequence[Descriptor] = (),
|
descriptors: Sequence[Descriptor] = (),
|
||||||
):
|
):
|
||||||
super().__init__(uuid, permissions, value)
|
super().__init__(uuid, permissions, value)
|
||||||
@@ -525,7 +536,11 @@ class CharacteristicDeclaration(Attribute):
|
|||||||
|
|
||||||
characteristic: Characteristic
|
characteristic: Characteristic
|
||||||
|
|
||||||
def __init__(self, characteristic: Characteristic, value_handle: int) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
characteristic: Characteristic,
|
||||||
|
value_handle: int,
|
||||||
|
) -> None:
|
||||||
declaration_bytes = (
|
declaration_bytes = (
|
||||||
struct.pack('<BH', characteristic.properties, value_handle)
|
struct.pack('<BH', characteristic.properties, value_handle)
|
||||||
+ characteristic.uuid.to_pdu_bytes()
|
+ characteristic.uuid.to_pdu_bytes()
|
||||||
@@ -705,7 +720,7 @@ class MappedCharacteristicAdapter(PackedCharacteristicAdapter):
|
|||||||
'''
|
'''
|
||||||
Adapter that packs/unpacks characteristic values according to a standard
|
Adapter that packs/unpacks characteristic values according to a standard
|
||||||
Python `struct` format.
|
Python `struct` format.
|
||||||
The adapted `read_value` and `write_value` methods return/accept aa dictionary which
|
The adapted `read_value` and `write_value` methods return/accept a dictionary which
|
||||||
is packed/unpacked according to format, with the arguments extracted from the
|
is packed/unpacked according to format, with the arguments extracted from the
|
||||||
dictionary by key, in the same order as they occur in the `keys` parameter.
|
dictionary by key, in the same order as they occur in the `keys` parameter.
|
||||||
'''
|
'''
|
||||||
@@ -735,6 +750,24 @@ class UTF8CharacteristicAdapter(CharacteristicAdapter):
|
|||||||
return value.decode('utf-8')
|
return value.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class SerializableCharacteristicAdapter(CharacteristicAdapter):
|
||||||
|
'''
|
||||||
|
Adapter that converts any class to/from bytes using the class'
|
||||||
|
`to_bytes` and `__bytes__` methods, respectively.
|
||||||
|
'''
|
||||||
|
|
||||||
|
def __init__(self, characteristic, cls: Type[ByteSerializable]):
|
||||||
|
super().__init__(characteristic)
|
||||||
|
self.cls = cls
|
||||||
|
|
||||||
|
def encode_value(self, value: SupportsBytes) -> bytes:
|
||||||
|
return bytes(value)
|
||||||
|
|
||||||
|
def decode_value(self, value: bytes) -> Any:
|
||||||
|
return self.cls.from_bytes(value)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Descriptor(Attribute):
|
class Descriptor(Attribute):
|
||||||
'''
|
'''
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ class Client:
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
|
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
|
||||||
)
|
)
|
||||||
self.send_gatt_pdu(command.to_bytes())
|
self.send_gatt_pdu(bytes(command))
|
||||||
|
|
||||||
async def send_request(self, request: ATT_PDU):
|
async def send_request(self, request: ATT_PDU):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -310,7 +310,7 @@ class Client:
|
|||||||
self.pending_request = request
|
self.pending_request = request
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.send_gatt_pdu(request.to_bytes())
|
self.send_gatt_pdu(bytes(request))
|
||||||
response = await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
self.pending_response, GATT_REQUEST_TIMEOUT
|
self.pending_response, GATT_REQUEST_TIMEOUT
|
||||||
)
|
)
|
||||||
@@ -328,7 +328,7 @@ class Client:
|
|||||||
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
|
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
|
||||||
f'{confirmation}'
|
f'{confirmation}'
|
||||||
)
|
)
|
||||||
self.send_gatt_pdu(confirmation.to_bytes())
|
self.send_gatt_pdu(bytes(confirmation))
|
||||||
|
|
||||||
async def request_mtu(self, mtu: int) -> int:
|
async def request_mtu(self, mtu: int) -> int:
|
||||||
# Check the range
|
# Check the range
|
||||||
@@ -898,6 +898,12 @@ class Client:
|
|||||||
) and subscriber in subscribers:
|
) and subscriber in subscribers:
|
||||||
subscribers.remove(subscriber)
|
subscribers.remove(subscriber)
|
||||||
|
|
||||||
|
# The characteristic itself is added as subscriber. If it is the
|
||||||
|
# last remaining subscriber, we remove it, such that the clean up
|
||||||
|
# works correctly. Otherwise the CCCD never is set back to 0.
|
||||||
|
if len(subscribers) == 1 and characteristic in subscribers:
|
||||||
|
subscribers.remove(characteristic)
|
||||||
|
|
||||||
# Cleanup if we removed the last one
|
# Cleanup if we removed the last one
|
||||||
if not subscribers:
|
if not subscribers:
|
||||||
del subscriber_set[characteristic.handle]
|
del subscriber_set[characteristic.handle]
|
||||||
|
|||||||
+14
-3
@@ -28,7 +28,17 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import struct
|
import struct
|
||||||
from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
|
from typing import (
|
||||||
|
Dict,
|
||||||
|
Iterable,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
TypeVar,
|
||||||
|
Type,
|
||||||
|
Union,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
@@ -68,6 +78,7 @@ from bumble.gatt import (
|
|||||||
GATT_REQUEST_TIMEOUT,
|
GATT_REQUEST_TIMEOUT,
|
||||||
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
||||||
Characteristic,
|
Characteristic,
|
||||||
|
CharacteristicAdapter,
|
||||||
CharacteristicDeclaration,
|
CharacteristicDeclaration,
|
||||||
CharacteristicValue,
|
CharacteristicValue,
|
||||||
IncludedServiceDeclaration,
|
IncludedServiceDeclaration,
|
||||||
@@ -353,7 +364,7 @@ class Server(EventEmitter):
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
f'GATT Response from server: [0x{connection.handle:04X}] {response}'
|
f'GATT Response from server: [0x{connection.handle:04X}] {response}'
|
||||||
)
|
)
|
||||||
self.send_gatt_pdu(connection.handle, response.to_bytes())
|
self.send_gatt_pdu(connection.handle, bytes(response))
|
||||||
|
|
||||||
async def notify_subscriber(
|
async def notify_subscriber(
|
||||||
self,
|
self,
|
||||||
@@ -450,7 +461,7 @@ class Server(EventEmitter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.send_gatt_pdu(connection.handle, indication.to_bytes())
|
self.send_gatt_pdu(connection.handle, bytes(indication))
|
||||||
await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
|
await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
|
||||||
except asyncio.TimeoutError as error:
|
except asyncio.TimeoutError as error:
|
||||||
logger.warning(color('!!! GATT Indicate timeout', 'red'))
|
logger.warning(color('!!! GATT Indicate timeout', 'red'))
|
||||||
|
|||||||
+844
-65
File diff suppressed because it is too large
Load Diff
+20
-17
@@ -141,7 +141,7 @@ class HfFeature(enum.IntFlag):
|
|||||||
"""
|
"""
|
||||||
HF supported features (AT+BRSF=) (normative).
|
HF supported features (AT+BRSF=) (normative).
|
||||||
|
|
||||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
Hands-Free Profile v1.9, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
EC_NR = 0x001 # Echo Cancel & Noise reduction
|
EC_NR = 0x001 # Echo Cancel & Noise reduction
|
||||||
@@ -155,14 +155,14 @@ class HfFeature(enum.IntFlag):
|
|||||||
HF_INDICATORS = 0x100
|
HF_INDICATORS = 0x100
|
||||||
ESCO_S4_SETTINGS_SUPPORTED = 0x200
|
ESCO_S4_SETTINGS_SUPPORTED = 0x200
|
||||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x400
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x400
|
||||||
VOICE_RECOGNITION_TEST = 0x800
|
VOICE_RECOGNITION_TEXT = 0x800
|
||||||
|
|
||||||
|
|
||||||
class AgFeature(enum.IntFlag):
|
class AgFeature(enum.IntFlag):
|
||||||
"""
|
"""
|
||||||
AG supported features (+BRSF:) (normative).
|
AG supported features (+BRSF:) (normative).
|
||||||
|
|
||||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
Hands-Free Profile v1.9, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
THREE_WAY_CALLING = 0x001
|
THREE_WAY_CALLING = 0x001
|
||||||
@@ -178,7 +178,7 @@ class AgFeature(enum.IntFlag):
|
|||||||
HF_INDICATORS = 0x400
|
HF_INDICATORS = 0x400
|
||||||
ESCO_S4_SETTINGS_SUPPORTED = 0x800
|
ESCO_S4_SETTINGS_SUPPORTED = 0x800
|
||||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x1000
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x1000
|
||||||
VOICE_RECOGNITION_TEST = 0x2000
|
VOICE_RECOGNITION_TEXT = 0x2000
|
||||||
|
|
||||||
|
|
||||||
class AudioCodec(enum.IntEnum):
|
class AudioCodec(enum.IntEnum):
|
||||||
@@ -1390,6 +1390,7 @@ class AgProtocol(pyee.EventEmitter):
|
|||||||
|
|
||||||
def _on_bac(self, *args) -> None:
|
def _on_bac(self, *args) -> None:
|
||||||
self.supported_audio_codecs = [AudioCodec(int(value)) for value in args]
|
self.supported_audio_codecs = [AudioCodec(int(value)) for value in args]
|
||||||
|
self.emit('supported_audio_codecs', self.supported_audio_codecs)
|
||||||
self.send_ok()
|
self.send_ok()
|
||||||
|
|
||||||
def _on_bcs(self, codec: bytes) -> None:
|
def _on_bcs(self, codec: bytes) -> None:
|
||||||
@@ -1618,7 +1619,7 @@ class ProfileVersion(enum.IntEnum):
|
|||||||
"""
|
"""
|
||||||
Profile version (normative).
|
Profile version (normative).
|
||||||
|
|
||||||
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
Hands-Free Profile v1.8, 6.3 SDP Interoperability Requirements.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
V1_5 = 0x0105
|
V1_5 = 0x0105
|
||||||
@@ -1632,7 +1633,7 @@ class HfSdpFeature(enum.IntFlag):
|
|||||||
"""
|
"""
|
||||||
HF supported features (normative).
|
HF supported features (normative).
|
||||||
|
|
||||||
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
Hands-Free Profile v1.9, 6.3 SDP Interoperability Requirements.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
EC_NR = 0x01 # Echo Cancel & Noise reduction
|
EC_NR = 0x01 # Echo Cancel & Noise reduction
|
||||||
@@ -1640,16 +1641,17 @@ class HfSdpFeature(enum.IntFlag):
|
|||||||
CLI_PRESENTATION_CAPABILITY = 0x04
|
CLI_PRESENTATION_CAPABILITY = 0x04
|
||||||
VOICE_RECOGNITION_ACTIVATION = 0x08
|
VOICE_RECOGNITION_ACTIVATION = 0x08
|
||||||
REMOTE_VOLUME_CONTROL = 0x10
|
REMOTE_VOLUME_CONTROL = 0x10
|
||||||
WIDE_BAND = 0x20 # Wide band speech
|
WIDE_BAND_SPEECH = 0x20
|
||||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||||
VOICE_RECOGNITION_TEST = 0x80
|
VOICE_RECOGNITION_TEXT = 0x80
|
||||||
|
SUPER_WIDE_BAND = 0x100
|
||||||
|
|
||||||
|
|
||||||
class AgSdpFeature(enum.IntFlag):
|
class AgSdpFeature(enum.IntFlag):
|
||||||
"""
|
"""
|
||||||
AG supported features (normative).
|
AG supported features (normative).
|
||||||
|
|
||||||
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
Hands-Free Profile v1.9, 6.3 SDP Interoperability Requirements.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
THREE_WAY_CALLING = 0x01
|
THREE_WAY_CALLING = 0x01
|
||||||
@@ -1657,9 +1659,10 @@ class AgSdpFeature(enum.IntFlag):
|
|||||||
VOICE_RECOGNITION_FUNCTION = 0x04
|
VOICE_RECOGNITION_FUNCTION = 0x04
|
||||||
IN_BAND_RING_TONE_CAPABILITY = 0x08
|
IN_BAND_RING_TONE_CAPABILITY = 0x08
|
||||||
VOICE_TAG = 0x10 # Attach a number to voice tag
|
VOICE_TAG = 0x10 # Attach a number to voice tag
|
||||||
WIDE_BAND = 0x20 # Wide band speech
|
WIDE_BAND_SPEECH = 0x20
|
||||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||||
VOICE_RECOGNITION_TEST = 0x80
|
VOICE_RECOGNITION_TEXT = 0x80
|
||||||
|
SUPER_WIDE_BAND_SPEED_SPEECH = 0x100
|
||||||
|
|
||||||
|
|
||||||
def make_hf_sdp_records(
|
def make_hf_sdp_records(
|
||||||
@@ -1692,11 +1695,11 @@ def make_hf_sdp_records(
|
|||||||
in configuration.supported_hf_features
|
in configuration.supported_hf_features
|
||||||
):
|
):
|
||||||
hf_supported_features |= HfSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
hf_supported_features |= HfSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||||
if HfFeature.VOICE_RECOGNITION_TEST in configuration.supported_hf_features:
|
if HfFeature.VOICE_RECOGNITION_TEXT in configuration.supported_hf_features:
|
||||||
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEST
|
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEXT
|
||||||
|
|
||||||
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
||||||
hf_supported_features |= HfSdpFeature.WIDE_BAND
|
hf_supported_features |= HfSdpFeature.WIDE_BAND_SPEECH
|
||||||
|
|
||||||
return [
|
return [
|
||||||
sdp.ServiceAttribute(
|
sdp.ServiceAttribute(
|
||||||
@@ -1772,14 +1775,14 @@ def make_ag_sdp_records(
|
|||||||
in configuration.supported_ag_features
|
in configuration.supported_ag_features
|
||||||
):
|
):
|
||||||
ag_supported_features |= AgSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
ag_supported_features |= AgSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||||
if AgFeature.VOICE_RECOGNITION_TEST in configuration.supported_ag_features:
|
if AgFeature.VOICE_RECOGNITION_TEXT in configuration.supported_ag_features:
|
||||||
ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_TEST
|
ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_TEXT
|
||||||
if AgFeature.IN_BAND_RING_TONE_CAPABILITY in configuration.supported_ag_features:
|
if AgFeature.IN_BAND_RING_TONE_CAPABILITY in configuration.supported_ag_features:
|
||||||
ag_supported_features |= AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
|
ag_supported_features |= AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
|
||||||
if AgFeature.VOICE_RECOGNITION_FUNCTION in configuration.supported_ag_features:
|
if AgFeature.VOICE_RECOGNITION_FUNCTION in configuration.supported_ag_features:
|
||||||
ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_FUNCTION
|
ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_FUNCTION
|
||||||
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
||||||
ag_supported_features |= AgSdpFeature.WIDE_BAND
|
ag_supported_features |= AgSdpFeature.WIDE_BAND_SPEECH
|
||||||
|
|
||||||
return [
|
return [
|
||||||
sdp.ServiceAttribute(
|
sdp.ServiceAttribute(
|
||||||
|
|||||||
+314
-65
@@ -1,4 +1,4 @@
|
|||||||
# Copyright 2021-2022 Google LLC
|
# Copyright 2021-2025 Google LLC
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -34,6 +34,8 @@ from typing import (
|
|||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
import pyee
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.l2cap import L2CAP_PDU
|
from bumble.l2cap import L2CAP_PDU
|
||||||
from bumble.snoop import Snooper
|
from bumble.snoop import Snooper
|
||||||
@@ -59,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
|
max_packet_size: int
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -68,40 +82,105 @@ class AclPacketQueue:
|
|||||||
max_in_flight: int,
|
max_in_flight: int,
|
||||||
send: Callable[[hci.HCI_Packet], None],
|
send: Callable[[hci.HCI_Packet], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
self.max_packet_size = max_packet_size
|
self.max_packet_size = max_packet_size
|
||||||
self.max_in_flight = max_in_flight
|
self.max_in_flight = max_in_flight
|
||||||
self.in_flight = 0
|
self._in_flight = 0 # Total number of packets in flight across all connections
|
||||||
self.send = send
|
self._in_flight_per_connection: dict[int, int] = collections.defaultdict(
|
||||||
self.packets: Deque[hci.HCI_AclDataPacket] = collections.deque()
|
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:
|
@property
|
||||||
self.packets.appendleft(packet)
|
def queued(self) -> int:
|
||||||
self.check_queue()
|
"""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(
|
logger.debug(
|
||||||
f'{self.in_flight} ACL packets in flight, '
|
f'{self._in_flight} packets in flight, '
|
||||||
f'{len(self.packets)} in queue'
|
f'{len(self._packets)} in queue'
|
||||||
)
|
)
|
||||||
|
|
||||||
def check_queue(self) -> None:
|
def flush(self, connection_handle: int) -> None:
|
||||||
while self.packets and self.in_flight < self.max_in_flight:
|
"""
|
||||||
packet = self.packets.pop()
|
Remove all packets associated with a connection.
|
||||||
self.send(packet)
|
|
||||||
self.in_flight += 1
|
|
||||||
|
|
||||||
def on_packets_completed(self, packet_count: int) -> None:
|
All packets associated with the connection that are in flight are implicitly
|
||||||
if packet_count > self.in_flight:
|
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(
|
logger.warning(
|
||||||
color(
|
f'received completion for unknown connection {connection_handle}'
|
||||||
'!!! {packet_count} completed but only '
|
|
||||||
f'{self.in_flight} in flight'
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
packet_count = self.in_flight
|
return
|
||||||
|
|
||||||
self.in_flight -= packet_count
|
in_flight_for_connection = self._in_flight_per_connection[connection_handle]
|
||||||
self.check_queue()
|
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')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -114,7 +193,7 @@ class Connection:
|
|||||||
self.peer_address = peer_address
|
self.peer_address = peer_address
|
||||||
self.assembler = hci.HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.assembler = hci.HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
acl_packet_queue: Optional[AclPacketQueue] = (
|
acl_packet_queue: Optional[DataPacketQueue] = (
|
||||||
host.le_acl_packet_queue
|
host.le_acl_packet_queue
|
||||||
if transport == BT_LE_TRANSPORT
|
if transport == BT_LE_TRANSPORT
|
||||||
else host.acl_packet_queue
|
else host.acl_packet_queue
|
||||||
@@ -129,28 +208,37 @@ class Connection:
|
|||||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||||
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
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
|
@dataclasses.dataclass
|
||||||
class ScoLink:
|
class ScoLink:
|
||||||
peer_address: hci.Address
|
peer_address: hci.Address
|
||||||
handle: int
|
connection_handle: int
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class CisLink:
|
class IsoLink:
|
||||||
peer_address: hci.Address
|
|
||||||
handle: int
|
handle: int
|
||||||
|
packet_queue: DataPacketQueue = dataclasses.field(repr=False)
|
||||||
|
packet_sequence_number: int = 0
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Host(AbortableEventEmitter):
|
class Host(AbortableEventEmitter):
|
||||||
connections: Dict[int, Connection]
|
connections: Dict[int, Connection]
|
||||||
cis_links: Dict[int, CisLink]
|
cis_links: Dict[int, IsoLink]
|
||||||
|
bis_links: Dict[int, IsoLink]
|
||||||
sco_links: Dict[int, ScoLink]
|
sco_links: Dict[int, ScoLink]
|
||||||
acl_packet_queue: Optional[AclPacketQueue] = None
|
bigs: dict[int, set[int]] = {} # BIG Handle to BIS Handles
|
||||||
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_sink: Optional[TransportSink] = None
|
||||||
hci_metadata: Dict[str, Any]
|
hci_metadata: Dict[str, Any]
|
||||||
long_term_key_provider: Optional[
|
long_term_key_provider: Optional[
|
||||||
@@ -169,6 +257,7 @@ class Host(AbortableEventEmitter):
|
|||||||
self.ready = False # True when we can accept incoming packets
|
self.ready = False # True when we can accept incoming packets
|
||||||
self.connections = {} # Connections, by connection handle
|
self.connections = {} # Connections, by connection handle
|
||||||
self.cis_links = {} # CIS links, 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.sco_links = {} # SCO links, by connection handle
|
||||||
self.pending_command = None
|
self.pending_command = None
|
||||||
self.pending_response: Optional[asyncio.Future[Any]] = None
|
self.pending_response: Optional[asyncio.Future[Any]] = None
|
||||||
@@ -199,7 +288,7 @@ class Host(AbortableEventEmitter):
|
|||||||
check_address_type: bool = False,
|
check_address_type: bool = False,
|
||||||
) -> Optional[Connection]:
|
) -> Optional[Connection]:
|
||||||
for connection in self.connections.values():
|
for connection in self.connections.values():
|
||||||
if connection.peer_address.to_bytes() == bd_addr.to_bytes():
|
if bytes(connection.peer_address) == bytes(bd_addr):
|
||||||
if (
|
if (
|
||||||
check_address_type
|
check_address_type
|
||||||
and connection.peer_address.address_type != bd_addr.address_type
|
and connection.peer_address.address_type != bd_addr.address_type
|
||||||
@@ -411,39 +500,70 @@ class Host(AbortableEventEmitter):
|
|||||||
f'hc_total_num_acl_data_packets={hc_total_num_acl_data_packets}'
|
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_packet_size=hc_acl_data_packet_length,
|
||||||
max_in_flight=hc_total_num_acl_data_packets,
|
max_in_flight=hc_total_num_acl_data_packets,
|
||||||
send=self.send_hci_packet,
|
send=self.send_hci_packet,
|
||||||
)
|
)
|
||||||
|
|
||||||
hc_le_acl_data_packet_length = 0
|
le_acl_data_packet_length = 0
|
||||||
hc_total_num_le_acl_data_packets = 0
|
total_num_le_acl_data_packets = 0
|
||||||
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
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(
|
response = await self.send_command(
|
||||||
hci.HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
hci.HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
||||||
)
|
)
|
||||||
hc_le_acl_data_packet_length = (
|
le_acl_data_packet_length = (
|
||||||
response.return_parameters.hc_le_acl_data_packet_length
|
response.return_parameters.le_acl_data_packet_length
|
||||||
)
|
)
|
||||||
hc_total_num_le_acl_data_packets = (
|
total_num_le_acl_data_packets = (
|
||||||
response.return_parameters.hc_total_num_le_acl_data_packets
|
response.return_parameters.total_num_le_acl_data_packets
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'HCI LE ACL flow control: '
|
'HCI LE ACL flow control: '
|
||||||
f'hc_le_acl_data_packet_length={hc_le_acl_data_packet_length},'
|
f'le_acl_data_packet_length={le_acl_data_packet_length},'
|
||||||
f'hc_total_num_le_acl_data_packets={hc_total_num_le_acl_data_packets}'
|
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
|
# LE and Classic share the same queue
|
||||||
self.le_acl_packet_queue = self.acl_packet_queue
|
self.le_acl_packet_queue = self.acl_packet_queue
|
||||||
else:
|
else:
|
||||||
# Create a separate queue for LE
|
# Create a separate queue for LE
|
||||||
self.le_acl_packet_queue = AclPacketQueue(
|
self.le_acl_packet_queue = DataPacketQueue(
|
||||||
max_packet_size=hc_le_acl_data_packet_length,
|
max_packet_size=le_acl_data_packet_length,
|
||||||
max_in_flight=hc_total_num_le_acl_data_packets,
|
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,
|
send=self.send_hci_packet,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -552,7 +672,7 @@ class Host(AbortableEventEmitter):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(
|
logger.exception(
|
||||||
f'{color("!!! Exception while sending command:", "red")} {error}'
|
f'{color("!!! Exception while sending command:", "red")} {error}'
|
||||||
)
|
)
|
||||||
raise error
|
raise error
|
||||||
@@ -595,11 +715,78 @@ class Host(AbortableEventEmitter):
|
|||||||
data=l2cap_pdu[offset : offset + data_total_length],
|
data=l2cap_pdu[offset : offset + data_total_length],
|
||||||
)
|
)
|
||||||
logger.debug(f'>>> ACL packet enqueue: (CID={cid}) {acl_packet}')
|
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
|
pb_flag = 1
|
||||||
offset += data_total_length
|
offset += data_total_length
|
||||||
bytes_remaining -= 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:
|
def supports_command(self, op_code: int) -> bool:
|
||||||
return (
|
return (
|
||||||
self.local_supported_commands
|
self.local_supported_commands
|
||||||
@@ -727,16 +914,17 @@ class Host(AbortableEventEmitter):
|
|||||||
def on_hci_command_status_event(self, event):
|
def on_hci_command_status_event(self, event):
|
||||||
return self.on_command_processed(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(
|
for connection_handle, num_completed_packets in zip(
|
||||||
event.connection_handles, event.num_completed_packets
|
event.connection_handles, event.num_completed_packets
|
||||||
):
|
):
|
||||||
if connection := self.connections.get(connection_handle):
|
if queue := self.get_data_packet_queue(connection_handle):
|
||||||
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
|
queue.on_packets_completed(num_completed_packets, connection_handle)
|
||||||
elif not (
|
continue
|
||||||
self.cis_links.get(connection_handle)
|
|
||||||
or self.sco_links.get(connection_handle)
|
if connection_handle not in self.sco_links:
|
||||||
):
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'received packet completion event for unknown handle '
|
'received packet completion event for unknown handle '
|
||||||
f'0x{connection_handle:04X}'
|
f'0x{connection_handle:04X}'
|
||||||
@@ -854,11 +1042,7 @@ class Host(AbortableEventEmitter):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if event.status == hci.HCI_SUCCESS:
|
if event.status == hci.HCI_SUCCESS:
|
||||||
logger.debug(
|
logger.debug(f'### DISCONNECTION: {connection}, reason={event.reason}')
|
||||||
f'### DISCONNECTION: [0x{handle:04X}] '
|
|
||||||
f'{connection.peer_address} '
|
|
||||||
f'reason={event.reason}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Notify the listeners
|
# Notify the listeners
|
||||||
self.emit('disconnection', handle, event.reason)
|
self.emit('disconnection', handle, event.reason)
|
||||||
@@ -869,6 +1053,12 @@ class Host(AbortableEventEmitter):
|
|||||||
or self.cis_links.pop(handle, 0)
|
or self.cis_links.pop(handle, 0)
|
||||||
or self.sco_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:
|
else:
|
||||||
logger.debug(f'### DISCONNECTION FAILED: {event.status}')
|
logger.debug(f'### DISCONNECTION FAILED: {event.status}')
|
||||||
|
|
||||||
@@ -953,12 +1143,68 @@ class Host(AbortableEventEmitter):
|
|||||||
event.cis_id,
|
event.cis_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
event.big_handle,
|
||||||
|
event.connection_handle,
|
||||||
|
event.big_sync_delay,
|
||||||
|
event.transport_latency_big,
|
||||||
|
event.phy,
|
||||||
|
event.nse,
|
||||||
|
event.bn,
|
||||||
|
event.pto,
|
||||||
|
event.irc,
|
||||||
|
event.max_pdu,
|
||||||
|
event.iso_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
event.big_handle,
|
||||||
|
event.transport_latency_big,
|
||||||
|
event.nse,
|
||||||
|
event.bn,
|
||||||
|
event.pto,
|
||||||
|
event.irc,
|
||||||
|
event.max_pdu,
|
||||||
|
event.iso_interval,
|
||||||
|
event.connection_handle,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_hci_le_big_sync_lost_event(self, event):
|
||||||
|
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.remove_big(event.big_handle)
|
||||||
|
self.emit('big_termination', event.reason, event.big_handle)
|
||||||
|
|
||||||
def on_hci_le_cis_established_event(self, event):
|
def on_hci_le_cis_established_event(self, event):
|
||||||
# The remaining parameters are unused for now.
|
# The remaining parameters are unused for now.
|
||||||
if event.status == hci.HCI_SUCCESS:
|
if event.status == hci.HCI_SUCCESS:
|
||||||
self.cis_links[event.connection_handle] = CisLink(
|
if self.iso_packet_queue is None:
|
||||||
handle=event.connection_handle,
|
logger.warning("CIS established but ISO packets not supported")
|
||||||
peer_address=hci.Address.ANY,
|
self.cis_links[event.connection_handle] = IsoLink(
|
||||||
|
handle=event.connection_handle, packet_queue=self.iso_packet_queue
|
||||||
)
|
)
|
||||||
self.emit('cis_establishment', event.connection_handle)
|
self.emit('cis_establishment', event.connection_handle)
|
||||||
else:
|
else:
|
||||||
@@ -1028,7 +1274,7 @@ class Host(AbortableEventEmitter):
|
|||||||
|
|
||||||
self.sco_links[event.connection_handle] = ScoLink(
|
self.sco_links[event.connection_handle] = ScoLink(
|
||||||
peer_address=event.bd_addr,
|
peer_address=event.bd_addr,
|
||||||
handle=event.connection_handle,
|
connection_handle=event.connection_handle,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify the client
|
# Notify the client
|
||||||
@@ -1248,3 +1494,6 @@ class Host(AbortableEventEmitter):
|
|||||||
event.connection_handle,
|
event.connection_handle,
|
||||||
int.from_bytes(event.le_features, 'little'),
|
int.from_bytes(event.le_features, 'little'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_hci_vendor_event(self, event):
|
||||||
|
self.emit('vendor_event', event)
|
||||||
|
|||||||
+6
-24
@@ -225,7 +225,7 @@ class L2CAP_PDU:
|
|||||||
|
|
||||||
return L2CAP_PDU(l2cap_pdu_cid, l2cap_pdu_payload)
|
return L2CAP_PDU(l2cap_pdu_cid, l2cap_pdu_payload)
|
||||||
|
|
||||||
def to_bytes(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
header = struct.pack('<HH', len(self.payload), self.cid)
|
header = struct.pack('<HH', len(self.payload), self.cid)
|
||||||
return header + self.payload
|
return header + self.payload
|
||||||
|
|
||||||
@@ -233,9 +233,6 @@ class L2CAP_PDU:
|
|||||||
self.cid = cid
|
self.cid = cid
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
|
||||||
return self.to_bytes()
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'{color("L2CAP", "green")} [CID={self.cid}]: {self.payload.hex()}'
|
return f'{color("L2CAP", "green")} [CID={self.cid}]: {self.payload.hex()}'
|
||||||
|
|
||||||
@@ -333,11 +330,8 @@ class L2CAP_Control_Frame:
|
|||||||
def init_from_bytes(self, pdu, offset):
|
def init_from_bytes(self, pdu, offset):
|
||||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||||
|
|
||||||
def to_bytes(self) -> bytes:
|
|
||||||
return self.pdu
|
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
return self.to_bytes()
|
return self.pdu
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
|
result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
|
||||||
@@ -779,7 +773,6 @@ class ClassicChannel(EventEmitter):
|
|||||||
self.psm = psm
|
self.psm = psm
|
||||||
self.source_cid = source_cid
|
self.source_cid = source_cid
|
||||||
self.destination_cid = 0
|
self.destination_cid = 0
|
||||||
self.response = None
|
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
self.sink = None
|
self.sink = None
|
||||||
@@ -789,27 +782,15 @@ class ClassicChannel(EventEmitter):
|
|||||||
self.state = new_state
|
self.state = new_state
|
||||||
|
|
||||||
def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
|
def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
|
||||||
|
if self.state != self.State.OPEN:
|
||||||
|
raise InvalidStateError('channel not open')
|
||||||
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
||||||
|
|
||||||
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
||||||
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
|
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
|
||||||
|
|
||||||
async def send_request(self, request: SupportsBytes) -> bytes:
|
|
||||||
# Check that there isn't already a request pending
|
|
||||||
if self.response:
|
|
||||||
raise InvalidStateError('request already pending')
|
|
||||||
if self.state != self.State.OPEN:
|
|
||||||
raise InvalidStateError('channel not open')
|
|
||||||
|
|
||||||
self.response = asyncio.get_running_loop().create_future()
|
|
||||||
self.send_pdu(request)
|
|
||||||
return await self.response
|
|
||||||
|
|
||||||
def on_pdu(self, pdu: bytes) -> None:
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
if self.response:
|
if self.sink:
|
||||||
self.response.set_result(pdu)
|
|
||||||
self.response = None
|
|
||||||
elif self.sink:
|
|
||||||
# pylint: disable=not-callable
|
# pylint: disable=not-callable
|
||||||
self.sink(pdu)
|
self.sink(pdu)
|
||||||
else:
|
else:
|
||||||
@@ -1911,6 +1892,7 @@ class ChannelManager:
|
|||||||
data = sum(1 << cid for cid in self.fixed_channels).to_bytes(8, 'little')
|
data = sum(1 << cid for cid in self.fixed_channels).to_bytes(8, 'little')
|
||||||
else:
|
else:
|
||||||
result = L2CAP_Information_Response.NOT_SUPPORTED
|
result = L2CAP_Information_Response.NOT_SUPPORTED
|
||||||
|
data = b''
|
||||||
|
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
connection,
|
connection,
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ class LocalLink:
|
|||||||
elif transport == BT_BR_EDR_TRANSPORT:
|
elif transport == BT_BR_EDR_TRANSPORT:
|
||||||
destination_controller = self.find_classic_controller(destination_address)
|
destination_controller = self.find_classic_controller(destination_address)
|
||||||
source_address = sender_controller.public_address
|
source_address = sender_controller.public_address
|
||||||
|
else:
|
||||||
|
raise ValueError("unsupported transport type")
|
||||||
|
|
||||||
if destination_controller is not None:
|
if destination_controller is not None:
|
||||||
destination_controller.on_link_acl_data(source_address, transport, data)
|
destination_controller.on_link_acl_data(source_address, transport, data)
|
||||||
|
|||||||
@@ -139,16 +139,19 @@ class PairingDelegate:
|
|||||||
io_capability: IoCapability
|
io_capability: IoCapability
|
||||||
local_initiator_key_distribution: KeyDistribution
|
local_initiator_key_distribution: KeyDistribution
|
||||||
local_responder_key_distribution: KeyDistribution
|
local_responder_key_distribution: KeyDistribution
|
||||||
|
maximum_encryption_key_size: int
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
io_capability: IoCapability = NO_OUTPUT_NO_INPUT,
|
io_capability: IoCapability = NO_OUTPUT_NO_INPUT,
|
||||||
local_initiator_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION,
|
local_initiator_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION,
|
||||||
local_responder_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION,
|
local_responder_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION,
|
||||||
|
maximum_encryption_key_size: int = 16,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.io_capability = io_capability
|
self.io_capability = io_capability
|
||||||
self.local_initiator_key_distribution = local_initiator_key_distribution
|
self.local_initiator_key_distribution = local_initiator_key_distribution
|
||||||
self.local_responder_key_distribution = local_responder_key_distribution
|
self.local_responder_key_distribution = local_responder_key_distribution
|
||||||
|
self.maximum_encryption_key_size = maximum_encryption_key_size
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def classic_io_capability(self) -> int:
|
def classic_io_capability(self) -> int:
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ from bumble.device import (
|
|||||||
AdvertisingEventProperties,
|
AdvertisingEventProperties,
|
||||||
AdvertisingType,
|
AdvertisingType,
|
||||||
Device,
|
Device,
|
||||||
Phy,
|
|
||||||
)
|
)
|
||||||
from bumble.gatt import Service
|
from bumble.gatt import Service
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
@@ -47,6 +46,7 @@ from bumble.hci import (
|
|||||||
HCI_PAGE_TIMEOUT_ERROR,
|
HCI_PAGE_TIMEOUT_ERROR,
|
||||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||||
Address,
|
Address,
|
||||||
|
Phy,
|
||||||
)
|
)
|
||||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
||||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
||||||
|
|||||||
+37
-53
@@ -17,6 +17,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
@@ -28,10 +29,11 @@ from bumble.device import Connection
|
|||||||
from bumble.att import ATT_Error
|
from bumble.att import ATT_Error
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
Characteristic,
|
Characteristic,
|
||||||
DelegatedCharacteristicAdapter,
|
SerializableCharacteristicAdapter,
|
||||||
|
PackedCharacteristicAdapter,
|
||||||
TemplateService,
|
TemplateService,
|
||||||
CharacteristicValue,
|
CharacteristicValue,
|
||||||
PackedCharacteristicAdapter,
|
UTF8CharacteristicAdapter,
|
||||||
GATT_AUDIO_INPUT_CONTROL_SERVICE,
|
GATT_AUDIO_INPUT_CONTROL_SERVICE,
|
||||||
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
|
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
|
||||||
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
||||||
@@ -95,7 +97,7 @@ class AudioInputStatus(OpenIntEnum):
|
|||||||
Cf. 3.4 Audio Input Status
|
Cf. 3.4 Audio Input Status
|
||||||
'''
|
'''
|
||||||
|
|
||||||
INATIVE = 0x00
|
INACTIVE = 0x00
|
||||||
ACTIVE = 0x01
|
ACTIVE = 0x01
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ class AudioInputControlPointOpCode(OpenIntEnum):
|
|||||||
Cf. 3.5.1 Audio Input Control Point procedure requirements
|
Cf. 3.5.1 Audio Input Control Point procedure requirements
|
||||||
'''
|
'''
|
||||||
|
|
||||||
SET_GAIN_SETTING = 0x00
|
SET_GAIN_SETTING = 0x01
|
||||||
UNMUTE = 0x02
|
UNMUTE = 0x02
|
||||||
MUTE = 0x03
|
MUTE = 0x03
|
||||||
SET_MANUAL_GAIN_MODE = 0x04
|
SET_MANUAL_GAIN_MODE = 0x04
|
||||||
@@ -154,9 +156,6 @@ class AudioInputState:
|
|||||||
attribute=self.attribute_value, value=bytes(self)
|
attribute=self.attribute_value, value=bytes(self)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_read(self, _connection: Optional[Connection]) -> bytes:
|
|
||||||
return bytes(self)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GainSettingsProperties:
|
class GainSettingsProperties:
|
||||||
@@ -173,7 +172,7 @@ class GainSettingsProperties:
|
|||||||
(gain_settings_unit, gain_settings_minimum, gain_settings_maximum) = (
|
(gain_settings_unit, gain_settings_minimum, gain_settings_maximum) = (
|
||||||
struct.unpack('BBB', data)
|
struct.unpack('BBB', data)
|
||||||
)
|
)
|
||||||
GainSettingsProperties(
|
return GainSettingsProperties(
|
||||||
gain_settings_unit, gain_settings_minimum, gain_settings_maximum
|
gain_settings_unit, gain_settings_minimum, gain_settings_maximum
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,9 +185,6 @@ class GainSettingsProperties:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_read(self, _connection: Optional[Connection]) -> bytes:
|
|
||||||
return bytes(self)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AudioInputControlPoint:
|
class AudioInputControlPoint:
|
||||||
@@ -239,7 +235,7 @@ class AudioInputControlPoint:
|
|||||||
or gain_settings_operand
|
or gain_settings_operand
|
||||||
> self.gain_settings_properties.gain_settings_maximum
|
> self.gain_settings_properties.gain_settings_maximum
|
||||||
):
|
):
|
||||||
logger.error("gain_seetings value out of range")
|
logger.error("gain_settings value out of range")
|
||||||
raise ATT_Error(ErrorCode.VALUE_OUT_OF_RANGE)
|
raise ATT_Error(ErrorCode.VALUE_OUT_OF_RANGE)
|
||||||
|
|
||||||
if self.audio_input_state.gain_settings != gain_settings_operand:
|
if self.audio_input_state.gain_settings != gain_settings_operand:
|
||||||
@@ -321,21 +317,14 @@ class AudioInputDescription:
|
|||||||
audio_input_description: str = "Bluetooth"
|
audio_input_description: str = "Bluetooth"
|
||||||
attribute_value: Optional[CharacteristicValue] = None
|
attribute_value: Optional[CharacteristicValue] = None
|
||||||
|
|
||||||
@classmethod
|
def on_read(self, _connection: Optional[Connection]) -> str:
|
||||||
def from_bytes(cls, data: bytes):
|
return self.audio_input_description
|
||||||
return cls(audio_input_description=data.decode('utf-8'))
|
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
async def on_write(self, connection: Optional[Connection], value: str) -> None:
|
||||||
return self.audio_input_description.encode('utf-8')
|
|
||||||
|
|
||||||
def on_read(self, _connection: Optional[Connection]) -> bytes:
|
|
||||||
return self.audio_input_description.encode('utf-8')
|
|
||||||
|
|
||||||
async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
|
|
||||||
assert connection
|
assert connection
|
||||||
assert self.attribute_value
|
assert self.attribute_value
|
||||||
|
|
||||||
self.audio_input_description = value.decode('utf-8')
|
self.audio_input_description = value
|
||||||
await connection.device.notify_subscribers(
|
await connection.device.notify_subscribers(
|
||||||
attribute=self.attribute_value, value=value
|
attribute=self.attribute_value, value=value
|
||||||
)
|
)
|
||||||
@@ -375,26 +364,29 @@ class AICSService(TemplateService):
|
|||||||
self.audio_input_state, self.gain_settings_properties
|
self.audio_input_state, self.gain_settings_properties
|
||||||
)
|
)
|
||||||
|
|
||||||
self.audio_input_state_characteristic = DelegatedCharacteristicAdapter(
|
self.audio_input_state_characteristic = SerializableCharacteristicAdapter(
|
||||||
Characteristic(
|
Characteristic(
|
||||||
uuid=GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
|
uuid=GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
|
||||||
properties=Characteristic.Properties.READ
|
properties=Characteristic.Properties.READ
|
||||||
| Characteristic.Properties.NOTIFY,
|
| Characteristic.Properties.NOTIFY,
|
||||||
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
value=CharacteristicValue(read=self.audio_input_state.on_read),
|
value=self.audio_input_state,
|
||||||
),
|
),
|
||||||
encode=lambda value: bytes(value),
|
AudioInputState,
|
||||||
)
|
)
|
||||||
self.audio_input_state.attribute_value = (
|
self.audio_input_state.attribute_value = (
|
||||||
self.audio_input_state_characteristic.value
|
self.audio_input_state_characteristic.value
|
||||||
)
|
)
|
||||||
|
|
||||||
self.gain_settings_properties_characteristic = DelegatedCharacteristicAdapter(
|
self.gain_settings_properties_characteristic = (
|
||||||
Characteristic(
|
SerializableCharacteristicAdapter(
|
||||||
uuid=GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
Characteristic(
|
||||||
properties=Characteristic.Properties.READ,
|
uuid=GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
||||||
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
properties=Characteristic.Properties.READ,
|
||||||
value=CharacteristicValue(read=self.gain_settings_properties.on_read),
|
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
|
value=self.gain_settings_properties,
|
||||||
|
),
|
||||||
|
GainSettingsProperties,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -402,7 +394,7 @@ class AICSService(TemplateService):
|
|||||||
uuid=GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC,
|
uuid=GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC,
|
||||||
properties=Characteristic.Properties.READ,
|
properties=Characteristic.Properties.READ,
|
||||||
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
value=audio_input_type,
|
value=bytes(audio_input_type, 'utf-8'),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.audio_input_status_characteristic = Characteristic(
|
self.audio_input_status_characteristic = Characteristic(
|
||||||
@@ -412,18 +404,14 @@ class AICSService(TemplateService):
|
|||||||
value=bytes([self.audio_input_status]),
|
value=bytes([self.audio_input_status]),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.audio_input_control_point_characteristic = DelegatedCharacteristicAdapter(
|
self.audio_input_control_point_characteristic = Characteristic(
|
||||||
Characteristic(
|
uuid=GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
|
||||||
uuid=GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
|
properties=Characteristic.Properties.WRITE,
|
||||||
properties=Characteristic.Properties.WRITE,
|
permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||||
permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
value=CharacteristicValue(write=self.audio_input_control_point.on_write),
|
||||||
value=CharacteristicValue(
|
|
||||||
write=self.audio_input_control_point.on_write
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.audio_input_description_characteristic = DelegatedCharacteristicAdapter(
|
self.audio_input_description_characteristic = UTF8CharacteristicAdapter(
|
||||||
Characteristic(
|
Characteristic(
|
||||||
uuid=GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
|
uuid=GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
|
||||||
properties=Characteristic.Properties.READ
|
properties=Characteristic.Properties.READ
|
||||||
@@ -469,8 +457,8 @@ class AICSServiceProxy(ProfileServiceProxy):
|
|||||||
)
|
)
|
||||||
):
|
):
|
||||||
raise gatt.InvalidServiceError("Audio Input State Characteristic not found")
|
raise gatt.InvalidServiceError("Audio Input State Characteristic not found")
|
||||||
self.audio_input_state = DelegatedCharacteristicAdapter(
|
self.audio_input_state = SerializableCharacteristicAdapter(
|
||||||
characteristic=characteristics[0], decode=AudioInputState.from_bytes
|
characteristics[0], AudioInputState
|
||||||
)
|
)
|
||||||
|
|
||||||
if not (
|
if not (
|
||||||
@@ -481,9 +469,8 @@ class AICSServiceProxy(ProfileServiceProxy):
|
|||||||
raise gatt.InvalidServiceError(
|
raise gatt.InvalidServiceError(
|
||||||
"Gain Settings Attribute Characteristic not found"
|
"Gain Settings Attribute Characteristic not found"
|
||||||
)
|
)
|
||||||
self.gain_settings_properties = PackedCharacteristicAdapter(
|
self.gain_settings_properties = SerializableCharacteristicAdapter(
|
||||||
characteristics[0],
|
characteristics[0], GainSettingsProperties
|
||||||
'BBB',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not (
|
if not (
|
||||||
@@ -494,10 +481,7 @@ class AICSServiceProxy(ProfileServiceProxy):
|
|||||||
raise gatt.InvalidServiceError(
|
raise gatt.InvalidServiceError(
|
||||||
"Audio Input Status Characteristic not found"
|
"Audio Input Status Characteristic not found"
|
||||||
)
|
)
|
||||||
self.audio_input_status = PackedCharacteristicAdapter(
|
self.audio_input_status = PackedCharacteristicAdapter(characteristics[0], 'B')
|
||||||
characteristics[0],
|
|
||||||
'B',
|
|
||||||
)
|
|
||||||
|
|
||||||
if not (
|
if not (
|
||||||
characteristics := service_proxy.get_characteristics_by_uuid(
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
@@ -517,4 +501,4 @@ class AICSServiceProxy(ProfileServiceProxy):
|
|||||||
raise gatt.InvalidServiceError(
|
raise gatt.InvalidServiceError(
|
||||||
"Audio Input Description Characteristic not found"
|
"Audio Input Description Characteristic not found"
|
||||||
)
|
)
|
||||||
self.audio_input_description = characteristics[0]
|
self.audio_input_description = UTF8CharacteristicAdapter(characteristics[0])
|
||||||
|
|||||||
+6
-18
@@ -17,6 +17,7 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
@@ -258,8 +259,8 @@ class AseReasonCode(enum.IntEnum):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class AudioRole(enum.IntEnum):
|
class AudioRole(enum.IntEnum):
|
||||||
SINK = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST
|
SINK = device.CisLink.Direction.CONTROLLER_TO_HOST
|
||||||
SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER
|
SOURCE = device.CisLink.Direction.HOST_TO_CONTROLLER
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -354,16 +355,7 @@ class AseStateMachine(gatt.Characteristic):
|
|||||||
cis_link.on('disconnection', self.on_cis_disconnection)
|
cis_link.on('disconnection', self.on_cis_disconnection)
|
||||||
|
|
||||||
async def post_cis_established():
|
async def post_cis_established():
|
||||||
await self.service.device.send_command(
|
await cis_link.setup_data_path(direction=self.role)
|
||||||
hci.HCI_LE_Setup_ISO_Data_Path_Command(
|
|
||||||
connection_handle=cis_link.handle,
|
|
||||||
data_path_direction=self.role,
|
|
||||||
data_path_id=0x00, # Fixed HCI
|
|
||||||
codec_id=hci.CodingFormat(hci.CodecID.TRANSPARENT),
|
|
||||||
controller_delay=0,
|
|
||||||
codec_configuration=b'',
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if self.role == AudioRole.SINK:
|
if self.role == AudioRole.SINK:
|
||||||
self.state = self.State.STREAMING
|
self.state = self.State.STREAMING
|
||||||
await self.service.device.notify_subscribers(self, self.value)
|
await self.service.device.notify_subscribers(self, self.value)
|
||||||
@@ -511,12 +503,8 @@ class AseStateMachine(gatt.Characteristic):
|
|||||||
self.state = self.State.RELEASING
|
self.state = self.State.RELEASING
|
||||||
|
|
||||||
async def remove_cis_async():
|
async def remove_cis_async():
|
||||||
await self.service.device.send_command(
|
if self.cis_link:
|
||||||
hci.HCI_LE_Remove_ISO_Data_Path_Command(
|
await self.cis_link.remove_data_path(self.role)
|
||||||
connection_handle=self.cis_link.handle,
|
|
||||||
data_path_direction=self.role,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.state = self.State.IDLE
|
self.state = self.State.IDLE
|
||||||
await self.service.device.notify_subscribers(self, self.value)
|
await self.service.device.notify_subscribers(self, self.value)
|
||||||
|
|
||||||
|
|||||||
+116
-41
@@ -102,6 +102,7 @@ class ContextType(enum.IntFlag):
|
|||||||
|
|
||||||
# fmt: off
|
# fmt: off
|
||||||
PROHIBITED = 0x0000
|
PROHIBITED = 0x0000
|
||||||
|
UNSPECIFIED = 0x0001
|
||||||
CONVERSATIONAL = 0x0002
|
CONVERSATIONAL = 0x0002
|
||||||
MEDIA = 0x0004
|
MEDIA = 0x0004
|
||||||
GAME = 0x0008
|
GAME = 0x0008
|
||||||
@@ -264,7 +265,7 @@ class UnicastServerAdvertisingData:
|
|||||||
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
||||||
struct.pack(
|
struct.pack(
|
||||||
'<2sBIB',
|
'<2sBIB',
|
||||||
gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE.to_bytes(),
|
bytes(gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE),
|
||||||
self.announcement_type,
|
self.announcement_type,
|
||||||
self.available_audio_contexts,
|
self.available_audio_contexts,
|
||||||
len(self.metadata),
|
len(self.metadata),
|
||||||
@@ -350,6 +351,7 @@ class CodecSpecificCapabilities:
|
|||||||
supported_max_codec_frames_per_sdu = value
|
supported_max_codec_frames_per_sdu = value
|
||||||
|
|
||||||
# It is expected here that if some fields are missing, an error should be raised.
|
# It is expected here that if some fields are missing, an error should be raised.
|
||||||
|
# pylint: disable=possibly-used-before-assignment,used-before-assignment
|
||||||
return CodecSpecificCapabilities(
|
return CodecSpecificCapabilities(
|
||||||
supported_sampling_frequencies=supported_sampling_frequencies,
|
supported_sampling_frequencies=supported_sampling_frequencies,
|
||||||
supported_frame_durations=supported_frame_durations,
|
supported_frame_durations=supported_frame_durations,
|
||||||
@@ -396,18 +398,21 @@ class CodecSpecificConfiguration:
|
|||||||
OCTETS_PER_FRAME = 0x04
|
OCTETS_PER_FRAME = 0x04
|
||||||
CODEC_FRAMES_PER_SDU = 0x05
|
CODEC_FRAMES_PER_SDU = 0x05
|
||||||
|
|
||||||
sampling_frequency: SamplingFrequency
|
sampling_frequency: SamplingFrequency | None = None
|
||||||
frame_duration: FrameDuration
|
frame_duration: FrameDuration | None = None
|
||||||
audio_channel_allocation: AudioLocation
|
audio_channel_allocation: AudioLocation | None = None
|
||||||
octets_per_codec_frame: int
|
octets_per_codec_frame: int | None = None
|
||||||
codec_frames_per_sdu: int
|
codec_frames_per_sdu: int | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_bytes(cls, data: bytes) -> CodecSpecificConfiguration:
|
def from_bytes(cls, data: bytes) -> CodecSpecificConfiguration:
|
||||||
offset = 0
|
offset = 0
|
||||||
# Allowed default values.
|
sampling_frequency: SamplingFrequency | None = None
|
||||||
audio_channel_allocation = AudioLocation.NOT_ALLOWED
|
frame_duration: FrameDuration | None = None
|
||||||
codec_frames_per_sdu = 1
|
audio_channel_allocation: AudioLocation | None = None
|
||||||
|
octets_per_codec_frame: int | None = None
|
||||||
|
codec_frames_per_sdu: int | None = None
|
||||||
|
|
||||||
while offset < len(data):
|
while offset < len(data):
|
||||||
length, type = struct.unpack_from('BB', data, offset)
|
length, type = struct.unpack_from('BB', data, offset)
|
||||||
offset += 2
|
offset += 2
|
||||||
@@ -425,7 +430,6 @@ class CodecSpecificConfiguration:
|
|||||||
elif type == CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU:
|
elif type == CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU:
|
||||||
codec_frames_per_sdu = value
|
codec_frames_per_sdu = value
|
||||||
|
|
||||||
# It is expected here that if some fields are missing, an error should be raised.
|
|
||||||
return CodecSpecificConfiguration(
|
return CodecSpecificConfiguration(
|
||||||
sampling_frequency=sampling_frequency,
|
sampling_frequency=sampling_frequency,
|
||||||
frame_duration=frame_duration,
|
frame_duration=frame_duration,
|
||||||
@@ -435,23 +439,43 @@ class CodecSpecificConfiguration:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
return struct.pack(
|
return b''.join(
|
||||||
'<BBBBBBBBIBBHBBB',
|
[
|
||||||
2,
|
struct.pack(fmt, length, tag, value)
|
||||||
CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY,
|
for fmt, length, tag, value in [
|
||||||
self.sampling_frequency,
|
(
|
||||||
2,
|
'<BBB',
|
||||||
CodecSpecificConfiguration.Type.FRAME_DURATION,
|
2,
|
||||||
self.frame_duration,
|
CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY,
|
||||||
5,
|
self.sampling_frequency,
|
||||||
CodecSpecificConfiguration.Type.AUDIO_CHANNEL_ALLOCATION,
|
),
|
||||||
self.audio_channel_allocation,
|
(
|
||||||
3,
|
'<BBB',
|
||||||
CodecSpecificConfiguration.Type.OCTETS_PER_FRAME,
|
2,
|
||||||
self.octets_per_codec_frame,
|
CodecSpecificConfiguration.Type.FRAME_DURATION,
|
||||||
2,
|
self.frame_duration,
|
||||||
CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU,
|
),
|
||||||
self.codec_frames_per_sdu,
|
(
|
||||||
|
'<BBI',
|
||||||
|
5,
|
||||||
|
CodecSpecificConfiguration.Type.AUDIO_CHANNEL_ALLOCATION,
|
||||||
|
self.audio_channel_allocation,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'<BBH',
|
||||||
|
3,
|
||||||
|
CodecSpecificConfiguration.Type.OCTETS_PER_FRAME,
|
||||||
|
self.octets_per_codec_frame,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'<BBB',
|
||||||
|
2,
|
||||||
|
CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU,
|
||||||
|
self.codec_frames_per_sdu,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if value is not None
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -463,6 +487,24 @@ class BroadcastAudioAnnouncement:
|
|||||||
def from_bytes(cls, data: bytes) -> Self:
|
def from_bytes(cls, data: bytes) -> Self:
|
||||||
return cls(int.from_bytes(data[:3], 'little'))
|
return cls(int.from_bytes(data[:3], 'little'))
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.broadcast_id.to_bytes(3, 'little')
|
||||||
|
|
||||||
|
def get_advertising_data(self) -> bytes:
|
||||||
|
return bytes(
|
||||||
|
core.AdvertisingData(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
||||||
|
(
|
||||||
|
bytes(gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE)
|
||||||
|
+ bytes(self)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class BasicAudioAnnouncement:
|
class BasicAudioAnnouncement:
|
||||||
@@ -471,26 +513,37 @@ class BasicAudioAnnouncement:
|
|||||||
index: int
|
index: int
|
||||||
codec_specific_configuration: CodecSpecificConfiguration
|
codec_specific_configuration: CodecSpecificConfiguration
|
||||||
|
|
||||||
@dataclasses.dataclass
|
def __bytes__(self) -> bytes:
|
||||||
class CodecInfo:
|
codec_specific_configuration_bytes = bytes(
|
||||||
coding_format: hci.CodecID
|
self.codec_specific_configuration
|
||||||
company_id: int
|
)
|
||||||
vendor_specific_codec_id: int
|
return (
|
||||||
|
bytes([self.index, len(codec_specific_configuration_bytes)])
|
||||||
@classmethod
|
+ codec_specific_configuration_bytes
|
||||||
def from_bytes(cls, data: bytes) -> Self:
|
)
|
||||||
coding_format = hci.CodecID(data[0])
|
|
||||||
company_id = int.from_bytes(data[1:3], 'little')
|
|
||||||
vendor_specific_codec_id = int.from_bytes(data[3:5], 'little')
|
|
||||||
return cls(coding_format, company_id, vendor_specific_codec_id)
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class Subgroup:
|
class Subgroup:
|
||||||
codec_id: BasicAudioAnnouncement.CodecInfo
|
codec_id: hci.CodingFormat
|
||||||
codec_specific_configuration: CodecSpecificConfiguration
|
codec_specific_configuration: CodecSpecificConfiguration
|
||||||
metadata: le_audio.Metadata
|
metadata: le_audio.Metadata
|
||||||
bis: List[BasicAudioAnnouncement.BIS]
|
bis: List[BasicAudioAnnouncement.BIS]
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
metadata_bytes = bytes(self.metadata)
|
||||||
|
codec_specific_configuration_bytes = bytes(
|
||||||
|
self.codec_specific_configuration
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
bytes([len(self.bis)])
|
||||||
|
+ bytes(self.codec_id)
|
||||||
|
+ bytes([len(codec_specific_configuration_bytes)])
|
||||||
|
+ codec_specific_configuration_bytes
|
||||||
|
+ bytes([len(metadata_bytes)])
|
||||||
|
+ metadata_bytes
|
||||||
|
+ b''.join(map(bytes, self.bis))
|
||||||
|
)
|
||||||
|
|
||||||
presentation_delay: int
|
presentation_delay: int
|
||||||
subgroups: List[BasicAudioAnnouncement.Subgroup]
|
subgroups: List[BasicAudioAnnouncement.Subgroup]
|
||||||
|
|
||||||
@@ -502,7 +555,7 @@ class BasicAudioAnnouncement:
|
|||||||
for _ in range(data[3]):
|
for _ in range(data[3]):
|
||||||
num_bis = data[offset]
|
num_bis = data[offset]
|
||||||
offset += 1
|
offset += 1
|
||||||
codec_id = cls.CodecInfo.from_bytes(data[offset : offset + 5])
|
codec_id = hci.CodingFormat.from_bytes(data[offset : offset + 5])
|
||||||
offset += 5
|
offset += 5
|
||||||
codec_specific_configuration_length = data[offset]
|
codec_specific_configuration_length = data[offset]
|
||||||
offset += 1
|
offset += 1
|
||||||
@@ -546,3 +599,25 @@ class BasicAudioAnnouncement:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return cls(presentation_delay, subgroups)
|
return cls(presentation_delay, subgroups)
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return (
|
||||||
|
self.presentation_delay.to_bytes(3, 'little')
|
||||||
|
+ bytes([len(self.subgroups)])
|
||||||
|
+ b''.join(map(bytes, self.subgroups))
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_advertising_data(self) -> bytes:
|
||||||
|
return bytes(
|
||||||
|
core.AdvertisingData(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
||||||
|
(
|
||||||
|
bytes(gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE)
|
||||||
|
+ bytes(self)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -276,10 +276,7 @@ class BroadcastReceiveState:
|
|||||||
subgroups: List[SubgroupInfo]
|
subgroups: List[SubgroupInfo]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_bytes(cls, data: bytes) -> Optional[BroadcastReceiveState]:
|
def from_bytes(cls, data: bytes) -> BroadcastReceiveState:
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
source_id = data[0]
|
source_id = data[0]
|
||||||
_, source_address = hci.Address.parse_address_preceded_by_type(data, 2)
|
_, source_address = hci.Address.parse_address_preceded_by_type(data, 2)
|
||||||
source_adv_sid = data[8]
|
source_adv_sid = data[8]
|
||||||
@@ -357,7 +354,7 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
|
|||||||
SERVICE_CLASS = BroadcastAudioScanService
|
SERVICE_CLASS = BroadcastAudioScanService
|
||||||
|
|
||||||
broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy
|
broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy
|
||||||
broadcast_receive_states: List[gatt.DelegatedCharacteristicAdapter]
|
broadcast_receive_states: List[gatt.SerializableCharacteristicAdapter]
|
||||||
|
|
||||||
def __init__(self, service_proxy: gatt_client.ServiceProxy):
|
def __init__(self, service_proxy: gatt_client.ServiceProxy):
|
||||||
self.service_proxy = service_proxy
|
self.service_proxy = service_proxy
|
||||||
@@ -381,8 +378,8 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
|
|||||||
"Broadcast Receive State characteristic not found"
|
"Broadcast Receive State characteristic not found"
|
||||||
)
|
)
|
||||||
self.broadcast_receive_states = [
|
self.broadcast_receive_states = [
|
||||||
gatt.DelegatedCharacteristicAdapter(
|
gatt.SerializableCharacteristicAdapter(
|
||||||
characteristic, decode=BroadcastReceiveState.from_bytes
|
characteristic, BroadcastReceiveState
|
||||||
)
|
)
|
||||||
for characteristic in characteristics
|
for characteristic in characteristics
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ class DeviceInformationService(TemplateService):
|
|||||||
):
|
):
|
||||||
characteristics = [
|
characteristics = [
|
||||||
Characteristic(
|
Characteristic(
|
||||||
uuid, Characteristic.Properties.READ, Characteristic.READABLE, field
|
uuid,
|
||||||
|
Characteristic.Properties.READ,
|
||||||
|
Characteristic.READABLE,
|
||||||
|
bytes(field, 'utf-8'),
|
||||||
)
|
)
|
||||||
for (field, uuid) in (
|
for (field, uuid) in (
|
||||||
(manufacturer_name, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),
|
(manufacturer_name, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""LE Audio - Gaming Audio Profile"""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import struct
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from bumble.gatt import (
|
||||||
|
TemplateService,
|
||||||
|
DelegatedCharacteristicAdapter,
|
||||||
|
Characteristic,
|
||||||
|
GATT_GAMING_AUDIO_SERVICE,
|
||||||
|
GATT_GMAP_ROLE_CHARACTERISTIC,
|
||||||
|
GATT_UGG_FEATURES_CHARACTERISTIC,
|
||||||
|
GATT_UGT_FEATURES_CHARACTERISTIC,
|
||||||
|
GATT_BGS_FEATURES_CHARACTERISTIC,
|
||||||
|
GATT_BGR_FEATURES_CHARACTERISTIC,
|
||||||
|
InvalidServiceError,
|
||||||
|
)
|
||||||
|
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
|
||||||
|
from enum import IntFlag
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Classes
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class GmapRole(IntFlag):
|
||||||
|
UNICAST_GAME_GATEWAY = 1 << 0
|
||||||
|
UNICAST_GAME_TERMINAL = 1 << 1
|
||||||
|
BROADCAST_GAME_SENDER = 1 << 2
|
||||||
|
BROADCAST_GAME_RECEIVER = 1 << 3
|
||||||
|
|
||||||
|
|
||||||
|
class UggFeatures(IntFlag):
|
||||||
|
UGG_MULTIPLEX = 1 << 0
|
||||||
|
UGG_96_KBPS_SOURCE = 1 << 1
|
||||||
|
UGG_MULTISINK = 1 << 2
|
||||||
|
|
||||||
|
|
||||||
|
class UgtFeatures(IntFlag):
|
||||||
|
UGT_SOURCE = 1 << 0
|
||||||
|
UGT_80_KBPS_SOURCE = 1 << 1
|
||||||
|
UGT_SINK = 1 << 2
|
||||||
|
UGT_64_KBPS_SINK = 1 << 3
|
||||||
|
UGT_MULTIPLEX = 1 << 4
|
||||||
|
UGT_MULTISINK = 1 << 5
|
||||||
|
UGT_MULTISOURCE = 1 << 6
|
||||||
|
|
||||||
|
|
||||||
|
class BgsFeatures(IntFlag):
|
||||||
|
BGS_96_KBPS = 1 << 0
|
||||||
|
|
||||||
|
|
||||||
|
class BgrFeatures(IntFlag):
|
||||||
|
BGR_MULTISINK = 1 << 0
|
||||||
|
BGR_MULTIPLEX = 1 << 1
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Server
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class GamingAudioService(TemplateService):
|
||||||
|
UUID = GATT_GAMING_AUDIO_SERVICE
|
||||||
|
|
||||||
|
gmap_role: Characteristic
|
||||||
|
ugg_features: Optional[Characteristic] = None
|
||||||
|
ugt_features: Optional[Characteristic] = None
|
||||||
|
bgs_features: Optional[Characteristic] = None
|
||||||
|
bgr_features: Optional[Characteristic] = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
gmap_role: GmapRole,
|
||||||
|
ugg_features: Optional[UggFeatures] = None,
|
||||||
|
ugt_features: Optional[UgtFeatures] = None,
|
||||||
|
bgs_features: Optional[BgsFeatures] = None,
|
||||||
|
bgr_features: Optional[BgrFeatures] = None,
|
||||||
|
) -> None:
|
||||||
|
characteristics = []
|
||||||
|
|
||||||
|
ugg_features = UggFeatures(0) if ugg_features is None else ugg_features
|
||||||
|
ugt_features = UgtFeatures(0) if ugt_features is None else ugt_features
|
||||||
|
bgs_features = BgsFeatures(0) if bgs_features is None else bgs_features
|
||||||
|
bgr_features = BgrFeatures(0) if bgr_features is None else bgr_features
|
||||||
|
|
||||||
|
self.gmap_role = Characteristic(
|
||||||
|
uuid=GATT_GMAP_ROLE_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.READ,
|
||||||
|
permissions=Characteristic.Permissions.READABLE,
|
||||||
|
value=struct.pack('B', gmap_role),
|
||||||
|
)
|
||||||
|
characteristics.append(self.gmap_role)
|
||||||
|
|
||||||
|
if gmap_role & GmapRole.UNICAST_GAME_GATEWAY:
|
||||||
|
self.ugg_features = Characteristic(
|
||||||
|
uuid=GATT_UGG_FEATURES_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.READ,
|
||||||
|
permissions=Characteristic.Permissions.READABLE,
|
||||||
|
value=struct.pack('B', ugg_features),
|
||||||
|
)
|
||||||
|
characteristics.append(self.ugg_features)
|
||||||
|
|
||||||
|
if gmap_role & GmapRole.UNICAST_GAME_TERMINAL:
|
||||||
|
self.ugt_features = Characteristic(
|
||||||
|
uuid=GATT_UGT_FEATURES_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.READ,
|
||||||
|
permissions=Characteristic.Permissions.READABLE,
|
||||||
|
value=struct.pack('B', ugt_features),
|
||||||
|
)
|
||||||
|
characteristics.append(self.ugt_features)
|
||||||
|
|
||||||
|
if gmap_role & GmapRole.BROADCAST_GAME_SENDER:
|
||||||
|
self.bgs_features = Characteristic(
|
||||||
|
uuid=GATT_BGS_FEATURES_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.READ,
|
||||||
|
permissions=Characteristic.Permissions.READABLE,
|
||||||
|
value=struct.pack('B', bgs_features),
|
||||||
|
)
|
||||||
|
characteristics.append(self.bgs_features)
|
||||||
|
|
||||||
|
if gmap_role & GmapRole.BROADCAST_GAME_RECEIVER:
|
||||||
|
self.bgr_features = Characteristic(
|
||||||
|
uuid=GATT_BGR_FEATURES_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.READ,
|
||||||
|
permissions=Characteristic.Permissions.READABLE,
|
||||||
|
value=struct.pack('B', bgr_features),
|
||||||
|
)
|
||||||
|
characteristics.append(self.bgr_features)
|
||||||
|
|
||||||
|
super().__init__(characteristics)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Client
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class GamingAudioServiceProxy(ProfileServiceProxy):
|
||||||
|
SERVICE_CLASS = GamingAudioService
|
||||||
|
|
||||||
|
def __init__(self, service_proxy: ServiceProxy) -> None:
|
||||||
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
|
if not (
|
||||||
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_GMAP_ROLE_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise InvalidServiceError("GMAP Role Characteristic not found")
|
||||||
|
self.gmap_role = DelegatedCharacteristicAdapter(
|
||||||
|
characteristic=characteristics[0],
|
||||||
|
decode=lambda value: GmapRole(value[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_UGG_FEATURES_CHARACTERISTIC
|
||||||
|
):
|
||||||
|
self.ugg_features = DelegatedCharacteristicAdapter(
|
||||||
|
characteristic=characteristics[0],
|
||||||
|
decode=lambda value: UggFeatures(value[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_UGT_FEATURES_CHARACTERISTIC
|
||||||
|
):
|
||||||
|
self.ugt_features = DelegatedCharacteristicAdapter(
|
||||||
|
characteristic=characteristics[0],
|
||||||
|
decode=lambda value: UgtFeatures(value[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_BGS_FEATURES_CHARACTERISTIC
|
||||||
|
):
|
||||||
|
self.bgs_features = DelegatedCharacteristicAdapter(
|
||||||
|
characteristic=characteristics[0],
|
||||||
|
decode=lambda value: BgsFeatures(value[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_BGR_FEATURES_CHARACTERISTIC
|
||||||
|
):
|
||||||
|
self.bgr_features = DelegatedCharacteristicAdapter(
|
||||||
|
characteristic=characteristics[0],
|
||||||
|
decode=lambda value: BgrFeatures(value[0]),
|
||||||
|
)
|
||||||
@@ -30,6 +30,7 @@ from ..gatt import (
|
|||||||
TemplateService,
|
TemplateService,
|
||||||
Characteristic,
|
Characteristic,
|
||||||
CharacteristicValue,
|
CharacteristicValue,
|
||||||
|
SerializableCharacteristicAdapter,
|
||||||
DelegatedCharacteristicAdapter,
|
DelegatedCharacteristicAdapter,
|
||||||
PackedCharacteristicAdapter,
|
PackedCharacteristicAdapter,
|
||||||
)
|
)
|
||||||
@@ -150,15 +151,14 @@ class HeartRateService(TemplateService):
|
|||||||
body_sensor_location=None,
|
body_sensor_location=None,
|
||||||
reset_energy_expended=None,
|
reset_energy_expended=None,
|
||||||
):
|
):
|
||||||
self.heart_rate_measurement_characteristic = DelegatedCharacteristicAdapter(
|
self.heart_rate_measurement_characteristic = SerializableCharacteristicAdapter(
|
||||||
Characteristic(
|
Characteristic(
|
||||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
||||||
Characteristic.Properties.NOTIFY,
|
Characteristic.Properties.NOTIFY,
|
||||||
0,
|
0,
|
||||||
CharacteristicValue(read=read_heart_rate_measurement),
|
CharacteristicValue(read=read_heart_rate_measurement),
|
||||||
),
|
),
|
||||||
# pylint: disable=unnecessary-lambda
|
HeartRateService.HeartRateMeasurement,
|
||||||
encode=lambda value: bytes(value),
|
|
||||||
)
|
)
|
||||||
characteristics = [self.heart_rate_measurement_characteristic]
|
characteristics = [self.heart_rate_measurement_characteristic]
|
||||||
|
|
||||||
@@ -204,9 +204,8 @@ class HeartRateServiceProxy(ProfileServiceProxy):
|
|||||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC
|
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC
|
||||||
):
|
):
|
||||||
self.heart_rate_measurement = DelegatedCharacteristicAdapter(
|
self.heart_rate_measurement = SerializableCharacteristicAdapter(
|
||||||
characteristics[0],
|
characteristics[0], HeartRateService.HeartRateMeasurement
|
||||||
decode=HeartRateService.HeartRateMeasurement.from_bytes,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.heart_rate_measurement = None
|
self.heart_rate_measurement = None
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from bumble.device import Connection
|
||||||
|
from bumble.att import ATT_Error
|
||||||
|
from bumble.gatt import (
|
||||||
|
Characteristic,
|
||||||
|
DelegatedCharacteristicAdapter,
|
||||||
|
TemplateService,
|
||||||
|
CharacteristicValue,
|
||||||
|
UTF8CharacteristicAdapter,
|
||||||
|
InvalidServiceError,
|
||||||
|
GATT_VOLUME_OFFSET_CONTROL_SERVICE,
|
||||||
|
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
|
||||||
|
GATT_AUDIO_LOCATION_CHARACTERISTIC,
|
||||||
|
GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC,
|
||||||
|
GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
|
||||||
|
)
|
||||||
|
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
|
||||||
|
from bumble.utils import OpenIntEnum
|
||||||
|
from bumble.profiles.bap import AudioLocation
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MIN_VOLUME_OFFSET = -255
|
||||||
|
MAX_VOLUME_OFFSET = 255
|
||||||
|
CHANGE_COUNTER_MAX_VALUE = 0xFF
|
||||||
|
|
||||||
|
|
||||||
|
class SetVolumeOffsetOpCode(OpenIntEnum):
|
||||||
|
SET_VOLUME_OFFSET = 0x01
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCode(OpenIntEnum):
|
||||||
|
"""
|
||||||
|
See Volume Offset Control Service 1.6. Application error codes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
INVALID_CHANGE_COUNTER = 0x80
|
||||||
|
OPCODE_NOT_SUPPORTED = 0x81
|
||||||
|
VALUE_OUT_OF_RANGE = 0x82
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
|
class VolumeOffsetState:
|
||||||
|
volume_offset: int = 0
|
||||||
|
change_counter: int = 0
|
||||||
|
attribute_value: Optional[CharacteristicValue] = None
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return struct.pack('<hB', self.volume_offset, self.change_counter)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes):
|
||||||
|
volume_offset, change_counter = struct.unpack('<hB', data)
|
||||||
|
return cls(volume_offset, change_counter)
|
||||||
|
|
||||||
|
def increment_change_counter(self) -> None:
|
||||||
|
self.change_counter = (self.change_counter + 1) % (CHANGE_COUNTER_MAX_VALUE + 1)
|
||||||
|
|
||||||
|
async def notify_subscribers_via_connection(self, connection: Connection) -> None:
|
||||||
|
assert self.attribute_value is not None
|
||||||
|
await connection.device.notify_subscribers(
|
||||||
|
attribute=self.attribute_value, value=bytes(self)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_read(self, _connection: Optional[Connection]) -> bytes:
|
||||||
|
return bytes(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VocsAudioLocation:
|
||||||
|
audio_location: AudioLocation = AudioLocation.NOT_ALLOWED
|
||||||
|
attribute_value: Optional[CharacteristicValue] = None
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return struct.pack('<I', self.audio_location)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes):
|
||||||
|
audio_location = AudioLocation(struct.unpack('<I', data)[0])
|
||||||
|
return cls(audio_location)
|
||||||
|
|
||||||
|
def on_read(self, _connection: Optional[Connection]) -> bytes:
|
||||||
|
return bytes(self)
|
||||||
|
|
||||||
|
async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
|
||||||
|
assert connection
|
||||||
|
assert self.attribute_value
|
||||||
|
|
||||||
|
self.audio_location = AudioLocation(int.from_bytes(value, 'little'))
|
||||||
|
await connection.device.notify_subscribers(
|
||||||
|
attribute=self.attribute_value, value=value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VolumeOffsetControlPoint:
|
||||||
|
volume_offset_state: VolumeOffsetState
|
||||||
|
|
||||||
|
async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
|
||||||
|
assert connection
|
||||||
|
|
||||||
|
opcode = value[0]
|
||||||
|
if opcode != SetVolumeOffsetOpCode.SET_VOLUME_OFFSET:
|
||||||
|
raise ATT_Error(ErrorCode.OPCODE_NOT_SUPPORTED)
|
||||||
|
|
||||||
|
change_counter, volume_offset = struct.unpack('<Bh', value[1:])
|
||||||
|
await self._set_volume_offset(connection, change_counter, volume_offset)
|
||||||
|
|
||||||
|
async def _set_volume_offset(
|
||||||
|
self,
|
||||||
|
connection: Connection,
|
||||||
|
change_counter_operand: int,
|
||||||
|
volume_offset_operand: int,
|
||||||
|
) -> None:
|
||||||
|
change_counter = self.volume_offset_state.change_counter
|
||||||
|
|
||||||
|
if change_counter != change_counter_operand:
|
||||||
|
raise ATT_Error(ErrorCode.INVALID_CHANGE_COUNTER)
|
||||||
|
|
||||||
|
if not MIN_VOLUME_OFFSET <= volume_offset_operand <= MAX_VOLUME_OFFSET:
|
||||||
|
raise ATT_Error(ErrorCode.VALUE_OUT_OF_RANGE)
|
||||||
|
|
||||||
|
self.volume_offset_state.volume_offset = volume_offset_operand
|
||||||
|
self.volume_offset_state.increment_change_counter()
|
||||||
|
await self.volume_offset_state.notify_subscribers_via_connection(connection)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AudioOutputDescription:
|
||||||
|
audio_output_description: str = ''
|
||||||
|
attribute_value: Optional[CharacteristicValue] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes):
|
||||||
|
return cls(audio_output_description=data.decode('utf-8'))
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return self.audio_output_description.encode('utf-8')
|
||||||
|
|
||||||
|
def on_read(self, _connection: Optional[Connection]) -> bytes:
|
||||||
|
return bytes(self)
|
||||||
|
|
||||||
|
async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
|
||||||
|
assert connection
|
||||||
|
assert self.attribute_value
|
||||||
|
|
||||||
|
self.audio_output_description = value.decode('utf-8')
|
||||||
|
await connection.device.notify_subscribers(
|
||||||
|
attribute=self.attribute_value, value=value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class VolumeOffsetControlService(TemplateService):
|
||||||
|
UUID = GATT_VOLUME_OFFSET_CONTROL_SERVICE
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
volume_offset_state: Optional[VolumeOffsetState] = None,
|
||||||
|
audio_location: Optional[VocsAudioLocation] = None,
|
||||||
|
audio_output_description: Optional[AudioOutputDescription] = None,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
self.volume_offset_state = (
|
||||||
|
VolumeOffsetState() if volume_offset_state is None else volume_offset_state
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_location = (
|
||||||
|
VocsAudioLocation() if audio_location is None else audio_location
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_output_description = (
|
||||||
|
AudioOutputDescription()
|
||||||
|
if audio_output_description is None
|
||||||
|
else audio_output_description
|
||||||
|
)
|
||||||
|
|
||||||
|
self.volume_offset_control_point: VolumeOffsetControlPoint = (
|
||||||
|
VolumeOffsetControlPoint(self.volume_offset_state)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.volume_offset_state_characteristic = DelegatedCharacteristicAdapter(
|
||||||
|
Characteristic(
|
||||||
|
uuid=GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
|
||||||
|
properties=(
|
||||||
|
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY
|
||||||
|
),
|
||||||
|
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
|
value=CharacteristicValue(read=self.volume_offset_state.on_read),
|
||||||
|
),
|
||||||
|
encode=lambda value: bytes(value),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_location_characteristic = DelegatedCharacteristicAdapter(
|
||||||
|
Characteristic(
|
||||||
|
uuid=GATT_AUDIO_LOCATION_CHARACTERISTIC,
|
||||||
|
properties=(
|
||||||
|
Characteristic.Properties.READ
|
||||||
|
| Characteristic.Properties.NOTIFY
|
||||||
|
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
|
||||||
|
| Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
|
||||||
|
),
|
||||||
|
value=CharacteristicValue(
|
||||||
|
read=self.audio_location.on_read,
|
||||||
|
write=self.audio_location.on_write,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
encode=lambda value: bytes(value),
|
||||||
|
decode=VocsAudioLocation.from_bytes,
|
||||||
|
)
|
||||||
|
self.audio_location.attribute_value = self.audio_location_characteristic.value
|
||||||
|
|
||||||
|
self.volume_offset_control_point_characteristic = Characteristic(
|
||||||
|
uuid=GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC,
|
||||||
|
properties=Characteristic.Properties.WRITE,
|
||||||
|
permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||||
|
value=CharacteristicValue(write=self.volume_offset_control_point.on_write),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_output_description_characteristic = DelegatedCharacteristicAdapter(
|
||||||
|
Characteristic(
|
||||||
|
uuid=GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
|
||||||
|
properties=(
|
||||||
|
Characteristic.Properties.READ
|
||||||
|
| Characteristic.Properties.NOTIFY
|
||||||
|
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
|
||||||
|
| Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
|
||||||
|
),
|
||||||
|
value=CharacteristicValue(
|
||||||
|
read=self.audio_output_description.on_read,
|
||||||
|
write=self.audio_output_description.on_write,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.audio_output_description.attribute_value = (
|
||||||
|
self.audio_output_description_characteristic.value
|
||||||
|
)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
characteristics=[
|
||||||
|
self.volume_offset_state_characteristic, # type: ignore
|
||||||
|
self.audio_location_characteristic, # type: ignore
|
||||||
|
self.volume_offset_control_point_characteristic, # type: ignore
|
||||||
|
self.audio_output_description_characteristic, # type: ignore
|
||||||
|
],
|
||||||
|
primary=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Client
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class VolumeOffsetControlServiceProxy(ProfileServiceProxy):
|
||||||
|
SERVICE_CLASS = VolumeOffsetControlService
|
||||||
|
|
||||||
|
def __init__(self, service_proxy: ServiceProxy) -> None:
|
||||||
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
|
if not (
|
||||||
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise InvalidServiceError("Volume Offset State characteristic not found")
|
||||||
|
self.volume_offset_state = DelegatedCharacteristicAdapter(
|
||||||
|
characteristics[0], decode=VolumeOffsetState.from_bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_AUDIO_LOCATION_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise InvalidServiceError("Audio Location characteristic not found")
|
||||||
|
self.audio_location = DelegatedCharacteristicAdapter(
|
||||||
|
characteristics[0],
|
||||||
|
encode=lambda value: bytes(value),
|
||||||
|
decode=VocsAudioLocation.from_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise InvalidServiceError(
|
||||||
|
"Volume Offset Control Point characteristic not found"
|
||||||
|
)
|
||||||
|
self.volume_offset_control_point = characteristics[0]
|
||||||
|
|
||||||
|
if not (
|
||||||
|
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
|
GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise InvalidServiceError(
|
||||||
|
"Audio Output Description characteristic not found"
|
||||||
|
)
|
||||||
|
self.audio_output_description = UTF8CharacteristicAdapter(characteristics[0])
|
||||||
+225
-99
@@ -16,15 +16,21 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
|
from typing import Iterable, NewType, Optional, Union, Sequence, Type, TYPE_CHECKING
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from . import core, l2cap
|
from bumble import core, l2cap
|
||||||
from .colors import color
|
from bumble.colors import color
|
||||||
from .core import InvalidStateError, InvalidArgumentError, InvalidPacketError
|
from bumble.core import (
|
||||||
from .hci import HCI_Object, name_or_number, key_with_value
|
InvalidStateError,
|
||||||
|
InvalidArgumentError,
|
||||||
|
InvalidPacketError,
|
||||||
|
ProtocolError,
|
||||||
|
)
|
||||||
|
from bumble.hci import HCI_Object, name_or_number, key_with_value
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .device import Device, Connection
|
from .device import Device, Connection
|
||||||
@@ -242,11 +248,11 @@ class DataElement:
|
|||||||
return DataElement(DataElement.BOOLEAN, value)
|
return DataElement(DataElement.BOOLEAN, value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sequence(value: List[DataElement]) -> DataElement:
|
def sequence(value: Iterable[DataElement]) -> DataElement:
|
||||||
return DataElement(DataElement.SEQUENCE, value)
|
return DataElement(DataElement.SEQUENCE, value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def alternative(value: List[DataElement]) -> DataElement:
|
def alternative(value: Iterable[DataElement]) -> DataElement:
|
||||||
return DataElement(DataElement.ALTERNATIVE, value)
|
return DataElement(DataElement.ALTERNATIVE, value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -344,9 +350,6 @@ class DataElement:
|
|||||||
] # Keep a copy so we can re-serialize to an exact replica
|
] # Keep a copy so we can re-serialize to an exact replica
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def to_bytes(self):
|
|
||||||
return bytes(self)
|
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self):
|
||||||
# Return early if we have a cache
|
# Return early if we have a cache
|
||||||
if self.bytes:
|
if self.bytes:
|
||||||
@@ -434,6 +437,8 @@ class DataElement:
|
|||||||
if size != 1:
|
if size != 1:
|
||||||
raise InvalidArgumentError('boolean must be 1 byte')
|
raise InvalidArgumentError('boolean must be 1 byte')
|
||||||
size_index = 0
|
size_index = 0
|
||||||
|
else:
|
||||||
|
raise RuntimeError("internal error - self.type not supported")
|
||||||
|
|
||||||
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
||||||
return self.bytes
|
return self.bytes
|
||||||
@@ -474,7 +479,9 @@ class ServiceAttribute:
|
|||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
|
def list_from_data_elements(
|
||||||
|
elements: Sequence[DataElement],
|
||||||
|
) -> list[ServiceAttribute]:
|
||||||
attribute_list = []
|
attribute_list = []
|
||||||
for i in range(0, len(elements) // 2):
|
for i in range(0, len(elements) // 2):
|
||||||
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
|
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
|
||||||
@@ -487,7 +494,7 @@ class ServiceAttribute:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_attribute_in_list(
|
def find_attribute_in_list(
|
||||||
attribute_list: List[ServiceAttribute], attribute_id: int
|
attribute_list: Iterable[ServiceAttribute], attribute_id: int
|
||||||
) -> Optional[DataElement]:
|
) -> Optional[DataElement]:
|
||||||
return next(
|
return next(
|
||||||
(
|
(
|
||||||
@@ -535,7 +542,12 @@ class SDP_PDU:
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.2 PROTOCOL DATA UNIT FORMAT
|
See Bluetooth spec @ Vol 3, Part B - 4.2 PROTOCOL DATA UNIT FORMAT
|
||||||
'''
|
'''
|
||||||
|
|
||||||
sdp_pdu_classes: Dict[int, Type[SDP_PDU]] = {}
|
RESPONSE_PDU_IDS = {
|
||||||
|
SDP_SERVICE_SEARCH_REQUEST: SDP_SERVICE_SEARCH_RESPONSE,
|
||||||
|
SDP_SERVICE_ATTRIBUTE_REQUEST: SDP_SERVICE_ATTRIBUTE_RESPONSE,
|
||||||
|
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST: SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE,
|
||||||
|
}
|
||||||
|
sdp_pdu_classes: dict[int, Type[SDP_PDU]] = {}
|
||||||
name = None
|
name = None
|
||||||
pdu_id = 0
|
pdu_id = 0
|
||||||
|
|
||||||
@@ -559,7 +571,7 @@ class SDP_PDU:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_service_record_handle_list_preceded_by_count(
|
def parse_service_record_handle_list_preceded_by_count(
|
||||||
data: bytes, offset: int
|
data: bytes, offset: int
|
||||||
) -> Tuple[int, List[int]]:
|
) -> tuple[int, list[int]]:
|
||||||
count = struct.unpack_from('>H', data, offset - 2)[0]
|
count = struct.unpack_from('>H', data, offset - 2)[0]
|
||||||
handle_list = [
|
handle_list = [
|
||||||
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
||||||
@@ -621,11 +633,8 @@ class SDP_PDU:
|
|||||||
def init_from_bytes(self, pdu, offset):
|
def init_from_bytes(self, pdu, offset):
|
||||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||||
|
|
||||||
def to_bytes(self):
|
|
||||||
return self.pdu
|
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self):
|
||||||
return self.to_bytes()
|
return self.pdu
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
result = f'{color(self.name, "blue")} [TID={self.transaction_id}]'
|
result = f'{color(self.name, "blue")} [TID={self.transaction_id}]'
|
||||||
@@ -643,6 +652,8 @@ class SDP_ErrorResponse(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.4.1 SDP_ErrorResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.4.1 SDP_ErrorResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
error_code: int
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass(
|
||||||
@@ -679,7 +690,7 @@ class SDP_ServiceSearchResponse(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
service_record_handle_list: List[int]
|
service_record_handle_list: list[int]
|
||||||
total_service_record_count: int
|
total_service_record_count: int
|
||||||
current_service_record_count: int
|
current_service_record_count: int
|
||||||
continuation_state: bytes
|
continuation_state: bytes
|
||||||
@@ -756,31 +767,99 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
attribute_list_byte_count: int
|
attribute_lists_byte_count: int
|
||||||
attribute_list: bytes
|
attribute_lists: bytes
|
||||||
continuation_state: bytes
|
continuation_state: bytes
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Client:
|
class Client:
|
||||||
channel: Optional[l2cap.ClassicChannel]
|
def __init__(self, connection: Connection, mtu: int = 0) -> None:
|
||||||
|
|
||||||
def __init__(self, connection: Connection) -> None:
|
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.pending_request = None
|
self.channel: Optional[l2cap.ClassicChannel] = None
|
||||||
self.channel = None
|
self.mtu = mtu
|
||||||
|
self.request_semaphore = asyncio.Semaphore(1)
|
||||||
|
self.pending_request: Optional[SDP_PDU] = None
|
||||||
|
self.pending_response: Optional[asyncio.futures.Future[SDP_PDU]] = None
|
||||||
|
self.next_transaction_id = 0
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
self.channel = await self.connection.create_l2cap_channel(
|
self.channel = await self.connection.create_l2cap_channel(
|
||||||
spec=l2cap.ClassicChannelSpec(SDP_PSM)
|
spec=(
|
||||||
|
l2cap.ClassicChannelSpec(SDP_PSM, self.mtu)
|
||||||
|
if self.mtu
|
||||||
|
else l2cap.ClassicChannelSpec(SDP_PSM)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
self.channel.sink = self.on_pdu
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
if self.channel:
|
if self.channel:
|
||||||
await self.channel.disconnect()
|
await self.channel.disconnect()
|
||||||
self.channel = None
|
self.channel = None
|
||||||
|
|
||||||
async def search_services(self, uuids: List[core.UUID]) -> List[int]:
|
def make_transaction_id(self) -> int:
|
||||||
|
transaction_id = self.next_transaction_id
|
||||||
|
self.next_transaction_id = (self.next_transaction_id + 1) & 0xFFFF
|
||||||
|
return transaction_id
|
||||||
|
|
||||||
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
|
if not self.pending_request:
|
||||||
|
logger.warning('received response with no pending request')
|
||||||
|
return
|
||||||
|
assert self.pending_response is not None
|
||||||
|
|
||||||
|
response = SDP_PDU.from_bytes(pdu)
|
||||||
|
|
||||||
|
# Check that the transaction ID is what we expect
|
||||||
|
if self.pending_request.transaction_id != response.transaction_id:
|
||||||
|
logger.warning(
|
||||||
|
f"received response with transaction ID {response.transaction_id} "
|
||||||
|
f"but expected {self.pending_request.transaction_id}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if the response is an error
|
||||||
|
if isinstance(response, SDP_ErrorResponse):
|
||||||
|
self.pending_response.set_exception(
|
||||||
|
ProtocolError(error_code=response.error_code)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check that the type of the response matches the request
|
||||||
|
if response.pdu_id != SDP_PDU.RESPONSE_PDU_IDS.get(self.pending_request.pdu_id):
|
||||||
|
logger.warning("response type mismatch")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.pending_response.set_result(response)
|
||||||
|
|
||||||
|
async def send_request(self, request: SDP_PDU) -> SDP_PDU:
|
||||||
|
assert self.channel is not None
|
||||||
|
async with self.request_semaphore:
|
||||||
|
assert self.pending_request is None
|
||||||
|
assert self.pending_response is None
|
||||||
|
|
||||||
|
# Create a future value to hold the eventual response
|
||||||
|
self.pending_response = asyncio.get_running_loop().create_future()
|
||||||
|
self.pending_request = request
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.channel.send_pdu(bytes(request))
|
||||||
|
return await self.pending_response
|
||||||
|
finally:
|
||||||
|
self.pending_request = None
|
||||||
|
self.pending_response = None
|
||||||
|
|
||||||
|
async def search_services(self, uuids: Iterable[core.UUID]) -> list[int]:
|
||||||
|
"""
|
||||||
|
Search for services by UUID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
uuids: service the UUIDs to search for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of matching service record handles.
|
||||||
|
"""
|
||||||
if self.pending_request is not None:
|
if self.pending_request is not None:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
if self.channel is None:
|
if self.channel is None:
|
||||||
@@ -795,16 +874,16 @@ class Client:
|
|||||||
continuation_state = bytes([0])
|
continuation_state = bytes([0])
|
||||||
watchdog = SDP_CONTINUATION_WATCHDOG
|
watchdog = SDP_CONTINUATION_WATCHDOG
|
||||||
while watchdog > 0:
|
while watchdog > 0:
|
||||||
response_pdu = await self.channel.send_request(
|
response = await self.send_request(
|
||||||
SDP_ServiceSearchRequest(
|
SDP_ServiceSearchRequest(
|
||||||
transaction_id=0, # Transaction ID TODO: pick a real value
|
transaction_id=self.make_transaction_id(),
|
||||||
service_search_pattern=service_search_pattern,
|
service_search_pattern=service_search_pattern,
|
||||||
maximum_service_record_count=0xFFFF,
|
maximum_service_record_count=0xFFFF,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
response = SDP_PDU.from_bytes(response_pdu)
|
|
||||||
logger.debug(f'<<< Response: {response}')
|
logger.debug(f'<<< Response: {response}')
|
||||||
|
assert isinstance(response, SDP_ServiceSearchResponse)
|
||||||
service_record_handle_list += response.service_record_handle_list
|
service_record_handle_list += response.service_record_handle_list
|
||||||
continuation_state = response.continuation_state
|
continuation_state = response.continuation_state
|
||||||
if len(continuation_state) == 1 and continuation_state[0] == 0:
|
if len(continuation_state) == 1 and continuation_state[0] == 0:
|
||||||
@@ -815,8 +894,21 @@ class Client:
|
|||||||
return service_record_handle_list
|
return service_record_handle_list
|
||||||
|
|
||||||
async def search_attributes(
|
async def search_attributes(
|
||||||
self, uuids: List[core.UUID], attribute_ids: List[Union[int, Tuple[int, int]]]
|
self,
|
||||||
) -> List[List[ServiceAttribute]]:
|
uuids: Iterable[core.UUID],
|
||||||
|
attribute_ids: Iterable[Union[int, tuple[int, int]]],
|
||||||
|
) -> list[list[ServiceAttribute]]:
|
||||||
|
"""
|
||||||
|
Search for attributes by UUID and attribute IDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
uuids: the service UUIDs to search for.
|
||||||
|
attribute_ids: list of attribute IDs or (start, end) attribute ID ranges.
|
||||||
|
(use (0, 0xFFFF) to include all attributes)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of list of attributes, one list per matching service.
|
||||||
|
"""
|
||||||
if self.pending_request is not None:
|
if self.pending_request is not None:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
if self.channel is None:
|
if self.channel is None:
|
||||||
@@ -828,8 +920,8 @@ class Client:
|
|||||||
attribute_id_list = DataElement.sequence(
|
attribute_id_list = DataElement.sequence(
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
DataElement.unsigned_integer(
|
DataElement.unsigned_integer_32(
|
||||||
attribute_id[0], value_size=attribute_id[1]
|
attribute_id[0] << 16 | attribute_id[1]
|
||||||
)
|
)
|
||||||
if isinstance(attribute_id, tuple)
|
if isinstance(attribute_id, tuple)
|
||||||
else DataElement.unsigned_integer_16(attribute_id)
|
else DataElement.unsigned_integer_16(attribute_id)
|
||||||
@@ -843,17 +935,17 @@ class Client:
|
|||||||
continuation_state = bytes([0])
|
continuation_state = bytes([0])
|
||||||
watchdog = SDP_CONTINUATION_WATCHDOG
|
watchdog = SDP_CONTINUATION_WATCHDOG
|
||||||
while watchdog > 0:
|
while watchdog > 0:
|
||||||
response_pdu = await self.channel.send_request(
|
response = await self.send_request(
|
||||||
SDP_ServiceSearchAttributeRequest(
|
SDP_ServiceSearchAttributeRequest(
|
||||||
transaction_id=0, # Transaction ID TODO: pick a real value
|
transaction_id=self.make_transaction_id(),
|
||||||
service_search_pattern=service_search_pattern,
|
service_search_pattern=service_search_pattern,
|
||||||
maximum_attribute_byte_count=0xFFFF,
|
maximum_attribute_byte_count=0xFFFF,
|
||||||
attribute_id_list=attribute_id_list,
|
attribute_id_list=attribute_id_list,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
response = SDP_PDU.from_bytes(response_pdu)
|
|
||||||
logger.debug(f'<<< Response: {response}')
|
logger.debug(f'<<< Response: {response}')
|
||||||
|
assert isinstance(response, SDP_ServiceSearchAttributeResponse)
|
||||||
accumulator += response.attribute_lists
|
accumulator += response.attribute_lists
|
||||||
continuation_state = response.continuation_state
|
continuation_state = response.continuation_state
|
||||||
if len(continuation_state) == 1 and continuation_state[0] == 0:
|
if len(continuation_state) == 1 and continuation_state[0] == 0:
|
||||||
@@ -876,8 +968,18 @@ class Client:
|
|||||||
async def get_attributes(
|
async def get_attributes(
|
||||||
self,
|
self,
|
||||||
service_record_handle: int,
|
service_record_handle: int,
|
||||||
attribute_ids: List[Union[int, Tuple[int, int]]],
|
attribute_ids: Iterable[Union[int, tuple[int, int]]],
|
||||||
) -> List[ServiceAttribute]:
|
) -> list[ServiceAttribute]:
|
||||||
|
"""
|
||||||
|
Get attributes for a service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_record_handle: the handle for a service
|
||||||
|
attribute_ids: list or attribute IDs or (start, end) attribute ID handles.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of attributes.
|
||||||
|
"""
|
||||||
if self.pending_request is not None:
|
if self.pending_request is not None:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
if self.channel is None:
|
if self.channel is None:
|
||||||
@@ -886,8 +988,8 @@ class Client:
|
|||||||
attribute_id_list = DataElement.sequence(
|
attribute_id_list = DataElement.sequence(
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
DataElement.unsigned_integer(
|
DataElement.unsigned_integer_32(
|
||||||
attribute_id[0], value_size=attribute_id[1]
|
attribute_id[0] << 16 | attribute_id[1]
|
||||||
)
|
)
|
||||||
if isinstance(attribute_id, tuple)
|
if isinstance(attribute_id, tuple)
|
||||||
else DataElement.unsigned_integer_16(attribute_id)
|
else DataElement.unsigned_integer_16(attribute_id)
|
||||||
@@ -901,17 +1003,17 @@ class Client:
|
|||||||
continuation_state = bytes([0])
|
continuation_state = bytes([0])
|
||||||
watchdog = SDP_CONTINUATION_WATCHDOG
|
watchdog = SDP_CONTINUATION_WATCHDOG
|
||||||
while watchdog > 0:
|
while watchdog > 0:
|
||||||
response_pdu = await self.channel.send_request(
|
response = await self.send_request(
|
||||||
SDP_ServiceAttributeRequest(
|
SDP_ServiceAttributeRequest(
|
||||||
transaction_id=0, # Transaction ID TODO: pick a real value
|
transaction_id=self.make_transaction_id(),
|
||||||
service_record_handle=service_record_handle,
|
service_record_handle=service_record_handle,
|
||||||
maximum_attribute_byte_count=0xFFFF,
|
maximum_attribute_byte_count=0xFFFF,
|
||||||
attribute_id_list=attribute_id_list,
|
attribute_id_list=attribute_id_list,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
response = SDP_PDU.from_bytes(response_pdu)
|
|
||||||
logger.debug(f'<<< Response: {response}')
|
logger.debug(f'<<< Response: {response}')
|
||||||
|
assert isinstance(response, SDP_ServiceAttributeResponse)
|
||||||
accumulator += response.attribute_list
|
accumulator += response.attribute_list
|
||||||
continuation_state = response.continuation_state
|
continuation_state = response.continuation_state
|
||||||
if len(continuation_state) == 1 and continuation_state[0] == 0:
|
if len(continuation_state) == 1 and continuation_state[0] == 0:
|
||||||
@@ -937,17 +1039,17 @@ class Client:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server:
|
class Server:
|
||||||
CONTINUATION_STATE = bytes([0x01, 0x43])
|
CONTINUATION_STATE = bytes([0x01, 0x00])
|
||||||
channel: Optional[l2cap.ClassicChannel]
|
channel: Optional[l2cap.ClassicChannel]
|
||||||
Service = NewType('Service', List[ServiceAttribute])
|
Service = NewType('Service', list[ServiceAttribute])
|
||||||
service_records: Dict[int, Service]
|
service_records: dict[int, Service]
|
||||||
current_response: Union[None, bytes, Tuple[int, List[int]]]
|
current_response: Union[None, bytes, tuple[int, list[int]]]
|
||||||
|
|
||||||
def __init__(self, device: Device) -> None:
|
def __init__(self, device: Device) -> None:
|
||||||
self.device = device
|
self.device = device
|
||||||
self.service_records = {} # Service records maps, by record handle
|
self.service_records = {} # Service records maps, by record handle
|
||||||
self.channel = None
|
self.channel = None
|
||||||
self.current_response = None
|
self.current_response = None # Current response data, used for continuations
|
||||||
|
|
||||||
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
|
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
|
||||||
l2cap_channel_manager.create_classic_server(
|
l2cap_channel_manager.create_classic_server(
|
||||||
@@ -958,7 +1060,7 @@ class Server:
|
|||||||
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
||||||
self.channel.send_pdu(response)
|
self.channel.send_pdu(response)
|
||||||
|
|
||||||
def match_services(self, search_pattern: DataElement) -> Dict[int, Service]:
|
def match_services(self, search_pattern: DataElement) -> dict[int, Service]:
|
||||||
# Find the services for which the attributes in the pattern is a subset of the
|
# Find the services for which the attributes in the pattern is a subset of the
|
||||||
# service's attribute values (NOTE: the value search recurses into sequences)
|
# service's attribute values (NOTE: the value search recurses into sequences)
|
||||||
matching_services = {}
|
matching_services = {}
|
||||||
@@ -1015,6 +1117,31 @@ class Server:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def check_continuation(
|
||||||
|
self,
|
||||||
|
continuation_state: bytes,
|
||||||
|
transaction_id: int,
|
||||||
|
) -> Optional[bool]:
|
||||||
|
# Check if this is a valid continuation
|
||||||
|
if len(continuation_state) > 1:
|
||||||
|
if (
|
||||||
|
self.current_response is None
|
||||||
|
or continuation_state != self.CONTINUATION_STATE
|
||||||
|
):
|
||||||
|
self.send_response(
|
||||||
|
SDP_ErrorResponse(
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Cleanup any partial response leftover
|
||||||
|
self.current_response = None
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def get_next_response_payload(self, maximum_size):
|
def get_next_response_payload(self, maximum_size):
|
||||||
if len(self.current_response) > maximum_size:
|
if len(self.current_response) > maximum_size:
|
||||||
payload = self.current_response[:maximum_size]
|
payload = self.current_response[:maximum_size]
|
||||||
@@ -1029,7 +1156,7 @@ class Server:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_service_attributes(
|
def get_service_attributes(
|
||||||
service: Service, attribute_ids: List[DataElement]
|
service: Service, attribute_ids: Iterable[DataElement]
|
||||||
) -> DataElement:
|
) -> DataElement:
|
||||||
attributes = []
|
attributes = []
|
||||||
for attribute_id in attribute_ids:
|
for attribute_id in attribute_ids:
|
||||||
@@ -1057,30 +1184,24 @@ class Server:
|
|||||||
|
|
||||||
def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
|
def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
|
||||||
# Check if this is a continuation
|
# Check if this is a continuation
|
||||||
if len(request.continuation_state) > 1:
|
if (
|
||||||
if self.current_response is None:
|
continuation := self.check_continuation(
|
||||||
self.send_response(
|
request.continuation_state, request.transaction_id
|
||||||
SDP_ErrorResponse(
|
)
|
||||||
transaction_id=request.transaction_id,
|
) is None:
|
||||||
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
|
return
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# Cleanup any partial response leftover
|
|
||||||
self.current_response = None
|
|
||||||
|
|
||||||
|
if not continuation:
|
||||||
# Find the matching services
|
# Find the matching services
|
||||||
matching_services = self.match_services(request.service_search_pattern)
|
matching_services = self.match_services(request.service_search_pattern)
|
||||||
service_record_handles = list(matching_services.keys())
|
service_record_handles = list(matching_services.keys())
|
||||||
|
logger.debug(f'Service Record Handles: {service_record_handles}')
|
||||||
|
|
||||||
# Only return up to the maximum requested
|
# Only return up to the maximum requested
|
||||||
service_record_handles_subset = service_record_handles[
|
service_record_handles_subset = service_record_handles[
|
||||||
: request.maximum_service_record_count
|
: request.maximum_service_record_count
|
||||||
]
|
]
|
||||||
|
|
||||||
# Serialize to a byte array, and remember the total count
|
|
||||||
logger.debug(f'Service Record Handles: {service_record_handles}')
|
|
||||||
self.current_response = (
|
self.current_response = (
|
||||||
len(service_record_handles),
|
len(service_record_handles),
|
||||||
service_record_handles_subset,
|
service_record_handles_subset,
|
||||||
@@ -1088,15 +1209,21 @@ class Server:
|
|||||||
|
|
||||||
# Respond, keeping any unsent handles for later
|
# Respond, keeping any unsent handles for later
|
||||||
assert isinstance(self.current_response, tuple)
|
assert isinstance(self.current_response, tuple)
|
||||||
service_record_handles = self.current_response[1][
|
assert self.channel is not None
|
||||||
: request.maximum_service_record_count
|
total_service_record_count, service_record_handles = self.current_response
|
||||||
|
maximum_service_record_count = (self.channel.peer_mtu - 11) // 4
|
||||||
|
service_record_handles_remaining = service_record_handles[
|
||||||
|
maximum_service_record_count:
|
||||||
]
|
]
|
||||||
|
service_record_handles = service_record_handles[:maximum_service_record_count]
|
||||||
self.current_response = (
|
self.current_response = (
|
||||||
self.current_response[0],
|
total_service_record_count,
|
||||||
self.current_response[1][request.maximum_service_record_count :],
|
service_record_handles_remaining,
|
||||||
)
|
)
|
||||||
continuation_state = (
|
continuation_state = (
|
||||||
Server.CONTINUATION_STATE if self.current_response[1] else bytes([0])
|
Server.CONTINUATION_STATE
|
||||||
|
if service_record_handles_remaining
|
||||||
|
else bytes([0])
|
||||||
)
|
)
|
||||||
service_record_handle_list = b''.join(
|
service_record_handle_list = b''.join(
|
||||||
[struct.pack('>I', handle) for handle in service_record_handles]
|
[struct.pack('>I', handle) for handle in service_record_handles]
|
||||||
@@ -1104,7 +1231,7 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceSearchResponse(
|
SDP_ServiceSearchResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
total_service_record_count=self.current_response[0],
|
total_service_record_count=total_service_record_count,
|
||||||
current_service_record_count=len(service_record_handles),
|
current_service_record_count=len(service_record_handles),
|
||||||
service_record_handle_list=service_record_handle_list,
|
service_record_handle_list=service_record_handle_list,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
@@ -1115,19 +1242,14 @@ class Server:
|
|||||||
self, request: SDP_ServiceAttributeRequest
|
self, request: SDP_ServiceAttributeRequest
|
||||||
) -> None:
|
) -> None:
|
||||||
# Check if this is a continuation
|
# Check if this is a continuation
|
||||||
if len(request.continuation_state) > 1:
|
if (
|
||||||
if self.current_response is None:
|
continuation := self.check_continuation(
|
||||||
self.send_response(
|
request.continuation_state, request.transaction_id
|
||||||
SDP_ErrorResponse(
|
)
|
||||||
transaction_id=request.transaction_id,
|
) is None:
|
||||||
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
|
return
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# Cleanup any partial response leftover
|
|
||||||
self.current_response = None
|
|
||||||
|
|
||||||
|
if not continuation:
|
||||||
# Check that the service exists
|
# Check that the service exists
|
||||||
service = self.service_records.get(request.service_record_handle)
|
service = self.service_records.get(request.service_record_handle)
|
||||||
if service is None:
|
if service is None:
|
||||||
@@ -1149,14 +1271,18 @@ class Server:
|
|||||||
self.current_response = bytes(attribute_list)
|
self.current_response = bytes(attribute_list)
|
||||||
|
|
||||||
# Respond, keeping any pending chunks for later
|
# Respond, keeping any pending chunks for later
|
||||||
|
assert self.channel is not None
|
||||||
|
maximum_attribute_byte_count = min(
|
||||||
|
request.maximum_attribute_byte_count, self.channel.peer_mtu - 9
|
||||||
|
)
|
||||||
attribute_list_response, continuation_state = self.get_next_response_payload(
|
attribute_list_response, continuation_state = self.get_next_response_payload(
|
||||||
request.maximum_attribute_byte_count
|
maximum_attribute_byte_count
|
||||||
)
|
)
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceAttributeResponse(
|
SDP_ServiceAttributeResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
attribute_list_byte_count=len(attribute_list_response),
|
attribute_list_byte_count=len(attribute_list_response),
|
||||||
attribute_list=attribute_list,
|
attribute_list=attribute_list_response,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1165,18 +1291,14 @@ class Server:
|
|||||||
self, request: SDP_ServiceSearchAttributeRequest
|
self, request: SDP_ServiceSearchAttributeRequest
|
||||||
) -> None:
|
) -> None:
|
||||||
# Check if this is a continuation
|
# Check if this is a continuation
|
||||||
if len(request.continuation_state) > 1:
|
if (
|
||||||
if self.current_response is None:
|
continuation := self.check_continuation(
|
||||||
self.send_response(
|
request.continuation_state, request.transaction_id
|
||||||
SDP_ErrorResponse(
|
)
|
||||||
transaction_id=request.transaction_id,
|
) is None:
|
||||||
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
|
return
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Cleanup any partial response leftover
|
|
||||||
self.current_response = None
|
|
||||||
|
|
||||||
|
if not continuation:
|
||||||
# Find the matching services
|
# Find the matching services
|
||||||
matching_services = self.match_services(
|
matching_services = self.match_services(
|
||||||
request.service_search_pattern
|
request.service_search_pattern
|
||||||
@@ -1196,14 +1318,18 @@ class Server:
|
|||||||
self.current_response = bytes(attribute_lists)
|
self.current_response = bytes(attribute_lists)
|
||||||
|
|
||||||
# Respond, keeping any pending chunks for later
|
# Respond, keeping any pending chunks for later
|
||||||
|
assert self.channel is not None
|
||||||
|
maximum_attribute_byte_count = min(
|
||||||
|
request.maximum_attribute_byte_count, self.channel.peer_mtu - 9
|
||||||
|
)
|
||||||
attribute_lists_response, continuation_state = self.get_next_response_payload(
|
attribute_lists_response, continuation_state = self.get_next_response_payload(
|
||||||
request.maximum_attribute_byte_count
|
maximum_attribute_byte_count
|
||||||
)
|
)
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceSearchAttributeResponse(
|
SDP_ServiceSearchAttributeResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
attribute_lists_byte_count=len(attribute_lists_response),
|
attribute_lists_byte_count=len(attribute_lists_response),
|
||||||
attribute_lists=attribute_lists,
|
attribute_lists=attribute_lists_response,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-8
@@ -298,11 +298,8 @@ class SMP_Command:
|
|||||||
def init_from_bytes(self, pdu: bytes, offset: int) -> None:
|
def init_from_bytes(self, pdu: bytes, offset: int) -> None:
|
||||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||||
|
|
||||||
def to_bytes(self):
|
|
||||||
return self.pdu
|
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self):
|
||||||
return self.to_bytes()
|
return self.pdu
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
result = color(self.name, 'yellow')
|
result = color(self.name, 'yellow')
|
||||||
@@ -698,6 +695,7 @@ class Session:
|
|||||||
self.ltk_ediv = 0
|
self.ltk_ediv = 0
|
||||||
self.ltk_rand = bytes(8)
|
self.ltk_rand = bytes(8)
|
||||||
self.link_key: Optional[bytes] = None
|
self.link_key: Optional[bytes] = None
|
||||||
|
self.maximum_encryption_key_size: int = 0
|
||||||
self.initiator_key_distribution: int = 0
|
self.initiator_key_distribution: int = 0
|
||||||
self.responder_key_distribution: int = 0
|
self.responder_key_distribution: int = 0
|
||||||
self.peer_random_value: Optional[bytes] = None
|
self.peer_random_value: Optional[bytes] = None
|
||||||
@@ -744,6 +742,10 @@ class Session:
|
|||||||
else:
|
else:
|
||||||
self.pairing_result = None
|
self.pairing_result = None
|
||||||
|
|
||||||
|
self.maximum_encryption_key_size = (
|
||||||
|
pairing_config.delegate.maximum_encryption_key_size
|
||||||
|
)
|
||||||
|
|
||||||
# Key Distribution (default values before negotiation)
|
# Key Distribution (default values before negotiation)
|
||||||
self.initiator_key_distribution = (
|
self.initiator_key_distribution = (
|
||||||
pairing_config.delegate.local_initiator_key_distribution
|
pairing_config.delegate.local_initiator_key_distribution
|
||||||
@@ -996,7 +998,7 @@ class Session:
|
|||||||
io_capability=self.io_capability,
|
io_capability=self.io_capability,
|
||||||
oob_data_flag=self.oob_data_flag,
|
oob_data_flag=self.oob_data_flag,
|
||||||
auth_req=self.auth_req,
|
auth_req=self.auth_req,
|
||||||
maximum_encryption_key_size=16,
|
maximum_encryption_key_size=self.maximum_encryption_key_size,
|
||||||
initiator_key_distribution=self.initiator_key_distribution,
|
initiator_key_distribution=self.initiator_key_distribution,
|
||||||
responder_key_distribution=self.responder_key_distribution,
|
responder_key_distribution=self.responder_key_distribution,
|
||||||
)
|
)
|
||||||
@@ -1008,7 +1010,7 @@ class Session:
|
|||||||
io_capability=self.io_capability,
|
io_capability=self.io_capability,
|
||||||
oob_data_flag=self.oob_data_flag,
|
oob_data_flag=self.oob_data_flag,
|
||||||
auth_req=self.auth_req,
|
auth_req=self.auth_req,
|
||||||
maximum_encryption_key_size=16,
|
maximum_encryption_key_size=self.maximum_encryption_key_size,
|
||||||
initiator_key_distribution=self.initiator_key_distribution,
|
initiator_key_distribution=self.initiator_key_distribution,
|
||||||
responder_key_distribution=self.responder_key_distribution,
|
responder_key_distribution=self.responder_key_distribution,
|
||||||
)
|
)
|
||||||
@@ -1839,7 +1841,7 @@ class Session:
|
|||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
if self.pairing_method == PairingMethod.OOB:
|
if self.pairing_method == PairingMethod.OOB:
|
||||||
self.send_pairing_random_command()
|
self.send_pairing_random_command()
|
||||||
else:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
else:
|
else:
|
||||||
if self.pairing_method == PairingMethod.PASSKEY:
|
if self.pairing_method == PairingMethod.PASSKEY:
|
||||||
@@ -1949,7 +1951,7 @@ class Manager(EventEmitter):
|
|||||||
f'{connection.peer_address}: {command}'
|
f'{connection.peer_address}: {command}'
|
||||||
)
|
)
|
||||||
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
|
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
|
||||||
connection.send_l2cap_pdu(cid, command.to_bytes())
|
connection.send_l2cap_pdu(cid, bytes(command))
|
||||||
|
|
||||||
def on_smp_security_request_command(
|
def on_smp_security_request_command(
|
||||||
self, connection: Connection, request: SMP_Security_Request_Command
|
self, connection: Connection, request: SMP_Security_Request_Command
|
||||||
|
|||||||
@@ -370,11 +370,13 @@ class PumpedPacketSource(ParserSource):
|
|||||||
self.parser.feed_data(packet)
|
self.parser.feed_data(packet)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug('source pump task done')
|
logger.debug('source pump task done')
|
||||||
self.terminated.set_result(None)
|
if not self.terminated.done():
|
||||||
|
self.terminated.set_result(None)
|
||||||
break
|
break
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(f'exception while waiting for packet: {error}')
|
logger.warning(f'exception while waiting for packet: {error}')
|
||||||
self.terminated.set_exception(error)
|
if not self.terminated.done():
|
||||||
|
self.terminated.set_exception(error)
|
||||||
break
|
break
|
||||||
|
|
||||||
self.pump_task = asyncio.create_task(pump_packets())
|
self.pump_task = asyncio.create_task(pump_packets())
|
||||||
|
|||||||
@@ -149,7 +149,10 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
|
|
||||||
if status != usb1.TRANSFER_COMPLETED:
|
if status != usb1.TRANSFER_COMPLETED:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color(f'!!! OUT transfer not completed: status={status}', 'red')
|
color(
|
||||||
|
f'!!! OUT transfer not completed: status={status}',
|
||||||
|
'red',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def process_queue(self):
|
async def process_queue(self):
|
||||||
@@ -275,7 +278,10 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color(f'!!! IN transfer not completed: status={status}', 'red')
|
color(
|
||||||
|
f'!!! IN[{packet_type}] transfer not completed: status={status}',
|
||||||
|
'red',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.loop.call_soon_threadsafe(self.on_transport_lost)
|
self.loop.call_soon_threadsafe(self.on_transport_lost)
|
||||||
|
|
||||||
|
|||||||
+21
-6
@@ -24,17 +24,19 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from typing import (
|
from typing import (
|
||||||
Awaitable,
|
|
||||||
Set,
|
|
||||||
TypeVar,
|
|
||||||
List,
|
|
||||||
Tuple,
|
|
||||||
Callable,
|
|
||||||
Any,
|
Any,
|
||||||
|
Awaitable,
|
||||||
|
Callable,
|
||||||
|
List,
|
||||||
Optional,
|
Optional,
|
||||||
|
Protocol,
|
||||||
|
Set,
|
||||||
|
Tuple,
|
||||||
|
TypeVar,
|
||||||
Union,
|
Union,
|
||||||
overload,
|
overload,
|
||||||
)
|
)
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
@@ -487,3 +489,16 @@ class OpenIntEnum(enum.IntEnum):
|
|||||||
obj._value_ = value
|
obj._value_ = value
|
||||||
obj._name_ = f"{cls.__name__}[{value}]"
|
obj._name_ = f"{cls.__name__}[{value}]"
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ByteSerializable(Protocol):
|
||||||
|
"""
|
||||||
|
Type protocol for classes that can be instantiated from bytes and serialized
|
||||||
|
to bytes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> Self: ...
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes: ...
|
||||||
|
|||||||
Vendored
+29
-4
@@ -16,6 +16,7 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import struct
|
import struct
|
||||||
|
from typing import Dict, Optional, Type
|
||||||
|
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
name_or_number,
|
name_or_number,
|
||||||
@@ -24,7 +25,9 @@ from bumble.hci import (
|
|||||||
HCI_Constant,
|
HCI_Constant,
|
||||||
HCI_Object,
|
HCI_Object,
|
||||||
HCI_Command,
|
HCI_Command,
|
||||||
HCI_Vendor_Event,
|
HCI_Event,
|
||||||
|
HCI_Extended_Event,
|
||||||
|
HCI_VENDOR_EVENT,
|
||||||
STATUS_SPEC,
|
STATUS_SPEC,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,7 +51,6 @@ HCI_DYNAMIC_AUDIO_BUFFER_COMMAND = hci_vendor_command_op_code(0x15F)
|
|||||||
HCI_BLUETOOTH_QUALITY_REPORT_EVENT = 0x58
|
HCI_BLUETOOTH_QUALITY_REPORT_EVENT = 0x58
|
||||||
|
|
||||||
HCI_Command.register_commands(globals())
|
HCI_Command.register_commands(globals())
|
||||||
HCI_Vendor_Event.register_subevents(globals())
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -279,7 +281,29 @@ class HCI_Dynamic_Audio_Buffer_Command(HCI_Command):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Vendor_Event.event(
|
class HCI_Android_Vendor_Event(HCI_Extended_Event):
|
||||||
|
event_code: int = HCI_VENDOR_EVENT
|
||||||
|
subevent_classes: Dict[int, Type[HCI_Extended_Event]] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def subclass_from_parameters(
|
||||||
|
cls, parameters: bytes
|
||||||
|
) -> Optional[HCI_Extended_Event]:
|
||||||
|
subevent_code = parameters[0]
|
||||||
|
if subevent_code == HCI_BLUETOOTH_QUALITY_REPORT_EVENT:
|
||||||
|
quality_report_id = parameters[1]
|
||||||
|
if quality_report_id in (0x01, 0x02, 0x03, 0x04, 0x07, 0x08, 0x09):
|
||||||
|
return HCI_Bluetooth_Quality_Report_Event.from_parameters(parameters)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
HCI_Android_Vendor_Event.register_subevents(globals())
|
||||||
|
HCI_Event.add_vendor_factory(HCI_Android_Vendor_Event.subclass_from_parameters)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@HCI_Extended_Event.event(
|
||||||
fields=[
|
fields=[
|
||||||
('quality_report_id', 1),
|
('quality_report_id', 1),
|
||||||
('packet_types', 1),
|
('packet_types', 1),
|
||||||
@@ -308,10 +332,11 @@ class HCI_Dynamic_Audio_Buffer_Command(HCI_Command):
|
|||||||
('tx_last_subevent_packets', 4),
|
('tx_last_subevent_packets', 4),
|
||||||
('crc_error_packets', 4),
|
('crc_error_packets', 4),
|
||||||
('rx_duplicate_packets', 4),
|
('rx_duplicate_packets', 4),
|
||||||
|
('rx_unreceived_packets', 4),
|
||||||
('vendor_specific_parameters', '*'),
|
('vendor_specific_parameters', '*'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
class HCI_Bluetooth_Quality_Report_Event(HCI_Vendor_Event):
|
class HCI_Bluetooth_Quality_Report_Event(HCI_Android_Vendor_Event):
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
'''
|
'''
|
||||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#bluetooth-quality-report-sub-event
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#bluetooth-quality-report-sub-event
|
||||||
|
|||||||
@@ -11,32 +11,44 @@ Usage: bumble-bench [OPTIONS] COMMAND [ARGS]...
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--device-config FILENAME Device configuration file
|
--device-config FILENAME Device configuration file
|
||||||
--role [sender|receiver|ping|pong]
|
--scenario [send|receive|ping|pong]
|
||||||
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
|
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
|
||||||
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
|
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
|
||||||
--extended-data-length TEXT Request a data length upon connection,
|
--extended-data-length TEXT Request a data length upon connection,
|
||||||
specified as tx_octets/tx_time
|
specified as tx_octets/tx_time
|
||||||
--rfcomm-channel INTEGER RFComm channel to use
|
--role-switch [central|peripheral]
|
||||||
|
Request role switch upon connection (central
|
||||||
|
or peripheral)
|
||||||
|
--rfcomm-channel INTEGER RFComm channel to use (specify 0 for channel
|
||||||
|
discovery via SDP)
|
||||||
--rfcomm-uuid TEXT RFComm service UUID to use (ignored if
|
--rfcomm-uuid TEXT RFComm service UUID to use (ignored if
|
||||||
--rfcomm-channel is not 0)
|
--rfcomm-channel is not 0)
|
||||||
|
--rfcomm-l2cap-mtu INTEGER RFComm L2CAP MTU
|
||||||
|
--rfcomm-max-frame-size INTEGER
|
||||||
|
RFComm maximum frame size
|
||||||
|
--rfcomm-initial-credits INTEGER
|
||||||
|
RFComm initial credits
|
||||||
|
--rfcomm-max-credits INTEGER RFComm max credits
|
||||||
|
--rfcomm-credits-threshold INTEGER
|
||||||
|
RFComm credits threshold
|
||||||
--l2cap-psm INTEGER L2CAP PSM to use
|
--l2cap-psm INTEGER L2CAP PSM to use
|
||||||
--l2cap-mtu INTEGER L2CAP MTU to use
|
--l2cap-mtu INTEGER L2CAP MTU to use
|
||||||
--l2cap-mps INTEGER L2CAP MPS to use
|
--l2cap-mps INTEGER L2CAP MPS to use
|
||||||
--l2cap-max-credits INTEGER L2CAP maximum number of credits allowed for
|
--l2cap-max-credits INTEGER L2CAP maximum number of credits allowed for
|
||||||
the peer
|
the peer
|
||||||
-s, --packet-size SIZE Packet size (client or ping role)
|
-s, --packet-size SIZE Packet size (send or ping scenario)
|
||||||
[8<=x<=4096]
|
[8<=x<=8192]
|
||||||
-c, --packet-count COUNT Packet count (client or ping role)
|
-c, --packet-count COUNT Packet count (send or ping scenario)
|
||||||
-sd, --start-delay SECONDS Start delay (client or ping role)
|
-sd, --start-delay SECONDS Start delay (send or ping scenario)
|
||||||
--repeat N Repeat the run N times (client and ping
|
--repeat N Repeat the run N times (send and ping
|
||||||
roles)(0, which is the fault, to run just
|
scenario)(0, which is the fault, to run just
|
||||||
once)
|
once)
|
||||||
--repeat-delay SECONDS Delay, in seconds, between repeats
|
--repeat-delay SECONDS Delay, in seconds, between repeats
|
||||||
--pace MILLISECONDS Wait N milliseconds between packets (0,
|
--pace MILLISECONDS Wait N milliseconds between packets (0,
|
||||||
which is the fault, to send as fast as
|
which is the fault, to send as fast as
|
||||||
possible)
|
possible)
|
||||||
--linger Don't exit at the end of a run (server and
|
--linger Don't exit at the end of a run (receive and
|
||||||
pong roles)
|
pong scenarios)
|
||||||
--help Show this message and exit.
|
--help Show this message and exit.
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
@@ -71,19 +83,19 @@ using the ``--peripheral`` option. The address will be printed by the Peripheral
|
|||||||
it starts.
|
it starts.
|
||||||
|
|
||||||
Independently of whether the device is the Central or Peripheral, each device selects a
|
Independently of whether the device is the Central or Peripheral, each device selects a
|
||||||
``mode`` and and ``role`` to run as. The ``mode`` and ``role`` of the Central and Peripheral
|
``mode`` and and ``scenario`` to run as. The ``mode`` and ``scenario`` of the Central and Peripheral
|
||||||
must be compatible.
|
must be compatible.
|
||||||
|
|
||||||
Device 1 mode | Device 2 mode
|
Device 1 scenario | Device 2 scenario
|
||||||
------------------|------------------
|
------------------|------------------
|
||||||
``gatt-client`` | ``gatt-server``
|
``gatt-client`` | ``gatt-server``
|
||||||
``l2cap-client`` | ``l2cap-server``
|
``l2cap-client`` | ``l2cap-server``
|
||||||
``rfcomm-client`` | ``rfcomm-server``
|
``rfcomm-client`` | ``rfcomm-server``
|
||||||
|
|
||||||
Device 1 role | Device 2 role
|
Device 1 scenario | Device 2 scenario
|
||||||
--------------|--------------
|
------------------|--------------
|
||||||
``sender`` | ``receiver``
|
``send`` | ``receive``
|
||||||
``ping`` | ``pong``
|
``ping`` | ``pong``
|
||||||
|
|
||||||
|
|
||||||
# Examples
|
# Examples
|
||||||
@@ -92,7 +104,7 @@ In the following examples, we have two USB Bluetooth controllers, one on `usb:0`
|
|||||||
the other on `usb:1`, and two consoles/terminals. We will run a command in each.
|
the other on `usb:1`, and two consoles/terminals. We will run a command in each.
|
||||||
|
|
||||||
!!! example "GATT Throughput"
|
!!! example "GATT Throughput"
|
||||||
Using the default mode and role for the Central and Peripheral.
|
Using the default mode and scenario for the Central and Peripheral.
|
||||||
|
|
||||||
In the first console/terminal:
|
In the first console/terminal:
|
||||||
```
|
```
|
||||||
@@ -137,12 +149,12 @@ the other on `usb:1`, and two consoles/terminals. We will run a command in each.
|
|||||||
!!! example "Ping/Pong Latency"
|
!!! example "Ping/Pong Latency"
|
||||||
In the first console/terminal:
|
In the first console/terminal:
|
||||||
```
|
```
|
||||||
$ bumble-bench --role pong peripheral usb:0
|
$ bumble-bench --scenario pong peripheral usb:0
|
||||||
```
|
```
|
||||||
|
|
||||||
In the second console/terminal:
|
In the second console/terminal:
|
||||||
```
|
```
|
||||||
$ bumble-bench --role ping central usb:1
|
$ bumble-bench --scenario ping central usb:1
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! example "Reversed modes with GATT and custom connection interval"
|
!!! example "Reversed modes with GATT and custom connection interval"
|
||||||
@@ -167,13 +179,13 @@ the other on `usb:1`, and two consoles/terminals. We will run a command in each.
|
|||||||
$ bumble-bench --mode l2cap-server central --phy 2m usb:1
|
$ bumble-bench --mode l2cap-server central --phy 2m usb:1
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! example "Reversed roles with L2CAP"
|
!!! example "Reversed scenarios with L2CAP"
|
||||||
In the first console/terminal:
|
In the first console/terminal:
|
||||||
```
|
```
|
||||||
$ bumble-bench --mode l2cap-client --role sender peripheral usb:0
|
$ bumble-bench --mode l2cap-client --scenario send peripheral usb:0
|
||||||
```
|
```
|
||||||
|
|
||||||
In the second console/terminal:
|
In the second console/terminal:
|
||||||
```
|
```
|
||||||
$ bumble-bench --mode l2cap-server --role receiver central usb:1
|
$ bumble-bench --mode l2cap-server --scenario receive central usb:1
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ USB vendor ID and product ID.
|
|||||||
|
|
||||||
Drivers included in the module are:
|
Drivers included in the module are:
|
||||||
|
|
||||||
* [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles.
|
* [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles.
|
||||||
|
* [Intel](intel.md): Loading of Firmware and Config for Intel USB controllers.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
INTEL DRIVER
|
||||||
|
==============
|
||||||
|
|
||||||
|
This driver supports loading firmware images and optional config data to
|
||||||
|
Intel USB controllers.
|
||||||
|
A number of USB dongles are supported, but likely not all.
|
||||||
|
The initial implementation has been tested on BE200 and AX210 controllers.
|
||||||
|
When using a USB controller, the USB product ID and vendor ID are used
|
||||||
|
to find whether a matching set of firmware image and config data
|
||||||
|
is needed for that specific model. If a match exists, the driver will try
|
||||||
|
load the firmware image and, if needed, config data.
|
||||||
|
Alternatively, the metadata property ``driver=intel`` may be specified in a transport
|
||||||
|
name to force that driver to be used (ex: ``usb:[driver=intel]0`` instead of just
|
||||||
|
``usb:0`` for the first USB device).
|
||||||
|
The driver will look for the firmware and config files by name, in order, in:
|
||||||
|
|
||||||
|
* The directory specified by the environment variable `BUMBLE_INTEL_FIRMWARE_DIR`
|
||||||
|
if set.
|
||||||
|
* The directory `<package-dir>/drivers/intel_fw` where `<package-dir>` is the directory
|
||||||
|
where the `bumble` package is installed.
|
||||||
|
* The current directory.
|
||||||
|
|
||||||
|
It is also possible to override or extend the config data with parameters passed via the
|
||||||
|
transport name. The driver name `intel` may be suffixed with `/<param:value>[+<param:value>]...`
|
||||||
|
The supported params are:
|
||||||
|
* `ddc_addon`: configuration data to add to the data loaded from the config data file
|
||||||
|
* `ddc_override`: configuration data to use instead of the data loaded from the config data file.
|
||||||
|
|
||||||
|
With both `dcc_addon` and `dcc_override`, the param value is a hex-encoded byte array containing
|
||||||
|
the config data (same format as the config file).
|
||||||
|
Example transport name:
|
||||||
|
`usb:[driver=intel/dcc_addon:03E40200]0`
|
||||||
|
|
||||||
|
|
||||||
|
Obtaining Firmware Images and Config Data
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
|
Firmware images and config data may be obtained from a variety of online
|
||||||
|
sources.
|
||||||
|
To facilitate finding a downloading the, the utility program `bumble-intel-fw-download`
|
||||||
|
may be used.
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: bumble-intel-fw-download [OPTIONS]
|
||||||
|
|
||||||
|
Download Intel firmware images and configs.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--output-dir TEXT Output directory where the files will be saved.
|
||||||
|
Defaults to the OS-specific app data dir, which the
|
||||||
|
driver will check when trying to find firmware
|
||||||
|
--source [linux-kernel] [default: linux-kernel]
|
||||||
|
--single TEXT Only download a single image set, by its base name
|
||||||
|
--force Overwrite files if they already exist
|
||||||
|
--help Show this message and exit.
|
||||||
|
```
|
||||||
|
|
||||||
|
Utility
|
||||||
|
-------
|
||||||
|
|
||||||
|
The `bumble-intel-util` utility may be used to interact with an Intel USB controller.
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage: bumble-intel-util [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--help Show this message and exit.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
bootloader Reboot in bootloader mode.
|
||||||
|
info Get the firmware info.
|
||||||
|
load Load a firmware image.
|
||||||
|
```
|
||||||
@@ -3,9 +3,7 @@ GETTING STARTED WITH BUMBLE
|
|||||||
|
|
||||||
# Prerequisites
|
# Prerequisites
|
||||||
|
|
||||||
You need Python 3.8 or above. Python >= 3.9 is recommended, but 3.8 should be sufficient if
|
You need Python 3.9 or above.
|
||||||
necessary (there may be some optional functionality that will not work on some platforms with
|
|
||||||
python 3.8).
|
|
||||||
Visit the [Python site](https://www.python.org/) for instructions on how to install Python
|
Visit the [Python site](https://www.python.org/) for instructions on how to install Python
|
||||||
for your platform.
|
for your platform.
|
||||||
Throughout the documentation, when shell commands are shown, it is assumed that you can
|
Throughout the documentation, when shell commands are shown, it is assumed that you can
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Some of the configurations that may be useful:
|
|||||||
|
|
||||||
See the [use cases page](use_cases/index.md) for more use cases.
|
See the [use cases page](use_cases/index.md) for more use cases.
|
||||||
|
|
||||||
The project is implemented in Python (Python >= 3.8 is required). A number of APIs for functionality that is inherently I/O bound is implemented in terms of python coroutines with async IO. This means that all of the concurrent tasks run in the same thread, which makes everything much simpler and more predictable.
|
The project is implemented in Python (Python >= 3.9 is required). A number of APIs for functionality that is inherently I/O bound is implemented in terms of python coroutines with async IO. This means that all of the concurrent tasks run in the same thread, which makes everything much simpler and more predictable.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
PLATFORMS
|
PLATFORMS
|
||||||
=========
|
=========
|
||||||
|
|
||||||
Most of the code included in the project should run on any platform that supports Python >= 3.8. Not all features are supported on all platforms (for example, USB dongle support is only available on platforms where the python USB library is functional).
|
Most of the code included in the project should run on any platform that supports Python >= 3.9. Not all features are supported on all platforms (for example, USB dongle support is only available on platforms where the python USB library is functional).
|
||||||
|
|
||||||
For platform-specific information, see the following pages:
|
For platform-specific information, see the following pages:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,6 +4,6 @@ channels:
|
|||||||
- conda-forge
|
- conda-forge
|
||||||
dependencies:
|
dependencies:
|
||||||
- pip=23
|
- pip=23
|
||||||
- python=3.8
|
- python=3.9
|
||||||
- pip:
|
- pip:
|
||||||
- --editable .[development,documentation,test]
|
- --editable .[development,documentation,test]
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ async def keyboard_device(device, command):
|
|||||||
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
||||||
Characteristic.Properties.READ,
|
Characteristic.Properties.READ,
|
||||||
Characteristic.READABLE,
|
Characteristic.READABLE,
|
||||||
'Bumble',
|
bytes('Bumble', 'utf-8'),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from mobly import base_test
|
||||||
|
from mobly import test_runner
|
||||||
|
from mobly.controllers import android_device
|
||||||
|
|
||||||
|
|
||||||
|
class OneDeviceBenchTest(base_test.BaseTestClass):
|
||||||
|
|
||||||
|
def setup_class(self):
|
||||||
|
self.ads = self.register_controller(android_device)
|
||||||
|
self.dut = self.ads[0]
|
||||||
|
self.dut.load_snippet("bench", "com.github.google.bumble.btbench")
|
||||||
|
|
||||||
|
def test_rfcomm_client_ping(self):
|
||||||
|
runner = self.dut.bench.runRfcommClient(
|
||||||
|
"ping", "DC:E5:5B:E5:51:2C", 100, 970, 100
|
||||||
|
)
|
||||||
|
print("### Initial status:", runner)
|
||||||
|
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||||
|
print("### Final status:", final_status)
|
||||||
|
|
||||||
|
def test_rfcomm_client_send(self):
|
||||||
|
runner = self.dut.bench.runRfcommClient(
|
||||||
|
"send", "DC:E5:5B:E5:51:2C", 100, 970, 0
|
||||||
|
)
|
||||||
|
print("### Initial status:", runner)
|
||||||
|
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||||
|
print("### Final status:", final_status)
|
||||||
|
|
||||||
|
def test_l2cap_client_ping(self):
|
||||||
|
runner = self.dut.bench.runL2capClient(
|
||||||
|
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100
|
||||||
|
)
|
||||||
|
print("### Initial status:", runner)
|
||||||
|
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||||
|
print("### Final status:", final_status)
|
||||||
|
|
||||||
|
def test_l2cap_client_send(self):
|
||||||
|
runner = self.dut.bench.runL2capClient(
|
||||||
|
"send", "7E:90:D0:F2:7A:11", 131, True, 100, 970, 0
|
||||||
|
)
|
||||||
|
print("### Initial status:", runner)
|
||||||
|
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||||
|
print("### Final status:", final_status)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_runner.main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
TestBeds:
|
||||||
|
- Name: BenchTestBed
|
||||||
|
Controllers:
|
||||||
|
AndroidDevice:
|
||||||
|
- serial: 37211FDJG000DJ
|
||||||
|
local_bt_address: 94:45:60:5E:03:B0
|
||||||
|
|
||||||
|
- serial: 23071FDEE001F7
|
||||||
|
local_bt_address: DC:E5:5B:E5:51:2C
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from mobly import base_test
|
||||||
|
from mobly import test_runner
|
||||||
|
from mobly.controllers import android_device
|
||||||
|
|
||||||
|
|
||||||
|
class TwoDevicesBenchTest(base_test.BaseTestClass):
|
||||||
|
def setup_class(self):
|
||||||
|
self.ads = self.register_controller(android_device)
|
||||||
|
self.dut1 = self.ads[0]
|
||||||
|
self.dut1.load_snippet("bench", "com.github.google.bumble.btbench")
|
||||||
|
self.dut2 = self.ads[1]
|
||||||
|
self.dut2.load_snippet("bench", "com.github.google.bumble.btbench")
|
||||||
|
|
||||||
|
def test_rfcomm_client_send_receive(self):
|
||||||
|
print("### Starting Receiver")
|
||||||
|
receiver = self.dut2.bench.runRfcommServer("receive")
|
||||||
|
receiver_id = receiver["id"]
|
||||||
|
print("--- Receiver status:", receiver)
|
||||||
|
while not receiver["model"]["running"]:
|
||||||
|
print("--- Waiting for Receiver to be running...")
|
||||||
|
time.sleep(1)
|
||||||
|
receiver = self.dut2.bench.getRunner(receiver_id)
|
||||||
|
|
||||||
|
print("### Starting Sender")
|
||||||
|
sender = self.dut1.bench.runRfcommClient(
|
||||||
|
"send", "DC:E5:5B:E5:51:2C", 100, 970, 100
|
||||||
|
)
|
||||||
|
print("--- Sender status:", sender)
|
||||||
|
|
||||||
|
print("--- Waiting for Sender to complete...")
|
||||||
|
sender_result = self.dut1.bench.waitForRunnerCompletion(sender["id"])
|
||||||
|
print("--- Sender result:", sender_result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_runner.main()
|
||||||
@@ -64,6 +64,7 @@ async def main() -> None:
|
|||||||
[(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 2".encode("utf-8"))]
|
[(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 2".encode("utf-8"))]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# pylint: disable=possibly-used-before-assignment
|
||||||
if device.host.number_of_supported_advertising_sets >= 2:
|
if device.host.number_of_supported_advertising_sets >= 2:
|
||||||
set2 = await device.create_advertising_set(
|
set2 = await device.create_advertising_set(
|
||||||
random_address=Address("F0:F0:F0:F0:F0:F1"),
|
random_address=Address("F0:F0:F0:F0:F0:F1"),
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ async def main() -> None:
|
|||||||
'486F64C6-4B5F-4B3B-8AFF-EDE134A8446A',
|
'486F64C6-4B5F-4B3B-8AFF-EDE134A8446A',
|
||||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||||
Characteristic.READABLE,
|
Characteristic.READABLE,
|
||||||
'hello',
|
bytes('hello', 'utf-8'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
# 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 dataclasses
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from typing import Any, List, Union
|
||||||
|
|
||||||
|
from bumble.device import Connection, Device, Peer
|
||||||
|
from bumble import transport
|
||||||
|
from bumble import gatt
|
||||||
|
from bumble import hci
|
||||||
|
from bumble import core
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
SERVICE_UUID = core.UUID("50DB505C-8AC4-4738-8448-3B1D9CC09CC5")
|
||||||
|
CHARACTERISTIC_UUID_BASE = "D901B45B-4916-412E-ACCA-0000000000"
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CustomSerializableClass:
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> CustomSerializableClass:
|
||||||
|
return cls(*struct.unpack(">II", data))
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return struct.pack(">II", self.x, self.y)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CustomClass:
|
||||||
|
a: int
|
||||||
|
b: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def decode(cls, data: bytes) -> CustomClass:
|
||||||
|
return cls(*struct.unpack(">II", data))
|
||||||
|
|
||||||
|
def encode(self) -> bytes:
|
||||||
|
return struct.pack(">II", self.a, self.b)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def client(device: Device, address: hci.Address) -> None:
|
||||||
|
print(f'=== Connecting to {address}...')
|
||||||
|
connection = await device.connect(address)
|
||||||
|
print('=== Connected')
|
||||||
|
|
||||||
|
# Discover all characteristics.
|
||||||
|
peer = Peer(connection)
|
||||||
|
print("*** Discovering services and characteristics...")
|
||||||
|
await peer.discover_all()
|
||||||
|
print("*** Discovery complete")
|
||||||
|
|
||||||
|
service = peer.get_services_by_uuid(SERVICE_UUID)[0]
|
||||||
|
characteristics = []
|
||||||
|
for index in range(1, 9):
|
||||||
|
characteristics.append(
|
||||||
|
service.get_characteristics_by_uuid(
|
||||||
|
CHARACTERISTIC_UUID_BASE + f"{index:02X}"
|
||||||
|
)[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read all characteristics as raw bytes.
|
||||||
|
for characteristic in characteristics:
|
||||||
|
value = await characteristic.read_value()
|
||||||
|
print(f"### {characteristic} = {value} ({value.hex()})")
|
||||||
|
|
||||||
|
# Static characteristic with a bytes value.
|
||||||
|
c1 = characteristics[0]
|
||||||
|
c1_value = await c1.read_value()
|
||||||
|
print(f"@@@ C1 {c1} value = {c1_value} (type={type(c1_value)})")
|
||||||
|
await c1.write_value("happy π day".encode("utf-8"))
|
||||||
|
|
||||||
|
# Static characteristic with a string value.
|
||||||
|
c2 = gatt.UTF8CharacteristicAdapter(characteristics[1])
|
||||||
|
c2_value = await c2.read_value()
|
||||||
|
print(f"@@@ C2 {c2} value = {c2_value} (type={type(c2_value)})")
|
||||||
|
await c2.write_value("happy π day")
|
||||||
|
|
||||||
|
# Static characteristic with a tuple value.
|
||||||
|
c3 = gatt.PackedCharacteristicAdapter(characteristics[2], ">III")
|
||||||
|
c3_value = await c3.read_value()
|
||||||
|
print(f"@@@ C3 {c3} value = {c3_value} (type={type(c3_value)})")
|
||||||
|
await c3.write_value((2001, 2002, 2003))
|
||||||
|
|
||||||
|
# Static characteristic with a named tuple value.
|
||||||
|
c4 = gatt.MappedCharacteristicAdapter(
|
||||||
|
characteristics[3], ">III", ["f1", "f2", "f3"]
|
||||||
|
)
|
||||||
|
c4_value = await c4.read_value()
|
||||||
|
print(f"@@@ C4 {c4} value = {c4_value} (type={type(c4_value)})")
|
||||||
|
await c4.write_value({"f1": 4001, "f2": 4002, "f3": 4003})
|
||||||
|
|
||||||
|
# Static characteristic with a serializable value.
|
||||||
|
c5 = gatt.SerializableCharacteristicAdapter(
|
||||||
|
characteristics[4], CustomSerializableClass
|
||||||
|
)
|
||||||
|
c5_value = await c5.read_value()
|
||||||
|
print(f"@@@ C5 {c5} value = {c5_value} (type={type(c5_value)})")
|
||||||
|
await c5.write_value(CustomSerializableClass(56, 57))
|
||||||
|
|
||||||
|
# Static characteristic with a delegated value.
|
||||||
|
c6 = gatt.DelegatedCharacteristicAdapter(
|
||||||
|
characteristics[5], encode=CustomClass.encode, decode=CustomClass.decode
|
||||||
|
)
|
||||||
|
c6_value = await c6.read_value()
|
||||||
|
print(f"@@@ C6 {c6} value = {c6_value} (type={type(c6_value)})")
|
||||||
|
await c6.write_value(CustomClass(6, 7))
|
||||||
|
|
||||||
|
# Dynamic characteristic with a bytes value.
|
||||||
|
c7 = characteristics[6]
|
||||||
|
c7_value = await c7.read_value()
|
||||||
|
print(f"@@@ C7 {c7} value = {c7_value} (type={type(c7_value)})")
|
||||||
|
await c7.write_value(bytes.fromhex("01020304"))
|
||||||
|
|
||||||
|
# Dynamic characteristic with a string value.
|
||||||
|
c8 = gatt.UTF8CharacteristicAdapter(characteristics[7])
|
||||||
|
c8_value = await c8.read_value()
|
||||||
|
print(f"@@@ C8 {c8} value = {c8_value} (type={type(c8_value)})")
|
||||||
|
await c8.write_value("howdy")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def dynamic_read(selector: str) -> Union[bytes, str]:
|
||||||
|
if selector == "bytes":
|
||||||
|
print("$$$ Returning random bytes")
|
||||||
|
return random.randbytes(7)
|
||||||
|
elif selector == "string":
|
||||||
|
print("$$$ Returning random string")
|
||||||
|
return random.randbytes(7).hex()
|
||||||
|
|
||||||
|
raise ValueError("invalid selector")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def dynamic_write(selector: str, value: Any) -> None:
|
||||||
|
print(f"$$$ Received[{selector}]: {value} (type={type(value)})")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_characteristic_read(characteristic: gatt.Characteristic, value: Any) -> None:
|
||||||
|
"""Event listener invoked when a characteristic is read."""
|
||||||
|
print(f"<<< READ: {characteristic} -> {value} ({type(value)})")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_characteristic_write(characteristic: gatt.Characteristic, value: Any) -> None:
|
||||||
|
"""Event listener invoked when a characteristic is written."""
|
||||||
|
print(f"<<< WRITE: {characteristic} <- {value} ({type(value)})")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def main() -> None:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: run_gatt_with_adapters.py <transport-spec> [<bluetooth-address>]")
|
||||||
|
print("example: run_gatt_with_adapters.py usb:0 E1:CA:72:48:C4:E8")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with await transport.open_transport(sys.argv[1]) as hci_transport:
|
||||||
|
# Create a device to manage the host
|
||||||
|
device = Device.with_hci(
|
||||||
|
"Bumble",
|
||||||
|
hci.Address("F0:F1:F2:F3:F4:F5"),
|
||||||
|
hci_transport.source,
|
||||||
|
hci_transport.sink,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static characteristic with a bytes value.
|
||||||
|
c1 = gatt.Characteristic(
|
||||||
|
CHARACTERISTIC_UUID_BASE + "01",
|
||||||
|
gatt.Characteristic.Properties.READ | gatt.Characteristic.Properties.WRITE,
|
||||||
|
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
|
||||||
|
b'hello',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static characteristic with a string value.
|
||||||
|
c2 = gatt.UTF8CharacteristicAdapter(
|
||||||
|
gatt.Characteristic(
|
||||||
|
CHARACTERISTIC_UUID_BASE + "02",
|
||||||
|
gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.WRITE,
|
||||||
|
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
|
||||||
|
'hello',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static characteristic with a tuple value.
|
||||||
|
c3 = gatt.PackedCharacteristicAdapter(
|
||||||
|
gatt.Characteristic(
|
||||||
|
CHARACTERISTIC_UUID_BASE + "03",
|
||||||
|
gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.WRITE,
|
||||||
|
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
|
||||||
|
(1007, 1008, 1009),
|
||||||
|
),
|
||||||
|
">III",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static characteristic with a named tuple value.
|
||||||
|
c4 = gatt.MappedCharacteristicAdapter(
|
||||||
|
gatt.Characteristic(
|
||||||
|
CHARACTERISTIC_UUID_BASE + "04",
|
||||||
|
gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.WRITE,
|
||||||
|
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
|
||||||
|
{"f1": 3007, "f2": 3008, "f3": 3009},
|
||||||
|
),
|
||||||
|
">III",
|
||||||
|
["f1", "f2", "f3"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static characteristic with a serializable value.
|
||||||
|
c5 = gatt.SerializableCharacteristicAdapter(
|
||||||
|
gatt.Characteristic(
|
||||||
|
CHARACTERISTIC_UUID_BASE + "05",
|
||||||
|
gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.WRITE,
|
||||||
|
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
|
||||||
|
CustomSerializableClass(11, 12),
|
||||||
|
),
|
||||||
|
CustomSerializableClass,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static characteristic with a delegated value.
|
||||||
|
c6 = gatt.DelegatedCharacteristicAdapter(
|
||||||
|
gatt.Characteristic(
|
||||||
|
CHARACTERISTIC_UUID_BASE + "06",
|
||||||
|
gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.WRITE,
|
||||||
|
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
|
||||||
|
CustomClass(1, 2),
|
||||||
|
),
|
||||||
|
encode=CustomClass.encode,
|
||||||
|
decode=CustomClass.decode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dynamic characteristic with a bytes value.
|
||||||
|
c7 = gatt.Characteristic(
|
||||||
|
CHARACTERISTIC_UUID_BASE + "07",
|
||||||
|
gatt.Characteristic.Properties.READ | gatt.Characteristic.Properties.WRITE,
|
||||||
|
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
|
||||||
|
gatt.CharacteristicValue(
|
||||||
|
read=lambda connection: dynamic_read("bytes"),
|
||||||
|
write=lambda connection, value: dynamic_write("bytes", value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dynamic characteristic with a string value.
|
||||||
|
c8 = gatt.UTF8CharacteristicAdapter(
|
||||||
|
gatt.Characteristic(
|
||||||
|
CHARACTERISTIC_UUID_BASE + "08",
|
||||||
|
gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.WRITE,
|
||||||
|
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
|
||||||
|
gatt.CharacteristicValue(
|
||||||
|
read=lambda connection: dynamic_read("string"),
|
||||||
|
write=lambda connection, value: dynamic_write("string", value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
characteristics: List[
|
||||||
|
Union[gatt.Characteristic, gatt.CharacteristicAdapter]
|
||||||
|
] = [c1, c2, c3, c4, c5, c6, c7, c8]
|
||||||
|
|
||||||
|
# Listen for read and write events.
|
||||||
|
for characteristic in characteristics:
|
||||||
|
characteristic.on(
|
||||||
|
"read",
|
||||||
|
lambda _, value, c=characteristic: on_characteristic_read(c, value),
|
||||||
|
)
|
||||||
|
characteristic.on(
|
||||||
|
"write",
|
||||||
|
lambda _, value, c=characteristic: on_characteristic_write(c, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
device.add_service(gatt.Service(SERVICE_UUID, characteristics)) # type: ignore
|
||||||
|
|
||||||
|
# Get things going
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
# Connect to a peer
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
await client(device, hci.Address(sys.argv[2]))
|
||||||
|
else:
|
||||||
|
await device.start_advertising(auto_restart=True)
|
||||||
|
|
||||||
|
await hci_transport.source.wait_for_termination()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||||
|
asyncio.run(main())
|
||||||
@@ -21,9 +21,9 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import websockets
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
from typing import Optional
|
import websockets
|
||||||
|
|
||||||
import bumble.core
|
import bumble.core
|
||||||
from bumble.device import Device, ScoLink
|
from bumble.device import Device, ScoLink
|
||||||
@@ -82,6 +82,10 @@ def on_microphone_volume(level: int):
|
|||||||
send_message(type='microphone_volume', level=level)
|
send_message(type='microphone_volume', level=level)
|
||||||
|
|
||||||
|
|
||||||
|
def on_supported_audio_codecs(codecs: Iterable[hfp.AudioCodec]):
|
||||||
|
send_message(type='supported_audio_codecs', codecs=[codec.name for codec in codecs])
|
||||||
|
|
||||||
|
|
||||||
def on_sco_state_change(codec: int):
|
def on_sco_state_change(codec: int):
|
||||||
if codec == hfp.AudioCodec.CVSD:
|
if codec == hfp.AudioCodec.CVSD:
|
||||||
sample_rate = 8000
|
sample_rate = 8000
|
||||||
@@ -207,6 +211,7 @@ async def main() -> None:
|
|||||||
ag_protocol = hfp.AgProtocol(dlc, configuration)
|
ag_protocol = hfp.AgProtocol(dlc, configuration)
|
||||||
ag_protocol.on('speaker_volume', on_speaker_volume)
|
ag_protocol.on('speaker_volume', on_speaker_volume)
|
||||||
ag_protocol.on('microphone_volume', on_microphone_volume)
|
ag_protocol.on('microphone_volume', on_microphone_volume)
|
||||||
|
ag_protocol.on('supported_audio_codecs', on_supported_audio_codecs)
|
||||||
on_hfp_state_change(True)
|
on_hfp_state_change(True)
|
||||||
dlc.multiplexer.l2cap_channel.on(
|
dlc.multiplexer.l2cap_channel.on(
|
||||||
'close', lambda: on_hfp_state_change(False)
|
'close', lambda: on_hfp_state_change(False)
|
||||||
@@ -241,7 +246,7 @@ async def main() -> None:
|
|||||||
# Pick the first one
|
# Pick the first one
|
||||||
channel, version, hf_sdp_features = hfp_record
|
channel, version, hf_sdp_features = hfp_record
|
||||||
print(f'HF version: {version}')
|
print(f'HF version: {version}')
|
||||||
print(f'HF features: {hf_sdp_features}')
|
print(f'HF features: {hf_sdp_features.name}')
|
||||||
|
|
||||||
# Request authentication
|
# Request authentication
|
||||||
print('*** Authenticating...')
|
print('*** Authenticating...')
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.HfConfiguration):
|
|||||||
esco_parameters = hfp.ESCO_PARAMETERS[
|
esco_parameters = hfp.ESCO_PARAMETERS[
|
||||||
hfp.DefaultCodecParameters.ESCO_CVSD_S4
|
hfp.DefaultCodecParameters.ESCO_CVSD_S4
|
||||||
]
|
]
|
||||||
|
else:
|
||||||
|
raise RuntimeError("unknown active codec")
|
||||||
|
|
||||||
connection.abort_on(
|
connection.abort_on(
|
||||||
'disconnection',
|
'disconnection',
|
||||||
connection.device.send_command(
|
connection.device.send_command(
|
||||||
|
|||||||
@@ -161,7 +161,13 @@ async def main() -> None:
|
|||||||
else:
|
else:
|
||||||
file_output = open(f'{datetime.datetime.now().isoformat()}.lc3', 'wb')
|
file_output = open(f'{datetime.datetime.now().isoformat()}.lc3', 'wb')
|
||||||
codec_configuration = ase.codec_specific_configuration
|
codec_configuration = ase.codec_specific_configuration
|
||||||
assert isinstance(codec_configuration, CodecSpecificConfiguration)
|
if (
|
||||||
|
not isinstance(codec_configuration, CodecSpecificConfiguration)
|
||||||
|
or codec_configuration.sampling_frequency is None
|
||||||
|
or codec_configuration.audio_channel_allocation is None
|
||||||
|
or codec_configuration.frame_duration is None
|
||||||
|
):
|
||||||
|
return
|
||||||
# Write a LC3 header.
|
# Write a LC3 header.
|
||||||
file_output.write(
|
file_output.write(
|
||||||
bytes([0x1C, 0xCC]) # Header.
|
bytes([0x1C, 0xCC]) # Header.
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ dependencies {
|
|||||||
implementation(libs.ui.graphics)
|
implementation(libs.ui.graphics)
|
||||||
implementation(libs.ui.tooling.preview)
|
implementation(libs.ui.tooling.preview)
|
||||||
implementation(libs.material3)
|
implementation(libs.material3)
|
||||||
|
implementation(libs.mobly.snippet)
|
||||||
|
implementation(libs.androidx.core)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
androidTestImplementation(libs.espresso.core)
|
androidTestImplementation(libs.espresso.core)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.BTBench"
|
android:theme="@style/Theme.BTBench"
|
||||||
>
|
>
|
||||||
|
<meta-data
|
||||||
|
android:name="mobly-snippets"
|
||||||
|
android:value="com.github.google.bumble.btbench.AutomationSnippet"/>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -35,5 +38,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<!-- <profileable android:shell="true"/>-->
|
<!-- <profileable android:shell="true"/>-->
|
||||||
</application>
|
</application>
|
||||||
|
<instrumentation
|
||||||
</manifest>
|
android:name="com.google.android.mobly.snippet.SnippetRunner"
|
||||||
|
android:targetPackage="com.github.google.bumble.btbench" />
|
||||||
|
</manifest>
|
||||||
|
|||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
// 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.bluetooth.BluetoothAdapter;
|
||||||
|
import android.bluetooth.BluetoothManager;
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import androidx.test.core.app.ApplicationProvider;
|
||||||
|
|
||||||
|
import com.google.android.mobly.snippet.Snippet;
|
||||||
|
import com.google.android.mobly.snippet.rpc.Rpc;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.security.InvalidParameterException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
class Runner {
|
||||||
|
public UUID mId;
|
||||||
|
private final Mode mMode;
|
||||||
|
private final String mModeName;
|
||||||
|
private final String mScenario;
|
||||||
|
private final AppViewModel mModel;
|
||||||
|
|
||||||
|
Runner(Mode mode, String modeName, String scenario, AppViewModel model) {
|
||||||
|
this.mId = UUID.randomUUID();
|
||||||
|
this.mMode = mode;
|
||||||
|
this.mModeName = modeName;
|
||||||
|
this.mScenario = scenario;
|
||||||
|
this.mModel = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JSONObject toJson() throws JSONException {
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
result.put("id", mId.toString());
|
||||||
|
result.put("mode", mModeName);
|
||||||
|
result.put("scenario", mScenario);
|
||||||
|
result.put("model", AutomationSnippet.modelToJson(mModel));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
mModel.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void waitForCompletion() {
|
||||||
|
mMode.waitForCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutomationSnippet implements Snippet {
|
||||||
|
private static final String TAG = "btbench.snippet";
|
||||||
|
private final BluetoothAdapter mBluetoothAdapter;
|
||||||
|
private final Context mContext;
|
||||||
|
private final ArrayList<Runner> mRunners = new ArrayList<>();
|
||||||
|
|
||||||
|
public AutomationSnippet() {
|
||||||
|
mContext = ApplicationProvider.getApplicationContext();
|
||||||
|
BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class);
|
||||||
|
mBluetoothAdapter = bluetoothManager.getAdapter();
|
||||||
|
if (mBluetoothAdapter == null) {
|
||||||
|
throw new RuntimeException("bluetooth not supported");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Runner runScenario(AppViewModel model, String mode, String scenario) {
|
||||||
|
Mode runnable;
|
||||||
|
switch (mode) {
|
||||||
|
case "rfcomm-client":
|
||||||
|
runnable = new RfcommClient(model, mBluetoothAdapter,
|
||||||
|
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||||
|
packetIO));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "rfcomm-server":
|
||||||
|
runnable = new RfcommServer(model, mBluetoothAdapter,
|
||||||
|
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||||
|
packetIO));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "l2cap-client":
|
||||||
|
runnable = new L2capClient(model, mBluetoothAdapter, mContext,
|
||||||
|
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||||
|
packetIO));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "l2cap-server":
|
||||||
|
runnable = new L2capServer(model, mBluetoothAdapter,
|
||||||
|
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||||
|
packetIO));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
runnable.run();
|
||||||
|
Runner runner = new Runner(runnable, mode, scenario, model);
|
||||||
|
mRunners.add(runner);
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IoClient createIoClient(AppViewModel model, String scenario, PacketIO packetIO) {
|
||||||
|
switch (scenario) {
|
||||||
|
case "send":
|
||||||
|
return new Sender(model, packetIO);
|
||||||
|
|
||||||
|
case "receive":
|
||||||
|
return new Receiver(model, packetIO);
|
||||||
|
|
||||||
|
case "ping":
|
||||||
|
return new Pinger(model, packetIO);
|
||||||
|
|
||||||
|
case "pong":
|
||||||
|
return new Ponger(model, packetIO);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JSONObject modelToJson(AppViewModel model) throws JSONException {
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
result.put("status", model.getStatus());
|
||||||
|
result.put("running", model.getRunning());
|
||||||
|
result.put("l2cap_psm", model.getL2capPsm());
|
||||||
|
if (model.getStatus().equals("OK")) {
|
||||||
|
JSONObject stats = new JSONObject();
|
||||||
|
result.put("stats", stats);
|
||||||
|
stats.put("throughput", model.getThroughput());
|
||||||
|
JSONObject rttStats = new JSONObject();
|
||||||
|
stats.put("rtt", rttStats);
|
||||||
|
rttStats.put("compound", model.getStats());
|
||||||
|
} else {
|
||||||
|
result.put("last_error", model.getLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Runner findRunner(String runnerId) {
|
||||||
|
for (Runner runner : mRunners) {
|
||||||
|
if (runner.mId.toString().equals(runnerId)) {
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
|
||||||
|
// 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.setSenderPacketCount(packetCount);
|
||||||
|
model.setSenderPacketSize(packetSize);
|
||||||
|
model.setSenderPacketInterval(packetInterval);
|
||||||
|
|
||||||
|
Runner runner = runScenario(model, "rfcomm-client", scenario);
|
||||||
|
assert runner != null;
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Run a scenario in RFComm Server mode")
|
||||||
|
public JSONObject runRfcommServer(String scenario) throws JSONException {
|
||||||
|
assert (mBluetoothAdapter != null);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
Runner runner = runScenario(model, "rfcomm-server", scenario);
|
||||||
|
assert runner != null;
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
|
||||||
|
// 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.setL2capPsm(psm);
|
||||||
|
model.setUse2mPhy(use_2m_phy);
|
||||||
|
model.setSenderPacketCount(packetCount);
|
||||||
|
model.setSenderPacketSize(packetSize);
|
||||||
|
model.setSenderPacketInterval(packetInterval);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
Runner runner = runScenario(model, "l2cap-server", scenario);
|
||||||
|
assert runner != null;
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Stop a Runner")
|
||||||
|
public JSONObject stopRunner(String runnerId) throws JSONException {
|
||||||
|
Runner runner = findRunner(runnerId);
|
||||||
|
if (runner == null) {
|
||||||
|
return new JSONObject();
|
||||||
|
}
|
||||||
|
runner.stop();
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Wait for a Runner to complete")
|
||||||
|
public JSONObject waitForRunnerCompletion(String runnerId) throws JSONException {
|
||||||
|
Runner runner = findRunner(runnerId);
|
||||||
|
if (runner == null) {
|
||||||
|
return new JSONObject();
|
||||||
|
}
|
||||||
|
runner.waitForCompletion();
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Get a Runner by ID")
|
||||||
|
public JSONObject getRunner(String runnerId) throws JSONException {
|
||||||
|
Runner runner = findRunner(runnerId);
|
||||||
|
if (runner == null) {
|
||||||
|
return new JSONObject();
|
||||||
|
}
|
||||||
|
return runner.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Rpc(description = "Get all Runners")
|
||||||
|
public JSONObject getRunners() throws JSONException {
|
||||||
|
JSONObject result = new JSONObject();
|
||||||
|
JSONArray runners = new JSONArray();
|
||||||
|
result.put("runners", runners);
|
||||||
|
for (Runner runner: mRunners) {
|
||||||
|
runners.put(runner.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
interface IoClient {
|
||||||
|
fun run()
|
||||||
|
fun abort()
|
||||||
|
}
|
||||||
+14
-6
@@ -29,10 +29,13 @@ private val Log = Logger.getLogger("btbench.l2cap-client")
|
|||||||
class L2capClient(
|
class L2capClient(
|
||||||
private val viewModel: AppViewModel,
|
private val viewModel: AppViewModel,
|
||||||
private val bluetoothAdapter: BluetoothAdapter,
|
private val bluetoothAdapter: BluetoothAdapter,
|
||||||
private val context: Context
|
private val context: Context,
|
||||||
) {
|
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||||
|
) : Mode {
|
||||||
|
private var socketClient: SocketClient? = null
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
override fun run() {
|
||||||
viewModel.running = true
|
viewModel.running = true
|
||||||
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
|
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
|
||||||
val address = viewModel.peerBluetoothAddress.take(17)
|
val address = viewModel.peerBluetoothAddress.take(17)
|
||||||
@@ -75,6 +78,7 @@ class L2capClient(
|
|||||||
) {
|
) {
|
||||||
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
|
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
|
||||||
if (viewModel.use2mPhy) {
|
if (viewModel.use2mPhy) {
|
||||||
|
Log.info("requesting 2M PHY")
|
||||||
gatt.setPreferredPhy(
|
gatt.setPreferredPhy(
|
||||||
BluetoothDevice.PHY_LE_2M_MASK,
|
BluetoothDevice.PHY_LE_2M_MASK,
|
||||||
BluetoothDevice.PHY_LE_2M_MASK,
|
BluetoothDevice.PHY_LE_2M_MASK,
|
||||||
@@ -95,7 +99,11 @@ class L2capClient(
|
|||||||
|
|
||||||
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
|
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
|
||||||
|
|
||||||
val client = SocketClient(viewModel, socket)
|
socketClient = SocketClient(viewModel, socket, createIoClient)
|
||||||
client.run()
|
socketClient!!.run()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override fun waitForCompletion() {
|
||||||
|
socketClient?.waitForCompletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+19
-6
@@ -27,11 +27,17 @@ import kotlin.concurrent.thread
|
|||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.l2cap-server")
|
private val Log = Logger.getLogger("btbench.l2cap-server")
|
||||||
|
|
||||||
class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
|
class L2capServer(
|
||||||
|
private val viewModel: AppViewModel,
|
||||||
|
private val bluetoothAdapter: BluetoothAdapter,
|
||||||
|
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||||
|
) : Mode {
|
||||||
|
private var socketServer: SocketServer? = null
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
override fun run() {
|
||||||
// Advertise so that the peer can find us and connect.
|
// Advertise so that the peer can find us and connect.
|
||||||
val callback = object: AdvertiseCallback() {
|
val callback = object : AdvertiseCallback() {
|
||||||
override fun onStartFailure(errorCode: Int) {
|
override fun onStartFailure(errorCode: Int) {
|
||||||
Log.warning("failed to start advertising: $errorCode")
|
Log.warning("failed to start advertising: $errorCode")
|
||||||
}
|
}
|
||||||
@@ -55,7 +61,14 @@ class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdap
|
|||||||
viewModel.l2capPsm = serverSocket.psm
|
viewModel.l2capPsm = serverSocket.psm
|
||||||
Log.info("psm = $serverSocket.psm")
|
Log.info("psm = $serverSocket.psm")
|
||||||
|
|
||||||
val server = SocketServer(viewModel, serverSocket)
|
socketServer = SocketServer(viewModel, serverSocket, createIoClient)
|
||||||
server.run({ advertiser.stopAdvertising(callback) }, { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) })
|
socketServer!!.run(
|
||||||
|
{ advertiser.stopAdvertising(callback) },
|
||||||
|
{ advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override fun waitForCompletion() {
|
||||||
|
socketServer?.waitForCompletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+168
-48
@@ -34,12 +34,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
@@ -54,6 +57,7 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
@@ -69,6 +73,9 @@ private val Log = Logger.getLogger("bumble.main-activity")
|
|||||||
const val PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
|
const val PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
|
||||||
const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
|
const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
|
||||||
const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
|
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"
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val appViewModel = AppViewModel()
|
private val appViewModel = AppViewModel()
|
||||||
@@ -139,10 +146,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
MainView(
|
MainView(
|
||||||
appViewModel,
|
appViewModel,
|
||||||
::becomeDiscoverable,
|
::becomeDiscoverable,
|
||||||
::runRfcommClient,
|
::runScenario
|
||||||
::runRfcommServer,
|
|
||||||
::runL2capClient,
|
|
||||||
::runL2capServer,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,37 +163,54 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (packetSize > 0) {
|
if (packetSize > 0) {
|
||||||
appViewModel.senderPacketSize = packetSize
|
appViewModel.senderPacketSize = packetSize
|
||||||
}
|
}
|
||||||
|
val packetInterval = intent.getIntExtra("packet-interval", 0)
|
||||||
|
if (packetInterval > 0) {
|
||||||
|
appViewModel.senderPacketInterval = packetInterval
|
||||||
|
}
|
||||||
appViewModel.updateSenderPacketSizeSlider()
|
appViewModel.updateSenderPacketSizeSlider()
|
||||||
|
intent.getStringExtra("scenario")?.let {
|
||||||
|
when (it) {
|
||||||
|
"send" -> appViewModel.scenario = SEND_SCENARIO
|
||||||
|
"receive" -> appViewModel.scenario = RECEIVE_SCENARIO
|
||||||
|
"ping" -> appViewModel.scenario = PING_SCENARIO
|
||||||
|
"pong" -> appViewModel.scenario = PONG_SCENARIO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
intent.getStringExtra("mode")?.let {
|
||||||
|
when (it) {
|
||||||
|
"rfcomm-client" -> appViewModel.mode = RFCOMM_CLIENT_MODE
|
||||||
|
"rfcomm-server" -> appViewModel.mode = RFCOMM_SERVER_MODE
|
||||||
|
"l2cap-client" -> appViewModel.mode = L2CAP_CLIENT_MODE
|
||||||
|
"l2cap-server" -> appViewModel.mode = L2CAP_SERVER_MODE
|
||||||
|
}
|
||||||
|
}
|
||||||
intent.getStringExtra("autostart")?.let {
|
intent.getStringExtra("autostart")?.let {
|
||||||
when (it) {
|
when (it) {
|
||||||
"rfcomm-client" -> runRfcommClient()
|
"run-scenario" -> runScenario()
|
||||||
"rfcomm-server" -> runRfcommServer()
|
|
||||||
"l2cap-client" -> runL2capClient()
|
|
||||||
"l2cap-server" -> runL2capServer()
|
|
||||||
"scan-start" -> runScan(true)
|
"scan-start" -> runScan(true)
|
||||||
"stop-start" -> runScan(false)
|
"stop-start" -> runScan(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runRfcommClient() {
|
private fun runScenario() {
|
||||||
val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
|
if (bluetoothAdapter == null) {
|
||||||
rfcommClient?.run()
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runRfcommServer() {
|
val runner = when (appViewModel.mode) {
|
||||||
val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
|
RFCOMM_CLIENT_MODE -> RfcommClient(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||||
rfcommServer?.run()
|
RFCOMM_SERVER_MODE -> RfcommServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||||
}
|
L2CAP_CLIENT_MODE -> L2capClient(
|
||||||
|
appViewModel,
|
||||||
private fun runL2capClient() {
|
bluetoothAdapter!!,
|
||||||
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it, baseContext) }
|
baseContext,
|
||||||
l2capClient?.run()
|
::createIoClient
|
||||||
}
|
)
|
||||||
|
L2CAP_SERVER_MODE -> L2capServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||||
private fun runL2capServer() {
|
else -> throw IllegalStateException()
|
||||||
val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
|
}
|
||||||
l2capServer?.run()
|
runner.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runScan(startScan: Boolean) {
|
private fun runScan(startScan: Boolean) {
|
||||||
@@ -197,6 +218,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
scan?.run(startScan)
|
scan?.run(startScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createIoClient(packetIo: PacketIO): IoClient {
|
||||||
|
return when (appViewModel.scenario) {
|
||||||
|
SEND_SCENARIO -> Sender(appViewModel, packetIo)
|
||||||
|
RECEIVE_SCENARIO -> Receiver(appViewModel, packetIo)
|
||||||
|
PING_SCENARIO -> Pinger(appViewModel, packetIo)
|
||||||
|
PONG_SCENARIO -> Ponger(appViewModel, packetIo)
|
||||||
|
else -> throw IllegalStateException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun becomeDiscoverable() {
|
fun becomeDiscoverable() {
|
||||||
val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
|
val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
|
||||||
@@ -210,10 +242,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
fun MainView(
|
fun MainView(
|
||||||
appViewModel: AppViewModel,
|
appViewModel: AppViewModel,
|
||||||
becomeDiscoverable: () -> Unit,
|
becomeDiscoverable: () -> Unit,
|
||||||
runRfcommClient: () -> Unit,
|
runScenario: () -> Unit,
|
||||||
runRfcommServer: () -> Unit,
|
|
||||||
runL2capClient: () -> Unit,
|
|
||||||
runL2capServer: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
BTBenchTheme {
|
BTBenchTheme {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
@@ -239,7 +268,9 @@ fun MainView(
|
|||||||
Text(text = "Peer Bluetooth Address")
|
Text(text = "Peer Bluetooth Address")
|
||||||
},
|
},
|
||||||
value = appViewModel.peerBluetoothAddress,
|
value = appViewModel.peerBluetoothAddress,
|
||||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
|
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
|
||||||
),
|
),
|
||||||
@@ -249,14 +280,18 @@ fun MainView(
|
|||||||
keyboardActions = KeyboardActions(onDone = {
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
keyboardController?.hide()
|
keyboardController?.hide()
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
})
|
}),
|
||||||
|
enabled = (appViewModel.mode == RFCOMM_CLIENT_MODE) or (appViewModel.mode == L2CAP_CLIENT_MODE)
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
TextField(label = {
|
TextField(
|
||||||
Text(text = "L2CAP PSM")
|
label = {
|
||||||
},
|
Text(text = "L2CAP PSM")
|
||||||
|
},
|
||||||
value = appViewModel.l2capPsm.toString(),
|
value = appViewModel.l2capPsm.toString(),
|
||||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||||
),
|
),
|
||||||
@@ -271,7 +306,8 @@ fun MainView(
|
|||||||
keyboardActions = KeyboardActions(onDone = {
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
keyboardController?.hide()
|
keyboardController?.hide()
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
})
|
}),
|
||||||
|
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE)
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
Slider(
|
Slider(
|
||||||
@@ -290,6 +326,32 @@ fun MainView(
|
|||||||
)
|
)
|
||||||
Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
|
Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
|
||||||
Divider()
|
Divider()
|
||||||
|
TextField(
|
||||||
|
label = {
|
||||||
|
Text(text = "Packet Interval (ms)")
|
||||||
|
},
|
||||||
|
value = appViewModel.senderPacketInterval.toString(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
|
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
onValueChange = {
|
||||||
|
if (it.isNotEmpty()) {
|
||||||
|
val interval = it.toIntOrNull()
|
||||||
|
if (interval != null) {
|
||||||
|
appViewModel.updateSenderPacketInterval(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
|
keyboardController?.hide()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}),
|
||||||
|
enabled = (appViewModel.scenario == PING_SCENARIO)
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
ActionButton(
|
ActionButton(
|
||||||
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
||||||
)
|
)
|
||||||
@@ -300,25 +362,78 @@ fun MainView(
|
|||||||
Text(text = "2M PHY")
|
Text(text = "2M PHY")
|
||||||
Spacer(modifier = Modifier.padding(start = 8.dp))
|
Spacer(modifier = Modifier.padding(start = 8.dp))
|
||||||
Switch(
|
Switch(
|
||||||
|
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE),
|
||||||
checked = appViewModel.use2mPhy,
|
checked = appViewModel.use2mPhy,
|
||||||
onCheckedChange = { appViewModel.use2mPhy = it }
|
onCheckedChange = { appViewModel.use2mPhy = it }
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
Row {
|
Row {
|
||||||
ActionButton(
|
Column(Modifier.selectableGroup()) {
|
||||||
text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
|
listOf(
|
||||||
)
|
RFCOMM_CLIENT_MODE,
|
||||||
ActionButton(
|
RFCOMM_SERVER_MODE,
|
||||||
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
|
L2CAP_CLIENT_MODE,
|
||||||
)
|
L2CAP_SERVER_MODE
|
||||||
|
).forEach { text ->
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.selectable(
|
||||||
|
selected = (text == appViewModel.mode),
|
||||||
|
onClick = { appViewModel.updateMode(text) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = (text == appViewModel.mode),
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.selectableGroup()) {
|
||||||
|
listOf(
|
||||||
|
SEND_SCENARIO,
|
||||||
|
RECEIVE_SCENARIO,
|
||||||
|
PING_SCENARIO,
|
||||||
|
PONG_SCENARIO
|
||||||
|
).forEach { text ->
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.selectable(
|
||||||
|
selected = (text == appViewModel.scenario),
|
||||||
|
onClick = { appViewModel.updateScenario(text) },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = (text == appViewModel.scenario),
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Row {
|
Row {
|
||||||
ActionButton(
|
ActionButton(
|
||||||
text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
|
text = "Start", onClick = runScenario, enabled = !appViewModel.running
|
||||||
)
|
)
|
||||||
ActionButton(
|
ActionButton(
|
||||||
text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
|
text = "Stop", onClick = appViewModel::abort, enabled = appViewModel.running
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
@@ -328,6 +443,12 @@ fun MainView(
|
|||||||
Text(
|
Text(
|
||||||
text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else ""
|
text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else ""
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = "Status: ${appViewModel.status}"
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Last Error: ${appViewModel.lastError}"
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Packets Sent: ${appViewModel.packetsSent}"
|
text = "Packets Sent: ${appViewModel.packetsSent}"
|
||||||
)
|
)
|
||||||
@@ -337,9 +458,8 @@ fun MainView(
|
|||||||
Text(
|
Text(
|
||||||
text = "Throughput: ${appViewModel.throughput}"
|
text = "Throughput: ${appViewModel.throughput}"
|
||||||
)
|
)
|
||||||
Divider()
|
Text(
|
||||||
ActionButton(
|
text = "Stats: ${appViewModel.stats}"
|
||||||
text = "Abort", onClick = appViewModel::abort, appViewModel.running
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,4 +471,4 @@ fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
|
|||||||
Button(onClick = onClick, enabled = enabled) {
|
Button(onClick = onClick, enabled = enabled) {
|
||||||
Text(text = text)
|
Text(text = text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
interface Mode {
|
||||||
|
fun run()
|
||||||
|
fun waitForCompletion()
|
||||||
|
}
|
||||||
@@ -27,10 +27,25 @@ val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF
|
|||||||
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||||
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
||||||
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
||||||
|
const val DEFAULT_SENDER_PACKET_INTERVAL = 100
|
||||||
const val DEFAULT_PSM = 128
|
const val DEFAULT_PSM = 128
|
||||||
|
|
||||||
|
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 SEND_SCENARIO = "Send"
|
||||||
|
const val RECEIVE_SCENARIO = "Receive"
|
||||||
|
const val PING_SCENARIO = "Ping"
|
||||||
|
const val PONG_SCENARIO = "Pong"
|
||||||
|
|
||||||
class AppViewModel : ViewModel() {
|
class AppViewModel : ViewModel() {
|
||||||
private var preferences: SharedPreferences? = null
|
private var preferences: SharedPreferences? = null
|
||||||
|
var status by mutableStateOf("")
|
||||||
|
var lastError by mutableStateOf("")
|
||||||
|
var mode by mutableStateOf(RFCOMM_SERVER_MODE)
|
||||||
|
var scenario by mutableStateOf(RECEIVE_SCENARIO)
|
||||||
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
||||||
var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
|
var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
|
||||||
var use2mPhy by mutableStateOf(true)
|
var use2mPhy by mutableStateOf(true)
|
||||||
@@ -41,9 +56,11 @@ class AppViewModel : ViewModel() {
|
|||||||
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
||||||
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
||||||
var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
|
var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
|
||||||
|
var senderPacketInterval by mutableIntStateOf(DEFAULT_SENDER_PACKET_INTERVAL)
|
||||||
var packetsSent by mutableIntStateOf(0)
|
var packetsSent by mutableIntStateOf(0)
|
||||||
var packetsReceived by mutableIntStateOf(0)
|
var packetsReceived by mutableIntStateOf(0)
|
||||||
var throughput by mutableIntStateOf(0)
|
var throughput by mutableIntStateOf(0)
|
||||||
|
var stats by mutableStateOf("")
|
||||||
var running by mutableStateOf(false)
|
var running by mutableStateOf(false)
|
||||||
var aborter: (() -> Unit)? = null
|
var aborter: (() -> Unit)? = null
|
||||||
|
|
||||||
@@ -66,6 +83,21 @@ class AppViewModel : ViewModel() {
|
|||||||
senderPacketSize = savedSenderPacketSize
|
senderPacketSize = savedSenderPacketSize
|
||||||
}
|
}
|
||||||
updateSenderPacketSizeSlider()
|
updateSenderPacketSizeSlider()
|
||||||
|
|
||||||
|
val savedSenderPacketInterval = preferences.getInt(SENDER_PACKET_INTERVAL_PREF_KEY, -1)
|
||||||
|
if (savedSenderPacketInterval != -1) {
|
||||||
|
senderPacketInterval = savedSenderPacketInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
val savedMode = preferences.getString(MODE_PREF_KEY, null)
|
||||||
|
if (savedMode != null) {
|
||||||
|
mode = savedMode
|
||||||
|
}
|
||||||
|
|
||||||
|
val savedScenario = preferences.getString(SCENARIO_PREF_KEY, null)
|
||||||
|
if (savedScenario != null) {
|
||||||
|
scenario = savedScenario
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
|
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
|
||||||
@@ -164,6 +196,42 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateSenderPacketInterval(senderPacketInterval: Int) {
|
||||||
|
this.senderPacketInterval = senderPacketInterval
|
||||||
|
with(preferences!!.edit()) {
|
||||||
|
putInt(SENDER_PACKET_INTERVAL_PREF_KEY, senderPacketInterval)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateScenario(scenario: String) {
|
||||||
|
this.scenario = scenario
|
||||||
|
with(preferences!!.edit()) {
|
||||||
|
putString(SCENARIO_PREF_KEY, scenario)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateMode(mode: String) {
|
||||||
|
this.mode = mode
|
||||||
|
with(preferences!!.edit()) {
|
||||||
|
putString(MODE_PREF_KEY, mode)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
status = ""
|
||||||
|
lastError = ""
|
||||||
|
mtu = 0
|
||||||
|
rxPhy = 0
|
||||||
|
txPhy = 0
|
||||||
|
packetsSent = 0
|
||||||
|
packetsReceived = 0
|
||||||
|
throughput = 0
|
||||||
|
stats = ""
|
||||||
|
}
|
||||||
|
|
||||||
fun abort() {
|
fun abort() {
|
||||||
aborter?.let { it() }
|
aborter?.let { it() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,13 +74,13 @@ abstract class PacketSink {
|
|||||||
fun onPacket(packet: Packet) {
|
fun onPacket(packet: Packet) {
|
||||||
when (packet) {
|
when (packet) {
|
||||||
is ResetPacket -> onResetPacket()
|
is ResetPacket -> onResetPacket()
|
||||||
is AckPacket -> onAckPacket()
|
is AckPacket -> onAckPacket(packet)
|
||||||
is SequencePacket -> onSequencePacket(packet)
|
is SequencePacket -> onSequencePacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract fun onResetPacket()
|
abstract fun onResetPacket()
|
||||||
abstract fun onAckPacket()
|
abstract fun onAckPacket(packet: AckPacket)
|
||||||
abstract fun onSequencePacket(packet: SequencePacket)
|
abstract fun onSequencePacket(packet: SequencePacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,4 +175,4 @@ class SocketDataSource(
|
|||||||
} while (true)
|
} while (true)
|
||||||
Log.info("end of stream")
|
Log.info("end of stream")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// 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.concurrent.Semaphore
|
||||||
|
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,
|
||||||
|
PacketSink() {
|
||||||
|
private val pingTimes: ArrayList<TimeSource.Monotonic.ValueTimeMark> = ArrayList()
|
||||||
|
private val rtts: ArrayList<Long> = ArrayList()
|
||||||
|
private val done = Semaphore(0)
|
||||||
|
|
||||||
|
init {
|
||||||
|
packetIO.packetSink = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
viewModel.clear()
|
||||||
|
|
||||||
|
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
|
||||||
|
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||||
|
Log.info("running")
|
||||||
|
|
||||||
|
Log.info("sending reset")
|
||||||
|
packetIO.sendPacket(ResetPacket())
|
||||||
|
|
||||||
|
val packetCount = viewModel.senderPacketCount
|
||||||
|
val packetSize = viewModel.senderPacketSize
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
pingTimes.add(TimeSource.Monotonic.markNow())
|
||||||
|
packetIO.sendPacket(
|
||||||
|
SequencePacket(
|
||||||
|
if (i < packetCount - 1) 0 else Packet.LAST_FLAG,
|
||||||
|
i,
|
||||||
|
ByteArray(packetSize - 6)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
viewModel.packetsSent = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the last ACK
|
||||||
|
Log.info("waiting for last ACK")
|
||||||
|
done.acquire()
|
||||||
|
Log.info("got last ACK")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun abort() {
|
||||||
|
done.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResetPacket() {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAckPacket(packet: AckPacket) {
|
||||||
|
val now = TimeSource.Monotonic.markNow()
|
||||||
|
viewModel.packetsReceived += 1
|
||||||
|
if (packet.sequenceNumber < pingTimes.size) {
|
||||||
|
val rtt = (now - pingTimes[packet.sequenceNumber]).inWholeMilliseconds
|
||||||
|
rtts.add(rtt)
|
||||||
|
Log.info("received ACK ${packet.sequenceNumber}, RTT=$rtt")
|
||||||
|
} else {
|
||||||
|
Log.warning("received ACK with unexpected sequence ${packet.sequenceNumber}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.flags and Packet.LAST_FLAG != 0) {
|
||||||
|
Log.info("last packet received")
|
||||||
|
val stats = "RTTs: min=${rtts.min()}, max=${rtts.max()}, avg=${rtts.sum() / rtts.size}"
|
||||||
|
Log.info(stats)
|
||||||
|
viewModel.stats = stats
|
||||||
|
done.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSequencePacket(packet: SequencePacket) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// 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.logging.Logger
|
||||||
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
private val Log = Logger.getLogger("btbench.receiver")
|
||||||
|
|
||||||
|
class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient, PacketSink() {
|
||||||
|
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
|
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
|
private var expectedSequenceNumber: Int = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
packetIO.packetSink = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
viewModel.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun abort() {}
|
||||||
|
|
||||||
|
override fun onResetPacket() {
|
||||||
|
startTime = TimeSource.Monotonic.markNow()
|
||||||
|
lastPacketTime = startTime
|
||||||
|
expectedSequenceNumber = 0
|
||||||
|
viewModel.packetsSent = 0
|
||||||
|
viewModel.packetsReceived = 0
|
||||||
|
viewModel.stats = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAckPacket(packet: AckPacket) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSequencePacket(packet: SequencePacket) {
|
||||||
|
val now = TimeSource.Monotonic.markNow()
|
||||||
|
lastPacketTime = now
|
||||||
|
viewModel.packetsReceived += 1
|
||||||
|
|
||||||
|
if (packet.sequenceNumber != expectedSequenceNumber) {
|
||||||
|
Log.warning("unexpected packet sequence number (expected ${expectedSequenceNumber}, got ${packet.sequenceNumber})")
|
||||||
|
}
|
||||||
|
expectedSequenceNumber += 1
|
||||||
|
|
||||||
|
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
|
||||||
|
viewModel.packetsSent += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-2
@@ -20,7 +20,7 @@ import kotlin.time.TimeSource
|
|||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.receiver")
|
private val Log = Logger.getLogger("btbench.receiver")
|
||||||
|
|
||||||
class Receiver(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
|
class Receiver(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient, PacketSink() {
|
||||||
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
private var bytesReceived = 0
|
private var bytesReceived = 0
|
||||||
@@ -29,6 +29,12 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
|
|||||||
packetIO.packetSink = this
|
packetIO.packetSink = this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun run() {
|
||||||
|
viewModel.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun abort() {}
|
||||||
|
|
||||||
override fun onResetPacket() {
|
override fun onResetPacket() {
|
||||||
startTime = TimeSource.Monotonic.markNow()
|
startTime = TimeSource.Monotonic.markNow()
|
||||||
lastPacketTime = startTime
|
lastPacketTime = startTime
|
||||||
@@ -36,9 +42,10 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
|
|||||||
viewModel.throughput = 0
|
viewModel.throughput = 0
|
||||||
viewModel.packetsSent = 0
|
viewModel.packetsSent = 0
|
||||||
viewModel.packetsReceived = 0
|
viewModel.packetsReceived = 0
|
||||||
|
viewModel.stats = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAckPacket() {
|
override fun onAckPacket(packet: AckPacket) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -16,22 +16,30 @@ package com.github.google.bumble.btbench
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothAdapter
|
import android.bluetooth.BluetoothAdapter
|
||||||
import java.io.IOException
|
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.rfcomm-client")
|
private val Log = Logger.getLogger("btbench.rfcomm-client")
|
||||||
|
|
||||||
class RfcommClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
class RfcommClient(
|
||||||
|
private val viewModel: AppViewModel,
|
||||||
|
private val bluetoothAdapter: BluetoothAdapter,
|
||||||
|
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||||
|
) : Mode {
|
||||||
|
private var socketClient: SocketClient? = null
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
override fun run() {
|
||||||
val address = viewModel.peerBluetoothAddress.take(17)
|
val address = viewModel.peerBluetoothAddress.take(17)
|
||||||
val remoteDevice = bluetoothAdapter.getRemoteDevice(address)
|
val remoteDevice = bluetoothAdapter.getRemoteDevice(address)
|
||||||
val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
|
val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
|
||||||
DEFAULT_RFCOMM_UUID
|
DEFAULT_RFCOMM_UUID
|
||||||
)
|
)
|
||||||
|
|
||||||
val client = SocketClient(viewModel, socket)
|
socketClient = SocketClient(viewModel, socket, createIoClient)
|
||||||
client.run()
|
socketClient!!.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun waitForCompletion() {
|
||||||
|
socketClient?.waitForCompletion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-8
@@ -16,20 +16,27 @@ package com.github.google.bumble.btbench
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothAdapter
|
import android.bluetooth.BluetoothAdapter
|
||||||
import java.io.IOException
|
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.rfcomm-server")
|
private val Log = Logger.getLogger("btbench.rfcomm-server")
|
||||||
|
|
||||||
class RfcommServer(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
class RfcommServer(
|
||||||
|
private val viewModel: AppViewModel,
|
||||||
|
private val bluetoothAdapter: BluetoothAdapter,
|
||||||
|
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||||
|
) : Mode {
|
||||||
|
private var socketServer: SocketServer? = null
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
override fun run() {
|
||||||
val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
|
val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
|
||||||
"BumbleBench", DEFAULT_RFCOMM_UUID
|
"BumbleBench", DEFAULT_RFCOMM_UUID
|
||||||
)
|
)
|
||||||
|
socketServer = SocketServer(viewModel, serverSocket, createIoClient)
|
||||||
val server = SocketServer(viewModel, serverSocket)
|
socketServer!!.run({}, {})
|
||||||
server.run({}, {})
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override fun waitForCompletion() {
|
||||||
|
socketServer?.waitForCompletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ class Scan(val bluetoothAdapter: BluetoothAdapter) {
|
|||||||
bluetoothLeScanner?.stopScan(scanCallback)
|
bluetoothLeScanner?.stopScan(scanCallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-8
@@ -19,9 +19,12 @@ import java.util.logging.Logger
|
|||||||
import kotlin.time.DurationUnit
|
import kotlin.time.DurationUnit
|
||||||
import kotlin.time.TimeSource
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
|
private const val DEFAULT_STARTUP_DELAY = 3000
|
||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.sender")
|
private val Log = Logger.getLogger("btbench.sender")
|
||||||
|
|
||||||
class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
|
class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
|
||||||
|
PacketSink() {
|
||||||
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||||
private var bytesSent = 0
|
private var bytesSent = 0
|
||||||
private val done = Semaphore(0)
|
private val done = Semaphore(0)
|
||||||
@@ -30,10 +33,12 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
|||||||
packetIO.packetSink = this
|
packetIO.packetSink = this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun run() {
|
override fun run() {
|
||||||
viewModel.packetsSent = 0
|
viewModel.clear()
|
||||||
viewModel.packetsReceived = 0
|
|
||||||
viewModel.throughput = 0
|
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
|
||||||
|
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||||
|
Log.info("running")
|
||||||
|
|
||||||
Log.info("sending reset")
|
Log.info("sending reset")
|
||||||
packetIO.sendPacket(ResetPacket())
|
packetIO.sendPacket(ResetPacket())
|
||||||
@@ -63,14 +68,14 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
|||||||
Log.info("got ACK")
|
Log.info("got ACK")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun abort() {
|
override fun abort() {
|
||||||
done.release()
|
done.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResetPacket() {
|
override fun onResetPacket() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAckPacket() {
|
override fun onAckPacket(packet: AckPacket) {
|
||||||
Log.info("received ACK")
|
Log.info("received ACK")
|
||||||
val elapsed = TimeSource.Monotonic.markNow() - startTime
|
val elapsed = TimeSource.Monotonic.markNow() - startTime
|
||||||
val throughput = (bytesSent / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
|
val throughput = (bytesSent / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
|
||||||
@@ -81,4 +86,4 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
|||||||
|
|
||||||
override fun onSequencePacket(packet: SequencePacket) {
|
override fun onSequencePacket(packet: SequencePacket) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-13
@@ -22,16 +22,20 @@ import kotlin.concurrent.thread
|
|||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.socket-client")
|
private val Log = Logger.getLogger("btbench.socket-client")
|
||||||
|
|
||||||
private const val DEFAULT_STARTUP_DELAY = 3000
|
class SocketClient(
|
||||||
|
private val viewModel: AppViewModel,
|
||||||
|
private val socket: BluetoothSocket,
|
||||||
|
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||||
|
) {
|
||||||
|
private var clientThread: Thread? = null
|
||||||
|
|
||||||
class SocketClient(private val viewModel: AppViewModel, private val socket: BluetoothSocket) {
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
fun run() {
|
||||||
viewModel.running = true
|
viewModel.running = true
|
||||||
val socketDataSink = SocketDataSink(socket)
|
val socketDataSink = SocketDataSink(socket)
|
||||||
val streamIO = StreamedPacketIO(socketDataSink)
|
val streamIO = StreamedPacketIO(socketDataSink)
|
||||||
val socketDataSource = SocketDataSource(socket, streamIO::onData)
|
val socketDataSource = SocketDataSource(socket, streamIO::onData)
|
||||||
val sender = Sender(viewModel, streamIO)
|
val ioClient = createIoClient(streamIO)
|
||||||
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
socket.close()
|
socket.close()
|
||||||
@@ -39,9 +43,9 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
|
|||||||
viewModel.running = false
|
viewModel.running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
thread(name = "SocketClient") {
|
clientThread = thread(name = "SocketClient") {
|
||||||
viewModel.aborter = {
|
viewModel.aborter = {
|
||||||
sender.abort()
|
ioClient.abort()
|
||||||
socket.close()
|
socket.close()
|
||||||
}
|
}
|
||||||
Log.info("connecting to remote")
|
Log.info("connecting to remote")
|
||||||
@@ -49,27 +53,37 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
|
|||||||
socket.connect()
|
socket.connect()
|
||||||
} catch (error: IOException) {
|
} catch (error: IOException) {
|
||||||
Log.warning("connection failed")
|
Log.warning("connection failed")
|
||||||
|
viewModel.status = "ABORTED"
|
||||||
|
viewModel.lastError = "CONNECTION_FAILED"
|
||||||
cleanup()
|
cleanup()
|
||||||
return@thread
|
return@thread
|
||||||
}
|
}
|
||||||
Log.info("connected")
|
Log.info("connected")
|
||||||
|
|
||||||
thread {
|
val sourceThread = thread {
|
||||||
socketDataSource.receive()
|
socketDataSource.receive()
|
||||||
socket.close()
|
socket.close()
|
||||||
sender.abort()
|
ioClient.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
|
|
||||||
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
|
||||||
Log.info("Starting to send")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sender.run()
|
ioClient.run()
|
||||||
|
socket.close()
|
||||||
|
viewModel.status = "OK"
|
||||||
} catch (error: IOException) {
|
} catch (error: IOException) {
|
||||||
Log.info("run ended abruptly")
|
Log.info("run ended abruptly")
|
||||||
|
viewModel.status = "ABORTED"
|
||||||
|
viewModel.lastError = "IO_ERROR"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.info("waiting for source thread to finish")
|
||||||
|
sourceThread.join()
|
||||||
|
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
fun waitForCompletion() {
|
||||||
|
clientThread?.join()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+23
-4
@@ -21,7 +21,13 @@ import kotlin.concurrent.thread
|
|||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.socket-server")
|
private val Log = Logger.getLogger("btbench.socket-server")
|
||||||
|
|
||||||
class SocketServer(private val viewModel: AppViewModel, private val serverSocket: BluetoothServerSocket) {
|
class SocketServer(
|
||||||
|
private val viewModel: AppViewModel,
|
||||||
|
private val serverSocket: BluetoothServerSocket,
|
||||||
|
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||||
|
) {
|
||||||
|
private var serverThread: Thread? = null
|
||||||
|
|
||||||
fun run(onConnected: () -> Unit, onDisconnected: () -> Unit) {
|
fun run(onConnected: () -> Unit, onDisconnected: () -> Unit) {
|
||||||
var aborted = false
|
var aborted = false
|
||||||
viewModel.running = true
|
viewModel.running = true
|
||||||
@@ -31,7 +37,7 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
|||||||
viewModel.running = false
|
viewModel.running = false
|
||||||
}
|
}
|
||||||
|
|
||||||
thread(name = "SocketServer") {
|
serverThread = thread(name = "SocketServer") {
|
||||||
while (!aborted) {
|
while (!aborted) {
|
||||||
viewModel.aborter = {
|
viewModel.aborter = {
|
||||||
serverSocket.close()
|
serverSocket.close()
|
||||||
@@ -46,6 +52,8 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
|||||||
return@thread
|
return@thread
|
||||||
}
|
}
|
||||||
Log.info("got connection from ${socket.remoteDevice.address}")
|
Log.info("got connection from ${socket.remoteDevice.address}")
|
||||||
|
Log.info("maxReceivePacketSize=${socket.maxReceivePacketSize}")
|
||||||
|
Log.info("maxTransmitPacketSize=${socket.maxTransmitPacketSize}")
|
||||||
onConnected()
|
onConnected()
|
||||||
|
|
||||||
viewModel.aborter = {
|
viewModel.aborter = {
|
||||||
@@ -57,11 +65,22 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
|||||||
val socketDataSink = SocketDataSink(socket)
|
val socketDataSink = SocketDataSink(socket)
|
||||||
val streamIO = StreamedPacketIO(socketDataSink)
|
val streamIO = StreamedPacketIO(socketDataSink)
|
||||||
val socketDataSource = SocketDataSource(socket, streamIO::onData)
|
val socketDataSource = SocketDataSource(socket, streamIO::onData)
|
||||||
val receiver = Receiver(viewModel, streamIO)
|
|
||||||
|
val ioThread = thread(name = "IoClient") {
|
||||||
|
val ioClient = createIoClient(streamIO)
|
||||||
|
ioClient.run()
|
||||||
|
}
|
||||||
|
|
||||||
socketDataSource.receive()
|
socketDataSource.receive()
|
||||||
socket.close()
|
socket.close()
|
||||||
|
ioThread.join()
|
||||||
}
|
}
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
fun waitForCompletion() {
|
||||||
|
serverThread?.join()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.2.0"
|
agp = "8.4.0"
|
||||||
kotlin = "1.9.0"
|
kotlin = "1.9.0"
|
||||||
core-ktx = "1.12.0"
|
core-ktx = "1.12.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
@@ -8,6 +8,8 @@ espresso-core = "3.5.1"
|
|||||||
lifecycle-runtime-ktx = "2.6.2"
|
lifecycle-runtime-ktx = "2.6.2"
|
||||||
activity-compose = "1.7.2"
|
activity-compose = "1.7.2"
|
||||||
compose-bom = "2023.08.00"
|
compose-bom = "2023.08.00"
|
||||||
|
mobly-snippet = "1.4.0"
|
||||||
|
core = "1.6.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
|
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
|
||||||
@@ -24,6 +26,8 @@ ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview
|
|||||||
ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
material3 = { group = "androidx.compose.material3", name = "material3" }
|
material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
mobly-snippet = { group = "com.google.android.mobly", name = "mobly-snippet-lib", version.ref = "mobly.snippet" }
|
||||||
|
androidx-core = { group = "androidx.test", name = "core", version.ref = "core" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#Wed Oct 25 07:40:52 PDT 2023
|
#Wed Oct 25 07:40:52 PDT 2023
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
+2
-1
@@ -23,6 +23,7 @@ public class HciProxy {
|
|||||||
HciHal hciHal = HciHal.create(new HciHalCallback() {
|
HciHal hciHal = HciHal.create(new HciHalCallback() {
|
||||||
@Override
|
@Override
|
||||||
public void onPacket(HciPacket.Type type, byte[] packet) {
|
public void onPacket(HciPacket.Type type, byte[] packet) {
|
||||||
|
Log.d(TAG, String.format("CONTROLLER->HOST: type=%s, size=%d", type, packet.length));
|
||||||
mServer.sendPacket(type, packet);
|
mServer.sendPacket(type, packet);
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -83,7 +84,7 @@ public class HciProxy {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPacket(HciPacket.Type type, byte[] packet) {
|
public void onPacket(HciPacket.Type type, byte[] packet) {
|
||||||
Log.d(TAG, String.format("onPacket: type=%s, size=%d", type, packet.length));
|
Log.d(TAG, String.format("HOST->CONTROLLER: type=%s, size=%d", type, packet.length));
|
||||||
hciHal.sendPacket(type, packet);
|
hciHal.sendPacket(type, packet);
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|||||||
+125
-13
@@ -1,21 +1,133 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=52", "wheel", "setuptools_scm>=6.2"]
|
requires = ["setuptools>=61", "wheel", "setuptools_scm>=8"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "bumble"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = "Bluetooth Stack for Apps, Emulation, Test and Experimentation"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [{ name = "Google", email = "bumble-dev@google.com" }]
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
dependencies = [
|
||||||
|
"aiohttp ~= 3.8; platform_system!='Emscripten'",
|
||||||
|
"appdirs >= 1.4; platform_system!='Emscripten'",
|
||||||
|
"click >= 8.1.3; platform_system!='Emscripten'",
|
||||||
|
"cryptography >= 39; platform_system!='Emscripten'",
|
||||||
|
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the
|
||||||
|
# versions available on PyPI. Relax the version requirement since it's better than being
|
||||||
|
# completely unable to import the package in case of version mismatch.
|
||||||
|
"cryptography >= 39.0; platform_system=='Emscripten'",
|
||||||
|
"grpcio >= 1.62.1; platform_system!='Emscripten'",
|
||||||
|
"humanize >= 4.6.0; platform_system!='Emscripten'",
|
||||||
|
"libusb1 >= 2.0.1; platform_system!='Emscripten'",
|
||||||
|
"libusb-package == 1.0.26.1; platform_system!='Emscripten'",
|
||||||
|
"platformdirs >= 3.10.0; platform_system!='Emscripten'",
|
||||||
|
"prompt_toolkit >= 3.0.16; platform_system!='Emscripten'",
|
||||||
|
"prettytable >= 3.6.0; platform_system!='Emscripten'",
|
||||||
|
"protobuf >= 3.12.4; platform_system!='Emscripten'",
|
||||||
|
"pyee >= 8.2.2",
|
||||||
|
"pyserial-asyncio >= 0.5; platform_system!='Emscripten'",
|
||||||
|
"pyserial >= 3.5; platform_system!='Emscripten'",
|
||||||
|
"pyusb >= 1.2; platform_system!='Emscripten'",
|
||||||
|
"websockets == 13.1; platform_system!='Emscripten'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
build = ["build >= 0.7"]
|
||||||
|
test = [
|
||||||
|
"pytest >= 8.2",
|
||||||
|
"pytest-asyncio >= 0.23.5",
|
||||||
|
"pytest-html >= 3.2.0",
|
||||||
|
"coverage >= 6.4",
|
||||||
|
]
|
||||||
|
development = [
|
||||||
|
"black == 24.3",
|
||||||
|
"bt-test-interfaces >= 0.0.6",
|
||||||
|
"grpcio-tools >= 1.62.1",
|
||||||
|
"invoke >= 1.7.3",
|
||||||
|
"mobly >= 1.12.2",
|
||||||
|
"mypy == 1.12.0",
|
||||||
|
"nox >= 2022",
|
||||||
|
"pylint == 3.3.1",
|
||||||
|
"pyyaml >= 6.0",
|
||||||
|
"types-appdirs >= 1.4.3",
|
||||||
|
"types-invoke >= 1.7.3",
|
||||||
|
"types-protobuf >= 4.21.0",
|
||||||
|
]
|
||||||
|
avatar = [
|
||||||
|
"pandora-avatar == 0.0.10",
|
||||||
|
"rootcanal == 1.10.0 ; python_version>='3.10'",
|
||||||
|
]
|
||||||
|
pandora = ["bt-test-interfaces >= 0.0.6"]
|
||||||
|
documentation = [
|
||||||
|
"mkdocs >= 1.4.0",
|
||||||
|
"mkdocs-material >= 8.5.6",
|
||||||
|
"mkdocstrings[python] >= 0.19.0",
|
||||||
|
]
|
||||||
|
lc3 = [
|
||||||
|
"lc3 @ git+https://github.com/google/liblc3.git",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
bumble-ble-rpa-tool = "bumble.apps.ble_rpa_tool:main"
|
||||||
|
bumble-console = "bumble.apps.console:main"
|
||||||
|
bumble-controller-info = "bumble.apps.controller_info:main"
|
||||||
|
bumble-controller-loopback = "bumble.apps.controller_loopback:main"
|
||||||
|
bumble-gatt-dump = "bumble.apps.gatt_dump:main"
|
||||||
|
bumble-hci-bridge = "bumble.apps.hci_bridge:main"
|
||||||
|
bumble-l2cap-bridge = "bumble.apps.l2cap_bridge:main"
|
||||||
|
bumble-rfcomm-bridge = "bumble.apps.rfcomm_bridge:main"
|
||||||
|
bumble-pair = "bumble.apps.pair:main"
|
||||||
|
bumble-scan = "bumble.apps.scan:main"
|
||||||
|
bumble-show = "bumble.apps.show:main"
|
||||||
|
bumble-unbond = "bumble.apps.unbond:main"
|
||||||
|
bumble-usb-probe = "bumble.apps.usb_probe:main"
|
||||||
|
bumble-link-relay = "bumble.apps.link_relay.link_relay:main"
|
||||||
|
bumble-bench = "bumble.apps.bench:main"
|
||||||
|
bumble-player = "bumble.apps.player.player:main"
|
||||||
|
bumble-speaker = "bumble.apps.speaker.speaker:main"
|
||||||
|
bumble-pandora-server = "bumble.apps.pandora_server:main"
|
||||||
|
bumble-rtk-util = "bumble.tools.rtk_util:main"
|
||||||
|
bumble-rtk-fw-download = "bumble.tools.rtk_fw_download:main"
|
||||||
|
bumble-intel-util = "bumble.tools.intel_util:main"
|
||||||
|
bumble-intel-fw-download = "bumble.tools.intel_fw_download:main"
|
||||||
|
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/google/bumble"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = [
|
||||||
|
"bumble",
|
||||||
|
"bumble.transport",
|
||||||
|
"bumble.transport.grpc_protobuf",
|
||||||
|
"bumble.drivers",
|
||||||
|
"bumble.profiles",
|
||||||
|
"bumble.apps",
|
||||||
|
"bumble.apps.link_relay",
|
||||||
|
"bumble.pandora",
|
||||||
|
"bumble.tools",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.package-dir]
|
||||||
|
"bumble" = "bumble"
|
||||||
|
"bumble.apps" = "apps"
|
||||||
|
"bumble.tools" = "tools"
|
||||||
|
|
||||||
[tool.setuptools_scm]
|
[tool.setuptools_scm]
|
||||||
write_to = "bumble/_version.py"
|
write_to = "bumble/_version.py"
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"*" = ["*.pyi", "py.typed"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = "."
|
pythonpath = "."
|
||||||
testpaths = [
|
testpaths = ["tests"]
|
||||||
"tests"
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pylint.master]
|
[tool.pylint.master]
|
||||||
init-hook = 'import sys; sys.path.append(".")'
|
init-hook = 'import sys; sys.path.append(".")'
|
||||||
ignore-paths = [
|
ignore-paths = ['.*_pb2(_grpc)?.py']
|
||||||
'.*_pb2(_grpc)?.py'
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pylint.messages_control]
|
[tool.pylint.messages_control]
|
||||||
max-line-length = "88"
|
max-line-length = "88"
|
||||||
@@ -25,8 +137,8 @@ disable = [
|
|||||||
"fixme",
|
"fixme",
|
||||||
"logging-fstring-interpolation",
|
"logging-fstring-interpolation",
|
||||||
"logging-not-lazy",
|
"logging-not-lazy",
|
||||||
"no-member", # Temporary until pylint works better with class/method decorators
|
"no-member", # Temporary until pylint works better with class/method decorators
|
||||||
"no-value-for-parameter", # Temporary until pylint works better with class/method decorators
|
"no-value-for-parameter", # Temporary until pylint works better with class/method decorators
|
||||||
"missing-class-docstring",
|
"missing-class-docstring",
|
||||||
"missing-function-docstring",
|
"missing-function-docstring",
|
||||||
"missing-module-docstring",
|
"missing-module-docstring",
|
||||||
@@ -41,10 +153,11 @@ disable = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.pylint.main]
|
[tool.pylint.main]
|
||||||
ignore="pandora" # FIXME: pylint does not support stubs yet:
|
ignore = "pandora" # FIXME: pylint does not support stubs yet:
|
||||||
|
|
||||||
[tool.pylint.typecheck]
|
[tool.pylint.typecheck]
|
||||||
signature-mutators="AsyncRunner.run_in_task"
|
signature-mutators = "AsyncRunner.run_in_task"
|
||||||
|
disable = "not-callable"
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
skip-string-normalization = true
|
skip-string-normalization = true
|
||||||
@@ -55,7 +168,7 @@ extend-exclude = '''
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
exclude = ['bumble/transport/grpc_protobuf']
|
exclude = ['bumble/transport/grpc_protobuf', 'examples/mobly/bench']
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "bumble.transport.grpc_protobuf.*"
|
module = "bumble.transport.grpc_protobuf.*"
|
||||||
@@ -84,4 +197,3 @@ ignore_missing_imports = true
|
|||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "usb1.*"
|
module = "usb1.*"
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ impl Address {
|
|||||||
/// Creates a new [Address] object.
|
/// Creates a new [Address] object.
|
||||||
pub fn new(address: &str, address_type: AddressType) -> PyResult<Self> {
|
pub fn new(address: &str, address_type: AddressType) -> PyResult<Self> {
|
||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
PyModule::import(py, intern!(py, "bumble.device"))?
|
PyModule::import(py, intern!(py, "bumble.hci"))?
|
||||||
.getattr(intern!(py, "Address"))?
|
.getattr(intern!(py, "Address"))?
|
||||||
.call1((address, address_type))
|
.call1((address, address_type))
|
||||||
.map(|any| Self(any.into()))
|
.map(|any| Self(any.into()))
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
# Copyright 2021-2023 Google LLC
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
[metadata]
|
|
||||||
name = bumble
|
|
||||||
use_scm_version = True
|
|
||||||
description = Bluetooth Stack for Apps, Emulation, Test and Experimentation
|
|
||||||
long_description = file: README.md
|
|
||||||
long_description_content_type = text/markdown
|
|
||||||
author = Google
|
|
||||||
author_email = tbd@tbd.com
|
|
||||||
url = https://github.com/google/bumble
|
|
||||||
|
|
||||||
[options]
|
|
||||||
python_requires = >=3.8
|
|
||||||
packages = bumble, bumble.transport, bumble.transport.grpc_protobuf, bumble.drivers, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora, bumble.tools
|
|
||||||
package_dir =
|
|
||||||
bumble = bumble
|
|
||||||
bumble.apps = apps
|
|
||||||
bumble.tools = tools
|
|
||||||
include_package_data = True
|
|
||||||
install_requires =
|
|
||||||
aiohttp ~= 3.8; platform_system!='Emscripten'
|
|
||||||
appdirs >= 1.4; platform_system!='Emscripten'
|
|
||||||
click >= 8.1.3; platform_system!='Emscripten'
|
|
||||||
cryptography == 39; platform_system!='Emscripten'
|
|
||||||
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the
|
|
||||||
# versions available on PyPI. Relax the version requirement since it's better than being
|
|
||||||
# completely unable to import the package in case of version mismatch.
|
|
||||||
cryptography >= 39.0; platform_system=='Emscripten'
|
|
||||||
grpcio >= 1.62.1; platform_system!='Emscripten'
|
|
||||||
humanize >= 4.6.0; platform_system!='Emscripten'
|
|
||||||
libusb1 >= 2.0.1; platform_system!='Emscripten'
|
|
||||||
libusb-package == 1.0.26.1; platform_system!='Emscripten'
|
|
||||||
platformdirs >= 3.10.0; platform_system!='Emscripten'
|
|
||||||
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
|
|
||||||
prettytable >= 3.6.0; platform_system!='Emscripten'
|
|
||||||
protobuf >= 3.12.4; platform_system!='Emscripten'
|
|
||||||
pyee >= 8.2.2
|
|
||||||
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
|
|
||||||
pyserial >= 3.5; platform_system!='Emscripten'
|
|
||||||
pyusb >= 1.2; platform_system!='Emscripten'
|
|
||||||
websockets >= 12.0; platform_system!='Emscripten'
|
|
||||||
|
|
||||||
[options.entry_points]
|
|
||||||
console_scripts =
|
|
||||||
bumble-ble-rpa-tool = bumble.apps.ble_rpa_tool:main
|
|
||||||
bumble-console = bumble.apps.console:main
|
|
||||||
bumble-controller-info = bumble.apps.controller_info:main
|
|
||||||
bumble-controller-loopback = bumble.apps.controller_loopback:main
|
|
||||||
bumble-gatt-dump = bumble.apps.gatt_dump:main
|
|
||||||
bumble-hci-bridge = bumble.apps.hci_bridge:main
|
|
||||||
bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
|
|
||||||
bumble-rfcomm-bridge = bumble.apps.rfcomm_bridge:main
|
|
||||||
bumble-pair = bumble.apps.pair:main
|
|
||||||
bumble-scan = bumble.apps.scan:main
|
|
||||||
bumble-show = bumble.apps.show:main
|
|
||||||
bumble-unbond = bumble.apps.unbond:main
|
|
||||||
bumble-usb-probe = bumble.apps.usb_probe:main
|
|
||||||
bumble-link-relay = bumble.apps.link_relay.link_relay:main
|
|
||||||
bumble-bench = bumble.apps.bench:main
|
|
||||||
bumble-player = bumble.apps.player.player:main
|
|
||||||
bumble-speaker = bumble.apps.speaker.speaker:main
|
|
||||||
bumble-pandora-server = bumble.apps.pandora_server:main
|
|
||||||
bumble-rtk-util = bumble.tools.rtk_util:main
|
|
||||||
bumble-rtk-fw-download = bumble.tools.rtk_fw_download:main
|
|
||||||
|
|
||||||
[options.package_data]
|
|
||||||
* = py.typed, *.pyi
|
|
||||||
|
|
||||||
[options.extras_require]
|
|
||||||
build =
|
|
||||||
build >= 0.7
|
|
||||||
test =
|
|
||||||
pytest >= 8.2
|
|
||||||
pytest-asyncio >= 0.23.5
|
|
||||||
pytest-html >= 3.2.0
|
|
||||||
coverage >= 6.4
|
|
||||||
development =
|
|
||||||
black == 24.3
|
|
||||||
grpcio-tools >= 1.62.1
|
|
||||||
invoke >= 1.7.3
|
|
||||||
mypy == 1.10.0
|
|
||||||
nox >= 2022
|
|
||||||
pylint == 3.1.0
|
|
||||||
pyyaml >= 6.0
|
|
||||||
types-appdirs >= 1.4.3
|
|
||||||
types-invoke >= 1.7.3
|
|
||||||
types-protobuf >= 4.21.0
|
|
||||||
wasmtime == 20.0.0
|
|
||||||
avatar =
|
|
||||||
pandora-avatar == 0.0.10
|
|
||||||
rootcanal == 1.10.0 ; python_version>='3.10'
|
|
||||||
pandora =
|
|
||||||
bt-test-interfaces >= 0.0.6
|
|
||||||
documentation =
|
|
||||||
mkdocs >= 1.4.0
|
|
||||||
mkdocs-material >= 8.5.6
|
|
||||||
mkdocstrings[python] >= 0.19.0
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Copyright 2021-2022 Google LLC
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# 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 setuptools import setup
|
|
||||||
|
|
||||||
setup()
|
|
||||||
+9
-3
@@ -28,6 +28,7 @@ from bumble.profiles.aics import (
|
|||||||
AudioInputState,
|
AudioInputState,
|
||||||
AICSServiceProxy,
|
AICSServiceProxy,
|
||||||
GainMode,
|
GainMode,
|
||||||
|
GainSettingsProperties,
|
||||||
AudioInputStatus,
|
AudioInputStatus,
|
||||||
AudioInputControlPointOpCode,
|
AudioInputControlPointOpCode,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
@@ -82,7 +83,12 @@ async def test_init_service(aics_client: AICSServiceProxy):
|
|||||||
gain_mode=GainMode.MANUAL,
|
gain_mode=GainMode.MANUAL,
|
||||||
change_counter=0,
|
change_counter=0,
|
||||||
)
|
)
|
||||||
assert await aics_client.gain_settings_properties.read_value() == (1, 0, 255)
|
assert (
|
||||||
|
await aics_client.gain_settings_properties.read_value()
|
||||||
|
== GainSettingsProperties(
|
||||||
|
gain_settings_unit=1, gain_settings_minimum=0, gain_settings_maximum=255
|
||||||
|
)
|
||||||
|
)
|
||||||
assert await aics_client.audio_input_status.read_value() == (
|
assert await aics_client.audio_input_status.read_value() == (
|
||||||
AudioInputStatus.ACTIVE
|
AudioInputStatus.ACTIVE
|
||||||
)
|
)
|
||||||
@@ -481,12 +487,12 @@ async def test_set_automatic_gain_mode_when_automatic_only(
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_audio_input_description_initial_value(aics_client: AICSServiceProxy):
|
async def test_audio_input_description_initial_value(aics_client: AICSServiceProxy):
|
||||||
description = await aics_client.audio_input_description.read_value()
|
description = await aics_client.audio_input_description.read_value()
|
||||||
assert description.decode('utf-8') == "Bluetooth"
|
assert description == "Bluetooth"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_audio_input_description_write_and_read(aics_client: AICSServiceProxy):
|
async def test_audio_input_description_write_and_read(aics_client: AICSServiceProxy):
|
||||||
new_description = "Line Input".encode('utf-8')
|
new_description = "Line Input"
|
||||||
|
|
||||||
await aics_client.audio_input_description.write_value(new_description)
|
await aics_client.audio_input_description.write_value(new_description)
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ from bumble.profiles.ascs import (
|
|||||||
)
|
)
|
||||||
from bumble.profiles.bap import (
|
from bumble.profiles.bap import (
|
||||||
AudioLocation,
|
AudioLocation,
|
||||||
|
BasicAudioAnnouncement,
|
||||||
|
BroadcastAudioAnnouncement,
|
||||||
SupportedFrameDuration,
|
SupportedFrameDuration,
|
||||||
SupportedSamplingFrequency,
|
SupportedSamplingFrequency,
|
||||||
SamplingFrequency,
|
SamplingFrequency,
|
||||||
@@ -200,6 +202,56 @@ def test_codec_specific_configuration() -> None:
|
|||||||
assert CodecSpecificConfiguration.from_bytes(bytes(config)) == config
|
assert CodecSpecificConfiguration.from_bytes(bytes(config)) == config
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_broadcast_audio_announcement() -> None:
|
||||||
|
broadcast_audio_announcement = BroadcastAudioAnnouncement(123456)
|
||||||
|
assert (
|
||||||
|
BroadcastAudioAnnouncement.from_bytes(bytes(broadcast_audio_announcement))
|
||||||
|
== broadcast_audio_announcement
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_basic_audio_announcement() -> None:
|
||||||
|
basic_audio_announcement = BasicAudioAnnouncement(
|
||||||
|
presentation_delay=40000,
|
||||||
|
subgroups=[
|
||||||
|
BasicAudioAnnouncement.Subgroup(
|
||||||
|
codec_id=CodingFormat(codec_id=CodecID.LC3),
|
||||||
|
codec_specific_configuration=CodecSpecificConfiguration(
|
||||||
|
sampling_frequency=SamplingFrequency.FREQ_48000,
|
||||||
|
frame_duration=FrameDuration.DURATION_10000_US,
|
||||||
|
octets_per_codec_frame=100,
|
||||||
|
),
|
||||||
|
metadata=Metadata(
|
||||||
|
[
|
||||||
|
Metadata.Entry(tag=Metadata.Tag.LANGUAGE, data=b'eng'),
|
||||||
|
Metadata.Entry(tag=Metadata.Tag.PROGRAM_INFO, data=b'Disco'),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
bis=[
|
||||||
|
BasicAudioAnnouncement.BIS(
|
||||||
|
index=0,
|
||||||
|
codec_specific_configuration=CodecSpecificConfiguration(
|
||||||
|
audio_channel_allocation=AudioLocation.FRONT_LEFT
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BasicAudioAnnouncement.BIS(
|
||||||
|
index=1,
|
||||||
|
codec_specific_configuration=CodecSpecificConfiguration(
|
||||||
|
audio_channel_allocation=AudioLocation.FRONT_RIGHT
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
BasicAudioAnnouncement.from_bytes(bytes(basic_audio_announcement))
|
||||||
|
== basic_audio_announcement
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pacs():
|
async def test_pacs():
|
||||||
|
|||||||
+59
-14
@@ -19,9 +19,7 @@ import asyncio
|
|||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from types import LambdaType
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
BT_BR_EDR_TRANSPORT,
|
BT_BR_EDR_TRANSPORT,
|
||||||
@@ -29,8 +27,14 @@ from bumble.core import (
|
|||||||
BT_PERIPHERAL_ROLE,
|
BT_PERIPHERAL_ROLE,
|
||||||
ConnectionParameters,
|
ConnectionParameters,
|
||||||
)
|
)
|
||||||
from bumble.device import AdvertisingParameters, Connection, Device
|
from bumble.device import (
|
||||||
from bumble.host import AclPacketQueue, Host
|
AdvertisingEventProperties,
|
||||||
|
AdvertisingParameters,
|
||||||
|
Connection,
|
||||||
|
Device,
|
||||||
|
PeriodicAdvertisingParameters,
|
||||||
|
)
|
||||||
|
from bumble.host import DataPacketQueue, Host
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
||||||
HCI_COMMAND_STATUS_PENDING,
|
HCI_COMMAND_STATUS_PENDING,
|
||||||
@@ -86,9 +90,9 @@ async def test_device_connect_parallel():
|
|||||||
def _send(packet):
|
def _send(packet):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
d0.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
|
d0.host.acl_packet_queue = DataPacketQueue(0, 0, _send)
|
||||||
d1.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
|
d1.host.acl_packet_queue = DataPacketQueue(0, 0, _send)
|
||||||
d2.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
|
d2.host.acl_packet_queue = DataPacketQueue(0, 0, _send)
|
||||||
|
|
||||||
# enable classic
|
# enable classic
|
||||||
d0.classic_enabled = True
|
d0.classic_enabled = True
|
||||||
@@ -265,7 +269,8 @@ async def test_flush():
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_legacy_advertising():
|
async def test_legacy_advertising():
|
||||||
device = Device(host=mock.AsyncMock(Host))
|
device = TwoDevices()[0]
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
# Start advertising
|
# Start advertising
|
||||||
await device.start_advertising()
|
await device.start_advertising()
|
||||||
@@ -283,7 +288,10 @@ async def test_legacy_advertising():
|
|||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_legacy_advertising_disconnection(auto_restart):
|
async def test_legacy_advertising_disconnection(auto_restart):
|
||||||
device = Device(host=mock.AsyncMock(spec=Host))
|
devices = TwoDevices()
|
||||||
|
device = devices[0]
|
||||||
|
devices.controllers[0].le_features = bytes.fromhex('ffffffffffffffff')
|
||||||
|
await device.power_on()
|
||||||
peer_address = Address('F0:F1:F2:F3:F4:F5')
|
peer_address = Address('F0:F1:F2:F3:F4:F5')
|
||||||
await device.start_advertising(auto_restart=auto_restart)
|
await device.start_advertising(auto_restart=auto_restart)
|
||||||
device.on_connection(
|
device.on_connection(
|
||||||
@@ -305,6 +313,11 @@ async def test_legacy_advertising_disconnection(auto_restart):
|
|||||||
await async_barrier()
|
await async_barrier()
|
||||||
|
|
||||||
if auto_restart:
|
if auto_restart:
|
||||||
|
assert device.legacy_advertising_set
|
||||||
|
started = asyncio.Event()
|
||||||
|
if not device.is_advertising:
|
||||||
|
device.legacy_advertising_set.once('start', started.set)
|
||||||
|
await asyncio.wait_for(started.wait(), _TIMEOUT)
|
||||||
assert device.is_advertising
|
assert device.is_advertising
|
||||||
else:
|
else:
|
||||||
assert not device.is_advertising
|
assert not device.is_advertising
|
||||||
@@ -313,7 +326,8 @@ async def test_legacy_advertising_disconnection(auto_restart):
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extended_advertising():
|
async def test_extended_advertising():
|
||||||
device = Device(host=mock.AsyncMock(Host))
|
device = TwoDevices()[0]
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
# Start advertising
|
# Start advertising
|
||||||
advertising_set = await device.create_advertising_set()
|
advertising_set = await device.create_advertising_set()
|
||||||
@@ -332,7 +346,8 @@ async def test_extended_advertising():
|
|||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extended_advertising_connection(own_address_type):
|
async def test_extended_advertising_connection(own_address_type):
|
||||||
device = Device(host=mock.AsyncMock(spec=Host))
|
device = TwoDevices()[0]
|
||||||
|
await device.power_on()
|
||||||
peer_address = Address('F0:F1:F2:F3:F4:F5')
|
peer_address = Address('F0:F1:F2:F3:F4:F5')
|
||||||
advertising_set = await device.create_advertising_set(
|
advertising_set = await device.create_advertising_set(
|
||||||
advertising_parameters=AdvertisingParameters(own_address_type=own_address_type)
|
advertising_parameters=AdvertisingParameters(own_address_type=own_address_type)
|
||||||
@@ -368,8 +383,10 @@ async def test_extended_advertising_connection(own_address_type):
|
|||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extended_advertising_connection_out_of_order(own_address_type):
|
async def test_extended_advertising_connection_out_of_order(own_address_type):
|
||||||
device = Device(host=mock.AsyncMock(spec=Host))
|
devices = TwoDevices()
|
||||||
peer_address = Address('F0:F1:F2:F3:F4:F5')
|
device = devices[0]
|
||||||
|
devices.controllers[0].le_features = bytes.fromhex('ffffffffffffffff')
|
||||||
|
await device.power_on()
|
||||||
advertising_set = await device.create_advertising_set(
|
advertising_set = await device.create_advertising_set(
|
||||||
advertising_parameters=AdvertisingParameters(own_address_type=own_address_type)
|
advertising_parameters=AdvertisingParameters(own_address_type=own_address_type)
|
||||||
)
|
)
|
||||||
@@ -382,7 +399,7 @@ async def test_extended_advertising_connection_out_of_order(own_address_type):
|
|||||||
device.on_connection(
|
device.on_connection(
|
||||||
0x0001,
|
0x0001,
|
||||||
BT_LE_TRANSPORT,
|
BT_LE_TRANSPORT,
|
||||||
peer_address,
|
Address('F0:F1:F2:F3:F4:F5'),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
BT_PERIPHERAL_ROLE,
|
BT_PERIPHERAL_ROLE,
|
||||||
@@ -397,6 +414,34 @@ async def test_extended_advertising_connection_out_of_order(own_address_type):
|
|||||||
await async_barrier()
|
await async_barrier()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_periodic_advertising():
|
||||||
|
device = TwoDevices()[0]
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
# Start advertising
|
||||||
|
advertising_set = await device.create_advertising_set(
|
||||||
|
advertising_parameters=AdvertisingParameters(
|
||||||
|
advertising_event_properties=AdvertisingEventProperties(
|
||||||
|
is_connectable=False
|
||||||
|
)
|
||||||
|
),
|
||||||
|
advertising_data=b'123',
|
||||||
|
periodic_advertising_parameters=PeriodicAdvertisingParameters(),
|
||||||
|
periodic_advertising_data=b'abc',
|
||||||
|
)
|
||||||
|
assert device.extended_advertising_sets
|
||||||
|
assert advertising_set.enabled
|
||||||
|
assert not advertising_set.periodic_enabled
|
||||||
|
|
||||||
|
await advertising_set.start_periodic()
|
||||||
|
assert advertising_set.periodic_enabled
|
||||||
|
|
||||||
|
await advertising_set.stop_periodic()
|
||||||
|
assert not advertising_set.periodic_enabled
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_remote_le_features():
|
async def test_get_remote_le_features():
|
||||||
|
|||||||
+84
-43
@@ -15,11 +15,13 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
import pytest
|
import pytest
|
||||||
|
from typing_extensions import Self
|
||||||
from unittest.mock import AsyncMock, Mock, ANY
|
from unittest.mock import AsyncMock, Mock, ANY
|
||||||
|
|
||||||
from bumble.controller import Controller
|
from bumble.controller import Controller
|
||||||
@@ -31,6 +33,7 @@ from bumble.gatt import (
|
|||||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
||||||
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||||
CharacteristicAdapter,
|
CharacteristicAdapter,
|
||||||
|
SerializableCharacteristicAdapter,
|
||||||
DelegatedCharacteristicAdapter,
|
DelegatedCharacteristicAdapter,
|
||||||
PackedCharacteristicAdapter,
|
PackedCharacteristicAdapter,
|
||||||
MappedCharacteristicAdapter,
|
MappedCharacteristicAdapter,
|
||||||
@@ -57,7 +60,7 @@ from .test_utils import async_barrier
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def basic_check(x):
|
def basic_check(x):
|
||||||
pdu = x.to_bytes()
|
pdu = bytes(x)
|
||||||
parsed = ATT_PDU.from_bytes(pdu)
|
parsed = ATT_PDU.from_bytes(pdu)
|
||||||
x_str = str(x)
|
x_str = str(x)
|
||||||
parsed_str = str(parsed)
|
parsed_str = str(parsed)
|
||||||
@@ -74,7 +77,7 @@ def test_UUID():
|
|||||||
assert str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
assert str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
||||||
v = UUID(str(u))
|
v = UUID(str(u))
|
||||||
assert str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
assert str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
||||||
w = UUID.from_bytes(v.to_bytes())
|
w = UUID.from_bytes(bytes(v))
|
||||||
assert str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
assert str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
||||||
|
|
||||||
u1 = UUID.from_16_bits(0x1234)
|
u1 = UUID.from_16_bits(0x1234)
|
||||||
@@ -310,7 +313,7 @@ async def test_attribute_getters():
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_CharacteristicAdapter():
|
async def test_CharacteristicAdapter() -> None:
|
||||||
# Check that the CharacteristicAdapter base class is transparent
|
# Check that the CharacteristicAdapter base class is transparent
|
||||||
v = bytes([1, 2, 3])
|
v = bytes([1, 2, 3])
|
||||||
c = Characteristic(
|
c = Characteristic(
|
||||||
@@ -329,67 +332,94 @@ async def test_CharacteristicAdapter():
|
|||||||
assert c.value == v
|
assert c.value == v
|
||||||
|
|
||||||
# Simple delegated adapter
|
# Simple delegated adapter
|
||||||
a = DelegatedCharacteristicAdapter(
|
delegated = DelegatedCharacteristicAdapter(
|
||||||
c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x))
|
c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x))
|
||||||
)
|
)
|
||||||
|
|
||||||
value = await a.read_value(None)
|
delegated_value = await delegated.read_value(None)
|
||||||
assert value == bytes(reversed(v))
|
assert delegated_value == bytes(reversed(v))
|
||||||
|
|
||||||
v = bytes([3, 4, 5])
|
delegated_value2 = bytes([3, 4, 5])
|
||||||
await a.write_value(None, v)
|
await delegated.write_value(None, delegated_value2)
|
||||||
assert a.value == bytes(reversed(v))
|
assert delegated.value == bytes(reversed(delegated_value2))
|
||||||
|
|
||||||
# Packed adapter with single element format
|
# Packed adapter with single element format
|
||||||
v = 1234
|
packed_value_ref = 1234
|
||||||
pv = struct.pack('>H', v)
|
packed_value_bytes = struct.pack('>H', packed_value_ref)
|
||||||
c.value = v
|
c.value = packed_value_ref
|
||||||
a = PackedCharacteristicAdapter(c, '>H')
|
packed = PackedCharacteristicAdapter(c, '>H')
|
||||||
|
|
||||||
value = await a.read_value(None)
|
packed_value_read = await packed.read_value(None)
|
||||||
assert value == pv
|
assert packed_value_read == packed_value_bytes
|
||||||
c.value = None
|
c.value = b''
|
||||||
await a.write_value(None, pv)
|
await packed.write_value(None, packed_value_bytes)
|
||||||
assert a.value == v
|
assert packed.value == packed_value_ref
|
||||||
|
|
||||||
# Packed adapter with multi-element format
|
# Packed adapter with multi-element format
|
||||||
v1 = 1234
|
v1 = 1234
|
||||||
v2 = 5678
|
v2 = 5678
|
||||||
pv = struct.pack('>HH', v1, v2)
|
packed_multi_value_bytes = struct.pack('>HH', v1, v2)
|
||||||
c.value = (v1, v2)
|
c.value = (v1, v2)
|
||||||
a = PackedCharacteristicAdapter(c, '>HH')
|
packed_multi = PackedCharacteristicAdapter(c, '>HH')
|
||||||
|
|
||||||
value = await a.read_value(None)
|
packed_multi_read_value = await packed_multi.read_value(None)
|
||||||
assert value == pv
|
assert packed_multi_read_value == packed_multi_value_bytes
|
||||||
c.value = None
|
packed_multi.value = b''
|
||||||
await a.write_value(None, pv)
|
await packed_multi.write_value(None, packed_multi_value_bytes)
|
||||||
assert a.value == (v1, v2)
|
assert packed_multi.value == (v1, v2)
|
||||||
|
|
||||||
# Mapped adapter
|
# Mapped adapter
|
||||||
v1 = 1234
|
v1 = 1234
|
||||||
v2 = 5678
|
v2 = 5678
|
||||||
pv = struct.pack('>HH', v1, v2)
|
packed_mapped_value_bytes = struct.pack('>HH', v1, v2)
|
||||||
mapped = {'v1': v1, 'v2': v2}
|
mapped = {'v1': v1, 'v2': v2}
|
||||||
c.value = mapped
|
c.value = mapped
|
||||||
a = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2'))
|
packed_mapped = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2'))
|
||||||
|
|
||||||
value = await a.read_value(None)
|
packed_mapped_read_value = await packed_mapped.read_value(None)
|
||||||
assert value == pv
|
assert packed_mapped_read_value == packed_mapped_value_bytes
|
||||||
c.value = None
|
c.value = b''
|
||||||
await a.write_value(None, pv)
|
await packed_mapped.write_value(None, packed_mapped_value_bytes)
|
||||||
assert a.value == mapped
|
assert packed_mapped.value == mapped
|
||||||
|
|
||||||
# UTF-8 adapter
|
# UTF-8 adapter
|
||||||
v = 'Hello π'
|
string_value = 'Hello π'
|
||||||
ev = v.encode('utf-8')
|
string_value_bytes = string_value.encode('utf-8')
|
||||||
c.value = v
|
c.value = string_value
|
||||||
a = UTF8CharacteristicAdapter(c)
|
string_c = UTF8CharacteristicAdapter(c)
|
||||||
|
|
||||||
value = await a.read_value(None)
|
string_read_value = await string_c.read_value(None)
|
||||||
assert value == ev
|
assert string_read_value == string_value_bytes
|
||||||
c.value = None
|
c.value = b''
|
||||||
await a.write_value(None, ev)
|
await string_c.write_value(None, string_value_bytes)
|
||||||
assert a.value == v
|
assert string_c.value == string_value
|
||||||
|
|
||||||
|
# Class adapter
|
||||||
|
class BlaBla:
|
||||||
|
def __init__(self, a: int, b: int) -> None:
|
||||||
|
self.a = a
|
||||||
|
self.b = b
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> Self:
|
||||||
|
a, b = struct.unpack(">II", data)
|
||||||
|
return cls(a, b)
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return struct.pack(">II", self.a, self.b)
|
||||||
|
|
||||||
|
class_value = BlaBla(3, 4)
|
||||||
|
class_value_bytes = struct.pack(">II", 3, 4)
|
||||||
|
c.value = class_value
|
||||||
|
class_c = SerializableCharacteristicAdapter(c, BlaBla)
|
||||||
|
|
||||||
|
class_read_value = await class_c.read_value(None)
|
||||||
|
assert class_read_value == class_value_bytes
|
||||||
|
c.value = b''
|
||||||
|
await class_c.write_value(None, class_value_bytes)
|
||||||
|
assert isinstance(c.value, BlaBla)
|
||||||
|
assert c.value.a == 3
|
||||||
|
assert c.value.b == 4
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -851,7 +881,12 @@ async def test_unsubscribe():
|
|||||||
await async_barrier()
|
await async_barrier()
|
||||||
mock1.assert_called_once_with(ANY, True, False)
|
mock1.assert_called_once_with(ANY, True, False)
|
||||||
|
|
||||||
await c2.subscribe()
|
assert len(server.gatt_server.subscribers) == 1
|
||||||
|
|
||||||
|
def callback(_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
await c2.subscribe(callback)
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
mock2.assert_called_once_with(ANY, True, False)
|
mock2.assert_called_once_with(ANY, True, False)
|
||||||
|
|
||||||
@@ -861,10 +896,16 @@ async def test_unsubscribe():
|
|||||||
mock1.assert_called_once_with(ANY, False, False)
|
mock1.assert_called_once_with(ANY, False, False)
|
||||||
|
|
||||||
mock2.reset_mock()
|
mock2.reset_mock()
|
||||||
await c2.unsubscribe()
|
await c2.unsubscribe(callback)
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
mock2.assert_called_once_with(ANY, False, False)
|
mock2.assert_called_once_with(ANY, False, False)
|
||||||
|
|
||||||
|
# All CCCDs should be zeros now
|
||||||
|
assert list(server.gatt_server.subscribers.values())[0] == {
|
||||||
|
c1.handle: bytes([0, 0]),
|
||||||
|
c2.handle: bytes([0, 0]),
|
||||||
|
}
|
||||||
|
|
||||||
mock1.reset_mock()
|
mock1.reset_mock()
|
||||||
await c1.unsubscribe()
|
await c1.unsubscribe()
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from bumble import device
|
||||||
|
from bumble.profiles.gmap import (
|
||||||
|
GamingAudioService,
|
||||||
|
GamingAudioServiceProxy,
|
||||||
|
GmapRole,
|
||||||
|
UggFeatures,
|
||||||
|
UgtFeatures,
|
||||||
|
BgrFeatures,
|
||||||
|
BgsFeatures,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .test_utils import TwoDevices
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
gmas_service = GamingAudioService(
|
||||||
|
gmap_role=GmapRole.UNICAST_GAME_GATEWAY
|
||||||
|
| GmapRole.UNICAST_GAME_TERMINAL
|
||||||
|
| GmapRole.BROADCAST_GAME_RECEIVER
|
||||||
|
| GmapRole.BROADCAST_GAME_SENDER,
|
||||||
|
ugg_features=UggFeatures.UGG_MULTISINK,
|
||||||
|
ugt_features=UgtFeatures.UGT_SOURCE,
|
||||||
|
bgr_features=BgrFeatures.BGR_MULTISINK,
|
||||||
|
bgs_features=BgsFeatures.BGS_96_KBPS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def gmap_client():
|
||||||
|
devices = TwoDevices()
|
||||||
|
devices[0].add_service(gmas_service)
|
||||||
|
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
assert devices.connections[0]
|
||||||
|
assert devices.connections[1]
|
||||||
|
|
||||||
|
devices.connections[0].encryption = 1
|
||||||
|
devices.connections[1].encryption = 1
|
||||||
|
|
||||||
|
peer = device.Peer(devices.connections[1])
|
||||||
|
|
||||||
|
gmap_client = await peer.discover_service_and_create_proxy(GamingAudioServiceProxy)
|
||||||
|
|
||||||
|
assert gmap_client
|
||||||
|
yield gmap_client
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_init_service(gmap_client: GamingAudioServiceProxy):
|
||||||
|
assert (
|
||||||
|
await gmap_client.gmap_role.read_value()
|
||||||
|
== GmapRole.UNICAST_GAME_GATEWAY
|
||||||
|
| GmapRole.UNICAST_GAME_TERMINAL
|
||||||
|
| GmapRole.BROADCAST_GAME_RECEIVER
|
||||||
|
| GmapRole.BROADCAST_GAME_SENDER
|
||||||
|
)
|
||||||
|
assert await gmap_client.ugg_features.read_value() == UggFeatures.UGG_MULTISINK
|
||||||
|
assert await gmap_client.ugt_features.read_value() == UgtFeatures.UGT_SOURCE
|
||||||
|
assert await gmap_client.bgr_features.read_value() == BgrFeatures.BGR_MULTISINK
|
||||||
|
assert await gmap_client.bgs_features.read_value() == BgsFeatures.BGS_96_KBPS
|
||||||
+45
-6
@@ -15,6 +15,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
import struct
|
||||||
|
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
HCI_DISCONNECT_COMMAND,
|
HCI_DISCONNECT_COMMAND,
|
||||||
@@ -22,6 +23,7 @@ from bumble.hci import (
|
|||||||
HCI_LE_CODED_PHY_BIT,
|
HCI_LE_CODED_PHY_BIT,
|
||||||
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||||
HCI_RESET_COMMAND,
|
HCI_RESET_COMMAND,
|
||||||
|
HCI_VENDOR_EVENT,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_LE_CONNECTION_COMPLETE_EVENT,
|
HCI_LE_CONNECTION_COMPLETE_EVENT,
|
||||||
HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT,
|
HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT,
|
||||||
@@ -67,6 +69,7 @@ from bumble.hci import (
|
|||||||
HCI_Read_Local_Version_Information_Command,
|
HCI_Read_Local_Version_Information_Command,
|
||||||
HCI_Reset_Command,
|
HCI_Reset_Command,
|
||||||
HCI_Set_Event_Mask_Command,
|
HCI_Set_Event_Mask_Command,
|
||||||
|
HCI_Vendor_Event,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,13 +78,13 @@ from bumble.hci import (
|
|||||||
|
|
||||||
|
|
||||||
def basic_check(x):
|
def basic_check(x):
|
||||||
packet = x.to_bytes()
|
packet = bytes(x)
|
||||||
print(packet.hex())
|
print(packet.hex())
|
||||||
parsed = HCI_Packet.from_bytes(packet)
|
parsed = HCI_Packet.from_bytes(packet)
|
||||||
x_str = str(x)
|
x_str = str(x)
|
||||||
parsed_str = str(parsed)
|
parsed_str = str(parsed)
|
||||||
print(x_str)
|
print(x_str)
|
||||||
parsed_bytes = parsed.to_bytes()
|
parsed_bytes = bytes(parsed)
|
||||||
assert x_str == parsed_str
|
assert x_str == parsed_str
|
||||||
assert packet == parsed_bytes
|
assert packet == parsed_bytes
|
||||||
|
|
||||||
@@ -167,8 +170,8 @@ def test_HCI_Command_Complete_Event():
|
|||||||
command_opcode=HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
command_opcode=HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||||
return_parameters=HCI_LE_Read_Buffer_Size_Command.create_return_parameters(
|
return_parameters=HCI_LE_Read_Buffer_Size_Command.create_return_parameters(
|
||||||
status=0,
|
status=0,
|
||||||
hc_le_acl_data_packet_length=1234,
|
le_acl_data_packet_length=1234,
|
||||||
hc_total_num_le_acl_data_packets=56,
|
total_num_le_acl_data_packets=56,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
basic_check(event)
|
basic_check(event)
|
||||||
@@ -188,7 +191,7 @@ def test_HCI_Command_Complete_Event():
|
|||||||
return_parameters=bytes([7]),
|
return_parameters=bytes([7]),
|
||||||
)
|
)
|
||||||
basic_check(event)
|
basic_check(event)
|
||||||
event = HCI_Packet.from_bytes(event.to_bytes())
|
event = HCI_Packet.from_bytes(bytes(event))
|
||||||
assert event.return_parameters == 7
|
assert event.return_parameters == 7
|
||||||
|
|
||||||
# With a simple status as an integer status
|
# With a simple status as an integer status
|
||||||
@@ -213,6 +216,41 @@ def test_HCI_Number_Of_Completed_Packets_Event():
|
|||||||
basic_check(event)
|
basic_check(event)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_HCI_Vendor_Event():
|
||||||
|
data = bytes.fromhex('01020304')
|
||||||
|
event = HCI_Vendor_Event(data=data)
|
||||||
|
event_bytes = bytes(event)
|
||||||
|
parsed = HCI_Packet.from_bytes(event_bytes)
|
||||||
|
assert isinstance(parsed, HCI_Vendor_Event)
|
||||||
|
assert parsed.data == data
|
||||||
|
|
||||||
|
class HCI_Custom_Event(HCI_Event):
|
||||||
|
def __init__(self, blabla):
|
||||||
|
super().__init__(HCI_VENDOR_EVENT, parameters=struct.pack("<I", blabla))
|
||||||
|
self.name = 'HCI_CUSTOM_EVENT'
|
||||||
|
self.blabla = blabla
|
||||||
|
|
||||||
|
def create_event(payload):
|
||||||
|
if payload[0] == 1:
|
||||||
|
return HCI_Custom_Event(blabla=struct.unpack('<I', payload)[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
HCI_Event.add_vendor_factory(create_event)
|
||||||
|
parsed = HCI_Packet.from_bytes(event_bytes)
|
||||||
|
assert isinstance(parsed, HCI_Custom_Event)
|
||||||
|
assert parsed.blabla == 0x04030201
|
||||||
|
event_bytes2 = event_bytes[:3] + bytes([7]) + event_bytes[4:]
|
||||||
|
parsed = HCI_Packet.from_bytes(event_bytes2)
|
||||||
|
assert not isinstance(parsed, HCI_Custom_Event)
|
||||||
|
assert isinstance(parsed, HCI_Vendor_Event)
|
||||||
|
HCI_Event.remove_vendor_factory(create_event)
|
||||||
|
|
||||||
|
parsed = HCI_Packet.from_bytes(event_bytes)
|
||||||
|
assert not isinstance(parsed, HCI_Custom_Event)
|
||||||
|
assert isinstance(parsed, HCI_Vendor_Event)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def test_HCI_Command():
|
def test_HCI_Command():
|
||||||
command = HCI_Command(0x5566)
|
command = HCI_Command(0x5566)
|
||||||
@@ -562,7 +600,7 @@ def test_iso_data_packet():
|
|||||||
'6281bc77ed6a3206d984bcdabee6be831c699cb50e2'
|
'6281bc77ed6a3206d984bcdabee6be831c699cb50e2'
|
||||||
)
|
)
|
||||||
|
|
||||||
assert packet.to_bytes() == data
|
assert bytes(packet) == data
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -576,6 +614,7 @@ def run_test_events():
|
|||||||
test_HCI_Command_Complete_Event()
|
test_HCI_Command_Complete_Event()
|
||||||
test_HCI_Command_Status_Event()
|
test_HCI_Command_Status_Event()
|
||||||
test_HCI_Number_Of_Completed_Packets_Event()
|
test_HCI_Number_Of_Completed_Packets_Event()
|
||||||
|
test_HCI_Vendor_Event()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
+2
-2
@@ -61,7 +61,7 @@ def _default_hf_configuration() -> hfp.HfConfiguration:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def _default_hf_sdp_features() -> hfp.HfSdpFeature:
|
def _default_hf_sdp_features() -> hfp.HfSdpFeature:
|
||||||
return (
|
return (
|
||||||
hfp.HfSdpFeature.WIDE_BAND
|
hfp.HfSdpFeature.WIDE_BAND_SPEECH
|
||||||
| hfp.HfSdpFeature.THREE_WAY_CALLING
|
| hfp.HfSdpFeature.THREE_WAY_CALLING
|
||||||
| hfp.HfSdpFeature.CLI_PRESENTATION_CAPABILITY
|
| hfp.HfSdpFeature.CLI_PRESENTATION_CAPABILITY
|
||||||
)
|
)
|
||||||
@@ -108,7 +108,7 @@ def _default_ag_configuration() -> hfp.AgConfiguration:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def _default_ag_sdp_features() -> hfp.AgSdpFeature:
|
def _default_ag_sdp_features() -> hfp.AgSdpFeature:
|
||||||
return (
|
return (
|
||||||
hfp.AgSdpFeature.WIDE_BAND
|
hfp.AgSdpFeature.WIDE_BAND_SPEECH
|
||||||
| hfp.AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
|
| hfp.AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
|
||||||
| hfp.AgSdpFeature.THREE_WAY_CALLING
|
| hfp.AgSdpFeature.THREE_WAY_CALLING
|
||||||
)
|
)
|
||||||
|
|||||||
+91
-1
@@ -16,11 +16,14 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
|
import unittest.mock
|
||||||
import pytest
|
import pytest
|
||||||
|
import unittest
|
||||||
|
|
||||||
from bumble.controller import Controller
|
from bumble.controller import Controller
|
||||||
from bumble.host import Host
|
from bumble.host import Host, DataPacketQueue
|
||||||
from bumble.transport import AsyncPipeSink
|
from bumble.transport import AsyncPipeSink
|
||||||
|
from bumble.hci import HCI_AclDataPacket
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -60,3 +63,90 @@ async def test_reset(supported_commands: str, lmp_features: str):
|
|||||||
assert host.local_lmp_features == int.from_bytes(
|
assert host.local_lmp_features == int.from_bytes(
|
||||||
bytes.fromhex(lmp_features), 'little'
|
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
|
||||||
|
|||||||
+161
-31
@@ -20,12 +20,11 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from bumble.core import UUID, BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID
|
from bumble.core import UUID, BT_L2CAP_PROTOCOL_ID
|
||||||
from bumble.sdp import (
|
from bumble.sdp import (
|
||||||
DataElement,
|
DataElement,
|
||||||
ServiceAttribute,
|
ServiceAttribute,
|
||||||
Client,
|
Client,
|
||||||
Server,
|
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
SDP_PUBLIC_BROWSE_ROOT,
|
SDP_PUBLIC_BROWSE_ROOT,
|
||||||
@@ -174,9 +173,10 @@ def test_data_elements() -> None:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def sdp_records():
|
def sdp_records(record_count=1):
|
||||||
return {
|
return {
|
||||||
0x00010001: [
|
0x00010001
|
||||||
|
+ i: [
|
||||||
ServiceAttribute(
|
ServiceAttribute(
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
DataElement.unsigned_integer_32(0x00010001),
|
DataElement.unsigned_integer_32(0x00010001),
|
||||||
@@ -200,6 +200,7 @@ def sdp_records():
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
for i in range(record_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -216,19 +217,55 @@ async def test_service_search():
|
|||||||
devices.devices[0].sdp_server.service_records.update(sdp_records())
|
devices.devices[0].sdp_server.service_records.update(sdp_records())
|
||||||
|
|
||||||
# Search for service
|
# Search for service
|
||||||
client = Client(devices.connections[1])
|
async with Client(devices.connections[1]) as client:
|
||||||
await client.connect()
|
services = await client.search_services(
|
||||||
services = await client.search_services(
|
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AF')]
|
||||||
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
|
)
|
||||||
)
|
assert len(services) == 0
|
||||||
|
|
||||||
# Then
|
services = await client.search_services(
|
||||||
assert services[0] == 0x00010001
|
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
|
||||||
|
)
|
||||||
|
assert len(services) == 1
|
||||||
|
assert services[0] == 0x00010001
|
||||||
|
|
||||||
|
services = await client.search_services(
|
||||||
|
[BT_L2CAP_PROTOCOL_ID, SDP_PUBLIC_BROWSE_ROOT]
|
||||||
|
)
|
||||||
|
assert len(services) == 1
|
||||||
|
assert services[0] == 0x00010001
|
||||||
|
|
||||||
|
services = await client.search_services(
|
||||||
|
[BT_L2CAP_PROTOCOL_ID, SDP_PUBLIC_BROWSE_ROOT]
|
||||||
|
)
|
||||||
|
assert len(services) == 1
|
||||||
|
assert services[0] == 0x00010001
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_service_attribute():
|
async def test_service_search_with_continuation():
|
||||||
|
# Setup connections
|
||||||
|
devices = TwoDevices()
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
# Register SDP service
|
||||||
|
records = sdp_records(100)
|
||||||
|
devices.devices[0].sdp_server.service_records.update(records)
|
||||||
|
|
||||||
|
# Search for service
|
||||||
|
async with Client(devices.connections[1], mtu=48) as client:
|
||||||
|
services = await client.search_services(
|
||||||
|
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
|
||||||
|
)
|
||||||
|
assert len(services) == len(records)
|
||||||
|
for i in range(len(records)):
|
||||||
|
assert services[i] == 0x00010001 + i
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_attributes():
|
||||||
# Setup connections
|
# Setup connections
|
||||||
devices = TwoDevices()
|
devices = TwoDevices()
|
||||||
await devices.setup_connection()
|
await devices.setup_connection()
|
||||||
@@ -236,15 +273,43 @@ async def test_service_attribute():
|
|||||||
# Register SDP service
|
# Register SDP service
|
||||||
devices.devices[0].sdp_server.service_records.update(sdp_records())
|
devices.devices[0].sdp_server.service_records.update(sdp_records())
|
||||||
|
|
||||||
# Search for service
|
# Get attributes
|
||||||
client = Client(devices.connections[1])
|
async with Client(devices.connections[1]) as client:
|
||||||
await client.connect()
|
attributes = await client.get_attributes(0x00010001, [1234])
|
||||||
attributes = await client.get_attributes(
|
assert len(attributes) == 0
|
||||||
0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Then
|
attributes = await client.get_attributes(
|
||||||
assert attributes[0].value.value == sdp_records()[0x00010001][0].value.value
|
0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
|
||||||
|
)
|
||||||
|
assert len(attributes) == 1
|
||||||
|
assert attributes[0].value.value == sdp_records()[0x00010001][0].value.value
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_attributes_with_continuation():
|
||||||
|
# Setup connections
|
||||||
|
devices = TwoDevices()
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
# Register SDP service
|
||||||
|
records = {
|
||||||
|
0x00010001: [
|
||||||
|
ServiceAttribute(
|
||||||
|
x,
|
||||||
|
DataElement.unsigned_integer_32(0x00010001),
|
||||||
|
)
|
||||||
|
for x in range(100)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
devices.devices[0].sdp_server.service_records.update(records)
|
||||||
|
|
||||||
|
# Get attributes
|
||||||
|
async with Client(devices.connections[1], mtu=48) as client:
|
||||||
|
attributes = await client.get_attributes(0x00010001, list(range(100)))
|
||||||
|
assert len(attributes) == 100
|
||||||
|
for i, attribute in enumerate(attributes):
|
||||||
|
assert attribute.id == i
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -255,19 +320,81 @@ async def test_service_search_attribute():
|
|||||||
await devices.setup_connection()
|
await devices.setup_connection()
|
||||||
|
|
||||||
# Register SDP service
|
# Register SDP service
|
||||||
devices.devices[0].sdp_server.service_records.update(sdp_records())
|
records = {
|
||||||
|
0x00010001: [
|
||||||
|
ServiceAttribute(
|
||||||
|
4,
|
||||||
|
DataElement.sequence(
|
||||||
|
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
3,
|
||||||
|
DataElement.sequence(
|
||||||
|
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ServiceAttribute(
|
||||||
|
1,
|
||||||
|
DataElement.sequence(
|
||||||
|
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.devices[0].sdp_server.service_records.update(records)
|
||||||
|
|
||||||
# Search for service
|
# Search for service
|
||||||
client = Client(devices.connections[1])
|
async with Client(devices.connections[1]) as client:
|
||||||
await client.connect()
|
attributes = await client.search_attributes(
|
||||||
attributes = await client.search_attributes(
|
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0, 0xFFFF)]
|
||||||
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0x0000FFFF, 8)]
|
)
|
||||||
)
|
assert len(attributes) == 1
|
||||||
|
assert len(attributes[0]) == 3
|
||||||
|
assert attributes[0][0].id == 1
|
||||||
|
assert attributes[0][1].id == 3
|
||||||
|
assert attributes[0][2].id == 4
|
||||||
|
|
||||||
# Then
|
attributes = await client.search_attributes(
|
||||||
for expect, actual in zip(attributes, sdp_records().values()):
|
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [1, 2, 3]
|
||||||
assert expect.id == actual.id
|
)
|
||||||
assert expect.value == actual.value
|
assert len(attributes) == 1
|
||||||
|
assert len(attributes[0]) == 2
|
||||||
|
assert attributes[0][0].id == 1
|
||||||
|
assert attributes[0][1].id == 3
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_search_attribute_with_continuation():
|
||||||
|
# Setup connections
|
||||||
|
devices = TwoDevices()
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
# Register SDP service
|
||||||
|
records = {
|
||||||
|
0x00010001: [
|
||||||
|
ServiceAttribute(
|
||||||
|
x,
|
||||||
|
DataElement.sequence(
|
||||||
|
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for x in range(100)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
devices.devices[0].sdp_server.service_records.update(records)
|
||||||
|
|
||||||
|
# Search for service
|
||||||
|
async with Client(devices.connections[1], mtu=48) as client:
|
||||||
|
attributes = await client.search_attributes(
|
||||||
|
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0, 0xFFFF)]
|
||||||
|
)
|
||||||
|
assert len(attributes) == 1
|
||||||
|
assert len(attributes[0]) == 100
|
||||||
|
for i in range(100):
|
||||||
|
assert attributes[0][i].id == i
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -287,9 +414,12 @@ async def test_client_async_context():
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def run():
|
async def run():
|
||||||
test_data_elements()
|
test_data_elements()
|
||||||
await test_service_attribute()
|
await test_service_attributes()
|
||||||
|
await test_service_attributes_with_continuation()
|
||||||
await test_service_search()
|
await test_service_search()
|
||||||
|
await test_service_search_with_continuation()
|
||||||
await test_service_search_attribute()
|
await test_service_search_attribute()
|
||||||
|
await test_service_search_attribute_with_continuation()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
+1
-1
@@ -240,7 +240,7 @@ async def test_self_gatt():
|
|||||||
result = await peer.discover_included_services(result[0])
|
result = await peer.discover_included_services(result[0])
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
# Service UUID is only present when the UUID is 16-bit Bluetooth UUID
|
# Service UUID is only present when the UUID is 16-bit Bluetooth UUID
|
||||||
assert result[1].uuid.to_bytes() == s3.uuid.to_bytes()
|
assert bytes(result[1].uuid) == bytes(s3.uuid)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from bumble import device
|
||||||
|
|
||||||
|
from bumble.att import ATT_Error
|
||||||
|
|
||||||
|
from bumble.profiles.vocs import (
|
||||||
|
VolumeOffsetControlService,
|
||||||
|
ErrorCode,
|
||||||
|
MIN_VOLUME_OFFSET,
|
||||||
|
MAX_VOLUME_OFFSET,
|
||||||
|
SetVolumeOffsetOpCode,
|
||||||
|
VolumeOffsetControlServiceProxy,
|
||||||
|
VolumeOffsetState,
|
||||||
|
VocsAudioLocation,
|
||||||
|
)
|
||||||
|
from bumble.profiles.vcp import VolumeControlService, VolumeControlServiceProxy
|
||||||
|
from bumble.profiles.bap import AudioLocation
|
||||||
|
|
||||||
|
from .test_utils import TwoDevices
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
vocs_service = VolumeOffsetControlService()
|
||||||
|
vcp_service = VolumeControlService(included_services=[vocs_service])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def vocs_client():
|
||||||
|
devices = TwoDevices()
|
||||||
|
devices[0].add_service(vcp_service)
|
||||||
|
|
||||||
|
await devices.setup_connection()
|
||||||
|
|
||||||
|
assert devices.connections[0]
|
||||||
|
assert devices.connections[1]
|
||||||
|
|
||||||
|
devices.connections[0].encryption = 1
|
||||||
|
devices.connections[1].encryption = 1
|
||||||
|
|
||||||
|
peer = device.Peer(devices.connections[1])
|
||||||
|
|
||||||
|
vcp_client = await peer.discover_service_and_create_proxy(VolumeControlServiceProxy)
|
||||||
|
|
||||||
|
assert vcp_client
|
||||||
|
included_services = await peer.discover_included_services(vcp_client.service_proxy)
|
||||||
|
assert included_services
|
||||||
|
vocs_service_discovered = included_services[0]
|
||||||
|
await peer.discover_characteristics(service=vocs_service_discovered)
|
||||||
|
vocs_client = VolumeOffsetControlServiceProxy(vocs_service_discovered)
|
||||||
|
|
||||||
|
yield vocs_client
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_init_service(vocs_client: VolumeOffsetControlServiceProxy):
|
||||||
|
assert await vocs_client.volume_offset_state.read_value() == VolumeOffsetState(
|
||||||
|
volume_offset=0,
|
||||||
|
change_counter=0,
|
||||||
|
)
|
||||||
|
assert await vocs_client.audio_location.read_value() == VocsAudioLocation(
|
||||||
|
audio_location=AudioLocation.NOT_ALLOWED
|
||||||
|
)
|
||||||
|
description = await vocs_client.audio_output_description.read_value()
|
||||||
|
assert description == ''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrong_opcode_raise_error(vocs_client: VolumeOffsetControlServiceProxy):
|
||||||
|
with pytest.raises(ATT_Error) as e:
|
||||||
|
await vocs_client.volume_offset_control_point.write_value(
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
0xFF,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
with_response=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert e.value.error_code == ErrorCode.OPCODE_NOT_SUPPORTED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrong_change_counter_raise_error(
|
||||||
|
vocs_client: VolumeOffsetControlServiceProxy,
|
||||||
|
):
|
||||||
|
initial_offset = vocs_service.volume_offset_state.volume_offset
|
||||||
|
initial_counter = vocs_service.volume_offset_state.change_counter
|
||||||
|
wrong_counter = initial_counter + 1
|
||||||
|
|
||||||
|
with pytest.raises(ATT_Error) as e:
|
||||||
|
await vocs_client.volume_offset_control_point.write_value(
|
||||||
|
struct.pack(
|
||||||
|
'<BBh', SetVolumeOffsetOpCode.SET_VOLUME_OFFSET, wrong_counter, 0
|
||||||
|
),
|
||||||
|
with_response=True,
|
||||||
|
)
|
||||||
|
assert e.value.error_code == ErrorCode.INVALID_CHANGE_COUNTER
|
||||||
|
|
||||||
|
counter = await vocs_client.volume_offset_state.read_value()
|
||||||
|
assert counter == VolumeOffsetState(initial_offset, initial_counter)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrong_volume_offset_raise_error(
|
||||||
|
vocs_client: VolumeOffsetControlServiceProxy,
|
||||||
|
):
|
||||||
|
invalid_offset_low = MIN_VOLUME_OFFSET - 1
|
||||||
|
invalid_offset_high = MAX_VOLUME_OFFSET + 1
|
||||||
|
|
||||||
|
with pytest.raises(ATT_Error) as e_low:
|
||||||
|
await vocs_client.volume_offset_control_point.write_value(
|
||||||
|
struct.pack(
|
||||||
|
'<BBh', SetVolumeOffsetOpCode.SET_VOLUME_OFFSET, 0, invalid_offset_low
|
||||||
|
),
|
||||||
|
with_response=True,
|
||||||
|
)
|
||||||
|
assert e_low.value.error_code == ErrorCode.VALUE_OUT_OF_RANGE
|
||||||
|
|
||||||
|
with pytest.raises(ATT_Error) as e_high:
|
||||||
|
await vocs_client.volume_offset_control_point.write_value(
|
||||||
|
struct.pack(
|
||||||
|
'<BBh', SetVolumeOffsetOpCode.SET_VOLUME_OFFSET, 0, invalid_offset_high
|
||||||
|
),
|
||||||
|
with_response=True,
|
||||||
|
)
|
||||||
|
assert e_high.value.error_code == ErrorCode.VALUE_OUT_OF_RANGE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_volume_offset(vocs_client: VolumeOffsetControlServiceProxy):
|
||||||
|
await vocs_client.volume_offset_control_point.write_value(
|
||||||
|
struct.pack('<BBh', SetVolumeOffsetOpCode.SET_VOLUME_OFFSET, 0, -255),
|
||||||
|
)
|
||||||
|
assert await vocs_client.volume_offset_state.read_value() == VolumeOffsetState(
|
||||||
|
-255, 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_audio_channel_location(vocs_client: VolumeOffsetControlServiceProxy):
|
||||||
|
new_audio_location = VocsAudioLocation(audio_location=AudioLocation.FRONT_LEFT)
|
||||||
|
|
||||||
|
await vocs_client.audio_location.write_value(
|
||||||
|
struct.pack('<I', new_audio_location.audio_location)
|
||||||
|
)
|
||||||
|
|
||||||
|
location = await vocs_client.audio_location.read_value()
|
||||||
|
assert location == new_audio_location
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_audio_output_description(
|
||||||
|
vocs_client: VolumeOffsetControlServiceProxy,
|
||||||
|
):
|
||||||
|
new_description = 'Left Speaker'
|
||||||
|
|
||||||
|
await vocs_client.audio_output_description.write_value(new_description)
|
||||||
|
|
||||||
|
description = await vocs_client.audio_output_description.read_value()
|
||||||
|
assert description == new_description
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble.drivers import intel
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
LINUX_KERNEL_GIT_SOURCE = "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/intel"
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Functions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def download_file(base_url, name):
|
||||||
|
url = f"{base_url}/{name}"
|
||||||
|
with urllib.request.urlopen(url) as file:
|
||||||
|
data = file.read()
|
||||||
|
print(f"Downloaded {name}: {len(data)} bytes")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@click.command
|
||||||
|
@click.option(
|
||||||
|
"--output-dir",
|
||||||
|
default="",
|
||||||
|
help="Output directory where the files will be saved. Defaults to the OS-specific"
|
||||||
|
"app data dir, which the driver will check when trying to find firmware",
|
||||||
|
show_default=True,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--source",
|
||||||
|
type=click.Choice(["linux-kernel"]),
|
||||||
|
default="linux-kernel",
|
||||||
|
show_default=True,
|
||||||
|
)
|
||||||
|
@click.option("--single", help="Only download a single image set, by its base name")
|
||||||
|
@click.option("--force", is_flag=True, help="Overwrite files if they already exist")
|
||||||
|
def main(output_dir, source, single, force):
|
||||||
|
"""Download Intel firmware images and configs."""
|
||||||
|
|
||||||
|
# Check that the output dir exists
|
||||||
|
if output_dir == '':
|
||||||
|
output_dir = intel.intel_firmware_dir()
|
||||||
|
else:
|
||||||
|
output_dir = pathlib.Path(output_dir)
|
||||||
|
if not output_dir.is_dir():
|
||||||
|
print("Output dir does not exist or is not a directory")
|
||||||
|
return
|
||||||
|
|
||||||
|
base_url = {
|
||||||
|
"linux-kernel": LINUX_KERNEL_GIT_SOURCE,
|
||||||
|
}[source]
|
||||||
|
|
||||||
|
print("Downloading")
|
||||||
|
print(color("FROM:", "green"), base_url)
|
||||||
|
print(color("TO:", "green"), output_dir)
|
||||||
|
|
||||||
|
if single:
|
||||||
|
images = [(f"{single}.sfi", f"{single}.ddc")]
|
||||||
|
else:
|
||||||
|
images = [
|
||||||
|
(f"{base_name}.sfi", f"{base_name}.ddc")
|
||||||
|
for base_name in intel.INTEL_FW_IMAGE_NAMES
|
||||||
|
]
|
||||||
|
|
||||||
|
for fw_name, config_name in images:
|
||||||
|
print(color("---", "yellow"))
|
||||||
|
fw_image_out = output_dir / fw_name
|
||||||
|
if not force and fw_image_out.exists():
|
||||||
|
print(color(f"{fw_image_out} already exists, skipping", "red"))
|
||||||
|
continue
|
||||||
|
if config_name:
|
||||||
|
config_image_out = output_dir / config_name
|
||||||
|
if not force and config_image_out.exists():
|
||||||
|
print(color("f{config_image_out} already exists, skipping", "red"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
fw_image = download_file(base_url, fw_name)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
print(f"Failed to download {fw_name}: {error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
config_image = None
|
||||||
|
if config_name:
|
||||||
|
try:
|
||||||
|
config_image = download_file(base_url, config_name)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
print(f"Failed to download {config_name}: {error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
fw_image_out.write_bytes(fw_image)
|
||||||
|
if config_image:
|
||||||
|
config_image_out.write_bytes(config_image)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble import transport
|
||||||
|
from bumble.drivers import intel
|
||||||
|
from bumble.host import Host
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def print_device_info(device_info: dict[intel.ValueType, Any]) -> None:
|
||||||
|
if (mode := device_info.get(intel.ValueType.CURRENT_MODE_OF_OPERATION)) is not None:
|
||||||
|
print(
|
||||||
|
color("MODE:", "yellow"),
|
||||||
|
mode.name,
|
||||||
|
)
|
||||||
|
print(color("DETAILS:", "yellow"))
|
||||||
|
for key, value in device_info.items():
|
||||||
|
print(f" {color(key.name, 'green')}: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def get_driver(host: Host, force: bool) -> Optional[intel.Driver]:
|
||||||
|
# Create a driver
|
||||||
|
driver = await intel.Driver.for_host(host, force)
|
||||||
|
if driver is None:
|
||||||
|
print("Device does not appear to be an Intel device")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return driver
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def do_info(usb_transport, force):
|
||||||
|
async with await transport.open_transport(usb_transport) as (
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
):
|
||||||
|
host = Host(hci_source, hci_sink)
|
||||||
|
driver = await get_driver(host, force)
|
||||||
|
if driver is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get and print the device info
|
||||||
|
print_device_info(await driver.read_device_info())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def do_load(usb_transport: str, force: bool) -> None:
|
||||||
|
async with await transport.open_transport(usb_transport) as (
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
):
|
||||||
|
host = Host(hci_source, hci_sink)
|
||||||
|
driver = await get_driver(host, force)
|
||||||
|
if driver is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reboot in bootloader mode
|
||||||
|
await driver.load_firmware()
|
||||||
|
|
||||||
|
# Get and print the device info
|
||||||
|
print_device_info(await driver.read_device_info())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def do_bootloader(usb_transport: str, force: bool) -> None:
|
||||||
|
async with await transport.open_transport(usb_transport) as (
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
):
|
||||||
|
host = Host(hci_source, hci_sink)
|
||||||
|
driver = await get_driver(host, force)
|
||||||
|
if driver is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reboot in bootloader mode
|
||||||
|
await driver.reboot_bootloader()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@click.group()
|
||||||
|
def main():
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||||
|
|
||||||
|
|
||||||
|
@main.command
|
||||||
|
@click.argument("usb_transport")
|
||||||
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Try to get the device info even if the USB info doesn't match",
|
||||||
|
)
|
||||||
|
def info(usb_transport, force):
|
||||||
|
"""Get the firmware info."""
|
||||||
|
asyncio.run(do_info(usb_transport, force))
|
||||||
|
|
||||||
|
|
||||||
|
@main.command
|
||||||
|
@click.argument("usb_transport")
|
||||||
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Load even if the USB info doesn't match",
|
||||||
|
)
|
||||||
|
def load(usb_transport, force):
|
||||||
|
"""Load a firmware image."""
|
||||||
|
asyncio.run(do_load(usb_transport, force))
|
||||||
|
|
||||||
|
|
||||||
|
@main.command
|
||||||
|
@click.argument("usb_transport")
|
||||||
|
@click.option(
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Attempt to reboot event if the USB info doesn't match",
|
||||||
|
)
|
||||||
|
def bootloader(usb_transport, force):
|
||||||
|
"""Reboot in bootloader mode."""
|
||||||
|
asyncio.run(do_bootloader(usb_transport, force))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user