forked from auracaster/bumble_mirror
Compare commits
154 Commits
gbg/bench-
...
gbg/update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeb9de31ed | ||
|
|
2c3af5b2bb | ||
|
|
dfb92e8ed1 | ||
|
|
73d2b54e30 | ||
|
|
8315a60f24 | ||
| 185d5fd577 | |||
|
|
ae5f9cf690 | ||
|
|
4b66a38fe6 | ||
|
|
f526f549ee | ||
|
|
8761129677 | ||
|
|
3f6f036270 | ||
|
|
859bb0609f | ||
|
|
5f2d24570e | ||
|
|
dbf94c8f3e | ||
|
|
b6adc29365 | ||
|
|
5caa7bfa90 | ||
|
|
f39d706fa0 | ||
|
|
c02c1f33d2 | ||
|
|
33435c2980 | ||
|
|
c08449d9db | ||
|
|
3c8718bb5b | ||
|
|
26e87f09fe | ||
|
|
7f5e0d190e | ||
|
|
efae307b3d | ||
|
|
26d38a855c | ||
|
|
7360a887d9 | ||
|
|
9756572c93 | ||
|
|
d6100755b1 | ||
|
|
a66eef6630 | ||
|
|
ae23ef7b9b | ||
|
|
f368b5e518 | ||
|
|
5293d32dc6 | ||
|
|
6d9a0bf4e1 | ||
|
|
3c7b5df7c5 | ||
|
|
70141c0439 | ||
|
|
dedc0aca54 | ||
|
|
7c019b574f | ||
|
|
9b485fd943 | ||
|
|
fdee8269ec | ||
|
|
0767f2d4ae | ||
|
|
c4a0846727 | ||
|
|
83ac70e426 | ||
|
|
01cce3525f | ||
|
|
b9d35aea47 | ||
|
|
079cf6b896 | ||
|
|
180655088c | ||
|
|
a1bade6f20 | ||
|
|
5d80e7fd80 | ||
|
|
2198692961 | ||
|
|
55d3fd90f5 | ||
|
|
afee659ca6 | ||
|
|
6fe7931d7d | ||
|
|
9023407ee4 | ||
|
|
54d961bbe5 | ||
|
|
cbd46adbcf | ||
|
|
745e107849 | ||
|
|
af466c2970 | ||
|
|
931e2de854 | ||
|
|
55eb7eb237 | ||
|
|
bade4502f9 | ||
|
|
9f952f202f | ||
|
|
1eb9d8d055 | ||
|
|
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 | ||
|
|
32642c5d7c | ||
|
|
ff8b0c375d | ||
|
|
ae0228aeb8 | ||
|
|
5d2dac18c8 | ||
|
|
d03fc14cfd | ||
|
|
ad7ce79bc4 | ||
|
|
c6bf27fd2c | ||
|
|
7584daa3f9 | ||
|
|
654030e789 | ||
|
|
1de7d2cd6f | ||
|
|
68db78c833 | ||
|
|
e1714c16cc | ||
|
|
0a20f14ea9 | ||
|
|
23f46b36b3 | ||
|
|
009649abd1 | ||
|
|
855a007116 | ||
|
|
d064de35e0 | ||
|
|
dab4d13303 | ||
|
|
f5443a9826 | ||
|
|
db723a5196 | ||
|
|
e16be1a8f4 | ||
|
|
2fa8075fb0 | ||
|
|
9e663ad051 |
4
.github/workflows/code-check.yml
vendored
4
.github/workflows/code-check.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
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
|
||||
|
||||
steps:
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install ".[build,test,development,pandora]"
|
||||
python -m pip install ".[build,test,development]"
|
||||
- name: Check
|
||||
run: |
|
||||
invoke project.pre-commit
|
||||
|
||||
6
.github/workflows/python-avatar.yml
vendored
6
.github/workflows/python-avatar.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
- name: Install
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install .[avatar,pandora]
|
||||
python -m pip install .[avatar]
|
||||
- name: Rootcanal
|
||||
run: nohup python -m rootcanal > rootcanal.log &
|
||||
- name: Test
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
run: cat rootcanal.log
|
||||
- name: Upload Mobly logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mobly-logs
|
||||
name: mobly-logs-${{ strategy.job-index }}
|
||||
path: /tmp/logs/mobly/bumble.bumbles/
|
||||
|
||||
4
.github/workflows/python-build-test.yml
vendored
4
.github/workflows/python-build-test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
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
|
||||
|
||||
steps:
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
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" ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -14,9 +14,12 @@
|
||||
"ASHA",
|
||||
"asyncio",
|
||||
"ATRAC",
|
||||
"auracast",
|
||||
"avctp",
|
||||
"avdtp",
|
||||
"avrcp",
|
||||
"biginfo",
|
||||
"bigs",
|
||||
"bitpool",
|
||||
"bitstruct",
|
||||
"BSCP",
|
||||
@@ -36,6 +39,7 @@
|
||||
"deregistration",
|
||||
"dhkey",
|
||||
"diversifier",
|
||||
"ediv",
|
||||
"endianness",
|
||||
"ESCO",
|
||||
"Fitbit",
|
||||
@@ -47,6 +51,7 @@
|
||||
"libc",
|
||||
"liblc",
|
||||
"libusb",
|
||||
"maxs",
|
||||
"MITM",
|
||||
"MSBC",
|
||||
"NDIS",
|
||||
@@ -54,8 +59,10 @@
|
||||
"NONBLOCK",
|
||||
"NONCONN",
|
||||
"OXIMETER",
|
||||
"PDUS",
|
||||
"popleft",
|
||||
"PRAND",
|
||||
"prefs",
|
||||
"protobuf",
|
||||
"psms",
|
||||
"pyee",
|
||||
|
||||
730
apps/auracast.py
730
apps/auracast.py
@@ -1,4 +1,4 @@
|
||||
# Copyright 2024 Google LLC
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -16,29 +16,50 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import asyncio.subprocess
|
||||
import collections
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple
|
||||
import struct
|
||||
from typing import (
|
||||
cast,
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
Coroutine,
|
||||
Deque,
|
||||
Optional,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
import click
|
||||
import pyee
|
||||
|
||||
try:
|
||||
import lc3 # type: ignore # pylint: disable=E0401
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Try `python -m pip install \"git+https://github.com/google/liblc3.git\"`."
|
||||
) from e
|
||||
|
||||
from bumble.audio import io as audio_io
|
||||
from bumble.colors import color
|
||||
import bumble.company_ids
|
||||
import bumble.core
|
||||
from bumble import company_ids
|
||||
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.gatt
|
||||
import bumble.hci
|
||||
import bumble.profiles.bap
|
||||
import bumble.profiles.bass
|
||||
import bumble.profiles.pbp
|
||||
import bumble.transport
|
||||
import bumble.utils
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -49,9 +70,34 @@ logger = logging.getLogger(__name__)
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
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_ATT_MTU = 256
|
||||
AURACAST_DEFAULT_FRAME_DURATION = 10000
|
||||
AURACAST_DEFAULT_SAMPLE_RATE = 48000
|
||||
AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utils
|
||||
# -----------------------------------------------------------------------------
|
||||
def codec_config_string(
|
||||
codec_config: bap.CodecSpecificConfiguration, indent: str
|
||||
) -> str:
|
||||
lines = []
|
||||
if codec_config.sampling_frequency is not None:
|
||||
lines.append(f'Sampling Frequency: {codec_config.sampling_frequency.hz} hz')
|
||||
if codec_config.frame_duration is not None:
|
||||
lines.append(f'Frame Duration: {codec_config.frame_duration.us} µs')
|
||||
if codec_config.octets_per_codec_frame is not None:
|
||||
lines.append(f'Frame Size: {codec_config.octets_per_codec_frame} bytes')
|
||||
if codec_config.codec_frames_per_sdu is not None:
|
||||
lines.append(f'Frames Per SDU: {codec_config.codec_frames_per_sdu}')
|
||||
if codec_config.audio_channel_allocation is not None:
|
||||
lines.append(
|
||||
f'Audio Location: {codec_config.audio_channel_allocation.name}'
|
||||
)
|
||||
return '\n'.join(indent + line for line in lines)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -60,19 +106,14 @@ AURACAST_DEFAULT_ATT_MTU = 256
|
||||
class BroadcastScanner(pyee.EventEmitter):
|
||||
@dataclasses.dataclass
|
||||
class Broadcast(pyee.EventEmitter):
|
||||
name: str
|
||||
name: str | None
|
||||
sync: bumble.device.PeriodicAdvertisingSync
|
||||
broadcast_id: int
|
||||
rssi: int = 0
|
||||
public_broadcast_announcement: Optional[
|
||||
bumble.profiles.pbp.PublicBroadcastAnnouncement
|
||||
] = None
|
||||
broadcast_audio_announcement: Optional[
|
||||
bumble.profiles.bap.BroadcastAudioAnnouncement
|
||||
] = None
|
||||
basic_audio_announcement: Optional[
|
||||
bumble.profiles.bap.BasicAudioAnnouncement
|
||||
] = None
|
||||
appearance: Optional[bumble.core.Appearance] = None
|
||||
public_broadcast_announcement: Optional[pbp.PublicBroadcastAnnouncement] = None
|
||||
broadcast_audio_announcement: Optional[bap.BroadcastAudioAnnouncement] = None
|
||||
basic_audio_announcement: Optional[bap.BasicAudioAnnouncement] = None
|
||||
appearance: Optional[core.Appearance] = None
|
||||
biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
|
||||
manufacturer_data: Optional[Tuple[str, bytes]] = None
|
||||
|
||||
@@ -86,42 +127,36 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
def update(self, advertisement: bumble.device.Advertisement) -> None:
|
||||
self.rssi = advertisement.rssi
|
||||
for service_data in advertisement.data.get_all(
|
||||
bumble.core.AdvertisingData.SERVICE_DATA
|
||||
core.AdvertisingData.SERVICE_DATA
|
||||
):
|
||||
assert isinstance(service_data, tuple)
|
||||
service_uuid, data = service_data
|
||||
assert isinstance(data, bytes)
|
||||
|
||||
if (
|
||||
service_uuid
|
||||
== bumble.gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE
|
||||
):
|
||||
if service_uuid == gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE:
|
||||
self.public_broadcast_announcement = (
|
||||
bumble.profiles.pbp.PublicBroadcastAnnouncement.from_bytes(data)
|
||||
pbp.PublicBroadcastAnnouncement.from_bytes(data)
|
||||
)
|
||||
continue
|
||||
|
||||
if (
|
||||
service_uuid
|
||||
== bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
|
||||
):
|
||||
if service_uuid == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE:
|
||||
self.broadcast_audio_announcement = (
|
||||
bumble.profiles.bap.BroadcastAudioAnnouncement.from_bytes(data)
|
||||
bap.BroadcastAudioAnnouncement.from_bytes(data)
|
||||
)
|
||||
continue
|
||||
|
||||
self.appearance = advertisement.data.get( # type: ignore[assignment]
|
||||
bumble.core.AdvertisingData.APPEARANCE
|
||||
core.AdvertisingData.APPEARANCE
|
||||
)
|
||||
|
||||
if manufacturer_data := advertisement.data.get(
|
||||
bumble.core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
|
||||
core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
|
||||
):
|
||||
assert isinstance(manufacturer_data, tuple)
|
||||
company_id = cast(int, manufacturer_data[0])
|
||||
data = cast(bytes, manufacturer_data[1])
|
||||
self.manufacturer_data = (
|
||||
bumble.company_ids.COMPANY_IDENTIFIERS.get(
|
||||
company_ids.COMPANY_IDENTIFIERS.get(
|
||||
company_id, f'0x{company_id:04X}'
|
||||
),
|
||||
data,
|
||||
@@ -135,7 +170,8 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
self.sync.advertiser_address,
|
||||
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:
|
||||
print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
|
||||
print(f' {color("RSSI", "cyan")}: {self.rssi}')
|
||||
@@ -156,25 +192,24 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
if self.public_broadcast_announcement:
|
||||
print(
|
||||
f' {color("Features", "cyan")}: '
|
||||
f'{self.public_broadcast_announcement.features}'
|
||||
)
|
||||
print(
|
||||
f' {color("Metadata", "cyan")}: '
|
||||
f'{self.public_broadcast_announcement.metadata}'
|
||||
f'{self.public_broadcast_announcement.features.name}'
|
||||
)
|
||||
print(f' {color("Metadata", "cyan")}:')
|
||||
print(self.public_broadcast_announcement.metadata.pretty_print(' '))
|
||||
|
||||
if self.basic_audio_announcement:
|
||||
print(color(' Audio:', 'cyan'))
|
||||
print(
|
||||
color(' Presentation Delay:', 'magenta'),
|
||||
self.basic_audio_announcement.presentation_delay,
|
||||
"µs",
|
||||
)
|
||||
for subgroup in self.basic_audio_announcement.subgroups:
|
||||
print(color(' Subgroup:', 'magenta'))
|
||||
print(color(' Codec ID:', 'yellow'))
|
||||
print(
|
||||
color(' Coding Format: ', 'green'),
|
||||
subgroup.codec_id.coding_format.name,
|
||||
subgroup.codec_id.codec_id.name,
|
||||
)
|
||||
print(
|
||||
color(' Company ID: ', 'green'),
|
||||
@@ -184,17 +219,22 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
color(' Vendor Specific Codec ID:', 'green'),
|
||||
subgroup.codec_id.vendor_specific_codec_id,
|
||||
)
|
||||
print(color(' Codec Config:', 'yellow'))
|
||||
print(
|
||||
color(' Codec Config:', 'yellow'),
|
||||
subgroup.codec_specific_configuration,
|
||||
codec_config_string(
|
||||
subgroup.codec_specific_configuration, ' '
|
||||
),
|
||||
)
|
||||
print(color(' Metadata: ', 'yellow'), subgroup.metadata)
|
||||
print(color(' Metadata: ', 'yellow'))
|
||||
print(subgroup.metadata.pretty_print(' '))
|
||||
|
||||
for bis in subgroup.bis:
|
||||
print(color(f' BIS [{bis.index}]:', 'yellow'))
|
||||
print(color(' Codec Config:', 'green'))
|
||||
print(
|
||||
color(' Codec Config:', 'green'),
|
||||
bis.codec_specific_configuration,
|
||||
codec_config_string(
|
||||
bis.codec_specific_configuration, ' '
|
||||
),
|
||||
)
|
||||
|
||||
if self.biginfo:
|
||||
@@ -231,15 +271,15 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
return
|
||||
|
||||
for service_data in advertisement.data.get_all(
|
||||
bumble.core.AdvertisingData.SERVICE_DATA
|
||||
core.AdvertisingData.SERVICE_DATA
|
||||
):
|
||||
assert isinstance(service_data, tuple)
|
||||
service_uuid, data = service_data
|
||||
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 = (
|
||||
bumble.profiles.bap.BasicAudioAnnouncement.from_bytes(data)
|
||||
bap.BasicAudioAnnouncement.from_bytes(data)
|
||||
)
|
||||
break
|
||||
|
||||
@@ -261,7 +301,7 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
self.device = device
|
||||
self.filter_duplicates = filter_duplicates
|
||||
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)
|
||||
|
||||
async def start(self) -> None:
|
||||
@@ -274,24 +314,46 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
await self.device.stop_scanning()
|
||||
|
||||
def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
|
||||
if (
|
||||
broadcast_name := advertisement.data.get(
|
||||
bumble.core.AdvertisingData.BROADCAST_NAME
|
||||
if not (
|
||||
ads := advertisement.data.get_all(
|
||||
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
|
||||
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):
|
||||
broadcast.update(advertisement)
|
||||
return
|
||||
|
||||
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(
|
||||
self, name: str, advertisement: bumble.device.Advertisement
|
||||
self,
|
||||
name: str | None,
|
||||
advertisement: bumble.device.Advertisement,
|
||||
broadcast_id: int,
|
||||
) -> None:
|
||||
periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
|
||||
advertiser_address=advertisement.address,
|
||||
@@ -299,10 +361,7 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
sync_timeout=self.sync_timeout,
|
||||
filter_duplicates=self.filter_duplicates,
|
||||
)
|
||||
broadcast = self.Broadcast(
|
||||
name,
|
||||
periodic_advertising_sync,
|
||||
)
|
||||
broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
|
||||
broadcast.update(advertisement)
|
||||
self.broadcasts[advertisement.address] = broadcast
|
||||
periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
|
||||
@@ -314,10 +373,11 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
self.emit('broadcast_loss', broadcast)
|
||||
|
||||
|
||||
class PrintingBroadcastScanner:
|
||||
class PrintingBroadcastScanner(pyee.EventEmitter):
|
||||
def __init__(
|
||||
self, device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.scanner = BroadcastScanner(device, filter_duplicates, sync_timeout)
|
||||
self.scanner.on('new_broadcast', self.on_new_broadcast)
|
||||
self.scanner.on('broadcast_loss', self.on_broadcast_loss)
|
||||
@@ -452,27 +512,29 @@ async def run_assist(
|
||||
await peer.request_mtu(mtu)
|
||||
|
||||
# Get the BASS service
|
||||
bass = await peer.discover_service_and_create_proxy(
|
||||
bumble.profiles.bass.BroadcastAudioScanServiceProxy
|
||||
bass_client = await peer.discover_service_and_create_proxy(
|
||||
bass.BroadcastAudioScanServiceProxy
|
||||
)
|
||||
|
||||
# Check that the service was found
|
||||
if not bass:
|
||||
if not bass_client:
|
||||
print(color('!!! Broadcast Audio Scan Service not found', 'red'))
|
||||
return
|
||||
|
||||
# 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:
|
||||
await broadcast_receive_state.subscribe(
|
||||
lambda value, i=i: print(
|
||||
f"{color(f'Broadcast Receive State Update [{i}]:', 'green')} {value}"
|
||||
)
|
||||
)
|
||||
except bumble.core.ProtocolError as error:
|
||||
except core.ProtocolError as error:
|
||||
print(
|
||||
color(
|
||||
f'!!! Failed to subscribe to Broadcast Receive State characteristic:',
|
||||
'!!! Failed to subscribe to Broadcast Receive State characteristic',
|
||||
'red',
|
||||
),
|
||||
error,
|
||||
@@ -488,7 +550,7 @@ async def run_assist(
|
||||
|
||||
if command == 'add-source':
|
||||
# Find the requested broadcast
|
||||
await bass.remote_scan_started()
|
||||
await bass_client.remote_scan_started()
|
||||
if broadcast_name:
|
||||
print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
|
||||
else:
|
||||
@@ -508,15 +570,15 @@ async def run_assist(
|
||||
|
||||
# Add the source
|
||||
print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
|
||||
await bass.add_source(
|
||||
await bass_client.add_source(
|
||||
broadcast.sync.advertiser_address,
|
||||
broadcast.sync.sid,
|
||||
broadcast.broadcast_audio_announcement.broadcast_id,
|
||||
bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
|
||||
bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
|
||||
0xFFFF,
|
||||
[
|
||||
bumble.profiles.bass.SubgroupInfo(
|
||||
bumble.profiles.bass.SubgroupInfo.ANY_BIS,
|
||||
bass.SubgroupInfo(
|
||||
bass.SubgroupInfo.ANY_BIS,
|
||||
bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
|
||||
)
|
||||
],
|
||||
@@ -526,7 +588,7 @@ async def run_assist(
|
||||
await broadcast.sync.transfer(peer.connection)
|
||||
|
||||
# Notify the sink that we're done scanning.
|
||||
await bass.remote_scan_stopped()
|
||||
await bass_client.remote_scan_stopped()
|
||||
|
||||
await peer.sustain()
|
||||
return
|
||||
@@ -537,7 +599,7 @@ async def run_assist(
|
||||
return
|
||||
|
||||
# Find the requested broadcast
|
||||
await bass.remote_scan_started()
|
||||
await bass_client.remote_scan_started()
|
||||
if broadcast_name:
|
||||
print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
|
||||
else:
|
||||
@@ -560,13 +622,13 @@ async def run_assist(
|
||||
color('Modifying source:', 'blue'),
|
||||
source_id,
|
||||
)
|
||||
await bass.modify_source(
|
||||
await bass_client.modify_source(
|
||||
source_id,
|
||||
bumble.profiles.bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
|
||||
bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
|
||||
0xFFFF,
|
||||
[
|
||||
bumble.profiles.bass.SubgroupInfo(
|
||||
bumble.profiles.bass.SubgroupInfo.ANY_BIS,
|
||||
bass.SubgroupInfo(
|
||||
bass.SubgroupInfo.ANY_BIS,
|
||||
bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
|
||||
)
|
||||
],
|
||||
@@ -581,7 +643,7 @@ async def run_assist(
|
||||
|
||||
# Remove the source
|
||||
print(color('Removing source:', 'blue'), source_id)
|
||||
await bass.remove_source(source_id)
|
||||
await bass_client.remove_source(source_id)
|
||||
await peer.sustain()
|
||||
return
|
||||
|
||||
@@ -601,14 +663,346 @@ async def run_pair(transport: str, address: str) -> None:
|
||||
print("+++ Paired")
|
||||
|
||||
|
||||
async def run_receive(
|
||||
transport: str,
|
||||
broadcast_id: Optional[int],
|
||||
output: str,
|
||||
broadcast_code: str | None,
|
||||
sync_timeout: float,
|
||||
subgroup_index: int,
|
||||
) -> None:
|
||||
# Run a pre-flight check for the output.
|
||||
try:
|
||||
if not audio_io.check_audio_output(output):
|
||||
return
|
||||
except ValueError as error:
|
||||
print(error)
|
||||
return
|
||||
|
||||
async with create_device(transport) as device:
|
||||
if not device.supports_le_periodic_advertising:
|
||||
print(color('Periodic advertising not supported', 'red'))
|
||||
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_id is None or 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,
|
||||
)
|
||||
lc3_queues: list[Deque[bytes]] = [collections.deque() for i in range(num_bis)]
|
||||
packet_stats = [0, 0]
|
||||
|
||||
audio_output = await audio_io.create_audio_output(output)
|
||||
# This try should be replaced with contextlib.aclosing() when python 3.9 is no
|
||||
# longer needed.
|
||||
try:
|
||||
await audio_output.open(
|
||||
audio_io.PcmFormat(
|
||||
audio_io.PcmFormat.Endianness.LITTLE,
|
||||
audio_io.PcmFormat.SampleType.FLOAT32,
|
||||
sampling_frequency.hz,
|
||||
num_bis,
|
||||
)
|
||||
)
|
||||
|
||||
def sink(queue: Deque[bytes], packet: hci.HCI_IsoDataPacket):
|
||||
# TODO: re-assemble fragments and detect errors
|
||||
queue.append(packet.iso_sdu_fragment)
|
||||
|
||||
while all(lc3_queues):
|
||||
# This assumes SDUs contain one LC3 frame each, which may not
|
||||
# be correct for all cases. TODO: revisit this assumption.
|
||||
frame = b''.join([lc3_queue.popleft() for lc3_queue in lc3_queues])
|
||||
if not frame:
|
||||
print(color('!!! received empty frame', 'red'))
|
||||
continue
|
||||
|
||||
packet_stats[0] += len(frame)
|
||||
packet_stats[1] += 1
|
||||
print(
|
||||
f'\rRECEIVED: {packet_stats[0]} bytes in '
|
||||
f'{packet_stats[1]} packets',
|
||||
end='',
|
||||
)
|
||||
|
||||
try:
|
||||
pcm = decoder.decode(frame).tobytes()
|
||||
except lc3.BaseError as error:
|
||||
print(color(f'!!! LC3 decoding error: {error}'))
|
||||
continue
|
||||
|
||||
audio_output.write(pcm)
|
||||
|
||||
for i, bis_link in enumerate(big_sync.bis_links):
|
||||
print(f'Setup ISO for BIS {bis_link.handle}')
|
||||
bis_link.sink = functools.partial(sink, lc3_queues[i])
|
||||
await device.send_command(
|
||||
hci.HCI_LE_Setup_ISO_Data_Path_Command(
|
||||
connection_handle=bis_link.handle,
|
||||
data_path_direction=hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST,
|
||||
data_path_id=0,
|
||||
codec_id=hci.CodingFormat(codec_id=hci.CodecID.TRANSPARENT),
|
||||
controller_delay=0,
|
||||
codec_configuration=b'',
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
terminated = asyncio.Event()
|
||||
big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
|
||||
await terminated.wait()
|
||||
finally:
|
||||
await audio_output.aclose()
|
||||
|
||||
|
||||
async def run_transmit(
|
||||
transport: str,
|
||||
broadcast_id: int,
|
||||
broadcast_code: str | None,
|
||||
broadcast_name: str,
|
||||
bitrate: int,
|
||||
manufacturer_data: tuple[int, bytes] | None,
|
||||
input: str,
|
||||
input_format: str,
|
||||
) -> None:
|
||||
# Run a pre-flight check for the input.
|
||||
try:
|
||||
if not audio_io.check_audio_input(input):
|
||||
return
|
||||
except ValueError as error:
|
||||
print(error)
|
||||
return
|
||||
|
||||
async with create_device(transport) as device:
|
||||
if not device.supports_le_periodic_advertising:
|
||||
print(color('Periodic advertising not supported', 'red'))
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
advertising_manufacturer_data = (
|
||||
b''
|
||||
if manufacturer_data is None
|
||||
else bytes(
|
||||
core.AdvertisingData(
|
||||
[
|
||||
(
|
||||
core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA,
|
||||
struct.pack('<H', manufacturer_data[0])
|
||||
+ manufacturer_data[1],
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
advertising_set = await device.create_advertising_set(
|
||||
advertising_parameters=bumble.device.AdvertisingParameters(
|
||||
advertising_event_properties=bumble.device.AdvertisingEventProperties(
|
||||
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, broadcast_name.encode())]
|
||||
)
|
||||
)
|
||||
+ advertising_manufacturer_data
|
||||
),
|
||||
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()
|
||||
|
||||
audio_input = await audio_io.create_audio_input(input, input_format)
|
||||
pcm_format = await audio_input.open()
|
||||
# This try should be replaced with contextlib.aclosing() when python 3.9 is no
|
||||
# longer needed.
|
||||
try:
|
||||
if pcm_format.channels != 2:
|
||||
print("Only 2 channels PCM configurations are supported")
|
||||
return
|
||||
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
|
||||
pcm_bit_depth = 16
|
||||
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
|
||||
pcm_bit_depth = None
|
||||
else:
|
||||
print("Only INT16 and FLOAT32 sample types are supported")
|
||||
return
|
||||
|
||||
encoder = lc3.Encoder(
|
||||
frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
|
||||
sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE,
|
||||
num_channels=pcm_format.channels,
|
||||
input_sample_rate_hz=pcm_format.sample_rate,
|
||||
)
|
||||
lc3_frame_samples = encoder.get_frame_samples()
|
||||
lc3_frame_size = encoder.get_frame_bytes(bitrate)
|
||||
print(
|
||||
f'Encoding with {lc3_frame_samples} '
|
||||
f'PCM samples per {lc3_frame_size} byte frame'
|
||||
)
|
||||
|
||||
print('Setup BIG')
|
||||
big = await device.create_big(
|
||||
advertising_set,
|
||||
parameters=bumble.device.BigParameters(
|
||||
num_bis=pcm_format.channels,
|
||||
sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
|
||||
max_sdu=lc3_frame_size,
|
||||
max_transport_latency=65,
|
||||
rtn=4,
|
||||
broadcast_code=(
|
||||
bytes.fromhex(broadcast_code) if broadcast_code else None
|
||||
),
|
||||
),
|
||||
)
|
||||
for bis_link in big.bis_links:
|
||||
await bis_link.setup_data_path(
|
||||
direction=bis_link.Direction.HOST_TO_CONTROLLER
|
||||
)
|
||||
|
||||
iso_queues = [
|
||||
bumble.device.IsoPacketStream(big.bis_links[0], 64),
|
||||
bumble.device.IsoPacketStream(big.bis_links[1], 64),
|
||||
]
|
||||
|
||||
def on_flow():
|
||||
data_packet_queue = iso_queues[0].data_packet_queue
|
||||
print(
|
||||
f'\rPACKETS: pending={data_packet_queue.pending}, '
|
||||
f'queued={data_packet_queue.queued}, '
|
||||
f'completed={data_packet_queue.completed}',
|
||||
end='',
|
||||
)
|
||||
|
||||
iso_queues[0].data_packet_queue.on('flow', on_flow)
|
||||
|
||||
frame_count = 0
|
||||
async for pcm_frame in audio_input.frames(lc3_frame_samples):
|
||||
lc3_frame = encoder.encode(
|
||||
pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=pcm_bit_depth
|
||||
)
|
||||
|
||||
mid = len(lc3_frame) // 2
|
||||
await iso_queues[0].write(lc3_frame[:mid])
|
||||
await iso_queues[1].write(lc3_frame[mid:])
|
||||
|
||||
frame_count += 1
|
||||
finally:
|
||||
await audio_input.aclose()
|
||||
|
||||
|
||||
def run_async(async_command: Coroutine) -> None:
|
||||
try:
|
||||
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(
|
||||
bumble.profiles.bass.ApplicationError
|
||||
bass.ApplicationError
|
||||
):
|
||||
message = bumble.profiles.bass.ApplicationError(error.error_code).name
|
||||
message = bass.ApplicationError(error.error_code).name
|
||||
else:
|
||||
message = str(error)
|
||||
|
||||
@@ -622,9 +1016,7 @@ def run_async(async_command: Coroutine) -> None:
|
||||
# -----------------------------------------------------------------------------
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
def auracast(
|
||||
ctx,
|
||||
):
|
||||
def auracast(ctx):
|
||||
ctx.ensure_object(dict)
|
||||
|
||||
|
||||
@@ -669,7 +1061,7 @@ def scan(ctx, filter_duplicates, sync_timeout, transport):
|
||||
@click.argument('address')
|
||||
@click.pass_context
|
||||
def assist(ctx, broadcast_name, source_id, command, transport, address):
|
||||
"""Scan for broadcasts on behalf of a audio server"""
|
||||
"""Scan for broadcasts on behalf of an audio server"""
|
||||
run_async(run_assist(broadcast_name, source_id, command, transport, address))
|
||||
|
||||
|
||||
@@ -682,6 +1074,166 @@ def pair(ctx, transport, address):
|
||||
run_async(run_pair(transport, address))
|
||||
|
||||
|
||||
@auracast.command('receive')
|
||||
@click.argument('transport')
|
||||
@click.argument(
|
||||
'broadcast_id',
|
||||
type=int,
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
'--output',
|
||||
default='device',
|
||||
help=(
|
||||
"Audio output. "
|
||||
"'device' -> use the host's default sound output device, "
|
||||
"'device:<DEVICE_ID>' -> use one of the host's sound output device "
|
||||
"(specify 'device:?' to get a list of available sound output devices), "
|
||||
"'stdout' -> send audio to stdout, "
|
||||
"'file:<filename> -> write audio to a raw float32 PCM file, "
|
||||
"'ffplay' -> pipe the audio to ffplay"
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
'--broadcast-code',
|
||||
metavar='BROADCAST_CODE',
|
||||
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,
|
||||
output,
|
||||
broadcast_code,
|
||||
sync_timeout,
|
||||
subgroup,
|
||||
):
|
||||
"""Receive a broadcast source"""
|
||||
run_async(
|
||||
run_receive(
|
||||
transport,
|
||||
broadcast_id,
|
||||
output,
|
||||
broadcast_code,
|
||||
sync_timeout,
|
||||
subgroup,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@auracast.command('transmit')
|
||||
@click.argument('transport')
|
||||
@click.option(
|
||||
'--input',
|
||||
required=True,
|
||||
help=(
|
||||
"Audio input. "
|
||||
"'device' -> use the host's default sound input device, "
|
||||
"'device:<DEVICE_ID>' -> use one of the host's sound input devices "
|
||||
"(specify 'device:?' to get a list of available sound input devices), "
|
||||
"'stdin' -> receive audio from stdin as int16 PCM, "
|
||||
"'file:<filename> -> read audio from a .wav or raw int16 PCM file. "
|
||||
"(The file: prefix may be omitted if the file path does not start with "
|
||||
"the substring 'device:' or 'file:' and is not 'stdin')"
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
'--input-format',
|
||||
metavar="FORMAT",
|
||||
default='auto',
|
||||
help=(
|
||||
"Audio input format. "
|
||||
"Use 'auto' for .wav files, or for the default setting with the devices. "
|
||||
"For other inputs, the format is specified as "
|
||||
"<sample-type>,<sample-rate>,<channels> (supported <sample-type>: 'int16le' "
|
||||
"for 16-bit signed integers with little-endian byte order or 'float32le' for "
|
||||
"32-bit floating point with little-endian byte order)"
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
'--broadcast-id',
|
||||
metavar='BROADCAST_ID',
|
||||
type=int,
|
||||
default=123456,
|
||||
help='Broadcast ID',
|
||||
)
|
||||
@click.option(
|
||||
'--broadcast-code',
|
||||
metavar='BROADCAST_CODE',
|
||||
help='Broadcast encryption code in hex format',
|
||||
)
|
||||
@click.option(
|
||||
'--broadcast-name',
|
||||
metavar='BROADCAST_NAME',
|
||||
default='Bumble Auracast',
|
||||
help='Broadcast name',
|
||||
)
|
||||
@click.option(
|
||||
'--bitrate',
|
||||
type=int,
|
||||
default=AURACAST_DEFAULT_TRANSMIT_BITRATE,
|
||||
help='Bitrate, per channel, in bps',
|
||||
)
|
||||
@click.option(
|
||||
'--manufacturer-data',
|
||||
metavar='VENDOR-ID:DATA-HEX',
|
||||
help='Manufacturer data (specify as <vendor-id>:<data-hex>)',
|
||||
)
|
||||
@click.pass_context
|
||||
def transmit(
|
||||
ctx,
|
||||
transport,
|
||||
broadcast_id,
|
||||
broadcast_code,
|
||||
manufacturer_data,
|
||||
broadcast_name,
|
||||
bitrate,
|
||||
input,
|
||||
input_format,
|
||||
):
|
||||
"""Transmit a broadcast source"""
|
||||
if manufacturer_data:
|
||||
vendor_id_str, data_hex = manufacturer_data.split(':')
|
||||
vendor_id = int(vendor_id_str)
|
||||
data = bytes.fromhex(data_hex)
|
||||
manufacturer_data_tuple = (vendor_id, data)
|
||||
else:
|
||||
manufacturer_data_tuple = None
|
||||
|
||||
if (input == 'device' or input.startswith('device:')) and input_format == 'auto':
|
||||
# Use a default format for device inputs
|
||||
input_format = 'int16le,48000,1'
|
||||
|
||||
run_async(
|
||||
run_transmit(
|
||||
transport=transport,
|
||||
broadcast_id=broadcast_id,
|
||||
broadcast_code=broadcast_code,
|
||||
broadcast_name=broadcast_name,
|
||||
bitrate=bitrate,
|
||||
manufacturer_data=manufacturer_data_tuple,
|
||||
input=input,
|
||||
input_format=input_format,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
auracast()
|
||||
|
||||
396
apps/bench.py
396
apps/bench.py
@@ -16,6 +16,7 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
@@ -97,49 +98,22 @@ DEFAULT_RFCOMM_MTU = 2048
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utils
|
||||
# -----------------------------------------------------------------------------
|
||||
def parse_packet(packet):
|
||||
if len(packet) < 1:
|
||||
logging.info(
|
||||
color(f'!!! Packet too short (got {len(packet)} bytes, need >= 1)', 'red')
|
||||
)
|
||||
raise ValueError('packet too short')
|
||||
|
||||
try:
|
||||
packet_type = PacketType(packet[0])
|
||||
except ValueError:
|
||||
logging.info(color(f'!!! Invalid packet type 0x{packet[0]:02X}', 'red'))
|
||||
raise
|
||||
|
||||
return (packet_type, packet[1:])
|
||||
|
||||
|
||||
def parse_packet_sequence(packet_data):
|
||||
if len(packet_data) < 5:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!!Packet too short (got {len(packet_data)} bytes, need >= 5)',
|
||||
'red',
|
||||
)
|
||||
)
|
||||
raise ValueError('packet too short')
|
||||
return struct.unpack_from('>bI', packet_data, 0)
|
||||
|
||||
|
||||
def le_phy_name(phy_id):
|
||||
return {HCI_LE_1M_PHY: '1M', HCI_LE_2M_PHY: '2M', HCI_LE_CODED_PHY: 'CODED'}.get(
|
||||
phy_id, HCI_Constant.le_phy_name(phy_id)
|
||||
)
|
||||
|
||||
|
||||
def print_connection_phy(phy):
|
||||
logging.info(
|
||||
color('@@@ PHY: ', 'yellow') + f'TX:{le_phy_name(phy.tx_phy)}/'
|
||||
f'RX:{le_phy_name(phy.rx_phy)}'
|
||||
)
|
||||
|
||||
|
||||
def print_connection(connection):
|
||||
params = []
|
||||
if connection.transport == BT_LE_TRANSPORT:
|
||||
params.append(
|
||||
'PHY='
|
||||
f'TX:{le_phy_name(connection.phy.tx_phy)}/'
|
||||
f'RX:{le_phy_name(connection.phy.rx_phy)}'
|
||||
)
|
||||
|
||||
params.append(
|
||||
'DL=('
|
||||
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
|
||||
@@ -199,7 +173,7 @@ def log_stats(title, stats, precision=2):
|
||||
stats_min = min(stats)
|
||||
stats_max = max(stats)
|
||||
stats_avg = statistics.mean(stats)
|
||||
stats_stdev = statistics.stdev(stats)
|
||||
stats_stdev = statistics.stdev(stats) if len(stats) >= 2 else 0
|
||||
logging.info(
|
||||
color(
|
||||
(
|
||||
@@ -225,13 +199,135 @@ async def switch_roles(connection, role):
|
||||
logging.info(f'{color("### Role switch failed:", "red")} {error}')
|
||||
|
||||
|
||||
class PacketType(enum.IntEnum):
|
||||
RESET = 0
|
||||
SEQUENCE = 1
|
||||
ACK = 2
|
||||
# -----------------------------------------------------------------------------
|
||||
# Packet
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class Packet:
|
||||
class PacketType(enum.IntEnum):
|
||||
RESET = 0
|
||||
SEQUENCE = 1
|
||||
ACK = 2
|
||||
|
||||
class PacketFlags(enum.IntFlag):
|
||||
LAST = 1
|
||||
|
||||
packet_type: PacketType
|
||||
flags: PacketFlags = PacketFlags(0)
|
||||
sequence: int = 0
|
||||
timestamp: int = 0
|
||||
payload: bytes = b""
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
if len(data) < 1:
|
||||
logging.warning(
|
||||
color(f'!!! Packet too short (got {len(data)} bytes, need >= 1)', 'red')
|
||||
)
|
||||
raise ValueError('packet too short')
|
||||
|
||||
try:
|
||||
packet_type = cls.PacketType(data[0])
|
||||
except ValueError:
|
||||
logging.warning(color(f'!!! Invalid packet type 0x{data[0]:02X}', 'red'))
|
||||
raise
|
||||
|
||||
if packet_type == cls.PacketType.RESET:
|
||||
return cls(packet_type)
|
||||
|
||||
flags = cls.PacketFlags(data[1])
|
||||
(sequence,) = struct.unpack_from("<I", data, 2)
|
||||
|
||||
if packet_type == cls.PacketType.ACK:
|
||||
if len(data) < 6:
|
||||
logging.warning(
|
||||
color(
|
||||
f'!!! Packet too short (got {len(data)} bytes, need >= 6)',
|
||||
'red',
|
||||
)
|
||||
)
|
||||
return cls(packet_type, flags, sequence)
|
||||
|
||||
if len(data) < 10:
|
||||
logging.warning(
|
||||
color(
|
||||
f'!!! Packet too short (got {len(data)} bytes, need >= 10)', 'red'
|
||||
)
|
||||
)
|
||||
raise ValueError('packet too short')
|
||||
|
||||
(timestamp,) = struct.unpack_from("<I", data, 6)
|
||||
return cls(packet_type, flags, sequence, timestamp, data[10:])
|
||||
|
||||
def __bytes__(self):
|
||||
if self.packet_type == self.PacketType.RESET:
|
||||
return bytes([self.packet_type])
|
||||
|
||||
if self.packet_type == self.PacketType.ACK:
|
||||
return struct.pack("<BBI", self.packet_type, self.flags, self.sequence)
|
||||
|
||||
return (
|
||||
struct.pack(
|
||||
"<BBII", self.packet_type, self.flags, self.sequence, self.timestamp
|
||||
)
|
||||
+ self.payload
|
||||
)
|
||||
|
||||
|
||||
PACKET_FLAG_LAST = 1
|
||||
# -----------------------------------------------------------------------------
|
||||
# Jitter Stats
|
||||
# -----------------------------------------------------------------------------
|
||||
class JitterStats:
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.packets = []
|
||||
self.receive_times = []
|
||||
self.jitter = []
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
now = time.time()
|
||||
self.packets.append(packet)
|
||||
self.receive_times.append(now)
|
||||
|
||||
if packet.timestamp and len(self.packets) > 1:
|
||||
expected_time = (
|
||||
self.receive_times[0]
|
||||
+ (packet.timestamp - self.packets[0].timestamp) / 1000000
|
||||
)
|
||||
jitter = now - expected_time
|
||||
else:
|
||||
jitter = 0.0
|
||||
|
||||
self.jitter.append(jitter)
|
||||
return jitter
|
||||
|
||||
def show_stats(self):
|
||||
if len(self.jitter) < 3:
|
||||
return
|
||||
average = sum(self.jitter) / len(self.jitter)
|
||||
adjusted = [jitter - average for jitter in self.jitter]
|
||||
|
||||
log_stats('Jitter (signed)', adjusted, 3)
|
||||
log_stats('Jitter (absolute)', [abs(jitter) for jitter in adjusted], 3)
|
||||
|
||||
# Show a histogram
|
||||
bin_count = 20
|
||||
bins = [0] * bin_count
|
||||
interval_min = min(adjusted)
|
||||
interval_max = max(adjusted)
|
||||
interval_range = interval_max - interval_min
|
||||
bin_thresholds = [
|
||||
interval_min + i * (interval_range / bin_count) for i in range(bin_count)
|
||||
]
|
||||
for jitter in adjusted:
|
||||
for i in reversed(range(bin_count)):
|
||||
if jitter >= bin_thresholds[i]:
|
||||
bins[i] += 1
|
||||
break
|
||||
for i in range(bin_count):
|
||||
logging.info(f'@@@ >= {bin_thresholds[i]:.4f}: {bins[i]}')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -281,19 +377,37 @@ class Sender:
|
||||
await asyncio.sleep(self.tx_start_delay)
|
||||
|
||||
logging.info(color('=== Sending RESET', 'magenta'))
|
||||
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
||||
await self.packet_io.send_packet(
|
||||
bytes(Packet(packet_type=Packet.PacketType.RESET))
|
||||
)
|
||||
|
||||
self.start_time = time.time()
|
||||
self.bytes_sent = 0
|
||||
for tx_i in range(self.tx_packet_count):
|
||||
packet_flags = (
|
||||
PACKET_FLAG_LAST if tx_i == self.tx_packet_count - 1 else 0
|
||||
if self.pace > 0:
|
||||
# Wait until it is time to send the next packet
|
||||
target_time = self.start_time + (tx_i * self.pace / 1000)
|
||||
now = time.time()
|
||||
if now < target_time:
|
||||
await asyncio.sleep(target_time - now)
|
||||
else:
|
||||
await self.packet_io.drain()
|
||||
|
||||
packet = bytes(
|
||||
Packet(
|
||||
packet_type=Packet.PacketType.SEQUENCE,
|
||||
flags=(
|
||||
Packet.PacketFlags.LAST
|
||||
if tx_i == self.tx_packet_count - 1
|
||||
else 0
|
||||
),
|
||||
sequence=tx_i,
|
||||
timestamp=int((time.time() - self.start_time) * 1000000),
|
||||
payload=bytes(
|
||||
self.tx_packet_size - 10 - self.packet_io.overhead_size
|
||||
),
|
||||
)
|
||||
)
|
||||
packet = struct.pack(
|
||||
'>bbI',
|
||||
PacketType.SEQUENCE,
|
||||
packet_flags,
|
||||
tx_i,
|
||||
) + bytes(self.tx_packet_size - 6 - self.packet_io.overhead_size)
|
||||
logging.info(
|
||||
color(
|
||||
f'Sending packet {tx_i}: {self.tx_packet_size} bytes', 'yellow'
|
||||
@@ -302,14 +416,6 @@ class Sender:
|
||||
self.bytes_sent += len(packet)
|
||||
await self.packet_io.send_packet(packet)
|
||||
|
||||
if self.pace is None:
|
||||
continue
|
||||
|
||||
if self.pace > 0:
|
||||
await asyncio.sleep(self.pace / 1000)
|
||||
else:
|
||||
await self.packet_io.drain()
|
||||
|
||||
await self.done.wait()
|
||||
|
||||
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
|
||||
@@ -321,13 +427,13 @@ class Sender:
|
||||
if self.repeat:
|
||||
logging.info(color('--- End of runs', 'blue'))
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
def on_packet_received(self, data):
|
||||
try:
|
||||
packet_type, _ = parse_packet(packet)
|
||||
packet = Packet.from_bytes(data)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if packet_type == PacketType.ACK:
|
||||
if packet.packet_type == Packet.PacketType.ACK:
|
||||
elapsed = time.time() - self.start_time
|
||||
average_tx_speed = self.bytes_sent / elapsed
|
||||
self.stats.append(average_tx_speed)
|
||||
@@ -350,52 +456,53 @@ class Receiver:
|
||||
last_timestamp: float
|
||||
|
||||
def __init__(self, packet_io, linger):
|
||||
self.reset()
|
||||
self.jitter_stats = JitterStats()
|
||||
self.packet_io = packet_io
|
||||
self.packet_io.packet_listener = self
|
||||
self.linger = linger
|
||||
self.done = asyncio.Event()
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.expected_packet_index = 0
|
||||
self.measurements = [(time.time(), 0)]
|
||||
self.total_bytes_received = 0
|
||||
self.jitter_stats.reset()
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
def on_packet_received(self, data):
|
||||
try:
|
||||
packet_type, packet_data = parse_packet(packet)
|
||||
packet = Packet.from_bytes(data)
|
||||
except ValueError:
|
||||
logging.exception("invalid packet")
|
||||
return
|
||||
|
||||
if packet_type == PacketType.RESET:
|
||||
if packet.packet_type == Packet.PacketType.RESET:
|
||||
logging.info(color('=== Received RESET', 'magenta'))
|
||||
self.reset()
|
||||
return
|
||||
|
||||
try:
|
||||
packet_flags, packet_index = parse_packet_sequence(packet_data)
|
||||
except ValueError:
|
||||
return
|
||||
jitter = self.jitter_stats.on_packet_received(packet)
|
||||
logging.info(
|
||||
f'<<< Received packet {packet_index}: '
|
||||
f'flags=0x{packet_flags:02X}, '
|
||||
f'{len(packet) + self.packet_io.overhead_size} bytes'
|
||||
f'<<< Received packet {packet.sequence}: '
|
||||
f'flags={packet.flags}, '
|
||||
f'jitter={jitter:.4f}, '
|
||||
f'{len(data) + self.packet_io.overhead_size} bytes',
|
||||
)
|
||||
|
||||
if packet_index != self.expected_packet_index:
|
||||
if packet.sequence != self.expected_packet_index:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!! Unexpected packet, expected {self.expected_packet_index} '
|
||||
f'but received {packet_index}'
|
||||
f'but received {packet.sequence}'
|
||||
)
|
||||
)
|
||||
|
||||
now = time.time()
|
||||
elapsed_since_start = now - self.measurements[0][0]
|
||||
elapsed_since_last = now - self.measurements[-1][0]
|
||||
self.measurements.append((now, len(packet)))
|
||||
self.total_bytes_received += len(packet)
|
||||
instant_rx_speed = len(packet) / elapsed_since_last
|
||||
self.measurements.append((now, len(data)))
|
||||
self.total_bytes_received += len(data)
|
||||
instant_rx_speed = len(data) / elapsed_since_last
|
||||
average_rx_speed = self.total_bytes_received / elapsed_since_start
|
||||
window = self.measurements[-64:]
|
||||
windowed_rx_speed = sum(measurement[1] for measurement in window[1:]) / (
|
||||
@@ -411,15 +518,17 @@ class Receiver:
|
||||
)
|
||||
)
|
||||
|
||||
self.expected_packet_index = packet_index + 1
|
||||
self.expected_packet_index = packet.sequence + 1
|
||||
|
||||
if packet_flags & PACKET_FLAG_LAST:
|
||||
if packet.flags & Packet.PacketFlags.LAST:
|
||||
AsyncRunner.spawn(
|
||||
self.packet_io.send_packet(
|
||||
struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index)
|
||||
bytes(Packet(Packet.PacketType.ACK, packet.flags, packet.sequence))
|
||||
)
|
||||
)
|
||||
logging.info(color('@@@ Received last packet', 'green'))
|
||||
self.jitter_stats.show_stats()
|
||||
|
||||
if not self.linger:
|
||||
self.done.set()
|
||||
|
||||
@@ -468,6 +577,7 @@ class Ping:
|
||||
|
||||
for run in range(self.repeat + 1):
|
||||
self.done.clear()
|
||||
self.ping_times = []
|
||||
|
||||
if run > 0 and self.repeat and self.repeat_delay:
|
||||
logging.info(color(f'*** Repeat delay: {self.repeat_delay}', 'green'))
|
||||
@@ -478,25 +588,32 @@ class Ping:
|
||||
await asyncio.sleep(self.tx_start_delay)
|
||||
|
||||
logging.info(color('=== Sending RESET', 'magenta'))
|
||||
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
||||
await self.packet_io.send_packet(bytes(Packet(Packet.PacketType.RESET)))
|
||||
|
||||
packet_interval = self.pace / 1000
|
||||
start_time = time.time()
|
||||
self.next_expected_packet_index = 0
|
||||
for i in range(self.tx_packet_count):
|
||||
target_time = start_time + (i * packet_interval)
|
||||
target_time = start_time + (i * self.pace / 1000)
|
||||
now = time.time()
|
||||
if now < target_time:
|
||||
await asyncio.sleep(target_time - now)
|
||||
now = time.time()
|
||||
|
||||
packet = struct.pack(
|
||||
'>bbI',
|
||||
PacketType.SEQUENCE,
|
||||
(PACKET_FLAG_LAST if i == self.tx_packet_count - 1 else 0),
|
||||
i,
|
||||
) + bytes(self.tx_packet_size - 6)
|
||||
packet = bytes(
|
||||
Packet(
|
||||
packet_type=Packet.PacketType.SEQUENCE,
|
||||
flags=(
|
||||
Packet.PacketFlags.LAST
|
||||
if i == self.tx_packet_count - 1
|
||||
else 0
|
||||
),
|
||||
sequence=i,
|
||||
timestamp=int((now - start_time) * 1000000),
|
||||
payload=bytes(self.tx_packet_size - 10),
|
||||
)
|
||||
)
|
||||
logging.info(color(f'Sending packet {i}', 'yellow'))
|
||||
self.ping_times.append(time.time())
|
||||
self.ping_times.append(now)
|
||||
await self.packet_io.send_packet(packet)
|
||||
|
||||
await self.done.wait()
|
||||
@@ -530,40 +647,35 @@ class Ping:
|
||||
if self.repeat:
|
||||
logging.info(color('--- End of runs', 'blue'))
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
def on_packet_received(self, data):
|
||||
try:
|
||||
packet_type, packet_data = parse_packet(packet)
|
||||
packet = Packet.from_bytes(data)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
try:
|
||||
packet_flags, packet_index = parse_packet_sequence(packet_data)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if packet_type == PacketType.ACK:
|
||||
elapsed = time.time() - self.ping_times[packet_index]
|
||||
if packet.packet_type == Packet.PacketType.ACK:
|
||||
elapsed = time.time() - self.ping_times[packet.sequence]
|
||||
rtt = elapsed * 1000
|
||||
self.rtts.append(rtt)
|
||||
logging.info(
|
||||
color(
|
||||
f'<<< Received ACK [{packet_index}], RTT={rtt:.2f}ms',
|
||||
f'<<< Received ACK [{packet.sequence}], RTT={rtt:.2f}ms',
|
||||
'green',
|
||||
)
|
||||
)
|
||||
|
||||
if packet_index == self.next_expected_packet_index:
|
||||
if packet.sequence == self.next_expected_packet_index:
|
||||
self.next_expected_packet_index += 1
|
||||
else:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!! Unexpected packet, '
|
||||
f'expected {self.next_expected_packet_index} '
|
||||
f'but received {packet_index}'
|
||||
f'but received {packet.sequence}'
|
||||
)
|
||||
)
|
||||
|
||||
if packet_flags & PACKET_FLAG_LAST:
|
||||
if packet.flags & Packet.PacketFlags.LAST:
|
||||
self.done.set()
|
||||
return
|
||||
|
||||
@@ -575,89 +687,56 @@ class Pong:
|
||||
expected_packet_index: int
|
||||
|
||||
def __init__(self, packet_io, linger):
|
||||
self.reset()
|
||||
self.jitter_stats = JitterStats()
|
||||
self.packet_io = packet_io
|
||||
self.packet_io.packet_listener = self
|
||||
self.linger = linger
|
||||
self.done = asyncio.Event()
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.expected_packet_index = 0
|
||||
self.receive_times = []
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
self.receive_times.append(time.time())
|
||||
self.jitter_stats.reset()
|
||||
|
||||
def on_packet_received(self, data):
|
||||
try:
|
||||
packet_type, packet_data = parse_packet(packet)
|
||||
packet = Packet.from_bytes(data)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if packet_type == PacketType.RESET:
|
||||
if packet.packet_type == Packet.PacketType.RESET:
|
||||
logging.info(color('=== Received RESET', 'magenta'))
|
||||
self.reset()
|
||||
return
|
||||
|
||||
try:
|
||||
packet_flags, packet_index = parse_packet_sequence(packet_data)
|
||||
except ValueError:
|
||||
return
|
||||
interval = (
|
||||
self.receive_times[-1] - self.receive_times[-2]
|
||||
if len(self.receive_times) >= 2
|
||||
else 0
|
||||
)
|
||||
jitter = self.jitter_stats.on_packet_received(packet)
|
||||
logging.info(
|
||||
color(
|
||||
f'<<< Received packet {packet_index}: '
|
||||
f'flags=0x{packet_flags:02X}, {len(packet)} bytes, '
|
||||
f'interval={interval:.4f}',
|
||||
f'<<< Received packet {packet.sequence}: '
|
||||
f'flags={packet.flags}, {len(data)} bytes, '
|
||||
f'jitter={jitter:.4f}',
|
||||
'green',
|
||||
)
|
||||
)
|
||||
|
||||
if packet_index != self.expected_packet_index:
|
||||
if packet.sequence != self.expected_packet_index:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!! Unexpected packet, expected {self.expected_packet_index} '
|
||||
f'but received {packet_index}'
|
||||
f'but received {packet.sequence}'
|
||||
)
|
||||
)
|
||||
|
||||
self.expected_packet_index = packet_index + 1
|
||||
self.expected_packet_index = packet.sequence + 1
|
||||
|
||||
AsyncRunner.spawn(
|
||||
self.packet_io.send_packet(
|
||||
struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index)
|
||||
bytes(Packet(Packet.PacketType.ACK, packet.flags, packet.sequence))
|
||||
)
|
||||
)
|
||||
|
||||
if packet_flags & PACKET_FLAG_LAST:
|
||||
if len(self.receive_times) >= 3:
|
||||
# Show basic stats
|
||||
intervals = [
|
||||
self.receive_times[i + 1] - self.receive_times[i]
|
||||
for i in range(len(self.receive_times) - 1)
|
||||
]
|
||||
log_stats('Packet intervals', intervals, 3)
|
||||
|
||||
# Show a histogram
|
||||
bin_count = 20
|
||||
bins = [0] * bin_count
|
||||
interval_min = min(intervals)
|
||||
interval_max = max(intervals)
|
||||
interval_range = interval_max - interval_min
|
||||
bin_thresholds = [
|
||||
interval_min + i * (interval_range / bin_count)
|
||||
for i in range(bin_count)
|
||||
]
|
||||
for interval in intervals:
|
||||
for i in reversed(range(bin_count)):
|
||||
if interval >= bin_thresholds[i]:
|
||||
bins[i] += 1
|
||||
break
|
||||
for i in range(bin_count):
|
||||
logging.info(f'@@@ >= {bin_thresholds[i]:.4f}: {bins[i]}')
|
||||
if packet.flags & Packet.PacketFlags.LAST:
|
||||
self.jitter_stats.show_stats()
|
||||
|
||||
if not self.linger:
|
||||
self.done.set()
|
||||
@@ -1210,6 +1289,8 @@ class Central(Connection.Listener):
|
||||
logging.info(color('### Connected', 'cyan'))
|
||||
self.connection.listener = self
|
||||
print_connection(self.connection)
|
||||
phy = await self.connection.get_phy()
|
||||
print_connection_phy(phy)
|
||||
|
||||
# Switch roles if needed.
|
||||
if self.role_switch:
|
||||
@@ -1267,8 +1348,8 @@ class Central(Connection.Listener):
|
||||
def on_connection_parameters_update(self):
|
||||
print_connection(self.connection)
|
||||
|
||||
def on_connection_phy_update(self):
|
||||
print_connection(self.connection)
|
||||
def on_connection_phy_update(self, phy):
|
||||
print_connection_phy(phy)
|
||||
|
||||
def on_connection_att_mtu_update(self):
|
||||
print_connection(self.connection)
|
||||
@@ -1394,8 +1475,8 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
def on_connection_parameters_update(self):
|
||||
print_connection(self.connection)
|
||||
|
||||
def on_connection_phy_update(self):
|
||||
print_connection(self.connection)
|
||||
def on_connection_phy_update(self, phy):
|
||||
print_connection_phy(phy)
|
||||
|
||||
def on_connection_att_mtu_update(self):
|
||||
print_connection(self.connection)
|
||||
@@ -1470,7 +1551,7 @@ def create_mode_factory(ctx, default_mode):
|
||||
def create_scenario_factory(ctx, default_scenario):
|
||||
scenario = ctx.obj['scenario']
|
||||
if scenario is None:
|
||||
scenarion = default_scenario
|
||||
scenario = default_scenario
|
||||
|
||||
def create_scenario(packet_io):
|
||||
if scenario == 'send':
|
||||
@@ -1529,6 +1610,7 @@ def create_scenario_factory(ctx, default_scenario):
|
||||
'--att-mtu',
|
||||
metavar='MTU',
|
||||
type=click.IntRange(23, 517),
|
||||
default=517,
|
||||
help='GATT MTU (gatt-client mode)',
|
||||
)
|
||||
@click.option(
|
||||
@@ -1604,7 +1686,7 @@ def create_scenario_factory(ctx, default_scenario):
|
||||
'--packet-size',
|
||||
'-s',
|
||||
metavar='SIZE',
|
||||
type=click.IntRange(8, 8192),
|
||||
type=click.IntRange(10, 8192),
|
||||
default=500,
|
||||
help='Packet size (send or ping scenario)',
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import humanize
|
||||
from typing import Optional, Union
|
||||
@@ -57,7 +56,13 @@ from bumble import __version__
|
||||
import bumble.core
|
||||
from bumble import colors
|
||||
from bumble.core import UUID, AdvertisingData, BT_LE_TRANSPORT
|
||||
from bumble.device import ConnectionParametersPreferences, Device, Connection, Peer
|
||||
from bumble.device import (
|
||||
ConnectionParametersPreferences,
|
||||
ConnectionPHY,
|
||||
Device,
|
||||
Connection,
|
||||
Peer,
|
||||
)
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
|
||||
@@ -125,6 +130,7 @@ def parse_phys(phys):
|
||||
# -----------------------------------------------------------------------------
|
||||
class ConsoleApp:
|
||||
connected_peer: Optional[Peer]
|
||||
connection_phy: Optional[ConnectionPHY]
|
||||
|
||||
def __init__(self):
|
||||
self.known_addresses = set()
|
||||
@@ -132,6 +138,7 @@ class ConsoleApp:
|
||||
self.known_local_attributes = []
|
||||
self.device = None
|
||||
self.connected_peer = None
|
||||
self.connection_phy = None
|
||||
self.top_tab = 'device'
|
||||
self.monitor_rssi = False
|
||||
self.connection_rssi = None
|
||||
@@ -332,10 +339,10 @@ class ConsoleApp:
|
||||
f'{connection.parameters.peripheral_latency}/'
|
||||
f'{connection.parameters.supervision_timeout}'
|
||||
)
|
||||
if connection.transport == BT_LE_TRANSPORT:
|
||||
if self.connection_phy is not None:
|
||||
phy_state = (
|
||||
f' RX={le_phy_name(connection.phy.rx_phy)}/'
|
||||
f'TX={le_phy_name(connection.phy.tx_phy)}'
|
||||
f' RX={le_phy_name(self.connection_phy.rx_phy)}/'
|
||||
f'TX={le_phy_name(self.connection_phy.tx_phy)}'
|
||||
)
|
||||
else:
|
||||
phy_state = ''
|
||||
@@ -654,11 +661,12 @@ class ConsoleApp:
|
||||
self.append_to_output('connecting...')
|
||||
|
||||
try:
|
||||
await self.device.connect(
|
||||
connection = await self.device.connect(
|
||||
params[0],
|
||||
connection_parameters_preferences=connection_parameters_preferences,
|
||||
timeout=DEFAULT_CONNECTION_TIMEOUT,
|
||||
)
|
||||
self.connection_phy = await connection.get_phy()
|
||||
self.top_tab = 'services'
|
||||
except bumble.core.TimeoutError:
|
||||
self.show_error('connection timed out')
|
||||
@@ -838,8 +846,8 @@ class ConsoleApp:
|
||||
|
||||
phy = await self.connected_peer.connection.get_phy()
|
||||
self.append_to_output(
|
||||
f'PHY: RX={HCI_Constant.le_phy_name(phy[0])}, '
|
||||
f'TX={HCI_Constant.le_phy_name(phy[1])}'
|
||||
f'PHY: RX={HCI_Constant.le_phy_name(phy.rx_phy)}, '
|
||||
f'TX={HCI_Constant.le_phy_name(phy.tx_phy)}'
|
||||
)
|
||||
|
||||
async def do_request_mtu(self, params):
|
||||
@@ -1076,10 +1084,9 @@ class DeviceListener(Device.Listener, Connection.Listener):
|
||||
f'{self.app.connected_peer.connection.parameters}'
|
||||
)
|
||||
|
||||
def on_connection_phy_update(self):
|
||||
self.app.append_to_output(
|
||||
f'connection phy update: {self.app.connected_peer.connection.phy}'
|
||||
)
|
||||
def on_connection_phy_update(self, phy):
|
||||
self.app.connection_phy = phy
|
||||
self.app.append_to_output(f'connection phy update: {phy}')
|
||||
|
||||
def on_connection_att_mtu_update(self):
|
||||
self.app.append_to_output(
|
||||
|
||||
@@ -37,6 +37,8 @@ from bumble.hci import (
|
||||
HCI_Command_Status_Event,
|
||||
HCI_READ_BUFFER_SIZE_COMMAND,
|
||||
HCI_Read_Buffer_Size_Command,
|
||||
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
|
||||
HCI_LE_Read_Buffer_Size_V2_Command,
|
||||
HCI_READ_BD_ADDR_COMMAND,
|
||||
HCI_Read_BD_ADDR_Command,
|
||||
HCI_READ_LOCAL_NAME_COMMAND,
|
||||
@@ -75,7 +77,7 @@ async def get_classic_info(host: Host) -> None:
|
||||
if command_succeeded(response):
|
||||
print()
|
||||
print(
|
||||
color('Classic Address:', 'yellow'),
|
||||
color('Public Address:', 'yellow'),
|
||||
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()
|
||||
|
||||
if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
|
||||
@@ -160,14 +162,28 @@ async def get_acl_flow_control_info(host: Host) -> None:
|
||||
f'packets of size {response.return_parameters.hc_acl_data_packet_length}',
|
||||
)
|
||||
|
||||
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
||||
response = await host.send_command(
|
||||
HCI_LE_Read_Buffer_Size_V2_Command(), check_result=True
|
||||
)
|
||||
print(
|
||||
color('LE ACL Flow Control:', 'yellow'),
|
||||
f'{response.return_parameters.total_num_le_acl_data_packets} '
|
||||
f'packets of size {response.return_parameters.le_acl_data_packet_length}',
|
||||
)
|
||||
print(
|
||||
color('LE ISO Flow Control:', 'yellow'),
|
||||
f'{response.return_parameters.total_num_iso_data_packets} '
|
||||
f'packets of size {response.return_parameters.iso_data_packet_length}',
|
||||
)
|
||||
elif host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||
response = await host.send_command(
|
||||
HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
||||
)
|
||||
print(
|
||||
color('LE ACL Flow Control:', 'yellow'),
|
||||
f'{response.return_parameters.hc_total_num_le_acl_data_packets} '
|
||||
f'packets of size {response.return_parameters.hc_le_acl_data_packet_length}',
|
||||
f'{response.return_parameters.total_num_le_acl_data_packets} '
|
||||
f'packets of size {response.return_parameters.le_acl_data_packet_length}',
|
||||
)
|
||||
|
||||
|
||||
@@ -274,8 +290,8 @@ async def async_main(latency_probes, transport):
|
||||
# Get the LE info
|
||||
await get_le_info(host)
|
||||
|
||||
# Print the ACL flow control info
|
||||
await get_acl_flow_control_info(host)
|
||||
# Print the flow control info
|
||||
await get_flow_control_info(host)
|
||||
|
||||
# Get codec info
|
||||
await get_codecs_info(host)
|
||||
|
||||
@@ -29,7 +29,9 @@ from bumble.gatt import Service
|
||||
from bumble.profiles.device_information_service import DeviceInformationServiceProxy
|
||||
from bumble.profiles.battery_service import BatteryServiceProxy
|
||||
from bumble.profiles.gap import GenericAccessServiceProxy
|
||||
from bumble.profiles.pacs import PublishedAudioCapabilitiesServiceProxy
|
||||
from bumble.profiles.tmap import TelephonyAndMediaAudioServiceProxy
|
||||
from bumble.profiles.vcs import VolumeControlServiceProxy
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
|
||||
@@ -126,14 +128,52 @@ async def show_tmas(
|
||||
print(color('### Telephony And Media Audio Service', 'yellow'))
|
||||
|
||||
if tmas.role:
|
||||
print(
|
||||
color(' Role:', 'green'),
|
||||
await tmas.role.read_value(),
|
||||
)
|
||||
role = await tmas.role.read_value()
|
||||
print(color(' Role:', 'green'), role)
|
||||
|
||||
print()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def show_pacs(pacs: PublishedAudioCapabilitiesServiceProxy) -> None:
|
||||
print(color('### Published Audio Capabilities Service', 'yellow'))
|
||||
|
||||
contexts = await pacs.available_audio_contexts.read_value()
|
||||
print(color(' Available Audio Contexts:', 'green'), contexts)
|
||||
|
||||
contexts = await pacs.supported_audio_contexts.read_value()
|
||||
print(color(' Supported Audio Contexts:', 'green'), contexts)
|
||||
|
||||
if pacs.sink_pac:
|
||||
pac = await pacs.sink_pac.read_value()
|
||||
print(color(' Sink PAC: ', 'green'), pac)
|
||||
|
||||
if pacs.sink_audio_locations:
|
||||
audio_locations = await pacs.sink_audio_locations.read_value()
|
||||
print(color(' Sink Audio Locations: ', 'green'), audio_locations)
|
||||
|
||||
if pacs.source_pac:
|
||||
pac = await pacs.source_pac.read_value()
|
||||
print(color(' Source PAC: ', 'green'), pac)
|
||||
|
||||
if pacs.source_audio_locations:
|
||||
audio_locations = await pacs.source_audio_locations.read_value()
|
||||
print(color(' Source Audio Locations: ', 'green'), audio_locations)
|
||||
|
||||
print()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def show_vcs(vcs: VolumeControlServiceProxy) -> None:
|
||||
print(color('### Volume Control Service', 'yellow'))
|
||||
|
||||
volume_state = await vcs.volume_state.read_value()
|
||||
print(color(' Volume State:', 'green'), volume_state)
|
||||
|
||||
volume_flags = await vcs.volume_flags.read_value()
|
||||
print(color(' Volume Flags:', 'green'), volume_flags)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def show_device_info(peer, done: Optional[asyncio.Future]) -> None:
|
||||
try:
|
||||
@@ -161,6 +201,12 @@ async def show_device_info(peer, done: Optional[asyncio.Future]) -> None:
|
||||
if tmas := peer.create_service_proxy(TelephonyAndMediaAudioServiceProxy):
|
||||
await try_show(show_tmas, tmas)
|
||||
|
||||
if pacs := peer.create_service_proxy(PublishedAudioCapabilitiesServiceProxy):
|
||||
await try_show(show_pacs, pacs)
|
||||
|
||||
if vcs := peer.create_service_proxy(VolumeControlServiceProxy):
|
||||
await try_show(show_vcs, vcs)
|
||||
|
||||
if done is not None:
|
||||
done.set_result(None)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -83,7 +83,7 @@ async def async_main():
|
||||
return_parameters=bytes([hci.HCI_SUCCESS]),
|
||||
)
|
||||
# Return a packet with 'respond to sender' set to True
|
||||
return (response.to_bytes(), True)
|
||||
return (bytes(response), True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -16,23 +16,22 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import enum
|
||||
import functools
|
||||
from importlib import resources
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import Optional, List, cast
|
||||
import weakref
|
||||
import struct
|
||||
import wave
|
||||
|
||||
import ctypes
|
||||
import wasmtime
|
||||
import wasmtime.loader
|
||||
import liblc3 # type: ignore
|
||||
try:
|
||||
import lc3 # type: ignore # pylint: disable=E0401
|
||||
except ImportError as e:
|
||||
raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e
|
||||
|
||||
import click
|
||||
import aiohttp.web
|
||||
@@ -40,11 +39,12 @@ import aiohttp.web
|
||||
import bumble
|
||||
from bumble.core import AdvertisingData
|
||||
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.profiles import ascs, bap, pacs
|
||||
from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -54,6 +54,7 @@ logger = logging.getLogger(__name__)
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
DEFAULT_UI_PORT = 7654
|
||||
DEFAULT_PCM_BYTES_PER_SAMPLE = 2
|
||||
|
||||
|
||||
def _sink_pac_record() -> pacs.PacRecord:
|
||||
@@ -100,153 +101,8 @@ def _source_pac_record() -> pacs.PacRecord:
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# WASM - liblc3
|
||||
# -----------------------------------------------------------------------------
|
||||
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]
|
||||
)
|
||||
decoder: lc3.Decoder | None = None
|
||||
encoding_config: bap.CodecSpecificConfiguration | None = None
|
||||
|
||||
|
||||
async def lc3_source_task(
|
||||
@@ -254,44 +110,49 @@ async def lc3_source_task(
|
||||
sdu_length: int,
|
||||
frame_duration_us: int,
|
||||
device: Device,
|
||||
cis_handle: int,
|
||||
cis_link: CisLink,
|
||||
) -> None:
|
||||
with open(filename, 'rb') as f:
|
||||
header = f.read(44)
|
||||
assert header[8:12] == b'WAVE'
|
||||
logger.info(
|
||||
"lc3_source_task filename=%s, sdu_length=%d, frame_duration=%.1f",
|
||||
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 = (
|
||||
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
|
||||
encoder: lc3.Encoder | None = None
|
||||
|
||||
while True:
|
||||
next_round = datetime.datetime.now() + datetime.timedelta(
|
||||
microseconds=frame_duration_us
|
||||
)
|
||||
pcm_data = f.read(frame_bytes)
|
||||
sdu = encode(sdu_length, pcm_num_channel, pcm_num_channel, pcm_data)
|
||||
if not encoder:
|
||||
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()
|
||||
await asyncio.sleep(sleep_time.total_seconds())
|
||||
await asyncio.sleep(sleep_time.total_seconds() * 0.9)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -410,7 +271,7 @@ class Speaker:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_config_path: Optional[str],
|
||||
device_config_path: str | None,
|
||||
ui_port: int,
|
||||
transport: str,
|
||||
lc3_input_file_path: str,
|
||||
@@ -437,6 +298,7 @@ class Speaker:
|
||||
advertising_interval_min=25,
|
||||
advertising_interval_max=25,
|
||||
address=Address('F1:F2:F3:F4:F5:F6'),
|
||||
identity_address_type=Address.RANDOM_DEVICE_ADDRESS,
|
||||
)
|
||||
|
||||
device_config.le_enabled = True
|
||||
@@ -486,20 +348,31 @@ class Speaker:
|
||||
|
||||
def on_pdu(pdu: HCI_IsoDataPacket, ase: ascs.AseStateMachine):
|
||||
codec_config = ase.codec_specific_configuration
|
||||
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||
pcm = decode(
|
||||
codec_config.frame_duration.us,
|
||||
codec_config.audio_channel_allocation.channel_count,
|
||||
pdu.iso_sdu_fragment,
|
||||
if (
|
||||
not isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||
or codec_config.frame_duration is None
|
||||
or codec_config.audio_channel_allocation is None
|
||||
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))
|
||||
|
||||
def on_ase_state_change(ase: ascs.AseStateMachine) -> None:
|
||||
codec_config = ase.codec_specific_configuration
|
||||
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 (
|
||||
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(
|
||||
'disconnection',
|
||||
lc3_source_task(
|
||||
@@ -510,25 +383,30 @@ class Speaker:
|
||||
),
|
||||
frame_duration_us=codec_config.frame_duration.us,
|
||||
device=self.device,
|
||||
cis_handle=ase.cis_link.handle,
|
||||
cis_link=ase.cis_link,
|
||||
),
|
||||
)
|
||||
else:
|
||||
if not ase.cis_link:
|
||||
return
|
||||
ase.cis_link.sink = functools.partial(on_pdu, ase=ase)
|
||||
elif ase.state == ascs.AseStateMachine.State.CODEC_CONFIGURED:
|
||||
codec_config = ase.codec_specific_configuration
|
||||
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
|
||||
if (
|
||||
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:
|
||||
setup_encoders(
|
||||
codec_config.sampling_frequency.hz,
|
||||
codec_config.frame_duration.us,
|
||||
codec_config.audio_channel_allocation.channel_count,
|
||||
)
|
||||
global encoding_config
|
||||
encoding_config = codec_config
|
||||
else:
|
||||
setup_decoders(
|
||||
codec_config.sampling_frequency.hz,
|
||||
codec_config.frame_duration.us,
|
||||
codec_config.audio_channel_allocation.channel_count,
|
||||
global decoder
|
||||
decoder = lc3.Decoder(
|
||||
frame_duration_us=codec_config.frame_duration.us,
|
||||
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():
|
||||
@@ -567,7 +445,7 @@ def speaker(ui_port: int, device_config: str, transport: str, lc3_file: str) ->
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
speaker()
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
21
apps/pair.py
21
apps/pair.py
@@ -373,7 +373,9 @@ async def pair(
|
||||
shared_data = (
|
||||
None
|
||||
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()
|
||||
oob_contexts = PairingConfig.OobConfig(
|
||||
@@ -381,16 +383,19 @@ async def pair(
|
||||
peer_data=shared_data,
|
||||
legacy_context=legacy_context,
|
||||
)
|
||||
oob_data = OobData(
|
||||
address=device.random_address,
|
||||
shared_data=shared_data,
|
||||
legacy_context=legacy_context,
|
||||
)
|
||||
print(color('@@@-----------------------------------', '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'@@@ HEX: ({bytes(oob_data.to_ad()).hex()})', 'yellow'))
|
||||
print(color('@@@-----------------------------------', 'yellow'))
|
||||
else:
|
||||
oob_contexts = None
|
||||
|
||||
608
apps/player/player.py
Normal file
608
apps/player/player.py
Normal file
@@ -0,0 +1,608 @@
|
||||
# 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 asyncio.subprocess
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, Union
|
||||
|
||||
import click
|
||||
|
||||
from bumble.a2dp import (
|
||||
make_audio_source_service_sdp_records,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
A2DP_NON_A2DP_CODEC_TYPE,
|
||||
AacFrame,
|
||||
AacParser,
|
||||
AacPacketSource,
|
||||
AacMediaCodecInformation,
|
||||
SbcFrame,
|
||||
SbcParser,
|
||||
SbcPacketSource,
|
||||
SbcMediaCodecInformation,
|
||||
OpusPacket,
|
||||
OpusParser,
|
||||
OpusPacketSource,
|
||||
OpusMediaCodecInformation,
|
||||
)
|
||||
from bumble.avrcp import Protocol as AvrcpProtocol
|
||||
from bumble.avdtp import (
|
||||
find_avdtp_service_with_connection,
|
||||
AVDTP_AUDIO_MEDIA_TYPE,
|
||||
AVDTP_DELAY_REPORTING_SERVICE_CATEGORY,
|
||||
MediaCodecCapabilities,
|
||||
MediaPacketPump,
|
||||
Protocol as AvdtpProtocol,
|
||||
)
|
||||
from bumble.colors import color
|
||||
from bumble.core import (
|
||||
AdvertisingData,
|
||||
ConnectionError as BumbleConnectionError,
|
||||
DeviceClass,
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
)
|
||||
from bumble.device import Connection, Device, DeviceConfiguration
|
||||
from bumble.hci import Address, HCI_CONNECTION_ALREADY_EXISTS_ERROR, HCI_Constant
|
||||
from bumble.pairing import PairingConfig
|
||||
from bumble.transport import open_transport
|
||||
from bumble.utils import AsyncRunner
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def a2dp_source_sdp_records():
|
||||
service_record_handle = 0x00010001
|
||||
return {
|
||||
service_record_handle: make_audio_source_service_sdp_records(
|
||||
service_record_handle
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def sbc_codec_capabilities(read_function) -> MediaCodecCapabilities:
|
||||
sbc_parser = SbcParser(read_function)
|
||||
sbc_frame: SbcFrame
|
||||
async for sbc_frame in sbc_parser.frames:
|
||||
# We only need the first frame
|
||||
print(color(f"SBC format: {sbc_frame}", "cyan"))
|
||||
break
|
||||
|
||||
channel_mode = [
|
||||
SbcMediaCodecInformation.ChannelMode.MONO,
|
||||
SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL,
|
||||
SbcMediaCodecInformation.ChannelMode.STEREO,
|
||||
SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
][sbc_frame.channel_mode]
|
||||
block_length = {
|
||||
4: SbcMediaCodecInformation.BlockLength.BL_4,
|
||||
8: SbcMediaCodecInformation.BlockLength.BL_8,
|
||||
12: SbcMediaCodecInformation.BlockLength.BL_12,
|
||||
16: SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
}[sbc_frame.block_count]
|
||||
subbands = {
|
||||
4: SbcMediaCodecInformation.Subbands.S_4,
|
||||
8: SbcMediaCodecInformation.Subbands.S_8,
|
||||
}[sbc_frame.subband_count]
|
||||
allocation_method = [
|
||||
SbcMediaCodecInformation.AllocationMethod.LOUDNESS,
|
||||
SbcMediaCodecInformation.AllocationMethod.SNR,
|
||||
][sbc_frame.allocation_method]
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation(
|
||||
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.from_int(
|
||||
sbc_frame.sampling_frequency
|
||||
),
|
||||
channel_mode=channel_mode,
|
||||
block_length=block_length,
|
||||
subbands=subbands,
|
||||
allocation_method=allocation_method,
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=40,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def aac_codec_capabilities(read_function) -> MediaCodecCapabilities:
|
||||
aac_parser = AacParser(read_function)
|
||||
aac_frame: AacFrame
|
||||
async for aac_frame in aac_parser.frames:
|
||||
# We only need the first frame
|
||||
print(color(f"AAC format: {aac_frame}", "cyan"))
|
||||
break
|
||||
|
||||
sampling_frequency = AacMediaCodecInformation.SamplingFrequency.from_int(
|
||||
aac_frame.sampling_frequency
|
||||
)
|
||||
channels = (
|
||||
AacMediaCodecInformation.Channels.MONO
|
||||
if aac_frame.channel_configuration == 1
|
||||
else AacMediaCodecInformation.Channels.STEREO
|
||||
)
|
||||
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
media_codec_information=AacMediaCodecInformation(
|
||||
object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
|
||||
sampling_frequency=sampling_frequency,
|
||||
channels=channels,
|
||||
vbr=1,
|
||||
bitrate=128000,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def opus_codec_capabilities(read_function) -> MediaCodecCapabilities:
|
||||
opus_parser = OpusParser(read_function)
|
||||
opus_packet: OpusPacket
|
||||
async for opus_packet in opus_parser.packets:
|
||||
# We only need the first packet
|
||||
print(color(f"Opus format: {opus_packet}", "cyan"))
|
||||
break
|
||||
|
||||
if opus_packet.channel_mode == OpusPacket.ChannelMode.MONO:
|
||||
channel_mode = OpusMediaCodecInformation.ChannelMode.MONO
|
||||
elif opus_packet.channel_mode == OpusPacket.ChannelMode.STEREO:
|
||||
channel_mode = OpusMediaCodecInformation.ChannelMode.STEREO
|
||||
else:
|
||||
channel_mode = OpusMediaCodecInformation.ChannelMode.DUAL_MONO
|
||||
|
||||
if opus_packet.duration == 10:
|
||||
frame_size = OpusMediaCodecInformation.FrameSize.FS_10MS
|
||||
else:
|
||||
frame_size = OpusMediaCodecInformation.FrameSize.FS_20MS
|
||||
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_NON_A2DP_CODEC_TYPE,
|
||||
media_codec_information=OpusMediaCodecInformation(
|
||||
channel_mode=channel_mode,
|
||||
sampling_frequency=OpusMediaCodecInformation.SamplingFrequency.SF_48000,
|
||||
frame_size=frame_size,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Player:
|
||||
def __init__(
|
||||
self,
|
||||
transport: str,
|
||||
device_config: Optional[str],
|
||||
authenticate: bool,
|
||||
encrypt: bool,
|
||||
) -> None:
|
||||
self.transport = transport
|
||||
self.device_config = device_config
|
||||
self.authenticate = authenticate
|
||||
self.encrypt = encrypt
|
||||
self.avrcp_protocol: Optional[AvrcpProtocol] = None
|
||||
self.done: Optional[asyncio.Event]
|
||||
|
||||
async def run(self, workload) -> None:
|
||||
self.done = asyncio.Event()
|
||||
try:
|
||||
await self._run(workload)
|
||||
except Exception as error:
|
||||
print(color(f"!!! ERROR: {error}", "red"))
|
||||
|
||||
async def _run(self, workload) -> None:
|
||||
async with await open_transport(self.transport) as (hci_source, hci_sink):
|
||||
# Create a device
|
||||
device_config = DeviceConfiguration()
|
||||
if self.device_config:
|
||||
device_config.load_from_file(self.device_config)
|
||||
else:
|
||||
device_config.name = "Bumble Player"
|
||||
device_config.class_of_device = DeviceClass.pack_class_of_device(
|
||||
DeviceClass.AUDIO_SERVICE_CLASS,
|
||||
DeviceClass.AUDIO_VIDEO_MAJOR_DEVICE_CLASS,
|
||||
DeviceClass.AUDIO_VIDEO_UNCATEGORIZED_MINOR_DEVICE_CLASS,
|
||||
)
|
||||
device_config.keystore = "JsonKeyStore"
|
||||
|
||||
device_config.classic_enabled = True
|
||||
device_config.le_enabled = False
|
||||
device_config.le_simultaneous_enabled = False
|
||||
device_config.classic_sc_enabled = False
|
||||
device_config.classic_smp_enabled = False
|
||||
device = Device.from_config_with_hci(device_config, hci_source, hci_sink)
|
||||
|
||||
# Setup the SDP records to expose the SRC service
|
||||
device.sdp_service_records = a2dp_source_sdp_records()
|
||||
|
||||
# Setup AVRCP
|
||||
self.avrcp_protocol = AvrcpProtocol()
|
||||
self.avrcp_protocol.listen(device)
|
||||
|
||||
# Don't require MITM when pairing.
|
||||
device.pairing_config_factory = lambda connection: PairingConfig(mitm=False)
|
||||
|
||||
# Start the controller
|
||||
await device.power_on()
|
||||
|
||||
# Print some of the config/properties
|
||||
print(
|
||||
"Player Bluetooth Address:",
|
||||
color(
|
||||
device.public_address.to_string(with_type_qualifier=False),
|
||||
"yellow",
|
||||
),
|
||||
)
|
||||
|
||||
# Listen for connections
|
||||
device.on("connection", self.on_bluetooth_connection)
|
||||
|
||||
# Run the workload
|
||||
try:
|
||||
await workload(device)
|
||||
except BumbleConnectionError as error:
|
||||
if error.error_code == HCI_CONNECTION_ALREADY_EXISTS_ERROR:
|
||||
print(color("Connection already established", "blue"))
|
||||
else:
|
||||
print(color(f"Failed to connect: {error}", "red"))
|
||||
|
||||
# Wait until it is time to exit
|
||||
assert self.done is not None
|
||||
await asyncio.wait(
|
||||
[hci_source.terminated, asyncio.ensure_future(self.done.wait())],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
def on_bluetooth_connection(self, connection: Connection) -> None:
|
||||
print(color(f"--- Connected: {connection}", "cyan"))
|
||||
connection.on("disconnection", self.on_bluetooth_disconnection)
|
||||
|
||||
def on_bluetooth_disconnection(self, reason) -> None:
|
||||
print(color(f"--- Disconnected: {HCI_Constant.error_name(reason)}", "cyan"))
|
||||
self.set_done()
|
||||
|
||||
async def connect(self, device: Device, address: str) -> Connection:
|
||||
print(color(f"Connecting to {address}...", "green"))
|
||||
connection = await device.connect(address, transport=BT_BR_EDR_TRANSPORT)
|
||||
|
||||
# Request authentication
|
||||
if self.authenticate:
|
||||
print(color("*** Authenticating...", "blue"))
|
||||
await connection.authenticate()
|
||||
print(color("*** Authenticated", "blue"))
|
||||
|
||||
# Enable encryption
|
||||
if self.encrypt:
|
||||
print(color("*** Enabling encryption...", "blue"))
|
||||
await connection.encrypt()
|
||||
print(color("*** Encryption on", "blue"))
|
||||
|
||||
return connection
|
||||
|
||||
async def create_avdtp_protocol(self, connection: Connection) -> AvdtpProtocol:
|
||||
# Look for an A2DP service
|
||||
avdtp_version = await find_avdtp_service_with_connection(connection)
|
||||
if not avdtp_version:
|
||||
raise RuntimeError("no A2DP service found")
|
||||
|
||||
print(color(f"AVDTP Version: {avdtp_version}"))
|
||||
|
||||
# Create a client to interact with the remote device
|
||||
return await AvdtpProtocol.connect(connection, avdtp_version)
|
||||
|
||||
async def stream_packets(
|
||||
self,
|
||||
protocol: AvdtpProtocol,
|
||||
codec_type: int,
|
||||
vendor_id: int,
|
||||
codec_id: int,
|
||||
packet_source: Union[SbcPacketSource, AacPacketSource, OpusPacketSource],
|
||||
codec_capabilities: MediaCodecCapabilities,
|
||||
):
|
||||
# Discover all endpoints on the remote device
|
||||
endpoints = await protocol.discover_remote_endpoints()
|
||||
for endpoint in endpoints:
|
||||
print('@@@', endpoint)
|
||||
|
||||
# Select a sink
|
||||
sink = protocol.find_remote_sink_by_codec(
|
||||
AVDTP_AUDIO_MEDIA_TYPE, codec_type, vendor_id, codec_id
|
||||
)
|
||||
if sink is None:
|
||||
print(color('!!! no compatible sink found', 'red'))
|
||||
return
|
||||
print(f'### Selected sink: {sink.seid}')
|
||||
|
||||
# Check if the sink supports delay reporting
|
||||
delay_reporting = False
|
||||
for capability in sink.capabilities:
|
||||
if capability.service_category == AVDTP_DELAY_REPORTING_SERVICE_CATEGORY:
|
||||
delay_reporting = True
|
||||
break
|
||||
|
||||
def on_delay_report(delay: int):
|
||||
print(color(f"*** DELAY REPORT: {delay}", "blue"))
|
||||
|
||||
# Adjust the codec capabilities for certain codecs
|
||||
for capability in sink.capabilities:
|
||||
if isinstance(capability, MediaCodecCapabilities):
|
||||
if isinstance(
|
||||
codec_capabilities.media_codec_information, SbcMediaCodecInformation
|
||||
) and isinstance(
|
||||
capability.media_codec_information, SbcMediaCodecInformation
|
||||
):
|
||||
codec_capabilities.media_codec_information.minimum_bitpool_value = (
|
||||
capability.media_codec_information.minimum_bitpool_value
|
||||
)
|
||||
codec_capabilities.media_codec_information.maximum_bitpool_value = (
|
||||
capability.media_codec_information.maximum_bitpool_value
|
||||
)
|
||||
print(color("Source media codec:", "green"), codec_capabilities)
|
||||
|
||||
# Stream the packets
|
||||
packet_pump = MediaPacketPump(packet_source.packets)
|
||||
source = protocol.add_source(codec_capabilities, packet_pump, delay_reporting)
|
||||
source.on("delay_report", on_delay_report)
|
||||
stream = await protocol.create_stream(source, sink)
|
||||
await stream.start()
|
||||
|
||||
await packet_pump.wait_for_completion()
|
||||
|
||||
async def discover(self, device: Device) -> None:
|
||||
@device.listens_to("inquiry_result")
|
||||
def on_inquiry_result(
|
||||
address: Address, class_of_device: int, data: AdvertisingData, rssi: int
|
||||
) -> None:
|
||||
(
|
||||
service_classes,
|
||||
major_device_class,
|
||||
minor_device_class,
|
||||
) = DeviceClass.split_class_of_device(class_of_device)
|
||||
separator = "\n "
|
||||
print(f">>> {color(address.to_string(False), 'yellow')}:")
|
||||
print(f" Device Class (raw): {class_of_device:06X}")
|
||||
major_class_name = DeviceClass.major_device_class_name(major_device_class)
|
||||
print(" Device Major Class: " f"{major_class_name}")
|
||||
minor_class_name = DeviceClass.minor_device_class_name(
|
||||
major_device_class, minor_device_class
|
||||
)
|
||||
print(" Device Minor Class: " f"{minor_class_name}")
|
||||
print(
|
||||
" Device Services: "
|
||||
f"{', '.join(DeviceClass.service_class_labels(service_classes))}"
|
||||
)
|
||||
print(f" RSSI: {rssi}")
|
||||
if data.ad_structures:
|
||||
print(f" {data.to_string(separator)}")
|
||||
|
||||
await device.start_discovery()
|
||||
|
||||
async def pair(self, device: Device, address: str) -> None:
|
||||
print(color(f"Connecting to {address}...", "green"))
|
||||
connection = await device.connect(address, transport=BT_BR_EDR_TRANSPORT)
|
||||
|
||||
print(color("Pairing...", "magenta"))
|
||||
await connection.authenticate()
|
||||
print(color("Pairing completed", "magenta"))
|
||||
self.set_done()
|
||||
|
||||
async def inquire(self, device: Device, address: str) -> None:
|
||||
connection = await self.connect(device, address)
|
||||
avdtp_protocol = await self.create_avdtp_protocol(connection)
|
||||
|
||||
# Discover the remote endpoints
|
||||
endpoints = await avdtp_protocol.discover_remote_endpoints()
|
||||
print(f'@@@ Found {len(list(endpoints))} endpoints')
|
||||
for endpoint in endpoints:
|
||||
print('@@@', endpoint)
|
||||
|
||||
self.set_done()
|
||||
|
||||
async def play(
|
||||
self,
|
||||
device: Device,
|
||||
address: Optional[str],
|
||||
audio_format: str,
|
||||
audio_file: str,
|
||||
) -> None:
|
||||
if audio_format == "auto":
|
||||
if audio_file.endswith(".sbc"):
|
||||
audio_format = "sbc"
|
||||
elif audio_file.endswith(".aac") or audio_file.endswith(".adts"):
|
||||
audio_format = "aac"
|
||||
elif audio_file.endswith(".ogg"):
|
||||
audio_format = "opus"
|
||||
else:
|
||||
raise ValueError("Unable to determine audio format from file extension")
|
||||
|
||||
device.on(
|
||||
"connection",
|
||||
lambda connection: AsyncRunner.spawn(on_connection(connection)),
|
||||
)
|
||||
|
||||
async def on_connection(connection: Connection):
|
||||
avdtp_protocol = await self.create_avdtp_protocol(connection)
|
||||
|
||||
with open(audio_file, 'rb') as input_file:
|
||||
# NOTE: this should be using asyncio file reading, but blocking reads
|
||||
# are good enough for this command line app.
|
||||
async def read_audio_data(byte_count):
|
||||
return input_file.read(byte_count)
|
||||
|
||||
# Obtain the codec capabilities from the stream
|
||||
packet_source: Union[SbcPacketSource, AacPacketSource, OpusPacketSource]
|
||||
vendor_id = 0
|
||||
codec_id = 0
|
||||
if audio_format == "sbc":
|
||||
codec_type = A2DP_SBC_CODEC_TYPE
|
||||
codec_capabilities = await sbc_codec_capabilities(read_audio_data)
|
||||
packet_source = SbcPacketSource(
|
||||
read_audio_data,
|
||||
avdtp_protocol.l2cap_channel.peer_mtu,
|
||||
)
|
||||
elif audio_format == "aac":
|
||||
codec_type = A2DP_MPEG_2_4_AAC_CODEC_TYPE
|
||||
codec_capabilities = await aac_codec_capabilities(read_audio_data)
|
||||
packet_source = AacPacketSource(
|
||||
read_audio_data,
|
||||
avdtp_protocol.l2cap_channel.peer_mtu,
|
||||
)
|
||||
else:
|
||||
codec_type = A2DP_NON_A2DP_CODEC_TYPE
|
||||
vendor_id = OpusMediaCodecInformation.VENDOR_ID
|
||||
codec_id = OpusMediaCodecInformation.CODEC_ID
|
||||
codec_capabilities = await opus_codec_capabilities(read_audio_data)
|
||||
packet_source = OpusPacketSource(
|
||||
read_audio_data,
|
||||
avdtp_protocol.l2cap_channel.peer_mtu,
|
||||
)
|
||||
|
||||
# Rewind to the start
|
||||
input_file.seek(0)
|
||||
|
||||
try:
|
||||
await self.stream_packets(
|
||||
avdtp_protocol,
|
||||
codec_type,
|
||||
vendor_id,
|
||||
codec_id,
|
||||
packet_source,
|
||||
codec_capabilities,
|
||||
)
|
||||
except Exception as error:
|
||||
print(color(f"!!! Error while streaming: {error}", "red"))
|
||||
|
||||
self.set_done()
|
||||
|
||||
if address:
|
||||
await self.connect(device, address)
|
||||
else:
|
||||
print(color("Waiting for an incoming connection...", "magenta"))
|
||||
|
||||
def set_done(self) -> None:
|
||||
if self.done:
|
||||
self.done.set()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def create_player(context) -> Player:
|
||||
return Player(
|
||||
transport=context.obj["hci_transport"],
|
||||
device_config=context.obj["device_config"],
|
||||
authenticate=context.obj["authenticate"],
|
||||
encrypt=context.obj["encrypt"],
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@click.group()
|
||||
@click.pass_context
|
||||
@click.option("--hci-transport", metavar="TRANSPORT", required=True)
|
||||
@click.option("--device-config", metavar="FILENAME", help="Device configuration file")
|
||||
@click.option(
|
||||
"--authenticate",
|
||||
is_flag=True,
|
||||
help="Request authentication when connecting",
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--encrypt", is_flag=True, help="Request encryption when connecting", default=True
|
||||
)
|
||||
def player_cli(ctx, hci_transport, device_config, authenticate, encrypt):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["hci_transport"] = hci_transport
|
||||
ctx.obj["device_config"] = device_config
|
||||
ctx.obj["authenticate"] = authenticate
|
||||
ctx.obj["encrypt"] = encrypt
|
||||
|
||||
|
||||
@player_cli.command("discover")
|
||||
@click.pass_context
|
||||
def discover(context):
|
||||
"""Discover speakers or headphones"""
|
||||
player = create_player(context)
|
||||
asyncio.run(player.run(player.discover))
|
||||
|
||||
|
||||
@player_cli.command("inquire")
|
||||
@click.pass_context
|
||||
@click.argument(
|
||||
"address",
|
||||
metavar="ADDRESS",
|
||||
)
|
||||
def inquire(context, address):
|
||||
"""Connect to a speaker or headphone and inquire about their capabilities"""
|
||||
player = create_player(context)
|
||||
asyncio.run(player.run(lambda device: player.inquire(device, address)))
|
||||
|
||||
|
||||
@player_cli.command("pair")
|
||||
@click.pass_context
|
||||
@click.argument(
|
||||
"address",
|
||||
metavar="ADDRESS",
|
||||
)
|
||||
def pair(context, address):
|
||||
"""Pair with a speaker or headphone"""
|
||||
player = create_player(context)
|
||||
asyncio.run(player.run(lambda device: player.pair(device, address)))
|
||||
|
||||
|
||||
@player_cli.command("play")
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--connect",
|
||||
"address",
|
||||
metavar="ADDRESS",
|
||||
help="Address or name to connect to",
|
||||
)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--audio-format",
|
||||
type=click.Choice(["auto", "sbc", "aac", "opus"]),
|
||||
help="Audio file format (use 'auto' to infer the format from the file extension)",
|
||||
default="auto",
|
||||
)
|
||||
@click.argument("audio_file")
|
||||
def play(context, address, audio_format, audio_file):
|
||||
"""Play and audio file"""
|
||||
player = create_player(context)
|
||||
asyncio.run(
|
||||
player.run(
|
||||
lambda device: player.play(device, address, audio_format, audio_file)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def main():
|
||||
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
|
||||
player_cli()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
main() # pylint: disable=no-value-for-parameter
|
||||
12
apps/show.py
12
apps/show.py
@@ -144,18 +144,18 @@ class Printer:
|
||||
help='Format of the input file',
|
||||
)
|
||||
@click.option(
|
||||
'--vendors',
|
||||
'--vendor',
|
||||
type=click.Choice(['android', 'zephyr']),
|
||||
multiple=True,
|
||||
help='Support vendor-specific commands (list one or more)',
|
||||
)
|
||||
@click.argument('filename')
|
||||
# pylint: disable=redefined-builtin
|
||||
def main(format, vendors, filename):
|
||||
for vendor in vendors:
|
||||
if vendor == 'android':
|
||||
def main(format, vendor, filename):
|
||||
for vendor_name in vendor:
|
||||
if vendor_name == 'android':
|
||||
import bumble.vendor.android.hci
|
||||
elif vendor == 'zephyr':
|
||||
elif vendor_name == 'zephyr':
|
||||
import bumble.vendor.zephyr.hci
|
||||
|
||||
input = open(filename, 'rb')
|
||||
@@ -180,7 +180,7 @@ def main(format, vendors, filename):
|
||||
else:
|
||||
printer.print(color("[TRUNCATED]", "red"))
|
||||
except Exception as error:
|
||||
logger.exception()
|
||||
logger.exception('')
|
||||
print(color(f'!!! {error}', 'red'))
|
||||
|
||||
|
||||
|
||||
@@ -44,25 +44,18 @@ from bumble.avdtp import (
|
||||
AVDTP_AUDIO_MEDIA_TYPE,
|
||||
Listener,
|
||||
MediaCodecCapabilities,
|
||||
MediaPacket,
|
||||
Protocol,
|
||||
)
|
||||
from bumble.a2dp import (
|
||||
MPEG_2_AAC_LC_OBJECT_TYPE,
|
||||
make_audio_sink_service_sdp_records,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
SbcMediaCodecInformation,
|
||||
AacMediaCodecInformation,
|
||||
)
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.codecs import AacAudioRtpPacket
|
||||
from bumble.rtp import MediaPacket
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -93,7 +86,7 @@ class AudioExtractor:
|
||||
# -----------------------------------------------------------------------------
|
||||
class AacAudioExtractor:
|
||||
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||
return AacAudioRtpPacket(packet.payload).to_adts()
|
||||
return AacAudioRtpPacket.from_bytes(packet.payload).to_adts()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -451,10 +444,12 @@ class Speaker:
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
media_codec_information=AacMediaCodecInformation.from_lists(
|
||||
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
|
||||
sampling_frequencies=[48000, 44100],
|
||||
channels=[1, 2],
|
||||
media_codec_information=AacMediaCodecInformation(
|
||||
object_type=AacMediaCodecInformation.ObjectType.MPEG_2_AAC_LC,
|
||||
sampling_frequency=AacMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| AacMediaCodecInformation.SamplingFrequency.SF_44100,
|
||||
channels=AacMediaCodecInformation.Channels.MONO
|
||||
| AacMediaCodecInformation.Channels.STEREO,
|
||||
vbr=1,
|
||||
bitrate=256000,
|
||||
),
|
||||
@@ -464,20 +459,23 @@ class Speaker:
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation.from_lists(
|
||||
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||
channel_modes=[
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
],
|
||||
block_lengths=[4, 8, 12, 16],
|
||||
subbands=[4, 8],
|
||||
allocation_methods=[
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
],
|
||||
media_codec_information=SbcMediaCodecInformation(
|
||||
sampling_frequency=SbcMediaCodecInformation.SamplingFrequency.SF_48000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_44100
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_32000
|
||||
| SbcMediaCodecInformation.SamplingFrequency.SF_16000,
|
||||
channel_mode=SbcMediaCodecInformation.ChannelMode.MONO
|
||||
| SbcMediaCodecInformation.ChannelMode.DUAL_CHANNEL
|
||||
| SbcMediaCodecInformation.ChannelMode.STEREO
|
||||
| SbcMediaCodecInformation.ChannelMode.JOINT_STEREO,
|
||||
block_length=SbcMediaCodecInformation.BlockLength.BL_4
|
||||
| SbcMediaCodecInformation.BlockLength.BL_8
|
||||
| SbcMediaCodecInformation.BlockLength.BL_12
|
||||
| SbcMediaCodecInformation.BlockLength.BL_16,
|
||||
subbands=SbcMediaCodecInformation.Subbands.S_4
|
||||
| SbcMediaCodecInformation.Subbands.S_8,
|
||||
allocation_method=SbcMediaCodecInformation.AllocationMethod.LOUDNESS
|
||||
| SbcMediaCodecInformation.AllocationMethod.SNR,
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=53,
|
||||
),
|
||||
|
||||
708
bumble/a2dp.py
708
bumble/a2dp.py
@@ -17,12 +17,16 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import struct
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import List, Callable, Awaitable
|
||||
import dataclasses
|
||||
import enum
|
||||
import logging
|
||||
import struct
|
||||
from typing import Awaitable, Callable
|
||||
from typing_extensions import ClassVar, Self
|
||||
|
||||
|
||||
from .codecs import AacAudioRtpPacket
|
||||
from .company_ids import COMPANY_IDENTIFIERS
|
||||
from .sdp import (
|
||||
DataElement,
|
||||
@@ -42,6 +46,7 @@ from .core import (
|
||||
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
||||
name_or_number,
|
||||
)
|
||||
from .rtp import MediaPacket
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -103,6 +108,8 @@ SBC_ALLOCATION_METHOD_NAMES = {
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD: 'SBC_LOUDNESS_ALLOCATION_METHOD'
|
||||
}
|
||||
|
||||
SBC_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
||||
|
||||
MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [
|
||||
8000,
|
||||
11025,
|
||||
@@ -130,6 +137,9 @@ MPEG_2_4_OBJECT_TYPE_NAMES = {
|
||||
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 'MPEG_4_AAC_SCALABLE_OBJECT_TYPE'
|
||||
}
|
||||
|
||||
|
||||
OPUS_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
||||
|
||||
# fmt: on
|
||||
|
||||
|
||||
@@ -257,38 +267,61 @@ class SbcMediaCodecInformation:
|
||||
A2DP spec - 4.3.2 Codec Specific Information Elements
|
||||
'''
|
||||
|
||||
sampling_frequency: int
|
||||
channel_mode: int
|
||||
block_length: int
|
||||
subbands: int
|
||||
allocation_method: int
|
||||
sampling_frequency: SamplingFrequency
|
||||
channel_mode: ChannelMode
|
||||
block_length: BlockLength
|
||||
subbands: Subbands
|
||||
allocation_method: AllocationMethod
|
||||
minimum_bitpool_value: int
|
||||
maximum_bitpool_value: int
|
||||
|
||||
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
|
||||
CHANNEL_MODE_BITS = {
|
||||
SBC_MONO_CHANNEL_MODE: 1 << 3,
|
||||
SBC_DUAL_CHANNEL_MODE: 1 << 2,
|
||||
SBC_STEREO_CHANNEL_MODE: 1 << 1,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE: 1,
|
||||
}
|
||||
BLOCK_LENGTH_BITS = {4: 1 << 3, 8: 1 << 2, 12: 1 << 1, 16: 1}
|
||||
SUBBANDS_BITS = {4: 1 << 1, 8: 1}
|
||||
ALLOCATION_METHOD_BITS = {
|
||||
SBC_SNR_ALLOCATION_METHOD: 1 << 1,
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD: 1,
|
||||
}
|
||||
class SamplingFrequency(enum.IntFlag):
|
||||
SF_16000 = 1 << 3
|
||||
SF_32000 = 1 << 2
|
||||
SF_44100 = 1 << 1
|
||||
SF_48000 = 1 << 0
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> SbcMediaCodecInformation:
|
||||
sampling_frequency = (data[0] >> 4) & 0x0F
|
||||
channel_mode = (data[0] >> 0) & 0x0F
|
||||
block_length = (data[1] >> 4) & 0x0F
|
||||
subbands = (data[1] >> 2) & 0x03
|
||||
allocation_method = (data[1] >> 0) & 0x03
|
||||
@classmethod
|
||||
def from_int(cls, sampling_frequency: int) -> Self:
|
||||
sampling_frequencies = [
|
||||
16000,
|
||||
32000,
|
||||
44100,
|
||||
48000,
|
||||
]
|
||||
index = sampling_frequencies.index(sampling_frequency)
|
||||
return cls(1 << (len(sampling_frequencies) - index - 1))
|
||||
|
||||
class ChannelMode(enum.IntFlag):
|
||||
MONO = 1 << 3
|
||||
DUAL_CHANNEL = 1 << 2
|
||||
STEREO = 1 << 1
|
||||
JOINT_STEREO = 1 << 0
|
||||
|
||||
class BlockLength(enum.IntFlag):
|
||||
BL_4 = 1 << 3
|
||||
BL_8 = 1 << 2
|
||||
BL_12 = 1 << 1
|
||||
BL_16 = 1 << 0
|
||||
|
||||
class Subbands(enum.IntFlag):
|
||||
S_4 = 1 << 1
|
||||
S_8 = 1 << 0
|
||||
|
||||
class AllocationMethod(enum.IntFlag):
|
||||
SNR = 1 << 1
|
||||
LOUDNESS = 1 << 0
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Self:
|
||||
sampling_frequency = cls.SamplingFrequency((data[0] >> 4) & 0x0F)
|
||||
channel_mode = cls.ChannelMode((data[0] >> 0) & 0x0F)
|
||||
block_length = cls.BlockLength((data[1] >> 4) & 0x0F)
|
||||
subbands = cls.Subbands((data[1] >> 2) & 0x03)
|
||||
allocation_method = cls.AllocationMethod((data[1] >> 0) & 0x03)
|
||||
minimum_bitpool_value = (data[2] >> 0) & 0xFF
|
||||
maximum_bitpool_value = (data[3] >> 0) & 0xFF
|
||||
return SbcMediaCodecInformation(
|
||||
return cls(
|
||||
sampling_frequency,
|
||||
channel_mode,
|
||||
block_length,
|
||||
@@ -298,52 +331,6 @@ class SbcMediaCodecInformation:
|
||||
maximum_bitpool_value,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_discrete_values(
|
||||
cls,
|
||||
sampling_frequency: int,
|
||||
channel_mode: int,
|
||||
block_length: int,
|
||||
subbands: int,
|
||||
allocation_method: int,
|
||||
minimum_bitpool_value: int,
|
||||
maximum_bitpool_value: int,
|
||||
) -> SbcMediaCodecInformation:
|
||||
return SbcMediaCodecInformation(
|
||||
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
|
||||
channel_mode=cls.CHANNEL_MODE_BITS[channel_mode],
|
||||
block_length=cls.BLOCK_LENGTH_BITS[block_length],
|
||||
subbands=cls.SUBBANDS_BITS[subbands],
|
||||
allocation_method=cls.ALLOCATION_METHOD_BITS[allocation_method],
|
||||
minimum_bitpool_value=minimum_bitpool_value,
|
||||
maximum_bitpool_value=maximum_bitpool_value,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_lists(
|
||||
cls,
|
||||
sampling_frequencies: List[int],
|
||||
channel_modes: List[int],
|
||||
block_lengths: List[int],
|
||||
subbands: List[int],
|
||||
allocation_methods: List[int],
|
||||
minimum_bitpool_value: int,
|
||||
maximum_bitpool_value: int,
|
||||
) -> SbcMediaCodecInformation:
|
||||
return SbcMediaCodecInformation(
|
||||
sampling_frequency=sum(
|
||||
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
|
||||
),
|
||||
channel_mode=sum(cls.CHANNEL_MODE_BITS[x] for x in channel_modes),
|
||||
block_length=sum(cls.BLOCK_LENGTH_BITS[x] for x in block_lengths),
|
||||
subbands=sum(cls.SUBBANDS_BITS[x] for x in subbands),
|
||||
allocation_method=sum(
|
||||
cls.ALLOCATION_METHOD_BITS[x] for x in allocation_methods
|
||||
),
|
||||
minimum_bitpool_value=minimum_bitpool_value,
|
||||
maximum_bitpool_value=maximum_bitpool_value,
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes(
|
||||
[
|
||||
@@ -356,23 +343,6 @@ class SbcMediaCodecInformation:
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
|
||||
allocation_methods = ['SNR', 'Loudness']
|
||||
return '\n'.join(
|
||||
# pylint: disable=line-too-long
|
||||
[
|
||||
'SbcMediaCodecInformation(',
|
||||
f' sampling_frequency: {",".join([str(x) for x in flags_to_list(self.sampling_frequency, SBC_SAMPLING_FREQUENCIES)])}',
|
||||
f' channel_mode: {",".join([str(x) for x in flags_to_list(self.channel_mode, channel_modes)])}',
|
||||
f' block_length: {",".join([str(x) for x in flags_to_list(self.block_length, SBC_BLOCK_LENGTHS)])}',
|
||||
f' subbands: {",".join([str(x) for x in flags_to_list(self.subbands, SBC_SUBBANDS)])}',
|
||||
f' allocation_method: {",".join([str(x) for x in flags_to_list(self.allocation_method, allocation_methods)])}',
|
||||
f' minimum_bitpool_value: {self.minimum_bitpool_value}',
|
||||
f' maximum_bitpool_value: {self.maximum_bitpool_value}' ')',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
@@ -381,83 +351,66 @@ class AacMediaCodecInformation:
|
||||
A2DP spec - 4.5.2 Codec Specific Information Elements
|
||||
'''
|
||||
|
||||
object_type: int
|
||||
sampling_frequency: int
|
||||
channels: int
|
||||
rfa: int
|
||||
object_type: ObjectType
|
||||
sampling_frequency: SamplingFrequency
|
||||
channels: Channels
|
||||
vbr: int
|
||||
bitrate: int
|
||||
|
||||
OBJECT_TYPE_BITS = {
|
||||
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
|
||||
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
|
||||
MPEG_4_AAC_LTP_OBJECT_TYPE: 1 << 5,
|
||||
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 1 << 4,
|
||||
}
|
||||
SAMPLING_FREQUENCY_BITS = {
|
||||
8000: 1 << 11,
|
||||
11025: 1 << 10,
|
||||
12000: 1 << 9,
|
||||
16000: 1 << 8,
|
||||
22050: 1 << 7,
|
||||
24000: 1 << 6,
|
||||
32000: 1 << 5,
|
||||
44100: 1 << 4,
|
||||
48000: 1 << 3,
|
||||
64000: 1 << 2,
|
||||
88200: 1 << 1,
|
||||
96000: 1,
|
||||
}
|
||||
CHANNELS_BITS = {1: 1 << 1, 2: 1}
|
||||
class ObjectType(enum.IntFlag):
|
||||
MPEG_2_AAC_LC = 1 << 7
|
||||
MPEG_4_AAC_LC = 1 << 6
|
||||
MPEG_4_AAC_LTP = 1 << 5
|
||||
MPEG_4_AAC_SCALABLE = 1 << 4
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> AacMediaCodecInformation:
|
||||
object_type = data[0]
|
||||
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
|
||||
channels = (data[2] >> 2) & 0x03
|
||||
rfa = 0
|
||||
class SamplingFrequency(enum.IntFlag):
|
||||
SF_8000 = 1 << 11
|
||||
SF_11025 = 1 << 10
|
||||
SF_12000 = 1 << 9
|
||||
SF_16000 = 1 << 8
|
||||
SF_22050 = 1 << 7
|
||||
SF_24000 = 1 << 6
|
||||
SF_32000 = 1 << 5
|
||||
SF_44100 = 1 << 4
|
||||
SF_48000 = 1 << 3
|
||||
SF_64000 = 1 << 2
|
||||
SF_88200 = 1 << 1
|
||||
SF_96000 = 1 << 0
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, sampling_frequency: int) -> Self:
|
||||
sampling_frequencies = [
|
||||
8000,
|
||||
11025,
|
||||
12000,
|
||||
16000,
|
||||
22050,
|
||||
24000,
|
||||
32000,
|
||||
44100,
|
||||
48000,
|
||||
64000,
|
||||
88200,
|
||||
96000,
|
||||
]
|
||||
index = sampling_frequencies.index(sampling_frequency)
|
||||
return cls(1 << (len(sampling_frequencies) - index - 1))
|
||||
|
||||
class Channels(enum.IntFlag):
|
||||
MONO = 1 << 1
|
||||
STEREO = 1 << 0
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> AacMediaCodecInformation:
|
||||
object_type = cls.ObjectType(data[0])
|
||||
sampling_frequency = cls.SamplingFrequency(
|
||||
(data[1] << 4) | ((data[2] >> 4) & 0x0F)
|
||||
)
|
||||
channels = cls.Channels((data[2] >> 2) & 0x03)
|
||||
vbr = (data[3] >> 7) & 0x01
|
||||
bitrate = ((data[3] & 0x7F) << 16) | (data[4] << 8) | data[5]
|
||||
return AacMediaCodecInformation(
|
||||
object_type, sampling_frequency, channels, rfa, vbr, bitrate
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_discrete_values(
|
||||
cls,
|
||||
object_type: int,
|
||||
sampling_frequency: int,
|
||||
channels: int,
|
||||
vbr: int,
|
||||
bitrate: int,
|
||||
) -> AacMediaCodecInformation:
|
||||
return AacMediaCodecInformation(
|
||||
object_type=cls.OBJECT_TYPE_BITS[object_type],
|
||||
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
|
||||
channels=cls.CHANNELS_BITS[channels],
|
||||
rfa=0,
|
||||
vbr=vbr,
|
||||
bitrate=bitrate,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_lists(
|
||||
cls,
|
||||
object_types: List[int],
|
||||
sampling_frequencies: List[int],
|
||||
channels: List[int],
|
||||
vbr: int,
|
||||
bitrate: int,
|
||||
) -> AacMediaCodecInformation:
|
||||
return AacMediaCodecInformation(
|
||||
object_type=sum(cls.OBJECT_TYPE_BITS[x] for x in object_types),
|
||||
sampling_frequency=sum(
|
||||
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
|
||||
),
|
||||
channels=sum(cls.CHANNELS_BITS[x] for x in channels),
|
||||
rfa=0,
|
||||
vbr=vbr,
|
||||
bitrate=bitrate,
|
||||
object_type, sampling_frequency, channels, vbr, bitrate
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
@@ -472,30 +425,6 @@ class AacMediaCodecInformation:
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
object_types = [
|
||||
'MPEG_2_AAC_LC',
|
||||
'MPEG_4_AAC_LC',
|
||||
'MPEG_4_AAC_LTP',
|
||||
'MPEG_4_AAC_SCALABLE',
|
||||
'[4]',
|
||||
'[5]',
|
||||
'[6]',
|
||||
'[7]',
|
||||
]
|
||||
channels = [1, 2]
|
||||
# pylint: disable=line-too-long
|
||||
return '\n'.join(
|
||||
[
|
||||
'AacMediaCodecInformation(',
|
||||
f' object_type: {",".join([str(x) for x in flags_to_list(self.object_type, object_types)])}',
|
||||
f' sampling_frequency: {",".join([str(x) for x in flags_to_list(self.sampling_frequency, MPEG_2_4_AAC_SAMPLING_FREQUENCIES)])}',
|
||||
f' channels: {",".join([str(x) for x in flags_to_list(self.channels, channels)])}',
|
||||
f' vbr: {self.vbr}',
|
||||
f' bitrate: {self.bitrate}' ')',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -514,7 +443,7 @@ class VendorSpecificMediaCodecInformation:
|
||||
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return struct.pack('<IH', self.vendor_id, self.codec_id, self.value)
|
||||
return struct.pack('<IH', self.vendor_id, self.codec_id) + self.value
|
||||
|
||||
def __str__(self) -> str:
|
||||
# pylint: disable=line-too-long
|
||||
@@ -528,13 +457,69 @@ class VendorSpecificMediaCodecInformation:
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class OpusMediaCodecInformation(VendorSpecificMediaCodecInformation):
|
||||
vendor_id: int = dataclasses.field(init=False, repr=False)
|
||||
codec_id: int = dataclasses.field(init=False, repr=False)
|
||||
value: bytes = dataclasses.field(init=False, repr=False)
|
||||
channel_mode: ChannelMode
|
||||
frame_size: FrameSize
|
||||
sampling_frequency: SamplingFrequency
|
||||
|
||||
class ChannelMode(enum.IntFlag):
|
||||
MONO = 1 << 0
|
||||
STEREO = 1 << 1
|
||||
DUAL_MONO = 1 << 2
|
||||
|
||||
class FrameSize(enum.IntFlag):
|
||||
FS_10MS = 1 << 0
|
||||
FS_20MS = 1 << 1
|
||||
|
||||
class SamplingFrequency(enum.IntFlag):
|
||||
SF_48000 = 1 << 0
|
||||
|
||||
VENDOR_ID: ClassVar[int] = 0x000000E0
|
||||
CODEC_ID: ClassVar[int] = 0x0001
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.vendor_id = self.VENDOR_ID
|
||||
self.codec_id = self.CODEC_ID
|
||||
self.value = bytes(
|
||||
[
|
||||
self.channel_mode
|
||||
| (self.frame_size << 3)
|
||||
| (self.sampling_frequency << 7)
|
||||
]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Self:
|
||||
"""Create a new instance from the `value` part of the data, not including
|
||||
the vendor id and codec id"""
|
||||
channel_mode = cls.ChannelMode(data[0] & 0x07)
|
||||
frame_size = cls.FrameSize((data[0] >> 3) & 0x03)
|
||||
sampling_frequency = cls.SamplingFrequency((data[0] >> 7) & 0x01)
|
||||
|
||||
return cls(
|
||||
channel_mode,
|
||||
frame_size,
|
||||
sampling_frequency,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return repr(self)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class SbcFrame:
|
||||
sampling_frequency: int
|
||||
block_count: int
|
||||
channel_mode: int
|
||||
allocation_method: int
|
||||
subband_count: int
|
||||
bitpool: int
|
||||
payload: bytes
|
||||
|
||||
@property
|
||||
@@ -553,8 +538,10 @@ class SbcFrame:
|
||||
return (
|
||||
f'SBC(sf={self.sampling_frequency},'
|
||||
f'cm={self.channel_mode},'
|
||||
f'am={self.allocation_method},'
|
||||
f'br={self.bitrate},'
|
||||
f'sc={self.sample_count},'
|
||||
f'bp={self.bitpool},'
|
||||
f'size={len(self.payload)})'
|
||||
)
|
||||
|
||||
@@ -583,6 +570,7 @@ class SbcParser:
|
||||
blocks = 4 * (1 + ((header[1] >> 4) & 3))
|
||||
channel_mode = (header[1] >> 2) & 3
|
||||
channels = 1 if channel_mode == SBC_MONO_CHANNEL_MODE else 2
|
||||
allocation_method = (header[1] >> 1) & 1
|
||||
subbands = 8 if ((header[1]) & 1) else 4
|
||||
bitpool = header[2]
|
||||
|
||||
@@ -602,7 +590,13 @@ class SbcParser:
|
||||
|
||||
# Emit the next frame
|
||||
yield SbcFrame(
|
||||
sampling_frequency, blocks, channel_mode, subbands, payload
|
||||
sampling_frequency,
|
||||
blocks,
|
||||
channel_mode,
|
||||
allocation_method,
|
||||
subbands,
|
||||
bitpool,
|
||||
payload,
|
||||
)
|
||||
|
||||
return generate_frames()
|
||||
@@ -610,21 +604,15 @@ class SbcParser:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class SbcPacketSource:
|
||||
def __init__(
|
||||
self, read: Callable[[int], Awaitable[bytes]], mtu: int, codec_capabilities
|
||||
) -> None:
|
||||
def __init__(self, read: Callable[[int], Awaitable[bytes]], mtu: int) -> None:
|
||||
self.read = read
|
||||
self.mtu = mtu
|
||||
self.codec_capabilities = codec_capabilities
|
||||
|
||||
@property
|
||||
def packets(self):
|
||||
async def generate_packets():
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from .avdtp import MediaPacket # Import here to avoid a circular reference
|
||||
|
||||
sequence_number = 0
|
||||
timestamp = 0
|
||||
sample_count = 0
|
||||
frames = []
|
||||
frames_size = 0
|
||||
max_rtp_payload = self.mtu - 12 - 1
|
||||
@@ -632,29 +620,29 @@ class SbcPacketSource:
|
||||
# NOTE: this doesn't support frame fragments
|
||||
sbc_parser = SbcParser(self.read)
|
||||
async for frame in sbc_parser.frames:
|
||||
print(frame)
|
||||
|
||||
if (
|
||||
frames_size + len(frame.payload) > max_rtp_payload
|
||||
or len(frames) == 16
|
||||
or len(frames) == SBC_MAX_FRAMES_IN_RTP_PAYLOAD
|
||||
):
|
||||
# Need to flush what has been accumulated so far
|
||||
logger.debug(f"yielding {len(frames)} frames")
|
||||
|
||||
# Emit a packet
|
||||
sbc_payload = bytes([len(frames)]) + b''.join(
|
||||
sbc_payload = bytes([len(frames) & 0x0F]) + b''.join(
|
||||
[frame.payload for frame in frames]
|
||||
)
|
||||
timestamp_seconds = sample_count / frame.sampling_frequency
|
||||
timestamp = int(1000 * timestamp_seconds)
|
||||
packet = MediaPacket(
|
||||
2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, sbc_payload
|
||||
)
|
||||
packet.timestamp_seconds = timestamp / frame.sampling_frequency
|
||||
packet.timestamp_seconds = timestamp_seconds
|
||||
yield packet
|
||||
|
||||
# Prepare for next packets
|
||||
sequence_number += 1
|
||||
sequence_number &= 0xFFFF
|
||||
timestamp += sum((frame.sample_count for frame in frames))
|
||||
timestamp &= 0xFFFFFFFF
|
||||
sample_count += sum((frame.sample_count for frame in frames))
|
||||
frames = [frame]
|
||||
frames_size = len(frame.payload)
|
||||
else:
|
||||
@@ -663,3 +651,315 @@ class SbcPacketSource:
|
||||
frames_size += len(frame.payload)
|
||||
|
||||
return generate_packets()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class AacFrame:
|
||||
class Profile(enum.IntEnum):
|
||||
MAIN = 0
|
||||
LC = 1
|
||||
SSR = 2
|
||||
LTP = 3
|
||||
|
||||
profile: Profile
|
||||
sampling_frequency: int
|
||||
channel_configuration: int
|
||||
payload: bytes
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return 1024
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return self.sample_count / self.sampling_frequency
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'AAC(sf={self.sampling_frequency},'
|
||||
f'ch={self.channel_configuration},'
|
||||
f'size={len(self.payload)})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
ADTS_AAC_SAMPLING_FREQUENCIES = [
|
||||
96000,
|
||||
88200,
|
||||
64000,
|
||||
48000,
|
||||
44100,
|
||||
32000,
|
||||
24000,
|
||||
22050,
|
||||
16000,
|
||||
12000,
|
||||
11025,
|
||||
8000,
|
||||
7350,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AacParser:
|
||||
"""Parser for AAC frames in an ADTS stream"""
|
||||
|
||||
def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None:
|
||||
self.read = read
|
||||
|
||||
@property
|
||||
def frames(self) -> AsyncGenerator[AacFrame, None]:
|
||||
async def generate_frames() -> AsyncGenerator[AacFrame, None]:
|
||||
while True:
|
||||
header = await self.read(7)
|
||||
if not header:
|
||||
return
|
||||
|
||||
sync_word = (header[0] << 4) | (header[1] >> 4)
|
||||
if sync_word != 0b111111111111:
|
||||
raise ValueError(f"invalid sync word ({sync_word:06x})")
|
||||
layer = (header[1] >> 1) & 0b11
|
||||
profile = AacFrame.Profile((header[2] >> 6) & 0b11)
|
||||
sampling_frequency = ADTS_AAC_SAMPLING_FREQUENCIES[
|
||||
(header[2] >> 2) & 0b1111
|
||||
]
|
||||
channel_configuration = ((header[2] & 0b1) << 2) | (header[3] >> 6)
|
||||
frame_length = (
|
||||
((header[3] & 0b11) << 11) | (header[4] << 3) | (header[5] >> 5)
|
||||
)
|
||||
|
||||
if layer != 0:
|
||||
raise ValueError("layer must be 0")
|
||||
|
||||
payload = await self.read(frame_length - 7)
|
||||
if payload:
|
||||
yield AacFrame(
|
||||
profile, sampling_frequency, channel_configuration, payload
|
||||
)
|
||||
|
||||
return generate_frames()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AacPacketSource:
|
||||
def __init__(self, read: Callable[[int], Awaitable[bytes]], mtu: int) -> None:
|
||||
self.read = read
|
||||
self.mtu = mtu
|
||||
|
||||
@property
|
||||
def packets(self):
|
||||
async def generate_packets():
|
||||
sequence_number = 0
|
||||
sample_count = 0
|
||||
|
||||
aac_parser = AacParser(self.read)
|
||||
async for frame in aac_parser.frames:
|
||||
logger.debug("yielding one AAC frame")
|
||||
|
||||
# Emit a packet
|
||||
aac_payload = bytes(
|
||||
AacAudioRtpPacket.for_simple_aac(
|
||||
frame.sampling_frequency,
|
||||
frame.channel_configuration,
|
||||
frame.payload,
|
||||
)
|
||||
)
|
||||
timestamp_seconds = sample_count / frame.sampling_frequency
|
||||
timestamp = int(1000 * timestamp_seconds)
|
||||
packet = MediaPacket(
|
||||
2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, aac_payload
|
||||
)
|
||||
packet.timestamp_seconds = timestamp_seconds
|
||||
yield packet
|
||||
|
||||
# Prepare for next packets
|
||||
sequence_number += 1
|
||||
sequence_number &= 0xFFFF
|
||||
sample_count += frame.sample_count
|
||||
|
||||
return generate_packets()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class OpusPacket:
|
||||
class ChannelMode(enum.IntEnum):
|
||||
MONO = 0
|
||||
STEREO = 1
|
||||
DUAL_MONO = 2
|
||||
|
||||
channel_mode: ChannelMode
|
||||
duration: int # Duration in ms.
|
||||
sampling_frequency: int
|
||||
payload: bytes
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Opus(ch={self.channel_mode.name}, '
|
||||
f'd={self.duration}ms, '
|
||||
f'size={len(self.payload)})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class OpusParser:
|
||||
"""
|
||||
Parser for Opus packets in an Ogg stream
|
||||
|
||||
See RFC 3533
|
||||
|
||||
NOTE: this parser only supports bitstreams with a single logical stream.
|
||||
"""
|
||||
|
||||
CAPTURE_PATTERN = b'OggS'
|
||||
|
||||
class HeaderType(enum.IntFlag):
|
||||
CONTINUED = 0x01
|
||||
FIRST = 0x02
|
||||
LAST = 0x04
|
||||
|
||||
def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None:
|
||||
self.read = read
|
||||
|
||||
@property
|
||||
def packets(self) -> AsyncGenerator[OpusPacket, None]:
|
||||
async def generate_frames() -> AsyncGenerator[OpusPacket, None]:
|
||||
packet = b''
|
||||
packet_count = 0
|
||||
expected_bitstream_serial_number = None
|
||||
expected_page_sequence_number = 0
|
||||
channel_mode = OpusPacket.ChannelMode.STEREO
|
||||
|
||||
while True:
|
||||
# Parse the page header
|
||||
header = await self.read(27)
|
||||
if len(header) != 27:
|
||||
logger.debug("end of stream")
|
||||
break
|
||||
|
||||
capture_pattern = header[:4]
|
||||
if capture_pattern != self.CAPTURE_PATTERN:
|
||||
print(capture_pattern.hex())
|
||||
raise ValueError("invalid capture pattern at start of page")
|
||||
|
||||
version = header[4]
|
||||
if version != 0:
|
||||
raise ValueError(f"version {version} not supported")
|
||||
|
||||
header_type = self.HeaderType(header[5])
|
||||
(
|
||||
granule_position,
|
||||
bitstream_serial_number,
|
||||
page_sequence_number,
|
||||
crc_checksum,
|
||||
page_segments,
|
||||
) = struct.unpack_from("<QIIIB", header, 6)
|
||||
segment_table = await self.read(page_segments)
|
||||
|
||||
if header_type & self.HeaderType.FIRST:
|
||||
if expected_bitstream_serial_number is None:
|
||||
# We will only accept pages for the first encountered stream
|
||||
logger.debug("BOS")
|
||||
expected_bitstream_serial_number = bitstream_serial_number
|
||||
expected_page_sequence_number = page_sequence_number
|
||||
|
||||
if (
|
||||
expected_bitstream_serial_number is None
|
||||
or expected_bitstream_serial_number != bitstream_serial_number
|
||||
):
|
||||
logger.debug("skipping page (not the first logical bitstream)")
|
||||
for lacing_value in segment_table:
|
||||
if lacing_value:
|
||||
await self.read(lacing_value)
|
||||
continue
|
||||
|
||||
if expected_page_sequence_number != page_sequence_number:
|
||||
raise ValueError(
|
||||
f"expected page sequence number {expected_page_sequence_number}"
|
||||
f" but got {page_sequence_number}"
|
||||
)
|
||||
expected_page_sequence_number = page_sequence_number + 1
|
||||
|
||||
# Assemble the page
|
||||
if not header_type & self.HeaderType.CONTINUED:
|
||||
packet = b''
|
||||
for lacing_value in segment_table:
|
||||
if lacing_value:
|
||||
packet += await self.read(lacing_value)
|
||||
if lacing_value < 255:
|
||||
# End of packet
|
||||
packet_count += 1
|
||||
|
||||
if packet_count == 1:
|
||||
# The first packet contains the identification header
|
||||
logger.debug("first packet (header)")
|
||||
if packet[:8] != b"OpusHead":
|
||||
raise ValueError("first packet is not OpusHead")
|
||||
packet_count = (
|
||||
OpusPacket.ChannelMode.MONO
|
||||
if packet[9] == 1
|
||||
else OpusPacket.ChannelMode.STEREO
|
||||
)
|
||||
|
||||
elif packet_count == 2:
|
||||
# The second packet contains the comment header
|
||||
logger.debug("second packet (tags)")
|
||||
if packet[:8] != b"OpusTags":
|
||||
logger.warning("second packet is not OpusTags")
|
||||
else:
|
||||
yield OpusPacket(channel_mode, 20, 48000, packet)
|
||||
|
||||
packet = b''
|
||||
|
||||
if header_type & self.HeaderType.LAST:
|
||||
logger.debug("EOS")
|
||||
|
||||
return generate_frames()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class OpusPacketSource:
|
||||
def __init__(self, read: Callable[[int], Awaitable[bytes]], mtu: int) -> None:
|
||||
self.read = read
|
||||
self.mtu = mtu
|
||||
|
||||
@property
|
||||
def packets(self):
|
||||
async def generate_packets():
|
||||
sequence_number = 0
|
||||
elapsed_ms = 0
|
||||
|
||||
opus_parser = OpusParser(self.read)
|
||||
async for opus_packet in opus_parser.packets:
|
||||
# We only support sending one Opus frame per RTP packet
|
||||
# TODO: check the spec for the first byte value here
|
||||
opus_payload = bytes([1]) + opus_packet.payload
|
||||
elapsed_s = elapsed_ms / 1000
|
||||
timestamp = int(elapsed_s * opus_packet.sampling_frequency)
|
||||
rtp_packet = MediaPacket(
|
||||
2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, opus_payload
|
||||
)
|
||||
rtp_packet.timestamp_seconds = elapsed_s
|
||||
yield rtp_packet
|
||||
|
||||
# Prepare for next packets
|
||||
sequence_number += 1
|
||||
sequence_number &= 0xFFFF
|
||||
elapsed_ms += opus_packet.duration
|
||||
|
||||
return generate_packets()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# This map should be left at the end of the file so it can refer to the classes
|
||||
# above
|
||||
# -----------------------------------------------------------------------------
|
||||
A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES = {
|
||||
OpusMediaCodecInformation.VENDOR_ID: {
|
||||
OpusMediaCodecInformation.CODEC_ID: OpusMediaCodecInformation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ if TYPE_CHECKING:
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
ATT_CID = 0x04
|
||||
ATT_PSM = 0x001F
|
||||
|
||||
ATT_ERROR_RESPONSE = 0x01
|
||||
ATT_EXCHANGE_MTU_REQUEST = 0x02
|
||||
@@ -291,9 +292,6 @@ class ATT_PDU:
|
||||
def init_from_bytes(self, pdu, offset):
|
||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||
|
||||
def to_bytes(self):
|
||||
return self.pdu
|
||||
|
||||
@property
|
||||
def is_command(self):
|
||||
return ((self.op_code >> 6) & 1) == 1
|
||||
@@ -303,7 +301,7 @@ class ATT_PDU:
|
||||
return ((self.op_code >> 7) & 1) == 1
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
return self.pdu
|
||||
|
||||
def __str__(self):
|
||||
result = color(self.name, 'yellow')
|
||||
@@ -710,7 +708,7 @@ class ATT_Prepare_Write_Response(ATT_PDU):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@ATT_PDU.subclass([])
|
||||
@ATT_PDU.subclass([("flags", 1)])
|
||||
class ATT_Execute_Write_Request(ATT_PDU):
|
||||
'''
|
||||
See Bluetooth spec @ Vol 3, Part F - 3.4.6.3 Execute Write Request
|
||||
@@ -759,13 +757,13 @@ class AttributeValue:
|
||||
def __init__(
|
||||
self,
|
||||
read: Union[
|
||||
Callable[[Optional[Connection]], bytes],
|
||||
Callable[[Optional[Connection]], Awaitable[bytes]],
|
||||
Callable[[Optional[Connection]], Any],
|
||||
Callable[[Optional[Connection]], Awaitable[Any]],
|
||||
None,
|
||||
] = None,
|
||||
write: Union[
|
||||
Callable[[Optional[Connection], bytes], None],
|
||||
Callable[[Optional[Connection], bytes], Awaitable[None]],
|
||||
Callable[[Optional[Connection], Any], None],
|
||||
Callable[[Optional[Connection], Any], Awaitable[None]],
|
||||
None,
|
||||
] = None,
|
||||
):
|
||||
@@ -824,13 +822,13 @@ class Attribute(EventEmitter):
|
||||
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
|
||||
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
|
||||
|
||||
value: Union[bytes, AttributeValue]
|
||||
value: Any
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attribute_type: Union[str, bytes, UUID],
|
||||
permissions: Union[str, Attribute.Permissions],
|
||||
value: Union[str, bytes, AttributeValue] = b'',
|
||||
value: Any = b'',
|
||||
) -> None:
|
||||
EventEmitter.__init__(self)
|
||||
self.handle = 0
|
||||
@@ -848,11 +846,7 @@ class Attribute(EventEmitter):
|
||||
else:
|
||||
self.type = attribute_type
|
||||
|
||||
# Convert the value to a byte array
|
||||
if isinstance(value, str):
|
||||
self.value = bytes(value, 'utf-8')
|
||||
else:
|
||||
self.value = value
|
||||
self.value = value
|
||||
|
||||
def encode_value(self, value: Any) -> bytes:
|
||||
return value
|
||||
@@ -895,6 +889,8 @@ class Attribute(EventEmitter):
|
||||
else:
|
||||
value = self.value
|
||||
|
||||
self.emit('read', connection, value)
|
||||
|
||||
return self.encode_value(value)
|
||||
|
||||
async def write_value(self, connection: Connection, value_bytes: bytes) -> None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -12,6 +12,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
553
bumble/audio/io.py
Normal file
553
bumble/audio/io.py
Normal file
@@ -0,0 +1,553 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import abc
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import dataclasses
|
||||
import enum
|
||||
import logging
|
||||
import pathlib
|
||||
from typing import (
|
||||
AsyncGenerator,
|
||||
BinaryIO,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
import sys
|
||||
import wave
|
||||
|
||||
from bumble.colors import color
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sounddevice # type: ignore[import-untyped]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class PcmFormat:
|
||||
class Endianness(enum.Enum):
|
||||
LITTLE = 0
|
||||
BIG = 1
|
||||
|
||||
class SampleType(enum.Enum):
|
||||
FLOAT32 = 0
|
||||
INT16 = 1
|
||||
|
||||
endianness: Endianness
|
||||
sample_type: SampleType
|
||||
sample_rate: int
|
||||
channels: int
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, format_str: str) -> PcmFormat:
|
||||
endianness = cls.Endianness.LITTLE # Others not yet supported.
|
||||
sample_type_str, sample_rate_str, channels_str = format_str.split(',')
|
||||
if sample_type_str == 'int16le':
|
||||
sample_type = cls.SampleType.INT16
|
||||
elif sample_type_str == 'float32le':
|
||||
sample_type = cls.SampleType.FLOAT32
|
||||
else:
|
||||
raise ValueError(f'sample type {sample_type_str} not supported')
|
||||
sample_rate = int(sample_rate_str)
|
||||
channels = int(channels_str)
|
||||
|
||||
return cls(endianness, sample_type, sample_rate, channels)
|
||||
|
||||
@property
|
||||
def bytes_per_sample(self) -> int:
|
||||
return 2 if self.sample_type == self.SampleType.INT16 else 4
|
||||
|
||||
|
||||
def check_audio_output(output: str) -> bool:
|
||||
if output == 'device' or output.startswith('device:'):
|
||||
try:
|
||||
import sounddevice
|
||||
except ImportError as exc:
|
||||
raise ValueError(
|
||||
'audio output not available (sounddevice python module not installed)'
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise ValueError(
|
||||
'audio output not available '
|
||||
'(sounddevice python module failed to load: '
|
||||
f'{exc})'
|
||||
) from exc
|
||||
|
||||
if output == 'device':
|
||||
# Default device
|
||||
return True
|
||||
|
||||
# Specific device
|
||||
device = output[7:]
|
||||
if device == '?':
|
||||
print(color('Audio Devices:', 'yellow'))
|
||||
for device_info in [
|
||||
device_info
|
||||
for device_info in sounddevice.query_devices()
|
||||
if device_info['max_output_channels'] > 0
|
||||
]:
|
||||
device_index = device_info['index']
|
||||
is_default = (
|
||||
color(' [default]', 'green')
|
||||
if sounddevice.default.device[1] == device_index
|
||||
else ''
|
||||
)
|
||||
print(
|
||||
f'{color(device_index, "cyan")}: {device_info["name"]}{is_default}'
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
device_info = sounddevice.query_devices(int(device))
|
||||
except sounddevice.PortAudioError as exc:
|
||||
raise ValueError('No such audio device') from exc
|
||||
|
||||
if device_info['max_output_channels'] < 1:
|
||||
raise ValueError(
|
||||
f'Device {device} ({device_info["name"]}) does not have an output'
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def create_audio_output(output: str) -> AudioOutput:
|
||||
if output == 'stdout':
|
||||
return StreamAudioOutput(sys.stdout.buffer)
|
||||
|
||||
if output == 'device' or output.startswith('device:'):
|
||||
device_name = '' if output == 'device' else output[7:]
|
||||
return SoundDeviceAudioOutput(device_name)
|
||||
|
||||
if output == 'ffplay':
|
||||
return SubprocessAudioOutput(
|
||||
command=(
|
||||
'ffplay -probesize 32 -fflags nobuffer -analyzeduration 0 '
|
||||
'-ar {sample_rate} '
|
||||
'-ch_layout {channel_layout} '
|
||||
'-f f32le pipe:0'
|
||||
)
|
||||
)
|
||||
|
||||
if output.startswith('file:'):
|
||||
return FileAudioOutput(output[5:])
|
||||
|
||||
raise ValueError('unsupported audio output')
|
||||
|
||||
|
||||
class AudioOutput(abc.ABC):
|
||||
"""Audio output to which PCM samples can be written."""
|
||||
|
||||
async def open(self, pcm_format: PcmFormat) -> None:
|
||||
"""Start the output."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def write(self, pcm_samples: bytes) -> None:
|
||||
"""Write PCM samples. Must not block."""
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the output."""
|
||||
|
||||
|
||||
class ThreadedAudioOutput(AudioOutput):
|
||||
"""Base class for AudioOutput classes that may need to call blocking functions.
|
||||
|
||||
The actual writing is performed in a thread, so as to ensure that calling write()
|
||||
does not block the caller.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._thread_pool = ThreadPoolExecutor(1)
|
||||
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
self._write_task = asyncio.create_task(self._write_loop())
|
||||
|
||||
async def _write_loop(self) -> None:
|
||||
while True:
|
||||
pcm_samples = await self._pcm_samples.get()
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
self._thread_pool, self._write, pcm_samples
|
||||
)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _write(self, pcm_samples: bytes) -> None:
|
||||
"""This method does the actual writing and can block."""
|
||||
|
||||
def write(self, pcm_samples: bytes) -> None:
|
||||
self._pcm_samples.put_nowait(pcm_samples)
|
||||
|
||||
def _close(self) -> None:
|
||||
"""This method does the actual closing and can block."""
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close)
|
||||
self._write_task.cancel()
|
||||
self._thread_pool.shutdown()
|
||||
|
||||
|
||||
class SoundDeviceAudioOutput(ThreadedAudioOutput):
|
||||
def __init__(self, device_name: str) -> None:
|
||||
super().__init__()
|
||||
self._device = int(device_name) if device_name else None
|
||||
self._stream: sounddevice.RawOutputStream | None = None
|
||||
|
||||
async def open(self, pcm_format: PcmFormat) -> None:
|
||||
import sounddevice # pylint: disable=import-error
|
||||
|
||||
self._stream = sounddevice.RawOutputStream(
|
||||
samplerate=pcm_format.sample_rate,
|
||||
device=self._device,
|
||||
channels=pcm_format.channels,
|
||||
dtype='float32',
|
||||
)
|
||||
self._stream.start()
|
||||
|
||||
def _write(self, pcm_samples: bytes) -> None:
|
||||
if self._stream is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self._stream.write(pcm_samples)
|
||||
except Exception as error:
|
||||
print(f'Sound device error: {error}')
|
||||
raise
|
||||
|
||||
def _close(self):
|
||||
self._stream.stop()
|
||||
self._stream = None
|
||||
|
||||
|
||||
class StreamAudioOutput(ThreadedAudioOutput):
|
||||
"""AudioOutput where PCM samples are written to a stream that may block."""
|
||||
|
||||
def __init__(self, stream: BinaryIO) -> None:
|
||||
super().__init__()
|
||||
self._stream = stream
|
||||
|
||||
def _write(self, pcm_samples: bytes) -> None:
|
||||
self._stream.write(pcm_samples)
|
||||
self._stream.flush()
|
||||
|
||||
|
||||
class FileAudioOutput(StreamAudioOutput):
|
||||
"""AudioOutput where PCM samples are written to a file."""
|
||||
|
||||
def __init__(self, filename: str) -> None:
|
||||
self._file = open(filename, "wb")
|
||||
super().__init__(self._file)
|
||||
|
||||
async def shutdown(self):
|
||||
self._file.close()
|
||||
return await super().shutdown()
|
||||
|
||||
|
||||
class SubprocessAudioOutput(AudioOutput):
|
||||
"""AudioOutput where audio samples are written to a subprocess via stdin."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
self._command = command
|
||||
self._subprocess: asyncio.subprocess.Process | None
|
||||
|
||||
async def open(self, pcm_format: PcmFormat) -> None:
|
||||
if pcm_format.channels == 1:
|
||||
channel_layout = 'mono'
|
||||
elif pcm_format.channels == 2:
|
||||
channel_layout = 'stereo'
|
||||
else:
|
||||
raise ValueError(f'{pcm_format.channels} channels not supported')
|
||||
|
||||
command = self._command.format(
|
||||
sample_rate=pcm_format.sample_rate, channel_layout=channel_layout
|
||||
)
|
||||
self._subprocess = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
def write(self, pcm_samples: bytes) -> None:
|
||||
if self._subprocess is None or self._subprocess.stdin is None:
|
||||
return
|
||||
|
||||
self._subprocess.stdin.write(pcm_samples)
|
||||
|
||||
async def aclose(self):
|
||||
if self._subprocess:
|
||||
self._subprocess.terminate()
|
||||
|
||||
|
||||
def check_audio_input(input: str) -> bool:
|
||||
if input == 'device' or input.startswith('device:'):
|
||||
try:
|
||||
import sounddevice # pylint: disable=import-error
|
||||
except ImportError as exc:
|
||||
raise ValueError(
|
||||
'audio input not available (sounddevice python module not installed)'
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise ValueError(
|
||||
'audio input not available '
|
||||
'(sounddevice python module failed to load: '
|
||||
f'{exc})'
|
||||
) from exc
|
||||
|
||||
if input == 'device':
|
||||
# Default device
|
||||
return True
|
||||
|
||||
# Specific device
|
||||
device = input[7:]
|
||||
if device == '?':
|
||||
print(color('Audio Devices:', 'yellow'))
|
||||
for device_info in [
|
||||
device_info
|
||||
for device_info in sounddevice.query_devices()
|
||||
if device_info['max_input_channels'] > 0
|
||||
]:
|
||||
device_index = device_info["index"]
|
||||
is_mono = device_info['max_input_channels'] == 1
|
||||
max_channels = color(f'[{"mono" if is_mono else "stereo"}]', 'cyan')
|
||||
is_default = (
|
||||
color(' [default]', 'green')
|
||||
if sounddevice.default.device[0] == device_index
|
||||
else ''
|
||||
)
|
||||
print(
|
||||
f'{color(device_index, "cyan")}: {device_info["name"]}'
|
||||
f' {max_channels}{is_default}'
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
device_info = sounddevice.query_devices(int(device))
|
||||
except sounddevice.PortAudioError as exc:
|
||||
raise ValueError('No such audio device') from exc
|
||||
|
||||
if device_info['max_input_channels'] < 1:
|
||||
raise ValueError(
|
||||
f'Device {device} ({device_info["name"]}) does not have an input'
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def create_audio_input(input: str, input_format: str) -> AudioInput:
|
||||
pcm_format: PcmFormat | None
|
||||
if input_format == 'auto':
|
||||
pcm_format = None
|
||||
else:
|
||||
pcm_format = PcmFormat.from_str(input_format)
|
||||
|
||||
if input == 'stdin':
|
||||
if not pcm_format:
|
||||
raise ValueError('input format details required for stdin')
|
||||
return StreamAudioInput(sys.stdin.buffer, pcm_format)
|
||||
|
||||
if input == 'device' or input.startswith('device:'):
|
||||
if not pcm_format:
|
||||
raise ValueError('input format details required for device')
|
||||
device_name = '' if input == 'device' else input[7:]
|
||||
return SoundDeviceAudioInput(device_name, pcm_format)
|
||||
|
||||
# If there's no file: prefix, check if we can assume it is a file.
|
||||
if pathlib.Path(input).is_file():
|
||||
input = 'file:' + input
|
||||
|
||||
if input.startswith('file:'):
|
||||
filename = input[5:]
|
||||
if filename.endswith('.wav'):
|
||||
if input_format != 'auto':
|
||||
raise ValueError(".wav file only supported with 'auto' format")
|
||||
return WaveAudioInput(filename)
|
||||
|
||||
if pcm_format is None:
|
||||
raise ValueError('input format details required for raw PCM files')
|
||||
return FileAudioInput(filename, pcm_format)
|
||||
|
||||
raise ValueError('input not supported')
|
||||
|
||||
|
||||
class AudioInput(abc.ABC):
|
||||
"""Audio input that produces PCM samples."""
|
||||
|
||||
@abc.abstractmethod
|
||||
async def open(self) -> PcmFormat:
|
||||
"""Open the input."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def frames(self, frame_size: int) -> AsyncGenerator[bytes]:
|
||||
"""Generate one frame of PCM samples. Must not block."""
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the input."""
|
||||
|
||||
|
||||
class ThreadedAudioInput(AudioInput):
|
||||
"""Base class for AudioInput implementation where reading samples may block."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._thread_pool = ThreadPoolExecutor(1)
|
||||
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
|
||||
@abc.abstractmethod
|
||||
def _read(self, frame_size: int) -> bytes:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _open(self) -> PcmFormat:
|
||||
pass
|
||||
|
||||
def _close(self) -> None:
|
||||
pass
|
||||
|
||||
async def open(self) -> PcmFormat:
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
self._thread_pool, self._open
|
||||
)
|
||||
|
||||
async def frames(self, frame_size: int) -> AsyncGenerator[bytes]:
|
||||
while pcm_sample := await asyncio.get_running_loop().run_in_executor(
|
||||
self._thread_pool, self._read, frame_size
|
||||
):
|
||||
yield pcm_sample
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close)
|
||||
self._thread_pool.shutdown()
|
||||
|
||||
|
||||
class WaveAudioInput(ThreadedAudioInput):
|
||||
"""Audio input that reads PCM samples from a .wav file."""
|
||||
|
||||
def __init__(self, filename: str) -> None:
|
||||
super().__init__()
|
||||
self._filename = filename
|
||||
self._wav: wave.Wave_read | None = None
|
||||
self._bytes_read = 0
|
||||
|
||||
def _open(self) -> PcmFormat:
|
||||
self._wav = wave.open(self._filename, 'rb')
|
||||
if self._wav.getsampwidth() != 2:
|
||||
raise ValueError('sample width not supported')
|
||||
return PcmFormat(
|
||||
PcmFormat.Endianness.LITTLE,
|
||||
PcmFormat.SampleType.INT16,
|
||||
self._wav.getframerate(),
|
||||
self._wav.getnchannels(),
|
||||
)
|
||||
|
||||
def _read(self, frame_size: int) -> bytes:
|
||||
if not self._wav:
|
||||
return b''
|
||||
|
||||
pcm_samples = self._wav.readframes(frame_size)
|
||||
if not pcm_samples and self._bytes_read:
|
||||
# Loop around.
|
||||
self._wav.rewind()
|
||||
self._bytes_read = 0
|
||||
pcm_samples = self._wav.readframes(frame_size)
|
||||
|
||||
self._bytes_read += len(pcm_samples)
|
||||
return pcm_samples
|
||||
|
||||
def _close(self) -> None:
|
||||
if self._wav:
|
||||
self._wav.close()
|
||||
|
||||
|
||||
class StreamAudioInput(ThreadedAudioInput):
|
||||
"""AudioInput where samples are read from a raw PCM stream that may block."""
|
||||
|
||||
def __init__(self, stream: BinaryIO, pcm_format: PcmFormat) -> None:
|
||||
super().__init__()
|
||||
self._stream = stream
|
||||
self._pcm_format = pcm_format
|
||||
|
||||
def _open(self) -> PcmFormat:
|
||||
return self._pcm_format
|
||||
|
||||
def _read(self, frame_size: int) -> bytes:
|
||||
return self._stream.read(
|
||||
frame_size * self._pcm_format.channels * self._pcm_format.bytes_per_sample
|
||||
)
|
||||
|
||||
|
||||
class FileAudioInput(StreamAudioInput):
|
||||
"""AudioInput where PCM samples are read from a raw PCM file."""
|
||||
|
||||
def __init__(self, filename: str, pcm_format: PcmFormat) -> None:
|
||||
self._stream = open(filename, "rb")
|
||||
super().__init__(self._stream, pcm_format)
|
||||
|
||||
def _close(self) -> None:
|
||||
self._stream.close()
|
||||
|
||||
|
||||
class SoundDeviceAudioInput(ThreadedAudioInput):
|
||||
def __init__(self, device_name: str, pcm_format: PcmFormat) -> None:
|
||||
super().__init__()
|
||||
self._device = int(device_name) if device_name else None
|
||||
self._pcm_format = pcm_format
|
||||
self._stream: sounddevice.RawInputStream | None = None
|
||||
|
||||
def _open(self) -> PcmFormat:
|
||||
import sounddevice # pylint: disable=import-error
|
||||
|
||||
self._stream = sounddevice.RawInputStream(
|
||||
samplerate=self._pcm_format.sample_rate,
|
||||
device=self._device,
|
||||
channels=self._pcm_format.channels,
|
||||
dtype='int16',
|
||||
)
|
||||
self._stream.start()
|
||||
|
||||
return PcmFormat(
|
||||
PcmFormat.Endianness.LITTLE,
|
||||
PcmFormat.SampleType.INT16,
|
||||
self._pcm_format.sample_rate,
|
||||
2,
|
||||
)
|
||||
|
||||
def _read(self, frame_size: int) -> bytes:
|
||||
if not self._stream:
|
||||
return b''
|
||||
pcm_buffer, overflowed = self._stream.read(frame_size)
|
||||
if overflowed:
|
||||
logger.warning("input overflow")
|
||||
|
||||
# Convert the buffer to stereo if needed
|
||||
if self._pcm_format.channels == 1:
|
||||
stereo_buffer = bytearray()
|
||||
for i in range(frame_size):
|
||||
sample = pcm_buffer[i * 2 : i * 2 + 2]
|
||||
stereo_buffer += sample + sample
|
||||
return stereo_buffer
|
||||
|
||||
return bytes(pcm_buffer)
|
||||
|
||||
def _close(self):
|
||||
self._stream.stop()
|
||||
self._stream = None
|
||||
@@ -119,7 +119,7 @@ class Frame:
|
||||
# Not supported
|
||||
raise NotImplementedError("extended subunit types not supported")
|
||||
|
||||
if subunit_id < 5:
|
||||
if subunit_id < 5 or subunit_id == 7:
|
||||
opcode_offset = 2
|
||||
elif subunit_id == 5:
|
||||
# Extended to the next byte
|
||||
@@ -132,9 +132,10 @@ class Frame:
|
||||
else:
|
||||
subunit_id = 5 + extension
|
||||
opcode_offset = 3
|
||||
|
||||
elif subunit_id == 6:
|
||||
raise core.InvalidPacketError("reserved subunit ID")
|
||||
else:
|
||||
raise core.InvalidPacketError("invalid subunit ID")
|
||||
|
||||
opcode = Frame.OperationCode(data[opcode_offset])
|
||||
operands = data[opcode_offset + 1 :]
|
||||
|
||||
151
bumble/avdtp.py
151
bumble/avdtp.py
@@ -17,12 +17,10 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
import enum
|
||||
import warnings
|
||||
from pyee import EventEmitter
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
@@ -39,6 +37,8 @@ from typing import (
|
||||
cast,
|
||||
)
|
||||
|
||||
from pyee import EventEmitter
|
||||
|
||||
from .core import (
|
||||
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
||||
InvalidStateError,
|
||||
@@ -51,13 +51,16 @@ from .a2dp import (
|
||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
A2DP_NON_A2DP_CODEC_TYPE,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES,
|
||||
AacMediaCodecInformation,
|
||||
SbcMediaCodecInformation,
|
||||
VendorSpecificMediaCodecInformation,
|
||||
)
|
||||
from .rtp import MediaPacket
|
||||
from . import sdp, device, l2cap
|
||||
from .colors import color
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -278,95 +281,6 @@ class RealtimeClock:
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class MediaPacket:
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> MediaPacket:
|
||||
version = (data[0] >> 6) & 0x03
|
||||
padding = (data[0] >> 5) & 0x01
|
||||
extension = (data[0] >> 4) & 0x01
|
||||
csrc_count = data[0] & 0x0F
|
||||
marker = (data[1] >> 7) & 0x01
|
||||
payload_type = data[1] & 0x7F
|
||||
sequence_number = struct.unpack_from('>H', data, 2)[0]
|
||||
timestamp = struct.unpack_from('>I', data, 4)[0]
|
||||
ssrc = struct.unpack_from('>I', data, 8)[0]
|
||||
csrc_list = [
|
||||
struct.unpack_from('>I', data, 12 + i)[0] for i in range(csrc_count)
|
||||
]
|
||||
payload = data[12 + csrc_count * 4 :]
|
||||
|
||||
return MediaPacket(
|
||||
version,
|
||||
padding,
|
||||
extension,
|
||||
marker,
|
||||
sequence_number,
|
||||
timestamp,
|
||||
ssrc,
|
||||
csrc_list,
|
||||
payload_type,
|
||||
payload,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version: int,
|
||||
padding: int,
|
||||
extension: int,
|
||||
marker: int,
|
||||
sequence_number: int,
|
||||
timestamp: int,
|
||||
ssrc: int,
|
||||
csrc_list: List[int],
|
||||
payload_type: int,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.padding = padding
|
||||
self.extension = extension
|
||||
self.marker = marker
|
||||
self.sequence_number = sequence_number & 0xFFFF
|
||||
self.timestamp = timestamp & 0xFFFFFFFF
|
||||
self.ssrc = ssrc
|
||||
self.csrc_list = csrc_list
|
||||
self.payload_type = payload_type
|
||||
self.payload = payload
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
header = bytes(
|
||||
[
|
||||
self.version << 6
|
||||
| self.padding << 5
|
||||
| self.extension << 4
|
||||
| len(self.csrc_list),
|
||||
self.marker << 7 | self.payload_type,
|
||||
]
|
||||
) + struct.pack(
|
||||
'>HII',
|
||||
self.sequence_number,
|
||||
self.timestamp,
|
||||
self.ssrc,
|
||||
)
|
||||
for csrc in self.csrc_list:
|
||||
header += struct.pack('>I', csrc)
|
||||
return header + self.payload
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'RTP(v={self.version},'
|
||||
f'p={self.padding},'
|
||||
f'x={self.extension},'
|
||||
f'm={self.marker},'
|
||||
f'pt={self.payload_type},'
|
||||
f'sn={self.sequence_number},'
|
||||
f'ts={self.timestamp},'
|
||||
f'ssrc={self.ssrc},'
|
||||
f'csrcs={self.csrc_list},'
|
||||
f'payload_size={len(self.payload)})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class MediaPacketPump:
|
||||
pump_task: Optional[asyncio.Task]
|
||||
@@ -377,6 +291,7 @@ class MediaPacketPump:
|
||||
self.packets = packets
|
||||
self.clock = clock
|
||||
self.pump_task = None
|
||||
self.completed = asyncio.Event()
|
||||
|
||||
async def start(self, rtp_channel: l2cap.ClassicChannel) -> None:
|
||||
async def pump_packets():
|
||||
@@ -406,6 +321,8 @@ class MediaPacketPump:
|
||||
)
|
||||
except asyncio.exceptions.CancelledError:
|
||||
logger.debug('pump canceled')
|
||||
finally:
|
||||
self.completed.set()
|
||||
|
||||
# Pump packets
|
||||
self.pump_task = asyncio.create_task(pump_packets())
|
||||
@@ -417,6 +334,9 @@ class MediaPacketPump:
|
||||
await self.pump_task
|
||||
self.pump_task = None
|
||||
|
||||
async def wait_for_completion(self) -> None:
|
||||
await self.completed.wait()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class MessageAssembler:
|
||||
@@ -615,11 +535,25 @@ class MediaCodecCapabilities(ServiceCapabilities):
|
||||
self.media_codec_information
|
||||
)
|
||||
elif self.media_codec_type == A2DP_NON_A2DP_CODEC_TYPE:
|
||||
self.media_codec_information = (
|
||||
vendor_media_codec_information = (
|
||||
VendorSpecificMediaCodecInformation.from_bytes(
|
||||
self.media_codec_information
|
||||
)
|
||||
)
|
||||
if (
|
||||
vendor_class_map := A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES.get(
|
||||
vendor_media_codec_information.vendor_id
|
||||
)
|
||||
) and (
|
||||
media_codec_information_class := vendor_class_map.get(
|
||||
vendor_media_codec_information.codec_id
|
||||
)
|
||||
):
|
||||
self.media_codec_information = media_codec_information_class.from_bytes(
|
||||
vendor_media_codec_information.value
|
||||
)
|
||||
else:
|
||||
self.media_codec_information = vendor_media_codec_information
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1316,10 +1250,20 @@ class Protocol(EventEmitter):
|
||||
return None
|
||||
|
||||
def add_source(
|
||||
self, codec_capabilities: MediaCodecCapabilities, packet_pump: MediaPacketPump
|
||||
self,
|
||||
codec_capabilities: MediaCodecCapabilities,
|
||||
packet_pump: MediaPacketPump,
|
||||
delay_reporting: bool = False,
|
||||
) -> LocalSource:
|
||||
seid = len(self.local_endpoints) + 1
|
||||
source = LocalSource(self, seid, codec_capabilities, packet_pump)
|
||||
service_capabilities = (
|
||||
[ServiceCapabilities(AVDTP_DELAY_REPORTING_SERVICE_CATEGORY)]
|
||||
if delay_reporting
|
||||
else []
|
||||
)
|
||||
source = LocalSource(
|
||||
self, seid, codec_capabilities, service_capabilities, packet_pump
|
||||
)
|
||||
self.local_endpoints.append(source)
|
||||
|
||||
return source
|
||||
@@ -1372,7 +1316,7 @@ class Protocol(EventEmitter):
|
||||
return self.remote_endpoints.values()
|
||||
|
||||
def find_remote_sink_by_codec(
|
||||
self, media_type: int, codec_type: int
|
||||
self, media_type: int, codec_type: int, vendor_id: int = 0, codec_id: int = 0
|
||||
) -> Optional[DiscoveredStreamEndPoint]:
|
||||
for endpoint in self.remote_endpoints.values():
|
||||
if (
|
||||
@@ -1397,7 +1341,19 @@ class Protocol(EventEmitter):
|
||||
codec_capabilities.media_type == AVDTP_AUDIO_MEDIA_TYPE
|
||||
and codec_capabilities.media_codec_type == codec_type
|
||||
):
|
||||
has_codec = True
|
||||
if isinstance(
|
||||
codec_capabilities.media_codec_information,
|
||||
VendorSpecificMediaCodecInformation,
|
||||
):
|
||||
if (
|
||||
codec_capabilities.media_codec_information.vendor_id
|
||||
== vendor_id
|
||||
and codec_capabilities.media_codec_information.codec_id
|
||||
== codec_id
|
||||
):
|
||||
has_codec = True
|
||||
else:
|
||||
has_codec = True
|
||||
if has_media_transport and has_codec:
|
||||
return endpoint
|
||||
|
||||
@@ -2180,12 +2136,13 @@ class LocalSource(LocalStreamEndPoint):
|
||||
protocol: Protocol,
|
||||
seid: int,
|
||||
codec_capabilities: MediaCodecCapabilities,
|
||||
other_capabilitiles: Iterable[ServiceCapabilities],
|
||||
packet_pump: MediaPacketPump,
|
||||
) -> None:
|
||||
capabilities = [
|
||||
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
|
||||
codec_capabilities,
|
||||
]
|
||||
] + list(other_capabilitiles)
|
||||
super().__init__(
|
||||
protocol,
|
||||
seid,
|
||||
|
||||
@@ -1491,10 +1491,14 @@ class Protocol(pyee.EventEmitter):
|
||||
f"<<< AVCTP Command, transaction_label={transaction_label}: " f"{command}"
|
||||
)
|
||||
|
||||
# Only the PANEL subunit type with subunit ID 0 is supported in this profile.
|
||||
if (
|
||||
command.subunit_type != avc.Frame.SubunitType.PANEL
|
||||
or command.subunit_id != 0
|
||||
# Only addressing the unit, or the PANEL subunit with subunit ID 0 is supported
|
||||
# in this profile.
|
||||
if not (
|
||||
command.subunit_type == avc.Frame.SubunitType.UNIT
|
||||
and command.subunit_id == 7
|
||||
) and not (
|
||||
command.subunit_type == avc.Frame.SubunitType.PANEL
|
||||
and command.subunit_id == 0
|
||||
):
|
||||
logger.debug("subunit not supported")
|
||||
self.send_not_implemented_response(transaction_label, command)
|
||||
@@ -1528,8 +1532,8 @@ class Protocol(pyee.EventEmitter):
|
||||
# TODO: delegate
|
||||
response = avc.PassThroughResponseFrame(
|
||||
avc.ResponseFrame.ResponseCode.ACCEPTED,
|
||||
avc.Frame.SubunitType.PANEL,
|
||||
0,
|
||||
command.subunit_type,
|
||||
command.subunit_id,
|
||||
command.state_flag,
|
||||
command.operation_id,
|
||||
command.operation_data,
|
||||
@@ -1846,6 +1850,15 @@ class Protocol(pyee.EventEmitter):
|
||||
RejectedResponse(pdu_id, status_code),
|
||||
)
|
||||
|
||||
def send_not_implemented_avrcp_response(
|
||||
self, transaction_label: int, pdu_id: Protocol.PduId
|
||||
) -> None:
|
||||
self.send_avrcp_response(
|
||||
transaction_label,
|
||||
avc.ResponseFrame.ResponseCode.NOT_IMPLEMENTED,
|
||||
NotImplementedResponse(pdu_id, b''),
|
||||
)
|
||||
|
||||
def _on_get_capabilities_command(
|
||||
self, transaction_label: int, command: GetCapabilitiesCommand
|
||||
) -> None:
|
||||
@@ -1891,29 +1904,35 @@ class Protocol(pyee.EventEmitter):
|
||||
async def register_notification():
|
||||
# Check if the event is supported.
|
||||
supported_events = await self.delegate.get_supported_events()
|
||||
if command.event_id in supported_events:
|
||||
if command.event_id == EventId.VOLUME_CHANGED:
|
||||
volume = await self.delegate.get_absolute_volume()
|
||||
response = RegisterNotificationResponse(VolumeChangedEvent(volume))
|
||||
self.send_avrcp_response(
|
||||
transaction_label,
|
||||
avc.ResponseFrame.ResponseCode.INTERIM,
|
||||
response,
|
||||
)
|
||||
self._register_notification_listener(transaction_label, command)
|
||||
return
|
||||
if command.event_id not in supported_events:
|
||||
logger.debug("event not supported")
|
||||
self.send_not_implemented_avrcp_response(
|
||||
transaction_label, self.PduId.REGISTER_NOTIFICATION
|
||||
)
|
||||
return
|
||||
|
||||
if command.event_id == EventId.PLAYBACK_STATUS_CHANGED:
|
||||
# TODO: testing only, use delegate
|
||||
response = RegisterNotificationResponse(
|
||||
PlaybackStatusChangedEvent(play_status=PlayStatus.PLAYING)
|
||||
)
|
||||
self.send_avrcp_response(
|
||||
transaction_label,
|
||||
avc.ResponseFrame.ResponseCode.INTERIM,
|
||||
response,
|
||||
)
|
||||
self._register_notification_listener(transaction_label, command)
|
||||
return
|
||||
if command.event_id == EventId.VOLUME_CHANGED:
|
||||
volume = await self.delegate.get_absolute_volume()
|
||||
response = RegisterNotificationResponse(VolumeChangedEvent(volume))
|
||||
self.send_avrcp_response(
|
||||
transaction_label,
|
||||
avc.ResponseFrame.ResponseCode.INTERIM,
|
||||
response,
|
||||
)
|
||||
self._register_notification_listener(transaction_label, command)
|
||||
return
|
||||
|
||||
if command.event_id == EventId.PLAYBACK_STATUS_CHANGED:
|
||||
# TODO: testing only, use delegate
|
||||
response = RegisterNotificationResponse(
|
||||
PlaybackStatusChangedEvent(play_status=PlayStatus.PLAYING)
|
||||
)
|
||||
self.send_avrcp_response(
|
||||
transaction_label,
|
||||
avc.ResponseFrame.ResponseCode.INTERIM,
|
||||
response,
|
||||
)
|
||||
self._register_notification_listener(transaction_label, command)
|
||||
return
|
||||
|
||||
self._delegate_command(transaction_label, command, register_notification())
|
||||
|
||||
290
bumble/codecs.py
290
bumble/codecs.py
@@ -17,6 +17,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble import core
|
||||
|
||||
@@ -101,12 +102,40 @@ class BitReader:
|
||||
break
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class BitWriter:
|
||||
"""Simple but not optimized bit stream writer."""
|
||||
|
||||
data: int
|
||||
bit_count: int
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.data = 0
|
||||
self.bit_count = 0
|
||||
|
||||
def write(self, value: int, bit_count: int) -> None:
|
||||
self.data = (self.data << bit_count) | value
|
||||
self.bit_count += bit_count
|
||||
|
||||
def write_bytes(self, data: bytes) -> None:
|
||||
bit_count = 8 * len(data)
|
||||
self.data = (self.data << bit_count) | int.from_bytes(data, 'big')
|
||||
self.bit_count += bit_count
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return (self.data << ((8 - (self.bit_count % 8)) % 8)).to_bytes(
|
||||
(self.bit_count + 7) // 8, 'big'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AacAudioRtpPacket:
|
||||
"""AAC payload encapsulated in an RTP packet payload"""
|
||||
|
||||
audio_mux_element: AudioMuxElement
|
||||
|
||||
@staticmethod
|
||||
def latm_value(reader: BitReader) -> int:
|
||||
def read_latm_value(reader: BitReader) -> int:
|
||||
bytes_for_value = reader.read(2)
|
||||
value = 0
|
||||
for _ in range(bytes_for_value + 1):
|
||||
@@ -114,24 +143,33 @@ class AacAudioRtpPacket:
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def program_config_element(reader: BitReader):
|
||||
raise core.InvalidPacketError('program_config_element not supported')
|
||||
def read_audio_object_type(reader: BitReader):
|
||||
# GetAudioObjectType - ISO/EIC 14496-3 Table 1.16
|
||||
audio_object_type = reader.read(5)
|
||||
if audio_object_type == 31:
|
||||
audio_object_type = 32 + reader.read(6)
|
||||
|
||||
return audio_object_type
|
||||
|
||||
@dataclass
|
||||
class GASpecificConfig:
|
||||
def __init__(
|
||||
self, reader: BitReader, channel_configuration: int, audio_object_type: int
|
||||
) -> None:
|
||||
audio_object_type: int
|
||||
# NOTE: other fields not supported
|
||||
|
||||
@classmethod
|
||||
def from_bits(
|
||||
cls, reader: BitReader, channel_configuration: int, audio_object_type: int
|
||||
) -> Self:
|
||||
# GASpecificConfig - ISO/EIC 14496-3 Table 4.1
|
||||
frame_length_flag = reader.read(1)
|
||||
depends_on_core_coder = reader.read(1)
|
||||
if depends_on_core_coder:
|
||||
self.core_coder_delay = reader.read(14)
|
||||
core_coder_delay = reader.read(14)
|
||||
extension_flag = reader.read(1)
|
||||
if not channel_configuration:
|
||||
AacAudioRtpPacket.program_config_element(reader)
|
||||
raise core.InvalidPacketError('program_config_element not supported')
|
||||
if audio_object_type in (6, 20):
|
||||
self.layer_nr = reader.read(3)
|
||||
layer_nr = reader.read(3)
|
||||
if extension_flag:
|
||||
if audio_object_type == 22:
|
||||
num_of_sub_frame = reader.read(5)
|
||||
@@ -144,14 +182,13 @@ class AacAudioRtpPacket:
|
||||
if extension_flag_3 == 1:
|
||||
raise core.InvalidPacketError('extensionFlag3 == 1 not supported')
|
||||
|
||||
@staticmethod
|
||||
def audio_object_type(reader: BitReader):
|
||||
# GetAudioObjectType - ISO/EIC 14496-3 Table 1.16
|
||||
audio_object_type = reader.read(5)
|
||||
if audio_object_type == 31:
|
||||
audio_object_type = 32 + reader.read(6)
|
||||
return cls(audio_object_type)
|
||||
|
||||
return audio_object_type
|
||||
def to_bits(self, writer: BitWriter) -> None:
|
||||
assert self.audio_object_type in (1, 2)
|
||||
writer.write(0, 1) # frame_length_flag = 0
|
||||
writer.write(0, 1) # depends_on_core_coder = 0
|
||||
writer.write(0, 1) # extension_flag = 0
|
||||
|
||||
@dataclass
|
||||
class AudioSpecificConfig:
|
||||
@@ -159,6 +196,7 @@ class AacAudioRtpPacket:
|
||||
sampling_frequency_index: int
|
||||
sampling_frequency: int
|
||||
channel_configuration: int
|
||||
ga_specific_config: AacAudioRtpPacket.GASpecificConfig
|
||||
sbr_present_flag: int
|
||||
ps_present_flag: int
|
||||
extension_audio_object_type: int
|
||||
@@ -182,44 +220,73 @@ class AacAudioRtpPacket:
|
||||
7350,
|
||||
]
|
||||
|
||||
def __init__(self, reader: BitReader) -> None:
|
||||
# AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15
|
||||
self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
|
||||
self.sampling_frequency_index = reader.read(4)
|
||||
if self.sampling_frequency_index == 0xF:
|
||||
self.sampling_frequency = reader.read(24)
|
||||
else:
|
||||
self.sampling_frequency = self.SAMPLING_FREQUENCIES[
|
||||
self.sampling_frequency_index
|
||||
]
|
||||
self.channel_configuration = reader.read(4)
|
||||
self.sbr_present_flag = -1
|
||||
self.ps_present_flag = -1
|
||||
if self.audio_object_type in (5, 29):
|
||||
self.extension_audio_object_type = 5
|
||||
self.sbc_present_flag = 1
|
||||
if self.audio_object_type == 29:
|
||||
self.ps_present_flag = 1
|
||||
self.extension_sampling_frequency_index = reader.read(4)
|
||||
if self.extension_sampling_frequency_index == 0xF:
|
||||
self.extension_sampling_frequency = reader.read(24)
|
||||
else:
|
||||
self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[
|
||||
self.extension_sampling_frequency_index
|
||||
]
|
||||
self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
|
||||
if self.audio_object_type == 22:
|
||||
self.extension_channel_configuration = reader.read(4)
|
||||
else:
|
||||
self.extension_audio_object_type = 0
|
||||
@classmethod
|
||||
def for_simple_aac(
|
||||
cls,
|
||||
audio_object_type: int,
|
||||
sampling_frequency: int,
|
||||
channel_configuration: int,
|
||||
) -> Self:
|
||||
if sampling_frequency not in cls.SAMPLING_FREQUENCIES:
|
||||
raise ValueError(f'invalid sampling frequency {sampling_frequency}')
|
||||
|
||||
if self.audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23):
|
||||
ga_specific_config = AacAudioRtpPacket.GASpecificConfig(
|
||||
reader, self.channel_configuration, self.audio_object_type
|
||||
ga_specific_config = AacAudioRtpPacket.GASpecificConfig(audio_object_type)
|
||||
|
||||
return cls(
|
||||
audio_object_type=audio_object_type,
|
||||
sampling_frequency_index=cls.SAMPLING_FREQUENCIES.index(
|
||||
sampling_frequency
|
||||
),
|
||||
sampling_frequency=sampling_frequency,
|
||||
channel_configuration=channel_configuration,
|
||||
ga_specific_config=ga_specific_config,
|
||||
sbr_present_flag=0,
|
||||
ps_present_flag=0,
|
||||
extension_audio_object_type=0,
|
||||
extension_sampling_frequency_index=0,
|
||||
extension_sampling_frequency=0,
|
||||
extension_channel_configuration=0,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bits(cls, reader: BitReader) -> Self:
|
||||
# AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15
|
||||
audio_object_type = AacAudioRtpPacket.read_audio_object_type(reader)
|
||||
sampling_frequency_index = reader.read(4)
|
||||
if sampling_frequency_index == 0xF:
|
||||
sampling_frequency = reader.read(24)
|
||||
else:
|
||||
sampling_frequency = cls.SAMPLING_FREQUENCIES[sampling_frequency_index]
|
||||
channel_configuration = reader.read(4)
|
||||
sbr_present_flag = 0
|
||||
ps_present_flag = 0
|
||||
extension_sampling_frequency_index = 0
|
||||
extension_sampling_frequency = 0
|
||||
extension_channel_configuration = 0
|
||||
extension_audio_object_type = 0
|
||||
if audio_object_type in (5, 29):
|
||||
extension_audio_object_type = 5
|
||||
sbr_present_flag = 1
|
||||
if audio_object_type == 29:
|
||||
ps_present_flag = 1
|
||||
extension_sampling_frequency_index = reader.read(4)
|
||||
if extension_sampling_frequency_index == 0xF:
|
||||
extension_sampling_frequency = reader.read(24)
|
||||
else:
|
||||
extension_sampling_frequency = cls.SAMPLING_FREQUENCIES[
|
||||
extension_sampling_frequency_index
|
||||
]
|
||||
audio_object_type = AacAudioRtpPacket.read_audio_object_type(reader)
|
||||
if audio_object_type == 22:
|
||||
extension_channel_configuration = reader.read(4)
|
||||
|
||||
if audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23):
|
||||
ga_specific_config = AacAudioRtpPacket.GASpecificConfig.from_bits(
|
||||
reader, channel_configuration, audio_object_type
|
||||
)
|
||||
else:
|
||||
raise core.InvalidPacketError(
|
||||
f'audioObjectType {self.audio_object_type} not supported'
|
||||
f'audioObjectType {audio_object_type} not supported'
|
||||
)
|
||||
|
||||
# if self.extension_audio_object_type != 5 and bits_to_decode >= 16:
|
||||
@@ -248,13 +315,44 @@ class AacAudioRtpPacket:
|
||||
# self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
|
||||
# self.extension_channel_configuration = reader.read(4)
|
||||
|
||||
return cls(
|
||||
audio_object_type,
|
||||
sampling_frequency_index,
|
||||
sampling_frequency,
|
||||
channel_configuration,
|
||||
ga_specific_config,
|
||||
sbr_present_flag,
|
||||
ps_present_flag,
|
||||
extension_audio_object_type,
|
||||
extension_sampling_frequency_index,
|
||||
extension_sampling_frequency,
|
||||
extension_channel_configuration,
|
||||
)
|
||||
|
||||
def to_bits(self, writer: BitWriter) -> None:
|
||||
if self.sampling_frequency_index >= 15:
|
||||
raise ValueError(
|
||||
f"unsupported sampling frequency index {self.sampling_frequency_index}"
|
||||
)
|
||||
|
||||
if self.audio_object_type not in (1, 2):
|
||||
raise ValueError(
|
||||
f"unsupported audio object type {self.audio_object_type} "
|
||||
)
|
||||
|
||||
writer.write(self.audio_object_type, 5)
|
||||
writer.write(self.sampling_frequency_index, 4)
|
||||
writer.write(self.channel_configuration, 4)
|
||||
self.ga_specific_config.to_bits(writer)
|
||||
|
||||
@dataclass
|
||||
class StreamMuxConfig:
|
||||
other_data_present: int
|
||||
other_data_len_bits: int
|
||||
audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig
|
||||
|
||||
def __init__(self, reader: BitReader) -> None:
|
||||
@classmethod
|
||||
def from_bits(cls, reader: BitReader) -> Self:
|
||||
# StreamMuxConfig - ISO/EIC 14496-3 Table 1.42
|
||||
audio_mux_version = reader.read(1)
|
||||
if audio_mux_version == 1:
|
||||
@@ -264,7 +362,7 @@ class AacAudioRtpPacket:
|
||||
if audio_mux_version_a != 0:
|
||||
raise core.InvalidPacketError('audioMuxVersionA != 0 not supported')
|
||||
if audio_mux_version == 1:
|
||||
tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
|
||||
tara_buffer_fullness = AacAudioRtpPacket.read_latm_value(reader)
|
||||
stream_cnt = 0
|
||||
all_streams_same_time_framing = reader.read(1)
|
||||
num_sub_frames = reader.read(6)
|
||||
@@ -275,13 +373,13 @@ class AacAudioRtpPacket:
|
||||
if num_layer != 0:
|
||||
raise core.InvalidPacketError('num_layer != 0 not supported')
|
||||
if audio_mux_version == 0:
|
||||
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
||||
audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig.from_bits(
|
||||
reader
|
||||
)
|
||||
else:
|
||||
asc_len = AacAudioRtpPacket.latm_value(reader)
|
||||
asc_len = AacAudioRtpPacket.read_latm_value(reader)
|
||||
marker = reader.bit_position
|
||||
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
|
||||
audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig.from_bits(
|
||||
reader
|
||||
)
|
||||
audio_specific_config_len = reader.bit_position - marker
|
||||
@@ -299,36 +397,49 @@ class AacAudioRtpPacket:
|
||||
f'frame_length_type {frame_length_type} not supported'
|
||||
)
|
||||
|
||||
self.other_data_present = reader.read(1)
|
||||
if self.other_data_present:
|
||||
other_data_present = reader.read(1)
|
||||
other_data_len_bits = 0
|
||||
if other_data_present:
|
||||
if audio_mux_version == 1:
|
||||
self.other_data_len_bits = AacAudioRtpPacket.latm_value(reader)
|
||||
other_data_len_bits = AacAudioRtpPacket.read_latm_value(reader)
|
||||
else:
|
||||
self.other_data_len_bits = 0
|
||||
while True:
|
||||
self.other_data_len_bits *= 256
|
||||
other_data_len_bits *= 256
|
||||
other_data_len_esc = reader.read(1)
|
||||
self.other_data_len_bits += reader.read(8)
|
||||
other_data_len_bits += reader.read(8)
|
||||
if other_data_len_esc == 0:
|
||||
break
|
||||
crc_check_present = reader.read(1)
|
||||
if crc_check_present:
|
||||
crc_checksum = reader.read(8)
|
||||
|
||||
return cls(other_data_present, other_data_len_bits, audio_specific_config)
|
||||
|
||||
def to_bits(self, writer: BitWriter) -> None:
|
||||
writer.write(0, 1) # audioMuxVersion = 0
|
||||
writer.write(1, 1) # allStreamsSameTimeFraming = 1
|
||||
writer.write(0, 6) # numSubFrames = 0
|
||||
writer.write(0, 4) # numProgram = 0
|
||||
writer.write(0, 3) # numLayer = 0
|
||||
self.audio_specific_config.to_bits(writer)
|
||||
writer.write(0, 3) # frameLengthType = 0
|
||||
writer.write(0, 8) # latmBufferFullness = 0
|
||||
writer.write(0, 1) # otherDataPresent = 0
|
||||
writer.write(0, 1) # crcCheckPresent = 0
|
||||
|
||||
@dataclass
|
||||
class AudioMuxElement:
|
||||
payload: bytes
|
||||
stream_mux_config: AacAudioRtpPacket.StreamMuxConfig
|
||||
payload: bytes
|
||||
|
||||
def __init__(self, reader: BitReader, mux_config_present: int):
|
||||
if mux_config_present == 0:
|
||||
raise core.InvalidPacketError('muxConfigPresent == 0 not supported')
|
||||
|
||||
@classmethod
|
||||
def from_bits(cls, reader: BitReader) -> Self:
|
||||
# AudioMuxElement - ISO/EIC 14496-3 Table 1.41
|
||||
# (only supports mux_config_present=1)
|
||||
use_same_stream_mux = reader.read(1)
|
||||
if use_same_stream_mux:
|
||||
raise core.InvalidPacketError('useSameStreamMux == 1 not supported')
|
||||
self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
|
||||
stream_mux_config = AacAudioRtpPacket.StreamMuxConfig.from_bits(reader)
|
||||
|
||||
# We only support:
|
||||
# allStreamsSameTimeFraming == 1
|
||||
@@ -344,19 +455,46 @@ class AacAudioRtpPacket:
|
||||
if tmp != 255:
|
||||
break
|
||||
|
||||
self.payload = reader.read_bytes(mux_slot_length_bytes)
|
||||
payload = reader.read_bytes(mux_slot_length_bytes)
|
||||
|
||||
if self.stream_mux_config.other_data_present:
|
||||
reader.skip(self.stream_mux_config.other_data_len_bits)
|
||||
if stream_mux_config.other_data_present:
|
||||
reader.skip(stream_mux_config.other_data_len_bits)
|
||||
|
||||
# ByteAlign
|
||||
while reader.bit_position % 8:
|
||||
reader.read(1)
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
return cls(stream_mux_config, payload)
|
||||
|
||||
def to_bits(self, writer: BitWriter) -> None:
|
||||
writer.write(0, 1) # useSameStreamMux = 0
|
||||
self.stream_mux_config.to_bits(writer)
|
||||
mux_slot_length_bytes = len(self.payload)
|
||||
while mux_slot_length_bytes > 255:
|
||||
writer.write(255, 8)
|
||||
mux_slot_length_bytes -= 255
|
||||
writer.write(mux_slot_length_bytes, 8)
|
||||
if mux_slot_length_bytes == 255:
|
||||
writer.write(0, 8)
|
||||
writer.write_bytes(self.payload)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Self:
|
||||
# Parse the bit stream
|
||||
reader = BitReader(data)
|
||||
self.audio_mux_element = self.AudioMuxElement(reader, mux_config_present=1)
|
||||
return cls(cls.AudioMuxElement.from_bits(reader))
|
||||
|
||||
@classmethod
|
||||
def for_simple_aac(
|
||||
cls, sampling_frequency: int, channel_configuration: int, payload: bytes
|
||||
) -> Self:
|
||||
audio_specific_config = cls.AudioSpecificConfig.for_simple_aac(
|
||||
2, sampling_frequency, channel_configuration
|
||||
)
|
||||
stream_mux_config = cls.StreamMuxConfig(0, 0, audio_specific_config)
|
||||
audio_mux_element = cls.AudioMuxElement(stream_mux_config, payload)
|
||||
|
||||
return cls(audio_mux_element)
|
||||
|
||||
def to_adts(self):
|
||||
# pylint: disable=line-too-long
|
||||
@@ -383,3 +521,11 @@ class AacAudioRtpPacket:
|
||||
)
|
||||
+ self.audio_mux_element.payload
|
||||
)
|
||||
|
||||
def __init__(self, audio_mux_element: AudioMuxElement) -> None:
|
||||
self.audio_mux_element = audio_mux_element
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
writer = BitWriter()
|
||||
self.audio_mux_element.to_bits(writer)
|
||||
return bytes(writer)
|
||||
|
||||
@@ -154,15 +154,17 @@ class Controller:
|
||||
'0000000060000000'
|
||||
) # BR/EDR Not Supported, LE Supported (Controller)
|
||||
self.manufacturer_name = 0xFFFF
|
||||
self.hc_data_packet_length = 27
|
||||
self.hc_total_num_data_packets = 64
|
||||
self.hc_le_data_packet_length = 27
|
||||
self.hc_total_num_le_data_packets = 64
|
||||
self.acl_data_packet_length = 27
|
||||
self.total_num_acl_data_packets = 64
|
||||
self.le_acl_data_packet_length = 27
|
||||
self.total_num_le_acl_data_packets = 64
|
||||
self.iso_data_packet_length = 960
|
||||
self.total_num_iso_data_packets = 64
|
||||
self.event_mask = 0
|
||||
self.event_mask_page_2 = 0
|
||||
self.supported_commands = bytes.fromhex(
|
||||
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
|
||||
'30f0f9ff01008004000000000000000000000000000000000000000000000000'
|
||||
'30f0f9ff01008004002000000000000000000000000000000000000000000000'
|
||||
)
|
||||
self.le_event_mask = 0
|
||||
self.advertising_parameters = None
|
||||
@@ -314,7 +316,7 @@ class Controller:
|
||||
f'{color("CONTROLLER -> HOST", "green")}: {packet}'
|
||||
)
|
||||
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
|
||||
async def wait_for_termination(self):
|
||||
@@ -1181,9 +1183,9 @@ class Controller:
|
||||
return struct.pack(
|
||||
'<BHBHH',
|
||||
HCI_SUCCESS,
|
||||
self.hc_data_packet_length,
|
||||
self.acl_data_packet_length,
|
||||
0,
|
||||
self.hc_total_num_data_packets,
|
||||
self.total_num_acl_data_packets,
|
||||
0,
|
||||
)
|
||||
|
||||
@@ -1192,7 +1194,7 @@ class Controller:
|
||||
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
|
||||
'''
|
||||
bd_addr = (
|
||||
self._public_address.to_bytes()
|
||||
bytes(self._public_address)
|
||||
if self._public_address is not None
|
||||
else bytes(6)
|
||||
)
|
||||
@@ -1212,8 +1214,21 @@ class Controller:
|
||||
return struct.pack(
|
||||
'<BHB',
|
||||
HCI_SUCCESS,
|
||||
self.hc_le_data_packet_length,
|
||||
self.hc_total_num_le_data_packets,
|
||||
self.le_acl_data_packet_length,
|
||||
self.total_num_le_acl_data_packets,
|
||||
)
|
||||
|
||||
def on_hci_le_read_buffer_size_v2_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.2 LE Read Buffer Size Command
|
||||
'''
|
||||
return struct.pack(
|
||||
'<BHBHB',
|
||||
HCI_SUCCESS,
|
||||
self.le_acl_data_packet_length,
|
||||
self.total_num_le_acl_data_packets,
|
||||
self.iso_data_packet_length,
|
||||
self.total_num_iso_data_packets,
|
||||
)
|
||||
|
||||
def on_hci_le_read_local_supported_features_command(self, _command):
|
||||
@@ -1543,6 +1558,41 @@ class Controller:
|
||||
}
|
||||
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):
|
||||
'''
|
||||
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)
|
||||
|
||||
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):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
|
||||
|
||||
@@ -1501,7 +1501,10 @@ class AdvertisingData:
|
||||
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
||||
elif ad_type == AdvertisingData.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:
|
||||
ad_type_str = 'TX Power Level'
|
||||
ad_data_str = str(ad_data[0])
|
||||
|
||||
2051
bumble/device.py
2051
bumble/device.py
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@ Common types for drivers.
|
||||
# -----------------------------------------------------------------------------
|
||||
import abc
|
||||
|
||||
from bumble import core
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
|
||||
@@ -11,18 +11,33 @@
|
||||
# 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.
|
||||
"""
|
||||
Support for Intel USB controllers.
|
||||
Loosely based on the Fuchsia OS implementation.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import collections
|
||||
import dataclasses
|
||||
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.hci import (
|
||||
hci_vendor_command_op_code, # type: ignore
|
||||
HCI_Command,
|
||||
HCI_Reset_Command,
|
||||
)
|
||||
from bumble import hci
|
||||
from bumble import utils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.host import Host
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -34,39 +49,328 @@ logger = logging.getLogger(__name__)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
INTEL_USB_PRODUCTS = {
|
||||
# Intel AX210
|
||||
(0x8087, 0x0032),
|
||||
# Intel BE200
|
||||
(0x8087, 0x0036),
|
||||
(0x8087, 0x0032), # AX210
|
||||
(0x8087, 0x0036), # BE200
|
||||
}
|
||||
|
||||
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_INTEL_DDC_CONFIG_WRITE_COMMAND = hci_vendor_command_op_code(0xFC8B) # type: ignore
|
||||
HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD = [0x03, 0xE4, 0x02, 0x00]
|
||||
HCI_INTEL_WRITE_DEVICE_CONFIG_COMMAND = hci.hci_vendor_command_op_code(0x008B)
|
||||
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
|
||||
fields=[("params", "*")],
|
||||
@hci.HCI_Command.command(
|
||||
fields=[
|
||||
("param0", 1),
|
||||
],
|
||||
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
|
||||
|
||||
|
||||
@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):
|
||||
def __init__(self, host):
|
||||
def __init__(self, host: Host) -> None:
|
||||
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
|
||||
def check(host):
|
||||
def check(host: Host) -> bool:
|
||||
driver = host.hci_metadata.get("driver")
|
||||
if driver == "intel":
|
||||
if driver == "intel" or driver is not None and driver.startswith("intel/"):
|
||||
return True
|
||||
|
||||
vendor_id = host.hci_metadata.get("vendor_id")
|
||||
@@ -85,18 +389,283 @@ class Driver(common.Driver):
|
||||
return True
|
||||
|
||||
@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
|
||||
if not force and not cls.check(host):
|
||||
return None
|
||||
|
||||
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
|
||||
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
||||
await self.host.send_command(
|
||||
Hci_Intel_DDC_Config_Write_Command(
|
||||
params=HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD
|
||||
device_info = await self.read_device_info()
|
||||
logger.debug(
|
||||
"device info: \n%s",
|
||||
"\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()
|
||||
|
||||
@@ -28,23 +28,26 @@ import functools
|
||||
import logging
|
||||
import struct
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
SupportsBytes,
|
||||
Type,
|
||||
Union,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.core import BaseBumbleError, UUID
|
||||
from bumble.core import BaseBumbleError, InvalidOperationError, UUID
|
||||
from bumble.att import Attribute, AttributeValue
|
||||
from bumble.utils import ByteSerializable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.gatt_client import AttributeProxy
|
||||
from bumble.device import Connection
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -275,6 +278,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_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
|
||||
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')
|
||||
@@ -304,6 +314,7 @@ GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bi
|
||||
GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B29, 'Client Supported Features')
|
||||
GATT_DATABASE_HASH_CHARACTERISTIC = UUID.from_16_bits(0x2B2A, 'Database Hash')
|
||||
GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B3A, 'Server Supported Features')
|
||||
GATT_LE_GATT_SECURITY_LEVELS_CHARACTERISTIC = UUID.from_16_bits(0x2BF5, 'E GATT Security Levels')
|
||||
|
||||
# fmt: on
|
||||
# pylint: enable=line-too-long
|
||||
@@ -312,8 +323,6 @@ GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bi
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utils
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def show_services(services: Iterable[Service]) -> None:
|
||||
for service in services:
|
||||
print(color(str(service), 'cyan'))
|
||||
@@ -343,7 +352,7 @@ class Service(Attribute):
|
||||
def __init__(
|
||||
self,
|
||||
uuid: Union[str, UUID],
|
||||
characteristics: List[Characteristic],
|
||||
characteristics: Iterable[Characteristic],
|
||||
primary=True,
|
||||
included_services: Iterable[Service] = (),
|
||||
) -> None:
|
||||
@@ -362,7 +371,7 @@ class Service(Attribute):
|
||||
)
|
||||
self.uuid = uuid
|
||||
self.included_services = list(included_services)
|
||||
self.characteristics = characteristics[:]
|
||||
self.characteristics = list(characteristics)
|
||||
self.primary = primary
|
||||
|
||||
def get_advertising_data(self) -> Optional[bytes]:
|
||||
@@ -393,7 +402,7 @@ class TemplateService(Service):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
characteristics: List[Characteristic],
|
||||
characteristics: Iterable[Characteristic],
|
||||
primary: bool = True,
|
||||
included_services: Iterable[Service] = (),
|
||||
) -> None:
|
||||
@@ -410,7 +419,7 @@ class IncludedServiceDeclaration(Attribute):
|
||||
|
||||
def __init__(self, service: Service) -> None:
|
||||
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__(
|
||||
GATT_INCLUDE_ATTRIBUTE_TYPE, Attribute.READABLE, declaration_bytes
|
||||
@@ -490,7 +499,7 @@ class Characteristic(Attribute):
|
||||
uuid: Union[str, bytes, UUID],
|
||||
properties: Characteristic.Properties,
|
||||
permissions: Union[str, Attribute.Permissions],
|
||||
value: Union[str, bytes, CharacteristicValue] = b'',
|
||||
value: Any = b'',
|
||||
descriptors: Sequence[Descriptor] = (),
|
||||
):
|
||||
super().__init__(uuid, permissions, value)
|
||||
@@ -525,7 +534,11 @@ class CharacteristicDeclaration(Attribute):
|
||||
|
||||
characteristic: Characteristic
|
||||
|
||||
def __init__(self, characteristic: Characteristic, value_handle: int) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
characteristic: Characteristic,
|
||||
value_handle: int,
|
||||
) -> None:
|
||||
declaration_bytes = (
|
||||
struct.pack('<BH', characteristic.properties, value_handle)
|
||||
+ characteristic.uuid.to_pdu_bytes()
|
||||
@@ -665,10 +678,14 @@ class DelegatedCharacteristicAdapter(CharacteristicAdapter):
|
||||
self.decode = decode
|
||||
|
||||
def encode_value(self, value):
|
||||
return self.encode(value) if self.encode else value
|
||||
if self.encode is None:
|
||||
raise InvalidOperationError('delegated adapter does not have an encoder')
|
||||
return self.encode(value)
|
||||
|
||||
def decode_value(self, value):
|
||||
return self.decode(value) if self.decode else value
|
||||
if self.decode is None:
|
||||
raise InvalidOperationError('delegate adapter does not have a decoder')
|
||||
return self.decode(value)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -705,7 +722,7 @@ class MappedCharacteristicAdapter(PackedCharacteristicAdapter):
|
||||
'''
|
||||
Adapter that packs/unpacks characteristic values according to a standard
|
||||
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
|
||||
dictionary by key, in the same order as they occur in the `keys` parameter.
|
||||
'''
|
||||
@@ -735,6 +752,24 @@ class UTF8CharacteristicAdapter(CharacteristicAdapter):
|
||||
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):
|
||||
'''
|
||||
@@ -769,3 +804,23 @@ class ClientCharacteristicConfigurationBits(enum.IntFlag):
|
||||
DEFAULT = 0x0000
|
||||
NOTIFICATION = 0x0001
|
||||
INDICATION = 0x0002
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ClientSupportedFeatures(enum.IntFlag):
|
||||
'''
|
||||
See Vol 3, Part G - 7.2 - Table 7.6: Client Supported Features bit assignments.
|
||||
'''
|
||||
|
||||
ROBUST_CACHING = 0x01
|
||||
ENHANCED_ATT_BEARER = 0x02
|
||||
MULTIPLE_HANDLE_VALUE_NOTIFICATIONS = 0x04
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ServerSupportedFeatures(enum.IntFlag):
|
||||
'''
|
||||
See Vol 3, Part G - 7.4 - Table 7.11: Server Supported Features bit assignments.
|
||||
'''
|
||||
|
||||
EATT_SUPPORTED = 0x01
|
||||
|
||||
@@ -78,6 +78,7 @@ from .gatt import (
|
||||
GATT_INCLUDE_ATTRIBUTE_TYPE,
|
||||
Characteristic,
|
||||
ClientCharacteristicConfigurationBits,
|
||||
InvalidServiceError,
|
||||
TemplateService,
|
||||
)
|
||||
|
||||
@@ -162,12 +163,23 @@ class ServiceProxy(AttributeProxy):
|
||||
self.uuid = uuid
|
||||
self.characteristics = []
|
||||
|
||||
async def discover_characteristics(self, uuids=()):
|
||||
async def discover_characteristics(self, uuids=()) -> list[CharacteristicProxy]:
|
||||
return await self.client.discover_characteristics(uuids, self)
|
||||
|
||||
def get_characteristics_by_uuid(self, uuid):
|
||||
def get_characteristics_by_uuid(self, uuid: UUID) -> list[CharacteristicProxy]:
|
||||
"""Get all the characteristics with a specified UUID."""
|
||||
return self.client.get_characteristics_by_uuid(uuid, self)
|
||||
|
||||
def get_required_characteristic_by_uuid(self, uuid: UUID) -> CharacteristicProxy:
|
||||
"""
|
||||
Get the first characteristic with a specified UUID.
|
||||
|
||||
If no characteristic with that UUID is found, an InvalidServiceError is raised.
|
||||
"""
|
||||
if not (characteristics := self.get_characteristics_by_uuid(uuid)):
|
||||
raise InvalidServiceError(f'{uuid} characteristic not found')
|
||||
return characteristics[0]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})'
|
||||
|
||||
@@ -292,7 +304,7 @@ class Client:
|
||||
logger.debug(
|
||||
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):
|
||||
logger.debug(
|
||||
@@ -310,7 +322,7 @@ class Client:
|
||||
self.pending_request = request
|
||||
|
||||
try:
|
||||
self.send_gatt_pdu(request.to_bytes())
|
||||
self.send_gatt_pdu(bytes(request))
|
||||
response = await asyncio.wait_for(
|
||||
self.pending_response, GATT_REQUEST_TIMEOUT
|
||||
)
|
||||
@@ -328,7 +340,7 @@ class Client:
|
||||
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
|
||||
f'{confirmation}'
|
||||
)
|
||||
self.send_gatt_pdu(confirmation.to_bytes())
|
||||
self.send_gatt_pdu(bytes(confirmation))
|
||||
|
||||
async def request_mtu(self, mtu: int) -> int:
|
||||
# Check the range
|
||||
@@ -898,6 +910,12 @@ class Client:
|
||||
) and subscriber in subscribers:
|
||||
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
|
||||
if not subscribers:
|
||||
del subscriber_set[characteristic.handle]
|
||||
|
||||
@@ -28,7 +28,17 @@ import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
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 bumble.colors import color
|
||||
@@ -68,6 +78,7 @@ from bumble.gatt import (
|
||||
GATT_REQUEST_TIMEOUT,
|
||||
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
||||
Characteristic,
|
||||
CharacteristicAdapter,
|
||||
CharacteristicDeclaration,
|
||||
CharacteristicValue,
|
||||
IncludedServiceDeclaration,
|
||||
@@ -353,7 +364,7 @@ class Server(EventEmitter):
|
||||
logger.debug(
|
||||
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(
|
||||
self,
|
||||
@@ -450,7 +461,7 @@ class Server(EventEmitter):
|
||||
)
|
||||
|
||||
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)
|
||||
except asyncio.TimeoutError as error:
|
||||
logger.warning(color('!!! GATT Indicate timeout', 'red'))
|
||||
|
||||
1037
bumble/hci.py
1037
bumble/hci.py
File diff suppressed because it is too large
Load Diff
123
bumble/hfp.py
123
bumble/hfp.py
@@ -141,7 +141,7 @@ class HfFeature(enum.IntFlag):
|
||||
"""
|
||||
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
|
||||
@@ -155,14 +155,14 @@ class HfFeature(enum.IntFlag):
|
||||
HF_INDICATORS = 0x100
|
||||
ESCO_S4_SETTINGS_SUPPORTED = 0x200
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x400
|
||||
VOICE_RECOGNITION_TEST = 0x800
|
||||
VOICE_RECOGNITION_TEXT = 0x800
|
||||
|
||||
|
||||
class AgFeature(enum.IntFlag):
|
||||
"""
|
||||
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
|
||||
@@ -178,7 +178,7 @@ class AgFeature(enum.IntFlag):
|
||||
HF_INDICATORS = 0x400
|
||||
ESCO_S4_SETTINGS_SUPPORTED = 0x800
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x1000
|
||||
VOICE_RECOGNITION_TEST = 0x2000
|
||||
VOICE_RECOGNITION_TEXT = 0x2000
|
||||
|
||||
|
||||
class AudioCodec(enum.IntEnum):
|
||||
@@ -795,29 +795,32 @@ class HfProtocol(pyee.EventEmitter):
|
||||
# Append to the read buffer.
|
||||
self.read_buffer.extend(data)
|
||||
|
||||
# Locate header and trailer.
|
||||
header = self.read_buffer.find(b'\r\n')
|
||||
trailer = self.read_buffer.find(b'\r\n', header + 2)
|
||||
if header == -1 or trailer == -1:
|
||||
return
|
||||
while self.read_buffer:
|
||||
# Locate header and trailer.
|
||||
header = self.read_buffer.find(b'\r\n')
|
||||
trailer = self.read_buffer.find(b'\r\n', header + 2)
|
||||
if header == -1 or trailer == -1:
|
||||
return
|
||||
|
||||
# Isolate the AT response code and parameters.
|
||||
raw_response = self.read_buffer[header + 2 : trailer]
|
||||
response = AtResponse.parse_from(raw_response)
|
||||
logger.debug(f"<<< {raw_response.decode()}")
|
||||
# Isolate the AT response code and parameters.
|
||||
raw_response = self.read_buffer[header + 2 : trailer]
|
||||
response = AtResponse.parse_from(raw_response)
|
||||
logger.debug(f"<<< {raw_response.decode()}")
|
||||
|
||||
# Consume the response bytes.
|
||||
self.read_buffer = self.read_buffer[trailer + 2 :]
|
||||
# Consume the response bytes.
|
||||
self.read_buffer = self.read_buffer[trailer + 2 :]
|
||||
|
||||
# Forward the received code to the correct queue.
|
||||
if self.command_lock.locked() and (
|
||||
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
||||
):
|
||||
self.response_queue.put_nowait(response)
|
||||
elif response.code in UNSOLICITED_CODES:
|
||||
self.unsolicited_queue.put_nowait(response)
|
||||
else:
|
||||
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
||||
# Forward the received code to the correct queue.
|
||||
if self.command_lock.locked() and (
|
||||
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
||||
):
|
||||
self.response_queue.put_nowait(response)
|
||||
elif response.code in UNSOLICITED_CODES:
|
||||
self.unsolicited_queue.put_nowait(response)
|
||||
else:
|
||||
logger.warning(
|
||||
f"dropping unexpected response with code '{response.code}'"
|
||||
)
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
@@ -1244,31 +1247,32 @@ class AgProtocol(pyee.EventEmitter):
|
||||
# Append to the read buffer.
|
||||
self.read_buffer.extend(data)
|
||||
|
||||
# Locate the trailer.
|
||||
trailer = self.read_buffer.find(b'\r')
|
||||
if trailer == -1:
|
||||
return
|
||||
while self.read_buffer:
|
||||
# Locate the trailer.
|
||||
trailer = self.read_buffer.find(b'\r')
|
||||
if trailer == -1:
|
||||
return
|
||||
|
||||
# Isolate the AT response code and parameters.
|
||||
raw_command = self.read_buffer[:trailer]
|
||||
command = AtCommand.parse_from(raw_command)
|
||||
logger.debug(f"<<< {raw_command.decode()}")
|
||||
# Isolate the AT response code and parameters.
|
||||
raw_command = self.read_buffer[:trailer]
|
||||
command = AtCommand.parse_from(raw_command)
|
||||
logger.debug(f"<<< {raw_command.decode()}")
|
||||
|
||||
# Consume the response bytes.
|
||||
self.read_buffer = self.read_buffer[trailer + 1 :]
|
||||
# Consume the response bytes.
|
||||
self.read_buffer = self.read_buffer[trailer + 1 :]
|
||||
|
||||
if command.sub_code == AtCommand.SubCode.TEST:
|
||||
handler_name = f'_on_{command.code.lower()}_test'
|
||||
elif command.sub_code == AtCommand.SubCode.READ:
|
||||
handler_name = f'_on_{command.code.lower()}_read'
|
||||
else:
|
||||
handler_name = f'_on_{command.code.lower()}'
|
||||
if command.sub_code == AtCommand.SubCode.TEST:
|
||||
handler_name = f'_on_{command.code.lower()}_test'
|
||||
elif command.sub_code == AtCommand.SubCode.READ:
|
||||
handler_name = f'_on_{command.code.lower()}_read'
|
||||
else:
|
||||
handler_name = f'_on_{command.code.lower()}'
|
||||
|
||||
if handler := getattr(self, handler_name, None):
|
||||
handler(*command.parameters)
|
||||
else:
|
||||
logger.warning('Handler %s not found', handler_name)
|
||||
self.send_response('ERROR')
|
||||
if handler := getattr(self, handler_name, None):
|
||||
handler(*command.parameters)
|
||||
else:
|
||||
logger.warning('Handler %s not found', handler_name)
|
||||
self.send_response('ERROR')
|
||||
|
||||
def send_response(self, response: str) -> None:
|
||||
"""Sends an AT response."""
|
||||
@@ -1386,6 +1390,7 @@ class AgProtocol(pyee.EventEmitter):
|
||||
|
||||
def _on_bac(self, *args) -> None:
|
||||
self.supported_audio_codecs = [AudioCodec(int(value)) for value in args]
|
||||
self.emit('supported_audio_codecs', self.supported_audio_codecs)
|
||||
self.send_ok()
|
||||
|
||||
def _on_bcs(self, codec: bytes) -> None:
|
||||
@@ -1614,7 +1619,7 @@ class ProfileVersion(enum.IntEnum):
|
||||
"""
|
||||
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
|
||||
@@ -1628,7 +1633,7 @@ class HfSdpFeature(enum.IntFlag):
|
||||
"""
|
||||
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
|
||||
@@ -1636,16 +1641,17 @@ class HfSdpFeature(enum.IntFlag):
|
||||
CLI_PRESENTATION_CAPABILITY = 0x04
|
||||
VOICE_RECOGNITION_ACTIVATION = 0x08
|
||||
REMOTE_VOLUME_CONTROL = 0x10
|
||||
WIDE_BAND = 0x20 # Wide band speech
|
||||
WIDE_BAND_SPEECH = 0x20
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||
VOICE_RECOGNITION_TEST = 0x80
|
||||
VOICE_RECOGNITION_TEXT = 0x80
|
||||
SUPER_WIDE_BAND = 0x100
|
||||
|
||||
|
||||
class AgSdpFeature(enum.IntFlag):
|
||||
"""
|
||||
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
|
||||
@@ -1653,9 +1659,10 @@ class AgSdpFeature(enum.IntFlag):
|
||||
VOICE_RECOGNITION_FUNCTION = 0x04
|
||||
IN_BAND_RING_TONE_CAPABILITY = 0x08
|
||||
VOICE_TAG = 0x10 # Attach a number to voice tag
|
||||
WIDE_BAND = 0x20 # Wide band speech
|
||||
WIDE_BAND_SPEECH = 0x20
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||
VOICE_RECOGNITION_TEST = 0x80
|
||||
VOICE_RECOGNITION_TEXT = 0x80
|
||||
SUPER_WIDE_BAND_SPEED_SPEECH = 0x100
|
||||
|
||||
|
||||
def make_hf_sdp_records(
|
||||
@@ -1688,11 +1695,11 @@ def make_hf_sdp_records(
|
||||
in configuration.supported_hf_features
|
||||
):
|
||||
hf_supported_features |= HfSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||
if HfFeature.VOICE_RECOGNITION_TEST in configuration.supported_hf_features:
|
||||
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEST
|
||||
if HfFeature.VOICE_RECOGNITION_TEXT in configuration.supported_hf_features:
|
||||
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEXT
|
||||
|
||||
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
||||
hf_supported_features |= HfSdpFeature.WIDE_BAND
|
||||
hf_supported_features |= HfSdpFeature.WIDE_BAND_SPEECH
|
||||
|
||||
return [
|
||||
sdp.ServiceAttribute(
|
||||
@@ -1768,14 +1775,14 @@ def make_ag_sdp_records(
|
||||
in configuration.supported_ag_features
|
||||
):
|
||||
ag_supported_features |= AgSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||
if AgFeature.VOICE_RECOGNITION_TEST in configuration.supported_ag_features:
|
||||
ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_TEST
|
||||
if AgFeature.VOICE_RECOGNITION_TEXT in configuration.supported_ag_features:
|
||||
ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_TEXT
|
||||
if AgFeature.IN_BAND_RING_TONE_CAPABILITY in configuration.supported_ag_features:
|
||||
ag_supported_features |= AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
|
||||
if AgFeature.VOICE_RECOGNITION_FUNCTION in configuration.supported_ag_features:
|
||||
ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_FUNCTION
|
||||
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
||||
ag_supported_features |= AgSdpFeature.WIDE_BAND
|
||||
ag_supported_features |= AgSdpFeature.WIDE_BAND_SPEECH
|
||||
|
||||
return [
|
||||
sdp.ServiceAttribute(
|
||||
|
||||
448
bumble/host.py
448
bumble/host.py
@@ -1,4 +1,4 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
# Copyright 2021-2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -34,6 +34,8 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
import pyee
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.l2cap import L2CAP_PDU
|
||||
from bumble.snoop import Snooper
|
||||
@@ -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
|
||||
|
||||
def __init__(
|
||||
@@ -68,40 +82,105 @@ class AclPacketQueue:
|
||||
max_in_flight: int,
|
||||
send: Callable[[hci.HCI_Packet], None],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.max_packet_size = max_packet_size
|
||||
self.max_in_flight = max_in_flight
|
||||
self.in_flight = 0
|
||||
self.send = send
|
||||
self.packets: Deque[hci.HCI_AclDataPacket] = collections.deque()
|
||||
self._in_flight = 0 # Total number of packets in flight across all connections
|
||||
self._in_flight_per_connection: dict[int, int] = collections.defaultdict(
|
||||
int
|
||||
) # Number of packets in flight per connection
|
||||
self._send = send
|
||||
self._packets: Deque[tuple[hci.HCI_Packet, int]] = collections.deque()
|
||||
self._queued = 0
|
||||
self._completed = 0
|
||||
|
||||
def enqueue(self, packet: hci.HCI_AclDataPacket) -> None:
|
||||
self.packets.appendleft(packet)
|
||||
self.check_queue()
|
||||
@property
|
||||
def queued(self) -> int:
|
||||
"""Total number of packets queued since creation."""
|
||||
return self._queued
|
||||
|
||||
if self.packets:
|
||||
@property
|
||||
def completed(self) -> int:
|
||||
"""Total number of packets completed since creation."""
|
||||
return self._completed
|
||||
|
||||
@property
|
||||
def pending(self) -> int:
|
||||
"""Number of packets that have been queued but not completed."""
|
||||
return self._queued - self._completed
|
||||
|
||||
def enqueue(self, packet: hci.HCI_Packet, connection_handle: int) -> None:
|
||||
"""Enqueue a packet associated with a connection"""
|
||||
self._packets.appendleft((packet, connection_handle))
|
||||
self._queued += 1
|
||||
self._check_queue()
|
||||
|
||||
if self._packets:
|
||||
logger.debug(
|
||||
f'{self.in_flight} ACL packets in flight, '
|
||||
f'{len(self.packets)} in queue'
|
||||
f'{self._in_flight} packets in flight, '
|
||||
f'{len(self._packets)} in queue'
|
||||
)
|
||||
|
||||
def check_queue(self) -> None:
|
||||
while self.packets and self.in_flight < self.max_in_flight:
|
||||
packet = self.packets.pop()
|
||||
self.send(packet)
|
||||
self.in_flight += 1
|
||||
def flush(self, connection_handle: int) -> None:
|
||||
"""
|
||||
Remove all packets associated with a connection.
|
||||
|
||||
def on_packets_completed(self, packet_count: int) -> None:
|
||||
if packet_count > self.in_flight:
|
||||
All packets associated with the connection that are in flight are implicitly
|
||||
marked as completed, but no 'flow' event is emitted.
|
||||
"""
|
||||
|
||||
packets_to_keep = [
|
||||
(packet, handle)
|
||||
for (packet, handle) in self._packets
|
||||
if handle != connection_handle
|
||||
]
|
||||
if flushed_count := len(self._packets) - len(packets_to_keep):
|
||||
self._completed += flushed_count
|
||||
self._packets = collections.deque(packets_to_keep)
|
||||
|
||||
if connection_handle in self._in_flight_per_connection:
|
||||
in_flight = self._in_flight_per_connection[connection_handle]
|
||||
self._completed += in_flight
|
||||
self._in_flight -= in_flight
|
||||
del self._in_flight_per_connection[connection_handle]
|
||||
|
||||
def _check_queue(self) -> None:
|
||||
while self._packets and self._in_flight < self.max_in_flight:
|
||||
packet, connection_handle = self._packets.pop()
|
||||
self._send(packet)
|
||||
self._in_flight += 1
|
||||
self._in_flight_per_connection[connection_handle] += 1
|
||||
|
||||
def on_packets_completed(self, packet_count: int, connection_handle: int) -> None:
|
||||
"""Mark one or more packets associated with a connection as completed."""
|
||||
if connection_handle not in self._in_flight_per_connection:
|
||||
logger.warning(
|
||||
color(
|
||||
'!!! {packet_count} completed but only '
|
||||
f'{self.in_flight} in flight'
|
||||
)
|
||||
f'received completion for unknown connection {connection_handle}'
|
||||
)
|
||||
packet_count = self.in_flight
|
||||
return
|
||||
|
||||
self.in_flight -= packet_count
|
||||
self.check_queue()
|
||||
in_flight_for_connection = self._in_flight_per_connection[connection_handle]
|
||||
if packet_count <= in_flight_for_connection:
|
||||
self._in_flight_per_connection[connection_handle] -= packet_count
|
||||
else:
|
||||
logger.warning(
|
||||
f'{packet_count} completed for {connection_handle} '
|
||||
f'but only {in_flight_for_connection} in flight'
|
||||
)
|
||||
self._in_flight_per_connection[connection_handle] = 0
|
||||
|
||||
if packet_count <= self._in_flight:
|
||||
self._in_flight -= packet_count
|
||||
self._completed += packet_count
|
||||
else:
|
||||
logger.warning(
|
||||
f'{packet_count} completed but only {self._in_flight} in flight'
|
||||
)
|
||||
self._in_flight = 0
|
||||
self._completed = self._queued
|
||||
|
||||
self._check_queue()
|
||||
self.emit('flow')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -114,7 +193,7 @@ class Connection:
|
||||
self.peer_address = peer_address
|
||||
self.assembler = hci.HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||
self.transport = transport
|
||||
acl_packet_queue: Optional[AclPacketQueue] = (
|
||||
acl_packet_queue: Optional[DataPacketQueue] = (
|
||||
host.le_acl_packet_queue
|
||||
if transport == BT_LE_TRANSPORT
|
||||
else host.acl_packet_queue
|
||||
@@ -129,28 +208,37 @@ class Connection:
|
||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Connection(transport={self.transport}, peer_address={self.peer_address})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class ScoLink:
|
||||
peer_address: hci.Address
|
||||
handle: int
|
||||
connection_handle: int
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class CisLink:
|
||||
peer_address: hci.Address
|
||||
class IsoLink:
|
||||
handle: int
|
||||
packet_queue: DataPacketQueue = dataclasses.field(repr=False)
|
||||
packet_sequence_number: int = 0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Host(AbortableEventEmitter):
|
||||
connections: Dict[int, Connection]
|
||||
cis_links: Dict[int, CisLink]
|
||||
cis_links: Dict[int, IsoLink]
|
||||
bis_links: Dict[int, IsoLink]
|
||||
sco_links: Dict[int, ScoLink]
|
||||
acl_packet_queue: Optional[AclPacketQueue] = None
|
||||
le_acl_packet_queue: Optional[AclPacketQueue] = None
|
||||
bigs: dict[int, set[int]] = {} # BIG Handle to BIS Handles
|
||||
acl_packet_queue: Optional[DataPacketQueue] = None
|
||||
le_acl_packet_queue: Optional[DataPacketQueue] = None
|
||||
iso_packet_queue: Optional[DataPacketQueue] = None
|
||||
hci_sink: Optional[TransportSink] = None
|
||||
hci_metadata: Dict[str, Any]
|
||||
long_term_key_provider: Optional[
|
||||
@@ -169,6 +257,7 @@ class Host(AbortableEventEmitter):
|
||||
self.ready = False # True when we can accept incoming packets
|
||||
self.connections = {} # Connections, by connection handle
|
||||
self.cis_links = {} # CIS links, by connection handle
|
||||
self.bis_links = {} # BIS links, by connection handle
|
||||
self.sco_links = {} # SCO links, by connection handle
|
||||
self.pending_command = None
|
||||
self.pending_response: Optional[asyncio.Future[Any]] = None
|
||||
@@ -199,7 +288,7 @@ class Host(AbortableEventEmitter):
|
||||
check_address_type: bool = False,
|
||||
) -> Optional[Connection]:
|
||||
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 (
|
||||
check_address_type
|
||||
and connection.peer_address.address_type != bd_addr.address_type
|
||||
@@ -387,6 +476,12 @@ class Host(AbortableEventEmitter):
|
||||
hci.HCI_LE_TRANSMIT_POWER_REPORTING_EVENT,
|
||||
hci.HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT,
|
||||
hci.HCI_LE_SUBRATE_CHANGE_EVENT,
|
||||
hci.HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMPLETE_EVENT,
|
||||
hci.HCI_LE_CS_PROCEDURE_ENABLE_COMPLETE_EVENT,
|
||||
hci.HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT,
|
||||
hci.HCI_LE_CS_CONFIG_COMPLETE_EVENT,
|
||||
hci.HCI_LE_CS_SUBEVENT_RESULT_EVENT,
|
||||
hci.HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -411,39 +506,70 @@ class Host(AbortableEventEmitter):
|
||||
f'hc_total_num_acl_data_packets={hc_total_num_acl_data_packets}'
|
||||
)
|
||||
|
||||
self.acl_packet_queue = AclPacketQueue(
|
||||
self.acl_packet_queue = DataPacketQueue(
|
||||
max_packet_size=hc_acl_data_packet_length,
|
||||
max_in_flight=hc_total_num_acl_data_packets,
|
||||
send=self.send_hci_packet,
|
||||
)
|
||||
|
||||
hc_le_acl_data_packet_length = 0
|
||||
hc_total_num_le_acl_data_packets = 0
|
||||
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||
le_acl_data_packet_length = 0
|
||||
total_num_le_acl_data_packets = 0
|
||||
iso_data_packet_length = 0
|
||||
total_num_iso_data_packets = 0
|
||||
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
||||
response = await self.send_command(
|
||||
hci.HCI_LE_Read_Buffer_Size_V2_Command(), check_result=True
|
||||
)
|
||||
le_acl_data_packet_length = (
|
||||
response.return_parameters.le_acl_data_packet_length
|
||||
)
|
||||
total_num_le_acl_data_packets = (
|
||||
response.return_parameters.total_num_le_acl_data_packets
|
||||
)
|
||||
iso_data_packet_length = response.return_parameters.iso_data_packet_length
|
||||
total_num_iso_data_packets = (
|
||||
response.return_parameters.total_num_iso_data_packets
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'HCI LE flow control: '
|
||||
f'le_acl_data_packet_length={le_acl_data_packet_length},'
|
||||
f'total_num_le_acl_data_packets={total_num_le_acl_data_packets}'
|
||||
f'iso_data_packet_length={iso_data_packet_length},'
|
||||
f'total_num_iso_data_packets={total_num_iso_data_packets}'
|
||||
)
|
||||
elif self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||
response = await self.send_command(
|
||||
hci.HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
||||
)
|
||||
hc_le_acl_data_packet_length = (
|
||||
response.return_parameters.hc_le_acl_data_packet_length
|
||||
le_acl_data_packet_length = (
|
||||
response.return_parameters.le_acl_data_packet_length
|
||||
)
|
||||
hc_total_num_le_acl_data_packets = (
|
||||
response.return_parameters.hc_total_num_le_acl_data_packets
|
||||
total_num_le_acl_data_packets = (
|
||||
response.return_parameters.total_num_le_acl_data_packets
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'HCI LE ACL flow control: '
|
||||
f'hc_le_acl_data_packet_length={hc_le_acl_data_packet_length},'
|
||||
f'hc_total_num_le_acl_data_packets={hc_total_num_le_acl_data_packets}'
|
||||
f'le_acl_data_packet_length={le_acl_data_packet_length},'
|
||||
f'total_num_le_acl_data_packets={total_num_le_acl_data_packets}'
|
||||
)
|
||||
|
||||
if hc_le_acl_data_packet_length == 0 or hc_total_num_le_acl_data_packets == 0:
|
||||
if le_acl_data_packet_length == 0 or total_num_le_acl_data_packets == 0:
|
||||
# LE and Classic share the same queue
|
||||
self.le_acl_packet_queue = self.acl_packet_queue
|
||||
else:
|
||||
# Create a separate queue for LE
|
||||
self.le_acl_packet_queue = AclPacketQueue(
|
||||
max_packet_size=hc_le_acl_data_packet_length,
|
||||
max_in_flight=hc_total_num_le_acl_data_packets,
|
||||
self.le_acl_packet_queue = DataPacketQueue(
|
||||
max_packet_size=le_acl_data_packet_length,
|
||||
max_in_flight=total_num_le_acl_data_packets,
|
||||
send=self.send_hci_packet,
|
||||
)
|
||||
|
||||
if iso_data_packet_length and total_num_iso_data_packets:
|
||||
self.iso_packet_queue = DataPacketQueue(
|
||||
max_packet_size=iso_data_packet_length,
|
||||
max_in_flight=total_num_iso_data_packets,
|
||||
send=self.send_hci_packet,
|
||||
)
|
||||
|
||||
@@ -552,7 +678,7 @@ class Host(AbortableEventEmitter):
|
||||
|
||||
return response
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
logger.exception(
|
||||
f'{color("!!! Exception while sending command:", "red")} {error}'
|
||||
)
|
||||
raise error
|
||||
@@ -595,11 +721,78 @@ class Host(AbortableEventEmitter):
|
||||
data=l2cap_pdu[offset : offset + data_total_length],
|
||||
)
|
||||
logger.debug(f'>>> ACL packet enqueue: (CID={cid}) {acl_packet}')
|
||||
packet_queue.enqueue(acl_packet)
|
||||
packet_queue.enqueue(acl_packet, connection_handle)
|
||||
pb_flag = 1
|
||||
offset += data_total_length
|
||||
bytes_remaining -= data_total_length
|
||||
|
||||
def get_data_packet_queue(self, connection_handle: int) -> DataPacketQueue | None:
|
||||
if connection := self.connections.get(connection_handle):
|
||||
return connection.acl_packet_queue
|
||||
|
||||
if iso_link := self.cis_links.get(connection_handle) or self.bis_links.get(
|
||||
connection_handle
|
||||
):
|
||||
return iso_link.packet_queue
|
||||
|
||||
return None
|
||||
|
||||
def send_iso_sdu(self, connection_handle: int, sdu: bytes) -> None:
|
||||
if not (
|
||||
iso_link := self.cis_links.get(connection_handle)
|
||||
or self.bis_links.get(connection_handle)
|
||||
):
|
||||
logger.warning(f"no ISO link for connection handle {connection_handle}")
|
||||
return
|
||||
|
||||
if iso_link.packet_queue is None:
|
||||
logger.warning("ISO link has no data packet queue")
|
||||
return
|
||||
|
||||
bytes_remaining = len(sdu)
|
||||
offset = 0
|
||||
while bytes_remaining:
|
||||
is_first_fragment = offset == 0
|
||||
header_length = 4 if is_first_fragment else 0
|
||||
assert iso_link.packet_queue.max_packet_size > header_length
|
||||
fragment_length = min(
|
||||
bytes_remaining, iso_link.packet_queue.max_packet_size - header_length
|
||||
)
|
||||
is_last_fragment = bytes_remaining == fragment_length
|
||||
iso_sdu_fragment = sdu[offset : offset + fragment_length]
|
||||
iso_link.packet_queue.enqueue(
|
||||
(
|
||||
hci.HCI_IsoDataPacket(
|
||||
connection_handle=connection_handle,
|
||||
data_total_length=header_length + fragment_length,
|
||||
packet_sequence_number=iso_link.packet_sequence_number,
|
||||
pb_flag=0b10 if is_last_fragment else 0b00,
|
||||
packet_status_flag=0,
|
||||
iso_sdu_length=len(sdu),
|
||||
iso_sdu_fragment=iso_sdu_fragment,
|
||||
)
|
||||
if is_first_fragment
|
||||
else hci.HCI_IsoDataPacket(
|
||||
connection_handle=connection_handle,
|
||||
data_total_length=fragment_length,
|
||||
pb_flag=0b11 if is_last_fragment else 0b01,
|
||||
iso_sdu_fragment=iso_sdu_fragment,
|
||||
)
|
||||
),
|
||||
connection_handle,
|
||||
)
|
||||
|
||||
offset += fragment_length
|
||||
bytes_remaining -= fragment_length
|
||||
|
||||
iso_link.packet_sequence_number = (iso_link.packet_sequence_number + 1) & 0xFFFF
|
||||
|
||||
def remove_big(self, big_handle: int) -> None:
|
||||
if big := self.bigs.pop(big_handle, None):
|
||||
for connection_handle in big:
|
||||
if bis_link := self.bis_links.pop(connection_handle, None):
|
||||
bis_link.packet_queue.flush(bis_link.handle)
|
||||
|
||||
def supports_command(self, op_code: int) -> bool:
|
||||
return (
|
||||
self.local_supported_commands
|
||||
@@ -727,16 +920,17 @@ class Host(AbortableEventEmitter):
|
||||
def on_hci_command_status_event(self, event):
|
||||
return self.on_command_processed(event)
|
||||
|
||||
def on_hci_number_of_completed_packets_event(self, event):
|
||||
def on_hci_number_of_completed_packets_event(
|
||||
self, event: hci.HCI_Number_Of_Completed_Packets_Event
|
||||
) -> None:
|
||||
for connection_handle, num_completed_packets in zip(
|
||||
event.connection_handles, event.num_completed_packets
|
||||
):
|
||||
if connection := self.connections.get(connection_handle):
|
||||
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
|
||||
elif not (
|
||||
self.cis_links.get(connection_handle)
|
||||
or self.sco_links.get(connection_handle)
|
||||
):
|
||||
if queue := self.get_data_packet_queue(connection_handle):
|
||||
queue.on_packets_completed(num_completed_packets, connection_handle)
|
||||
continue
|
||||
|
||||
if connection_handle not in self.sco_links:
|
||||
logger.warning(
|
||||
'received packet completion event for unknown handle '
|
||||
f'0x{connection_handle:04X}'
|
||||
@@ -854,11 +1048,7 @@ class Host(AbortableEventEmitter):
|
||||
return
|
||||
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
logger.debug(
|
||||
f'### DISCONNECTION: [0x{handle:04X}] '
|
||||
f'{connection.peer_address} '
|
||||
f'reason={event.reason}'
|
||||
)
|
||||
logger.debug(f'### DISCONNECTION: {connection}, reason={event.reason}')
|
||||
|
||||
# Notify the listeners
|
||||
self.emit('disconnection', handle, event.reason)
|
||||
@@ -869,6 +1059,12 @@ class Host(AbortableEventEmitter):
|
||||
or self.cis_links.pop(handle, 0)
|
||||
or self.sco_links.pop(handle, 0)
|
||||
)
|
||||
|
||||
# Flush the data queues
|
||||
self.acl_packet_queue.flush(handle)
|
||||
self.le_acl_packet_queue.flush(handle)
|
||||
if self.iso_packet_queue:
|
||||
self.iso_packet_queue.flush(handle)
|
||||
else:
|
||||
logger.debug(f'### DISCONNECTION FAILED: {event.status}')
|
||||
|
||||
@@ -902,8 +1098,11 @@ class Host(AbortableEventEmitter):
|
||||
|
||||
# Notify the client
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
connection_phy = ConnectionPHY(event.tx_phy, event.rx_phy)
|
||||
self.emit('connection_phy_update', connection.handle, connection_phy)
|
||||
self.emit(
|
||||
'connection_phy_update',
|
||||
connection.handle,
|
||||
ConnectionPHY(event.tx_phy, event.rx_phy),
|
||||
)
|
||||
else:
|
||||
self.emit('connection_phy_update_failure', connection.handle, event.status)
|
||||
|
||||
@@ -953,12 +1152,94 @@ class Host(AbortableEventEmitter):
|
||||
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_periodic_advertising_sync_transfer_received_event(self, event):
|
||||
self.emit(
|
||||
'periodic_advertising_sync_transfer',
|
||||
event.status,
|
||||
event.connection_handle,
|
||||
event.sync_handle,
|
||||
event.advertising_sid,
|
||||
event.advertiser_address,
|
||||
event.advertiser_phy,
|
||||
event.periodic_advertising_interval,
|
||||
event.advertiser_clock_accuracy,
|
||||
)
|
||||
|
||||
def on_hci_le_periodic_advertising_sync_transfer_received_v2_event(self, event):
|
||||
self.emit(
|
||||
'periodic_advertising_sync_transfer',
|
||||
event.status,
|
||||
event.connection_handle,
|
||||
event.sync_handle,
|
||||
event.advertising_sid,
|
||||
event.advertiser_address,
|
||||
event.advertiser_phy,
|
||||
event.periodic_advertising_interval,
|
||||
event.advertiser_clock_accuracy,
|
||||
)
|
||||
|
||||
def on_hci_le_cis_established_event(self, event):
|
||||
# The remaining parameters are unused for now.
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
self.cis_links[event.connection_handle] = CisLink(
|
||||
handle=event.connection_handle,
|
||||
peer_address=hci.Address.ANY,
|
||||
if self.iso_packet_queue is None:
|
||||
logger.warning("CIS established but ISO packets not supported")
|
||||
self.cis_links[event.connection_handle] = IsoLink(
|
||||
handle=event.connection_handle, packet_queue=self.iso_packet_queue
|
||||
)
|
||||
self.emit('cis_establishment', event.connection_handle)
|
||||
else:
|
||||
@@ -1028,7 +1309,7 @@ class Host(AbortableEventEmitter):
|
||||
|
||||
self.sco_links[event.connection_handle] = ScoLink(
|
||||
peer_address=event.bd_addr,
|
||||
handle=event.connection_handle,
|
||||
connection_handle=event.connection_handle,
|
||||
)
|
||||
|
||||
# Notify the client
|
||||
@@ -1106,6 +1387,18 @@ class Host(AbortableEventEmitter):
|
||||
event.status,
|
||||
)
|
||||
|
||||
def on_hci_qos_setup_complete_event(self, event):
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
self.emit(
|
||||
'connection_qos_setup', event.connection_handle, event.service_type
|
||||
)
|
||||
else:
|
||||
self.emit(
|
||||
'connection_qos_setup_failure',
|
||||
event.connection_handle,
|
||||
event.status,
|
||||
)
|
||||
|
||||
def on_hci_link_supervision_timeout_changed_event(self, event):
|
||||
pass
|
||||
|
||||
@@ -1236,3 +1529,24 @@ class Host(AbortableEventEmitter):
|
||||
event.connection_handle,
|
||||
int.from_bytes(event.le_features, 'little'),
|
||||
)
|
||||
|
||||
def on_hci_le_cs_read_remote_supported_capabilities_complete_event(self, event):
|
||||
self.emit('cs_remote_supported_capabilities', event)
|
||||
|
||||
def on_hci_le_cs_security_enable_complete_event(self, event):
|
||||
self.emit('cs_security', event)
|
||||
|
||||
def on_hci_le_cs_config_complete_event(self, event):
|
||||
self.emit('cs_config', event)
|
||||
|
||||
def on_hci_le_cs_procedure_enable_complete_event(self, event):
|
||||
self.emit('cs_procedure', event)
|
||||
|
||||
def on_hci_le_cs_subevent_result_event(self, event):
|
||||
self.emit('cs_subevent_result', event)
|
||||
|
||||
def on_hci_le_cs_subevent_result_continue_event(self, event):
|
||||
self.emit('cs_subevent_result_continue', event)
|
||||
|
||||
def on_hci_vendor_event(self, event):
|
||||
self.emit('vendor_event', event)
|
||||
|
||||
@@ -225,7 +225,7 @@ class L2CAP_PDU:
|
||||
|
||||
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)
|
||||
return header + self.payload
|
||||
|
||||
@@ -233,9 +233,6 @@ class L2CAP_PDU:
|
||||
self.cid = cid
|
||||
self.payload = payload
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.to_bytes()
|
||||
|
||||
def __str__(self) -> str:
|
||||
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):
|
||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
return self.pdu
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.to_bytes()
|
||||
return self.pdu
|
||||
|
||||
def __str__(self) -> str:
|
||||
result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
|
||||
@@ -779,7 +773,6 @@ class ClassicChannel(EventEmitter):
|
||||
self.psm = psm
|
||||
self.source_cid = source_cid
|
||||
self.destination_cid = 0
|
||||
self.response = None
|
||||
self.connection_result = None
|
||||
self.disconnection_result = None
|
||||
self.sink = None
|
||||
@@ -789,27 +782,15 @@ class ClassicChannel(EventEmitter):
|
||||
self.state = new_state
|
||||
|
||||
def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
|
||||
if self.state != self.State.OPEN:
|
||||
raise InvalidStateError('channel not open')
|
||||
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
||||
|
||||
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
||||
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
|
||||
|
||||
async def send_request(self, request: SupportsBytes) -> bytes:
|
||||
# Check that there isn't already a request pending
|
||||
if self.response:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.state != self.State.OPEN:
|
||||
raise InvalidStateError('channel not open')
|
||||
|
||||
self.response = asyncio.get_running_loop().create_future()
|
||||
self.send_pdu(request)
|
||||
return await self.response
|
||||
|
||||
def on_pdu(self, pdu: bytes) -> None:
|
||||
if self.response:
|
||||
self.response.set_result(pdu)
|
||||
self.response = None
|
||||
elif self.sink:
|
||||
if self.sink:
|
||||
# pylint: disable=not-callable
|
||||
self.sink(pdu)
|
||||
else:
|
||||
@@ -1911,6 +1892,7 @@ class ChannelManager:
|
||||
data = sum(1 << cid for cid in self.fixed_channels).to_bytes(8, 'little')
|
||||
else:
|
||||
result = L2CAP_Information_Response.NOT_SUPPORTED
|
||||
data = b''
|
||||
|
||||
self.send_control_frame(
|
||||
connection,
|
||||
|
||||
@@ -122,6 +122,8 @@ class LocalLink:
|
||||
elif transport == BT_BR_EDR_TRANSPORT:
|
||||
destination_controller = self.find_classic_controller(destination_address)
|
||||
source_address = sender_controller.public_address
|
||||
else:
|
||||
raise ValueError("unsupported transport type")
|
||||
|
||||
if destination_controller is not None:
|
||||
destination_controller.on_link_acl_data(source_address, transport, data)
|
||||
|
||||
@@ -139,16 +139,19 @@ class PairingDelegate:
|
||||
io_capability: IoCapability
|
||||
local_initiator_key_distribution: KeyDistribution
|
||||
local_responder_key_distribution: KeyDistribution
|
||||
maximum_encryption_key_size: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
io_capability: IoCapability = NO_OUTPUT_NO_INPUT,
|
||||
local_initiator_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION,
|
||||
local_responder_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION,
|
||||
maximum_encryption_key_size: int = 16,
|
||||
) -> None:
|
||||
self.io_capability = io_capability
|
||||
self.local_initiator_key_distribution = local_initiator_key_distribution
|
||||
self.local_responder_key_distribution = local_responder_key_distribution
|
||||
self.maximum_encryption_key_size = maximum_encryption_key_size
|
||||
|
||||
@property
|
||||
def classic_io_capability(self) -> int:
|
||||
|
||||
@@ -39,7 +39,6 @@ from bumble.device import (
|
||||
AdvertisingEventProperties,
|
||||
AdvertisingType,
|
||||
Device,
|
||||
Phy,
|
||||
)
|
||||
from bumble.gatt import Service
|
||||
from bumble.hci import (
|
||||
@@ -47,6 +46,7 @@ from bumble.hci import (
|
||||
HCI_PAGE_TIMEOUT_ERROR,
|
||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||
Address,
|
||||
Phy,
|
||||
)
|
||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
||||
@@ -371,9 +371,7 @@ class HostService(HostServicer):
|
||||
scan_response_data=scan_response_data,
|
||||
)
|
||||
|
||||
pending_connection: asyncio.Future[bumble.device.Connection] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
connections: asyncio.Queue[bumble.device.Connection] = asyncio.Queue()
|
||||
|
||||
if request.connectable:
|
||||
|
||||
@@ -382,7 +380,7 @@ class HostService(HostServicer):
|
||||
connection.transport == BT_LE_TRANSPORT
|
||||
and connection.role == BT_PERIPHERAL_ROLE
|
||||
):
|
||||
pending_connection.set_result(connection)
|
||||
connections.put_nowait(connection)
|
||||
|
||||
self.device.on('connection', on_connection)
|
||||
|
||||
@@ -397,8 +395,7 @@ class HostService(HostServicer):
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
connection = await pending_connection
|
||||
pending_connection = asyncio.get_running_loop().create_future()
|
||||
connection = await connections.get()
|
||||
|
||||
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
||||
yield AdvertiseResponse(connection=Connection(cookie=cookie))
|
||||
@@ -492,6 +489,8 @@ class HostService(HostServicer):
|
||||
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
|
||||
advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
|
||||
|
||||
connections: asyncio.Queue[bumble.device.Connection] = asyncio.Queue()
|
||||
|
||||
if request.connectable:
|
||||
|
||||
def on_connection(connection: bumble.device.Connection) -> None:
|
||||
@@ -499,7 +498,7 @@ class HostService(HostServicer):
|
||||
connection.transport == BT_LE_TRANSPORT
|
||||
and connection.role == BT_PERIPHERAL_ROLE
|
||||
):
|
||||
pending_connection.set_result(connection)
|
||||
connections.put_nowait(connection)
|
||||
|
||||
self.device.on('connection', on_connection)
|
||||
|
||||
@@ -517,12 +516,8 @@ class HostService(HostServicer):
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
pending_connection: asyncio.Future[bumble.device.Connection] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
|
||||
self.log.debug('Wait for LE connection...')
|
||||
connection = await pending_connection
|
||||
connection = await connections.get()
|
||||
|
||||
self.log.debug(
|
||||
f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import struct
|
||||
|
||||
@@ -28,10 +29,11 @@ from bumble.device import Connection
|
||||
from bumble.att import ATT_Error
|
||||
from bumble.gatt import (
|
||||
Characteristic,
|
||||
DelegatedCharacteristicAdapter,
|
||||
SerializableCharacteristicAdapter,
|
||||
PackedCharacteristicAdapter,
|
||||
TemplateService,
|
||||
CharacteristicValue,
|
||||
PackedCharacteristicAdapter,
|
||||
UTF8CharacteristicAdapter,
|
||||
GATT_AUDIO_INPUT_CONTROL_SERVICE,
|
||||
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
|
||||
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
||||
@@ -95,7 +97,7 @@ class AudioInputStatus(OpenIntEnum):
|
||||
Cf. 3.4 Audio Input Status
|
||||
'''
|
||||
|
||||
INATIVE = 0x00
|
||||
INACTIVE = 0x00
|
||||
ACTIVE = 0x01
|
||||
|
||||
|
||||
@@ -104,7 +106,7 @@ class AudioInputControlPointOpCode(OpenIntEnum):
|
||||
Cf. 3.5.1 Audio Input Control Point procedure requirements
|
||||
'''
|
||||
|
||||
SET_GAIN_SETTING = 0x00
|
||||
SET_GAIN_SETTING = 0x01
|
||||
UNMUTE = 0x02
|
||||
MUTE = 0x03
|
||||
SET_MANUAL_GAIN_MODE = 0x04
|
||||
@@ -154,9 +156,6 @@ class AudioInputState:
|
||||
attribute=self.attribute_value, value=bytes(self)
|
||||
)
|
||||
|
||||
def on_read(self, _connection: Optional[Connection]) -> bytes:
|
||||
return bytes(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GainSettingsProperties:
|
||||
@@ -173,7 +172,7 @@ class GainSettingsProperties:
|
||||
(gain_settings_unit, gain_settings_minimum, gain_settings_maximum) = (
|
||||
struct.unpack('BBB', data)
|
||||
)
|
||||
GainSettingsProperties(
|
||||
return GainSettingsProperties(
|
||||
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
|
||||
class AudioInputControlPoint:
|
||||
@@ -239,7 +235,7 @@ class AudioInputControlPoint:
|
||||
or gain_settings_operand
|
||||
> 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)
|
||||
|
||||
if self.audio_input_state.gain_settings != gain_settings_operand:
|
||||
@@ -321,21 +317,14 @@ class AudioInputDescription:
|
||||
audio_input_description: str = "Bluetooth"
|
||||
attribute_value: Optional[CharacteristicValue] = None
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
return cls(audio_input_description=data.decode('utf-8'))
|
||||
def on_read(self, _connection: Optional[Connection]) -> str:
|
||||
return self.audio_input_description
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
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:
|
||||
async def on_write(self, connection: Optional[Connection], value: str) -> None:
|
||||
assert connection
|
||||
assert self.attribute_value
|
||||
|
||||
self.audio_input_description = value.decode('utf-8')
|
||||
self.audio_input_description = value
|
||||
await connection.device.notify_subscribers(
|
||||
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_characteristic = DelegatedCharacteristicAdapter(
|
||||
self.audio_input_state_characteristic = SerializableCharacteristicAdapter(
|
||||
Characteristic(
|
||||
uuid=GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
|
||||
properties=Characteristic.Properties.READ
|
||||
| Characteristic.Properties.NOTIFY,
|
||||
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_characteristic.value
|
||||
)
|
||||
|
||||
self.gain_settings_properties_characteristic = DelegatedCharacteristicAdapter(
|
||||
Characteristic(
|
||||
uuid=GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
||||
properties=Characteristic.Properties.READ,
|
||||
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=CharacteristicValue(read=self.gain_settings_properties.on_read),
|
||||
self.gain_settings_properties_characteristic = (
|
||||
SerializableCharacteristicAdapter(
|
||||
Characteristic(
|
||||
uuid=GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
||||
properties=Characteristic.Properties.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,
|
||||
properties=Characteristic.Properties.READ,
|
||||
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=audio_input_type,
|
||||
value=bytes(audio_input_type, 'utf-8'),
|
||||
)
|
||||
|
||||
self.audio_input_status_characteristic = Characteristic(
|
||||
@@ -412,18 +404,14 @@ class AICSService(TemplateService):
|
||||
value=bytes([self.audio_input_status]),
|
||||
)
|
||||
|
||||
self.audio_input_control_point_characteristic = DelegatedCharacteristicAdapter(
|
||||
Characteristic(
|
||||
uuid=GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
|
||||
properties=Characteristic.Properties.WRITE,
|
||||
permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||
value=CharacteristicValue(
|
||||
write=self.audio_input_control_point.on_write
|
||||
),
|
||||
)
|
||||
self.audio_input_control_point_characteristic = Characteristic(
|
||||
uuid=GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
|
||||
properties=Characteristic.Properties.WRITE,
|
||||
permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||
value=CharacteristicValue(write=self.audio_input_control_point.on_write),
|
||||
)
|
||||
|
||||
self.audio_input_description_characteristic = DelegatedCharacteristicAdapter(
|
||||
self.audio_input_description_characteristic = UTF8CharacteristicAdapter(
|
||||
Characteristic(
|
||||
uuid=GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
|
||||
properties=Characteristic.Properties.READ
|
||||
@@ -463,58 +451,35 @@ class AICSServiceProxy(ProfileServiceProxy):
|
||||
def __init__(self, service_proxy: ServiceProxy) -> None:
|
||||
self.service_proxy = service_proxy
|
||||
|
||||
if not (
|
||||
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
self.audio_input_state = SerializableCharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC
|
||||
)
|
||||
):
|
||||
raise gatt.InvalidServiceError("Audio Input State Characteristic not found")
|
||||
self.audio_input_state = DelegatedCharacteristicAdapter(
|
||||
characteristic=characteristics[0], decode=AudioInputState.from_bytes
|
||||
),
|
||||
AudioInputState,
|
||||
)
|
||||
|
||||
if not (
|
||||
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
self.gain_settings_properties = SerializableCharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC
|
||||
)
|
||||
):
|
||||
raise gatt.InvalidServiceError(
|
||||
"Gain Settings Attribute Characteristic not found"
|
||||
)
|
||||
self.gain_settings_properties = PackedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
'BBB',
|
||||
),
|
||||
GainSettingsProperties,
|
||||
)
|
||||
|
||||
if not (
|
||||
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC
|
||||
)
|
||||
):
|
||||
raise gatt.InvalidServiceError(
|
||||
"Audio Input Status Characteristic not found"
|
||||
)
|
||||
self.audio_input_status = PackedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC
|
||||
),
|
||||
'B',
|
||||
)
|
||||
|
||||
if not (
|
||||
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
self.audio_input_control_point = (
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC
|
||||
)
|
||||
):
|
||||
raise gatt.InvalidServiceError(
|
||||
"Audio Input Control Point Characteristic not found"
|
||||
)
|
||||
self.audio_input_control_point = characteristics[0]
|
||||
)
|
||||
|
||||
if not (
|
||||
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
self.audio_input_description = UTF8CharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC
|
||||
)
|
||||
):
|
||||
raise gatt.InvalidServiceError(
|
||||
"Audio Input Description Characteristic not found"
|
||||
)
|
||||
self.audio_input_description = characteristics[0]
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import struct
|
||||
@@ -258,8 +259,8 @@ class AseReasonCode(enum.IntEnum):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AudioRole(enum.IntEnum):
|
||||
SINK = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST
|
||||
SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER
|
||||
SINK = device.CisLink.Direction.CONTROLLER_TO_HOST
|
||||
SOURCE = device.CisLink.Direction.HOST_TO_CONTROLLER
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -354,16 +355,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
cis_link.on('disconnection', self.on_cis_disconnection)
|
||||
|
||||
async def post_cis_established():
|
||||
await self.service.device.send_command(
|
||||
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'',
|
||||
)
|
||||
)
|
||||
await cis_link.setup_data_path(direction=self.role)
|
||||
if self.role == AudioRole.SINK:
|
||||
self.state = self.State.STREAMING
|
||||
await self.service.device.notify_subscribers(self, self.value)
|
||||
@@ -511,12 +503,8 @@ class AseStateMachine(gatt.Characteristic):
|
||||
self.state = self.State.RELEASING
|
||||
|
||||
async def remove_cis_async():
|
||||
await self.service.device.send_command(
|
||||
hci.HCI_LE_Remove_ISO_Data_Path_Command(
|
||||
connection_handle=self.cis_link.handle,
|
||||
data_path_direction=self.role,
|
||||
)
|
||||
)
|
||||
if self.cis_link:
|
||||
await self.cis_link.remove_data_path(self.role)
|
||||
self.state = self.State.IDLE
|
||||
await self.service.device.notify_subscribers(self, self.value)
|
||||
|
||||
|
||||
@@ -288,8 +288,8 @@ class AshaServiceProxy(gatt_client.ProfileServiceProxy):
|
||||
'psm_characteristic',
|
||||
),
|
||||
):
|
||||
if not (
|
||||
characteristics := self.service_proxy.get_characteristics_by_uuid(uuid)
|
||||
):
|
||||
raise gatt.InvalidServiceError(f"Missing {uuid} Characteristic")
|
||||
setattr(self, attribute_name, characteristics[0])
|
||||
setattr(
|
||||
self,
|
||||
attribute_name,
|
||||
self.service_proxy.get_required_characteristic_by_uuid(uuid),
|
||||
)
|
||||
|
||||
@@ -102,6 +102,7 @@ class ContextType(enum.IntFlag):
|
||||
|
||||
# fmt: off
|
||||
PROHIBITED = 0x0000
|
||||
UNSPECIFIED = 0x0001
|
||||
CONVERSATIONAL = 0x0002
|
||||
MEDIA = 0x0004
|
||||
GAME = 0x0008
|
||||
@@ -264,7 +265,7 @@ class UnicastServerAdvertisingData:
|
||||
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
||||
struct.pack(
|
||||
'<2sBIB',
|
||||
gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE.to_bytes(),
|
||||
bytes(gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE),
|
||||
self.announcement_type,
|
||||
self.available_audio_contexts,
|
||||
len(self.metadata),
|
||||
@@ -350,6 +351,7 @@ class CodecSpecificCapabilities:
|
||||
supported_max_codec_frames_per_sdu = value
|
||||
|
||||
# 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(
|
||||
supported_sampling_frequencies=supported_sampling_frequencies,
|
||||
supported_frame_durations=supported_frame_durations,
|
||||
@@ -396,18 +398,21 @@ class CodecSpecificConfiguration:
|
||||
OCTETS_PER_FRAME = 0x04
|
||||
CODEC_FRAMES_PER_SDU = 0x05
|
||||
|
||||
sampling_frequency: SamplingFrequency
|
||||
frame_duration: FrameDuration
|
||||
audio_channel_allocation: AudioLocation
|
||||
octets_per_codec_frame: int
|
||||
codec_frames_per_sdu: int
|
||||
sampling_frequency: SamplingFrequency | None = None
|
||||
frame_duration: FrameDuration | None = None
|
||||
audio_channel_allocation: AudioLocation | None = None
|
||||
octets_per_codec_frame: int | None = None
|
||||
codec_frames_per_sdu: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> CodecSpecificConfiguration:
|
||||
offset = 0
|
||||
# Allowed default values.
|
||||
audio_channel_allocation = AudioLocation.NOT_ALLOWED
|
||||
codec_frames_per_sdu = 1
|
||||
sampling_frequency: SamplingFrequency | None = None
|
||||
frame_duration: FrameDuration | None = None
|
||||
audio_channel_allocation: AudioLocation | None = None
|
||||
octets_per_codec_frame: int | None = None
|
||||
codec_frames_per_sdu: int | None = None
|
||||
|
||||
while offset < len(data):
|
||||
length, type = struct.unpack_from('BB', data, offset)
|
||||
offset += 2
|
||||
@@ -425,7 +430,6 @@ class CodecSpecificConfiguration:
|
||||
elif type == CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU:
|
||||
codec_frames_per_sdu = value
|
||||
|
||||
# It is expected here that if some fields are missing, an error should be raised.
|
||||
return CodecSpecificConfiguration(
|
||||
sampling_frequency=sampling_frequency,
|
||||
frame_duration=frame_duration,
|
||||
@@ -435,23 +439,43 @@ class CodecSpecificConfiguration:
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return struct.pack(
|
||||
'<BBBBBBBBIBBHBBB',
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY,
|
||||
self.sampling_frequency,
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.FRAME_DURATION,
|
||||
self.frame_duration,
|
||||
5,
|
||||
CodecSpecificConfiguration.Type.AUDIO_CHANNEL_ALLOCATION,
|
||||
self.audio_channel_allocation,
|
||||
3,
|
||||
CodecSpecificConfiguration.Type.OCTETS_PER_FRAME,
|
||||
self.octets_per_codec_frame,
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU,
|
||||
self.codec_frames_per_sdu,
|
||||
return b''.join(
|
||||
[
|
||||
struct.pack(fmt, length, tag, value)
|
||||
for fmt, length, tag, value in [
|
||||
(
|
||||
'<BBB',
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY,
|
||||
self.sampling_frequency,
|
||||
),
|
||||
(
|
||||
'<BBB',
|
||||
2,
|
||||
CodecSpecificConfiguration.Type.FRAME_DURATION,
|
||||
self.frame_duration,
|
||||
),
|
||||
(
|
||||
'<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:
|
||||
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
|
||||
class BasicAudioAnnouncement:
|
||||
@@ -471,26 +513,37 @@ class BasicAudioAnnouncement:
|
||||
index: int
|
||||
codec_specific_configuration: CodecSpecificConfiguration
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CodecInfo:
|
||||
coding_format: hci.CodecID
|
||||
company_id: int
|
||||
vendor_specific_codec_id: int
|
||||
|
||||
@classmethod
|
||||
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)
|
||||
def __bytes__(self) -> bytes:
|
||||
codec_specific_configuration_bytes = bytes(
|
||||
self.codec_specific_configuration
|
||||
)
|
||||
return (
|
||||
bytes([self.index, len(codec_specific_configuration_bytes)])
|
||||
+ codec_specific_configuration_bytes
|
||||
)
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Subgroup:
|
||||
codec_id: BasicAudioAnnouncement.CodecInfo
|
||||
codec_id: hci.CodingFormat
|
||||
codec_specific_configuration: CodecSpecificConfiguration
|
||||
metadata: le_audio.Metadata
|
||||
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
|
||||
subgroups: List[BasicAudioAnnouncement.Subgroup]
|
||||
|
||||
@@ -502,7 +555,7 @@ class BasicAudioAnnouncement:
|
||||
for _ in range(data[3]):
|
||||
num_bis = data[offset]
|
||||
offset += 1
|
||||
codec_id = cls.CodecInfo.from_bytes(data[offset : offset + 5])
|
||||
codec_id = hci.CodingFormat.from_bytes(data[offset : offset + 5])
|
||||
offset += 5
|
||||
codec_specific_configuration_length = data[offset]
|
||||
offset += 1
|
||||
@@ -546,3 +599,25 @@ class BasicAudioAnnouncement:
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> Optional[BroadcastReceiveState]:
|
||||
if not data:
|
||||
return None
|
||||
|
||||
def from_bytes(cls, data: bytes) -> BroadcastReceiveState:
|
||||
source_id = data[0]
|
||||
_, source_address = hci.Address.parse_address_preceded_by_type(data, 2)
|
||||
source_adv_sid = data[8]
|
||||
@@ -362,29 +359,20 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
|
||||
def __init__(self, service_proxy: gatt_client.ServiceProxy):
|
||||
self.service_proxy = service_proxy
|
||||
|
||||
if not (
|
||||
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
self.broadcast_audio_scan_control_point = (
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
gatt.GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC
|
||||
)
|
||||
):
|
||||
raise gatt.InvalidServiceError(
|
||||
"Broadcast Audio Scan Control Point characteristic not found"
|
||||
)
|
||||
self.broadcast_audio_scan_control_point = characteristics[0]
|
||||
)
|
||||
|
||||
if not (
|
||||
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC
|
||||
)
|
||||
):
|
||||
raise gatt.InvalidServiceError(
|
||||
"Broadcast Receive State characteristic not found"
|
||||
)
|
||||
self.broadcast_receive_states = [
|
||||
gatt.DelegatedCharacteristicAdapter(
|
||||
characteristic, decode=BroadcastReceiveState.from_bytes
|
||||
characteristic,
|
||||
decode=lambda x: BroadcastReceiveState.from_bytes(x) if x else None,
|
||||
)
|
||||
for characteristic in service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC
|
||||
)
|
||||
for characteristic in characteristics
|
||||
]
|
||||
|
||||
async def send_control_point_operation(
|
||||
|
||||
@@ -64,7 +64,10 @@ class DeviceInformationService(TemplateService):
|
||||
):
|
||||
characteristics = [
|
||||
Characteristic(
|
||||
uuid, Characteristic.Properties.READ, Characteristic.READABLE, field
|
||||
uuid,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
bytes(field, 'utf-8'),
|
||||
)
|
||||
for (field, uuid) in (
|
||||
(manufacturer_name, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),
|
||||
|
||||
166
bumble/profiles/gatt_service.py
Normal file
166
bumble/profiles/gatt_service.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# Copyright 2021-2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bumble import att
|
||||
from bumble import gatt
|
||||
from bumble import gatt_client
|
||||
from bumble import crypto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble import device
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class GenericAttributeProfileService(gatt.TemplateService):
|
||||
'''See Vol 3, Part G - 7 - DEFINED GENERIC ATTRIBUTE PROFILE SERVICE.'''
|
||||
|
||||
UUID = gatt.GATT_GENERIC_ATTRIBUTE_SERVICE
|
||||
|
||||
client_supported_features_characteristic: gatt.Characteristic | None = None
|
||||
server_supported_features_characteristic: gatt.Characteristic | None = None
|
||||
database_hash_characteristic: gatt.Characteristic | None = None
|
||||
service_changed_characteristic: gatt.Characteristic | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_supported_features: gatt.ServerSupportedFeatures | None = None,
|
||||
database_hash_enabled: bool = True,
|
||||
service_change_enabled: bool = True,
|
||||
) -> None:
|
||||
|
||||
if server_supported_features is not None:
|
||||
self.server_supported_features_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ,
|
||||
permissions=gatt.Characteristic.Permissions.READABLE,
|
||||
value=bytes([server_supported_features]),
|
||||
)
|
||||
|
||||
if database_hash_enabled:
|
||||
self.database_hash_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_DATABASE_HASH_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ,
|
||||
permissions=gatt.Characteristic.Permissions.READABLE,
|
||||
value=gatt.CharacteristicValue(read=self.get_database_hash),
|
||||
)
|
||||
|
||||
if service_change_enabled:
|
||||
self.service_changed_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.INDICATE,
|
||||
permissions=gatt.Characteristic.Permissions(0),
|
||||
value=b'',
|
||||
)
|
||||
|
||||
if (database_hash_enabled and service_change_enabled) or (
|
||||
server_supported_features
|
||||
and (
|
||||
server_supported_features & gatt.ServerSupportedFeatures.EATT_SUPPORTED
|
||||
)
|
||||
): # TODO: Support Multiple Handle Value Notifications
|
||||
self.client_supported_features_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC,
|
||||
properties=(
|
||||
gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.WRITE
|
||||
),
|
||||
permissions=(
|
||||
gatt.Characteristic.Permissions.READABLE
|
||||
| gatt.Characteristic.Permissions.WRITEABLE
|
||||
),
|
||||
value=bytes(1),
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
characteristics=[
|
||||
c
|
||||
for c in (
|
||||
self.service_changed_characteristic,
|
||||
self.client_supported_features_characteristic,
|
||||
self.database_hash_characteristic,
|
||||
self.server_supported_features_characteristic,
|
||||
)
|
||||
if c is not None
|
||||
],
|
||||
primary=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_attribute_data(cls, attribute: att.Attribute) -> bytes:
|
||||
if attribute.type in (
|
||||
gatt.GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
||||
gatt.GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
||||
gatt.GATT_INCLUDE_ATTRIBUTE_TYPE,
|
||||
gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
||||
gatt.GATT_CHARACTERISTIC_EXTENDED_PROPERTIES_DESCRIPTOR,
|
||||
):
|
||||
return (
|
||||
struct.pack("<H", attribute.handle)
|
||||
+ attribute.type.to_bytes()
|
||||
+ attribute.value
|
||||
)
|
||||
elif attribute.type in (
|
||||
gatt.GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR,
|
||||
gatt.GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||
gatt.GATT_SERVER_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||
gatt.GATT_CHARACTERISTIC_PRESENTATION_FORMAT_DESCRIPTOR,
|
||||
gatt.GATT_CHARACTERISTIC_AGGREGATE_FORMAT_DESCRIPTOR,
|
||||
):
|
||||
return struct.pack("<H", attribute.handle) + attribute.type.to_bytes()
|
||||
|
||||
return b''
|
||||
|
||||
def get_database_hash(self, connection: device.Connection | None) -> bytes:
|
||||
assert connection
|
||||
|
||||
m = b''.join(
|
||||
[
|
||||
self.get_attribute_data(attribute)
|
||||
for attribute in connection.device.gatt_server.attributes
|
||||
]
|
||||
)
|
||||
|
||||
return crypto.aes_cmac(m=m, k=bytes(16))
|
||||
|
||||
|
||||
class GenericAttributeProfileServiceProxy(gatt_client.ProfileServiceProxy):
|
||||
SERVICE_CLASS = GenericAttributeProfileService
|
||||
|
||||
client_supported_features_characteristic: gatt_client.CharacteristicProxy | None = (
|
||||
None
|
||||
)
|
||||
server_supported_features_characteristic: gatt_client.CharacteristicProxy | None = (
|
||||
None
|
||||
)
|
||||
database_hash_characteristic: gatt_client.CharacteristicProxy | None = None
|
||||
service_changed_characteristic: gatt_client.CharacteristicProxy | None = None
|
||||
|
||||
_CHARACTERISTICS = {
|
||||
gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC: 'client_supported_features_characteristic',
|
||||
gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC: 'server_supported_features_characteristic',
|
||||
gatt.GATT_DATABASE_HASH_CHARACTERISTIC: 'database_hash_characteristic',
|
||||
gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC: 'service_changed_characteristic',
|
||||
}
|
||||
|
||||
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||
self.service_proxy = service_proxy
|
||||
|
||||
for uuid, attribute_name in self._CHARACTERISTICS.items():
|
||||
if characteristics := self.service_proxy.get_characteristics_by_uuid(uuid):
|
||||
setattr(self, attribute_name, characteristics[0])
|
||||
193
bumble/profiles/gmap.py
Normal file
193
bumble/profiles/gmap.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# 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,
|
||||
)
|
||||
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
|
||||
|
||||
self.gmap_role = DelegatedCharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_GMAP_ROLE_CHARACTERISTIC
|
||||
),
|
||||
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]),
|
||||
)
|
||||
@@ -25,7 +25,7 @@ from bumble.utils import AsyncRunner, OpenIntEnum
|
||||
from bumble.hci import Address
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import Any, Dict, List, Optional, Set, Union
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -271,24 +271,12 @@ class HearingAccessService(gatt.TemplateService):
|
||||
def on_disconnection(_reason) -> None:
|
||||
self.currently_connected_clients.remove(connection)
|
||||
|
||||
# TODO Should we filter on device bonded && device is HAP ?
|
||||
self.currently_connected_clients.add(connection)
|
||||
if (
|
||||
connection.peer_address
|
||||
not in self.preset_changed_operations_history_per_device
|
||||
):
|
||||
self.preset_changed_operations_history_per_device[
|
||||
connection.peer_address
|
||||
] = []
|
||||
return
|
||||
@connection.on('pairing') # type: ignore
|
||||
def on_pairing(*_: Any) -> None:
|
||||
self.on_incoming_paired_connection(connection)
|
||||
|
||||
async def on_connection_async() -> None:
|
||||
# Send all the PresetChangedOperation that occur when not connected
|
||||
await self._preset_changed_operation(connection)
|
||||
# Update the active preset index if needed
|
||||
await self.notify_active_preset_for_connection(connection)
|
||||
|
||||
connection.abort_on('disconnection', on_connection_async())
|
||||
if connection.peer_resolvable_address:
|
||||
self.on_incoming_paired_connection(connection)
|
||||
|
||||
self.hearing_aid_features_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC,
|
||||
@@ -325,6 +313,27 @@ class HearingAccessService(gatt.TemplateService):
|
||||
]
|
||||
)
|
||||
|
||||
def on_incoming_paired_connection(self, connection: Connection):
|
||||
'''Setup initial operations to handle a remote bonded HAP device'''
|
||||
# TODO Should we filter on HAP device only ?
|
||||
self.currently_connected_clients.add(connection)
|
||||
if (
|
||||
connection.peer_address
|
||||
not in self.preset_changed_operations_history_per_device
|
||||
):
|
||||
self.preset_changed_operations_history_per_device[
|
||||
connection.peer_address
|
||||
] = []
|
||||
return
|
||||
|
||||
async def on_connection_async() -> None:
|
||||
# Send all the PresetChangedOperation that occur when not connected
|
||||
await self._preset_changed_operation(connection)
|
||||
# Update the active preset index if needed
|
||||
await self.notify_active_preset_for_connection(connection)
|
||||
|
||||
connection.abort_on('disconnection', on_connection_async())
|
||||
|
||||
def _on_read_active_preset_index(
|
||||
self, __connection__: Optional[Connection]
|
||||
) -> bytes:
|
||||
|
||||
@@ -30,6 +30,7 @@ from ..gatt import (
|
||||
TemplateService,
|
||||
Characteristic,
|
||||
CharacteristicValue,
|
||||
SerializableCharacteristicAdapter,
|
||||
DelegatedCharacteristicAdapter,
|
||||
PackedCharacteristicAdapter,
|
||||
)
|
||||
@@ -150,15 +151,14 @@ class HeartRateService(TemplateService):
|
||||
body_sensor_location=None,
|
||||
reset_energy_expended=None,
|
||||
):
|
||||
self.heart_rate_measurement_characteristic = DelegatedCharacteristicAdapter(
|
||||
self.heart_rate_measurement_characteristic = SerializableCharacteristicAdapter(
|
||||
Characteristic(
|
||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
||||
Characteristic.Properties.NOTIFY,
|
||||
0,
|
||||
CharacteristicValue(read=read_heart_rate_measurement),
|
||||
),
|
||||
# pylint: disable=unnecessary-lambda
|
||||
encode=lambda value: bytes(value),
|
||||
HeartRateService.HeartRateMeasurement,
|
||||
)
|
||||
characteristics = [self.heart_rate_measurement_characteristic]
|
||||
|
||||
@@ -204,9 +204,8 @@ class HeartRateServiceProxy(ProfileServiceProxy):
|
||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC
|
||||
):
|
||||
self.heart_rate_measurement = DelegatedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
decode=HeartRateService.HeartRateMeasurement.from_bytes,
|
||||
self.heart_rate_measurement = SerializableCharacteristicAdapter(
|
||||
characteristics[0], HeartRateService.HeartRateMeasurement
|
||||
)
|
||||
else:
|
||||
self.heart_rate_measurement = None
|
||||
|
||||
@@ -17,23 +17,35 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import enum
|
||||
import struct
|
||||
from typing import List, Type
|
||||
from typing import Any, List, Type
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble.profiles import bap
|
||||
from bumble import utils
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
class AudioActiveState(utils.OpenIntEnum):
|
||||
NO_AUDIO_DATA_TRANSMITTED = 0x00
|
||||
AUDIO_DATA_TRANSMITTED = 0x01
|
||||
|
||||
|
||||
class AssistedListeningStream(utils.OpenIntEnum):
|
||||
UNSPECIFIED_AUDIO_ENHANCEMENT = 0x00
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Metadata:
|
||||
'''Bluetooth Assigned Numbers, Section 6.12.6 - Metadata LTV structures.
|
||||
|
||||
As Metadata fields may extend, and Spec doesn't forbid duplication, we don't parse
|
||||
Metadata into a key-value style dataclass here. Rather, we encourage users to parse
|
||||
again outside the lib.
|
||||
As Metadata fields may extend, and the spec may not guarantee the uniqueness of
|
||||
tags, we don't automatically parse the Metadata data into specific classes.
|
||||
Users of this class may decode the data by themselves, or use the Entry.decode
|
||||
method.
|
||||
'''
|
||||
|
||||
class Tag(utils.OpenIntEnum):
|
||||
@@ -57,6 +69,44 @@ class Metadata:
|
||||
tag: Metadata.Tag
|
||||
data: bytes
|
||||
|
||||
def decode(self) -> Any:
|
||||
"""
|
||||
Decode the data into an object, if possible.
|
||||
|
||||
If no specific object class exists to represent the data, the raw data
|
||||
bytes are returned.
|
||||
"""
|
||||
|
||||
if self.tag in (
|
||||
Metadata.Tag.PREFERRED_AUDIO_CONTEXTS,
|
||||
Metadata.Tag.STREAMING_AUDIO_CONTEXTS,
|
||||
):
|
||||
return bap.ContextType(struct.unpack("<H", self.data)[0])
|
||||
|
||||
if self.tag in (
|
||||
Metadata.Tag.PROGRAM_INFO,
|
||||
Metadata.Tag.PROGRAM_INFO_URI,
|
||||
Metadata.Tag.BROADCAST_NAME,
|
||||
):
|
||||
return self.data.decode("utf-8")
|
||||
|
||||
if self.tag == Metadata.Tag.LANGUAGE:
|
||||
return self.data.decode("ascii")
|
||||
|
||||
if self.tag == Metadata.Tag.CCID_LIST:
|
||||
return list(self.data)
|
||||
|
||||
if self.tag == Metadata.Tag.PARENTAL_RATING:
|
||||
return self.data[0]
|
||||
|
||||
if self.tag == Metadata.Tag.AUDIO_ACTIVE_STATE:
|
||||
return AudioActiveState(self.data[0])
|
||||
|
||||
if self.tag == Metadata.Tag.ASSISTED_LISTENING_STREAM:
|
||||
return AssistedListeningStream(self.data[0])
|
||||
|
||||
return self.data
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls: Type[Self], data: bytes) -> Self:
|
||||
return cls(tag=Metadata.Tag(data[0]), data=data[1:])
|
||||
@@ -66,6 +116,29 @@ class Metadata:
|
||||
|
||||
entries: List[Entry] = dataclasses.field(default_factory=list)
|
||||
|
||||
def pretty_print(self, indent: str) -> str:
|
||||
"""Convenience method to generate a string with one key-value pair per line."""
|
||||
|
||||
max_key_length = 0
|
||||
keys = []
|
||||
values = []
|
||||
for entry in self.entries:
|
||||
key = entry.tag.name
|
||||
max_key_length = max(max_key_length, len(key))
|
||||
keys.append(key)
|
||||
decoded = entry.decode()
|
||||
if isinstance(decoded, enum.Enum):
|
||||
values.append(decoded.name)
|
||||
elif isinstance(decoded, bytes):
|
||||
values.append(decoded.hex())
|
||||
else:
|
||||
values.append(str(decoded))
|
||||
|
||||
return '\n'.join(
|
||||
f'{indent}{key}: {" " * (max_key_length-len(key))}{value}'
|
||||
for key, value in zip(keys, values)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls: Type[Self], data: bytes) -> Self:
|
||||
entries = []
|
||||
@@ -81,3 +154,13 @@ class Metadata:
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return b''.join([bytes(entry) for entry in self.entries])
|
||||
|
||||
def __str__(self) -> str:
|
||||
entries_str = []
|
||||
for entry in self.entries:
|
||||
decoded = entry.decode()
|
||||
entries_str.append(
|
||||
f'{entry.tag.name}: '
|
||||
f'{decoded.hex() if isinstance(decoded, bytes) else decoded!r}'
|
||||
)
|
||||
return f'Metadata(entries={", ".join(entry_str for entry_str in entries_str)})'
|
||||
|
||||
@@ -72,6 +72,19 @@ class PacRecord:
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def list_from_bytes(cls, data: bytes) -> list[PacRecord]:
|
||||
"""Parse a serialized list of records preceded by a one byte list length."""
|
||||
record_count = data[0]
|
||||
records = []
|
||||
offset = 1
|
||||
for _ in range(record_count):
|
||||
record = PacRecord.from_bytes(data[offset:])
|
||||
offset += len(bytes(record))
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
capabilities_bytes = bytes(self.codec_specific_capabilities)
|
||||
metadata_bytes = bytes(self.metadata)
|
||||
@@ -172,39 +185,58 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService):
|
||||
class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
|
||||
SERVICE_CLASS = PublishedAudioCapabilitiesService
|
||||
|
||||
sink_pac: Optional[gatt_client.CharacteristicProxy] = None
|
||||
sink_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
|
||||
source_pac: Optional[gatt_client.CharacteristicProxy] = None
|
||||
source_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
|
||||
available_audio_contexts: gatt_client.CharacteristicProxy
|
||||
supported_audio_contexts: gatt_client.CharacteristicProxy
|
||||
sink_pac: Optional[gatt.DelegatedCharacteristicAdapter] = None
|
||||
sink_audio_locations: Optional[gatt.DelegatedCharacteristicAdapter] = None
|
||||
source_pac: Optional[gatt.DelegatedCharacteristicAdapter] = None
|
||||
source_audio_locations: Optional[gatt.DelegatedCharacteristicAdapter] = None
|
||||
available_audio_contexts: gatt.DelegatedCharacteristicAdapter
|
||||
supported_audio_contexts: gatt.DelegatedCharacteristicAdapter
|
||||
|
||||
def __init__(self, service_proxy: gatt_client.ServiceProxy):
|
||||
self.service_proxy = service_proxy
|
||||
|
||||
self.available_audio_contexts = service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
|
||||
)[0]
|
||||
self.supported_audio_contexts = service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
|
||||
)[0]
|
||||
self.available_audio_contexts = gatt.DelegatedCharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
|
||||
),
|
||||
decode=lambda x: tuple(map(ContextType, struct.unpack('<HH', x))),
|
||||
)
|
||||
|
||||
self.supported_audio_contexts = gatt.DelegatedCharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
|
||||
),
|
||||
decode=lambda x: tuple(map(ContextType, struct.unpack('<HH', x))),
|
||||
)
|
||||
|
||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_SINK_PAC_CHARACTERISTIC
|
||||
):
|
||||
self.sink_pac = characteristics[0]
|
||||
self.sink_pac = gatt.DelegatedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
decode=PacRecord.list_from_bytes,
|
||||
)
|
||||
|
||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_SOURCE_PAC_CHARACTERISTIC
|
||||
):
|
||||
self.source_pac = characteristics[0]
|
||||
self.source_pac = gatt.DelegatedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
decode=PacRecord.list_from_bytes,
|
||||
)
|
||||
|
||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC
|
||||
):
|
||||
self.sink_audio_locations = characteristics[0]
|
||||
self.sink_audio_locations = gatt.DelegatedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
decode=lambda x: AudioLocation(struct.unpack('<I', x)[0]),
|
||||
)
|
||||
|
||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC
|
||||
):
|
||||
self.source_audio_locations = characteristics[0]
|
||||
self.source_audio_locations = gatt.DelegatedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
decode=lambda x: AudioLocation(struct.unpack('<I', x)[0]),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,6 @@ from bumble.gatt import (
|
||||
TemplateService,
|
||||
Characteristic,
|
||||
DelegatedCharacteristicAdapter,
|
||||
InvalidServiceError,
|
||||
GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE,
|
||||
GATT_TMAP_ROLE_CHARACTERISTIC,
|
||||
)
|
||||
@@ -74,15 +73,10 @@ class TelephonyAndMediaAudioServiceProxy(ProfileServiceProxy):
|
||||
def __init__(self, service_proxy: ServiceProxy):
|
||||
self.service_proxy = service_proxy
|
||||
|
||||
if not (
|
||||
characteristics := service_proxy.get_characteristics_by_uuid(
|
||||
GATT_TMAP_ROLE_CHARACTERISTIC
|
||||
)
|
||||
):
|
||||
raise InvalidServiceError('TMAP Role characteristic not found')
|
||||
|
||||
self.role = DelegatedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_TMAP_ROLE_CHARACTERISTIC
|
||||
),
|
||||
decode=lambda value: Role(
|
||||
struct.unpack_from('<H', value, 0)[0],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright 2021-2024 Google LLC
|
||||
# Copyright 2021-2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -17,14 +17,16 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import enum
|
||||
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from bumble import att
|
||||
from bumble import device
|
||||
from bumble import gatt
|
||||
from bumble import gatt_client
|
||||
|
||||
from typing import Optional, Sequence
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -67,6 +69,20 @@ class VolumeControlPointOpcode(enum.IntEnum):
|
||||
MUTE = 0x06
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class VolumeState:
|
||||
volume_setting: int
|
||||
mute: int
|
||||
change_counter: int
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> VolumeState:
|
||||
return cls(data[0], data[1], data[2])
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes([self.volume_setting, self.mute, self.change_counter])
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -126,16 +142,8 @@ class VolumeControlService(gatt.TemplateService):
|
||||
included_services=list(included_services),
|
||||
)
|
||||
|
||||
@property
|
||||
def volume_state_bytes(self) -> bytes:
|
||||
return bytes([self.volume_setting, self.muted, self.change_counter])
|
||||
|
||||
@volume_state_bytes.setter
|
||||
def volume_state_bytes(self, new_value: bytes) -> None:
|
||||
self.volume_setting, self.muted, self.change_counter = new_value
|
||||
|
||||
def _on_read_volume_state(self, _connection: Optional[device.Connection]) -> bytes:
|
||||
return self.volume_state_bytes
|
||||
return bytes(VolumeState(self.volume_setting, self.muted, self.change_counter))
|
||||
|
||||
def _on_write_volume_control_point(
|
||||
self, connection: Optional[device.Connection], value: bytes
|
||||
@@ -153,14 +161,9 @@ class VolumeControlService(gatt.TemplateService):
|
||||
self.change_counter = (self.change_counter + 1) % 256
|
||||
connection.abort_on(
|
||||
'disconnection',
|
||||
connection.device.notify_subscribers(
|
||||
attribute=self.volume_state,
|
||||
value=self.volume_state_bytes,
|
||||
),
|
||||
)
|
||||
self.emit(
|
||||
'volume_state', self.volume_setting, self.muted, self.change_counter
|
||||
connection.device.notify_subscribers(attribute=self.volume_state),
|
||||
)
|
||||
self.emit('volume_state_change')
|
||||
|
||||
def _on_relative_volume_down(self) -> bool:
|
||||
old_volume = self.volume_setting
|
||||
@@ -207,24 +210,26 @@ class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy):
|
||||
SERVICE_CLASS = VolumeControlService
|
||||
|
||||
volume_control_point: gatt_client.CharacteristicProxy
|
||||
volume_state: gatt.SerializableCharacteristicAdapter
|
||||
volume_flags: gatt.DelegatedCharacteristicAdapter
|
||||
|
||||
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||
self.service_proxy = service_proxy
|
||||
|
||||
self.volume_state = gatt.PackedCharacteristicAdapter(
|
||||
service_proxy.get_characteristics_by_uuid(
|
||||
self.volume_state = gatt.SerializableCharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
gatt.GATT_VOLUME_STATE_CHARACTERISTIC
|
||||
)[0],
|
||||
'BBB',
|
||||
),
|
||||
VolumeState,
|
||||
)
|
||||
|
||||
self.volume_control_point = service_proxy.get_characteristics_by_uuid(
|
||||
self.volume_control_point = service_proxy.get_required_characteristic_by_uuid(
|
||||
gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC
|
||||
)[0]
|
||||
|
||||
self.volume_flags = gatt.PackedCharacteristicAdapter(
|
||||
service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC
|
||||
)[0],
|
||||
'B',
|
||||
)
|
||||
|
||||
self.volume_flags = gatt.DelegatedCharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC
|
||||
),
|
||||
decode=lambda data: VolumeFlags(data[0]),
|
||||
)
|
||||
299
bumble/profiles/vocs.py
Normal file
299
bumble/profiles/vocs.py
Normal file
@@ -0,0 +1,299 @@
|
||||
# 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,
|
||||
SerializableCharacteristicAdapter,
|
||||
UTF8CharacteristicAdapter,
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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 = Characteristic(
|
||||
uuid=GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
|
||||
properties=(
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY
|
||||
),
|
||||
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=CharacteristicValue(read=self.volume_offset_state.on_read),
|
||||
)
|
||||
|
||||
self.audio_location_characteristic = Characteristic(
|
||||
uuid=GATT_AUDIO_LOCATION_CHARACTERISTIC,
|
||||
properties=(
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.NOTIFY
|
||||
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE
|
||||
),
|
||||
permissions=(
|
||||
Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
|
||||
| Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
|
||||
),
|
||||
value=CharacteristicValue(
|
||||
read=self.audio_location.on_read,
|
||||
write=self.audio_location.on_write,
|
||||
),
|
||||
)
|
||||
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 = 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
|
||||
|
||||
self.volume_offset_state = SerializableCharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC
|
||||
),
|
||||
VolumeOffsetState,
|
||||
)
|
||||
|
||||
self.audio_location = DelegatedCharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_AUDIO_LOCATION_CHARACTERISTIC
|
||||
),
|
||||
encode=lambda value: bytes([int(value)]),
|
||||
decode=lambda data: AudioLocation(data[0]),
|
||||
)
|
||||
|
||||
self.volume_offset_control_point = (
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC
|
||||
)
|
||||
)
|
||||
|
||||
self.audio_output_description = UTF8CharacteristicAdapter(
|
||||
service_proxy.get_required_characteristic_by_uuid(
|
||||
GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC
|
||||
)
|
||||
)
|
||||
110
bumble/rtp.py
Normal file
110
bumble/rtp.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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 struct
|
||||
from typing import List
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class MediaPacket:
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> MediaPacket:
|
||||
version = (data[0] >> 6) & 0x03
|
||||
padding = (data[0] >> 5) & 0x01
|
||||
extension = (data[0] >> 4) & 0x01
|
||||
csrc_count = data[0] & 0x0F
|
||||
marker = (data[1] >> 7) & 0x01
|
||||
payload_type = data[1] & 0x7F
|
||||
sequence_number = struct.unpack_from('>H', data, 2)[0]
|
||||
timestamp = struct.unpack_from('>I', data, 4)[0]
|
||||
ssrc = struct.unpack_from('>I', data, 8)[0]
|
||||
csrc_list = [
|
||||
struct.unpack_from('>I', data, 12 + i)[0] for i in range(csrc_count)
|
||||
]
|
||||
payload = data[12 + csrc_count * 4 :]
|
||||
|
||||
return MediaPacket(
|
||||
version,
|
||||
padding,
|
||||
extension,
|
||||
marker,
|
||||
sequence_number,
|
||||
timestamp,
|
||||
ssrc,
|
||||
csrc_list,
|
||||
payload_type,
|
||||
payload,
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version: int,
|
||||
padding: int,
|
||||
extension: int,
|
||||
marker: int,
|
||||
sequence_number: int,
|
||||
timestamp: int,
|
||||
ssrc: int,
|
||||
csrc_list: List[int],
|
||||
payload_type: int,
|
||||
payload: bytes,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.padding = padding
|
||||
self.extension = extension
|
||||
self.marker = marker
|
||||
self.sequence_number = sequence_number & 0xFFFF
|
||||
self.timestamp = timestamp & 0xFFFFFFFF
|
||||
self.timestamp_seconds = 0.0
|
||||
self.ssrc = ssrc
|
||||
self.csrc_list = csrc_list
|
||||
self.payload_type = payload_type
|
||||
self.payload = payload
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
header = bytes(
|
||||
[
|
||||
self.version << 6
|
||||
| self.padding << 5
|
||||
| self.extension << 4
|
||||
| len(self.csrc_list),
|
||||
self.marker << 7 | self.payload_type,
|
||||
]
|
||||
) + struct.pack(
|
||||
'>HII',
|
||||
self.sequence_number,
|
||||
self.timestamp,
|
||||
self.ssrc,
|
||||
)
|
||||
for csrc in self.csrc_list:
|
||||
header += struct.pack('>I', csrc)
|
||||
return header + self.payload
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'RTP(v={self.version},'
|
||||
f'p={self.padding},'
|
||||
f'x={self.extension},'
|
||||
f'm={self.marker},'
|
||||
f'pt={self.payload_type},'
|
||||
f'sn={self.sequence_number},'
|
||||
f'ts={self.timestamp},'
|
||||
f'ssrc={self.ssrc},'
|
||||
f'csrcs={self.csrc_list},'
|
||||
f'payload_size={len(self.payload)})'
|
||||
)
|
||||
326
bumble/sdp.py
326
bumble/sdp.py
@@ -16,15 +16,21 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
|
||||
from typing import Iterable, NewType, Optional, Union, Sequence, Type, TYPE_CHECKING
|
||||
from typing_extensions import Self
|
||||
|
||||
from . import core, l2cap
|
||||
from .colors import color
|
||||
from .core import InvalidStateError, InvalidArgumentError, InvalidPacketError
|
||||
from .hci import HCI_Object, name_or_number, key_with_value
|
||||
from bumble import core, l2cap
|
||||
from bumble.colors import color
|
||||
from bumble.core import (
|
||||
InvalidStateError,
|
||||
InvalidArgumentError,
|
||||
InvalidPacketError,
|
||||
ProtocolError,
|
||||
)
|
||||
from bumble.hci import HCI_Object, name_or_number, key_with_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .device import Device, Connection
|
||||
@@ -124,7 +130,7 @@ SDP_ATTRIBUTE_ID_NAMES = {
|
||||
SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
|
||||
|
||||
# To be used in searches where an attribute ID list allows a range to be specified
|
||||
SDP_ALL_ATTRIBUTES_RANGE = (0x0000FFFF, 4) # Express this as tuple so we can convey the desired encoding size
|
||||
SDP_ALL_ATTRIBUTES_RANGE = (0x0000, 0xFFFF)
|
||||
|
||||
# fmt: on
|
||||
# pylint: enable=line-too-long
|
||||
@@ -242,11 +248,11 @@ class DataElement:
|
||||
return DataElement(DataElement.BOOLEAN, value)
|
||||
|
||||
@staticmethod
|
||||
def sequence(value: List[DataElement]) -> DataElement:
|
||||
def sequence(value: Iterable[DataElement]) -> DataElement:
|
||||
return DataElement(DataElement.SEQUENCE, value)
|
||||
|
||||
@staticmethod
|
||||
def alternative(value: List[DataElement]) -> DataElement:
|
||||
def alternative(value: Iterable[DataElement]) -> DataElement:
|
||||
return DataElement(DataElement.ALTERNATIVE, value)
|
||||
|
||||
@staticmethod
|
||||
@@ -344,9 +350,6 @@ class DataElement:
|
||||
] # Keep a copy so we can re-serialize to an exact replica
|
||||
return result
|
||||
|
||||
def to_bytes(self):
|
||||
return bytes(self)
|
||||
|
||||
def __bytes__(self):
|
||||
# Return early if we have a cache
|
||||
if self.bytes:
|
||||
@@ -434,6 +437,8 @@ class DataElement:
|
||||
if size != 1:
|
||||
raise InvalidArgumentError('boolean must be 1 byte')
|
||||
size_index = 0
|
||||
else:
|
||||
raise RuntimeError("internal error - self.type not supported")
|
||||
|
||||
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
||||
return self.bytes
|
||||
@@ -474,7 +479,9 @@ class ServiceAttribute:
|
||||
self.value = value
|
||||
|
||||
@staticmethod
|
||||
def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
|
||||
def list_from_data_elements(
|
||||
elements: Sequence[DataElement],
|
||||
) -> list[ServiceAttribute]:
|
||||
attribute_list = []
|
||||
for i in range(0, len(elements) // 2):
|
||||
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
|
||||
@@ -487,7 +494,7 @@ class ServiceAttribute:
|
||||
|
||||
@staticmethod
|
||||
def find_attribute_in_list(
|
||||
attribute_list: List[ServiceAttribute], attribute_id: int
|
||||
attribute_list: Iterable[ServiceAttribute], attribute_id: int
|
||||
) -> Optional[DataElement]:
|
||||
return next(
|
||||
(
|
||||
@@ -535,7 +542,12 @@ class SDP_PDU:
|
||||
See Bluetooth spec @ Vol 3, Part B - 4.2 PROTOCOL DATA UNIT FORMAT
|
||||
'''
|
||||
|
||||
sdp_pdu_classes: Dict[int, Type[SDP_PDU]] = {}
|
||||
RESPONSE_PDU_IDS = {
|
||||
SDP_SERVICE_SEARCH_REQUEST: SDP_SERVICE_SEARCH_RESPONSE,
|
||||
SDP_SERVICE_ATTRIBUTE_REQUEST: SDP_SERVICE_ATTRIBUTE_RESPONSE,
|
||||
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST: SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE,
|
||||
}
|
||||
sdp_pdu_classes: dict[int, Type[SDP_PDU]] = {}
|
||||
name = None
|
||||
pdu_id = 0
|
||||
|
||||
@@ -559,7 +571,7 @@ class SDP_PDU:
|
||||
@staticmethod
|
||||
def parse_service_record_handle_list_preceded_by_count(
|
||||
data: bytes, offset: int
|
||||
) -> Tuple[int, List[int]]:
|
||||
) -> tuple[int, list[int]]:
|
||||
count = struct.unpack_from('>H', data, offset - 2)[0]
|
||||
handle_list = [
|
||||
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
||||
@@ -621,11 +633,8 @@ class SDP_PDU:
|
||||
def init_from_bytes(self, pdu, offset):
|
||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||
|
||||
def to_bytes(self):
|
||||
return self.pdu
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
return self.pdu
|
||||
|
||||
def __str__(self):
|
||||
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
|
||||
'''
|
||||
|
||||
error_code: int
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@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
|
||||
'''
|
||||
|
||||
service_record_handle_list: List[int]
|
||||
service_record_handle_list: list[int]
|
||||
total_service_record_count: int
|
||||
current_service_record_count: int
|
||||
continuation_state: bytes
|
||||
@@ -756,31 +767,99 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
|
||||
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
||||
'''
|
||||
|
||||
attribute_list_byte_count: int
|
||||
attribute_list: bytes
|
||||
attribute_lists_byte_count: int
|
||||
attribute_lists: bytes
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Client:
|
||||
channel: Optional[l2cap.ClassicChannel]
|
||||
|
||||
def __init__(self, connection: Connection) -> None:
|
||||
def __init__(self, connection: Connection, mtu: int = 0) -> None:
|
||||
self.connection = connection
|
||||
self.pending_request = None
|
||||
self.channel = None
|
||||
self.channel: Optional[l2cap.ClassicChannel] = None
|
||||
self.mtu = mtu
|
||||
self.request_semaphore = asyncio.Semaphore(1)
|
||||
self.pending_request: Optional[SDP_PDU] = None
|
||||
self.pending_response: Optional[asyncio.futures.Future[SDP_PDU]] = None
|
||||
self.next_transaction_id = 0
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.channel = await self.connection.create_l2cap_channel(
|
||||
spec=l2cap.ClassicChannelSpec(SDP_PSM)
|
||||
spec=(
|
||||
l2cap.ClassicChannelSpec(SDP_PSM, self.mtu)
|
||||
if self.mtu
|
||||
else l2cap.ClassicChannelSpec(SDP_PSM)
|
||||
)
|
||||
)
|
||||
self.channel.sink = self.on_pdu
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self.channel:
|
||||
await self.channel.disconnect()
|
||||
self.channel = None
|
||||
|
||||
async def search_services(self, uuids: List[core.UUID]) -> List[int]:
|
||||
def make_transaction_id(self) -> int:
|
||||
transaction_id = self.next_transaction_id
|
||||
self.next_transaction_id = (self.next_transaction_id + 1) & 0xFFFF
|
||||
return transaction_id
|
||||
|
||||
def on_pdu(self, pdu: bytes) -> None:
|
||||
if not self.pending_request:
|
||||
logger.warning('received response with no pending request')
|
||||
return
|
||||
assert self.pending_response is not None
|
||||
|
||||
response = SDP_PDU.from_bytes(pdu)
|
||||
|
||||
# Check that the transaction ID is what we expect
|
||||
if self.pending_request.transaction_id != response.transaction_id:
|
||||
logger.warning(
|
||||
f"received response with transaction ID {response.transaction_id} "
|
||||
f"but expected {self.pending_request.transaction_id}"
|
||||
)
|
||||
return
|
||||
|
||||
# Check if the response is an error
|
||||
if isinstance(response, SDP_ErrorResponse):
|
||||
self.pending_response.set_exception(
|
||||
ProtocolError(error_code=response.error_code)
|
||||
)
|
||||
return
|
||||
|
||||
# Check that the type of the response matches the request
|
||||
if response.pdu_id != SDP_PDU.RESPONSE_PDU_IDS.get(self.pending_request.pdu_id):
|
||||
logger.warning("response type mismatch")
|
||||
return
|
||||
|
||||
self.pending_response.set_result(response)
|
||||
|
||||
async def send_request(self, request: SDP_PDU) -> SDP_PDU:
|
||||
assert self.channel is not None
|
||||
async with self.request_semaphore:
|
||||
assert self.pending_request is None
|
||||
assert self.pending_response is None
|
||||
|
||||
# Create a future value to hold the eventual response
|
||||
self.pending_response = asyncio.get_running_loop().create_future()
|
||||
self.pending_request = request
|
||||
|
||||
try:
|
||||
self.channel.send_pdu(bytes(request))
|
||||
return await self.pending_response
|
||||
finally:
|
||||
self.pending_request = None
|
||||
self.pending_response = None
|
||||
|
||||
async def search_services(self, uuids: Iterable[core.UUID]) -> list[int]:
|
||||
"""
|
||||
Search for services by UUID.
|
||||
|
||||
Args:
|
||||
uuids: service the UUIDs to search for.
|
||||
|
||||
Returns:
|
||||
A list of matching service record handles.
|
||||
"""
|
||||
if self.pending_request is not None:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.channel is None:
|
||||
@@ -795,16 +874,16 @@ class Client:
|
||||
continuation_state = bytes([0])
|
||||
watchdog = SDP_CONTINUATION_WATCHDOG
|
||||
while watchdog > 0:
|
||||
response_pdu = await self.channel.send_request(
|
||||
response = await self.send_request(
|
||||
SDP_ServiceSearchRequest(
|
||||
transaction_id=0, # Transaction ID TODO: pick a real value
|
||||
transaction_id=self.make_transaction_id(),
|
||||
service_search_pattern=service_search_pattern,
|
||||
maximum_service_record_count=0xFFFF,
|
||||
continuation_state=continuation_state,
|
||||
)
|
||||
)
|
||||
response = SDP_PDU.from_bytes(response_pdu)
|
||||
logger.debug(f'<<< Response: {response}')
|
||||
assert isinstance(response, SDP_ServiceSearchResponse)
|
||||
service_record_handle_list += response.service_record_handle_list
|
||||
continuation_state = response.continuation_state
|
||||
if len(continuation_state) == 1 and continuation_state[0] == 0:
|
||||
@@ -815,8 +894,21 @@ class Client:
|
||||
return service_record_handle_list
|
||||
|
||||
async def search_attributes(
|
||||
self, uuids: List[core.UUID], attribute_ids: List[Union[int, Tuple[int, int]]]
|
||||
) -> List[List[ServiceAttribute]]:
|
||||
self,
|
||||
uuids: Iterable[core.UUID],
|
||||
attribute_ids: Iterable[Union[int, tuple[int, int]]],
|
||||
) -> list[list[ServiceAttribute]]:
|
||||
"""
|
||||
Search for attributes by UUID and attribute IDs.
|
||||
|
||||
Args:
|
||||
uuids: the service UUIDs to search for.
|
||||
attribute_ids: list of attribute IDs or (start, end) attribute ID ranges.
|
||||
(use (0, 0xFFFF) to include all attributes)
|
||||
|
||||
Returns:
|
||||
A list of list of attributes, one list per matching service.
|
||||
"""
|
||||
if self.pending_request is not None:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.channel is None:
|
||||
@@ -828,8 +920,8 @@ class Client:
|
||||
attribute_id_list = DataElement.sequence(
|
||||
[
|
||||
(
|
||||
DataElement.unsigned_integer(
|
||||
attribute_id[0], value_size=attribute_id[1]
|
||||
DataElement.unsigned_integer_32(
|
||||
attribute_id[0] << 16 | attribute_id[1]
|
||||
)
|
||||
if isinstance(attribute_id, tuple)
|
||||
else DataElement.unsigned_integer_16(attribute_id)
|
||||
@@ -843,17 +935,17 @@ class Client:
|
||||
continuation_state = bytes([0])
|
||||
watchdog = SDP_CONTINUATION_WATCHDOG
|
||||
while watchdog > 0:
|
||||
response_pdu = await self.channel.send_request(
|
||||
response = await self.send_request(
|
||||
SDP_ServiceSearchAttributeRequest(
|
||||
transaction_id=0, # Transaction ID TODO: pick a real value
|
||||
transaction_id=self.make_transaction_id(),
|
||||
service_search_pattern=service_search_pattern,
|
||||
maximum_attribute_byte_count=0xFFFF,
|
||||
attribute_id_list=attribute_id_list,
|
||||
continuation_state=continuation_state,
|
||||
)
|
||||
)
|
||||
response = SDP_PDU.from_bytes(response_pdu)
|
||||
logger.debug(f'<<< Response: {response}')
|
||||
assert isinstance(response, SDP_ServiceSearchAttributeResponse)
|
||||
accumulator += response.attribute_lists
|
||||
continuation_state = response.continuation_state
|
||||
if len(continuation_state) == 1 and continuation_state[0] == 0:
|
||||
@@ -876,8 +968,18 @@ class Client:
|
||||
async def get_attributes(
|
||||
self,
|
||||
service_record_handle: int,
|
||||
attribute_ids: List[Union[int, Tuple[int, int]]],
|
||||
) -> List[ServiceAttribute]:
|
||||
attribute_ids: Iterable[Union[int, tuple[int, int]]],
|
||||
) -> list[ServiceAttribute]:
|
||||
"""
|
||||
Get attributes for a service.
|
||||
|
||||
Args:
|
||||
service_record_handle: the handle for a service
|
||||
attribute_ids: list or attribute IDs or (start, end) attribute ID handles.
|
||||
|
||||
Returns:
|
||||
A list of attributes.
|
||||
"""
|
||||
if self.pending_request is not None:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.channel is None:
|
||||
@@ -886,8 +988,8 @@ class Client:
|
||||
attribute_id_list = DataElement.sequence(
|
||||
[
|
||||
(
|
||||
DataElement.unsigned_integer(
|
||||
attribute_id[0], value_size=attribute_id[1]
|
||||
DataElement.unsigned_integer_32(
|
||||
attribute_id[0] << 16 | attribute_id[1]
|
||||
)
|
||||
if isinstance(attribute_id, tuple)
|
||||
else DataElement.unsigned_integer_16(attribute_id)
|
||||
@@ -901,17 +1003,17 @@ class Client:
|
||||
continuation_state = bytes([0])
|
||||
watchdog = SDP_CONTINUATION_WATCHDOG
|
||||
while watchdog > 0:
|
||||
response_pdu = await self.channel.send_request(
|
||||
response = await self.send_request(
|
||||
SDP_ServiceAttributeRequest(
|
||||
transaction_id=0, # Transaction ID TODO: pick a real value
|
||||
transaction_id=self.make_transaction_id(),
|
||||
service_record_handle=service_record_handle,
|
||||
maximum_attribute_byte_count=0xFFFF,
|
||||
attribute_id_list=attribute_id_list,
|
||||
continuation_state=continuation_state,
|
||||
)
|
||||
)
|
||||
response = SDP_PDU.from_bytes(response_pdu)
|
||||
logger.debug(f'<<< Response: {response}')
|
||||
assert isinstance(response, SDP_ServiceAttributeResponse)
|
||||
accumulator += response.attribute_list
|
||||
continuation_state = response.continuation_state
|
||||
if len(continuation_state) == 1 and continuation_state[0] == 0:
|
||||
@@ -937,17 +1039,17 @@ class Client:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Server:
|
||||
CONTINUATION_STATE = bytes([0x01, 0x43])
|
||||
CONTINUATION_STATE = bytes([0x01, 0x00])
|
||||
channel: Optional[l2cap.ClassicChannel]
|
||||
Service = NewType('Service', List[ServiceAttribute])
|
||||
service_records: Dict[int, Service]
|
||||
current_response: Union[None, bytes, Tuple[int, List[int]]]
|
||||
Service = NewType('Service', list[ServiceAttribute])
|
||||
service_records: dict[int, Service]
|
||||
current_response: Union[None, bytes, tuple[int, list[int]]]
|
||||
|
||||
def __init__(self, device: Device) -> None:
|
||||
self.device = device
|
||||
self.service_records = {} # Service records maps, by record handle
|
||||
self.channel = None
|
||||
self.current_response = None
|
||||
self.current_response = None # Current response data, used for continuations
|
||||
|
||||
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
|
||||
l2cap_channel_manager.create_classic_server(
|
||||
@@ -958,7 +1060,7 @@ class Server:
|
||||
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
||||
self.channel.send_pdu(response)
|
||||
|
||||
def match_services(self, search_pattern: DataElement) -> Dict[int, Service]:
|
||||
def match_services(self, search_pattern: DataElement) -> dict[int, Service]:
|
||||
# Find the services for which the attributes in the pattern is a subset of the
|
||||
# service's attribute values (NOTE: the value search recurses into sequences)
|
||||
matching_services = {}
|
||||
@@ -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):
|
||||
if len(self.current_response) > maximum_size:
|
||||
payload = self.current_response[:maximum_size]
|
||||
@@ -1029,7 +1156,7 @@ class Server:
|
||||
|
||||
@staticmethod
|
||||
def get_service_attributes(
|
||||
service: Service, attribute_ids: List[DataElement]
|
||||
service: Service, attribute_ids: Iterable[DataElement]
|
||||
) -> DataElement:
|
||||
attributes = []
|
||||
for attribute_id in attribute_ids:
|
||||
@@ -1057,30 +1184,24 @@ class Server:
|
||||
|
||||
def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
|
||||
# Check if this is a continuation
|
||||
if len(request.continuation_state) > 1:
|
||||
if self.current_response is None:
|
||||
self.send_response(
|
||||
SDP_ErrorResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
|
||||
)
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Cleanup any partial response leftover
|
||||
self.current_response = None
|
||||
if (
|
||||
continuation := self.check_continuation(
|
||||
request.continuation_state, request.transaction_id
|
||||
)
|
||||
) is None:
|
||||
return
|
||||
|
||||
if not continuation:
|
||||
# Find the matching services
|
||||
matching_services = self.match_services(request.service_search_pattern)
|
||||
service_record_handles = list(matching_services.keys())
|
||||
logger.debug(f'Service Record Handles: {service_record_handles}')
|
||||
|
||||
# Only return up to the maximum requested
|
||||
service_record_handles_subset = service_record_handles[
|
||||
: request.maximum_service_record_count
|
||||
]
|
||||
|
||||
# Serialize to a byte array, and remember the total count
|
||||
logger.debug(f'Service Record Handles: {service_record_handles}')
|
||||
self.current_response = (
|
||||
len(service_record_handles),
|
||||
service_record_handles_subset,
|
||||
@@ -1088,15 +1209,21 @@ class Server:
|
||||
|
||||
# Respond, keeping any unsent handles for later
|
||||
assert isinstance(self.current_response, tuple)
|
||||
service_record_handles = self.current_response[1][
|
||||
: request.maximum_service_record_count
|
||||
assert self.channel is not None
|
||||
total_service_record_count, service_record_handles = self.current_response
|
||||
maximum_service_record_count = (self.channel.peer_mtu - 11) // 4
|
||||
service_record_handles_remaining = service_record_handles[
|
||||
maximum_service_record_count:
|
||||
]
|
||||
service_record_handles = service_record_handles[:maximum_service_record_count]
|
||||
self.current_response = (
|
||||
self.current_response[0],
|
||||
self.current_response[1][request.maximum_service_record_count :],
|
||||
total_service_record_count,
|
||||
service_record_handles_remaining,
|
||||
)
|
||||
continuation_state = (
|
||||
Server.CONTINUATION_STATE if self.current_response[1] else bytes([0])
|
||||
Server.CONTINUATION_STATE
|
||||
if service_record_handles_remaining
|
||||
else bytes([0])
|
||||
)
|
||||
service_record_handle_list = b''.join(
|
||||
[struct.pack('>I', handle) for handle in service_record_handles]
|
||||
@@ -1104,7 +1231,7 @@ class Server:
|
||||
self.send_response(
|
||||
SDP_ServiceSearchResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
total_service_record_count=self.current_response[0],
|
||||
total_service_record_count=total_service_record_count,
|
||||
current_service_record_count=len(service_record_handles),
|
||||
service_record_handle_list=service_record_handle_list,
|
||||
continuation_state=continuation_state,
|
||||
@@ -1115,19 +1242,14 @@ class Server:
|
||||
self, request: SDP_ServiceAttributeRequest
|
||||
) -> None:
|
||||
# Check if this is a continuation
|
||||
if len(request.continuation_state) > 1:
|
||||
if self.current_response is None:
|
||||
self.send_response(
|
||||
SDP_ErrorResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
|
||||
)
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Cleanup any partial response leftover
|
||||
self.current_response = None
|
||||
if (
|
||||
continuation := self.check_continuation(
|
||||
request.continuation_state, request.transaction_id
|
||||
)
|
||||
) is None:
|
||||
return
|
||||
|
||||
if not continuation:
|
||||
# Check that the service exists
|
||||
service = self.service_records.get(request.service_record_handle)
|
||||
if service is None:
|
||||
@@ -1149,14 +1271,18 @@ class Server:
|
||||
self.current_response = bytes(attribute_list)
|
||||
|
||||
# Respond, keeping any pending chunks for later
|
||||
assert self.channel is not None
|
||||
maximum_attribute_byte_count = min(
|
||||
request.maximum_attribute_byte_count, self.channel.peer_mtu - 9
|
||||
)
|
||||
attribute_list_response, continuation_state = self.get_next_response_payload(
|
||||
request.maximum_attribute_byte_count
|
||||
maximum_attribute_byte_count
|
||||
)
|
||||
self.send_response(
|
||||
SDP_ServiceAttributeResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
attribute_list_byte_count=len(attribute_list_response),
|
||||
attribute_list=attribute_list,
|
||||
attribute_list=attribute_list_response,
|
||||
continuation_state=continuation_state,
|
||||
)
|
||||
)
|
||||
@@ -1165,18 +1291,14 @@ class Server:
|
||||
self, request: SDP_ServiceSearchAttributeRequest
|
||||
) -> None:
|
||||
# Check if this is a continuation
|
||||
if len(request.continuation_state) > 1:
|
||||
if self.current_response is None:
|
||||
self.send_response(
|
||||
SDP_ErrorResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Cleanup any partial response leftover
|
||||
self.current_response = None
|
||||
if (
|
||||
continuation := self.check_continuation(
|
||||
request.continuation_state, request.transaction_id
|
||||
)
|
||||
) is None:
|
||||
return
|
||||
|
||||
if not continuation:
|
||||
# Find the matching services
|
||||
matching_services = self.match_services(
|
||||
request.service_search_pattern
|
||||
@@ -1196,14 +1318,18 @@ class Server:
|
||||
self.current_response = bytes(attribute_lists)
|
||||
|
||||
# Respond, keeping any pending chunks for later
|
||||
assert self.channel is not None
|
||||
maximum_attribute_byte_count = min(
|
||||
request.maximum_attribute_byte_count, self.channel.peer_mtu - 9
|
||||
)
|
||||
attribute_lists_response, continuation_state = self.get_next_response_payload(
|
||||
request.maximum_attribute_byte_count
|
||||
maximum_attribute_byte_count
|
||||
)
|
||||
self.send_response(
|
||||
SDP_ServiceSearchAttributeResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
attribute_lists_byte_count=len(attribute_lists_response),
|
||||
attribute_lists=attribute_lists,
|
||||
attribute_lists=attribute_lists_response,
|
||||
continuation_state=continuation_state,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -298,11 +298,8 @@ class SMP_Command:
|
||||
def init_from_bytes(self, pdu: bytes, offset: int) -> None:
|
||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||
|
||||
def to_bytes(self):
|
||||
return self.pdu
|
||||
|
||||
def __bytes__(self):
|
||||
return self.to_bytes()
|
||||
return self.pdu
|
||||
|
||||
def __str__(self):
|
||||
result = color(self.name, 'yellow')
|
||||
@@ -698,6 +695,7 @@ class Session:
|
||||
self.ltk_ediv = 0
|
||||
self.ltk_rand = bytes(8)
|
||||
self.link_key: Optional[bytes] = None
|
||||
self.maximum_encryption_key_size: int = 0
|
||||
self.initiator_key_distribution: int = 0
|
||||
self.responder_key_distribution: int = 0
|
||||
self.peer_random_value: Optional[bytes] = None
|
||||
@@ -744,6 +742,10 @@ class Session:
|
||||
else:
|
||||
self.pairing_result = None
|
||||
|
||||
self.maximum_encryption_key_size = (
|
||||
pairing_config.delegate.maximum_encryption_key_size
|
||||
)
|
||||
|
||||
# Key Distribution (default values before negotiation)
|
||||
self.initiator_key_distribution = (
|
||||
pairing_config.delegate.local_initiator_key_distribution
|
||||
@@ -996,7 +998,7 @@ class Session:
|
||||
io_capability=self.io_capability,
|
||||
oob_data_flag=self.oob_data_flag,
|
||||
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,
|
||||
responder_key_distribution=self.responder_key_distribution,
|
||||
)
|
||||
@@ -1008,7 +1010,7 @@ class Session:
|
||||
io_capability=self.io_capability,
|
||||
oob_data_flag=self.oob_data_flag,
|
||||
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,
|
||||
responder_key_distribution=self.responder_key_distribution,
|
||||
)
|
||||
@@ -1324,7 +1326,7 @@ class Session:
|
||||
self.connection.abort_on('disconnection', self.on_pairing())
|
||||
|
||||
def on_connection_encryption_change(self) -> None:
|
||||
if self.connection.is_encrypted:
|
||||
if self.connection.is_encrypted and not self.completed:
|
||||
if self.is_responder:
|
||||
# The responder distributes its keys first, the initiator later
|
||||
self.distribute_keys()
|
||||
@@ -1839,7 +1841,7 @@ class Session:
|
||||
if self.is_initiator:
|
||||
if self.pairing_method == PairingMethod.OOB:
|
||||
self.send_pairing_random_command()
|
||||
else:
|
||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||
self.send_pairing_confirm_command()
|
||||
else:
|
||||
if self.pairing_method == PairingMethod.PASSKEY:
|
||||
@@ -1949,7 +1951,7 @@ class Manager(EventEmitter):
|
||||
f'{connection.peer_address}: {command}'
|
||||
)
|
||||
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(
|
||||
self, connection: Connection, request: SMP_Security_Request_Command
|
||||
|
||||
@@ -20,12 +20,14 @@ import atexit
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
|
||||
import grpc.aio
|
||||
|
||||
from .common import (
|
||||
import bumble
|
||||
from bumble.transport.common import (
|
||||
ParserSource,
|
||||
PumpedTransport,
|
||||
PumpedPacketSource,
|
||||
@@ -36,15 +38,15 @@ from .common import (
|
||||
)
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from .grpc_protobuf.packet_streamer_pb2_grpc import (
|
||||
from .grpc_protobuf.netsim.packet_streamer_pb2_grpc import (
|
||||
PacketStreamerStub,
|
||||
PacketStreamerServicer,
|
||||
add_PacketStreamerServicer_to_server,
|
||||
)
|
||||
from .grpc_protobuf.packet_streamer_pb2 import PacketRequest, PacketResponse
|
||||
from .grpc_protobuf.hci_packet_pb2 import HCIPacket
|
||||
from .grpc_protobuf.startup_pb2 import Chip, ChipInfo
|
||||
from .grpc_protobuf.common_pb2 import ChipKind
|
||||
from .grpc_protobuf.netsim.packet_streamer_pb2 import PacketRequest, PacketResponse
|
||||
from .grpc_protobuf.netsim.hci_packet_pb2 import HCIPacket
|
||||
from .grpc_protobuf.netsim.startup_pb2 import Chip, ChipInfo, DeviceInfo
|
||||
from .grpc_protobuf.netsim.common_pb2 import ChipKind
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -58,6 +60,7 @@ logger = logging.getLogger(__name__)
|
||||
# -----------------------------------------------------------------------------
|
||||
DEFAULT_NAME = 'bumble0'
|
||||
DEFAULT_MANUFACTURER = 'Bumble'
|
||||
DEFAULT_VARIANT = ''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -199,7 +202,6 @@ async def open_android_netsim_controller_transport(
|
||||
data = (
|
||||
bytes([request.hci_packet.packet_type]) + request.hci_packet.packet
|
||||
)
|
||||
logger.debug(f'<<< PACKET: {data.hex()}')
|
||||
self.on_data_received(data)
|
||||
|
||||
async def send_packet(self, data):
|
||||
@@ -253,7 +255,7 @@ async def open_android_netsim_controller_transport(
|
||||
|
||||
# Check that we don't already have a device
|
||||
if self.device:
|
||||
logger.debug('busy, already serving a device')
|
||||
logger.debug('Busy, already serving a device')
|
||||
return PacketResponse(error='Busy')
|
||||
|
||||
# Instantiate a new device
|
||||
@@ -312,16 +314,24 @@ async def open_android_netsim_host_transport_with_channel(
|
||||
):
|
||||
# Wrapper for I/O operations
|
||||
class HciDevice:
|
||||
def __init__(self, name, manufacturer, hci_device):
|
||||
def __init__(self, name, variant, manufacturer, hci_device):
|
||||
self.name = name
|
||||
self.variant = variant
|
||||
self.manufacturer = manufacturer
|
||||
self.hci_device = hci_device
|
||||
|
||||
async def start(self): # Send the startup info
|
||||
chip_info = ChipInfo(
|
||||
device_info = DeviceInfo(
|
||||
name=self.name,
|
||||
chip=Chip(kind=ChipKind.BLUETOOTH, manufacturer=self.manufacturer),
|
||||
kind='BUMBLE',
|
||||
version=bumble.__version__,
|
||||
sdk_version=platform.python_version(),
|
||||
build_id=platform.platform(),
|
||||
arch=platform.machine(),
|
||||
variant=self.variant,
|
||||
)
|
||||
chip = Chip(kind=ChipKind.BLUETOOTH, manufacturer=self.manufacturer)
|
||||
chip_info = ChipInfo(name=self.name, chip=chip, device_info=device_info)
|
||||
logger.debug(f'Sending chip info to netsim: {chip_info}')
|
||||
await self.hci_device.write(PacketRequest(initial_info=chip_info))
|
||||
|
||||
@@ -349,12 +359,16 @@ async def open_android_netsim_host_transport_with_channel(
|
||||
)
|
||||
|
||||
name = DEFAULT_NAME if options is None else options.get('name', DEFAULT_NAME)
|
||||
variant = (
|
||||
DEFAULT_VARIANT if options is None else options.get('variant', DEFAULT_VARIANT)
|
||||
)
|
||||
manufacturer = DEFAULT_MANUFACTURER
|
||||
|
||||
# Connect as a host
|
||||
service = PacketStreamerStub(channel)
|
||||
hci_device = HciDevice(
|
||||
name=name,
|
||||
variant=variant,
|
||||
manufacturer=manufacturer,
|
||||
hci_device=service.StreamPackets(),
|
||||
)
|
||||
@@ -404,6 +418,9 @@ async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
||||
The "chip" name, used to identify the "chip" instance. This
|
||||
may be useful when several clients are connected, since each needs to use a
|
||||
different name.
|
||||
variant=<variant>
|
||||
The device info variant field, which may be used to convey a device or
|
||||
application type (ex: "virtual-speaker", or "keyboard")
|
||||
|
||||
In `controller` mode:
|
||||
The <host>:<port> part is required. <host> may be the address of a local network
|
||||
|
||||
@@ -370,11 +370,13 @@ class PumpedPacketSource(ParserSource):
|
||||
self.parser.feed_data(packet)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug('source pump task done')
|
||||
self.terminated.set_result(None)
|
||||
if not self.terminated.done():
|
||||
self.terminated.set_result(None)
|
||||
break
|
||||
except Exception as 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
|
||||
|
||||
self.pump_task = asyncio.create_task(pump_packets())
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: hci_packet.proto
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf.internal import builder as _builder
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10hci_packet.proto\x12\rnetsim.packet\"\xb2\x01\n\tHCIPacket\x12\x38\n\x0bpacket_type\x18\x01 \x01(\x0e\x32#.netsim.packet.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"[\n\nPacketType\x12\x1a\n\x16HCI_PACKET_UNSPECIFIED\x10\x00\x12\x0b\n\x07\x43OMMAND\x10\x01\x12\x07\n\x03\x41\x43L\x10\x02\x12\x07\n\x03SCO\x10\x03\x12\t\n\x05\x45VENT\x10\x04\x12\x07\n\x03ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3')
|
||||
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'hci_packet_pb2', globals())
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
|
||||
_HCIPACKET._serialized_start=36
|
||||
_HCIPACKET._serialized_end=214
|
||||
_HCIPACKET_PACKETTYPE._serialized_start=123
|
||||
_HCIPACKET_PACKETTYPE._serialized_end=214
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
0
bumble/transport/grpc_protobuf/netsim/__init__.py
Normal file
0
bumble/transport/grpc_protobuf/netsim/__init__.py
Normal file
@@ -1,11 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: common.proto
|
||||
# source: netsim/common.proto
|
||||
# Protobuf Python Version: 4.25.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf.internal import builder as _builder
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
@@ -13,13 +14,13 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63ommon.proto\x12\rnetsim.common*=\n\x08\x43hipKind\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\r\n\tBLUETOOTH\x10\x01\x12\x08\n\x04WIFI\x10\x02\x12\x07\n\x03UWB\x10\x03\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13netsim/common.proto\x12\rnetsim.common*S\n\x08\x43hipKind\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\r\n\tBLUETOOTH\x10\x01\x12\x08\n\x04WIFI\x10\x02\x12\x07\n\x03UWB\x10\x03\x12\x14\n\x10\x42LUETOOTH_BEACON\x10\x04\x62\x06proto3')
|
||||
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'common_pb2', globals())
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'netsim.common_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
_CHIPKIND._serialized_start=31
|
||||
_CHIPKIND._serialized_end=92
|
||||
_globals['_CHIPKIND']._serialized_start=38
|
||||
_globals['_CHIPKIND']._serialized_end=121
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -2,11 +2,17 @@ from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from typing import ClassVar as _ClassVar
|
||||
|
||||
BLUETOOTH: ChipKind
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
UNSPECIFIED: ChipKind
|
||||
UWB: ChipKind
|
||||
WIFI: ChipKind
|
||||
|
||||
class ChipKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = []
|
||||
__slots__ = ()
|
||||
UNSPECIFIED: _ClassVar[ChipKind]
|
||||
BLUETOOTH: _ClassVar[ChipKind]
|
||||
WIFI: _ClassVar[ChipKind]
|
||||
UWB: _ClassVar[ChipKind]
|
||||
BLUETOOTH_BEACON: _ClassVar[ChipKind]
|
||||
UNSPECIFIED: ChipKind
|
||||
BLUETOOTH: ChipKind
|
||||
WIFI: ChipKind
|
||||
UWB: ChipKind
|
||||
BLUETOOTH_BEACON: ChipKind
|
||||
29
bumble/transport/grpc_protobuf/netsim/hci_packet_pb2.py
Normal file
29
bumble/transport/grpc_protobuf/netsim/hci_packet_pb2.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: netsim/hci_packet.proto
|
||||
# Protobuf Python Version: 4.25.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17netsim/hci_packet.proto\x12\rnetsim.packet\"\xb2\x01\n\tHCIPacket\x12\x38\n\x0bpacket_type\x18\x01 \x01(\x0e\x32#.netsim.packet.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"[\n\nPacketType\x12\x1a\n\x16HCI_PACKET_UNSPECIFIED\x10\x00\x12\x0b\n\x07\x43OMMAND\x10\x01\x12\x07\n\x03\x41\x43L\x10\x02\x12\x07\n\x03SCO\x10\x03\x12\t\n\x05\x45VENT\x10\x04\x12\x07\n\x03ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'netsim.hci_packet_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
_globals['DESCRIPTOR']._options = None
|
||||
_globals['DESCRIPTOR']._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
|
||||
_globals['_HCIPACKET']._serialized_start=43
|
||||
_globals['_HCIPACKET']._serialized_end=221
|
||||
_globals['_HCIPACKET_PACKETTYPE']._serialized_start=130
|
||||
_globals['_HCIPACKET_PACKETTYPE']._serialized_end=221
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -6,17 +6,23 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class HCIPacket(_message.Message):
|
||||
__slots__ = ["packet", "packet_type"]
|
||||
__slots__ = ("packet_type", "packet")
|
||||
class PacketType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = []
|
||||
ACL: HCIPacket.PacketType
|
||||
COMMAND: HCIPacket.PacketType
|
||||
EVENT: HCIPacket.PacketType
|
||||
__slots__ = ()
|
||||
HCI_PACKET_UNSPECIFIED: _ClassVar[HCIPacket.PacketType]
|
||||
COMMAND: _ClassVar[HCIPacket.PacketType]
|
||||
ACL: _ClassVar[HCIPacket.PacketType]
|
||||
SCO: _ClassVar[HCIPacket.PacketType]
|
||||
EVENT: _ClassVar[HCIPacket.PacketType]
|
||||
ISO: _ClassVar[HCIPacket.PacketType]
|
||||
HCI_PACKET_UNSPECIFIED: HCIPacket.PacketType
|
||||
ISO: HCIPacket.PacketType
|
||||
PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
PACKET_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
COMMAND: HCIPacket.PacketType
|
||||
ACL: HCIPacket.PacketType
|
||||
SCO: HCIPacket.PacketType
|
||||
packet: bytes
|
||||
EVENT: HCIPacket.PacketType
|
||||
ISO: HCIPacket.PacketType
|
||||
PACKET_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
packet_type: HCIPacket.PacketType
|
||||
packet: bytes
|
||||
def __init__(self, packet_type: _Optional[_Union[HCIPacket.PacketType, str]] = ..., packet: _Optional[bytes] = ...) -> None: ...
|
||||
63
bumble/transport/grpc_protobuf/netsim/model_pb2.py
Normal file
63
bumble/transport/grpc_protobuf/netsim/model_pb2.py
Normal file
File diff suppressed because one or more lines are too long
238
bumble/transport/grpc_protobuf/netsim/model_pb2.pyi
Normal file
238
bumble/transport/grpc_protobuf/netsim/model_pb2.pyi
Normal file
@@ -0,0 +1,238 @@
|
||||
from bumble.transport.grpc_protobuf.netsim import common_pb2 as _common_pb2
|
||||
from google.protobuf import timestamp_pb2 as _timestamp_pb2
|
||||
from bumble.transport.grpc_protobuf.rootcanal import configuration_pb2 as _configuration_pb2
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class PhyKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
NONE: _ClassVar[PhyKind]
|
||||
BLUETOOTH_CLASSIC: _ClassVar[PhyKind]
|
||||
BLUETOOTH_LOW_ENERGY: _ClassVar[PhyKind]
|
||||
WIFI: _ClassVar[PhyKind]
|
||||
UWB: _ClassVar[PhyKind]
|
||||
WIFI_RTT: _ClassVar[PhyKind]
|
||||
NONE: PhyKind
|
||||
BLUETOOTH_CLASSIC: PhyKind
|
||||
BLUETOOTH_LOW_ENERGY: PhyKind
|
||||
WIFI: PhyKind
|
||||
UWB: PhyKind
|
||||
WIFI_RTT: PhyKind
|
||||
|
||||
class Position(_message.Message):
|
||||
__slots__ = ("x", "y", "z")
|
||||
X_FIELD_NUMBER: _ClassVar[int]
|
||||
Y_FIELD_NUMBER: _ClassVar[int]
|
||||
Z_FIELD_NUMBER: _ClassVar[int]
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ..., z: _Optional[float] = ...) -> None: ...
|
||||
|
||||
class Orientation(_message.Message):
|
||||
__slots__ = ("yaw", "pitch", "roll")
|
||||
YAW_FIELD_NUMBER: _ClassVar[int]
|
||||
PITCH_FIELD_NUMBER: _ClassVar[int]
|
||||
ROLL_FIELD_NUMBER: _ClassVar[int]
|
||||
yaw: float
|
||||
pitch: float
|
||||
roll: float
|
||||
def __init__(self, yaw: _Optional[float] = ..., pitch: _Optional[float] = ..., roll: _Optional[float] = ...) -> None: ...
|
||||
|
||||
class Chip(_message.Message):
|
||||
__slots__ = ("kind", "id", "name", "manufacturer", "product_name", "bt", "ble_beacon", "uwb", "wifi", "offset")
|
||||
class Radio(_message.Message):
|
||||
__slots__ = ("state", "range", "tx_count", "rx_count")
|
||||
STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
RANGE_FIELD_NUMBER: _ClassVar[int]
|
||||
TX_COUNT_FIELD_NUMBER: _ClassVar[int]
|
||||
RX_COUNT_FIELD_NUMBER: _ClassVar[int]
|
||||
state: bool
|
||||
range: float
|
||||
tx_count: int
|
||||
rx_count: int
|
||||
def __init__(self, state: bool = ..., range: _Optional[float] = ..., tx_count: _Optional[int] = ..., rx_count: _Optional[int] = ...) -> None: ...
|
||||
class Bluetooth(_message.Message):
|
||||
__slots__ = ("low_energy", "classic", "address", "bt_properties")
|
||||
LOW_ENERGY_FIELD_NUMBER: _ClassVar[int]
|
||||
CLASSIC_FIELD_NUMBER: _ClassVar[int]
|
||||
ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
BT_PROPERTIES_FIELD_NUMBER: _ClassVar[int]
|
||||
low_energy: Chip.Radio
|
||||
classic: Chip.Radio
|
||||
address: str
|
||||
bt_properties: _configuration_pb2.Controller
|
||||
def __init__(self, low_energy: _Optional[_Union[Chip.Radio, _Mapping]] = ..., classic: _Optional[_Union[Chip.Radio, _Mapping]] = ..., address: _Optional[str] = ..., bt_properties: _Optional[_Union[_configuration_pb2.Controller, _Mapping]] = ...) -> None: ...
|
||||
class BleBeacon(_message.Message):
|
||||
__slots__ = ("bt", "address", "settings", "adv_data", "scan_response")
|
||||
class AdvertiseSettings(_message.Message):
|
||||
__slots__ = ("advertise_mode", "milliseconds", "tx_power_level", "dbm", "scannable", "timeout")
|
||||
class AdvertiseMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
LOW_POWER: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseMode]
|
||||
BALANCED: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseMode]
|
||||
LOW_LATENCY: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseMode]
|
||||
LOW_POWER: Chip.BleBeacon.AdvertiseSettings.AdvertiseMode
|
||||
BALANCED: Chip.BleBeacon.AdvertiseSettings.AdvertiseMode
|
||||
LOW_LATENCY: Chip.BleBeacon.AdvertiseSettings.AdvertiseMode
|
||||
class AdvertiseTxPower(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
ULTRA_LOW: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower]
|
||||
LOW: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower]
|
||||
MEDIUM: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower]
|
||||
HIGH: _ClassVar[Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower]
|
||||
ULTRA_LOW: Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower
|
||||
LOW: Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower
|
||||
MEDIUM: Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower
|
||||
HIGH: Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower
|
||||
ADVERTISE_MODE_FIELD_NUMBER: _ClassVar[int]
|
||||
MILLISECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||
TX_POWER_LEVEL_FIELD_NUMBER: _ClassVar[int]
|
||||
DBM_FIELD_NUMBER: _ClassVar[int]
|
||||
SCANNABLE_FIELD_NUMBER: _ClassVar[int]
|
||||
TIMEOUT_FIELD_NUMBER: _ClassVar[int]
|
||||
advertise_mode: Chip.BleBeacon.AdvertiseSettings.AdvertiseMode
|
||||
milliseconds: int
|
||||
tx_power_level: Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower
|
||||
dbm: int
|
||||
scannable: bool
|
||||
timeout: int
|
||||
def __init__(self, advertise_mode: _Optional[_Union[Chip.BleBeacon.AdvertiseSettings.AdvertiseMode, str]] = ..., milliseconds: _Optional[int] = ..., tx_power_level: _Optional[_Union[Chip.BleBeacon.AdvertiseSettings.AdvertiseTxPower, str]] = ..., dbm: _Optional[int] = ..., scannable: bool = ..., timeout: _Optional[int] = ...) -> None: ...
|
||||
class AdvertiseData(_message.Message):
|
||||
__slots__ = ("include_device_name", "include_tx_power_level", "manufacturer_data", "services")
|
||||
class Service(_message.Message):
|
||||
__slots__ = ("uuid", "data")
|
||||
UUID_FIELD_NUMBER: _ClassVar[int]
|
||||
DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
uuid: str
|
||||
data: bytes
|
||||
def __init__(self, uuid: _Optional[str] = ..., data: _Optional[bytes] = ...) -> None: ...
|
||||
INCLUDE_DEVICE_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
INCLUDE_TX_POWER_LEVEL_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICES_FIELD_NUMBER: _ClassVar[int]
|
||||
include_device_name: bool
|
||||
include_tx_power_level: bool
|
||||
manufacturer_data: bytes
|
||||
services: _containers.RepeatedCompositeFieldContainer[Chip.BleBeacon.AdvertiseData.Service]
|
||||
def __init__(self, include_device_name: bool = ..., include_tx_power_level: bool = ..., manufacturer_data: _Optional[bytes] = ..., services: _Optional[_Iterable[_Union[Chip.BleBeacon.AdvertiseData.Service, _Mapping]]] = ...) -> None: ...
|
||||
BT_FIELD_NUMBER: _ClassVar[int]
|
||||
ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
SETTINGS_FIELD_NUMBER: _ClassVar[int]
|
||||
ADV_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
SCAN_RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
bt: Chip.Bluetooth
|
||||
address: str
|
||||
settings: Chip.BleBeacon.AdvertiseSettings
|
||||
adv_data: Chip.BleBeacon.AdvertiseData
|
||||
scan_response: Chip.BleBeacon.AdvertiseData
|
||||
def __init__(self, bt: _Optional[_Union[Chip.Bluetooth, _Mapping]] = ..., address: _Optional[str] = ..., settings: _Optional[_Union[Chip.BleBeacon.AdvertiseSettings, _Mapping]] = ..., adv_data: _Optional[_Union[Chip.BleBeacon.AdvertiseData, _Mapping]] = ..., scan_response: _Optional[_Union[Chip.BleBeacon.AdvertiseData, _Mapping]] = ...) -> None: ...
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
PRODUCT_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
BT_FIELD_NUMBER: _ClassVar[int]
|
||||
BLE_BEACON_FIELD_NUMBER: _ClassVar[int]
|
||||
UWB_FIELD_NUMBER: _ClassVar[int]
|
||||
WIFI_FIELD_NUMBER: _ClassVar[int]
|
||||
OFFSET_FIELD_NUMBER: _ClassVar[int]
|
||||
kind: _common_pb2.ChipKind
|
||||
id: int
|
||||
name: str
|
||||
manufacturer: str
|
||||
product_name: str
|
||||
bt: Chip.Bluetooth
|
||||
ble_beacon: Chip.BleBeacon
|
||||
uwb: Chip.Radio
|
||||
wifi: Chip.Radio
|
||||
offset: Position
|
||||
def __init__(self, kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., id: _Optional[int] = ..., name: _Optional[str] = ..., manufacturer: _Optional[str] = ..., product_name: _Optional[str] = ..., bt: _Optional[_Union[Chip.Bluetooth, _Mapping]] = ..., ble_beacon: _Optional[_Union[Chip.BleBeacon, _Mapping]] = ..., uwb: _Optional[_Union[Chip.Radio, _Mapping]] = ..., wifi: _Optional[_Union[Chip.Radio, _Mapping]] = ..., offset: _Optional[_Union[Position, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class ChipCreate(_message.Message):
|
||||
__slots__ = ("kind", "address", "name", "manufacturer", "product_name", "ble_beacon", "bt_properties")
|
||||
class BleBeaconCreate(_message.Message):
|
||||
__slots__ = ("address", "settings", "adv_data", "scan_response")
|
||||
ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
SETTINGS_FIELD_NUMBER: _ClassVar[int]
|
||||
ADV_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
SCAN_RESPONSE_FIELD_NUMBER: _ClassVar[int]
|
||||
address: str
|
||||
settings: Chip.BleBeacon.AdvertiseSettings
|
||||
adv_data: Chip.BleBeacon.AdvertiseData
|
||||
scan_response: Chip.BleBeacon.AdvertiseData
|
||||
def __init__(self, address: _Optional[str] = ..., settings: _Optional[_Union[Chip.BleBeacon.AdvertiseSettings, _Mapping]] = ..., adv_data: _Optional[_Union[Chip.BleBeacon.AdvertiseData, _Mapping]] = ..., scan_response: _Optional[_Union[Chip.BleBeacon.AdvertiseData, _Mapping]] = ...) -> None: ...
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
PRODUCT_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
BLE_BEACON_FIELD_NUMBER: _ClassVar[int]
|
||||
BT_PROPERTIES_FIELD_NUMBER: _ClassVar[int]
|
||||
kind: _common_pb2.ChipKind
|
||||
address: str
|
||||
name: str
|
||||
manufacturer: str
|
||||
product_name: str
|
||||
ble_beacon: ChipCreate.BleBeaconCreate
|
||||
bt_properties: _configuration_pb2.Controller
|
||||
def __init__(self, kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., address: _Optional[str] = ..., name: _Optional[str] = ..., manufacturer: _Optional[str] = ..., product_name: _Optional[str] = ..., ble_beacon: _Optional[_Union[ChipCreate.BleBeaconCreate, _Mapping]] = ..., bt_properties: _Optional[_Union[_configuration_pb2.Controller, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Device(_message.Message):
|
||||
__slots__ = ("id", "name", "visible", "position", "orientation", "chips")
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
VISIBLE_FIELD_NUMBER: _ClassVar[int]
|
||||
POSITION_FIELD_NUMBER: _ClassVar[int]
|
||||
ORIENTATION_FIELD_NUMBER: _ClassVar[int]
|
||||
CHIPS_FIELD_NUMBER: _ClassVar[int]
|
||||
id: int
|
||||
name: str
|
||||
visible: bool
|
||||
position: Position
|
||||
orientation: Orientation
|
||||
chips: _containers.RepeatedCompositeFieldContainer[Chip]
|
||||
def __init__(self, id: _Optional[int] = ..., name: _Optional[str] = ..., visible: bool = ..., position: _Optional[_Union[Position, _Mapping]] = ..., orientation: _Optional[_Union[Orientation, _Mapping]] = ..., chips: _Optional[_Iterable[_Union[Chip, _Mapping]]] = ...) -> None: ...
|
||||
|
||||
class DeviceCreate(_message.Message):
|
||||
__slots__ = ("name", "position", "orientation", "chips")
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
POSITION_FIELD_NUMBER: _ClassVar[int]
|
||||
ORIENTATION_FIELD_NUMBER: _ClassVar[int]
|
||||
CHIPS_FIELD_NUMBER: _ClassVar[int]
|
||||
name: str
|
||||
position: Position
|
||||
orientation: Orientation
|
||||
chips: _containers.RepeatedCompositeFieldContainer[ChipCreate]
|
||||
def __init__(self, name: _Optional[str] = ..., position: _Optional[_Union[Position, _Mapping]] = ..., orientation: _Optional[_Union[Orientation, _Mapping]] = ..., chips: _Optional[_Iterable[_Union[ChipCreate, _Mapping]]] = ...) -> None: ...
|
||||
|
||||
class Scene(_message.Message):
|
||||
__slots__ = ("devices",)
|
||||
DEVICES_FIELD_NUMBER: _ClassVar[int]
|
||||
devices: _containers.RepeatedCompositeFieldContainer[Device]
|
||||
def __init__(self, devices: _Optional[_Iterable[_Union[Device, _Mapping]]] = ...) -> None: ...
|
||||
|
||||
class Capture(_message.Message):
|
||||
__slots__ = ("id", "chip_kind", "device_name", "state", "size", "records", "timestamp", "valid")
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
CHIP_KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
SIZE_FIELD_NUMBER: _ClassVar[int]
|
||||
RECORDS_FIELD_NUMBER: _ClassVar[int]
|
||||
TIMESTAMP_FIELD_NUMBER: _ClassVar[int]
|
||||
VALID_FIELD_NUMBER: _ClassVar[int]
|
||||
id: int
|
||||
chip_kind: _common_pb2.ChipKind
|
||||
device_name: str
|
||||
state: bool
|
||||
size: int
|
||||
records: int
|
||||
timestamp: _timestamp_pb2.Timestamp
|
||||
valid: bool
|
||||
def __init__(self, id: _Optional[int] = ..., chip_kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., device_name: _Optional[str] = ..., state: bool = ..., size: _Optional[int] = ..., records: _Optional[int] = ..., timestamp: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., valid: bool = ...) -> None: ...
|
||||
32
bumble/transport/grpc_protobuf/netsim/packet_streamer_pb2.py
Normal file
32
bumble/transport/grpc_protobuf/netsim/packet_streamer_pb2.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: netsim/packet_streamer.proto
|
||||
# Protobuf Python Version: 4.25.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from bumble.transport.grpc_protobuf.netsim import hci_packet_pb2 as netsim_dot_hci__packet__pb2
|
||||
from bumble.transport.grpc_protobuf.netsim import startup_pb2 as netsim_dot_startup__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cnetsim/packet_streamer.proto\x12\rnetsim.packet\x1a\x17netsim/hci_packet.proto\x1a\x14netsim/startup.proto\"\x93\x01\n\rPacketRequest\x12\x30\n\x0cinitial_info\x18\x01 \x01(\x0b\x32\x18.netsim.startup.ChipInfoH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0e\n\x0crequest_type\"t\n\x0ePacketResponse\x12\x0f\n\x05\x65rror\x18\x01 \x01(\tH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0f\n\rresponse_type2b\n\x0ePacketStreamer\x12P\n\rStreamPackets\x12\x1c.netsim.packet.PacketRequest\x1a\x1d.netsim.packet.PacketResponse(\x01\x30\x01\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'netsim.packet_streamer_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
_globals['_PACKETREQUEST']._serialized_start=95
|
||||
_globals['_PACKETREQUEST']._serialized_end=242
|
||||
_globals['_PACKETRESPONSE']._serialized_start=244
|
||||
_globals['_PACKETRESPONSE']._serialized_end=360
|
||||
_globals['_PACKETSTREAMER']._serialized_start=362
|
||||
_globals['_PACKETSTREAMER']._serialized_end=460
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -1,5 +1,5 @@
|
||||
from . import hci_packet_pb2 as _hci_packet_pb2
|
||||
from . import startup_pb2 as _startup_pb2
|
||||
from bumble.transport.grpc_protobuf.netsim import hci_packet_pb2 as _hci_packet_pb2
|
||||
from bumble.transport.grpc_protobuf.netsim import startup_pb2 as _startup_pb2
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
@@ -7,17 +7,17 @@ from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Opti
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class PacketRequest(_message.Message):
|
||||
__slots__ = ["hci_packet", "initial_info", "packet"]
|
||||
HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
__slots__ = ("initial_info", "hci_packet", "packet")
|
||||
INITIAL_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
hci_packet: _hci_packet_pb2.HCIPacket
|
||||
initial_info: _startup_pb2.ChipInfo
|
||||
hci_packet: _hci_packet_pb2.HCIPacket
|
||||
packet: bytes
|
||||
def __init__(self, initial_info: _Optional[_Union[_startup_pb2.ChipInfo, _Mapping]] = ..., hci_packet: _Optional[_Union[_hci_packet_pb2.HCIPacket, _Mapping]] = ..., packet: _Optional[bytes] = ...) -> None: ...
|
||||
|
||||
class PacketResponse(_message.Message):
|
||||
__slots__ = ["error", "hci_packet", "packet"]
|
||||
__slots__ = ("error", "hci_packet", "packet")
|
||||
ERROR_FIELD_NUMBER: _ClassVar[int]
|
||||
HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
PACKET_FIELD_NUMBER: _ClassVar[int]
|
||||
@@ -2,7 +2,7 @@
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
from . import packet_streamer_pb2 as packet__streamer__pb2
|
||||
from bumble.transport.grpc_protobuf.netsim import packet_streamer_pb2 as netsim_dot_packet__streamer__pb2
|
||||
|
||||
|
||||
class PacketStreamerStub(object):
|
||||
@@ -30,8 +30,8 @@ class PacketStreamerStub(object):
|
||||
"""
|
||||
self.StreamPackets = channel.stream_stream(
|
||||
'/netsim.packet.PacketStreamer/StreamPackets',
|
||||
request_serializer=packet__streamer__pb2.PacketRequest.SerializeToString,
|
||||
response_deserializer=packet__streamer__pb2.PacketResponse.FromString,
|
||||
request_serializer=netsim_dot_packet__streamer__pb2.PacketRequest.SerializeToString,
|
||||
response_deserializer=netsim_dot_packet__streamer__pb2.PacketResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ def add_PacketStreamerServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'StreamPackets': grpc.stream_stream_rpc_method_handler(
|
||||
servicer.StreamPackets,
|
||||
request_deserializer=packet__streamer__pb2.PacketRequest.FromString,
|
||||
response_serializer=packet__streamer__pb2.PacketResponse.SerializeToString,
|
||||
request_deserializer=netsim_dot_packet__streamer__pb2.PacketRequest.FromString,
|
||||
response_serializer=netsim_dot_packet__streamer__pb2.PacketResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
@@ -103,7 +103,7 @@ class PacketStreamer(object):
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.stream_stream(request_iterator, target, '/netsim.packet.PacketStreamer/StreamPackets',
|
||||
packet__streamer__pb2.PacketRequest.SerializeToString,
|
||||
packet__streamer__pb2.PacketResponse.FromString,
|
||||
netsim_dot_packet__streamer__pb2.PacketRequest.SerializeToString,
|
||||
netsim_dot_packet__streamer__pb2.PacketResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
41
bumble/transport/grpc_protobuf/netsim/startup_pb2.py
Normal file
41
bumble/transport/grpc_protobuf/netsim/startup_pb2.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: netsim/startup.proto
|
||||
# Protobuf Python Version: 4.25.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from bumble.transport.grpc_protobuf.netsim import common_pb2 as netsim_dot_common__pb2
|
||||
from bumble.transport.grpc_protobuf.netsim import model_pb2 as netsim_dot_model__pb2
|
||||
from bumble.transport.grpc_protobuf.rootcanal import configuration_pb2 as rootcanal_dot_configuration__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14netsim/startup.proto\x12\x0enetsim.startup\x1a\x13netsim/common.proto\x1a\x12netsim/model.proto\x1a\x1drootcanal/configuration.proto\"\xb4\x01\n\x0bStartupInfo\x12\x33\n\x07\x64\x65vices\x18\x01 \x03(\x0b\x32\".netsim.startup.StartupInfo.Device\x1ap\n\x06\x44\x65vice\x12\x10\n\x04name\x18\x01 \x01(\tB\x02\x18\x01\x12#\n\x05\x63hips\x18\x02 \x03(\x0b\x32\x14.netsim.startup.Chip\x12/\n\x0b\x64\x65vice_info\x18\x03 \x01(\x0b\x32\x1a.netsim.startup.DeviceInfo\"q\n\x08\x43hipInfo\x12\x10\n\x04name\x18\x01 \x01(\tB\x02\x18\x01\x12\"\n\x04\x63hip\x18\x02 \x01(\x0b\x32\x14.netsim.startup.Chip\x12/\n\x0b\x64\x65vice_info\x18\x03 \x01(\x0b\x32\x1a.netsim.startup.DeviceInfo\"\x7f\n\nDeviceInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x13\n\x0bsdk_version\x18\x04 \x01(\t\x12\x10\n\x08\x62uild_id\x18\x05 \x01(\t\x12\x0f\n\x07variant\x18\x06 \x01(\t\x12\x0c\n\x04\x61rch\x18\x07 \x01(\t\"\x9b\x02\n\x04\x43hip\x12%\n\x04kind\x18\x01 \x01(\x0e\x32\x17.netsim.common.ChipKind\x12\n\n\x02id\x18\x02 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x03 \x01(\t\x12\x14\n\x0cproduct_name\x18\x04 \x01(\t\x12\r\n\x05\x66\x64_in\x18\x05 \x01(\x05\x12\x0e\n\x06\x66\x64_out\x18\x06 \x01(\x05\x12\x10\n\x08loopback\x18\x07 \x01(\x08\x12:\n\rbt_properties\x18\x08 \x01(\x0b\x32#.rootcanal.configuration.Controller\x12\x0f\n\x07\x61\x64\x64ress\x18\t \x01(\t\x12+\n\x06offset\x18\n \x01(\x0b\x32\x16.netsim.model.PositionH\x00\x88\x01\x01\x42\t\n\x07_offsetb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'netsim.startup_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
_globals['_STARTUPINFO_DEVICE'].fields_by_name['name']._options = None
|
||||
_globals['_STARTUPINFO_DEVICE'].fields_by_name['name']._serialized_options = b'\030\001'
|
||||
_globals['_CHIPINFO'].fields_by_name['name']._options = None
|
||||
_globals['_CHIPINFO'].fields_by_name['name']._serialized_options = b'\030\001'
|
||||
_globals['_STARTUPINFO']._serialized_start=113
|
||||
_globals['_STARTUPINFO']._serialized_end=293
|
||||
_globals['_STARTUPINFO_DEVICE']._serialized_start=181
|
||||
_globals['_STARTUPINFO_DEVICE']._serialized_end=293
|
||||
_globals['_CHIPINFO']._serialized_start=295
|
||||
_globals['_CHIPINFO']._serialized_end=408
|
||||
_globals['_DEVICEINFO']._serialized_start=410
|
||||
_globals['_DEVICEINFO']._serialized_end=537
|
||||
_globals['_CHIP']._serialized_start=540
|
||||
_globals['_CHIP']._serialized_end=823
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
76
bumble/transport/grpc_protobuf/netsim/startup_pb2.pyi
Normal file
76
bumble/transport/grpc_protobuf/netsim/startup_pb2.pyi
Normal file
@@ -0,0 +1,76 @@
|
||||
from bumble.transport.grpc_protobuf.netsim import common_pb2 as _common_pb2
|
||||
from bumble.transport.grpc_protobuf.netsim import model_pb2 as _model_pb2
|
||||
from bumble.transport.grpc_protobuf.rootcanal import configuration_pb2 as _configuration_pb2
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class StartupInfo(_message.Message):
|
||||
__slots__ = ("devices",)
|
||||
class Device(_message.Message):
|
||||
__slots__ = ("name", "chips", "device_info")
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
CHIPS_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
name: str
|
||||
chips: _containers.RepeatedCompositeFieldContainer[Chip]
|
||||
device_info: DeviceInfo
|
||||
def __init__(self, name: _Optional[str] = ..., chips: _Optional[_Iterable[_Union[Chip, _Mapping]]] = ..., device_info: _Optional[_Union[DeviceInfo, _Mapping]] = ...) -> None: ...
|
||||
DEVICES_FIELD_NUMBER: _ClassVar[int]
|
||||
devices: _containers.RepeatedCompositeFieldContainer[StartupInfo.Device]
|
||||
def __init__(self, devices: _Optional[_Iterable[_Union[StartupInfo.Device, _Mapping]]] = ...) -> None: ...
|
||||
|
||||
class ChipInfo(_message.Message):
|
||||
__slots__ = ("name", "chip", "device_info")
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
CHIP_FIELD_NUMBER: _ClassVar[int]
|
||||
DEVICE_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||
name: str
|
||||
chip: Chip
|
||||
device_info: DeviceInfo
|
||||
def __init__(self, name: _Optional[str] = ..., chip: _Optional[_Union[Chip, _Mapping]] = ..., device_info: _Optional[_Union[DeviceInfo, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class DeviceInfo(_message.Message):
|
||||
__slots__ = ("name", "kind", "version", "sdk_version", "build_id", "variant", "arch")
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
SDK_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||
BUILD_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
VARIANT_FIELD_NUMBER: _ClassVar[int]
|
||||
ARCH_FIELD_NUMBER: _ClassVar[int]
|
||||
name: str
|
||||
kind: str
|
||||
version: str
|
||||
sdk_version: str
|
||||
build_id: str
|
||||
variant: str
|
||||
arch: str
|
||||
def __init__(self, name: _Optional[str] = ..., kind: _Optional[str] = ..., version: _Optional[str] = ..., sdk_version: _Optional[str] = ..., build_id: _Optional[str] = ..., variant: _Optional[str] = ..., arch: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class Chip(_message.Message):
|
||||
__slots__ = ("kind", "id", "manufacturer", "product_name", "fd_in", "fd_out", "loopback", "bt_properties", "address", "offset")
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
PRODUCT_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
FD_IN_FIELD_NUMBER: _ClassVar[int]
|
||||
FD_OUT_FIELD_NUMBER: _ClassVar[int]
|
||||
LOOPBACK_FIELD_NUMBER: _ClassVar[int]
|
||||
BT_PROPERTIES_FIELD_NUMBER: _ClassVar[int]
|
||||
ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
OFFSET_FIELD_NUMBER: _ClassVar[int]
|
||||
kind: _common_pb2.ChipKind
|
||||
id: str
|
||||
manufacturer: str
|
||||
product_name: str
|
||||
fd_in: int
|
||||
fd_out: int
|
||||
loopback: bool
|
||||
bt_properties: _configuration_pb2.Controller
|
||||
address: str
|
||||
offset: _model_pb2.Position
|
||||
def __init__(self, kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., id: _Optional[str] = ..., manufacturer: _Optional[str] = ..., product_name: _Optional[str] = ..., fd_in: _Optional[int] = ..., fd_out: _Optional[int] = ..., loopback: bool = ..., bt_properties: _Optional[_Union[_configuration_pb2.Controller, _Mapping]] = ..., address: _Optional[str] = ..., offset: _Optional[_Union[_model_pb2.Position, _Mapping]] = ...) -> None: ...
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: packet_streamer.proto
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf.internal import builder as _builder
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from . import hci_packet_pb2 as hci__packet__pb2
|
||||
from . import startup_pb2 as startup__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15packet_streamer.proto\x12\rnetsim.packet\x1a\x10hci_packet.proto\x1a\rstartup.proto\"\x93\x01\n\rPacketRequest\x12\x30\n\x0cinitial_info\x18\x01 \x01(\x0b\x32\x18.netsim.startup.ChipInfoH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0e\n\x0crequest_type\"t\n\x0ePacketResponse\x12\x0f\n\x05\x65rror\x18\x01 \x01(\tH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0f\n\rresponse_type2b\n\x0ePacketStreamer\x12P\n\rStreamPackets\x12\x1c.netsim.packet.PacketRequest\x1a\x1d.netsim.packet.PacketResponse(\x01\x30\x01\x62\x06proto3')
|
||||
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'packet_streamer_pb2', globals())
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
_PACKETREQUEST._serialized_start=74
|
||||
_PACKETREQUEST._serialized_end=221
|
||||
_PACKETRESPONSE._serialized_start=223
|
||||
_PACKETRESPONSE._serialized_end=339
|
||||
_PACKETSTREAMER._serialized_start=341
|
||||
_PACKETSTREAMER._serialized_end=439
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: rootcanal/configuration.proto
|
||||
# Protobuf Python Version: 4.25.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1drootcanal/configuration.proto\x12\x17rootcanal.configuration\"\xbc\x01\n\x12\x43ontrollerFeatures\x12\x1f\n\x17le_extended_advertising\x18\x01 \x01(\x08\x12\x1f\n\x17le_periodic_advertising\x18\x02 \x01(\x08\x12\x12\n\nll_privacy\x18\x03 \x01(\x08\x12\x11\n\tle_2m_phy\x18\x04 \x01(\x08\x12\x14\n\x0cle_coded_phy\x18\x05 \x01(\x08\x12\'\n\x1fle_connected_isochronous_stream\x18\x06 \x01(\x08\"\x8d\x01\n\x10\x43ontrollerQuirks\x12\x30\n(send_acl_data_before_connection_complete\x18\x01 \x01(\x08\x12\"\n\x1ahas_default_random_address\x18\x02 \x01(\x08\x12#\n\x1bhardware_error_before_reset\x18\x03 \x01(\x08\".\n\x0eVendorFeatures\x12\x0b\n\x03\x63sr\x18\x01 \x01(\x08\x12\x0f\n\x07\x61ndroid\x18\x02 \x01(\x08\"\x8a\x02\n\nController\x12\x39\n\x06preset\x18\x01 \x01(\x0e\x32).rootcanal.configuration.ControllerPreset\x12=\n\x08\x66\x65\x61tures\x18\x02 \x01(\x0b\x32+.rootcanal.configuration.ControllerFeatures\x12\x39\n\x06quirks\x18\x03 \x01(\x0b\x32).rootcanal.configuration.ControllerQuirks\x12\x0e\n\x06strict\x18\x04 \x01(\x08\x12\x37\n\x06vendor\x18\x05 \x01(\x0b\x32\'.rootcanal.configuration.VendorFeatures\"Y\n\tTcpServer\x12\x10\n\x08tcp_port\x18\x01 \x02(\x05\x12:\n\rconfiguration\x18\x02 \x01(\x0b\x32#.rootcanal.configuration.Controller\"G\n\rConfiguration\x12\x36\n\ntcp_server\x18\x01 \x03(\x0b\x32\".rootcanal.configuration.TcpServer*H\n\x10\x43ontrollerPreset\x12\x0b\n\x07\x44\x45\x46\x41ULT\x10\x00\x12\x0f\n\x0bLAIRD_BL654\x10\x01\x12\x16\n\x12\x43SR_RCK_PTS_DONGLE\x10\x02\x42\x02H\x02')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rootcanal.configuration_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
_globals['DESCRIPTOR']._options = None
|
||||
_globals['DESCRIPTOR']._serialized_options = b'H\002'
|
||||
_globals['_CONTROLLERPRESET']._serialized_start=874
|
||||
_globals['_CONTROLLERPRESET']._serialized_end=946
|
||||
_globals['_CONTROLLERFEATURES']._serialized_start=59
|
||||
_globals['_CONTROLLERFEATURES']._serialized_end=247
|
||||
_globals['_CONTROLLERQUIRKS']._serialized_start=250
|
||||
_globals['_CONTROLLERQUIRKS']._serialized_end=391
|
||||
_globals['_VENDORFEATURES']._serialized_start=393
|
||||
_globals['_VENDORFEATURES']._serialized_end=439
|
||||
_globals['_CONTROLLER']._serialized_start=442
|
||||
_globals['_CONTROLLER']._serialized_end=708
|
||||
_globals['_TCPSERVER']._serialized_start=710
|
||||
_globals['_TCPSERVER']._serialized_end=799
|
||||
_globals['_CONFIGURATION']._serialized_start=801
|
||||
_globals['_CONFIGURATION']._serialized_end=872
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -0,0 +1,78 @@
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class ControllerPreset(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
DEFAULT: _ClassVar[ControllerPreset]
|
||||
LAIRD_BL654: _ClassVar[ControllerPreset]
|
||||
CSR_RCK_PTS_DONGLE: _ClassVar[ControllerPreset]
|
||||
DEFAULT: ControllerPreset
|
||||
LAIRD_BL654: ControllerPreset
|
||||
CSR_RCK_PTS_DONGLE: ControllerPreset
|
||||
|
||||
class ControllerFeatures(_message.Message):
|
||||
__slots__ = ("le_extended_advertising", "le_periodic_advertising", "ll_privacy", "le_2m_phy", "le_coded_phy", "le_connected_isochronous_stream")
|
||||
LE_EXTENDED_ADVERTISING_FIELD_NUMBER: _ClassVar[int]
|
||||
LE_PERIODIC_ADVERTISING_FIELD_NUMBER: _ClassVar[int]
|
||||
LL_PRIVACY_FIELD_NUMBER: _ClassVar[int]
|
||||
LE_2M_PHY_FIELD_NUMBER: _ClassVar[int]
|
||||
LE_CODED_PHY_FIELD_NUMBER: _ClassVar[int]
|
||||
LE_CONNECTED_ISOCHRONOUS_STREAM_FIELD_NUMBER: _ClassVar[int]
|
||||
le_extended_advertising: bool
|
||||
le_periodic_advertising: bool
|
||||
ll_privacy: bool
|
||||
le_2m_phy: bool
|
||||
le_coded_phy: bool
|
||||
le_connected_isochronous_stream: bool
|
||||
def __init__(self, le_extended_advertising: bool = ..., le_periodic_advertising: bool = ..., ll_privacy: bool = ..., le_2m_phy: bool = ..., le_coded_phy: bool = ..., le_connected_isochronous_stream: bool = ...) -> None: ...
|
||||
|
||||
class ControllerQuirks(_message.Message):
|
||||
__slots__ = ("send_acl_data_before_connection_complete", "has_default_random_address", "hardware_error_before_reset")
|
||||
SEND_ACL_DATA_BEFORE_CONNECTION_COMPLETE_FIELD_NUMBER: _ClassVar[int]
|
||||
HAS_DEFAULT_RANDOM_ADDRESS_FIELD_NUMBER: _ClassVar[int]
|
||||
HARDWARE_ERROR_BEFORE_RESET_FIELD_NUMBER: _ClassVar[int]
|
||||
send_acl_data_before_connection_complete: bool
|
||||
has_default_random_address: bool
|
||||
hardware_error_before_reset: bool
|
||||
def __init__(self, send_acl_data_before_connection_complete: bool = ..., has_default_random_address: bool = ..., hardware_error_before_reset: bool = ...) -> None: ...
|
||||
|
||||
class VendorFeatures(_message.Message):
|
||||
__slots__ = ("csr", "android")
|
||||
CSR_FIELD_NUMBER: _ClassVar[int]
|
||||
ANDROID_FIELD_NUMBER: _ClassVar[int]
|
||||
csr: bool
|
||||
android: bool
|
||||
def __init__(self, csr: bool = ..., android: bool = ...) -> None: ...
|
||||
|
||||
class Controller(_message.Message):
|
||||
__slots__ = ("preset", "features", "quirks", "strict", "vendor")
|
||||
PRESET_FIELD_NUMBER: _ClassVar[int]
|
||||
FEATURES_FIELD_NUMBER: _ClassVar[int]
|
||||
QUIRKS_FIELD_NUMBER: _ClassVar[int]
|
||||
STRICT_FIELD_NUMBER: _ClassVar[int]
|
||||
VENDOR_FIELD_NUMBER: _ClassVar[int]
|
||||
preset: ControllerPreset
|
||||
features: ControllerFeatures
|
||||
quirks: ControllerQuirks
|
||||
strict: bool
|
||||
vendor: VendorFeatures
|
||||
def __init__(self, preset: _Optional[_Union[ControllerPreset, str]] = ..., features: _Optional[_Union[ControllerFeatures, _Mapping]] = ..., quirks: _Optional[_Union[ControllerQuirks, _Mapping]] = ..., strict: bool = ..., vendor: _Optional[_Union[VendorFeatures, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class TcpServer(_message.Message):
|
||||
__slots__ = ("tcp_port", "configuration")
|
||||
TCP_PORT_FIELD_NUMBER: _ClassVar[int]
|
||||
CONFIGURATION_FIELD_NUMBER: _ClassVar[int]
|
||||
tcp_port: int
|
||||
configuration: Controller
|
||||
def __init__(self, tcp_port: _Optional[int] = ..., configuration: _Optional[_Union[Controller, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class Configuration(_message.Message):
|
||||
__slots__ = ("tcp_server",)
|
||||
TCP_SERVER_FIELD_NUMBER: _ClassVar[int]
|
||||
tcp_server: _containers.RepeatedCompositeFieldContainer[TcpServer]
|
||||
def __init__(self, tcp_server: _Optional[_Iterable[_Union[TcpServer, _Mapping]]] = ...) -> None: ...
|
||||
@@ -0,0 +1,4 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: startup.proto
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf.internal import builder as _builder
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from . import common_pb2 as common__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rstartup.proto\x12\x0enetsim.startup\x1a\x0c\x63ommon.proto\"\x7f\n\x0bStartupInfo\x12\x33\n\x07\x64\x65vices\x18\x01 \x03(\x0b\x32\".netsim.startup.StartupInfo.Device\x1a;\n\x06\x44\x65vice\x12\x0c\n\x04name\x18\x01 \x01(\t\x12#\n\x05\x63hips\x18\x02 \x03(\x0b\x32\x14.netsim.startup.Chip\"<\n\x08\x43hipInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\"\n\x04\x63hip\x18\x02 \x01(\x0b\x32\x14.netsim.startup.Chip\"\x96\x01\n\x04\x43hip\x12%\n\x04kind\x18\x01 \x01(\x0e\x32\x17.netsim.common.ChipKind\x12\n\n\x02id\x18\x02 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x03 \x01(\t\x12\x14\n\x0cproduct_name\x18\x04 \x01(\t\x12\r\n\x05\x66\x64_in\x18\x05 \x01(\x05\x12\x0e\n\x06\x66\x64_out\x18\x06 \x01(\x05\x12\x10\n\x08loopback\x18\x07 \x01(\x08\x62\x06proto3')
|
||||
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'startup_pb2', globals())
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
_STARTUPINFO._serialized_start=47
|
||||
_STARTUPINFO._serialized_end=174
|
||||
_STARTUPINFO_DEVICE._serialized_start=115
|
||||
_STARTUPINFO_DEVICE._serialized_end=174
|
||||
_CHIPINFO._serialized_start=176
|
||||
_CHIPINFO._serialized_end=236
|
||||
_CHIP._serialized_start=239
|
||||
_CHIP._serialized_end=389
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -1,46 +0,0 @@
|
||||
from . import common_pb2 as _common_pb2
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class Chip(_message.Message):
|
||||
__slots__ = ["fd_in", "fd_out", "id", "kind", "loopback", "manufacturer", "product_name"]
|
||||
FD_IN_FIELD_NUMBER: _ClassVar[int]
|
||||
FD_OUT_FIELD_NUMBER: _ClassVar[int]
|
||||
ID_FIELD_NUMBER: _ClassVar[int]
|
||||
KIND_FIELD_NUMBER: _ClassVar[int]
|
||||
LOOPBACK_FIELD_NUMBER: _ClassVar[int]
|
||||
MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
|
||||
PRODUCT_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
fd_in: int
|
||||
fd_out: int
|
||||
id: str
|
||||
kind: _common_pb2.ChipKind
|
||||
loopback: bool
|
||||
manufacturer: str
|
||||
product_name: str
|
||||
def __init__(self, kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., id: _Optional[str] = ..., manufacturer: _Optional[str] = ..., product_name: _Optional[str] = ..., fd_in: _Optional[int] = ..., fd_out: _Optional[int] = ..., loopback: bool = ...) -> None: ...
|
||||
|
||||
class ChipInfo(_message.Message):
|
||||
__slots__ = ["chip", "name"]
|
||||
CHIP_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
chip: Chip
|
||||
name: str
|
||||
def __init__(self, name: _Optional[str] = ..., chip: _Optional[_Union[Chip, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class StartupInfo(_message.Message):
|
||||
__slots__ = ["devices"]
|
||||
class Device(_message.Message):
|
||||
__slots__ = ["chips", "name"]
|
||||
CHIPS_FIELD_NUMBER: _ClassVar[int]
|
||||
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
chips: _containers.RepeatedCompositeFieldContainer[Chip]
|
||||
name: str
|
||||
def __init__(self, name: _Optional[str] = ..., chips: _Optional[_Iterable[_Union[Chip, _Mapping]]] = ...) -> None: ...
|
||||
DEVICES_FIELD_NUMBER: _ClassVar[int]
|
||||
devices: _containers.RepeatedCompositeFieldContainer[StartupInfo.Device]
|
||||
def __init__(self, devices: _Optional[_Iterable[_Union[StartupInfo.Device, _Mapping]]] = ...) -> None: ...
|
||||
@@ -149,7 +149,10 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
|
||||
if status != usb1.TRANSFER_COMPLETED:
|
||||
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):
|
||||
@@ -275,7 +278,10 @@ async def open_usb_transport(spec: str) -> Transport:
|
||||
)
|
||||
else:
|
||||
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)
|
||||
|
||||
|
||||
@@ -24,17 +24,19 @@ import logging
|
||||
import sys
|
||||
import warnings
|
||||
from typing import (
|
||||
Awaitable,
|
||||
Set,
|
||||
TypeVar,
|
||||
List,
|
||||
Tuple,
|
||||
Callable,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
List,
|
||||
Optional,
|
||||
Protocol,
|
||||
Set,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
from typing_extensions import Self
|
||||
|
||||
from pyee import EventEmitter
|
||||
|
||||
@@ -445,7 +447,7 @@ def deprecated(msg: str):
|
||||
def wrapper(function):
|
||||
@functools.wraps(function)
|
||||
def inner(*args, **kwargs):
|
||||
warnings.warn(msg, DeprecationWarning)
|
||||
warnings.warn(msg, DeprecationWarning, stacklevel=2)
|
||||
return function(*args, **kwargs)
|
||||
|
||||
return inner
|
||||
@@ -462,7 +464,7 @@ def experimental(msg: str):
|
||||
def wrapper(function):
|
||||
@functools.wraps(function)
|
||||
def inner(*args, **kwargs):
|
||||
warnings.warn(msg, FutureWarning)
|
||||
warnings.warn(msg, FutureWarning, stacklevel=2)
|
||||
return function(*args, **kwargs)
|
||||
|
||||
return inner
|
||||
@@ -487,3 +489,16 @@ class OpenIntEnum(enum.IntEnum):
|
||||
obj._value_ = value
|
||||
obj._name_ = f"{cls.__name__}[{value}]"
|
||||
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: ...
|
||||
|
||||
33
bumble/vendor/android/hci.py
vendored
33
bumble/vendor/android/hci.py
vendored
@@ -16,6 +16,7 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import struct
|
||||
from typing import Dict, Optional, Type
|
||||
|
||||
from bumble.hci import (
|
||||
name_or_number,
|
||||
@@ -24,7 +25,9 @@ from bumble.hci import (
|
||||
HCI_Constant,
|
||||
HCI_Object,
|
||||
HCI_Command,
|
||||
HCI_Vendor_Event,
|
||||
HCI_Event,
|
||||
HCI_Extended_Event,
|
||||
HCI_VENDOR_EVENT,
|
||||
STATUS_SPEC,
|
||||
)
|
||||
|
||||
@@ -48,7 +51,6 @@ HCI_DYNAMIC_AUDIO_BUFFER_COMMAND = hci_vendor_command_op_code(0x15F)
|
||||
HCI_BLUETOOTH_QUALITY_REPORT_EVENT = 0x58
|
||||
|
||||
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=[
|
||||
('quality_report_id', 1),
|
||||
('packet_types', 1),
|
||||
@@ -308,10 +332,11 @@ class HCI_Dynamic_Audio_Buffer_Command(HCI_Command):
|
||||
('tx_last_subevent_packets', 4),
|
||||
('crc_error_packets', 4),
|
||||
('rx_duplicate_packets', 4),
|
||||
('rx_unreceived_packets', 4),
|
||||
('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
|
||||
'''
|
||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#bluetooth-quality-report-sub-event
|
||||
|
||||
@@ -39,12 +39,14 @@ nav:
|
||||
- Drivers:
|
||||
- drivers/index.md
|
||||
- Realtek: drivers/realtek.md
|
||||
- Intel: drivers/intel.md
|
||||
- API:
|
||||
- Guide: api/guide.md
|
||||
- Examples: api/examples.md
|
||||
- Reference: api/reference.md
|
||||
- Apps & Tools:
|
||||
- apps_and_tools/index.md
|
||||
- Auracast: apps_and_tools/auracast.md
|
||||
- Console: apps_and_tools/console.md
|
||||
- Bench: apps_and_tools/bench.md
|
||||
- Speaker: apps_and_tools/speaker.md
|
||||
@@ -108,8 +110,8 @@ markdown_extensions:
|
||||
- pymdownx.details
|
||||
- pymdownx.superfences
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
- codehilite:
|
||||
|
||||
@@ -4,12 +4,13 @@ APPS & TOOLS
|
||||
Included in the project are a few apps and tools, built on top of the core libraries.
|
||||
These include:
|
||||
|
||||
* [Console](console.md) - an interactive text-based console
|
||||
* [Bench](bench.md) - Speed and Latency benchmarking between two devices (LE and Classic)
|
||||
* [Pair](pair.md) - Pair/bond two devices (LE and Classic)
|
||||
* [Unbond](unbond.md) - Remove a previously established bond
|
||||
* [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets
|
||||
* [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool"
|
||||
* [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form
|
||||
* [Auracast](auracast.md) - Commands to broadcast, receive and/or control LE Audio.
|
||||
* [Console](console.md) - An interactive text-based console.
|
||||
* [Bench](bench.md) - Speed and Latency benchmarking between two devices (LE and Classic).
|
||||
* [Pair](pair.md) - Pair/bond two devices (LE and Classic).
|
||||
* [Unbond](unbond.md) - Remove a previously established bond.
|
||||
* [HCI Bridge](hci_bridge.md) - An HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets.
|
||||
* [Golden Gate Bridge](gg_bridge.md) - Bridge between GATT and UDP to use with the Golden Gate "stack tool".
|
||||
* [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form.
|
||||
* [Speaker](speaker.md) - Virtual Bluetooth speaker, with a command line and browser-based UI.
|
||||
* [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.
|
||||
|
||||
@@ -16,4 +16,5 @@ USB vendor ID and product ID.
|
||||
|
||||
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.
|
||||
73
docs/mkdocs/src/drivers/intel.md
Normal file
73
docs/mkdocs/src/drivers/intel.md
Normal file
@@ -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.
|
||||
```
|
||||
@@ -25,6 +25,9 @@ An app that implements a virtual Bluetooth speaker that can receive audio.
|
||||
## `run_advertiser.py`
|
||||
An app that runs a simple device that just advertises (BLE).
|
||||
|
||||
## `run_cig_setup.py`
|
||||
An app that creates a simple CIG containing two CISes. **Note**: If using the example config file (e.g. `device1.json`), the `address` needs to be removed, so that the devices are given different random addresses.
|
||||
|
||||
## `run_classic_connect.py`
|
||||
An app that connects to a Bluetooth Classic device and prints its services.
|
||||
|
||||
|
||||
@@ -3,17 +3,15 @@ GETTING STARTED WITH BUMBLE
|
||||
|
||||
# Prerequisites
|
||||
|
||||
You need Python 3.8 or above. Python >= 3.9 is recommended, but 3.8 should be sufficient if
|
||||
necessary (there may be some optional functionality that will not work on some platforms with
|
||||
python 3.8).
|
||||
You need Python 3.9 or above.
|
||||
Visit the [Python site](https://www.python.org/) for instructions on how to install Python
|
||||
for your platform.
|
||||
Throughout the documentation, when shell commands are shown, it is assumed that you can
|
||||
invoke Python as
|
||||
```
|
||||
$ python
|
||||
$ python3
|
||||
```
|
||||
If invoking python is different on your platform (it may be `python3` for example, or just `py` or `py.exe`),
|
||||
If invoking python is different on your platform (it may be `python` for example, or just `py` or `py.exe`),
|
||||
adjust accordingly.
|
||||
|
||||
You may be simply using Bumble as a module for your own application or as a dependency to your own
|
||||
@@ -32,12 +30,18 @@ manager, or from source.
|
||||
python environment, or in a virtual environment, such as a `venv`, `pyenv` or `conda` environment.
|
||||
See the [Python Environments page](development/python_environments.md) page for details.
|
||||
|
||||
### Install from PyPI
|
||||
|
||||
```
|
||||
$ python3 -m pip install bumble
|
||||
```
|
||||
|
||||
### Install From Source
|
||||
|
||||
Install with `pip`. Run in a command shell in the directory where you downloaded the source
|
||||
distribution
|
||||
```
|
||||
$ python -m pip install -e .
|
||||
$ python3 -m pip install -e .
|
||||
```
|
||||
|
||||
### Install from GitHub
|
||||
@@ -46,21 +50,21 @@ You can install directly from GitHub without first downloading the repo.
|
||||
|
||||
Install the latest commit from the main branch with `pip`:
|
||||
```
|
||||
$ python -m pip install git+https://github.com/google/bumble.git
|
||||
$ python3 -m pip install git+https://github.com/google/bumble.git
|
||||
```
|
||||
|
||||
You can specify a specific tag.
|
||||
|
||||
Install tag `v0.0.1` with `pip`:
|
||||
```
|
||||
$ python -m pip install git+https://github.com/google/bumble.git@v0.0.1
|
||||
$ python3 -m pip install git+https://github.com/google/bumble.git@v0.0.1
|
||||
```
|
||||
|
||||
You can also specify a specific commit.
|
||||
|
||||
Install commit `27c0551` with `pip`:
|
||||
```
|
||||
$ python -m pip install git+https://github.com/google/bumble.git@27c0551
|
||||
$ python3 -m pip install git+https://github.com/google/bumble.git@27c0551
|
||||
```
|
||||
|
||||
# Working On The Bumble Code
|
||||
@@ -80,21 +84,21 @@ directory of the project.
|
||||
|
||||
```bash
|
||||
$ export PYTHONPATH=.
|
||||
$ python apps/console.py serial:/dev/tty.usbmodem0006839912171
|
||||
$ python3 apps/console.py serial:/dev/tty.usbmodem0006839912171
|
||||
```
|
||||
|
||||
or running an example, with the working directory set to the `examples` subdirectory
|
||||
```bash
|
||||
$ cd examples
|
||||
$ export PYTHONPATH=..
|
||||
$ python run_scanner.py usb:0
|
||||
$ python3 run_scanner.py usb:0
|
||||
```
|
||||
|
||||
Or course, `export PYTHONPATH` only needs to be invoked once, not before each app/script execution.
|
||||
|
||||
Setting `PYTHONPATH` locally with each command would look something like:
|
||||
```
|
||||
$ PYTHONPATH=. python examples/run_advertiser.py examples/device1.json serial:/dev/tty.usbmodem0006839912171
|
||||
$ PYTHONPATH=. python3 examples/run_advertiser.py examples/device1.json serial:/dev/tty.usbmodem0006839912171
|
||||
```
|
||||
|
||||
# Where To Go Next
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ the command line.
|
||||
visit [this Android Studio user guide page](https://developer.android.com/studio/run/emulator-commandline)
|
||||
|
||||
The `-packet-streamer-endpoint <endpoint>` command line option may be used to enable
|
||||
Bluetooth emulation and tell the emulator which virtual controller to connect to.
|
||||
Bluetooth emulation and tell the emulator which virtual controller to connect to.
|
||||
|
||||
## Connecting to Netsim
|
||||
|
||||
If the emulator doesn't have Bluetooth emulation enabled by default, use the
|
||||
If the emulator doesn't have Bluetooth emulation enabled by default, use the
|
||||
`-packet-streamer-endpoint default` option to tell it to connect to Netsim.
|
||||
If Netsim is not running, the emulator will start it automatically.
|
||||
|
||||
@@ -60,17 +60,17 @@ the Bumble `android-netsim` transport in `host` mode (the default).
|
||||
|
||||
!!! example "Run the example GATT server connected to the emulator via Netsim"
|
||||
``` shell
|
||||
$ python run_gatt_server.py device1.json android-netsim
|
||||
$ python3 run_gatt_server.py device1.json android-netsim
|
||||
```
|
||||
|
||||
By default, the Bumble `android-netsim` transport will try to automatically discover
|
||||
the port number on which the netsim process is exposing its gRPC server interface. If
|
||||
that discovery process fails, or if you want to specify the interface manually, you
|
||||
that discovery process fails, or if you want to specify the interface manually, you
|
||||
can pass a `hostname` and `port` as parameters to the transport, as: `android-netsim:<host>:<port>`.
|
||||
|
||||
!!! example "Run the example GATT server connected to the emulator via Netsim on a localhost, port 8877"
|
||||
``` shell
|
||||
$ python run_gatt_server.py device1.json android-netsim:localhost:8877
|
||||
$ python3 run_gatt_server.py device1.json android-netsim:localhost:8877
|
||||
```
|
||||
|
||||
### Multiple Instances
|
||||
@@ -84,7 +84,7 @@ For example: `android-netsim:localhost:8877,name=bumble1`
|
||||
This is an advanced use case, which may not be officially supported, but should work in recent
|
||||
versions of the emulator.
|
||||
|
||||
The first step is to run the Bumble HCI bridge, specifying netsim as the "host" end of the
|
||||
The first step is to run the Bumble HCI bridge, specifying netsim as the "host" end of the
|
||||
bridge, and another controller (typically a USB Bluetooth dongle, but any other supported
|
||||
transport can work as well) as the "controller" end of the bridge.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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:
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ channels:
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- pip=23
|
||||
- python=3.8
|
||||
- python=3.9
|
||||
- pip:
|
||||
- --editable .[development,documentation,test]
|
||||
|
||||
9
examples/cs_initiator.json
Normal file
9
examples/cs_initiator.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Bumble CS Initiator",
|
||||
"address": "F0:F1:F2:F3:F4:F5",
|
||||
"advertising_interval": 100,
|
||||
"keystore": "JsonKeyStore",
|
||||
"irk": "865F81FF5A8B486EAAE29A27AD9F77DC",
|
||||
"identity_address_type": 1,
|
||||
"channel_sounding_enabled": true
|
||||
}
|
||||
9
examples/cs_reflector.json
Normal file
9
examples/cs_reflector.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Bumble CS Reflector",
|
||||
"address": "F0:F1:F2:F3:F4:F6",
|
||||
"advertising_interval": 100,
|
||||
"keystore": "JsonKeyStore",
|
||||
"irk": "0c7d74db03a1c98e7be691f76141d53d",
|
||||
"identity_address_type": 1,
|
||||
"channel_sounding_enabled": true
|
||||
}
|
||||
@@ -282,7 +282,7 @@ async def keyboard_device(device, command):
|
||||
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
'Bumble',
|
||||
bytes('Bumble', 'utf-8'),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@@ -28,7 +28,7 @@ class OneDeviceBenchTest(base_test.BaseTestClass):
|
||||
|
||||
def test_l2cap_client_ping(self):
|
||||
runner = self.dut.bench.runL2capClient(
|
||||
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100
|
||||
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100, "HIGH"
|
||||
)
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
@@ -36,12 +36,34 @@ class OneDeviceBenchTest(base_test.BaseTestClass):
|
||||
|
||||
def test_l2cap_client_send(self):
|
||||
runner = self.dut.bench.runL2capClient(
|
||||
"send", "7E:90:D0:F2:7A:11", 131, True, 100, 970, 0
|
||||
"send",
|
||||
"F1:F1:F1:F1:F1:F1",
|
||||
128,
|
||||
True,
|
||||
100,
|
||||
970,
|
||||
0,
|
||||
"HIGH",
|
||||
10000,
|
||||
)
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
print("### Final status:", final_status)
|
||||
|
||||
def test_gatt_client_send(self):
|
||||
runner = self.dut.bench.runGattClient(
|
||||
"send", "F1:F1:F1:F1:F1:F1", 128, True, 100, 970, 100, "HIGH"
|
||||
)
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
print("### Final status:", final_status)
|
||||
|
||||
def test_gatt_server_receive(self):
|
||||
runner = self.dut.bench.runGattServer("receive")
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
print("### Final status:", final_status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_runner.main()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user