Compare commits

...

36 Commits

Author SHA1 Message Date
Alan Rosenthal c2c46e9ace Create inv web.build
This command will build a wheel, copy it in the web directory, and create a file `packageFile` with the name of the wheel. If the correct override param is given, bumble.js will read `packageFile` and load that package.
2024-07-09 09:32:21 -04:00
Gilles Boccon-Gibod 32a41a815d Merge pull request #502 from google/gbg/extended-advertising-termination-reverse
support out of order advertising set termination / connection events
2024-06-18 16:42:06 -07:00
Gilles Boccon-Gibod df5fc2ddfe add test 2024-06-12 10:13:57 -07:00
Gilles Boccon-Gibod 79122313a6 Merge pull request #489 from google/gbg/basic-auracast-app
basic auracast app
2024-06-12 10:06:30 -07:00
Gilles Boccon-Gibod d7d03e2e92 Merge pull request #504 from google/gbg/bench-role-change
bench role change
2024-06-12 10:06:11 -07:00
Gilles Boccon-Gibod ea493480a9 remove duplicated lines 2024-06-11 13:23:35 -07:00
Gilles Boccon-Gibod 658f641a53 add manufacturer data 2024-06-11 13:21:04 -07:00
Gilles Boccon-Gibod 00edd1fbf8 post-rebase fixes 2024-06-10 10:30:59 -07:00
Gilles Boccon-Gibod 999d7b07e1 wip 2024-06-09 11:39:44 -07:00
Gilles Boccon-Gibod 2e3aeb8648 support out of order advertising set termination / connection events 2024-06-05 16:29:31 -07:00
Gilles Boccon-Gibod f910a696ad Merge pull request #499 from google/gbg/rfcomm-bridge
rfcomm bridge app
2024-06-05 11:18:13 -07:00
Gilles Boccon-Gibod e1d10bc482 add rfcomm disconnect test 2024-06-05 10:03:27 -07:00
Gilles Boccon-Gibod 181467f11b Merge pull request #500 from google/gbg/fix-advertising-auto-restart
fix legacy advertising auto restart
2024-06-04 06:39:54 -07:00
Gilles Boccon-Gibod 394137b6f7 fix legacy advertising auto restart 2024-06-03 19:08:46 -07:00
Gilles Boccon-Gibod dea907be86 attempt to fix pandora test (+3 squashed commits)
Squashed commits:
[759372d] address PR comments
[2f2a275] wip
[cc86b98] wip

wip

address PR comments

attempt to fix pandora test
2024-06-03 18:22:29 -07:00
Gilles Boccon-Gibod f5baf51132 improve DLC parameters 2024-06-03 18:11:13 -07:00
Gilles Boccon-Gibod f2dc8bd84e wip (+2 squashed commits)
Squashed commits:
[451a295] wip
[ed7b5b6] wip (+1 squashed commit)
Squashed commits:
[9d938c8] wip

wip

wip
2024-05-30 14:59:22 -07:00
zxzxwu 090309302f Merge pull request #372 from zxzxwu/source
ASCS Source Implementation
2024-05-29 13:17:51 +08:00
Charlie Boutier 28e6229b24 Fix: Preserve transport metadata
Preserve transport metadata when wrapping with SnoopingTransport
2024-05-28 09:20:53 -07:00
Josh Wu 1b66f03dbe ASCS: Add Source ASE operations 2024-05-27 14:48:23 +08:00
Gilles Boccon-Gibod e34f6b5fd3 Merge pull request #484 from google/gbg/quick-fix-002
fix incorrect var reference
2024-05-13 16:11:42 -07:00
Gilles Boccon-Gibod 8a0482c947 Merge pull request #485 from google/gbg/gh-action-py312
add python 3.12 to GH actions
2024-05-13 16:11:25 -07:00
zxzxwu 938a189f3f Merge pull request #478 from zxzxwu/config
Make DeviceConfiguration dataclass
2024-05-13 16:57:15 +08:00
Gilles Boccon-Gibod 2005b4a11b python 3.12 compatibility 2024-05-12 12:54:52 -07:00
Gilles Boccon-Gibod 951fdc8bdd add python 3.12 to GH actions 2024-05-12 12:07:05 -07:00
Gilles Boccon-Gibod 12af7a526c fix incorrect var reference 2024-05-12 11:59:05 -07:00
zxzxwu 8781943646 Merge pull request #483 from zxzxwu/rfc
RFCOMM: Handle packets received before DLC sink set
2024-05-10 16:34:57 +08:00
Gilles Boccon-Gibod 7fbfdb634c Merge pull request #481 from google/gbg/command-status-fix
allow checking results for HCI_Command_Status_Event
2024-05-09 19:50:10 -07:00
Josh Wu 9682077f6b RFCOMM: Avoid receive packets before DLC sink set 2024-05-09 17:57:13 +08:00
Gilles Boccon-Gibod 22eb405fde Merge pull request #482 from servusdei2018/main
bumble.js(PacketSink): Implement asynchronous packet processing
2024-05-08 20:16:04 -07:00
zxzxwu 593c61973f Merge pull request #480 from zxzxwu/hfp-ag
HFP: Add AG example and fix errors
2024-05-07 17:50:01 +08:00
Josh Wu ccff32102f HFP: Add example and fix AG errors 2024-05-07 00:36:52 +08:00
Nate 851d62c6c9 bumble.js(PacketSink): Implement asynchronous packet processing 2024-05-05 15:03:22 -04:00
Josh Wu a5ac5f26e2 Make DeviceConfiguration dataclass 2024-05-05 17:25:01 +08:00
zxzxwu 26e6650038 Merge pull request #477 from zxzxwu/hfp-ag
Fix HFP query call status
2024-05-02 01:17:17 +08:00
Josh Wu c48568aabe Fix HFP query call status 2024-04-30 03:13:38 +00:00
42 changed files with 4719 additions and 576 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
fail-fast: false fail-fast: false
steps: steps:
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
python-version: ["3.8", "3.9", "3.10", "3.11"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
fail-fast: false fail-fast: false
steps: steps:
@@ -46,7 +46,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11" ] python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
rust-version: [ "1.76.0", "stable" ] rust-version: [ "1.76.0", "stable" ]
fail-fast: false fail-fast: false
steps: steps:
+4
View File
@@ -1,6 +1,7 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Abortable", "Abortable",
"aiohttp",
"altsetting", "altsetting",
"ansiblue", "ansiblue",
"ansicyan", "ansicyan",
@@ -9,6 +10,7 @@
"ansired", "ansired",
"ansiyellow", "ansiyellow",
"appendleft", "appendleft",
"ascs",
"ASHA", "ASHA",
"asyncio", "asyncio",
"ATRAC", "ATRAC",
@@ -43,6 +45,7 @@
"keyup", "keyup",
"levelname", "levelname",
"libc", "libc",
"liblc",
"libusb", "libusb",
"MITM", "MITM",
"MSBC", "MSBC",
@@ -78,6 +81,7 @@
"unmuted", "unmuted",
"usbmodem", "usbmodem",
"vhci", "vhci",
"wasmtime",
"websockets", "websockets",
"xcursor", "xcursor",
"ycursor" "ycursor"
+407
View File
@@ -0,0 +1,407 @@
# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import dataclasses
import logging
import os
from typing import cast, Dict, Optional, Tuple
import click
import pyee
from bumble.colors import color
import bumble.company_ids
import bumble.core
import bumble.device
import bumble.gatt
import bumble.hci
import bumble.profiles.bap
import bumble.profiles.pbp
import bumble.transport
import bumble.utils
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
AURACAST_DEFAULT_DEVICE_NAME = "Bumble Auracast"
AURACAST_DEFAULT_DEVICE_ADDRESS = bumble.hci.Address("F0:F1:F2:F3:F4:F5")
# -----------------------------------------------------------------------------
# Discover Broadcasts
# -----------------------------------------------------------------------------
class BroadcastDiscoverer:
@dataclasses.dataclass
class Broadcast(pyee.EventEmitter):
name: str
sync: bumble.device.PeriodicAdvertisingSync
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
biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
manufacturer_data: Optional[Tuple[str, bytes]] = None
def __post_init__(self) -> None:
super().__init__()
self.sync.on('establishment', self.on_sync_establishment)
self.sync.on('loss', self.on_sync_loss)
self.sync.on('periodic_advertisement', self.on_periodic_advertisement)
self.sync.on('biginfo_advertisement', self.on_biginfo_advertisement)
self.establishment_timeout_task = asyncio.create_task(
self.wait_for_establishment()
)
async def wait_for_establishment(self) -> None:
await asyncio.sleep(5.0)
if self.sync.state == bumble.device.PeriodicAdvertisingSync.State.PENDING:
print(
color(
'!!! Periodic advertisement sync not established in time, '
'canceling',
'red',
)
)
await self.sync.terminate()
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
):
assert isinstance(service_data, tuple)
service_uuid, data = service_data
assert isinstance(data, bytes)
if (
service_uuid
== bumble.gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE
):
self.public_broadcast_announcement = (
bumble.profiles.pbp.PublicBroadcastAnnouncement.from_bytes(data)
)
continue
if (
service_uuid
== bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
):
self.broadcast_audio_announcement = (
bumble.profiles.bap.BroadcastAudioAnnouncement.from_bytes(data)
)
continue
self.appearance = advertisement.data.get( # type: ignore[assignment]
bumble.core.AdvertisingData.APPEARANCE
)
if manufacturer_data := advertisement.data.get(
bumble.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_id, f'0x{company_id:04X}'
),
data,
)
def print(self) -> None:
print(
color('Broadcast:', 'yellow'),
self.sync.advertiser_address,
color(self.sync.state.name, 'green'),
)
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}')
print(f' {color("SID", "cyan")}: {self.sync.sid}')
if self.manufacturer_data:
print(
f' {color("Manufacturer Data", "cyan")}: '
f'{self.manufacturer_data[0]} -> {self.manufacturer_data[1].hex()}'
)
if self.broadcast_audio_announcement:
print(
f' {color("Broadcast ID", "cyan")}: '
f'{self.broadcast_audio_announcement.broadcast_id}'
)
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}'
)
if self.basic_audio_announcement:
print(color(' Audio:', 'cyan'))
print(
color(' Presentation Delay:', 'magenta'),
self.basic_audio_announcement.presentation_delay,
)
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,
)
print(
color(' Company ID: ', 'green'),
subgroup.codec_id.company_id,
)
print(
color(' Vendor Specific Codec ID:', 'green'),
subgroup.codec_id.vendor_specific_codec_id,
)
print(
color(' Codec Config:', 'yellow'),
subgroup.codec_specific_configuration,
)
print(color(' Metadata: ', 'yellow'), subgroup.metadata)
for bis in subgroup.bis:
print(color(f' BIS [{bis.index}]:', 'yellow'))
print(
color(' Codec Config:', 'green'),
bis.codec_specific_configuration,
)
if self.biginfo:
print(color(' BIG:', 'cyan'))
print(
color(' Number of BIS:', 'magenta'),
self.biginfo.num_bis,
)
print(
color(' PHY: ', 'magenta'),
self.biginfo.phy.name,
)
print(
color(' Framed: ', 'magenta'),
self.biginfo.framed,
)
print(
color(' Encrypted: ', 'magenta'),
self.biginfo.encrypted,
)
def on_sync_establishment(self) -> None:
self.establishment_timeout_task.cancel()
self.emit('change')
def on_sync_loss(self) -> None:
self.basic_audio_announcement = None
self.biginfo = None
self.emit('change')
def on_periodic_advertisement(
self, advertisement: bumble.device.PeriodicAdvertisement
) -> None:
if advertisement.data is None:
return
for service_data in advertisement.data.get_all(
bumble.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:
self.basic_audio_announcement = (
bumble.profiles.bap.BasicAudioAnnouncement.from_bytes(data)
)
break
self.emit('change')
def on_biginfo_advertisement(
self, advertisement: bumble.device.BIGInfoAdvertisement
) -> None:
self.biginfo = advertisement
self.emit('change')
def __init__(
self,
device: bumble.device.Device,
filter_duplicates: bool,
sync_timeout: float,
):
self.device = device
self.filter_duplicates = filter_duplicates
self.sync_timeout = sync_timeout
self.broadcasts: Dict[bumble.hci.Address, BroadcastDiscoverer.Broadcast] = {}
self.status_message = ''
device.on('advertisement', self.on_advertisement)
async def run(self) -> None:
self.status_message = color('Scanning...', 'green')
await self.device.start_scanning(
active=False,
filter_duplicates=False,
)
def refresh(self) -> None:
# Clear the screen from the top
print('\033[H')
print('\033[0J')
print('\033[H')
# Print the status message
print(self.status_message)
print("==========================================")
# Print all broadcasts
for broadcast in self.broadcasts.values():
broadcast.print()
print('------------------------------------------')
# Clear the screen to the bottom
print('\033[0J')
def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
if (
broadcast_name := advertisement.data.get(
bumble.core.AdvertisingData.BROADCAST_NAME
)
) is None:
return
assert isinstance(broadcast_name, str)
if broadcast := self.broadcasts.get(advertisement.address):
broadcast.update(advertisement)
self.refresh()
return
bumble.utils.AsyncRunner.spawn(
self.on_new_broadcast(broadcast_name, advertisement)
)
async def on_new_broadcast(
self, name: str, advertisement: bumble.device.Advertisement
) -> None:
periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
advertiser_address=advertisement.address,
sid=advertisement.sid,
sync_timeout=self.sync_timeout,
filter_duplicates=self.filter_duplicates,
)
broadcast = self.Broadcast(
name,
periodic_advertising_sync,
)
broadcast.on('change', self.refresh)
broadcast.update(advertisement)
self.broadcasts[advertisement.address] = broadcast
periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
self.status_message = color(
f'+Found {len(self.broadcasts)} broadcasts', 'green'
)
self.refresh()
def on_broadcast_loss(self, broadcast: Broadcast) -> None:
del self.broadcasts[broadcast.sync.advertiser_address]
bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
self.status_message = color(
f'-Found {len(self.broadcasts)} broadcasts', 'green'
)
self.refresh()
async def run_discover_broadcasts(
filter_duplicates: bool, sync_timeout: float, transport: str
) -> None:
async with await bumble.transport.open_transport(transport) as (
hci_source,
hci_sink,
):
device = bumble.device.Device.with_hci(
AURACAST_DEFAULT_DEVICE_NAME,
AURACAST_DEFAULT_DEVICE_ADDRESS,
hci_source,
hci_sink,
)
await device.power_on()
discoverer = BroadcastDiscoverer(device, filter_duplicates, sync_timeout)
await discoverer.run()
await hci_source.terminated
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
@click.group()
@click.pass_context
def auracast(
ctx,
):
ctx.ensure_object(dict)
@auracast.command('discover-broadcasts')
@click.option(
'--filter-duplicates', is_flag=True, default=False, help='Filter duplicates'
)
@click.option(
'--sync-timeout',
metavar='SYNC_TIMEOUT',
type=float,
default=5.0,
help='Sync timeout (in seconds)',
)
@click.argument('transport')
@click.pass_context
def discover_broadcasts(ctx, filter_duplicates, sync_timeout, transport):
"""Discover public broadcasts"""
asyncio.run(run_discover_broadcasts(filter_duplicates, sync_timeout, transport))
def main():
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
auracast()
# -----------------------------------------------------------------------------
if __name__ == "__main__":
main() # pylint: disable=no-value-for-parameter
+146 -35
View File
@@ -40,6 +40,8 @@ from bumble.hci import (
HCI_LE_1M_PHY, HCI_LE_1M_PHY,
HCI_LE_2M_PHY, HCI_LE_2M_PHY,
HCI_LE_CODED_PHY, HCI_LE_CODED_PHY,
HCI_CENTRAL_ROLE,
HCI_PERIPHERAL_ROLE,
HCI_Constant, HCI_Constant,
HCI_Error, HCI_Error,
HCI_StatusError, HCI_StatusError,
@@ -57,6 +59,7 @@ from bumble.transport import open_transport_or_link
import bumble.rfcomm import bumble.rfcomm
import bumble.core import bumble.core
from bumble.utils import AsyncRunner from bumble.utils import AsyncRunner
from bumble.pairing import PairingConfig
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -128,40 +131,34 @@ def le_phy_name(phy_id):
def print_connection(connection): def print_connection(connection):
params = []
if connection.transport == BT_LE_TRANSPORT: if connection.transport == BT_LE_TRANSPORT:
phy_state = ( params.append(
'PHY=' 'PHY='
f'TX:{le_phy_name(connection.phy.tx_phy)}/' f'TX:{le_phy_name(connection.phy.tx_phy)}/'
f'RX:{le_phy_name(connection.phy.rx_phy)}' f'RX:{le_phy_name(connection.phy.rx_phy)}'
) )
data_length = ( params.append(
'DL=(' 'DL=('
f'TX:{connection.data_length[0]}/{connection.data_length[1]},' f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
f'RX:{connection.data_length[2]}/{connection.data_length[3]}' f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
')' ')'
) )
connection_parameters = (
params.append(
'Parameters=' 'Parameters='
f'{connection.parameters.connection_interval * 1.25:.2f}/' f'{connection.parameters.connection_interval * 1.25:.2f}/'
f'{connection.parameters.peripheral_latency}/' f'{connection.parameters.peripheral_latency}/'
f'{connection.parameters.supervision_timeout * 10} ' f'{connection.parameters.supervision_timeout * 10} '
) )
params.append(f'MTU={connection.att_mtu}')
else: else:
phy_state = '' params.append(f'Role={HCI_Constant.role_name(connection.role)}')
data_length = ''
connection_parameters = ''
mtu = connection.att_mtu logging.info(color('@@@ Connection: ', 'yellow') + ' '.join(params))
logging.info(
f'{color("@@@ Connection:", "yellow")} '
f'{connection_parameters} '
f'{data_length} '
f'{phy_state} '
f'MTU={mtu}'
)
def make_sdp_records(channel): def make_sdp_records(channel):
@@ -214,6 +211,17 @@ def log_stats(title, stats):
) )
async def switch_roles(connection, role):
target_role = HCI_CENTRAL_ROLE if role == "central" else HCI_PERIPHERAL_ROLE
if connection.role != target_role:
logging.info(f'{color("### Switching roles to:", "cyan")} {role}')
try:
await connection.switch_role(target_role)
logging.info(color('### Role switch complete', 'cyan'))
except HCI_Error as error:
logging.info(f'{color("### Role switch failed:", "red")} {error}')
class PacketType(enum.IntEnum): class PacketType(enum.IntEnum):
RESET = 0 RESET = 0
SEQUENCE = 1 SEQUENCE = 1
@@ -899,14 +907,26 @@ class L2capServer(StreamedPacketIO):
# RfcommClient # RfcommClient
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class RfcommClient(StreamedPacketIO): class RfcommClient(StreamedPacketIO):
def __init__(self, device, channel, uuid, l2cap_mtu, max_frame_size, window_size): def __init__(
self,
device,
channel,
uuid,
l2cap_mtu,
max_frame_size,
initial_credits,
max_credits,
credits_threshold,
):
super().__init__() super().__init__()
self.device = device self.device = device
self.channel = channel self.channel = channel
self.uuid = uuid self.uuid = uuid
self.l2cap_mtu = l2cap_mtu self.l2cap_mtu = l2cap_mtu
self.max_frame_size = max_frame_size self.max_frame_size = max_frame_size
self.window_size = window_size self.initial_credits = initial_credits
self.max_credits = max_credits
self.credits_threshold = credits_threshold
self.rfcomm_session = None self.rfcomm_session = None
self.ready = asyncio.Event() self.ready = asyncio.Event()
@@ -940,12 +960,17 @@ class RfcommClient(StreamedPacketIO):
logging.info(color(f'### Opening session for channel {channel}...', 'yellow')) logging.info(color(f'### Opening session for channel {channel}...', 'yellow'))
try: try:
dlc_options = {} dlc_options = {}
if self.max_frame_size: if self.max_frame_size is not None:
dlc_options['max_frame_size'] = self.max_frame_size dlc_options['max_frame_size'] = self.max_frame_size
if self.window_size: if self.initial_credits is not None:
dlc_options['window_size'] = self.window_size dlc_options['initial_credits'] = self.initial_credits
rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options) rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options)
logging.info(color(f'### Session open: {rfcomm_session}', 'yellow')) logging.info(color(f'### Session open: {rfcomm_session}', 'yellow'))
if self.max_credits is not None:
rfcomm_session.rx_max_credits = self.max_credits
if self.credits_threshold is not None:
rfcomm_session.rx_credits_threshold = self.credits_threshold
except bumble.core.ConnectionError as error: except bumble.core.ConnectionError as error:
logging.info(color(f'!!! Session open failed: {error}', 'red')) logging.info(color(f'!!! Session open failed: {error}', 'red'))
await rfcomm_mux.disconnect() await rfcomm_mux.disconnect()
@@ -969,8 +994,19 @@ class RfcommClient(StreamedPacketIO):
# RfcommServer # RfcommServer
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class RfcommServer(StreamedPacketIO): class RfcommServer(StreamedPacketIO):
def __init__(self, device, channel, l2cap_mtu): def __init__(
self,
device,
channel,
l2cap_mtu,
max_frame_size,
initial_credits,
max_credits,
credits_threshold,
):
super().__init__() super().__init__()
self.max_credits = max_credits
self.credits_threshold = credits_threshold
self.dlc = None self.dlc = None
self.ready = asyncio.Event() self.ready = asyncio.Event()
@@ -981,7 +1017,12 @@ class RfcommServer(StreamedPacketIO):
rfcomm_server = bumble.rfcomm.Server(device, **server_options) rfcomm_server = bumble.rfcomm.Server(device, **server_options)
# Listen for incoming DLC connections # Listen for incoming DLC connections
channel_number = rfcomm_server.listen(self.on_dlc, channel) dlc_options = {}
if max_frame_size is not None:
dlc_options['max_frame_size'] = max_frame_size
if initial_credits is not None:
dlc_options['initial_credits'] = initial_credits
channel_number = rfcomm_server.listen(self.on_dlc, channel, **dlc_options)
# Setup the SDP to advertise this channel # Setup the SDP to advertise this channel
device.sdp_service_records = make_sdp_records(channel_number) device.sdp_service_records = make_sdp_records(channel_number)
@@ -1001,9 +1042,17 @@ class RfcommServer(StreamedPacketIO):
def on_dlc(self, dlc): def on_dlc(self, dlc):
logging.info(color(f'*** DLC connected: {dlc}', 'blue')) logging.info(color(f'*** DLC connected: {dlc}', 'blue'))
if self.credits_threshold is not None:
dlc.rx_threshold = self.credits_threshold
if self.max_credits is not None:
dlc.rx_max_credits = self.max_credits
dlc.sink = self.on_packet dlc.sink = self.on_packet
self.io_sink = dlc.write self.io_sink = dlc.write
self.dlc = dlc self.dlc = dlc
if self.max_credits is not None:
dlc.rx_max_credits = self.max_credits
if self.credits_threshold is not None:
dlc.rx_credits_threshold = self.credits_threshold
async def drain(self): async def drain(self):
assert self.dlc assert self.dlc
@@ -1026,6 +1075,7 @@ class Central(Connection.Listener):
authenticate, authenticate,
encrypt, encrypt,
extended_data_length, extended_data_length,
role_switch,
): ):
super().__init__() super().__init__()
self.transport = transport self.transport = transport
@@ -1036,6 +1086,7 @@ class Central(Connection.Listener):
self.authenticate = authenticate self.authenticate = authenticate
self.encrypt = encrypt or authenticate self.encrypt = encrypt or authenticate
self.extended_data_length = extended_data_length self.extended_data_length = extended_data_length
self.role_switch = role_switch
self.device = None self.device = None
self.connection = None self.connection = None
@@ -1086,6 +1137,11 @@ class Central(Connection.Listener):
role = self.role_factory(mode) role = self.role_factory(mode)
self.device.classic_enabled = self.classic self.device.classic_enabled = self.classic
# Set up a pairing config factory with minimal requirements.
self.device.pairing_config_factory = lambda _: PairingConfig(
sc=False, mitm=False, bonding=False
)
await self.device.power_on() await self.device.power_on()
if self.classic: if self.classic:
@@ -1114,6 +1170,10 @@ class Central(Connection.Listener):
self.connection.listener = self self.connection.listener = self
print_connection(self.connection) print_connection(self.connection)
# Switch roles if needed.
if self.role_switch:
await switch_roles(self.connection, self.role_switch)
# Wait a bit after the connection, some controllers aren't very good when # Wait a bit after the connection, some controllers aren't very good when
# we start sending data right away while some connection parameters are # we start sending data right away while some connection parameters are
# updated post connection # updated post connection
@@ -1175,20 +1235,30 @@ class Central(Connection.Listener):
def on_connection_data_length_change(self): def on_connection_data_length_change(self):
print_connection(self.connection) print_connection(self.connection)
def on_role_change(self):
print_connection(self.connection)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Peripheral # Peripheral
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Peripheral(Device.Listener, Connection.Listener): class Peripheral(Device.Listener, Connection.Listener):
def __init__( def __init__(
self, transport, classic, extended_data_length, role_factory, mode_factory self,
transport,
role_factory,
mode_factory,
classic,
extended_data_length,
role_switch,
): ):
self.transport = transport self.transport = transport
self.classic = classic self.classic = classic
self.extended_data_length = extended_data_length
self.role_factory = role_factory self.role_factory = role_factory
self.role = None
self.mode_factory = mode_factory self.mode_factory = mode_factory
self.extended_data_length = extended_data_length
self.role_switch = role_switch
self.role = None
self.mode = None self.mode = None
self.device = None self.device = None
self.connection = None self.connection = None
@@ -1211,6 +1281,11 @@ class Peripheral(Device.Listener, Connection.Listener):
self.role = self.role_factory(self.mode) self.role = self.role_factory(self.mode)
self.device.classic_enabled = self.classic self.device.classic_enabled = self.classic
# Set up a pairing config factory with minimal requirements.
self.device.pairing_config_factory = lambda _: PairingConfig(
sc=False, mitm=False, bonding=False
)
await self.device.power_on() await self.device.power_on()
if self.classic: if self.classic:
@@ -1237,6 +1312,7 @@ class Peripheral(Device.Listener, Connection.Listener):
await self.connected.wait() await self.connected.wait()
logging.info(color('### Connected', 'cyan')) logging.info(color('### Connected', 'cyan'))
print_connection(self.connection)
await self.mode.on_connection(self.connection) await self.mode.on_connection(self.connection)
await self.role.run() await self.role.run()
@@ -1253,7 +1329,7 @@ class Peripheral(Device.Listener, Connection.Listener):
AsyncRunner.spawn(self.device.set_connectable(False)) AsyncRunner.spawn(self.device.set_connectable(False))
# Request a new data length if needed # Request a new data length if needed
if self.extended_data_length: if not self.classic and self.extended_data_length:
logging.info("+++ Requesting extended data length") logging.info("+++ Requesting extended data length")
AsyncRunner.spawn( AsyncRunner.spawn(
connection.set_data_length( connection.set_data_length(
@@ -1261,6 +1337,10 @@ class Peripheral(Device.Listener, Connection.Listener):
) )
) )
# Switch roles if needed.
if self.role_switch:
AsyncRunner.spawn(switch_roles(connection, self.role_switch))
def on_disconnection(self, reason): def on_disconnection(self, reason):
logging.info(color(f'!!! Disconnection: reason={reason}', 'red')) logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
self.connection = None self.connection = None
@@ -1282,6 +1362,9 @@ class Peripheral(Device.Listener, Connection.Listener):
def on_connection_data_length_change(self): def on_connection_data_length_change(self):
print_connection(self.connection) print_connection(self.connection)
def on_role_change(self):
print_connection(self.connection)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def create_mode_factory(ctx, default_mode): def create_mode_factory(ctx, default_mode):
@@ -1321,7 +1404,9 @@ def create_mode_factory(ctx, default_mode):
uuid=ctx.obj['rfcomm_uuid'], uuid=ctx.obj['rfcomm_uuid'],
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'], l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
max_frame_size=ctx.obj['rfcomm_max_frame_size'], max_frame_size=ctx.obj['rfcomm_max_frame_size'],
window_size=ctx.obj['rfcomm_window_size'], initial_credits=ctx.obj['rfcomm_initial_credits'],
max_credits=ctx.obj['rfcomm_max_credits'],
credits_threshold=ctx.obj['rfcomm_credits_threshold'],
) )
if mode == 'rfcomm-server': if mode == 'rfcomm-server':
@@ -1329,6 +1414,10 @@ def create_mode_factory(ctx, default_mode):
device, device,
channel=ctx.obj['rfcomm_channel'], channel=ctx.obj['rfcomm_channel'],
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'], l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
max_frame_size=ctx.obj['rfcomm_max_frame_size'],
initial_credits=ctx.obj['rfcomm_initial_credits'],
max_credits=ctx.obj['rfcomm_max_credits'],
credits_threshold=ctx.obj['rfcomm_credits_threshold'],
) )
raise ValueError('invalid mode') raise ValueError('invalid mode')
@@ -1405,6 +1494,11 @@ def create_role_factory(ctx, default_role):
'--extended-data-length', '--extended-data-length',
help='Request a data length upon connection, specified as tx_octets/tx_time', help='Request a data length upon connection, specified as tx_octets/tx_time',
) )
@click.option(
'--role-switch',
type=click.Choice(['central', 'peripheral']),
help='Request role switch upon connection (central or peripheral)',
)
@click.option( @click.option(
'--rfcomm-channel', '--rfcomm-channel',
type=int, type=int,
@@ -1427,9 +1521,19 @@ def create_role_factory(ctx, default_role):
help='RFComm maximum frame size', help='RFComm maximum frame size',
) )
@click.option( @click.option(
'--rfcomm-window-size', '--rfcomm-initial-credits',
type=int, type=int,
help='RFComm window size', help='RFComm initial credits',
)
@click.option(
'--rfcomm-max-credits',
type=int,
help='RFComm max credits',
)
@click.option(
'--rfcomm-credits-threshold',
type=int,
help='RFComm credits threshold',
) )
@click.option( @click.option(
'--l2cap-psm', '--l2cap-psm',
@@ -1459,7 +1563,7 @@ def create_role_factory(ctx, default_role):
'--packet-size', '--packet-size',
'-s', '-s',
metavar='SIZE', metavar='SIZE',
type=click.IntRange(8, 4096), type=click.IntRange(8, 8192),
default=500, default=500,
help='Packet size (client or ping role)', help='Packet size (client or ping role)',
) )
@@ -1519,6 +1623,7 @@ def bench(
mode, mode,
att_mtu, att_mtu,
extended_data_length, extended_data_length,
role_switch,
packet_size, packet_size,
packet_count, packet_count,
start_delay, start_delay,
@@ -1530,7 +1635,9 @@ def bench(
rfcomm_uuid, rfcomm_uuid,
rfcomm_l2cap_mtu, rfcomm_l2cap_mtu,
rfcomm_max_frame_size, rfcomm_max_frame_size,
rfcomm_window_size, rfcomm_initial_credits,
rfcomm_max_credits,
rfcomm_credits_threshold,
l2cap_psm, l2cap_psm,
l2cap_mtu, l2cap_mtu,
l2cap_mps, l2cap_mps,
@@ -1545,7 +1652,9 @@ def bench(
ctx.obj['rfcomm_uuid'] = rfcomm_uuid ctx.obj['rfcomm_uuid'] = rfcomm_uuid
ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu
ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size
ctx.obj['rfcomm_window_size'] = rfcomm_window_size ctx.obj['rfcomm_initial_credits'] = rfcomm_initial_credits
ctx.obj['rfcomm_max_credits'] = rfcomm_max_credits
ctx.obj['rfcomm_credits_threshold'] = rfcomm_credits_threshold
ctx.obj['l2cap_psm'] = l2cap_psm ctx.obj['l2cap_psm'] = l2cap_psm
ctx.obj['l2cap_mtu'] = l2cap_mtu ctx.obj['l2cap_mtu'] = l2cap_mtu
ctx.obj['l2cap_mps'] = l2cap_mps ctx.obj['l2cap_mps'] = l2cap_mps
@@ -1557,12 +1666,12 @@ def bench(
ctx.obj['repeat_delay'] = repeat_delay ctx.obj['repeat_delay'] = repeat_delay
ctx.obj['pace'] = pace ctx.obj['pace'] = pace
ctx.obj['linger'] = linger ctx.obj['linger'] = linger
ctx.obj['extended_data_length'] = ( ctx.obj['extended_data_length'] = (
[int(x) for x in extended_data_length.split('/')] [int(x) for x in extended_data_length.split('/')]
if extended_data_length if extended_data_length
else None else None
) )
ctx.obj['role_switch'] = role_switch
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server') ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
@@ -1606,6 +1715,7 @@ def central(
authenticate, authenticate,
encrypt or authenticate, encrypt or authenticate,
ctx.obj['extended_data_length'], ctx.obj['extended_data_length'],
ctx.obj['role_switch'],
).run() ).run()
asyncio.run(run_central()) asyncio.run(run_central())
@@ -1622,10 +1732,11 @@ def peripheral(ctx, transport):
async def run_peripheral(): async def run_peripheral():
await Peripheral( await Peripheral(
transport, transport,
ctx.obj['classic'],
ctx.obj['extended_data_length'],
role_factory, role_factory,
mode_factory, mode_factory,
ctx.obj['classic'],
ctx.obj['extended_data_length'],
ctx.obj['role_switch'],
).run() ).run()
asyncio.run(run_peripheral()) asyncio.run(run_peripheral())
+3 -3
View File
@@ -27,7 +27,7 @@ from bumble.colors import color
from bumble.core import name_or_number from bumble.core import name_or_number
from bumble.hci import ( from bumble.hci import (
map_null_terminated_utf8_string, map_null_terminated_utf8_string,
LeFeatureMask, LeFeature,
HCI_SUCCESS, HCI_SUCCESS,
HCI_VERSION_NAMES, HCI_VERSION_NAMES,
LMP_VERSION_NAMES, LMP_VERSION_NAMES,
@@ -140,7 +140,7 @@ async def get_le_info(host: Host) -> None:
print(color('LE Features:', 'yellow')) print(color('LE Features:', 'yellow'))
for feature in host.supported_le_features: for feature in host.supported_le_features:
print(LeFeatureMask(feature).name) print(f' {LeFeature(feature).name}')
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -224,7 +224,7 @@ async def async_main(latency_probes, transport):
print() print()
print(color('Supported Commands:', 'yellow')) print(color('Supported Commands:', 'yellow'))
for command in host.supported_commands: for command in host.supported_commands:
print(' ', HCI_Command.command_name(command)) print(f' {HCI_Command.command_name(command)}')
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+577
View File
@@ -0,0 +1,577 @@
# Copyright 2021-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 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 ctypes
import wasmtime
import wasmtime.loader
import liblc3 # type: ignore
import logging
import click
import aiohttp.web
import bumble
from bumble.core import AdvertisingData
from bumble.colors import color
from bumble.device import Device, DeviceConfiguration, AdvertisingParameters
from bumble.transport import open_transport
from bumble.profiles import bap
from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
DEFAULT_UI_PORT = 7654
def _sink_pac_record() -> bap.PacRecord:
return bap.PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=bap.CodecSpecificCapabilities(
supported_sampling_frequencies=(
bap.SupportedSamplingFrequency.FREQ_8000
| bap.SupportedSamplingFrequency.FREQ_16000
| bap.SupportedSamplingFrequency.FREQ_24000
| bap.SupportedSamplingFrequency.FREQ_32000
| bap.SupportedSamplingFrequency.FREQ_48000
),
supported_frame_durations=(
bap.SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_count=[1, 2],
min_octets_per_codec_frame=26,
max_octets_per_codec_frame=240,
supported_max_codec_frames_per_sdu=2,
),
)
def _source_pac_record() -> bap.PacRecord:
return bap.PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=bap.CodecSpecificCapabilities(
supported_sampling_frequencies=(
bap.SupportedSamplingFrequency.FREQ_8000
| bap.SupportedSamplingFrequency.FREQ_16000
| bap.SupportedSamplingFrequency.FREQ_24000
| bap.SupportedSamplingFrequency.FREQ_32000
| bap.SupportedSamplingFrequency.FREQ_48000
),
supported_frame_durations=(
bap.SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_count=[1],
min_octets_per_codec_frame=30,
max_octets_per_codec_frame=100,
supported_max_codec_frames_per_sdu=1,
),
)
# -----------------------------------------------------------------------------
# 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]
)
async def lc3_source_task(
filename: str,
sdu_length: int,
frame_duration_us: int,
device: Device,
cis_handle: int,
) -> None:
with open(filename, 'rb') as f:
header = f.read(44)
assert header[8:12] == b'WAVE'
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
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)
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())
# -----------------------------------------------------------------------------
class UiServer:
speaker: weakref.ReferenceType[Speaker]
port: int
def __init__(self, speaker: Speaker, port: int) -> None:
self.speaker = weakref.ref(speaker)
self.port = port
self.channel_socket = None
async def start_http(self) -> None:
"""Start the UI HTTP server."""
app = aiohttp.web.Application()
app.add_routes(
[
aiohttp.web.get('/', self.get_static),
aiohttp.web.get('/index.html', self.get_static),
aiohttp.web.get('/channel', self.get_channel),
]
)
runner = aiohttp.web.AppRunner(app)
await runner.setup()
site = aiohttp.web.TCPSite(runner, 'localhost', self.port)
print('UI HTTP server at ' + color(f'http://127.0.0.1:{self.port}', 'green'))
await site.start()
async def get_static(self, request):
path = request.path
if path == '/':
path = '/index.html'
if path.endswith('.html'):
content_type = 'text/html'
elif path.endswith('.js'):
content_type = 'text/javascript'
elif path.endswith('.css'):
content_type = 'text/css'
elif path.endswith('.svg'):
content_type = 'image/svg+xml'
else:
content_type = 'text/plain'
text = (
resources.files("bumble.apps.lea_unicast")
.joinpath(pathlib.Path(path).relative_to('/'))
.read_text(encoding="utf-8")
)
return aiohttp.web.Response(text=text, content_type=content_type)
async def get_channel(self, request):
ws = aiohttp.web.WebSocketResponse()
await ws.prepare(request)
# Process messages until the socket is closed.
self.channel_socket = ws
async for message in ws:
if message.type == aiohttp.WSMsgType.TEXT:
logger.debug(f'<<< received message: {message.data}')
await self.on_message(message.data)
elif message.type == aiohttp.WSMsgType.ERROR:
logger.debug(
f'channel connection closed with exception {ws.exception()}'
)
self.channel_socket = None
logger.debug('--- channel connection closed')
return ws
async def on_message(self, message_str: str):
# Parse the message as JSON
message = json.loads(message_str)
# Dispatch the message
message_type = message['type']
message_params = message.get('params', {})
handler = getattr(self, f'on_{message_type}_message')
if handler:
await handler(**message_params)
async def on_hello_message(self):
await self.send_message(
'hello',
bumble_version=bumble.__version__,
codec=self.speaker().codec,
streamState=self.speaker().stream_state.name,
)
if connection := self.speaker().connection:
await self.send_message(
'connection',
peer_address=connection.peer_address.to_string(False),
peer_name=connection.peer_name,
)
async def send_message(self, message_type: str, **kwargs) -> None:
if self.channel_socket is None:
return
message = {'type': message_type, 'params': kwargs}
await self.channel_socket.send_json(message)
async def send_audio(self, data: bytes) -> None:
if self.channel_socket is None:
return
try:
await self.channel_socket.send_bytes(data)
except Exception as error:
logger.warning(f'exception while sending audio packet: {error}')
# -----------------------------------------------------------------------------
class Speaker:
def __init__(
self,
device_config_path: Optional[str],
ui_port: int,
transport: str,
lc3_input_file_path: str,
):
self.device_config_path = device_config_path
self.transport = transport
self.lc3_input_file_path = lc3_input_file_path
# Create an HTTP server for the UI
self.ui_server = UiServer(speaker=self, port=ui_port)
async def run(self) -> None:
await self.ui_server.start_http()
async with await open_transport(self.transport) as hci_transport:
# Create a device
if self.device_config_path:
device_config = DeviceConfiguration.from_file(self.device_config_path)
else:
device_config = DeviceConfiguration(
name="Bumble LE Headphone",
class_of_device=0x244418,
keystore="JsonKeyStore",
advertising_interval_min=25,
advertising_interval_max=25,
address=Address('F1:F2:F3:F4:F5:F6'),
)
device_config.le_enabled = True
device_config.cis_enabled = True
self.device = Device.from_config_with_hci(
device_config, hci_transport.source, hci_transport.sink
)
self.device.add_service(
bap.PublishedAudioCapabilitiesService(
supported_source_context=bap.ContextType(0xFFFF),
available_source_context=bap.ContextType(0xFFFF),
supported_sink_context=bap.ContextType(0xFFFF), # All context types
available_sink_context=bap.ContextType(0xFFFF), # All context types
sink_audio_locations=(
bap.AudioLocation.FRONT_LEFT | bap.AudioLocation.FRONT_RIGHT
),
sink_pac=[_sink_pac_record()],
source_audio_locations=bap.AudioLocation.FRONT_LEFT,
source_pac=[_source_pac_record()],
)
)
ascs = bap.AudioStreamControlService(
self.device, sink_ase_id=[1], source_ase_id=[2]
)
self.device.add_service(ascs)
advertising_data = bytes(
AdvertisingData(
[
(
AdvertisingData.COMPLETE_LOCAL_NAME,
bytes(device_config.name, 'utf-8'),
),
(
AdvertisingData.FLAGS,
bytes([AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG]),
),
(
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
bytes(bap.PublishedAudioCapabilitiesService.UUID),
),
]
)
) + bytes(bap.UnicastServerAdvertisingData())
def on_pdu(pdu: HCI_IsoDataPacket, ase: bap.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,
)
self.device.abort_on('disconnection', self.ui_server.send_audio(pcm))
def on_ase_state_change(ase: bap.AseStateMachine) -> None:
if ase.state == bap.AseStateMachine.State.STREAMING:
codec_config = ase.codec_specific_configuration
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
assert ase.cis_link
if ase.role == bap.AudioRole.SOURCE:
ase.cis_link.abort_on(
'disconnection',
lc3_source_task(
filename=self.lc3_input_file_path,
sdu_length=(
codec_config.codec_frames_per_sdu
* codec_config.octets_per_codec_frame
),
frame_duration_us=codec_config.frame_duration.us,
device=self.device,
cis_handle=ase.cis_link.handle,
),
)
else:
ase.cis_link.sink = functools.partial(on_pdu, ase=ase)
elif ase.state == bap.AseStateMachine.State.CODEC_CONFIGURED:
codec_config = ase.codec_specific_configuration
assert isinstance(codec_config, bap.CodecSpecificConfiguration)
if ase.role == bap.AudioRole.SOURCE:
setup_encoders(
codec_config.sampling_frequency.hz,
codec_config.frame_duration.us,
codec_config.audio_channel_allocation.channel_count,
)
else:
setup_decoders(
codec_config.sampling_frequency.hz,
codec_config.frame_duration.us,
codec_config.audio_channel_allocation.channel_count,
)
for ase in ascs.ase_state_machines.values():
ase.on('state_change', functools.partial(on_ase_state_change, ase=ase))
await self.device.power_on()
await self.device.create_advertising_set(
advertising_data=advertising_data,
auto_restart=True,
advertising_parameters=AdvertisingParameters(
primary_advertising_interval_min=100,
primary_advertising_interval_max=100,
),
)
await hci_transport.source.terminated
@click.command()
@click.option(
'--ui-port',
'ui_port',
metavar='HTTP_PORT',
default=DEFAULT_UI_PORT,
show_default=True,
help='HTTP port for the UI server',
)
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
@click.argument('transport')
@click.argument('lc3_file')
def speaker(ui_port: int, device_config: str, transport: str, lc3_file: str) -> None:
"""Run the speaker."""
asyncio.run(Speaker(device_config, ui_port, transport, lc3_file).run())
# -----------------------------------------------------------------------------
def main():
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
speaker()
# -----------------------------------------------------------------------------
if __name__ == "__main__":
main() # pylint: disable=no-value-for-parameter
+68
View File
@@ -0,0 +1,68 @@
<html data-bs-theme="dark">
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://unpkg.com/pcm-player"></script>
</head>
<body>
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<span class="navbar-brand mb-0 h1">Bumble Unicast Server</span>
</div>
</nav>
<br>
<div class="container">
<button type="button" class="btn btn-danger" id="connect-audio" onclick="connectAudio()">Connect Audio</button>
<button class="btn btn-primary" type="button" disabled>
<span class="spinner-border spinner-border-sm" id="ws-status-spinner" aria-hidden="true"></span>
<span role="status" id="ws-status">WebSocket Connecting...</span>
</button>
</div>
<script>
let player = null;
const wsStatus = document.getElementById("ws-status");
const wsStatusSpinner = document.getElementById("ws-status-spinner");
const socket = new WebSocket('ws://127.0.0.1:7654/channel');
socket.binaryType = "arraybuffer";
socket.onmessage = function (message) {
if (typeof message.data === 'string' || message.data instanceof String) {
console.log(`channel MESSAGE: ${message.data}`);
} else {
console.log(typeof (message.data))
// BINARY audio data.
if (player == null) return;
player.feed(message.data);
}
};
socket.onopen = (message) => {
wsStatusSpinner.remove();
wsStatus.textContent = "WebSocket Connected";
}
socket.onclose = (message) => {
wsStatus.textContent = "WebSocket Disconnected";
}
function connectAudio() {
player = new PCMPlayer({
inputCodec: 'Int16',
channels: 2,
sampleRate: 48000,
flushTime: 10,
});
const button = document.getElementById("connect-audio")
button.disabled = true;
button.textContent = "Audio Connected";
}
</script>
</div>
</body>
</html>
BIN
View File
Binary file not shown.
+511
View File
@@ -0,0 +1,511 @@
# 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 asyncio
import logging
import os
import time
from typing import Optional
import click
from bumble.colors import color
from bumble.device import Device, DeviceConfiguration, Connection
from bumble import core
from bumble import hci
from bumble import rfcomm
from bumble import transport
from bumble import utils
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
DEFAULT_RFCOMM_UUID = "E6D55659-C8B4-4B85-96BB-B1143AF6D3AE"
DEFAULT_MTU = 4096
DEFAULT_CLIENT_TCP_PORT = 9544
DEFAULT_SERVER_TCP_PORT = 9545
TRACE_MAX_SIZE = 48
# -----------------------------------------------------------------------------
class Tracer:
"""
Trace data buffers transmitted from one endpoint to another, with stats.
"""
def __init__(self, channel_name: str) -> None:
self.channel_name = channel_name
self.last_ts: float = 0.0
def trace_data(self, data: bytes) -> None:
now = time.time()
elapsed_s = now - self.last_ts if self.last_ts else 0
elapsed_ms = int(elapsed_s * 1000)
instant_throughput_kbps = ((len(data) / elapsed_s) / 1000) if elapsed_s else 0.0
hex_str = data[:TRACE_MAX_SIZE].hex() + (
"..." if len(data) > TRACE_MAX_SIZE else ""
)
print(
f"[{self.channel_name}] {len(data):4} bytes "
f"(+{elapsed_ms:4}ms, {instant_throughput_kbps: 7.2f}kB/s) "
f" {hex_str}"
)
self.last_ts = now
# -----------------------------------------------------------------------------
class ServerBridge:
"""
RFCOMM server bridge: waits for a peer to connect an RFCOMM channel.
The RFCOMM channel may be associated with a UUID published in an SDP service
description, or simply be on a system-assigned channel number.
When the connection is made, the bridge connects a TCP socket to a remote host and
bridges the data in both directions, with flow control.
When the RFCOMM channel is closed, the bridge disconnects the TCP socket
and waits for a new channel to be connected.
"""
READ_CHUNK_SIZE = 4096
def __init__(
self, channel: int, uuid: str, trace: bool, tcp_host: str, tcp_port: int
) -> None:
self.device: Optional[Device] = None
self.channel = channel
self.uuid = uuid
self.tcp_host = tcp_host
self.tcp_port = tcp_port
self.rfcomm_channel: Optional[rfcomm.DLC] = None
self.tcp_tracer: Optional[Tracer]
self.rfcomm_tracer: Optional[Tracer]
if trace:
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
self.rfcomm_tracer = Tracer(color("TCP->RFCOMM", "magenta"))
else:
self.rfcomm_tracer = None
self.tcp_tracer = None
async def start(self, device: Device) -> None:
self.device = device
# Create and register a server
rfcomm_server = rfcomm.Server(self.device)
# Listen for incoming DLC connections
self.channel = rfcomm_server.listen(self.on_rfcomm_channel, self.channel)
# Setup the SDP to advertise this channel
service_record_handle = 0x00010001
self.device.sdp_service_records = {
service_record_handle: rfcomm.make_service_sdp_records(
service_record_handle, self.channel, core.UUID(self.uuid)
)
}
# We're ready for a connection
self.device.on("connection", self.on_connection)
await self.set_available(True)
print(
color(
(
f"### Listening for RFCOMM connection on {device.public_address}, "
f"channel {self.channel}"
),
"yellow",
)
)
async def set_available(self, available: bool):
# Become discoverable and connectable
assert self.device
await self.device.set_connectable(available)
await self.device.set_discoverable(available)
def on_connection(self, connection):
print(color(f"@@@ Bluetooth connection: {connection}", "blue"))
connection.on("disconnection", self.on_disconnection)
# Don't accept new connections until we're disconnected
utils.AsyncRunner.spawn(self.set_available(False))
def on_disconnection(self, reason: int):
print(
color("@@@ Bluetooth disconnection:", "red"),
hci.HCI_Constant.error_name(reason),
)
# We're ready for a new connection
utils.AsyncRunner.spawn(self.set_available(True))
# Called when an RFCOMM channel is established
@utils.AsyncRunner.run_in_task()
async def on_rfcomm_channel(self, rfcomm_channel):
print(color("*** RFCOMM channel:", "cyan"), rfcomm_channel)
# Connect to the TCP server
print(
color(
f"### Connecting to TCP {self.tcp_host}:{self.tcp_port}",
"yellow",
)
)
try:
reader, writer = await asyncio.open_connection(self.tcp_host, self.tcp_port)
except OSError:
print(color("!!! Connection failed", "red"))
await rfcomm_channel.disconnect()
return
# Pipe data from RFCOMM to TCP
def on_rfcomm_channel_closed():
print(color("*** RFCOMM channel closed", "cyan"))
writer.close()
def write_rfcomm_data(data):
if self.rfcomm_tracer:
self.rfcomm_tracer.trace_data(data)
writer.write(data)
rfcomm_channel.sink = write_rfcomm_data
rfcomm_channel.on("close", on_rfcomm_channel_closed)
# Pipe data from TCP to RFCOMM
while True:
try:
data = await reader.read(self.READ_CHUNK_SIZE)
if len(data) == 0:
print(color("### TCP end of stream", "yellow"))
if rfcomm_channel.state == rfcomm.DLC.State.CONNECTED:
await rfcomm_channel.disconnect()
return
if self.tcp_tracer:
self.tcp_tracer.trace_data(data)
rfcomm_channel.write(data)
await rfcomm_channel.drain()
except Exception as error:
print(f"!!! Exception: {error}")
break
writer.close()
await writer.wait_closed()
print(color("~~~ Bye bye", "magenta"))
# -----------------------------------------------------------------------------
class ClientBridge:
"""
RFCOMM client bridge: connects to a BR/EDR device, then waits for an inbound
TCP connection on a specified port number. When a TCP client connects, an
RFCOMM connection to the device is established, and the data is bridged in both
directions, with flow control.
When the TCP connection is closed by the client, the RFCOMM channel is
disconnected, but the connection to the device remains, ready for a new TCP client
to connect.
"""
READ_CHUNK_SIZE = 4096
def __init__(
self,
channel: int,
uuid: str,
trace: bool,
address: str,
tcp_host: str,
tcp_port: int,
encrypt: bool,
):
self.channel = channel
self.uuid = uuid
self.trace = trace
self.address = address
self.tcp_host = tcp_host
self.tcp_port = tcp_port
self.encrypt = encrypt
self.device: Optional[Device] = None
self.connection: Optional[Connection] = None
self.rfcomm_client: Optional[rfcomm.Client]
self.rfcomm_mux: Optional[rfcomm.Multiplexer]
self.tcp_connected: bool = False
self.tcp_tracer: Optional[Tracer]
self.rfcomm_tracer: Optional[Tracer]
if trace:
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
self.rfcomm_tracer = Tracer(color("TCP->RFCOMM", "magenta"))
else:
self.rfcomm_tracer = None
self.tcp_tracer = None
async def connect(self) -> None:
if self.connection:
return
print(color(f"@@@ Connecting to Bluetooth {self.address}", "blue"))
assert self.device
self.connection = await self.device.connect(
self.address, transport=core.BT_BR_EDR_TRANSPORT
)
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
self.connection.on("disconnection", self.on_disconnection)
if self.encrypt:
print(color("@@@ Encrypting Bluetooth connection", "blue"))
await self.connection.encrypt()
print(color("@@@ Bluetooth connection encrypted", "blue"))
self.rfcomm_client = rfcomm.Client(self.connection)
try:
self.rfcomm_mux = await self.rfcomm_client.start()
except BaseException as e:
print(color("!!! Failed to setup RFCOMM connection", "red"), e)
raise
async def start(self, device: Device) -> None:
self.device = device
await device.set_connectable(False)
await device.set_discoverable(False)
# Called when a TCP connection is established
async def on_tcp_connection(reader, writer):
print(color("<<< TCP connection", "magenta"))
if self.tcp_connected:
print(
color("!!! TCP connection already active, rejecting new one", "red")
)
writer.close()
return
self.tcp_connected = True
try:
await self.pipe(reader, writer)
except BaseException as error:
print(color("!!! Exception while piping data:", "red"), error)
return
finally:
writer.close()
await writer.wait_closed()
self.tcp_connected = False
await asyncio.start_server(
on_tcp_connection,
host=self.tcp_host if self.tcp_host != "_" else None,
port=self.tcp_port,
)
print(
color(
f"### Listening for TCP connections on port {self.tcp_port}", "magenta"
)
)
async def pipe(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
# Resolve the channel number from the UUID if needed
if self.channel == 0:
await self.connect()
assert self.connection
channel = await rfcomm.find_rfcomm_channel_with_uuid(
self.connection, self.uuid
)
if channel:
print(color(f"### Found RFCOMM channel {channel}", "yellow"))
else:
print(color(f"!!! RFCOMM channel with UUID {self.uuid} not found"))
return
else:
channel = self.channel
# Connect a new RFCOMM channel
await self.connect()
assert self.rfcomm_mux
print(color(f"*** Opening RFCOMM channel {channel}", "green"))
try:
rfcomm_channel = await self.rfcomm_mux.open_dlc(channel)
print(color(f"*** RFCOMM channel open: {rfcomm_channel}", "green"))
except Exception as error:
print(color(f"!!! RFCOMM open failed: {error}", "red"))
return
# Pipe data from RFCOMM to TCP
def on_rfcomm_channel_closed():
print(color("*** RFCOMM channel closed", "green"))
def write_rfcomm_data(data):
if self.trace:
self.rfcomm_tracer.trace_data(data)
writer.write(data)
rfcomm_channel.on("close", on_rfcomm_channel_closed)
rfcomm_channel.sink = write_rfcomm_data
# Pipe data from TCP to RFCOMM
while True:
try:
data = await reader.read(self.READ_CHUNK_SIZE)
if len(data) == 0:
print(color("### TCP end of stream", "yellow"))
if rfcomm_channel.state == rfcomm.DLC.State.CONNECTED:
await rfcomm_channel.disconnect()
self.tcp_connected = False
return
if self.tcp_tracer:
self.tcp_tracer.trace_data(data)
rfcomm_channel.write(data)
await rfcomm_channel.drain()
except Exception as error:
print(f"!!! Exception: {error}")
break
print(color("~~~ Bye bye", "magenta"))
def on_disconnection(self, reason: int) -> None:
print(
color("@@@ Bluetooth disconnection:", "red"),
hci.HCI_Constant.error_name(reason),
)
self.connection = None
# -----------------------------------------------------------------------------
async def run(device_config, hci_transport, bridge):
print("<<< connecting to HCI...")
async with await transport.open_transport_or_link(hci_transport) as (
hci_source,
hci_sink,
):
print("<<< connected")
if device_config:
device = Device.from_config_file_with_hci(
device_config, hci_source, hci_sink
)
else:
device = Device.from_config_with_hci(
DeviceConfiguration(), hci_source, hci_sink
)
device.classic_enabled = True
# Let's go
await device.power_on()
try:
await bridge.start(device)
# Wait until the transport terminates
await hci_source.wait_for_termination()
except core.ConnectionError as error:
print(color(f"!!! Bluetooth connection failed: {error}", "red"))
except Exception as error:
print(f"Exception while running bridge: {error}")
# -----------------------------------------------------------------------------
@click.group()
@click.pass_context
@click.option(
"--device-config",
metavar="CONFIG_FILE",
help="Device configuration file",
)
@click.option(
"--hci-transport", metavar="TRANSPORT_NAME", help="HCI transport", required=True
)
@click.option("--trace", is_flag=True, help="Trace bridged data to stdout")
@click.option(
"--channel",
metavar="CHANNEL_NUMER",
help="RFCOMM channel number",
type=int,
default=0,
)
@click.option(
"--uuid",
metavar="UUID",
help="UUID for the RFCOMM channel",
default=DEFAULT_RFCOMM_UUID,
)
def cli(
context,
device_config,
hci_transport,
trace,
channel,
uuid,
):
context.ensure_object(dict)
context.obj["device_config"] = device_config
context.obj["hci_transport"] = hci_transport
context.obj["trace"] = trace
context.obj["channel"] = channel
context.obj["uuid"] = uuid
# -----------------------------------------------------------------------------
@cli.command()
@click.pass_context
@click.option("--tcp-host", help="TCP host", default="localhost")
@click.option("--tcp-port", help="TCP port", default=DEFAULT_SERVER_TCP_PORT)
def server(context, tcp_host, tcp_port):
bridge = ServerBridge(
context.obj["channel"],
context.obj["uuid"],
context.obj["trace"],
tcp_host,
tcp_port,
)
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
# -----------------------------------------------------------------------------
@cli.command()
@click.pass_context
@click.argument("bluetooth-address")
@click.option("--tcp-host", help="TCP host", default="_")
@click.option("--tcp-port", help="TCP port", default=DEFAULT_CLIENT_TCP_PORT)
@click.option("--encrypt", is_flag=True, help="Encrypt the connection")
def client(context, bluetooth_address, tcp_host, tcp_port, encrypt):
bridge = ClientBridge(
context.obj["channel"],
context.obj["uuid"],
context.obj["trace"],
bluetooth_address,
tcp_host,
tcp_port,
encrypt,
)
asyncio.run(run(context.obj["device_config"], context.obj["hci_transport"], bridge))
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
if __name__ == "__main__":
cli(obj={}) # pylint: disable=no-value-for-parameter
+689 -115
View File
@@ -16,11 +16,14 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
import dataclasses
import enum import enum
import struct import struct
from typing import List, Optional, Tuple, Union, cast, Dict from typing import List, Optional, Tuple, Union, cast, Dict
from typing_extensions import Self
from .company_ids import COMPANY_IDENTIFIERS from bumble.company_ids import COMPANY_IDENTIFIERS
from bumble.utils import OpenIntEnum
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -692,11 +695,569 @@ class DeviceClass:
return name_or_number(class_names, minor_device_class) return name_or_number(class_names, minor_device_class)
# -----------------------------------------------------------------------------
# Appearance
# -----------------------------------------------------------------------------
class Appearance:
class Category(OpenIntEnum):
UNKNOWN = 0x0000
PHONE = 0x0001
COMPUTER = 0x0002
WATCH = 0x0003
CLOCK = 0x0004
DISPLAY = 0x0005
REMOTE_CONTROL = 0x0006
EYE_GLASSES = 0x0007
TAG = 0x0008
KEYRING = 0x0009
MEDIA_PLAYER = 0x000A
BARCODE_SCANNER = 0x000B
THERMOMETER = 0x000C
HEART_RATE_SENSOR = 0x000D
BLOOD_PRESSURE = 0x000E
HUMAN_INTERFACE_DEVICE = 0x000F
GLUCOSE_METER = 0x0010
RUNNING_WALKING_SENSOR = 0x0011
CYCLING = 0x0012
CONTROL_DEVICE = 0x0013
NETWORK_DEVICE = 0x0014
SENSOR = 0x0015
LIGHT_FIXTURES = 0x0016
FAN = 0x0017
HVAC = 0x0018
AIR_CONDITIONING = 0x0019
HUMIDIFIER = 0x001A
HEATING = 0x001B
ACCESS_CONTROL = 0x001C
MOTORIZED_DEVICE = 0x001D
POWER_DEVICE = 0x001E
LIGHT_SOURCE = 0x001F
WINDOW_COVERING = 0x0020
AUDIO_SINK = 0x0021
AUDIO_SOURCE = 0x0022
MOTORIZED_VEHICLE = 0x0023
DOMESTIC_APPLIANCE = 0x0024
WEARABLE_AUDIO_DEVICE = 0x0025
AIRCRAFT = 0x0026
AV_EQUIPMENT = 0x0027
DISPLAY_EQUIPMENT = 0x0028
HEARING_AID = 0x0029
GAMING = 0x002A
SIGNAGE = 0x002B
PULSE_OXIMETER = 0x0031
WEIGHT_SCALE = 0x0032
PERSONAL_MOBILITY_DEVICE = 0x0033
CONTINUOUS_GLUCOSE_MONITOR = 0x0034
INSULIN_PUMP = 0x0035
MEDICATION_DELIVERY = 0x0036
SPIROMETER = 0x0037
OUTDOOR_SPORTS_ACTIVITY = 0x0051
class UnknownSubcategory(OpenIntEnum):
GENERIC_UNKNOWN = 0x00
class PhoneSubcategory(OpenIntEnum):
GENERIC_PHONE = 0x00
class ComputerSubcategory(OpenIntEnum):
GENERIC_COMPUTER = 0x00
DESKTOP_WORKSTATION = 0x01
SERVER_CLASS_COMPUTER = 0x02
LAPTOP = 0x03
HANDHELD_PC_PDA = 0x04
PALM_SIZE_PC_PDA = 0x05
WEARABLE_COMPUTER = 0x06
TABLET = 0x07
DOCKING_STATION = 0x08
ALL_IN_ONE = 0x09
BLADE_SERVER = 0x0A
CONVERTIBLE = 0x0B
DETACHABLE = 0x0C
IOT_GATEWAY = 0x0D
MINI_PC = 0x0E
STICK_PC = 0x0F
class WatchSubcategory(OpenIntEnum):
GENENERIC_WATCH = 0x00
SPORTS_WATCH = 0x01
SMARTWATCH = 0x02
class ClockSubcategory(OpenIntEnum):
GENERIC_CLOCK = 0x00
class DisplaySubcategory(OpenIntEnum):
GENERIC_DISPLAY = 0x00
class RemoteControlSubcategory(OpenIntEnum):
GENERIC_REMOTE_CONTROL = 0x00
class EyeglassesSubcategory(OpenIntEnum):
GENERIC_EYEGLASSES = 0x00
class TagSubcategory(OpenIntEnum):
GENERIC_TAG = 0x00
class KeyringSubcategory(OpenIntEnum):
GENERIC_KEYRING = 0x00
class MediaPlayerSubcategory(OpenIntEnum):
GENERIC_MEDIA_PLAYER = 0x00
class BarcodeScannerSubcategory(OpenIntEnum):
GENERIC_BARCODE_SCANNER = 0x00
class ThermometerSubcategory(OpenIntEnum):
GENERIC_THERMOMETER = 0x00
EAR_THERMOMETER = 0x01
class HeartRateSensorSubcategory(OpenIntEnum):
GENERIC_HEART_RATE_SENSOR = 0x00
HEART_RATE_BELT = 0x01
class BloodPressureSubcategory(OpenIntEnum):
GENERIC_BLOOD_PRESSURE = 0x00
ARM_BLOOD_PRESSURE = 0x01
WRIST_BLOOD_PRESSURE = 0x02
class HumanInterfaceDeviceSubcategory(OpenIntEnum):
GENERIC_HUMAN_INTERFACE_DEVICE = 0x00
KEYBOARD = 0x01
MOUSE = 0x02
JOYSTICK = 0x03
GAMEPAD = 0x04
DIGITIZER_TABLET = 0x05
CARD_READER = 0x06
DIGITAL_PEN = 0x07
BARCODE_SCANNER = 0x08
TOUCHPAD = 0x09
PRESENTATION_REMOTE = 0x0A
class GlucoseMeterSubcategory(OpenIntEnum):
GENERIC_GLUCOSE_METER = 0x00
class RunningWalkingSensorSubcategory(OpenIntEnum):
GENERIC_RUNNING_WALKING_SENSOR = 0x00
IN_SHOE_RUNNING_WALKING_SENSOR = 0x01
ON_SHOW_RUNNING_WALKING_SENSOR = 0x02
ON_HIP_RUNNING_WALKING_SENSOR = 0x03
class CyclingSubcategory(OpenIntEnum):
GENERIC_CYCLING = 0x00
CYCLING_COMPUTER = 0x01
SPEED_SENSOR = 0x02
CADENCE_SENSOR = 0x03
POWER_SENSOR = 0x04
SPEED_AND_CADENCE_SENSOR = 0x05
class ControlDeviceSubcategory(OpenIntEnum):
GENERIC_CONTROL_DEVICE = 0x00
SWITCH = 0x01
MULTI_SWITCH = 0x02
BUTTON = 0x03
SLIDER = 0x04
ROTARY_SWITCH = 0x05
TOUCH_PANEL = 0x06
SINGLE_SWITCH = 0x07
DOUBLE_SWITCH = 0x08
TRIPLE_SWITCH = 0x09
BATTERY_SWITCH = 0x0A
ENERGY_HARVESTING_SWITCH = 0x0B
PUSH_BUTTON = 0x0C
class NetworkDeviceSubcategory(OpenIntEnum):
GENERIC_NETWORK_DEVICE = 0x00
ACCESS_POINT = 0x01
MESH_DEVICE = 0x02
MESH_NETWORK_PROXY = 0x03
class SensorSubcategory(OpenIntEnum):
GENERIC_SENSOR = 0x00
MOTION_SENSOR = 0x01
AIR_QUALITY_SENSOR = 0x02
TEMPERATURE_SENSOR = 0x03
HUMIDITY_SENSOR = 0x04
LEAK_SENSOR = 0x05
SMOKE_SENSOR = 0x06
OCCUPANCY_SENSOR = 0x07
CONTACT_SENSOR = 0x08
CARBON_MONOXIDE_SENSOR = 0x09
CARBON_DIOXIDE_SENSOR = 0x0A
AMBIENT_LIGHT_SENSOR = 0x0B
ENERGY_SENSOR = 0x0C
COLOR_LIGHT_SENSOR = 0x0D
RAIN_SENSOR = 0x0E
FIRE_SENSOR = 0x0F
WIND_SENSOR = 0x10
PROXIMITY_SENSOR = 0x11
MULTI_SENSOR = 0x12
FLUSH_MOUNTED_SENSOR = 0x13
CEILING_MOUNTED_SENSOR = 0x14
WALL_MOUNTED_SENSOR = 0x15
MULTISENSOR = 0x16
ENERGY_METER = 0x17
FLAME_DETECTOR = 0x18
VEHICLE_TIRE_PRESSURE_SENSOR = 0x19
class LightFixturesSubcategory(OpenIntEnum):
GENERIC_LIGHT_FIXTURES = 0x00
WALL_LIGHT = 0x01
CEILING_LIGHT = 0x02
FLOOR_LIGHT = 0x03
CABINET_LIGHT = 0x04
DESK_LIGHT = 0x05
TROFFER_LIGHT = 0x06
PENDANT_LIGHT = 0x07
IN_GROUND_LIGHT = 0x08
FLOOD_LIGHT = 0x09
UNDERWATER_LIGHT = 0x0A
BOLLARD_WITH_LIGHT = 0x0B
PATHWAY_LIGHT = 0x0C
GARDEN_LIGHT = 0x0D
POLE_TOP_LIGHT = 0x0E
SPOTLIGHT = 0x0F
LINEAR_LIGHT = 0x10
STREET_LIGHT = 0x11
SHELVES_LIGHT = 0x12
BAY_LIGHT = 0x013
EMERGENCY_EXIT_LIGHT = 0x14
LIGHT_CONTROLLER = 0x15
LIGHT_DRIVER = 0x16
BULB = 0x17
LOW_BAY_LIGHT = 0x18
HIGH_BAY_LIGHT = 0x19
class FanSubcategory(OpenIntEnum):
GENERIC_FAN = 0x00
CEILING_FAN = 0x01
AXIAL_FAN = 0x02
EXHAUST_FAN = 0x03
PEDESTAL_FAN = 0x04
DESK_FAN = 0x05
WALL_FAN = 0x06
class HvacSubcategory(OpenIntEnum):
GENERIC_HVAC = 0x00
THERMOSTAT = 0x01
HUMIDIFIER = 0x02
DEHUMIDIFIER = 0x03
HEATER = 0x04
RADIATOR = 0x05
BOILER = 0x06
HEAT_PUMP = 0x07
INFRARED_HEATER = 0x08
RADIANT_PANEL_HEATER = 0x09
FAN_HEATER = 0x0A
AIR_CURTAIN = 0x0B
class AirConditioningSubcategory(OpenIntEnum):
GENERIC_AIR_CONDITIONING = 0x00
class HumidifierSubcategory(OpenIntEnum):
GENERIC_HUMIDIFIER = 0x00
class HeatingSubcategory(OpenIntEnum):
GENERIC_HEATING = 0x00
RADIATOR = 0x01
BOILER = 0x02
HEAT_PUMP = 0x03
INFRARED_HEATER = 0x04
RADIANT_PANEL_HEATER = 0x05
FAN_HEATER = 0x06
AIR_CURTAIN = 0x07
class AccessControlSubcategory(OpenIntEnum):
GENERIC_ACCESS_CONTROL = 0x00
ACCESS_DOOR = 0x01
GARAGE_DOOR = 0x02
EMERGENCY_EXIT_DOOR = 0x03
ACCESS_LOCK = 0x04
ELEVATOR = 0x05
WINDOW = 0x06
ENTRANCE_GATE = 0x07
DOOR_LOCK = 0x08
LOCKER = 0x09
class MotorizedDeviceSubcategory(OpenIntEnum):
GENERIC_MOTORIZED_DEVICE = 0x00
MOTORIZED_GATE = 0x01
AWNING = 0x02
BLINDS_OR_SHADES = 0x03
CURTAINS = 0x04
SCREEN = 0x05
class PowerDeviceSubcategory(OpenIntEnum):
GENERIC_POWER_DEVICE = 0x00
POWER_OUTLET = 0x01
POWER_STRIP = 0x02
PLUG = 0x03
POWER_SUPPLY = 0x04
LED_DRIVER = 0x05
FLUORESCENT_LAMP_GEAR = 0x06
HID_LAMP_GEAR = 0x07
CHARGE_CASE = 0x08
POWER_BANK = 0x09
class LightSourceSubcategory(OpenIntEnum):
GENERIC_LIGHT_SOURCE = 0x00
INCANDESCENT_LIGHT_BULB = 0x01
LED_LAMP = 0x02
HID_LAMP = 0x03
FLUORESCENT_LAMP = 0x04
LED_ARRAY = 0x05
MULTI_COLOR_LED_ARRAY = 0x06
LOW_VOLTAGE_HALOGEN = 0x07
ORGANIC_LIGHT_EMITTING_DIODE = 0x08
class WindowCoveringSubcategory(OpenIntEnum):
GENERIC_WINDOW_COVERING = 0x00
WINDOW_SHADES = 0x01
WINDOW_BLINDS = 0x02
WINDOW_AWNING = 0x03
WINDOW_CURTAIN = 0x04
EXTERIOR_SHUTTER = 0x05
EXTERIOR_SCREEN = 0x06
class AudioSinkSubcategory(OpenIntEnum):
GENERIC_AUDIO_SINK = 0x00
STANDALONE_SPEAKER = 0x01
SOUNDBAR = 0x02
BOOKSHELF_SPEAKER = 0x03
STANDMOUNTED_SPEAKER = 0x04
SPEAKERPHONE = 0x05
class AudioSourceSubcategory(OpenIntEnum):
GENERIC_AUDIO_SOURCE = 0x00
MICROPHONE = 0x01
ALARM = 0x02
BELL = 0x03
HORN = 0x04
BROADCASTING_DEVICE = 0x05
SERVICE_DESK = 0x06
KIOSK = 0x07
BROADCASTING_ROOM = 0x08
AUDITORIUM = 0x09
class MotorizedVehicleSubcategory(OpenIntEnum):
GENERIC_MOTORIZED_VEHICLE = 0x00
CAR = 0x01
LARGE_GOODS_VEHICLE = 0x02
TWO_WHEELED_VEHICLE = 0x03
MOTORBIKE = 0x04
SCOOTER = 0x05
MOPED = 0x06
THREE_WHEELED_VEHICLE = 0x07
LIGHT_VEHICLE = 0x08
QUAD_BIKE = 0x09
MINIBUS = 0x0A
BUS = 0x0B
TROLLEY = 0x0C
AGRICULTURAL_VEHICLE = 0x0D
CAMPER_CARAVAN = 0x0E
RECREATIONAL_VEHICLE_MOTOR_HOME = 0x0F
class DomesticApplianceSubcategory(OpenIntEnum):
GENERIC_DOMESTIC_APPLIANCE = 0x00
REFRIGERATOR = 0x01
FREEZER = 0x02
OVEN = 0x03
MICROWAVE = 0x04
TOASTER = 0x05
WASHING_MACHINE = 0x06
DRYER = 0x07
COFFEE_MAKER = 0x08
CLOTHES_IRON = 0x09
CURLING_IRON = 0x0A
HAIR_DRYER = 0x0B
VACUUM_CLEANER = 0x0C
ROBOTIC_VACUUM_CLEANER = 0x0D
RICE_COOKER = 0x0E
CLOTHES_STEAMER = 0x0F
class WearableAudioDeviceSubcategory(OpenIntEnum):
GENERIC_WEARABLE_AUDIO_DEVICE = 0x00
EARBUD = 0x01
HEADSET = 0x02
HEADPHONES = 0x03
NECK_BAND = 0x04
class AircraftSubcategory(OpenIntEnum):
GENERIC_AIRCRAFT = 0x00
LIGHT_AIRCRAFT = 0x01
MICROLIGHT = 0x02
PARAGLIDER = 0x03
LARGE_PASSENGER_AIRCRAFT = 0x04
class AvEquipmentSubcategory(OpenIntEnum):
GENERIC_AV_EQUIPMENT = 0x00
AMPLIFIER = 0x01
RECEIVER = 0x02
RADIO = 0x03
TUNER = 0x04
TURNTABLE = 0x05
CD_PLAYER = 0x06
DVD_PLAYER = 0x07
BLUERAY_PLAYER = 0x08
OPTICAL_DISC_PLAYER = 0x09
SET_TOP_BOX = 0x0A
class DisplayEquipmentSubcategory(OpenIntEnum):
GENERIC_DISPLAY_EQUIPMENT = 0x00
TELEVISION = 0x01
MONITOR = 0x02
PROJECTOR = 0x03
class HearingAidSubcategory(OpenIntEnum):
GENERIC_HEARING_AID = 0x00
IN_EAR_HEARING_AID = 0x01
BEHIND_EAR_HEARING_AID = 0x02
COCHLEAR_IMPLANT = 0x03
class GamingSubcategory(OpenIntEnum):
GENERIC_GAMING = 0x00
HOME_VIDEO_GAME_CONSOLE = 0x01
PORTABLE_HANDHELD_CONSOLE = 0x02
class SignageSubcategory(OpenIntEnum):
GENERIC_SIGNAGE = 0x00
DIGITAL_SIGNAGE = 0x01
ELECTRONIC_LABEL = 0x02
class PulseOximeterSubcategory(OpenIntEnum):
GENERIC_PULSE_OXIMETER = 0x00
FINGERTIP_PULSE_OXIMETER = 0x01
WRIST_WORN_PULSE_OXIMETER = 0x02
class WeightScaleSubcategory(OpenIntEnum):
GENERIC_WEIGHT_SCALE = 0x00
class PersonalMobilityDeviceSubcategory(OpenIntEnum):
GENERIC_PERSONAL_MOBILITY_DEVICE = 0x00
POWERED_WHEELCHAIR = 0x01
MOBILITY_SCOOTER = 0x02
class ContinuousGlucoseMonitorSubcategory(OpenIntEnum):
GENERIC_CONTINUOUS_GLUCOSE_MONITOR = 0x00
class InsulinPumpSubcategory(OpenIntEnum):
GENERIC_INSULIN_PUMP = 0x00
INSULIN_PUMP_DURABLE_PUMP = 0x01
INSULIN_PUMP_PATCH_PUMP = 0x02
INSULIN_PEN = 0x03
class MedicationDeliverySubcategory(OpenIntEnum):
GENERIC_MEDICATION_DELIVERY = 0x00
class SpirometerSubcategory(OpenIntEnum):
GENERIC_SPIROMETER = 0x00
HANDHELD_SPIROMETER = 0x01
class OutdoorSportsActivitySubcategory(OpenIntEnum):
GENERIC_OUTDOOR_SPORTS_ACTIVITY = 0x00
LOCATION_DISPLAY = 0x01
LOCATION_AND_NAVIGATION_DISPLAY = 0x02
LOCATION_POD = 0x03
LOCATION_AND_NAVIGATION_POD = 0x04
class _OpenSubcategory(OpenIntEnum):
GENERIC = 0x00
SUBCATEGORY_CLASSES = {
Category.UNKNOWN: UnknownSubcategory,
Category.PHONE: PhoneSubcategory,
Category.COMPUTER: ComputerSubcategory,
Category.WATCH: WatchSubcategory,
Category.CLOCK: ClockSubcategory,
Category.DISPLAY: DisplaySubcategory,
Category.REMOTE_CONTROL: RemoteControlSubcategory,
Category.EYE_GLASSES: EyeglassesSubcategory,
Category.TAG: TagSubcategory,
Category.KEYRING: KeyringSubcategory,
Category.MEDIA_PLAYER: MediaPlayerSubcategory,
Category.BARCODE_SCANNER: BarcodeScannerSubcategory,
Category.THERMOMETER: ThermometerSubcategory,
Category.HEART_RATE_SENSOR: HeartRateSensorSubcategory,
Category.BLOOD_PRESSURE: BloodPressureSubcategory,
Category.HUMAN_INTERFACE_DEVICE: HumanInterfaceDeviceSubcategory,
Category.GLUCOSE_METER: GlucoseMeterSubcategory,
Category.RUNNING_WALKING_SENSOR: RunningWalkingSensorSubcategory,
Category.CYCLING: CyclingSubcategory,
Category.CONTROL_DEVICE: ControlDeviceSubcategory,
Category.NETWORK_DEVICE: NetworkDeviceSubcategory,
Category.SENSOR: SensorSubcategory,
Category.LIGHT_FIXTURES: LightFixturesSubcategory,
Category.FAN: FanSubcategory,
Category.HVAC: HvacSubcategory,
Category.AIR_CONDITIONING: AirConditioningSubcategory,
Category.HUMIDIFIER: HumidifierSubcategory,
Category.HEATING: HeatingSubcategory,
Category.ACCESS_CONTROL: AccessControlSubcategory,
Category.MOTORIZED_DEVICE: MotorizedDeviceSubcategory,
Category.POWER_DEVICE: PowerDeviceSubcategory,
Category.LIGHT_SOURCE: LightSourceSubcategory,
Category.WINDOW_COVERING: WindowCoveringSubcategory,
Category.AUDIO_SINK: AudioSinkSubcategory,
Category.AUDIO_SOURCE: AudioSourceSubcategory,
Category.MOTORIZED_VEHICLE: MotorizedVehicleSubcategory,
Category.DOMESTIC_APPLIANCE: DomesticApplianceSubcategory,
Category.WEARABLE_AUDIO_DEVICE: WearableAudioDeviceSubcategory,
Category.AIRCRAFT: AircraftSubcategory,
Category.AV_EQUIPMENT: AvEquipmentSubcategory,
Category.DISPLAY_EQUIPMENT: DisplayEquipmentSubcategory,
Category.HEARING_AID: HearingAidSubcategory,
Category.GAMING: GamingSubcategory,
Category.SIGNAGE: SignageSubcategory,
Category.PULSE_OXIMETER: PulseOximeterSubcategory,
Category.WEIGHT_SCALE: WeightScaleSubcategory,
Category.PERSONAL_MOBILITY_DEVICE: PersonalMobilityDeviceSubcategory,
Category.CONTINUOUS_GLUCOSE_MONITOR: ContinuousGlucoseMonitorSubcategory,
Category.INSULIN_PUMP: InsulinPumpSubcategory,
Category.MEDICATION_DELIVERY: MedicationDeliverySubcategory,
Category.SPIROMETER: SpirometerSubcategory,
Category.OUTDOOR_SPORTS_ACTIVITY: OutdoorSportsActivitySubcategory,
}
category: Category
subcategory: enum.IntEnum
@classmethod
def from_int(cls, appearance: int) -> Self:
category = cls.Category(appearance >> 6)
return cls(category, appearance & 0x3F)
def __init__(self, category: Category, subcategory: int) -> None:
self.category = category
if subcategory_class := self.SUBCATEGORY_CLASSES.get(category):
self.subcategory = subcategory_class(subcategory)
else:
self.subcategory = self._OpenSubcategory(subcategory)
def __int__(self) -> int:
return self.category << 6 | self.subcategory
def __repr__(self) -> str:
return (
'Appearance('
f'category={self.category.name}, '
f'subcategory={self.subcategory.name}'
')'
)
def __str__(self) -> str:
return f'{self.category.name}/{self.subcategory.name}'
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Advertising Data # Advertising Data
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
AdvertisingObject = Union[ AdvertisingDataObject = Union[
List[UUID], Tuple[UUID, bytes], bytes, str, int, Tuple[int, int], Tuple[int, bytes] List[UUID],
Tuple[UUID, bytes],
bytes,
str,
int,
Tuple[int, int],
Tuple[int, bytes],
Appearance,
] ]
@@ -704,109 +1265,115 @@ class AdvertisingData:
# fmt: off # fmt: off
# pylint: disable=line-too-long # pylint: disable=line-too-long
# This list is only partial, it still needs to be filled in from the spec FLAGS = 0x01
FLAGS = 0x01 INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02 COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03 INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04 COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05 INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06 COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07 SHORTENED_LOCAL_NAME = 0x08
SHORTENED_LOCAL_NAME = 0x08 COMPLETE_LOCAL_NAME = 0x09
COMPLETE_LOCAL_NAME = 0x09 TX_POWER_LEVEL = 0x0A
TX_POWER_LEVEL = 0x0A CLASS_OF_DEVICE = 0x0D
CLASS_OF_DEVICE = 0x0D SIMPLE_PAIRING_HASH_C = 0x0E
SIMPLE_PAIRING_HASH_C = 0x0E SIMPLE_PAIRING_HASH_C_192 = 0x0E
SIMPLE_PAIRING_HASH_C_192 = 0x0E SIMPLE_PAIRING_RANDOMIZER_R = 0x0F
SIMPLE_PAIRING_RANDOMIZER_R = 0x0F SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F
SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F DEVICE_ID = 0x10
DEVICE_ID = 0x10 SECURITY_MANAGER_TK_VALUE = 0x10
SECURITY_MANAGER_TK_VALUE = 0x10 SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11 PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12
PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12 LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14 LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15 SERVICE_DATA = 0x16
SERVICE_DATA = 0x16 SERVICE_DATA_16_BIT_UUID = 0x16
SERVICE_DATA_16_BIT_UUID = 0x16 PUBLIC_TARGET_ADDRESS = 0x17
PUBLIC_TARGET_ADDRESS = 0x17 RANDOM_TARGET_ADDRESS = 0x18
RANDOM_TARGET_ADDRESS = 0x18 APPEARANCE = 0x19
APPEARANCE = 0x19 ADVERTISING_INTERVAL = 0x1A
ADVERTISING_INTERVAL = 0x1A LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B
LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B LE_ROLE = 0x1C
LE_ROLE = 0x1C SIMPLE_PAIRING_HASH_C_256 = 0x1D
SIMPLE_PAIRING_HASH_C_256 = 0x1D SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E
SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F SERVICE_DATA_32_BIT_UUID = 0x20
SERVICE_DATA_32_BIT_UUID = 0x20 SERVICE_DATA_128_BIT_UUID = 0x21
SERVICE_DATA_128_BIT_UUID = 0x21 LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22 LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23
LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23 URI = 0x24
URI = 0x24 INDOOR_POSITIONING = 0x25
INDOOR_POSITIONING = 0x25 TRANSPORT_DISCOVERY_DATA = 0x26
TRANSPORT_DISCOVERY_DATA = 0x26 LE_SUPPORTED_FEATURES = 0x27
LE_SUPPORTED_FEATURES = 0x27 CHANNEL_MAP_UPDATE_INDICATION = 0x28
CHANNEL_MAP_UPDATE_INDICATION = 0x28 PB_ADV = 0x29
PB_ADV = 0x29 MESH_MESSAGE = 0x2A
MESH_MESSAGE = 0x2A MESH_BEACON = 0x2B
MESH_BEACON = 0x2B BIGINFO = 0x2C
BIGINFO = 0x2C BROADCAST_CODE = 0x2D
BROADCAST_CODE = 0x2D RESOLVABLE_SET_IDENTIFIER = 0x2E
RESOLVABLE_SET_IDENTIFIER = 0x2E ADVERTISING_INTERVAL_LONG = 0x2F
ADVERTISING_INTERVAL_LONG = 0x2F BROADCAST_NAME = 0x30
THREE_D_INFORMATION_DATA = 0x3D ENCRYPTED_ADVERTISING_DATA = 0X31
MANUFACTURER_SPECIFIC_DATA = 0xFF PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION = 0X32
ELECTRONIC_SHELF_LABEL = 0X34
THREE_D_INFORMATION_DATA = 0x3D
MANUFACTURER_SPECIFIC_DATA = 0xFF
AD_TYPE_NAMES = { AD_TYPE_NAMES = {
FLAGS: 'FLAGS', FLAGS: 'FLAGS',
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS', INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS', COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS', INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS', COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS', INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS', COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
SHORTENED_LOCAL_NAME: 'SHORTENED_LOCAL_NAME', SHORTENED_LOCAL_NAME: 'SHORTENED_LOCAL_NAME',
COMPLETE_LOCAL_NAME: 'COMPLETE_LOCAL_NAME', COMPLETE_LOCAL_NAME: 'COMPLETE_LOCAL_NAME',
TX_POWER_LEVEL: 'TX_POWER_LEVEL', TX_POWER_LEVEL: 'TX_POWER_LEVEL',
CLASS_OF_DEVICE: 'CLASS_OF_DEVICE', CLASS_OF_DEVICE: 'CLASS_OF_DEVICE',
SIMPLE_PAIRING_HASH_C: 'SIMPLE_PAIRING_HASH_C', SIMPLE_PAIRING_HASH_C: 'SIMPLE_PAIRING_HASH_C',
SIMPLE_PAIRING_HASH_C_192: 'SIMPLE_PAIRING_HASH_C_192', SIMPLE_PAIRING_HASH_C_192: 'SIMPLE_PAIRING_HASH_C_192',
SIMPLE_PAIRING_RANDOMIZER_R: 'SIMPLE_PAIRING_RANDOMIZER_R', SIMPLE_PAIRING_RANDOMIZER_R: 'SIMPLE_PAIRING_RANDOMIZER_R',
SIMPLE_PAIRING_RANDOMIZER_R_192: 'SIMPLE_PAIRING_RANDOMIZER_R_192', SIMPLE_PAIRING_RANDOMIZER_R_192: 'SIMPLE_PAIRING_RANDOMIZER_R_192',
DEVICE_ID: 'DEVICE_ID', DEVICE_ID: 'DEVICE_ID',
SECURITY_MANAGER_TK_VALUE: 'SECURITY_MANAGER_TK_VALUE', SECURITY_MANAGER_TK_VALUE: 'SECURITY_MANAGER_TK_VALUE',
SECURITY_MANAGER_OUT_OF_BAND_FLAGS: 'SECURITY_MANAGER_OUT_OF_BAND_FLAGS', SECURITY_MANAGER_OUT_OF_BAND_FLAGS: 'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
PERIPHERAL_CONNECTION_INTERVAL_RANGE: 'PERIPHERAL_CONNECTION_INTERVAL_RANGE', PERIPHERAL_CONNECTION_INTERVAL_RANGE: 'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS', LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS',
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS', LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS',
SERVICE_DATA: 'SERVICE_DATA', SERVICE_DATA_16_BIT_UUID: 'SERVICE_DATA_16_BIT_UUID',
SERVICE_DATA_16_BIT_UUID: 'SERVICE_DATA_16_BIT_UUID', PUBLIC_TARGET_ADDRESS: 'PUBLIC_TARGET_ADDRESS',
PUBLIC_TARGET_ADDRESS: 'PUBLIC_TARGET_ADDRESS', RANDOM_TARGET_ADDRESS: 'RANDOM_TARGET_ADDRESS',
RANDOM_TARGET_ADDRESS: 'RANDOM_TARGET_ADDRESS', APPEARANCE: 'APPEARANCE',
APPEARANCE: 'APPEARANCE', ADVERTISING_INTERVAL: 'ADVERTISING_INTERVAL',
ADVERTISING_INTERVAL: 'ADVERTISING_INTERVAL', LE_BLUETOOTH_DEVICE_ADDRESS: 'LE_BLUETOOTH_DEVICE_ADDRESS',
LE_BLUETOOTH_DEVICE_ADDRESS: 'LE_BLUETOOTH_DEVICE_ADDRESS', LE_ROLE: 'LE_ROLE',
LE_ROLE: 'LE_ROLE', SIMPLE_PAIRING_HASH_C_256: 'SIMPLE_PAIRING_HASH_C_256',
SIMPLE_PAIRING_HASH_C_256: 'SIMPLE_PAIRING_HASH_C_256', SIMPLE_PAIRING_RANDOMIZER_R_256: 'SIMPLE_PAIRING_RANDOMIZER_R_256',
SIMPLE_PAIRING_RANDOMIZER_R_256: 'SIMPLE_PAIRING_RANDOMIZER_R_256', LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS',
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS', SERVICE_DATA_32_BIT_UUID: 'SERVICE_DATA_32_BIT_UUID',
SERVICE_DATA_32_BIT_UUID: 'SERVICE_DATA_32_BIT_UUID', SERVICE_DATA_128_BIT_UUID: 'SERVICE_DATA_128_BIT_UUID',
SERVICE_DATA_128_BIT_UUID: 'SERVICE_DATA_128_BIT_UUID', LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE: 'LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE',
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE: 'LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE', LE_SECURE_CONNECTIONS_RANDOM_VALUE: 'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
LE_SECURE_CONNECTIONS_RANDOM_VALUE: 'LE_SECURE_CONNECTIONS_RANDOM_VALUE', URI: 'URI',
URI: 'URI', INDOOR_POSITIONING: 'INDOOR_POSITIONING',
INDOOR_POSITIONING: 'INDOOR_POSITIONING', TRANSPORT_DISCOVERY_DATA: 'TRANSPORT_DISCOVERY_DATA',
TRANSPORT_DISCOVERY_DATA: 'TRANSPORT_DISCOVERY_DATA', LE_SUPPORTED_FEATURES: 'LE_SUPPORTED_FEATURES',
LE_SUPPORTED_FEATURES: 'LE_SUPPORTED_FEATURES', CHANNEL_MAP_UPDATE_INDICATION: 'CHANNEL_MAP_UPDATE_INDICATION',
CHANNEL_MAP_UPDATE_INDICATION: 'CHANNEL_MAP_UPDATE_INDICATION', PB_ADV: 'PB_ADV',
PB_ADV: 'PB_ADV', MESH_MESSAGE: 'MESH_MESSAGE',
MESH_MESSAGE: 'MESH_MESSAGE', MESH_BEACON: 'MESH_BEACON',
MESH_BEACON: 'MESH_BEACON', BIGINFO: 'BIGINFO',
BIGINFO: 'BIGINFO', BROADCAST_CODE: 'BROADCAST_CODE',
BROADCAST_CODE: 'BROADCAST_CODE', RESOLVABLE_SET_IDENTIFIER: 'RESOLVABLE_SET_IDENTIFIER',
RESOLVABLE_SET_IDENTIFIER: 'RESOLVABLE_SET_IDENTIFIER', ADVERTISING_INTERVAL_LONG: 'ADVERTISING_INTERVAL_LONG',
ADVERTISING_INTERVAL_LONG: 'ADVERTISING_INTERVAL_LONG', BROADCAST_NAME: 'BROADCAST_NAME',
THREE_D_INFORMATION_DATA: 'THREE_D_INFORMATION_DATA', ENCRYPTED_ADVERTISING_DATA: 'ENCRYPTED_ADVERTISING_DATA',
MANUFACTURER_SPECIFIC_DATA: 'MANUFACTURER_SPECIFIC_DATA' PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION: 'PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION',
ELECTRONIC_SHELF_LABEL: 'ELECTRONIC_SHELF_LABEL',
THREE_D_INFORMATION_DATA: 'THREE_D_INFORMATION_DATA',
MANUFACTURER_SPECIFIC_DATA: 'MANUFACTURER_SPECIFIC_DATA'
} }
LE_LIMITED_DISCOVERABLE_MODE_FLAG = 0x01 LE_LIMITED_DISCOVERABLE_MODE_FLAG = 0x01
@@ -915,7 +1482,11 @@ class AdvertisingData:
ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}' ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
elif ad_type == AdvertisingData.APPEARANCE: elif ad_type == AdvertisingData.APPEARANCE:
ad_type_str = 'Appearance' ad_type_str = 'Appearance'
ad_data_str = ad_data.hex() appearance = Appearance.from_int(struct.unpack_from('<H', ad_data, 0)[0])
ad_data_str = str(appearance)
elif ad_type == AdvertisingData.BROADCAST_NAME:
ad_type_str = 'Broadcast Name'
ad_data_str = ad_data.decode('utf-8')
else: else:
ad_type_str = AdvertisingData.AD_TYPE_NAMES.get(ad_type, f'0x{ad_type:02X}') ad_type_str = AdvertisingData.AD_TYPE_NAMES.get(ad_type, f'0x{ad_type:02X}')
ad_data_str = ad_data.hex() ad_data_str = ad_data.hex()
@@ -924,7 +1495,7 @@ class AdvertisingData:
# pylint: disable=too-many-return-statements # pylint: disable=too-many-return-statements
@staticmethod @staticmethod
def ad_data_to_object(ad_type: int, ad_data: bytes) -> AdvertisingObject: def ad_data_to_object(ad_type: int, ad_data: bytes) -> AdvertisingDataObject:
if ad_type in ( if ad_type in (
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
@@ -959,16 +1530,14 @@ class AdvertisingData:
AdvertisingData.SHORTENED_LOCAL_NAME, AdvertisingData.SHORTENED_LOCAL_NAME,
AdvertisingData.COMPLETE_LOCAL_NAME, AdvertisingData.COMPLETE_LOCAL_NAME,
AdvertisingData.URI, AdvertisingData.URI,
AdvertisingData.BROADCAST_NAME,
): ):
return ad_data.decode("utf-8") return ad_data.decode("utf-8")
if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS): if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS):
return cast(int, struct.unpack('B', ad_data)[0]) return cast(int, struct.unpack('B', ad_data)[0])
if ad_type in ( if ad_type in (AdvertisingData.ADVERTISING_INTERVAL,):
AdvertisingData.APPEARANCE,
AdvertisingData.ADVERTISING_INTERVAL,
):
return cast(int, struct.unpack('<H', ad_data)[0]) return cast(int, struct.unpack('<H', ad_data)[0])
if ad_type == AdvertisingData.CLASS_OF_DEVICE: if ad_type == AdvertisingData.CLASS_OF_DEVICE:
@@ -980,6 +1549,11 @@ class AdvertisingData:
if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA: if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:]) return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:])
if ad_type == AdvertisingData.APPEARANCE:
return Appearance.from_int(
cast(int, struct.unpack_from('<H', ad_data, 0)[0])
)
return ad_data return ad_data
def append(self, data: bytes) -> None: def append(self, data: bytes) -> None:
@@ -993,27 +1567,27 @@ class AdvertisingData:
self.ad_structures.append((ad_type, ad_data)) self.ad_structures.append((ad_type, ad_data))
offset += length offset += length
def get_all(self, type_id: int, raw: bool = False) -> List[AdvertisingObject]: def get_all(self, type_id: int, raw: bool = False) -> List[AdvertisingDataObject]:
''' '''
Get Advertising Data Structure(s) with a given type Get Advertising Data Structure(s) with a given type
Returns a (possibly empty) list of matches. Returns a (possibly empty) list of matches.
''' '''
def process_ad_data(ad_data: bytes) -> AdvertisingObject: def process_ad_data(ad_data: bytes) -> AdvertisingDataObject:
return ad_data if raw else self.ad_data_to_object(type_id, ad_data) return ad_data if raw else self.ad_data_to_object(type_id, ad_data)
return [process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id] return [process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id]
def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingObject]: def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingDataObject]:
''' '''
Get Advertising Data Structure(s) with a given type Get Advertising Data Structure(s) with a given type
Returns the first entry, or None if no structure matches. Returns the first entry, or None if no structure matches.
''' '''
all = self.get_all(type_id, raw=raw) all_objects = self.get_all(type_id, raw=raw)
return all[0] if all else None return all_objects[0] if all_objects else None
def __bytes__(self): def __bytes__(self):
return b''.join( return b''.join(
+593 -125
View File
@@ -16,15 +16,21 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
from enum import IntEnum import asyncio
from collections.abc import Iterable
from contextlib import (
asynccontextmanager,
AsyncExitStack,
closing,
)
import copy
from dataclasses import dataclass, field
from enum import Enum, IntEnum
import functools import functools
import json import json
import asyncio
import logging import logging
import secrets import secrets
from contextlib import asynccontextmanager, AsyncExitStack, closing import sys
from dataclasses import dataclass, field
from collections.abc import Iterable
from typing import ( from typing import (
Any, Any,
Callable, Callable,
@@ -40,6 +46,7 @@ from typing import (
overload, overload,
TYPE_CHECKING, TYPE_CHECKING,
) )
from typing_extensions import Self
from pyee import EventEmitter from pyee import EventEmitter
@@ -73,6 +80,7 @@ from .hci import (
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY, HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
HCI_OPERATION_CANCELLED_BY_HOST_ERROR,
HCI_R2_PAGE_SCAN_REPETITION_MODE, HCI_R2_PAGE_SCAN_REPETITION_MODE,
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR, HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
HCI_SUCCESS, HCI_SUCCESS,
@@ -94,11 +102,16 @@ from .hci import (
HCI_LE_Accept_CIS_Request_Command, HCI_LE_Accept_CIS_Request_Command,
HCI_LE_Add_Device_To_Resolving_List_Command, HCI_LE_Add_Device_To_Resolving_List_Command,
HCI_LE_Advertising_Report_Event, HCI_LE_Advertising_Report_Event,
HCI_LE_BIGInfo_Advertising_Report_Event,
HCI_LE_Clear_Resolving_List_Command, HCI_LE_Clear_Resolving_List_Command,
HCI_LE_Connection_Update_Command, HCI_LE_Connection_Update_Command,
HCI_LE_Create_Connection_Cancel_Command, HCI_LE_Create_Connection_Cancel_Command,
HCI_LE_Create_Connection_Command, HCI_LE_Create_Connection_Command,
HCI_LE_Create_CIS_Command, HCI_LE_Create_CIS_Command,
HCI_LE_Periodic_Advertising_Create_Sync_Command,
HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command,
HCI_LE_Periodic_Advertising_Report_Event,
HCI_LE_Periodic_Advertising_Terminate_Sync_Command,
HCI_LE_Enable_Encryption_Command, HCI_LE_Enable_Encryption_Command,
HCI_LE_Extended_Advertising_Report_Event, HCI_LE_Extended_Advertising_Report_Event,
HCI_LE_Extended_Create_Connection_Command, HCI_LE_Extended_Create_Connection_Command,
@@ -240,6 +253,8 @@ DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS = l2cap.L2CAP_LE_CREDIT_BASED_CONN
DEVICE_DEFAULT_ADVERTISING_TX_POWER = ( DEVICE_DEFAULT_ADVERTISING_TX_POWER = (
HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE
) )
DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_SKIP = 0
DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_TIMEOUT = 5.0
# fmt: on # fmt: on
# pylint: enable=line-too-long # pylint: enable=line-too-long
@@ -544,6 +559,70 @@ class AdvertisingEventProperties:
) )
# -----------------------------------------------------------------------------
@dataclass
class PeriodicAdvertisement:
address: Address
sid: int
tx_power: int = (
HCI_LE_Periodic_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
)
rssi: int = HCI_LE_Periodic_Advertising_Report_Event.RSSI_NOT_AVAILABLE
is_truncated: bool = False
data_bytes: bytes = b''
# Constants
TX_POWER_NOT_AVAILABLE: ClassVar[int] = (
HCI_LE_Periodic_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
)
RSSI_NOT_AVAILABLE: ClassVar[int] = (
HCI_LE_Periodic_Advertising_Report_Event.RSSI_NOT_AVAILABLE
)
def __post_init__(self) -> None:
self.data = (
None if self.is_truncated else AdvertisingData.from_bytes(self.data_bytes)
)
# -----------------------------------------------------------------------------
@dataclass
class BIGInfoAdvertisement:
address: Address
sid: int
num_bis: int
nse: int
iso_interval: int
bn: int
pto: int
irc: int
max_pdu: int
sdu_interval: int
max_sdu: int
phy: Phy
framed: bool
encrypted: bool
@classmethod
def from_report(cls, address: Address, sid: int, report) -> Self:
return cls(
address,
sid,
report.num_bis,
report.nse,
report.iso_interval,
report.bn,
report.pto,
report.irc,
report.max_pdu,
report.sdu_interval,
report.max_sdu,
Phy(report.phy),
report.framing != 0,
report.encryption != 0,
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# TODO: replace with typing.TypeAlias when the code base is all Python >= 3.10 # TODO: replace with typing.TypeAlias when the code base is all Python >= 3.10
AdvertisingChannelMap = HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap AdvertisingChannelMap = HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
@@ -787,6 +866,201 @@ class AdvertisingSet(EventEmitter):
self.emit('termination', status) self.emit('termination', status)
# -----------------------------------------------------------------------------
class PeriodicAdvertisingSync(EventEmitter):
class State(Enum):
INIT = 0
PENDING = 1
ESTABLISHED = 2
CANCELLED = 3
ERROR = 4
LOST = 5
TERMINATED = 6
_state: State
sync_handle: Optional[int]
advertiser_address: Address
sid: int
skip: int
sync_timeout: float # Sync timeout, in seconds
filter_duplicates: bool
status: int
advertiser_phy: int
periodic_advertising_interval: int
advertiser_clock_accuracy: int
def __init__(
self,
device: Device,
advertiser_address: Address,
sid: int,
skip: int,
sync_timeout: float,
filter_duplicates: bool,
) -> None:
super().__init__()
self._state = self.State.INIT
self.sync_handle = None
self.device = device
self.advertiser_address = advertiser_address
self.sid = sid
self.skip = skip
self.sync_timeout = sync_timeout
self.filter_duplicates = filter_duplicates
self.status = HCI_SUCCESS
self.advertiser_phy = 0
self.periodic_advertising_interval = 0
self.advertiser_clock_accuracy = 0
self.data_accumulator = b''
@property
def state(self) -> State:
return self._state
@state.setter
def state(self, state: State) -> None:
logger.debug(f'{self} -> {state.name}')
self._state = state
self.emit('state_change')
async def establish(self) -> None:
if self.state != self.State.INIT:
raise InvalidStateError('sync not in init state')
options = HCI_LE_Periodic_Advertising_Create_Sync_Command.Options(0)
if self.filter_duplicates:
options |= (
HCI_LE_Periodic_Advertising_Create_Sync_Command.Options.DUPLICATE_FILTERING_INITIALLY_ENABLED
)
response = await self.device.send_command(
HCI_LE_Periodic_Advertising_Create_Sync_Command(
options=options,
advertising_sid=self.sid,
advertiser_address_type=self.advertiser_address.address_type,
advertiser_address=self.advertiser_address,
skip=self.skip,
sync_timeout=int(self.sync_timeout * 100),
sync_cte_type=0,
)
)
if response.status != HCI_Command_Status_Event.PENDING:
raise HCI_StatusError(response)
self.state = self.State.PENDING
async def terminate(self) -> None:
if self.state in (self.State.INIT, self.State.CANCELLED, self.State.TERMINATED):
return
if self.state == self.State.PENDING:
self.state = self.State.CANCELLED
response = await self.device.send_command(
HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command(),
)
if response.status == HCI_SUCCESS:
if self in self.device.periodic_advertising_syncs:
self.device.periodic_advertising_syncs.remove(self)
return
if self.state in (self.State.ESTABLISHED, self.State.ERROR, self.State.LOST):
self.state = self.State.TERMINATED
await self.device.send_command(
HCI_LE_Periodic_Advertising_Terminate_Sync_Command(
sync_handle=self.sync_handle
)
)
self.device.periodic_advertising_syncs.remove(self)
def on_establishment(
self,
status,
sync_handle,
advertiser_phy,
periodic_advertising_interval,
advertiser_clock_accuracy,
) -> None:
self.status = status
if self.state == self.State.CANCELLED:
# Somehow, we receive an established event after trying to cancel, most
# likely because the cancel command was sent too late, when the sync was
# already established, but before the established event was sent.
# We need to automatically terminate.
logger.debug(
"received established event for cancelled sync, will terminate"
)
self.state = self.State.ESTABLISHED
AsyncRunner.spawn(self.terminate())
return
if status == HCI_SUCCESS:
self.sync_handle = sync_handle
self.advertiser_phy = advertiser_phy
self.periodic_advertising_interval = periodic_advertising_interval
self.advertiser_clock_accuracy = advertiser_clock_accuracy
self.state = self.State.ESTABLISHED
self.emit('establishment')
return
# We don't need to keep a reference anymore
if self in self.device.periodic_advertising_syncs:
self.device.periodic_advertising_syncs.remove(self)
if status == HCI_OPERATION_CANCELLED_BY_HOST_ERROR:
self.state = self.State.CANCELLED
self.emit('cancellation')
return
self.state = self.State.ERROR
self.emit('error')
def on_loss(self):
self.state = self.State.LOST
self.emit('loss')
def on_periodic_advertising_report(self, report) -> None:
self.data_accumulator += report.data
if (
report.data_status
== HCI_LE_Periodic_Advertising_Report_Event.DataStatus.DATA_INCOMPLETE_MORE_TO_COME
):
return
self.emit(
'periodic_advertisement',
PeriodicAdvertisement(
self.advertiser_address,
self.sid,
report.tx_power,
report.rssi,
is_truncated=(
report.data_status
== HCI_LE_Periodic_Advertising_Report_Event.DataStatus.DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME
),
data_bytes=self.data_accumulator,
),
)
self.data_accumulator = b''
def on_biginfo_advertising_report(self, report) -> None:
self.emit(
'biginfo_advertisement',
BIGInfoAdvertisement.from_report(self.advertiser_address, self.sid, report),
)
def __str__(self) -> str:
return (
'PeriodicAdvertisingSync('
f'state={self.state.name}, '
f'sync_handle={self.sync_handle}, '
f'sid={self.sid}, '
f'skip={self.skip}, '
f'filter_duplicates={self.filter_duplicates}'
')'
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class LePhyOptions: class LePhyOptions:
# Coded PHY preference # Coded PHY preference
@@ -959,8 +1233,9 @@ class ScoLink(CompositeEventEmitter):
acl_connection: Connection acl_connection: Connection
handle: int handle: int
link_type: int link_type: int
sink: Optional[Callable[[HCI_SynchronousDataPacket], Any]] = None
def __post_init__(self): def __post_init__(self) -> None:
super().__init__() super().__init__()
async def disconnect( async def disconnect(
@@ -982,8 +1257,9 @@ class CisLink(CompositeEventEmitter):
cis_id: int # CIS ID assigned by Central device cis_id: int # CIS ID assigned by Central device
cig_id: int # CIG ID assigned by Central device cig_id: int # CIG ID assigned by Central device
state: State = State.PENDING state: State = State.PENDING
sink: Optional[Callable[[HCI_IsoDataPacket], Any]] = None
def __post_init__(self): def __post_init__(self) -> None:
super().__init__() super().__init__()
async def disconnect( async def disconnect(
@@ -1252,75 +1528,47 @@ class Connection(CompositeEventEmitter):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@dataclass
class DeviceConfiguration: class DeviceConfiguration:
def __init__(self) -> None: # Setup defaults
# Setup defaults name: str = DEVICE_DEFAULT_NAME
self.name = DEVICE_DEFAULT_NAME address: Address = Address(DEVICE_DEFAULT_ADDRESS)
self.address = Address(DEVICE_DEFAULT_ADDRESS) class_of_device: int = DEVICE_DEFAULT_CLASS_OF_DEVICE
self.class_of_device = DEVICE_DEFAULT_CLASS_OF_DEVICE scan_response_data: bytes = DEVICE_DEFAULT_SCAN_RESPONSE_DATA
self.scan_response_data = DEVICE_DEFAULT_SCAN_RESPONSE_DATA advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
self.advertising_interval_min = DEVICE_DEFAULT_ADVERTISING_INTERVAL advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
self.advertising_interval_max = DEVICE_DEFAULT_ADVERTISING_INTERVAL le_enabled: bool = True
self.le_enabled = True # LE host enable 2nd parameter
# LE host enable 2nd parameter le_simultaneous_enabled: bool = False
self.le_simultaneous_enabled = False classic_enabled: bool = False
self.classic_enabled = False classic_sc_enabled: bool = True
self.classic_sc_enabled = True classic_ssp_enabled: bool = True
self.classic_ssp_enabled = True classic_smp_enabled: bool = True
self.classic_smp_enabled = True classic_accept_any: bool = True
self.classic_accept_any = True connectable: bool = True
self.connectable = True discoverable: bool = True
self.discoverable = True advertising_data: bytes = bytes(
self.advertising_data = bytes( AdvertisingData(
AdvertisingData( [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(DEVICE_DEFAULT_NAME, 'utf-8'))]
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
)
) )
self.irk = bytes(16) # This really must be changed for any level of security )
self.keystore = None irk: bytes = bytes(16) # This really must be changed for any level of security
keystore: Optional[str] = None
address_resolution_offload: bool = False
cis_enabled: bool = False
def __post_init__(self) -> None:
self.gatt_services: List[Dict[str, Any]] = [] self.gatt_services: List[Dict[str, Any]] = []
self.address_resolution_offload = False
self.cis_enabled = False
def load_from_dict(self, config: Dict[str, Any]) -> None: def load_from_dict(self, config: Dict[str, Any]) -> None:
config = copy.deepcopy(config)
# Load simple properties # Load simple properties
self.name = config.get('name', self.name) if address := config.pop('address', None):
if address := config.get('address', None):
self.address = Address(address) self.address = Address(address)
self.class_of_device = config.get('class_of_device', self.class_of_device)
self.advertising_interval_min = config.get(
'advertising_interval', self.advertising_interval_min
)
self.advertising_interval_max = self.advertising_interval_min
self.keystore = config.get('keystore')
self.le_enabled = config.get('le_enabled', self.le_enabled)
self.le_simultaneous_enabled = config.get(
'le_simultaneous_enabled', self.le_simultaneous_enabled
)
self.classic_enabled = config.get('classic_enabled', self.classic_enabled)
self.classic_sc_enabled = config.get(
'classic_sc_enabled', self.classic_sc_enabled
)
self.classic_ssp_enabled = config.get(
'classic_ssp_enabled', self.classic_ssp_enabled
)
self.classic_smp_enabled = config.get(
'classic_smp_enabled', self.classic_smp_enabled
)
self.classic_accept_any = config.get(
'classic_accept_any', self.classic_accept_any
)
self.connectable = config.get('connectable', self.connectable)
self.discoverable = config.get('discoverable', self.discoverable)
self.gatt_services = config.get('gatt_services', self.gatt_services)
self.address_resolution_offload = config.get(
'address_resolution_offload', self.address_resolution_offload
)
self.cis_enabled = config.get('cis_enabled', self.cis_enabled)
# Load or synthesize an IRK # Load or synthesize an IRK
irk = config.get('irk') if irk := config.pop('irk', None):
if irk:
self.irk = bytes.fromhex(irk) self.irk = bytes.fromhex(irk)
elif self.address != Address(DEVICE_DEFAULT_ADDRESS): elif self.address != Address(DEVICE_DEFAULT_ADDRESS):
# Construct an IRK from the address bytes # Construct an IRK from the address bytes
@@ -1332,21 +1580,53 @@ class DeviceConfiguration:
# Fallback - when both IRK and address are not set, randomly generate an IRK. # Fallback - when both IRK and address are not set, randomly generate an IRK.
self.irk = secrets.token_bytes(16) self.irk = secrets.token_bytes(16)
if (name := config.pop('name', None)) is not None:
self.name = name
# Load advertising data # Load advertising data
advertising_data = config.get('advertising_data') if advertising_data := config.pop('advertising_data', None):
if advertising_data:
self.advertising_data = bytes.fromhex(advertising_data) self.advertising_data = bytes.fromhex(advertising_data)
elif config.get('name') is not None: elif name is not None:
self.advertising_data = bytes( self.advertising_data = bytes(
AdvertisingData( AdvertisingData(
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))] [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
) )
) )
def load_from_file(self, filename): # Load advertising interval (for backward compatibility)
if advertising_interval := config.pop('advertising_interval', None):
self.advertising_interval_min = advertising_interval
self.advertising_interval_max = advertising_interval
if (
'advertising_interval_max' in config
or 'advertising_interval_min' in config
):
logger.warning(
'Trying to set both advertising_interval and '
'advertising_interval_min/max, advertising_interval will be'
'ignored.'
)
# Load data in primitive types.
for key, value in config.items():
setattr(self, key, value)
def load_from_file(self, filename: str) -> None:
with open(filename, 'r', encoding='utf-8') as file: with open(filename, 'r', encoding='utf-8') as file:
self.load_from_dict(json.load(file)) self.load_from_dict(json.load(file))
@classmethod
def from_file(cls: Type[Self], filename: str) -> Self:
config = cls()
config.load_from_file(filename)
return config
@classmethod
def from_dict(cls: Type[Self], config: Dict[str, Any]) -> Self:
device_config = cls()
device_config.load_from_dict(config)
return device_config
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Decorators used with the following Device class # Decorators used with the following Device class
@@ -1395,6 +1675,20 @@ def try_with_connection_from_address(function):
return wrapper return wrapper
# Decorator that converts the first argument from a sync handle to a periodic
# advertising sync object
def with_periodic_advertising_sync_from_handle(function):
@functools.wraps(function)
def wrapper(self, sync_handle, *args, **kwargs):
if (sync := self.lookup_periodic_advertising_sync(sync_handle)) is None:
raise ValueError(
f'no periodic advertising sync for handle: 0x{sync_handle:04x}'
)
return function(self, sync, *args, **kwargs)
return wrapper
# Decorator that adds a method to the list of event handlers for host events. # Decorator that adds a method to the list of event handlers for host events.
# This assumes that the method name starts with `on_` # This assumes that the method name starts with `on_`
def host_event_handler(function): def host_event_handler(function):
@@ -1425,6 +1719,7 @@ class Device(CompositeEventEmitter):
Address, List[asyncio.Future[Union[Connection, Tuple[Address, int, int]]]] Address, List[asyncio.Future[Union[Connection, Tuple[Address, int, int]]]]
] ]
advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator] advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator]
periodic_advertising_syncs: List[PeriodicAdvertisingSync]
config: DeviceConfiguration config: DeviceConfiguration
legacy_advertiser: Optional[LegacyAdvertiser] legacy_advertiser: Optional[LegacyAdvertiser]
sco_links: Dict[int, ScoLink] sco_links: Dict[int, ScoLink]
@@ -1470,8 +1765,7 @@ class Device(CompositeEventEmitter):
@classmethod @classmethod
def from_config_file(cls, filename: str) -> Device: def from_config_file(cls, filename: str) -> Device:
config = DeviceConfiguration() config = DeviceConfiguration.from_file(filename)
config.load_from_file(filename)
return cls(config=config) return cls(config=config)
@classmethod @classmethod
@@ -1488,8 +1782,7 @@ class Device(CompositeEventEmitter):
def from_config_file_with_hci( def from_config_file_with_hci(
cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
) -> Device: ) -> Device:
config = DeviceConfiguration() config = DeviceConfiguration.from_file(filename)
config.load_from_file(filename)
return cls.from_config_with_hci(config, hci_source, hci_sink) return cls.from_config_with_hci(config, hci_source, hci_sink)
def __init__( def __init__(
@@ -1512,6 +1805,7 @@ class Device(CompositeEventEmitter):
[l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS] [l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS]
) )
self.advertisement_accumulators = {} # Accumulators, by address self.advertisement_accumulators = {} # Accumulators, by address
self.periodic_advertising_syncs = []
self.scanning = False self.scanning = False
self.scanning_is_passive = False self.scanning_is_passive = False
self.discovering = False self.discovering = False
@@ -1529,6 +1823,12 @@ class Device(CompositeEventEmitter):
Address.ANY: [] Address.ANY: []
} # Futures, by BD address OR [Futures] for Address.ANY } # Futures, by BD address OR [Futures] for Address.ANY
# In Python <= 3.9 + Rust Runtime, asyncio.Lock cannot be properly initiated.
if sys.version_info >= (3, 10):
self._cis_lock = asyncio.Lock()
else:
self._cis_lock = AsyncExitStack()
# Own address type cache # Own address type cache
self.connect_own_address_type = None self.connect_own_address_type = None
@@ -1556,6 +1856,7 @@ class Device(CompositeEventEmitter):
# Extended advertising. # Extended advertising.
self.extended_advertising_sets: Dict[int, AdvertisingSet] = {} self.extended_advertising_sets: Dict[int, AdvertisingSet] = {}
self.connecting_extended_advertising_sets: Dict[int, AdvertisingSet] = {}
# Legacy advertising. # Legacy advertising.
# The advertising and scan response data, as well as the advertising interval # The advertising and scan response data, as well as the advertising interval
@@ -1688,6 +1989,18 @@ class Device(CompositeEventEmitter):
return None return None
def lookup_periodic_advertising_sync(
self, sync_handle: int
) -> Optional[PeriodicAdvertisingSync]:
return next(
(
sync
for sync in self.periodic_advertising_syncs
if sync.sync_handle == sync_handle
),
None,
)
@deprecated("Please use create_l2cap_server()") @deprecated("Please use create_l2cap_server()")
def register_l2cap_server(self, psm, server) -> int: def register_l2cap_server(self, psm, server) -> int:
return self.l2cap_channel_manager.register_server(psm, server) return self.l2cap_channel_manager.register_server(psm, server)
@@ -2184,7 +2497,7 @@ class Device(CompositeEventEmitter):
# controller. # controller.
await self.send_command( await self.send_command(
HCI_LE_Remove_Advertising_Set_Command( HCI_LE_Remove_Advertising_Set_Command(
advertising_handle=advertising_data advertising_handle=advertising_handle
), ),
check_result=False, check_result=False,
) )
@@ -2350,6 +2663,116 @@ class Device(CompositeEventEmitter):
if advertisement := accumulator.update(report): if advertisement := accumulator.update(report):
self.emit('advertisement', advertisement) self.emit('advertisement', advertisement)
async def create_periodic_advertising_sync(
self,
advertiser_address: Address,
sid: int,
skip: int = DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_SKIP,
sync_timeout: float = DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_TIMEOUT,
filter_duplicates: bool = False,
) -> PeriodicAdvertisingSync:
# Check that there isn't already an equivalent entry
if any(
sync.advertiser_address == advertiser_address and sync.sid == sid
for sync in self.periodic_advertising_syncs
):
raise ValueError("equivalent entry already created")
# Create a new entry
sync = PeriodicAdvertisingSync(
device=self,
advertiser_address=advertiser_address,
sid=sid,
skip=skip,
sync_timeout=sync_timeout,
filter_duplicates=filter_duplicates,
)
self.periodic_advertising_syncs.append(sync)
# Check if any sync should be started
await self._update_periodic_advertising_syncs()
return sync
async def _update_periodic_advertising_syncs(self) -> None:
# Check if there's already a pending sync
if any(
sync.state == PeriodicAdvertisingSync.State.PENDING
for sync in self.periodic_advertising_syncs
):
logger.debug("at least one sync pending, nothing to update yet")
return
# Start the next sync that's waiting to be started
if ready := next(
(
sync
for sync in self.periodic_advertising_syncs
if sync.state == PeriodicAdvertisingSync.State.INIT
),
None,
):
await ready.establish()
return
@host_event_handler
def on_periodic_advertising_sync_establishment(
self,
status: int,
sync_handle: int,
advertising_sid: int,
advertiser_address: Address,
advertiser_phy: int,
periodic_advertising_interval: int,
advertiser_clock_accuracy: int,
) -> None:
for periodic_advertising_sync in self.periodic_advertising_syncs:
if (
periodic_advertising_sync.advertiser_address == advertiser_address
and periodic_advertising_sync.sid == advertising_sid
):
periodic_advertising_sync.on_establishment(
status,
sync_handle,
advertiser_phy,
periodic_advertising_interval,
advertiser_clock_accuracy,
)
AsyncRunner.spawn(self._update_periodic_advertising_syncs())
return
logger.warning(
"periodic advertising sync establishment for unknown address/sid"
)
@host_event_handler
@with_periodic_advertising_sync_from_handle
def on_periodic_advertising_sync_loss(
self, periodic_advertising_sync: PeriodicAdvertisingSync
):
periodic_advertising_sync.on_loss()
@host_event_handler
@with_periodic_advertising_sync_from_handle
def on_periodic_advertising_report(
self,
periodic_advertising_sync: PeriodicAdvertisingSync,
report: HCI_LE_Periodic_Advertising_Report_Event,
):
periodic_advertising_sync.on_periodic_advertising_report(report)
@host_event_handler
@with_periodic_advertising_sync_from_handle
def on_biginfo_advertising_report(
self,
periodic_advertising_sync: PeriodicAdvertisingSync,
report: HCI_LE_BIGInfo_Advertising_Report_Event,
):
periodic_advertising_sync.on_biginfo_advertising_report(report)
async def start_discovery(self, auto_restart: bool = True) -> None: async def start_discovery(self, auto_restart: bool = True) -> None:
await self.send_command( await self.send_command(
HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE), HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE),
@@ -3402,49 +3825,71 @@ class Device(CompositeEventEmitter):
for cis_handle, _ in cis_acl_pairs for cis_handle, _ in cis_acl_pairs
} }
@watcher.on(self, 'cis_establishment')
def on_cis_establishment(cis_link: CisLink) -> None: def on_cis_establishment(cis_link: CisLink) -> None:
if pending_future := pending_cis_establishments.get(cis_link.handle): if pending_future := pending_cis_establishments.get(cis_link.handle):
pending_future.set_result(cis_link) pending_future.set_result(cis_link)
result = await self.send_command( def on_cis_establishment_failure(cis_handle: int, status: int) -> None:
if pending_future := pending_cis_establishments.get(cis_handle):
pending_future.set_exception(HCI_Error(status))
watcher.on(self, 'cis_establishment', on_cis_establishment)
watcher.on(self, 'cis_establishment_failure', on_cis_establishment_failure)
await self.send_command(
HCI_LE_Create_CIS_Command( HCI_LE_Create_CIS_Command(
cis_connection_handle=[p[0] for p in cis_acl_pairs], cis_connection_handle=[p[0] for p in cis_acl_pairs],
acl_connection_handle=[p[1] for p in cis_acl_pairs], acl_connection_handle=[p[1] for p in cis_acl_pairs],
), ),
check_result=True,
) )
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warning(
'HCI_LE_Create_CIS_Command failed: '
f'{HCI_Constant.error_name(result.status)}'
)
raise HCI_StatusError(result)
return await asyncio.gather(*pending_cis_establishments.values()) return await asyncio.gather(*pending_cis_establishments.values())
# [LE only] # [LE only]
@experimental('Only for testing.') @experimental('Only for testing.')
async def accept_cis_request(self, handle: int) -> CisLink: async def accept_cis_request(self, handle: int) -> CisLink:
result = await self.send_command( """[LE Only] Accepts an incoming CIS request.
HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
)
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warning(
'HCI_LE_Accept_CIS_Request_Command failed: '
f'{HCI_Constant.error_name(result.status)}'
)
raise HCI_StatusError(result)
pending_cis_establishment = asyncio.get_running_loop().create_future() When the specified CIS handle is already created, this method returns the
existed CIS link object immediately.
with closing(EventWatcher()) as watcher: Args:
handle: CIS handle to accept.
@watcher.on(self, 'cis_establishment') Returns:
def on_cis_establishment(cis_link: CisLink) -> None: CIS link object on the given handle.
if cis_link.handle == handle: """
pending_cis_establishment.set_result(cis_link) if not (cis_link := self.cis_links.get(handle)):
raise InvalidStateError(f'No pending CIS request of handle {handle}')
return await pending_cis_establishment # There might be multiple ASE sharing a CIS channel.
# If one of them has accepted the request, the others should just leverage it.
async with self._cis_lock:
if cis_link.state == CisLink.State.ESTABLISHED:
return cis_link
with closing(EventWatcher()) as watcher:
pending_establishment = asyncio.get_running_loop().create_future()
def on_establishment() -> None:
pending_establishment.set_result(None)
def on_establishment_failure(status: int) -> None:
pending_establishment.set_exception(HCI_Error(status))
watcher.on(cis_link, 'establishment', on_establishment)
watcher.on(cis_link, 'establishment_failure', on_establishment_failure)
await self.send_command(
HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
check_result=True,
)
await pending_establishment
return cis_link
# Mypy believes this is reachable when context is an ExitStack.
raise InvalidStateError('Unreachable')
# [LE only] # [LE only]
@experimental('Only for testing.') @experimental('Only for testing.')
@@ -3453,15 +3898,10 @@ class Device(CompositeEventEmitter):
handle: int, handle: int,
reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR, reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
) -> None: ) -> None:
result = await self.send_command( await self.send_command(
HCI_LE_Reject_CIS_Request_Command(connection_handle=handle, reason=reason), HCI_LE_Reject_CIS_Request_Command(connection_handle=handle, reason=reason),
check_result=True,
) )
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warning(
'HCI_LE_Reject_CIS_Request_Command failed: '
f'{HCI_Constant.error_name(result.status)}'
)
raise HCI_StatusError(result)
async def get_remote_le_features(self, connection: Connection) -> LeFeatureMask: async def get_remote_le_features(self, connection: Connection) -> LeFeatureMask:
"""[LE Only] Reads remote LE supported features. """[LE Only] Reads remote LE supported features.
@@ -3481,11 +3921,17 @@ class Device(CompositeEventEmitter):
if handle == connection.handle: if handle == connection.handle:
read_feature_future.set_result(LeFeatureMask(features)) read_feature_future.set_result(LeFeatureMask(features))
def on_failure(handle: int, status: int):
if handle == connection.handle:
read_feature_future.set_exception(HCI_Error(status))
watcher.on(self.host, 'le_remote_features', on_le_remote_features) watcher.on(self.host, 'le_remote_features', on_le_remote_features)
watcher.on(self.host, 'le_remote_features_failure', on_failure)
await self.send_command( await self.send_command(
HCI_LE_Read_Remote_Features_Command( HCI_LE_Read_Remote_Features_Command(
connection_handle=connection.handle connection_handle=connection.handle
), ),
check_result=True,
) )
return await read_feature_future return await read_feature_future
@@ -3564,14 +4010,28 @@ class Device(CompositeEventEmitter):
) )
return return
if not (connection := self.lookup_connection(connection_handle)): if connection := self.lookup_connection(connection_handle):
logger.warning(f'no connection for handle 0x{connection_handle:04x}') # We have already received the connection complete event.
self._complete_le_extended_advertising_connection(
connection, advertising_set
)
return return
# Associate the connection handle with the advertising set, the connection
# will complete later.
logger.debug(
f'the connection with handle {connection_handle:04X} will complete later'
)
self.connecting_extended_advertising_sets[connection_handle] = advertising_set
def _complete_le_extended_advertising_connection(
self, connection: Connection, advertising_set: AdvertisingSet
) -> None:
# Update the connection address. # Update the connection address.
connection.self_address = ( connection.self_address = (
advertising_set.random_address advertising_set.random_address
if advertising_set.advertising_parameters.own_address_type if advertising_set.random_address is not None
and advertising_set.advertising_parameters.own_address_type
in (OwnAddressType.RANDOM, OwnAddressType.RESOLVABLE_OR_RANDOM) in (OwnAddressType.RANDOM, OwnAddressType.RESOLVABLE_OR_RANDOM)
else self.public_address else self.public_address
) )
@@ -3660,7 +4120,6 @@ class Device(CompositeEventEmitter):
# We were connected via a legacy advertisement. # We were connected via a legacy advertisement.
if self.legacy_advertiser: if self.legacy_advertiser:
own_address_type = self.legacy_advertiser.own_address_type own_address_type = self.legacy_advertiser.own_address_type
self.legacy_advertiser = None
else: else:
# This should not happen, but just in case, pick a default. # This should not happen, but just in case, pick a default.
logger.warning("connection without an advertiser") logger.warning("connection without an advertiser")
@@ -3691,19 +4150,28 @@ class Device(CompositeEventEmitter):
) )
self.connections[connection_handle] = connection self.connections[connection_handle] = connection
if ( if role == HCI_PERIPHERAL_ROLE and self.legacy_advertiser:
role == HCI_PERIPHERAL_ROLE if self.legacy_advertiser.auto_restart:
and self.legacy_advertiser connection.once(
and self.legacy_advertiser.auto_restart 'disconnection',
): lambda _: self.abort_on('flush', self.legacy_advertiser.start()),
connection.once( )
'disconnection', else:
lambda _: self.abort_on('flush', self.legacy_advertiser.start()), self.legacy_advertiser = None
)
if role == HCI_CENTRAL_ROLE or not self.supports_le_extended_advertising: if role == HCI_CENTRAL_ROLE or not self.supports_le_extended_advertising:
# We can emit now, we have all the info we need # We can emit now, we have all the info we need
self._emit_le_connection(connection) self._emit_le_connection(connection)
return
if role == HCI_PERIPHERAL_ROLE and self.supports_le_extended_advertising:
if advertising_set := self.connecting_extended_advertising_sets.pop(
connection_handle, None
):
# We have already received the advertising set termination event.
self._complete_le_extended_advertising_connection(
connection, advertising_set
)
@host_event_handler @host_event_handler
def on_connection_failure(self, transport, peer_address, error_code): def on_connection_failure(self, transport, peer_address, error_code):
@@ -4107,8 +4575,8 @@ class Device(CompositeEventEmitter):
@host_event_handler @host_event_handler
@experimental('Only for testing') @experimental('Only for testing')
def on_sco_packet(self, sco_handle: int, packet: HCI_SynchronousDataPacket) -> None: def on_sco_packet(self, sco_handle: int, packet: HCI_SynchronousDataPacket) -> None:
if sco_link := self.sco_links.get(sco_handle): if (sco_link := self.sco_links.get(sco_handle)) and sco_link.sink:
sco_link.emit('pdu', packet) sco_link.sink(packet)
# [LE only] # [LE only]
@host_event_handler @host_event_handler
@@ -4164,15 +4632,15 @@ class Device(CompositeEventEmitter):
def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None: def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None:
logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***') logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***')
if cis_link := self.cis_links.pop(cis_handle): if cis_link := self.cis_links.pop(cis_handle):
cis_link.emit('establishment_failure') cis_link.emit('establishment_failure', status)
self.emit('cis_establishment_failure', cis_handle, status) self.emit('cis_establishment_failure', cis_handle, status)
# [LE only] # [LE only]
@host_event_handler @host_event_handler
@experimental('Only for testing') @experimental('Only for testing')
def on_iso_packet(self, handle: int, packet: HCI_IsoDataPacket) -> None: def on_iso_packet(self, handle: int, packet: HCI_IsoDataPacket) -> None:
if cis_link := self.cis_links.get(handle): if (cis_link := self.cis_links.get(handle)) and cis_link.sink:
cis_link.emit('pdu', packet) cis_link.sink(packet)
@host_event_handler @host_event_handler
@with_connection_from_handle @with_connection_from_handle
+264 -37
View File
@@ -23,11 +23,11 @@ import functools
import logging import logging
import secrets import secrets
import struct import struct
from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union, ClassVar
from bumble import crypto from bumble import crypto
from .colors import color from bumble.colors import color
from .core import ( from bumble.core import (
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
AdvertisingData, AdvertisingData,
DeviceClass, DeviceClass,
@@ -36,6 +36,7 @@ from .core import (
name_or_number, name_or_number,
padded_bytes, padded_bytes,
) )
from bumble.utils import OpenIntEnum
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -1104,7 +1105,7 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
# LE Supported Features # LE Supported Features
# See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT # See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
class LeFeature(enum.IntEnum): class LeFeature(OpenIntEnum):
LE_ENCRYPTION = 0 LE_ENCRYPTION = 0
CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1 CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1
EXTENDED_REJECT_INDICATION = 2 EXTENDED_REJECT_INDICATION = 2
@@ -1380,7 +1381,7 @@ class LmpFeatureMask(enum.IntFlag):
STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)} STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)}
class CodecID(enum.IntEnum): class CodecID(OpenIntEnum):
# fmt: off # fmt: off
U_LOG = 0x00 U_LOG = 0x00
A_LOG = 0x01 A_LOG = 0x01
@@ -1967,6 +1968,9 @@ class Address:
def __str__(self): def __str__(self):
return self.to_string() return self.to_string()
def __repr__(self):
return f'Address({self.to_string(False)}/{self.address_type_name(self.address_type)})'
# Predefined address values # Predefined address values
Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS) Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS)
@@ -2003,7 +2007,7 @@ class HCI_Packet:
Abstract Base class for HCI packets Abstract Base class for HCI packets
''' '''
hci_packet_type: int hci_packet_type: ClassVar[int]
@staticmethod @staticmethod
def from_bytes(packet: bytes) -> HCI_Packet: def from_bytes(packet: bytes) -> HCI_Packet:
@@ -4452,6 +4456,80 @@ class HCI_LE_Extended_Create_Connection_Command(HCI_Command):
) )
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
(
'options',
{
'size': 1,
'mapper': lambda x: HCI_LE_Periodic_Advertising_Create_Sync_Command.Options(
x
).name,
},
),
('advertising_sid', 1),
('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
('advertiser_address', Address.parse_address_preceded_by_type),
('skip', 2),
('sync_timeout', 2),
(
'sync_cte_type',
{
'size': 1,
'mapper': lambda x: HCI_LE_Periodic_Advertising_Create_Sync_Command.CteType(
x
).name,
},
),
]
)
class HCI_LE_Periodic_Advertising_Create_Sync_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.67 LE Periodic Advertising Create Sync command
'''
class Options(enum.IntFlag):
USE_PERIODIC_ADVERTISER_LIST = 1 << 0
REPORTING_INITIALLY_DISABLED = 1 << 1
DUPLICATE_FILTERING_INITIALLY_ENABLED = 1 << 2
class CteType(enum.IntFlag):
DO_NOT_SYNC_TO_PACKETS_WITH_AN_AOA_CONSTANT_TONE_EXTENSION = 1 << 0
DO_NOT_SYNC_TO_PACKETS_WITH_AN_AOD_CONSTANT_TONE_EXTENSION_1US = 1 << 1
DO_NOT_SYNC_TO_PACKETS_WITH_AN_AOD_CONSTANT_TONE_EXTENSION_2US = 1 << 2
DO_NOT_SYNC_TO_PACKETS_WITH_A_TYPE_3_CONSTANT_TONE_EXTENSION = 1 << 3
DO_NOT_SYNC_TO_PACKETS_WITHOUT_A_CONSTANT_TONE_EXTENSION = 1 << 4
# -----------------------------------------------------------------------------
@HCI_Command.command()
class HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.68 LE Periodic Advertising Create Sync Cancel Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command([('sync_handle', 2)])
class HCI_LE_Periodic_Advertising_Terminate_Sync_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.69 LE Periodic Advertising Terminate Sync Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command([('sync_handle', 2), ('enable', 1)])
class HCI_LE_Set_Periodic_Advertising_Receive_Enable_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.88 LE Set Periodic Advertising Receive Enable Command
'''
class Enable(enum.IntFlag):
REPORTING_ENABLED = 1 << 0
DUPLICATE_FILTERING_ENABLED = 1 << 1
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Command.command( @HCI_Command.command(
[ [
@@ -4487,14 +4565,6 @@ class HCI_LE_Set_Privacy_Mode_Command(HCI_Command):
return name_or_number(cls.PRIVACY_MODE_NAMES, privacy_mode) return name_or_number(cls.PRIVACY_MODE_NAMES, privacy_mode)
# -----------------------------------------------------------------------------
@HCI_Command.command([('bit_number', 1), ('bit_value', 1)])
class HCI_LE_Set_Host_Feature_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.115 LE Set Host Feature Command
'''
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Command.command( @HCI_Command.command(
fields=[ fields=[
@@ -4655,6 +4725,14 @@ class HCI_LE_Remove_ISO_Data_Path_Command(HCI_Command):
data_path_direction: int data_path_direction: int
# -----------------------------------------------------------------------------
@HCI_Command.command([('bit_number', 1), ('bit_value', 1)])
class HCI_LE_Set_Host_Feature_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.115 LE Set Host Feature Command
'''
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# HCI Events # HCI Events
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -5271,6 +5349,142 @@ HCI_LE_Meta_Event.subevent_classes[HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT] = (
) )
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('sync_handle', 2),
('advertising_sid', 1),
('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
('advertiser_address', Address.parse_address_preceded_by_type),
('advertiser_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
('periodic_advertising_interval', 2),
('advertiser_clock_accuracy', 1),
]
)
class HCI_LE_Periodic_Advertising_Sync_Established_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.14 LE Periodic Advertising Sync Established Event
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('sync_handle', 2),
('advertising_sid', 1),
('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
('advertiser_address', Address.parse_address_preceded_by_type),
('advertiser_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
('periodic_advertising_interval', 2),
('advertiser_clock_accuracy', 1),
('num_subevents', 1),
('subevent_interval', 1),
('response_slot_delay', 1),
('response_slot_spacing', 1),
]
)
class HCI_LE_Periodic_Advertising_Sync_Established_V2_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.14 LE Periodic Advertising Sync Established Event
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('sync_handle', 2),
('tx_power', -1),
('rssi', -1),
(
'cte_type',
{
'size': 1,
'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.CteType(
x
).name,
},
),
(
'data_status',
{
'size': 1,
'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.DataStatus(
x
).name,
},
),
('data', 'v'),
]
)
class HCI_LE_Periodic_Advertising_Report_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.15 LE Periodic Advertising Report Event
'''
TX_POWER_INFORMATION_NOT_AVAILABLE = 0x7F
RSSI_NOT_AVAILABLE = 0x7F
class CteType(OpenIntEnum):
AOA_CONSTANT_TONE_EXTENSION = 0x00
AOD_CONSTANT_TONE_EXTENSION_1US = 0x01
AOD_CONSTANT_TONE_EXTENSION_2US = 0x02
NO_CONSTANT_TONE_EXTENSION = 0xFF
class DataStatus(OpenIntEnum):
DATA_COMPLETE = 0x00
DATA_INCOMPLETE_MORE_TO_COME = 0x01
DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME = 0x02
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('sync_handle', 2),
('tx_power', -1),
('rssi', -1),
(
'cte_type',
{
'size': 1,
'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.CteType(
x
).name,
},
),
('periodic_event_counter', 2),
('subevent', 1),
(
'data_status',
{
'size': 1,
'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.DataStatus(
x
).name,
},
),
('data', 'v'),
]
)
class HCI_LE_Periodic_Advertising_Report_V2_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.15 LE Periodic Advertising Report Event
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('sync_handle', 2),
]
)
class HCI_LE_Periodic_Advertising_Sync_Lost_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.16 LE Periodic Advertising Sync Lost Event
'''
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event( @HCI_LE_Meta_Event.event(
[ [
@@ -5336,6 +5550,30 @@ class HCI_LE_CIS_Request_Event(HCI_LE_Meta_Event):
''' '''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('sync_handle', 2),
('num_bis', 1),
('nse', 1),
('iso_interval', 2),
('bn', 1),
('pto', 1),
('irc', 1),
('max_pdu', 2),
('sdu_interval', 3),
('max_sdu', 2),
('phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
('framing', 1),
('encryption', 1),
]
)
class HCI_LE_BIGInfo_Advertising_Report_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.34 LE BIGInfo Advertising Report Event
'''
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Event.event([('status', STATUS_SPEC)]) @HCI_Event.event([('status', STATUS_SPEC)])
class HCI_Inquiry_Complete_Event(HCI_Event): class HCI_Inquiry_Complete_Event(HCI_Event):
@@ -6192,12 +6430,23 @@ class HCI_SynchronousDataPacket(HCI_Packet):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@dataclasses.dataclass
class HCI_IsoDataPacket(HCI_Packet): class HCI_IsoDataPacket(HCI_Packet):
''' '''
See Bluetooth spec @ 5.4.5 HCI ISO Data Packets See Bluetooth spec @ 5.4.5 HCI ISO Data Packets
''' '''
hci_packet_type = HCI_ISO_DATA_PACKET hci_packet_type: ClassVar[int] = HCI_ISO_DATA_PACKET
connection_handle: int
data_total_length: int
iso_sdu_fragment: bytes
pb_flag: int
ts_flag: int = 0
time_stamp: Optional[int] = None
packet_sequence_number: Optional[int] = None
iso_sdu_length: Optional[int] = None
packet_status_flag: Optional[int] = None
@staticmethod @staticmethod
def from_bytes(packet: bytes) -> HCI_IsoDataPacket: def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
@@ -6241,28 +6490,6 @@ class HCI_IsoDataPacket(HCI_Packet):
iso_sdu_fragment=iso_sdu_fragment, iso_sdu_fragment=iso_sdu_fragment,
) )
def __init__(
self,
connection_handle: int,
pb_flag: int,
ts_flag: int,
data_total_length: int,
time_stamp: Optional[int],
packet_sequence_number: Optional[int],
iso_sdu_length: Optional[int],
packet_status_flag: Optional[int],
iso_sdu_fragment: bytes,
) -> None:
self.connection_handle = connection_handle
self.pb_flag = pb_flag
self.ts_flag = ts_flag
self.data_total_length = data_total_length
self.time_stamp = time_stamp
self.packet_sequence_number = packet_sequence_number
self.iso_sdu_length = iso_sdu_length
self.packet_status_flag = packet_status_flag
self.iso_sdu_fragment = iso_sdu_fragment
def __bytes__(self) -> bytes: def __bytes__(self) -> bytes:
return self.to_bytes() return self.to_bytes()
+56 -19
View File
@@ -50,7 +50,7 @@ from bumble.core import (
ProtocolError, ProtocolError,
BT_GENERIC_AUDIO_SERVICE, BT_GENERIC_AUDIO_SERVICE,
BT_HANDSFREE_SERVICE, BT_HANDSFREE_SERVICE,
BT_HEADSET_AUDIO_GATEWAY_SERVICE, BT_HANDSFREE_AUDIO_GATEWAY_SERVICE,
BT_L2CAP_PROTOCOL_ID, BT_L2CAP_PROTOCOL_ID,
BT_RFCOMM_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID,
) )
@@ -951,7 +951,7 @@ class HfProtocol(pyee.EventEmitter):
self.supported_ag_call_hold_operations = [ self.supported_ag_call_hold_operations = [
CallHoldOperation(operation.decode()) CallHoldOperation(operation.decode())
for operation in response.parameters for operation in response.parameters[0]
] ]
# 4.2.1.4 HF Indicators # 4.2.1.4 HF Indicators
@@ -1081,8 +1081,9 @@ class HfProtocol(pyee.EventEmitter):
mode=CallInfoMode(int(response.parameters[3])), mode=CallInfoMode(int(response.parameters[3])),
multi_party=CallInfoMultiParty(int(response.parameters[4])), multi_party=CallInfoMultiParty(int(response.parameters[4])),
) )
if len(response.parameters) >= 6:
call_info.number = response.parameters[5].decode()
if len(response.parameters) >= 7: if len(response.parameters) >= 7:
call_info.number = response.parameters[5]
call_info.type = int(response.parameters[6]) call_info.type = int(response.parameters[6])
calls.append(call_info) calls.append(call_info)
return calls return calls
@@ -1155,7 +1156,7 @@ class AgProtocol(pyee.EventEmitter):
active_codec: AudioCodec active_codec: AudioCodec
hf_indicator: When HF update their indicators, notify the new state. hf_indicator: When HF update their indicators, notify the new state.
Args: Args:
hf_indicator: HfIndicator hf_indicator: HfIndicatorState
codec_connection_request: Emit when HF sends AT+BCC to request codec connection. codec_connection_request: Emit when HF sends AT+BCC to request codec connection.
answer: Emit when HF sends ATA to answer phone call. answer: Emit when HF sends ATA to answer phone call.
hang_up: Emit when HF sends AT+CHUP to hang up phone call. hang_up: Emit when HF sends AT+CHUP to hang up phone call.
@@ -1167,7 +1168,12 @@ class AgProtocol(pyee.EventEmitter):
Args: Args:
operation: CallHoldOperation operation: CallHoldOperation
call_index: Optional[int] call_index: Optional[int]
speaker_volume: Emitted when AG update speaker volume autonomously.
Args:
volume: Int
microphone_volume: Emitted when AG update microphone volume autonomously.
Args:
volume: Int
""" """
supported_hf_features: int supported_hf_features: int
@@ -1190,6 +1196,7 @@ class AgProtocol(pyee.EventEmitter):
inband_ringtone_enabled: bool inband_ringtone_enabled: bool
cme_error_enabled: bool cme_error_enabled: bool
cli_notification_enabled: bool cli_notification_enabled: bool
call_waiting_enabled: bool
_remained_slc_setup_features: Set[HfFeature] _remained_slc_setup_features: Set[HfFeature]
def __init__(self, dlc: rfcomm.DLC, configuration: AgConfiguration) -> None: def __init__(self, dlc: rfcomm.DLC, configuration: AgConfiguration) -> None:
@@ -1217,6 +1224,7 @@ class AgProtocol(pyee.EventEmitter):
self.indicator_report_enabled = False self.indicator_report_enabled = False
self.cme_error_enabled = False self.cme_error_enabled = False
self.cli_notification_enabled = False self.cli_notification_enabled = False
self.call_waiting_enabled = False
self.hf_indicators = collections.OrderedDict() self.hf_indicators = collections.OrderedDict()
@@ -1422,9 +1430,11 @@ class AgProtocol(pyee.EventEmitter):
return return
self.send_response( self.send_response(
'+CHLD:' '+CHLD: ({})'.format(
+ ','.join( ','.join(
operation.value for operation in self.supported_ag_call_hold_operations operation.value
for operation in self.supported_ag_call_hold_operations
)
) )
) )
self.send_ok() self.send_ok()
@@ -1462,7 +1472,12 @@ class AgProtocol(pyee.EventEmitter):
display: Optional[bytes] = None, display: Optional[bytes] = None,
indicator: bytes = b'', indicator: bytes = b'',
) -> None: ) -> None:
if int(mode) != 3 or keypad or display or int(indicator) not in (0, 1): if (
int(mode) != 3
or (keypad and int(keypad))
or (display and int(display))
or int(indicator) not in (0, 1)
):
logger.error( logger.error(
f'Unexpected values: mode={mode!r}, keypad={keypad!r}, ' f'Unexpected values: mode={mode!r}, keypad={keypad!r}, '
f'display={display!r}, indicator={indicator!r}' f'display={display!r}, indicator={indicator!r}'
@@ -1476,6 +1491,10 @@ class AgProtocol(pyee.EventEmitter):
self.cme_error_enabled = bool(int(enabled)) self.cme_error_enabled = bool(int(enabled))
self.send_ok() self.send_ok()
def _on_ccwa(self, enabled: bytes) -> None:
self.call_waiting_enabled = bool(int(enabled))
self.send_ok()
def _on_bind(self, *args) -> None: def _on_bind(self, *args) -> None:
if not self.supports_ag_feature(AgFeature.HF_INDICATORS): if not self.supports_ag_feature(AgFeature.HF_INDICATORS):
self.send_error() self.send_error()
@@ -1557,15 +1576,16 @@ class AgProtocol(pyee.EventEmitter):
def _on_clcc(self) -> None: def _on_clcc(self) -> None:
for call in self.calls: for call in self.calls:
number_text = f',\"{call.number}\"' if call.number is not None else ''
type_text = f',{call.type}' if call.type is not None else ''
response = ( response = (
f'+CLCC: {call.index}' f'+CLCC: {call.index}'
f',{call.direction.value}' f',{call.direction.value}'
f',{call.status.value}' f',{call.status.value}'
f',{call.mode.value}' f',{call.mode.value}'
f',{call.multi_party.value}' f',{call.multi_party.value}'
f',\"{call.number}\"' f'{number_text}'
if call.number is not None f'{type_text}'
else '' f',{call.type}' if call.type is not None else ''
) )
self.send_response(response) self.send_response(response)
self.send_ok() self.send_ok()
@@ -1574,6 +1594,15 @@ class AgProtocol(pyee.EventEmitter):
if not self.supports_hf_feature(HfFeature.CLI_PRESENTATION_CAPABILITY): if not self.supports_hf_feature(HfFeature.CLI_PRESENTATION_CAPABILITY):
logger.error('Remote doesn not support CLI but sends AT+CLIP') logger.error('Remote doesn not support CLI but sends AT+CLIP')
self.cli_notification_enabled = True if enabled == b'1' else False self.cli_notification_enabled = True if enabled == b'1' else False
self.send_ok()
def _on_vgs(self, level: bytes) -> None:
self.emit('speaker_volume', int(level))
self.send_ok()
def _on_vgm(self, level: bytes) -> None:
self.emit('microphone_volume', int(level))
self.send_ok()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -1757,7 +1786,7 @@ def make_ag_sdp_records(
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
sdp.DataElement.sequence( sdp.DataElement.sequence(
[ [
sdp.DataElement.uuid(BT_HEADSET_AUDIO_GATEWAY_SERVICE), sdp.DataElement.uuid(BT_HANDSFREE_AUDIO_GATEWAY_SERVICE),
sdp.DataElement.uuid(BT_GENERIC_AUDIO_SERVICE), sdp.DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
] ]
), ),
@@ -1784,7 +1813,7 @@ def make_ag_sdp_records(
[ [
sdp.DataElement.sequence( sdp.DataElement.sequence(
[ [
sdp.DataElement.uuid(BT_HEADSET_AUDIO_GATEWAY_SERVICE), sdp.DataElement.uuid(BT_HANDSFREE_AUDIO_GATEWAY_SERVICE),
sdp.DataElement.unsigned_integer_16(version), sdp.DataElement.unsigned_integer_16(version),
] ]
) )
@@ -1816,6 +1845,7 @@ async def find_hf_sdp_record(
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID, sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID, sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
], ],
) )
for attribute_lists in search_result: for attribute_lists in search_result:
@@ -1835,10 +1865,17 @@ async def find_hf_sdp_record(
version = ProfileVersion(profile_descriptor_list[0].value[1].value) version = ProfileVersion(profile_descriptor_list[0].value[1].value)
elif attribute.id == sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID: elif attribute.id == sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID:
features = HfSdpFeature(attribute.value.value) features = HfSdpFeature(attribute.value.value)
if not channel or not version or features is None: elif attribute.id == sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
logger.warning(f"Bad result {attribute_lists}.") class_id_list = attribute.value.value
return None uuid = class_id_list[0].value
return (channel, version, features) # AG record may also contain HF UUID in its profile descriptor list.
# If found, skip this record.
if uuid == BT_HANDSFREE_AUDIO_GATEWAY_SERVICE:
channel, version, features = (None, None, None)
break
if channel is not None and version is not None and features is not None:
return (channel, version, features)
return None return None
@@ -1855,7 +1892,7 @@ async def find_ag_sdp_record(
""" """
async with sdp.Client(connection) as sdp_client: async with sdp.Client(connection) as sdp_client:
search_result = await sdp_client.search_attributes( search_result = await sdp_client.search_attributes(
uuids=[BT_HEADSET_AUDIO_GATEWAY_SERVICE], uuids=[BT_HANDSFREE_AUDIO_GATEWAY_SERVICE],
attribute_ids=[ attribute_ids=[
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID, sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+31 -4
View File
@@ -721,14 +721,16 @@ class Host(AbortableEventEmitter):
for connection_handle, num_completed_packets in zip( for connection_handle, num_completed_packets in zip(
event.connection_handles, event.num_completed_packets event.connection_handles, event.num_completed_packets
): ):
if not (connection := self.connections.get(connection_handle)): 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)
):
logger.warning( logger.warning(
'received packet completion event for unknown handle ' 'received packet completion event for unknown handle '
f'0x{connection_handle:04X}' f'0x{connection_handle:04X}'
) )
continue
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
# Classic only # Classic only
def on_hci_connection_request_event(self, event): def on_hci_connection_request_event(self, event):
@@ -785,6 +787,10 @@ class Host(AbortableEventEmitter):
# Just use the same implementation as for the non-enhanced event for now # Just use the same implementation as for the non-enhanced event for now
self.on_hci_le_connection_complete_event(event) self.on_hci_le_connection_complete_event(event)
def on_hci_le_enhanced_connection_complete_v2_event(self, event):
# Just use the same implementation as for the v1 event for now
self.on_hci_le_enhanced_connection_complete_event(event)
def on_hci_connection_complete_event(self, event): def on_hci_connection_complete_event(self, event):
if event.status == hci.HCI_SUCCESS: if event.status == hci.HCI_SUCCESS:
# Create/update the connection # Create/update the connection
@@ -903,6 +909,27 @@ class Host(AbortableEventEmitter):
event.num_completed_extended_advertising_events, event.num_completed_extended_advertising_events,
) )
def on_hci_le_periodic_advertising_sync_established_event(self, event):
self.emit(
'periodic_advertising_sync_establishment',
event.status,
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_lost_event(self, event):
self.emit('periodic_advertising_sync_loss', event.sync_handle)
def on_hci_le_periodic_advertising_report_event(self, event):
self.emit('periodic_advertising_report', event.sync_handle, event)
def on_hci_le_biginfo_advertising_report_event(self, event):
self.emit('biginfo_advertising_report', event.sync_handle, event)
def on_hci_le_cis_request_event(self, event): def on_hci_le_cis_request_event(self, event):
self.emit( self.emit(
'cis_request', 'cis_request',
+7 -4
View File
@@ -25,7 +25,8 @@ import asyncio
import logging import logging
import os import os
import json import json
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type
from typing_extensions import Self
from .colors import color from .colors import color
from .hci import Address from .hci import Address
@@ -253,8 +254,10 @@ class JsonKeyStore(KeyStore):
logger.debug(f'JSON keystore: {self.filename}') logger.debug(f'JSON keystore: {self.filename}')
@staticmethod @classmethod
def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]: def from_device(
cls: Type[Self], device: Device, filename: Optional[str] = None
) -> Self:
if not filename: if not filename:
# Extract the filename from the config if there is one # Extract the filename from the config if there is one
if device.config.keystore is not None: if device.config.keystore is not None:
@@ -270,7 +273,7 @@ class JsonKeyStore(KeyStore):
else: else:
namespace = JsonKeyStore.DEFAULT_NAMESPACE namespace = JsonKeyStore.DEFAULT_NAMESPACE
return JsonKeyStore(namespace, filename) return cls(namespace, filename)
async def load(self): async def load(self):
# Try to open the file, without failing. If the file does not exist, it # Try to open the file, without failing. If the file does not exist, it
+5 -2
View File
@@ -70,6 +70,7 @@ L2CAP_LE_SIGNALING_CID = 0x05
L2CAP_MIN_LE_MTU = 23 L2CAP_MIN_LE_MTU = 23
L2CAP_MIN_BR_EDR_MTU = 48 L2CAP_MIN_BR_EDR_MTU = 48
L2CAP_MAX_BR_EDR_MTU = 65535
L2CAP_DEFAULT_MTU = 2048 # Default value for the MTU we are willing to accept L2CAP_DEFAULT_MTU = 2048 # Default value for the MTU we are willing to accept
@@ -832,7 +833,9 @@ class ClassicChannel(EventEmitter):
# Wait for the connection to succeed or fail # Wait for the connection to succeed or fail
try: try:
return await self.connection_result return await self.connection.abort_on(
'disconnection', self.connection_result
)
finally: finally:
self.connection_result = None self.connection_result = None
@@ -2225,7 +2228,7 @@ class ChannelManager:
# Connect # Connect
try: try:
await channel.connect() await channel.connect()
except Exception as e: except BaseException as e:
del connection_channels[source_cid] del connection_channels[source_cid]
raise e raise e
+3 -2
View File
@@ -28,6 +28,7 @@ from bumble.core import (
BT_PERIPHERAL_ROLE, BT_PERIPHERAL_ROLE,
UUID, UUID,
AdvertisingData, AdvertisingData,
Appearance,
ConnectionError, ConnectionError,
) )
from bumble.device import ( from bumble.device import (
@@ -988,8 +989,8 @@ class HostService(HostServicer):
dt.random_target_addresses.extend( dt.random_target_addresses.extend(
[data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))] [data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]
) )
if i := cast(int, ad.get(AdvertisingData.APPEARANCE)): if appearance := cast(Appearance, ad.get(AdvertisingData.APPEARANCE)):
dt.appearance = i dt.appearance = int(appearance)
if i := cast(int, ad.get(AdvertisingData.ADVERTISING_INTERVAL)): if i := cast(int, ad.get(AdvertisingData.ADVERTISING_INTERVAL)):
dt.advertising_interval = i dt.advertising_interval = i
if s := cast(str, ad.get(AdvertisingData.URI)): if s := cast(str, ad.get(AdvertisingData.URI)):
+153 -16
View File
@@ -25,6 +25,7 @@ import struct
import functools import functools
import logging import logging
from typing import Optional, List, Union, Type, Dict, Any, Tuple from typing import Optional, List, Union, Type, Dict, Any, Tuple
from typing_extensions import Self
from bumble import core from bumble import core
from bumble import colors from bumble import colors
@@ -32,6 +33,8 @@ from bumble import device
from bumble import hci from bumble import hci
from bumble import gatt from bumble import gatt
from bumble import gatt_client from bumble import gatt_client
from bumble import utils
from bumble.profiles import le_audio
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -78,6 +81,10 @@ class AudioLocation(enum.IntFlag):
LEFT_SURROUND = 0x04000000 LEFT_SURROUND = 0x04000000
RIGHT_SURROUND = 0x08000000 RIGHT_SURROUND = 0x08000000
@property
def channel_count(self) -> int:
return bin(self.value).count('1')
class AudioInputType(enum.IntEnum): class AudioInputType(enum.IntEnum):
'''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type''' '''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type'''
@@ -111,7 +118,7 @@ class ContextType(enum.IntFlag):
EMERGENCY_ALARM = 0x0800 EMERGENCY_ALARM = 0x0800
class SamplingFrequency(enum.IntEnum): class SamplingFrequency(utils.OpenIntEnum):
'''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency''' '''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency'''
# fmt: off # fmt: off
@@ -218,6 +225,13 @@ class FrameDuration(enum.IntEnum):
DURATION_7500_US = 0x00 DURATION_7500_US = 0x00
DURATION_10000_US = 0x01 DURATION_10000_US = 0x01
@property
def us(self) -> int:
return {
FrameDuration.DURATION_7500_US: 7500,
FrameDuration.DURATION_10000_US: 10000,
}[self]
class SupportedFrameDuration(enum.IntFlag): class SupportedFrameDuration(enum.IntFlag):
'''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration''' '''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration'''
@@ -229,7 +243,7 @@ class SupportedFrameDuration(enum.IntFlag):
DURATION_10000_US_PREFERRED = 0b0010 DURATION_10000_US_PREFERRED = 0b0010
class AnnouncementType(enum.IntEnum): class AnnouncementType(utils.OpenIntEnum):
'''Basic Audio Profile, 3.5.3. Additional Audio Stream Control Service requirements''' '''Basic Audio Profile, 3.5.3. Additional Audio Stream Control Service requirements'''
# fmt: off # fmt: off
@@ -534,7 +548,7 @@ class CodecSpecificCapabilities:
supported_sampling_frequencies: SupportedSamplingFrequency supported_sampling_frequencies: SupportedSamplingFrequency
supported_frame_durations: SupportedFrameDuration supported_frame_durations: SupportedFrameDuration
supported_audio_channel_counts: Sequence[int] supported_audio_channel_count: Sequence[int]
min_octets_per_codec_frame: int min_octets_per_codec_frame: int
max_octets_per_codec_frame: int max_octets_per_codec_frame: int
supported_max_codec_frames_per_sdu: int supported_max_codec_frames_per_sdu: int
@@ -543,7 +557,7 @@ class CodecSpecificCapabilities:
def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities: def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities:
offset = 0 offset = 0
# Allowed default values. # Allowed default values.
supported_audio_channel_counts = [1] supported_audio_channel_count = [1]
supported_max_codec_frames_per_sdu = 1 supported_max_codec_frames_per_sdu = 1
while offset < len(data): while offset < len(data):
length, type = struct.unpack_from('BB', data, offset) length, type = struct.unpack_from('BB', data, offset)
@@ -556,7 +570,7 @@ class CodecSpecificCapabilities:
elif type == CodecSpecificCapabilities.Type.FRAME_DURATION: elif type == CodecSpecificCapabilities.Type.FRAME_DURATION:
supported_frame_durations = SupportedFrameDuration(value) supported_frame_durations = SupportedFrameDuration(value)
elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT: elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
supported_audio_channel_counts = bits_to_channel_counts(value) supported_audio_channel_count = bits_to_channel_counts(value)
elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME: elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
min_octets_per_sample = value & 0xFFFF min_octets_per_sample = value & 0xFFFF
max_octets_per_sample = value >> 16 max_octets_per_sample = value >> 16
@@ -567,7 +581,7 @@ class CodecSpecificCapabilities:
return CodecSpecificCapabilities( return CodecSpecificCapabilities(
supported_sampling_frequencies=supported_sampling_frequencies, supported_sampling_frequencies=supported_sampling_frequencies,
supported_frame_durations=supported_frame_durations, supported_frame_durations=supported_frame_durations,
supported_audio_channel_counts=supported_audio_channel_counts, supported_audio_channel_count=supported_audio_channel_count,
min_octets_per_codec_frame=min_octets_per_sample, min_octets_per_codec_frame=min_octets_per_sample,
max_octets_per_codec_frame=max_octets_per_sample, max_octets_per_codec_frame=max_octets_per_sample,
supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu, supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu,
@@ -584,7 +598,7 @@ class CodecSpecificCapabilities:
self.supported_frame_durations, self.supported_frame_durations,
2, 2,
CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT, CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT,
channel_counts_to_bits(self.supported_audio_channel_counts), channel_counts_to_bits(self.supported_audio_channel_count),
5, 5,
CodecSpecificCapabilities.Type.OCTETS_PER_FRAME, CodecSpecificCapabilities.Type.OCTETS_PER_FRAME,
self.min_octets_per_codec_frame, self.min_octets_per_codec_frame,
@@ -602,7 +616,7 @@ class CodecSpecificConfiguration:
* Basic Audio Profile, 4.3.2 - Codec_Specific_Capabilities LTV requirements * Basic Audio Profile, 4.3.2 - Codec_Specific_Capabilities LTV requirements
''' '''
class Type(enum.IntEnum): class Type(utils.OpenIntEnum):
# fmt: off # fmt: off
SAMPLING_FREQUENCY = 0x01 SAMPLING_FREQUENCY = 0x01
FRAME_DURATION = 0x02 FRAME_DURATION = 0x02
@@ -714,6 +728,99 @@ class PacRecord:
) )
@dataclasses.dataclass
class BroadcastAudioAnnouncement:
broadcast_id: int
@classmethod
def from_bytes(cls, data: bytes) -> Self:
return cls(int.from_bytes(data[:3], 'little'))
@dataclasses.dataclass
class BasicAudioAnnouncement:
@dataclasses.dataclass
class BIS:
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)
@dataclasses.dataclass
class Subgroup:
codec_id: BasicAudioAnnouncement.CodecInfo
codec_specific_configuration: CodecSpecificConfiguration
metadata: le_audio.Metadata
bis: List[BasicAudioAnnouncement.BIS]
presentation_delay: int
subgroups: List[BasicAudioAnnouncement.Subgroup]
@classmethod
def from_bytes(cls, data: bytes) -> Self:
presentation_delay = int.from_bytes(data[:3], 'little')
subgroups = []
offset = 4
for _ in range(data[3]):
num_bis = data[offset]
offset += 1
codec_id = cls.CodecInfo.from_bytes(data[offset : offset + 5])
offset += 5
codec_specific_configuration_length = data[offset]
offset += 1
codec_specific_configuration = data[
offset : offset + codec_specific_configuration_length
]
offset += codec_specific_configuration_length
metadata_length = data[offset]
offset += 1
metadata = le_audio.Metadata.from_bytes(
data[offset : offset + metadata_length]
)
offset += metadata_length
bis = []
for _ in range(num_bis):
bis_index = data[offset]
offset += 1
bis_codec_specific_configuration_length = data[offset]
offset += 1
bis_codec_specific_configuration = data[
offset : offset + bis_codec_specific_configuration_length
]
offset += bis_codec_specific_configuration_length
bis.append(
cls.BIS(
bis_index,
CodecSpecificConfiguration.from_bytes(
bis_codec_specific_configuration
),
)
)
subgroups.append(
cls.Subgroup(
codec_id,
CodecSpecificConfiguration.from_bytes(codec_specific_configuration),
metadata,
bis,
)
)
return cls(presentation_delay, subgroups)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Server # Server
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -733,9 +840,9 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService):
supported_sink_context: ContextType, supported_sink_context: ContextType,
available_source_context: ContextType, available_source_context: ContextType,
available_sink_context: ContextType, available_sink_context: ContextType,
sink_pac: Sequence[PacRecord] = [], sink_pac: Sequence[PacRecord] = (),
sink_audio_locations: Optional[AudioLocation] = None, sink_audio_locations: Optional[AudioLocation] = None,
source_pac: Sequence[PacRecord] = [], source_pac: Sequence[PacRecord] = (),
source_audio_locations: Optional[AudioLocation] = None, source_audio_locations: Optional[AudioLocation] = None,
) -> None: ) -> None:
characteristics = [] characteristics = []
@@ -870,15 +977,22 @@ class AseStateMachine(gatt.Characteristic):
cig_id: int, cig_id: int,
cis_id: int, cis_id: int,
) -> None: ) -> None:
if cis_id == self.cis_id and self.state == self.State.ENABLING: if (
cig_id == self.cig_id
and cis_id == self.cis_id
and self.state == self.State.ENABLING
):
acl_connection.abort_on( acl_connection.abort_on(
'flush', self.service.device.accept_cis_request(cis_handle) 'flush', self.service.device.accept_cis_request(cis_handle)
) )
def on_cis_establishment(self, cis_link: device.CisLink) -> None: def on_cis_establishment(self, cis_link: device.CisLink) -> None:
if cis_link.cis_id == self.cis_id and self.state == self.State.ENABLING: if (
self.state = self.State.STREAMING cis_link.cig_id == self.cig_id
self.cis_link = cis_link and cis_link.cis_id == self.cis_id
and self.state == self.State.ENABLING
):
cis_link.on('disconnection', self.on_cis_disconnection)
async def post_cis_established(): async def post_cis_established():
await self.service.device.send_command( await self.service.device.send_command(
@@ -891,9 +1005,15 @@ class AseStateMachine(gatt.Characteristic):
codec_configuration=b'', codec_configuration=b'',
) )
) )
if self.role == AudioRole.SINK:
self.state = self.State.STREAMING
await self.service.device.notify_subscribers(self, self.value) await self.service.device.notify_subscribers(self, self.value)
cis_link.acl_connection.abort_on('flush', post_cis_established()) cis_link.acl_connection.abort_on('flush', post_cis_established())
self.cis_link = cis_link
def on_cis_disconnection(self, _reason) -> None:
self.cis_link = None
def on_config_codec( def on_config_codec(
self, self,
@@ -991,11 +1111,17 @@ class AseStateMachine(gatt.Characteristic):
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
AseReasonCode.NONE, AseReasonCode.NONE,
) )
self.state = self.State.DISABLING if self.role == AudioRole.SINK:
self.state = self.State.QOS_CONFIGURED
else:
self.state = self.State.DISABLING
return (AseResponseCode.SUCCESS, AseReasonCode.NONE) return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]: def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
if self.state != AseStateMachine.State.DISABLING: if (
self.role != AudioRole.SOURCE
or self.state != AseStateMachine.State.DISABLING
):
return ( return (
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION, AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
AseReasonCode.NONE, AseReasonCode.NONE,
@@ -1046,6 +1172,7 @@ class AseStateMachine(gatt.Characteristic):
def state(self, new_state: State) -> None: def state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}') logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}')
self._state = new_state self._state = new_state
self.emit('state_change')
@property @property
def value(self): def value(self):
@@ -1118,6 +1245,7 @@ class AudioStreamControlService(gatt.TemplateService):
ase_state_machines: Dict[int, AseStateMachine] ase_state_machines: Dict[int, AseStateMachine]
ase_control_point: gatt.Characteristic ase_control_point: gatt.Characteristic
_active_client: Optional[device.Connection] = None
def __init__( def __init__(
self, self,
@@ -1155,7 +1283,16 @@ class AudioStreamControlService(gatt.TemplateService):
else: else:
return (ase_id, AseResponseCode.INVALID_ASE_ID, AseReasonCode.NONE) return (ase_id, AseResponseCode.INVALID_ASE_ID, AseReasonCode.NONE)
def _on_client_disconnected(self, _reason: int) -> None:
for ase in self.ase_state_machines.values():
ase.state = AseStateMachine.State.IDLE
self._active_client = None
def on_write_ase_control_point(self, connection, data): def on_write_ase_control_point(self, connection, data):
if not self._active_client and connection:
self._active_client = connection
connection.once('disconnection', self._on_client_disconnected)
operation = ASE_Operation.from_bytes(data) operation = ASE_Operation.from_bytes(data)
responses = [] responses = []
logger.debug(f'*** ASCS Write {operation} ***') logger.debug(f'*** ASCS Write {operation} ***')
+49
View File
@@ -0,0 +1,49 @@
# 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 dataclasses
from typing import List
from typing_extensions import Self
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class Metadata:
@dataclasses.dataclass
class Entry:
tag: int
data: bytes
entries: List[Entry]
@classmethod
def from_bytes(cls, data: bytes) -> Self:
entries = []
offset = 0
length = len(data)
while length >= 2:
entry_length = data[offset]
entry_tag = data[offset + 1]
entry_data = data[offset + 2 : offset + 2 + entry_length - 1]
entries.append(cls.Entry(entry_tag, entry_data))
length -= entry_length
offset += entry_length
return cls(entries)
+46
View File
@@ -0,0 +1,46 @@
# 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 dataclasses
import enum
from typing_extensions import Self
from bumble.profiles import le_audio
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class PublicBroadcastAnnouncement:
class Features(enum.IntFlag):
ENCRYPTED = 1 << 0
STANDARD_QUALITY_CONFIGURATION = 1 << 1
HIGH_QUALITY_CONFIGURATION = 1 << 2
features: Features
metadata: le_audio.Metadata
@classmethod
def from_bytes(cls, data: bytes) -> Self:
features = cls.Features(data[0])
metadata_length = data[1]
metadata_ltv = data[1 : 1 + metadata_length]
return cls(
features=features, metadata=le_audio.Metadata.from_bytes(metadata_ltv)
)
+183 -61
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging import logging
import asyncio import asyncio
import collections
import dataclasses import dataclasses
import enum import enum
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
@@ -54,6 +55,7 @@ logger = logging.getLogger(__name__)
# fmt: off # fmt: off
RFCOMM_PSM = 0x0003 RFCOMM_PSM = 0x0003
DEFAULT_RX_QUEUE_SIZE = 32
class FrameType(enum.IntEnum): class FrameType(enum.IntEnum):
SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
@@ -104,9 +106,11 @@ CRC_TABLE = bytes([
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF 0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
]) ])
RFCOMM_DEFAULT_L2CAP_MTU = 2048 RFCOMM_DEFAULT_L2CAP_MTU = 2048
RFCOMM_DEFAULT_WINDOW_SIZE = 7 RFCOMM_DEFAULT_INITIAL_CREDITS = 7
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000 RFCOMM_DEFAULT_MAX_CREDITS = 32
RFCOMM_DEFAULT_CREDIT_THRESHOLD = RFCOMM_DEFAULT_MAX_CREDITS // 2
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1 RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30 RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
@@ -363,12 +367,12 @@ class RFCOMM_MCC_PN:
ack_timer: int ack_timer: int
max_frame_size: int max_frame_size: int
max_retransmissions: int max_retransmissions: int
window_size: int initial_credits: int
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.window_size < 1 or self.window_size > 7: if self.initial_credits < 1 or self.initial_credits > 7:
logger.warning( logger.warning(
f'Error Recovery Window size {self.window_size} is out of range [1, 7].' f'Initial credits {self.initial_credits} is out of range [1, 7].'
) )
@staticmethod @staticmethod
@@ -380,7 +384,7 @@ class RFCOMM_MCC_PN:
ack_timer=data[3], ack_timer=data[3],
max_frame_size=data[4] | data[5] << 8, max_frame_size=data[4] | data[5] << 8,
max_retransmissions=data[6], max_retransmissions=data[6],
window_size=data[7] & 0x07, initial_credits=data[7] & 0x07,
) )
def __bytes__(self) -> bytes: def __bytes__(self) -> bytes:
@@ -394,7 +398,7 @@ class RFCOMM_MCC_PN:
(self.max_frame_size >> 8) & 0xFF, (self.max_frame_size >> 8) & 0xFF,
self.max_retransmissions & 0xFF, self.max_retransmissions & 0xFF,
# Only 3 bits are meaningful. # Only 3 bits are meaningful.
self.window_size & 0x07, self.initial_credits & 0x07,
] ]
) )
@@ -444,39 +448,58 @@ class DLC(EventEmitter):
DISCONNECTED = 0x04 DISCONNECTED = 0x04
RESET = 0x05 RESET = 0x05
connection_result: Optional[asyncio.Future]
sink: Optional[Callable[[bytes], None]]
def __init__( def __init__(
self, self,
multiplexer: Multiplexer, multiplexer: Multiplexer,
dlci: int, dlci: int,
max_frame_size: int, tx_max_frame_size: int,
window_size: int, tx_initial_credits: int,
rx_max_frame_size: int,
rx_initial_credits: int,
) -> None: ) -> None:
super().__init__() super().__init__()
self.multiplexer = multiplexer self.multiplexer = multiplexer
self.dlci = dlci self.dlci = dlci
self.max_frame_size = max_frame_size self.rx_max_frame_size = rx_max_frame_size
self.window_size = window_size self.rx_initial_credits = rx_initial_credits
self.rx_credits = window_size self.rx_max_credits = RFCOMM_DEFAULT_MAX_CREDITS
self.rx_threshold = window_size // 2 self.rx_credits = rx_initial_credits
self.tx_credits = window_size self.rx_credits_threshold = RFCOMM_DEFAULT_CREDIT_THRESHOLD
self.tx_max_frame_size = tx_max_frame_size
self.tx_credits = tx_initial_credits
self.tx_buffer = b'' self.tx_buffer = b''
self.state = DLC.State.INIT self.state = DLC.State.INIT
self.role = multiplexer.role self.role = multiplexer.role
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0 self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
self.sink = None self.connection_result: Optional[asyncio.Future] = None
self.connection_result = None self.disconnection_result: Optional[asyncio.Future] = None
self.drained = asyncio.Event() self.drained = asyncio.Event()
self.drained.set() self.drained.set()
# Queued packets when sink is not set.
self._enqueued_rx_packets: collections.deque[bytes] = collections.deque(
maxlen=DEFAULT_RX_QUEUE_SIZE
)
self._sink: Optional[Callable[[bytes], None]] = None
# Compute the MTU # Compute the MTU
max_overhead = 4 + 1 # header with 2-byte length + fcs max_overhead = 4 + 1 # header with 2-byte length + fcs
self.mtu = min( self.mtu = min(
max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead tx_max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
) )
@property
def sink(self) -> Optional[Callable[[bytes], None]]:
return self._sink
@sink.setter
def sink(self, sink: Optional[Callable[[bytes], None]]) -> None:
self._sink = sink
# Dump queued packets to sink
if sink:
for packet in self._enqueued_rx_packets:
sink(packet) # pylint: disable=not-callable
self._enqueued_rx_packets.clear()
def change_state(self, new_state: State) -> None: def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}') logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
self.state = new_state self.state = new_state
@@ -507,20 +530,35 @@ class DLC(EventEmitter):
self.emit('open') self.emit('open')
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None: def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state != DLC.State.CONNECTING: if self.state == DLC.State.CONNECTING:
# Exchange the modem status with the peer
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.State.CONNECTED)
if self.connection_result:
self.connection_result.set_result(None)
self.connection_result = None
self.multiplexer.on_dlc_open_complete(self)
elif self.state == DLC.State.DISCONNECTING:
self.change_state(DLC.State.DISCONNECTED)
if self.disconnection_result:
self.disconnection_result.set_result(None)
self.disconnection_result = None
self.multiplexer.on_dlc_disconnection(self)
self.emit('close')
else:
logger.warning( logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red') color(
(
'!!! received UA frame when not in '
'CONNECTING or DISCONNECTING state'
),
'red',
)
) )
return
# Exchange the modem status with the peer
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.State.CONNECTED)
self.multiplexer.on_dlc_open_complete(self)
def on_dm_frame(self, frame: RFCOMM_Frame) -> None: def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
# TODO: handle all states # TODO: handle all states
@@ -549,8 +587,15 @@ class DLC(EventEmitter):
f'rx_credits={self.rx_credits}: {data.hex()}' f'rx_credits={self.rx_credits}: {data.hex()}'
) )
if data: if data:
if self.sink: if self._sink:
self.sink(data) # pylint: disable=not-callable self._sink(data) # pylint: disable=not-callable
else:
self._enqueued_rx_packets.append(data)
if (
self._enqueued_rx_packets.maxlen
and len(self._enqueued_rx_packets) >= self._enqueued_rx_packets.maxlen
):
logger.warning(f'DLC [{self.dlci}] received packet queue is full')
# Update the credits # Update the credits
if self.rx_credits > 0: if self.rx_credits > 0:
@@ -584,6 +629,19 @@ class DLC(EventEmitter):
self.connection_result = asyncio.get_running_loop().create_future() self.connection_result = asyncio.get_running_loop().create_future()
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci)) self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
async def disconnect(self) -> None:
if self.state != DLC.State.CONNECTED:
raise InvalidStateError('invalid state')
self.disconnection_result = asyncio.get_running_loop().create_future()
self.change_state(DLC.State.DISCONNECTING)
self.send_frame(
RFCOMM_Frame.disc(
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=self.dlci
)
)
await self.disconnection_result
def accept(self) -> None: def accept(self) -> None:
if self.state != DLC.State.INIT: if self.state != DLC.State.INIT:
raise InvalidStateError('invalid state') raise InvalidStateError('invalid state')
@@ -593,9 +651,9 @@ class DLC(EventEmitter):
cl=0xE0, cl=0xE0,
priority=7, priority=7,
ack_timer=0, ack_timer=0,
max_frame_size=self.max_frame_size, max_frame_size=self.rx_max_frame_size,
max_retransmissions=0, max_retransmissions=0,
window_size=self.window_size, initial_credits=self.rx_initial_credits,
) )
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn)) mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn))
logger.debug(f'>>> PN Response: {pn}') logger.debug(f'>>> PN Response: {pn}')
@@ -603,8 +661,8 @@ class DLC(EventEmitter):
self.change_state(DLC.State.CONNECTING) self.change_state(DLC.State.CONNECTING)
def rx_credits_needed(self) -> int: def rx_credits_needed(self) -> int:
if self.rx_credits <= self.rx_threshold: if self.rx_credits <= self.rx_credits_threshold:
return self.window_size - self.rx_credits return self.rx_max_credits - self.rx_credits
return 0 return 0
@@ -664,8 +722,28 @@ class DLC(EventEmitter):
async def drain(self) -> None: async def drain(self) -> None:
await self.drained.wait() await self.drained.wait()
def abort(self) -> None:
logger.debug(f'aborting DLC: {self}')
if self.connection_result:
self.connection_result.cancel()
self.connection_result = None
if self.disconnection_result:
self.disconnection_result.cancel()
self.disconnection_result = None
self.change_state(DLC.State.RESET)
self.emit('close')
def __str__(self) -> str: def __str__(self) -> str:
return f'DLC(dlci={self.dlci},state={self.state.name})' return (
f'DLC(dlci={self.dlci}, '
f'state={self.state.name}, '
f'rx_max_frame_size={self.rx_max_frame_size}, '
f'rx_credits={self.rx_credits}, '
f'rx_max_credits={self.rx_max_credits}, '
f'tx_max_frame_size={self.tx_max_frame_size}, '
f'tx_credits={self.tx_credits}'
')'
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -686,7 +764,7 @@ class Multiplexer(EventEmitter):
connection_result: Optional[asyncio.Future] connection_result: Optional[asyncio.Future]
disconnection_result: Optional[asyncio.Future] disconnection_result: Optional[asyncio.Future]
open_result: Optional[asyncio.Future] open_result: Optional[asyncio.Future]
acceptor: Optional[Callable[[int], bool]] acceptor: Optional[Callable[[int], Optional[Tuple[int, int]]]]
dlcs: Dict[int, DLC] dlcs: Dict[int, DLC]
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None: def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
@@ -698,11 +776,15 @@ class Multiplexer(EventEmitter):
self.connection_result = None self.connection_result = None
self.disconnection_result = None self.disconnection_result = None
self.open_result = None self.open_result = None
self.open_pn: Optional[RFCOMM_MCC_PN] = None
self.open_rx_max_credits = 0
self.acceptor = None self.acceptor = None
# Become a sink for the L2CAP channel # Become a sink for the L2CAP channel
l2cap_channel.sink = self.on_pdu l2cap_channel.sink = self.on_pdu
l2cap_channel.on('close', self.on_l2cap_channel_close)
def change_state(self, new_state: State) -> None: def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}') logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
self.state = new_state self.state = new_state
@@ -766,6 +848,7 @@ class Multiplexer(EventEmitter):
'rfcomm', 'rfcomm',
) )
) )
self.open_result = None
else: else:
logger.warning(f'unexpected state for DM: {self}') logger.warning(f'unexpected state for DM: {self}')
@@ -803,9 +886,16 @@ class Multiplexer(EventEmitter):
else: else:
if self.acceptor: if self.acceptor:
channel_number = pn.dlci >> 1 channel_number = pn.dlci >> 1
if self.acceptor(channel_number): if dlc_params := self.acceptor(channel_number):
# Create a new DLC # Create a new DLC
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size) dlc = DLC(
self,
dlci=pn.dlci,
tx_max_frame_size=pn.max_frame_size,
tx_initial_credits=pn.initial_credits,
rx_max_frame_size=dlc_params[0],
rx_initial_credits=dlc_params[1],
)
self.dlcs[pn.dlci] = dlc self.dlcs[pn.dlci] = dlc
# Re-emit the handshake completion event # Re-emit the handshake completion event
@@ -823,8 +913,17 @@ class Multiplexer(EventEmitter):
# Response # Response
logger.debug(f'>>> PN Response: {pn}') logger.debug(f'>>> PN Response: {pn}')
if self.state == Multiplexer.State.OPENING: if self.state == Multiplexer.State.OPENING:
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size) assert self.open_pn
dlc = DLC(
self,
dlci=pn.dlci,
tx_max_frame_size=pn.max_frame_size,
tx_initial_credits=pn.initial_credits,
rx_max_frame_size=self.open_pn.max_frame_size,
rx_initial_credits=self.open_pn.initial_credits,
)
self.dlcs[pn.dlci] = dlc self.dlcs[pn.dlci] = dlc
self.open_pn = None
dlc.connect() dlc.connect()
else: else:
logger.warning('ignoring PN response') logger.warning('ignoring PN response')
@@ -862,7 +961,7 @@ class Multiplexer(EventEmitter):
self, self,
channel: int, channel: int,
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE, max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
window_size: int = RFCOMM_DEFAULT_WINDOW_SIZE, initial_credits: int = RFCOMM_DEFAULT_INITIAL_CREDITS,
) -> DLC: ) -> DLC:
if self.state != Multiplexer.State.CONNECTED: if self.state != Multiplexer.State.CONNECTED:
if self.state == Multiplexer.State.OPENING: if self.state == Multiplexer.State.OPENING:
@@ -870,17 +969,19 @@ class Multiplexer(EventEmitter):
raise InvalidStateError('not connected') raise InvalidStateError('not connected')
pn = RFCOMM_MCC_PN( self.open_pn = RFCOMM_MCC_PN(
dlci=channel << 1, dlci=channel << 1,
cl=0xF0, cl=0xF0,
priority=7, priority=7,
ack_timer=0, ack_timer=0,
max_frame_size=max_frame_size, max_frame_size=max_frame_size,
max_retransmissions=0, max_retransmissions=0,
window_size=window_size, initial_credits=initial_credits,
) )
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=1, data=bytes(pn)) mcc = RFCOMM_Frame.make_mcc(
logger.debug(f'>>> Sending MCC: {pn}') mcc_type=MccType.PN, c_r=1, data=bytes(self.open_pn)
)
logger.debug(f'>>> Sending MCC: {self.open_pn}')
self.open_result = asyncio.get_running_loop().create_future() self.open_result = asyncio.get_running_loop().create_future()
self.change_state(Multiplexer.State.OPENING) self.change_state(Multiplexer.State.OPENING)
self.send_frame( self.send_frame(
@@ -890,15 +991,31 @@ class Multiplexer(EventEmitter):
information=mcc, information=mcc,
) )
) )
result = await self.open_result return await self.open_result
self.open_result = None
return result
def on_dlc_open_complete(self, dlc: DLC) -> None: def on_dlc_open_complete(self, dlc: DLC) -> None:
logger.debug(f'DLC [{dlc.dlci}] open complete') logger.debug(f'DLC [{dlc.dlci}] open complete')
self.change_state(Multiplexer.State.CONNECTED) self.change_state(Multiplexer.State.CONNECTED)
if self.open_result: if self.open_result:
self.open_result.set_result(dlc) self.open_result.set_result(dlc)
self.open_result = None
def on_dlc_disconnection(self, dlc: DLC) -> None:
logger.debug(f'DLC [{dlc.dlci}] disconnection')
self.dlcs.pop(dlc.dlci, None)
def on_l2cap_channel_close(self) -> None:
logger.debug('L2CAP channel closed, cleaning up')
if self.open_result:
self.open_result.cancel()
self.open_result = None
if self.disconnection_result:
self.disconnection_result.cancel()
self.disconnection_result = None
for dlc in self.dlcs.values():
dlc.abort()
def __str__(self) -> str: def __str__(self) -> str:
return f'Multiplexer(state={self.state.name})' return f'Multiplexer(state={self.state.name})'
@@ -957,15 +1074,13 @@ class Client:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Server(EventEmitter): class Server(EventEmitter):
acceptors: Dict[int, Callable[[DLC], None]]
def __init__( def __init__(
self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
) -> None: ) -> None:
super().__init__() super().__init__()
self.device = device self.device = device
self.multiplexer = None self.acceptors: Dict[int, Callable[[DLC], None]] = {}
self.acceptors = {} self.dlc_configs: Dict[int, Tuple[int, int]] = {}
# Register ourselves with the L2CAP channel manager # Register ourselves with the L2CAP channel manager
self.l2cap_server = device.create_l2cap_server( self.l2cap_server = device.create_l2cap_server(
@@ -973,7 +1088,13 @@ class Server(EventEmitter):
handler=self.on_connection, handler=self.on_connection,
) )
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int: def listen(
self,
acceptor: Callable[[DLC], None],
channel: int = 0,
max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
initial_credits: int = RFCOMM_DEFAULT_INITIAL_CREDITS,
) -> int:
if channel: if channel:
if channel in self.acceptors: if channel in self.acceptors:
# Busy # Busy
@@ -993,6 +1114,8 @@ class Server(EventEmitter):
return 0 return 0
self.acceptors[channel] = acceptor self.acceptors[channel] = acceptor
self.dlc_configs[channel] = (max_frame_size, initial_credits)
return channel return channel
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None: def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
@@ -1010,15 +1133,14 @@ class Server(EventEmitter):
# Notify # Notify
self.emit('start', multiplexer) self.emit('start', multiplexer)
def accept_dlc(self, channel_number: int) -> bool: def accept_dlc(self, channel_number: int) -> Optional[Tuple[int, int]]:
return channel_number in self.acceptors return self.dlc_configs.get(channel_number)
def on_dlc(self, dlc: DLC) -> None: def on_dlc(self, dlc: DLC) -> None:
logger.debug(f'@@@ new DLC connected: {dlc}') logger.debug(f'@@@ new DLC connected: {dlc}')
# Let the acceptor know # Let the acceptor know
acceptor = self.acceptors.get(dlc.dlci >> 1) if acceptor := self.acceptors.get(dlc.dlci >> 1):
if acceptor:
acceptor(dlc) acceptor(dlc)
def __enter__(self) -> Self: def __enter__(self) -> Self:
+1 -1
View File
@@ -997,7 +997,7 @@ class Server:
try: try:
handler(sdp_pdu) handler(sdp_pdu)
except Exception as error: except Exception as error:
logger.warning(f'{color("!!! Exception in handler:", "red")} {error}') logger.exception(f'{color("!!! Exception in handler:", "red")} {error}')
self.send_response( self.send_response(
SDP_ErrorResponse( SDP_ErrorResponse(
transaction_id=sdp_pdu.transaction_id, transaction_id=sdp_pdu.transaction_id,
+4
View File
@@ -425,6 +425,10 @@ class SnoopingTransport(Transport):
class Source: class Source:
sink: TransportSink sink: TransportSink
@property
def metadata(self) -> dict[str, Any]:
return getattr(self.source, 'metadata', {})
def __init__(self, source: TransportSource, snooper: Snooper): def __init__(self, source: TransportSource, snooper: Snooper):
self.source = source self.source = source
self.snooper = snooper self.snooper = snooper
+350
View File
@@ -0,0 +1,350 @@
<html data-bs-theme="dark">
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://unpkg.com/pcm-player"></script>
</head>
<body>
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<span class="navbar-brand mb-0 h1">Bumble HFP Audio Gateway</span>
</div>
</nav>
<br>
<div class="container">
<label class="form-label">Send AT Response</label>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="AT Response" aria-label="AT response" id="at_response">
<button class="btn btn-primary" type="button"
onclick="send_at_response(document.getElementById('at_response').value)">Send</button>
</div>
<div class="row">
<div class="col-3">
<label class="form-label">Speaker Volume</label>
<div class="input-group mb-3 col-auto">
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Speaker Volume"
id="speaker_volume">
<button class="btn btn-primary" type="button"
onclick="send_at_response(`+VGS: ${document.getElementById('speaker_volume').value}`)">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">Mic Volume</label>
<div class="input-group mb-3 col-auto">
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Mic Volume"
id="mic_volume">
<button class="btn btn-primary" type="button"
onclick="send_at_response(`+VGM: ${document.getElementById('mic_volume').value}`)">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">Browser Gain</label>
<input type="range" class="form-range" id="browser-gain" min="0" max="2" value="1" step="0.1" onchange="setGain()">
</div>
</div>
<div class="row">
<div class="col-auto">
<div class="input-group mb-3">
<span class="input-group-text">Codec</span>
<select class="form-select" id="codec">
<option selected value="1">CVSD</option>
<option value="2">MSBC</option>
</select>
</div>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="negotiate_codec()">Negotiate Codec</button>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="connect_sco()">Connect SCO</button>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="disconnect_sco()">Disconnect SCO</button>
</div>
<div class="col-auto">
<button class="btn btn-danger" onclick="connectAudio()">Connect Audio</button>
</div>
</div>
<hr>
<div class="row">
<h4>AG Indicators</h2>
<div class="col-3">
<label class="form-label">call</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="call">
<option selected value="0">Inactive</option>
<option value="1">Active</option>
</select>
<button class="btn btn-primary" type="button" onclick="update_ag_indicator('call')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">callsetup</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="callsetup">
<option selected value="0">Idle</option>
<option value="1">Incoming</option>
<option value="2">Outgoing</option>
<option value="3">Remote Alerted</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('callsetup')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">callheld</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="callsetup">
<option selected value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('callheld')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">signal</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="signal">
<option selected value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('signal')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">roam</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="roam">
<option selected value="0">0</option>
<option value="1">1</option>
</select>
<button class="btn btn-primary" type="button" onclick="update_ag_indicator('roam')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">battchg</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="battchg">
<option selected value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('battchg')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">service</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="service">
<option selected value="0">0</option>
<option value="1">1</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('service')">Set</button>
</div>
</div>
</div>
<hr>
<button class="btn btn-primary" onclick="send_at_response('+BVRA: 1')">Start Voice Assistant</button>
<button class="btn btn-primary" onclick="send_at_response('+BVRA: 0')">Stop Voice Assistant</button>
<hr>
<h4>Calls</h4>
<div id="call-lists">
<template id="call-template">
<div class="row call-row">
<div class="input-group mb-3">
<label class="input-group-text">Index</label>
<input class="form-control call-index" value="1">
<label class="input-group-text">Number</label>
<input class="form-control call-number">
<label class="input-group-text">Direction</label>
<select class="form-select call-direction">
<option selected value="0">Originated</option>
<option value="1">Terminated</option>
</select>
<label class="input-group-text">Status</label>
<select class="form-select call-status">
<option value="0">ACTIVE</option>
<option value="1">HELD</option>
<option value="2">DIALING</option>
<option value="3">ALERTING</option>
<option value="4">INCOMING</option>
<option value="5">WAITING</option>
</select>
<button class="btn btn-primary call-remover"></button>
</div>
</div>
</template>
</div>
<button class="btn btn-primary" onclick="add_call()"> Add Call</button>
<button class="btn btn-primary" onclick="update_calls()">🗘 Update Calls</button>
<hr>
<div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
<h3>Log</h3>
<code id="log" style="white-space: pre-line;"></code>
</div>
</div>
<script>
let atResponseInput = document.getElementById("at_response")
let gainInput = document.getElementById('browser-gain')
let log = document.getElementById("log")
let socket = new WebSocket('ws://localhost:8888');
let sampleRate = 0;
let player;
socket.binaryType = "arraybuffer";
socket.onopen = _ => {
log.textContent += 'SOCKET OPEN\n'
}
socket.onclose = _ => {
log.textContent += 'SOCKET CLOSED\n'
}
socket.onerror = (error) => {
log.textContent += 'SOCKET ERROR\n'
console.log(`ERROR: ${error}`)
}
socket.onmessage = function (message) {
if (typeof message.data === 'string' || message.data instanceof String) {
log.textContent += `<-- ${event.data}\n`
const jsonMessage = JSON.parse(event.data)
if (jsonMessage.type == 'speaker_volume') {
document.getElementById('speaker_volume').value = jsonMessage.level;
} else if (jsonMessage.type == 'microphone_volume') {
document.getElementById('microphone_volume').value = jsonMessage.level;
} else if (jsonMessage.type == 'sco_state_change') {
sampleRate = jsonMessage.sample_rate;
console.log(sampleRate);
if (player != null) {
player = new PCMPlayer({
inputCodec: 'Int16',
channels: 1,
sampleRate: sampleRate,
flushTime: 7.5,
});
player.volume(gainInput.value);
}
}
} else {
// BINARY audio data.
if (player == null) return;
player.feed(message.data);
}
};
function send(message) {
if (socket && socket.readyState == WebSocket.OPEN) {
let jsonMessage = JSON.stringify(message)
log.textContent += `--> ${jsonMessage}\n`
socket.send(jsonMessage)
} else {
log.textContent += 'NOT CONNECTED\n'
}
}
function send_at_response(response) {
send({ type: 'at_response', response: response })
}
function update_ag_indicator(indicator) {
const value = document.getElementById(indicator).value
send({ type: 'ag_indicator', indicator: indicator, value: value })
}
function connect_sco() {
send({ type: 'connect_sco' })
}
function negotiate_codec() {
const codec = document.getElementById('codec').value
send({ type: 'negotiate_codec', codec: codec })
}
function disconnect_sco() {
send({ type: 'disconnect_sco' })
}
function add_call() {
let callLists = document.getElementById('call-lists');
let template = document.getElementById('call-template');
let newNode = document.importNode(template.content, true);
newNode.querySelector('.call-remover').onclick = function (event) {
event.target.closest('.call-row').remove();
}
callLists.appendChild(newNode);
}
function update_calls() {
let callLists = document.getElementById('call-lists');
send({
type: 'update_calls',
calls: Array.from(
callLists.querySelectorAll('.call-row')).map(
function (element) {
return {
index: element.querySelector('.call-index').value,
number: element.querySelector('.call-number').value,
direction: element.querySelector('.call-direction').value,
status: element.querySelector('.call-status').value,
}
}
),
}
)
}
function connectAudio() {
player = new PCMPlayer({
inputCodec: 'Int16',
channels: 1,
sampleRate: sampleRate,
flushTime: 7.5,
});
player.volume(gainInput.value);
}
function setGain() {
if (player != null) {
player.volume(gainInput.value);
}
}
</script>
</div>
</body>
</html>
+2 -1
View File
@@ -1,4 +1,5 @@
{ {
"name": "Bumble Phone", "name": "Bumble Phone",
"class_of_device": 6291980 "class_of_device": 6291980,
"keystore": "JsonKeyStore"
} }
+194 -50
View File
@@ -16,22 +16,30 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import json
import sys import sys
import os import os
import io
import logging import logging
import websockets
from typing import Optional
import bumble.core import bumble.core
from bumble.device import Device from bumble.device import Device, ScoLink
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.core import ( from bumble.core import (
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
) )
from bumble import rfcomm, hfp from bumble import hci, rfcomm, hfp
from bumble.hci import HCI_SynchronousDataPacket
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ws: Optional[websockets.WebSocketServerProtocol] = None
ag_protocol: Optional[hfp.AgProtocol] = None
source_file: Optional[io.BufferedReader] = None
def _default_configuration() -> hfp.AgConfiguration: def _default_configuration() -> hfp.AgConfiguration:
return hfp.AgConfiguration( return hfp.AgConfiguration(
@@ -41,12 +49,13 @@ def _default_configuration() -> hfp.AgConfiguration:
hfp.AgFeature.REJECT_CALL, hfp.AgFeature.REJECT_CALL,
hfp.AgFeature.CODEC_NEGOTIATION, hfp.AgFeature.CODEC_NEGOTIATION,
hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED, hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED,
hfp.AgFeature.ENHANCED_CALL_STATUS,
], ],
supported_ag_indicators=[ supported_ag_indicators=[
hfp.AgIndicatorState.call(), hfp.AgIndicatorState.call(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.callheld(),
hfp.AgIndicatorState.service(), hfp.AgIndicatorState.service(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.signal(), hfp.AgIndicatorState.signal(),
hfp.AgIndicatorState.roam(), hfp.AgIndicatorState.roam(),
hfp.AgIndicatorState.battchg(), hfp.AgIndicatorState.battchg(),
@@ -60,17 +69,123 @@ def _default_configuration() -> hfp.AgConfiguration:
) )
def send_message(type: str, **kwargs) -> None:
if ws:
asyncio.create_task(ws.send(json.dumps({'type': type, **kwargs})))
def on_speaker_volume(level: int):
send_message(type='speaker_volume', level=level)
def on_microphone_volume(level: int):
send_message(type='microphone_volume', level=level)
def on_sco_state_change(codec: int):
if codec == hfp.AudioCodec.CVSD:
sample_rate = 8000
elif codec == hfp.AudioCodec.MSBC:
sample_rate = 16000
else:
sample_rate = 0
send_message(type='sco_state_change', sample_rate=sample_rate)
def on_sco_packet(packet: hci.HCI_SynchronousDataPacket):
if ws:
asyncio.create_task(ws.send(packet.data))
if source_file and (pcm_data := source_file.read(packet.data_total_length)):
assert ag_protocol
host = ag_protocol.dlc.multiplexer.l2cap_channel.connection.device.host
host.send_hci_packet(
hci.HCI_SynchronousDataPacket(
connection_handle=packet.connection_handle,
packet_status=0,
data_total_length=len(pcm_data),
data=pcm_data,
)
)
def on_hfp_state_change(connected: bool):
send_message(type='hfp_state_change', connected=connected)
async def ws_server(ws_client: websockets.WebSocketServerProtocol, path: str):
del path
global ws
ws = ws_client
async for message in ws_client:
if not ag_protocol:
continue
json_message = json.loads(message)
message_type = json_message['type']
connection = ag_protocol.dlc.multiplexer.l2cap_channel.connection
device = connection.device
try:
if message_type == 'at_response':
ag_protocol.send_response(json_message['response'])
elif message_type == 'ag_indicator':
ag_protocol.update_ag_indicator(
hfp.AgIndicator(json_message['indicator']),
int(json_message['value']),
)
elif message_type == 'negotiate_codec':
codec = hfp.AudioCodec(int(json_message['codec']))
await ag_protocol.negotiate_codec(codec)
elif message_type == 'connect_sco':
if ag_protocol.active_codec == hfp.AudioCodec.CVSD:
esco_param = hfp.ESCO_PARAMETERS[
hfp.DefaultCodecParameters.ESCO_CVSD_S4
]
elif ag_protocol.active_codec == hfp.AudioCodec.MSBC:
esco_param = hfp.ESCO_PARAMETERS[
hfp.DefaultCodecParameters.ESCO_MSBC_T2
]
else:
raise ValueError(f'Unsupported codec {codec}')
await device.send_command(
hci.HCI_Enhanced_Setup_Synchronous_Connection_Command(
connection_handle=connection.handle, **esco_param.asdict()
)
)
elif message_type == 'disconnect_sco':
# Copy the values to avoid iteration error.
for sco_link in list(device.sco_links.values()):
await sco_link.disconnect()
elif message_type == 'update_calls':
ag_protocol.calls = [
hfp.CallInfo(
index=int(call['index']),
direction=hfp.CallInfoDirection(int(call['direction'])),
status=hfp.CallInfoStatus(int(call['status'])),
number=call['number'],
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
mode=hfp.CallInfoMode.VOICE,
)
for call in json_message['calls']
]
except Exception as e:
send_message(type='error', message=e)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main() -> None: async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 3:
print( print(
'Usage: run_hfp_gateway.py <device-config> <transport-spec> ' 'Usage: run_hfp_gateway.py <device-config> <transport-spec> '
'<bluetooth-address>' '[bluetooth-address] [wav-file-for-source]'
) )
print( print(
' specifying a channel number, or "discover" to list all RFCOMM channels' 'example: run_hfp_gateway.py hfp_gateway.json usb:0 E1:CA:72:48:C4:E8 sample.wav'
) )
print('example: run_hfp_gateway.py hfp_gateway.json usb:0 E1:CA:72:48:C4:E8')
return return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
@@ -84,56 +199,85 @@ async def main() -> None:
device.classic_enabled = True device.classic_enabled = True
await device.power_on() await device.power_on()
# Connect to a peer rfcomm_server = rfcomm.Server(device)
target_address = sys.argv[3] configuration = _default_configuration()
print(f'=== Connecting to {target_address}...')
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}!')
# Get a list of all the Handsfree services (should only be 1) def on_dlc(dlc: rfcomm.DLC):
if not (hfp_record := await hfp.find_hf_sdp_record(connection)): global ag_protocol
print('!!! no service found') ag_protocol = hfp.AgProtocol(dlc, configuration)
return ag_protocol.on('speaker_volume', on_speaker_volume)
ag_protocol.on('microphone_volume', on_microphone_volume)
on_hfp_state_change(True)
dlc.multiplexer.l2cap_channel.on(
'close', lambda: on_hfp_state_change(False)
)
# Pick the first one channel = rfcomm_server.listen(on_dlc)
channel, version, hf_sdp_features = hfp_record device.sdp_service_records = {
print(f'HF version: {version}') 1: hfp.make_ag_sdp_records(1, channel, configuration)
print(f'HF features: {hf_sdp_features}') }
# Request authentication def on_sco_connection(sco_link: ScoLink):
print('*** Authenticating...') assert ag_protocol
await connection.authenticate() on_sco_state_change(ag_protocol.active_codec)
print('*** Authenticated') sco_link.on('disconnection', lambda _: on_sco_state_change(0))
sco_link.sink = on_sco_packet
# Enable encryption device.on('sco_connection', on_sco_connection)
print('*** Enabling encryption...') if len(sys.argv) >= 4:
await connection.encrypt() # Connect to a peer
print('*** Encryption on') target_address = sys.argv[3]
print(f'=== Connecting to {target_address}...')
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
)
print(f'=== Connected to {connection.peer_address}!')
# Create a client and start it # Get a list of all the Handsfree services (should only be 1)
print('@@@ Starting to RFCOMM client...') if not (hfp_record := await hfp.find_hf_sdp_record(connection)):
rfcomm_client = rfcomm.Client(connection) print('!!! no service found')
rfcomm_mux = await rfcomm_client.start() return
print('@@@ Started')
print(f'### Opening session for channel {channel}...') # Pick the first one
try: channel, version, hf_sdp_features = hfp_record
session = await rfcomm_mux.open_dlc(channel) print(f'HF version: {version}')
print('### Session open', session) print(f'HF features: {hf_sdp_features}')
except bumble.core.ConnectionError as error:
print(f'### Session open failed: {error}')
await rfcomm_mux.disconnect()
print('@@@ Disconnected from RFCOMM server')
return
def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket): # Request authentication
# Reset packet and loopback print('*** Authenticating...')
packet.packet_status = 0 await connection.authenticate()
device.host.send_hci_packet(packet) print('*** Authenticated')
device.host.on('sco_packet', on_sco) # Enable encryption
print('*** Enabling encryption...')
await connection.encrypt()
print('*** Encryption on')
ag_protocol = hfp.AgProtocol(session, _default_configuration()) # Create a client and start it
print('@@@ Starting to RFCOMM client...')
rfcomm_client = rfcomm.Client(connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
print(f'### Opening session for channel {channel}...')
try:
session = await rfcomm_mux.open_dlc(channel)
print('### Session open', session)
except bumble.core.ConnectionError as error:
print(f'### Session open failed: {error}')
await rfcomm_mux.disconnect()
print('@@@ Disconnected from RFCOMM server')
return
on_dlc(session)
await websockets.serve(ws_server, port=8888)
if len(sys.argv) >= 5:
global source_file
source_file = open(sys.argv[4], 'rb')
# Skip header
source_file.seek(44)
await hci_transport.source.terminated await hci_transport.source.terminated
+83 -68
View File
@@ -16,20 +16,28 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import datetime
import functools
import logging import logging
import sys import sys
import os import os
import io
import struct import struct
import secrets import secrets
from typing import Dict
from bumble.core import AdvertisingData from bumble.core import AdvertisingData
from bumble.device import Device, CisLink from bumble.device import Device
from bumble.hci import ( from bumble.hci import (
CodecID, CodecID,
CodingFormat, CodingFormat,
HCI_IsoDataPacket, HCI_IsoDataPacket,
) )
from bumble.profiles.bap import ( from bumble.profiles.bap import (
AseStateMachine,
UnicastServerAdvertisingData, UnicastServerAdvertisingData,
CodecSpecificConfiguration,
CodecSpecificCapabilities, CodecSpecificCapabilities,
ContextType, ContextType,
AudioLocation, AudioLocation,
@@ -45,6 +53,32 @@ from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
def _sink_pac_record() -> PacRecord:
return PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=CodecSpecificCapabilities(
supported_sampling_frequencies=(
SupportedSamplingFrequency.FREQ_8000
| SupportedSamplingFrequency.FREQ_16000
| SupportedSamplingFrequency.FREQ_24000
| SupportedSamplingFrequency.FREQ_32000
| SupportedSamplingFrequency.FREQ_48000
),
supported_frame_durations=(
SupportedFrameDuration.DURATION_7500_US_SUPPORTED
| SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_count=[1, 2],
min_octets_per_codec_frame=26,
max_octets_per_codec_frame=240,
supported_max_codec_frames_per_sdu=2,
),
)
file_outputs: Dict[AseStateMachine, io.BufferedWriter] = {}
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def main() -> None: async def main() -> None:
if len(sys.argv) < 3: if len(sys.argv) < 3:
@@ -71,49 +105,17 @@ async def main() -> None:
PublishedAudioCapabilitiesService( PublishedAudioCapabilitiesService(
supported_source_context=ContextType.PROHIBITED, supported_source_context=ContextType.PROHIBITED,
available_source_context=ContextType.PROHIBITED, available_source_context=ContextType.PROHIBITED,
supported_sink_context=ContextType.MEDIA, supported_sink_context=ContextType(0xFF), # All context types
available_sink_context=ContextType.MEDIA, available_sink_context=ContextType(0xFF), # All context types
sink_audio_locations=( sink_audio_locations=(
AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
), ),
sink_pac=[ sink_pac=[_sink_pac_record()],
# Codec Capability Setting 16_2
PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=CodecSpecificCapabilities(
supported_sampling_frequencies=(
SupportedSamplingFrequency.FREQ_16000
),
supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_counts=[1],
min_octets_per_codec_frame=40,
max_octets_per_codec_frame=40,
supported_max_codec_frames_per_sdu=1,
),
),
# Codec Capability Setting 24_2
PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=CodecSpecificCapabilities(
supported_sampling_frequencies=(
SupportedSamplingFrequency.FREQ_48000
),
supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_counts=[1],
min_octets_per_codec_frame=120,
max_octets_per_codec_frame=120,
supported_max_codec_frames_per_sdu=1,
),
),
],
) )
) )
device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2])) ascs = AudioStreamControlService(device, sink_ase_id=[1], source_ase_id=[2])
device.add_service(ascs)
advertising_data = ( advertising_data = (
bytes( bytes(
@@ -143,44 +145,57 @@ async def main() -> None:
+ csis.get_advertising_data() + csis.get_advertising_data()
+ bytes(UnicastServerAdvertisingData()) + bytes(UnicastServerAdvertisingData())
) )
subprocess = await asyncio.create_subprocess_shell(
f'dlc3 | ffplay pipe:0',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdin = subprocess.stdin def on_pdu(ase: AseStateMachine, pdu: HCI_IsoDataPacket):
assert stdin
# Write a fake LC3 header to dlc3.
stdin.write(
bytes([0x1C, 0xCC]) # Header.
+ struct.pack(
'<HHHHHHI',
18, # Header length.
48000 // 100, # Sampling Rate(/100Hz).
0, # Bitrate(unused).
1, # Channels.
10000 // 10, # Frame duration(/10us).
0, # RFU.
0x0FFFFFFF, # Frame counts.
)
)
def on_pdu(pdu: HCI_IsoDataPacket):
# LC3 format: |frame_length(2)| + |frame(length)|. # LC3 format: |frame_length(2)| + |frame(length)|.
sdu = b''
if pdu.iso_sdu_length: if pdu.iso_sdu_length:
stdin.write(struct.pack('<H', pdu.iso_sdu_length)) sdu = struct.pack('<H', pdu.iso_sdu_length)
stdin.write(pdu.iso_sdu_fragment) sdu += pdu.iso_sdu_fragment
file_outputs[ase].write(sdu)
def on_cis(cis_link: CisLink): def on_ase_state_change(
cis_link.on('pdu', on_pdu) state: AseStateMachine.State,
ase: AseStateMachine,
) -> None:
if state != AseStateMachine.State.STREAMING:
if file_output := file_outputs.pop(ase):
file_output.close()
else:
file_output = open(f'{datetime.datetime.now().isoformat()}.lc3', 'wb')
codec_configuration = ase.codec_specific_configuration
assert isinstance(codec_configuration, CodecSpecificConfiguration)
# Write a LC3 header.
file_output.write(
bytes([0x1C, 0xCC]) # Header.
+ struct.pack(
'<HHHHHHI',
18, # Header length.
codec_configuration.sampling_frequency.hz
// 100, # Sampling Rate(/100Hz).
0, # Bitrate(unused).
bin(codec_configuration.audio_channel_allocation).count(
'1'
), # Channels.
codec_configuration.frame_duration.us
// 10, # Frame duration(/10us).
0, # RFU.
0x0FFFFFFF, # Frame counts.
)
)
file_outputs[ase] = file_output
assert ase.cis_link
ase.cis_link.sink = functools.partial(on_pdu, ase)
device.once('cis_establishment', on_cis) for ase in ascs.ase_state_machines.values():
ase.on(
'state_change',
functools.partial(on_ase_state_change, ase=ase),
)
await device.create_advertising_set( await device.create_advertising_set(
advertising_data=advertising_data, advertising_data=advertising_data,
auto_restart=True,
) )
await hci_transport.source.terminated await hci_transport.source.terminated
+1 -1
View File
@@ -102,7 +102,7 @@ async def main() -> None:
supported_frame_durations=( supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED SupportedFrameDuration.DURATION_10000_US_SUPPORTED
), ),
supported_audio_channel_counts=[1], supported_audio_channel_count=[1],
min_octets_per_codec_frame=120, min_octets_per_codec_frame=120,
max_octets_per_codec_frame=120, max_octets_per_codec_frame=120,
supported_max_codec_frames_per_sdu=1, supported_max_codec_frames_per_sdu=1,
@@ -150,7 +150,8 @@ class AppViewModel : ViewModel() {
} else if (senderPacketSizeSlider < 0.5F) { } else if (senderPacketSizeSlider < 0.5F) {
512 512
} else if (senderPacketSizeSlider < 0.7F) { } else if (senderPacketSizeSlider < 0.7F) {
1024 // 970 is a value that works well on Android.
970
} else if (senderPacketSizeSlider < 0.9F) { } else if (senderPacketSizeSlider < 0.9F) {
2048 2048
} else { } else {
@@ -56,13 +56,19 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
thread { thread {
socketDataSource.receive() socketDataSource.receive()
socket.close()
sender.abort()
} }
Log.info("Startup delay: $DEFAULT_STARTUP_DELAY") Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong()); Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
Log.info("Starting to send") Log.info("Starting to send")
sender.run() try {
sender.run()
} catch (error: IOException) {
Log.info("run ended abruptly")
}
cleanup() cleanup()
} }
} }
+5 -3
View File
@@ -62,6 +62,7 @@ console_scripts =
bumble-gatt-dump = bumble.apps.gatt_dump:main bumble-gatt-dump = bumble.apps.gatt_dump:main
bumble-hci-bridge = bumble.apps.hci_bridge:main bumble-hci-bridge = bumble.apps.hci_bridge:main
bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
bumble-rfcomm-bridge = bumble.apps.rfcomm_bridge:main
bumble-pair = bumble.apps.pair:main bumble-pair = bumble.apps.pair:main
bumble-scan = bumble.apps.scan:main bumble-scan = bumble.apps.scan:main
bumble-show = bumble.apps.show:main bumble-show = bumble.apps.show:main
@@ -81,7 +82,7 @@ console_scripts =
build = build =
build >= 0.7 build >= 0.7
test = test =
pytest >= 8.0 pytest >= 8.2
pytest-asyncio >= 0.23.5 pytest-asyncio >= 0.23.5
pytest-html >= 3.2.0 pytest-html >= 3.2.0
coverage >= 6.4 coverage >= 6.4
@@ -89,13 +90,14 @@ development =
black == 24.3 black == 24.3
grpcio-tools >= 1.62.1 grpcio-tools >= 1.62.1
invoke >= 1.7.3 invoke >= 1.7.3
mypy == 1.8.0 mypy == 1.10.0
nox >= 2022 nox >= 2022
pylint == 2.15.8 pylint == 3.1.0
pyyaml >= 6.0 pyyaml >= 6.0
types-appdirs >= 1.4.3 types-appdirs >= 1.4.3
types-invoke >= 1.7.3 types-invoke >= 1.7.3
types-protobuf >= 4.21.0 types-protobuf >= 4.21.0
wasmtime == 20.0.0
avatar = avatar =
pandora-avatar == 0.0.9 pandora-avatar == 0.0.9
rootcanal == 1.10.0 ; python_version>='3.10' rootcanal == 1.10.0 ; python_version>='3.10'
+20 -1
View File
@@ -20,7 +20,10 @@ Invoke tasks
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import os import os
import glob
import shutil
import urllib
from pathlib import Path
from invoke import task, call, Collection from invoke import task, call, Collection
from invoke.exceptions import Exit, UnexpectedExit from invoke.exceptions import Exit, UnexpectedExit
@@ -205,5 +208,21 @@ def serve(ctx, port=8000):
server.serve_forever() server.serve_forever()
# -----------------------------------------------------------------------------
@task
def web_build(ctx):
# Step 1: build the wheel
build(ctx)
# Step 2: Copy the wheel to the web folder, so the http server can access it
newest_wheel = Path(max(glob.glob('dist/*.whl'), key=lambda f: os.path.getmtime(f)))
shutil.copy(newest_wheel, Path('web/'))
# Step 3: Write wheel's name to web/packageFile
with open(Path('web', 'packageFile'), mode='w') as package_file:
package_file.write(str(Path('/') / newest_wheel.name))
# Step 4: Success!
print('Include ?packageFile=true in your URL!')
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
web_tasks.add_task(serve) web_tasks.add_task(serve)
web_tasks.add_task(web_build, name="build")
+4 -4
View File
@@ -72,7 +72,7 @@ def test_codec_specific_capabilities() -> None:
cap = CodecSpecificCapabilities( cap = CodecSpecificCapabilities(
supported_sampling_frequencies=SAMPLE_FREQUENCY, supported_sampling_frequencies=SAMPLE_FREQUENCY,
supported_frame_durations=FRAME_SURATION, supported_frame_durations=FRAME_SURATION,
supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS, supported_audio_channel_count=AUDIO_CHANNEL_COUNTS,
min_octets_per_codec_frame=40, min_octets_per_codec_frame=40,
max_octets_per_codec_frame=40, max_octets_per_codec_frame=40,
supported_max_codec_frames_per_sdu=1, supported_max_codec_frames_per_sdu=1,
@@ -88,7 +88,7 @@ def test_pac_record() -> None:
cap = CodecSpecificCapabilities( cap = CodecSpecificCapabilities(
supported_sampling_frequencies=SAMPLE_FREQUENCY, supported_sampling_frequencies=SAMPLE_FREQUENCY,
supported_frame_durations=FRAME_SURATION, supported_frame_durations=FRAME_SURATION,
supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS, supported_audio_channel_count=AUDIO_CHANNEL_COUNTS,
min_octets_per_codec_frame=40, min_octets_per_codec_frame=40,
max_octets_per_codec_frame=40, max_octets_per_codec_frame=40,
supported_max_codec_frames_per_sdu=1, supported_max_codec_frames_per_sdu=1,
@@ -216,7 +216,7 @@ async def test_pacs():
supported_frame_durations=( supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED SupportedFrameDuration.DURATION_10000_US_SUPPORTED
), ),
supported_audio_channel_counts=[1], supported_audio_channel_count=[1],
min_octets_per_codec_frame=40, min_octets_per_codec_frame=40,
max_octets_per_codec_frame=40, max_octets_per_codec_frame=40,
supported_max_codec_frames_per_sdu=1, supported_max_codec_frames_per_sdu=1,
@@ -232,7 +232,7 @@ async def test_pacs():
supported_frame_durations=( supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED SupportedFrameDuration.DURATION_10000_US_SUPPORTED
), ),
supported_audio_channel_counts=[1], supported_audio_channel_count=[1],
min_octets_per_codec_frame=60, min_octets_per_codec_frame=60,
max_octets_per_codec_frame=60, max_octets_per_codec_frame=60,
supported_max_codec_frames_per_sdu=1, supported_max_codec_frames_per_sdu=1,
+30 -1
View File
@@ -15,7 +15,9 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from bumble.core import AdvertisingData, UUID, get_dict_key_by_value from enum import IntEnum
from bumble.core import AdvertisingData, Appearance, UUID, get_dict_key_by_value
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -66,8 +68,35 @@ def test_uuid_to_hex_str() -> None:
) )
# -----------------------------------------------------------------------------
def test_appearance() -> None:
a = Appearance(Appearance.Category.COMPUTER, Appearance.ComputerSubcategory.LAPTOP)
assert str(a) == 'COMPUTER/LAPTOP'
assert int(a) == 0x0083
a = Appearance(Appearance.Category.HUMAN_INTERFACE_DEVICE, 0x77)
assert str(a) == 'HUMAN_INTERFACE_DEVICE/HumanInterfaceDeviceSubcategory[119]'
assert int(a) == 0x03C0 | 0x77
a = Appearance.from_int(0x0381)
assert a.category == Appearance.Category.BLOOD_PRESSURE
assert a.subcategory == Appearance.BloodPressureSubcategory.ARM_BLOOD_PRESSURE
assert int(a) == 0x381
a = Appearance.from_int(0x038A)
assert a.category == Appearance.Category.BLOOD_PRESSURE
assert a.subcategory == 0x0A
assert int(a) == 0x038A
a = Appearance.from_int(0x3333)
assert a.category == 0xCC
assert a.subcategory == 0x33
assert int(a) == 0x3333
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
if __name__ == '__main__': if __name__ == '__main__':
test_ad_data() test_ad_data()
test_get_dict_key_by_value() test_get_dict_key_by_value()
test_uuid_to_hex_str() test_uuid_to_hex_str()
test_appearance()
+133 -8
View File
@@ -16,6 +16,7 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import functools
import logging import logging
import os import os
from types import LambdaType from types import LambdaType
@@ -35,12 +36,14 @@ from bumble.hci import (
HCI_COMMAND_STATUS_PENDING, HCI_COMMAND_STATUS_PENDING,
HCI_CREATE_CONNECTION_COMMAND, HCI_CREATE_CONNECTION_COMMAND,
HCI_SUCCESS, HCI_SUCCESS,
HCI_CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR,
Address, Address,
OwnAddressType, OwnAddressType,
HCI_Command_Complete_Event, HCI_Command_Complete_Event,
HCI_Command_Status_Event, HCI_Command_Status_Event,
HCI_Connection_Complete_Event, HCI_Connection_Complete_Event,
HCI_Connection_Request_Event, HCI_Connection_Request_Event,
HCI_Error,
HCI_Packet, HCI_Packet,
) )
from bumble.gatt import ( from bumble.gatt import (
@@ -52,6 +55,10 @@ from bumble.gatt import (
from .test_utils import TwoDevices, async_barrier from .test_utils import TwoDevices, async_barrier
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
_TIMEOUT = 0.1
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -214,6 +221,12 @@ async def test_device_connect_parallel():
d1.host.set_packet_sink(Sink(d1_flow())) d1.host.set_packet_sink(Sink(d1_flow()))
d2.host.set_packet_sink(Sink(d2_flow())) d2.host.set_packet_sink(Sink(d2_flow()))
d1_accept_task = asyncio.create_task(d1.accept(peer_address=d0.public_address))
d2_accept_task = asyncio.create_task(d2.accept())
# Ensure that the accept tasks have started.
await async_barrier()
[c01, c02, a10, a20] = await asyncio.gather( [c01, c02, a10, a20] = await asyncio.gather(
*[ *[
asyncio.create_task( asyncio.create_task(
@@ -222,8 +235,8 @@ async def test_device_connect_parallel():
asyncio.create_task( asyncio.create_task(
d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT) d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT)
), ),
asyncio.create_task(d1.accept(peer_address=d0.public_address)), d1_accept_task,
asyncio.create_task(d2.accept()), d2_accept_task,
] ]
) )
@@ -288,9 +301,7 @@ async def test_legacy_advertising_connection(own_address_type):
else: else:
assert device.lookup_connection(0x0001).self_address == device.random_address assert device.lookup_connection(0x0001).self_address == device.random_address
# For unknown reason, read_phy() in on_connection() would be killed at the end of await async_barrier()
# test, so we force scheduling here to avoid an warning.
await asyncio.sleep(0.0001)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -371,9 +382,41 @@ async def test_extended_advertising_connection(own_address_type):
else: else:
assert device.lookup_connection(0x0001).self_address == device.random_address assert device.lookup_connection(0x0001).self_address == device.random_address
# For unknown reason, read_phy() in on_connection() would be killed at the end of await async_barrier()
# test, so we force scheduling here to avoid an warning.
await asyncio.sleep(0.0001)
# -----------------------------------------------------------------------------
@pytest.mark.parametrize(
'own_address_type,',
(OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
)
@pytest.mark.asyncio
async def test_extended_advertising_connection_out_of_order(own_address_type):
device = Device(host=mock.AsyncMock(spec=Host))
peer_address = Address('F0:F1:F2:F3:F4:F5')
advertising_set = await device.create_advertising_set(
advertising_parameters=AdvertisingParameters(own_address_type=own_address_type)
)
device.on_advertising_set_termination(
HCI_SUCCESS,
advertising_set.advertising_handle,
0x0001,
0,
)
device.on_connection(
0x0001,
BT_LE_TRANSPORT,
peer_address,
BT_PERIPHERAL_ROLE,
ConnectionParameters(0, 0, 0),
)
if own_address_type == OwnAddressType.PUBLIC:
assert device.lookup_connection(0x0001).self_address == device.public_address
else:
assert device.lookup_connection(0x0001).self_address == device.random_address
await async_barrier()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -385,6 +428,29 @@ async def test_get_remote_le_features():
assert (await devices.connections[0].get_remote_le_features()) is not None assert (await devices.connections[0].get_remote_le_features()) is not None
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_remote_le_features_failed():
devices = TwoDevices()
await devices.setup_connection()
def on_hci_le_read_remote_features_complete_event(event):
devices[0].host.emit(
'le_remote_features_failure',
event.connection_handle,
HCI_CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR,
)
devices[0].host.on_hci_le_read_remote_features_complete_event = (
on_hci_le_read_remote_features_complete_event
)
with pytest.raises(HCI_Error):
await asyncio.wait_for(
devices.connections[0].get_remote_le_features(), _TIMEOUT
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cis(): async def test_cis():
@@ -433,6 +499,65 @@ async def test_cis():
await cis_links[1].disconnect() await cis_links[1].disconnect()
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cis_setup_failure():
devices = TwoDevices()
await devices.setup_connection()
cis_requests = asyncio.Queue()
def on_cis_request(
acl_connection: Connection,
cis_handle: int,
cig_id: int,
cis_id: int,
):
del acl_connection, cig_id, cis_id
cis_requests.put_nowait(cis_handle)
devices[1].on('cis_request', on_cis_request)
cis_handles = await devices[0].setup_cig(
cig_id=1,
cis_id=[2],
sdu_interval=(0, 0),
framing=0,
max_sdu=(0, 0),
retransmission_number=0,
max_transport_latency=(0, 0),
)
assert len(cis_handles) == 1
cis_create_task = asyncio.create_task(
devices[0].create_cis(
[
(cis_handles[0], devices.connections[0].handle),
]
)
)
def on_hci_le_cis_established_event(host, event):
host.emit(
'cis_establishment_failure',
event.connection_handle,
HCI_CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR,
)
for device in devices:
device.host.on_hci_le_cis_established_event = functools.partial(
on_hci_le_cis_established_event, device.host
)
cis_request = await asyncio.wait_for(cis_requests.get(), _TIMEOUT)
with pytest.raises(HCI_Error):
await asyncio.wait_for(devices[1].accept_cis_request(cis_request), _TIMEOUT)
with pytest.raises(HCI_Error):
await asyncio.wait_for(cis_create_task, _TIMEOUT)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def test_gatt_services_with_gas(): def test_gatt_services_with_gas():
device = Device(host=Host(None, None)) device = Device(host=Host(None, None))
+2 -2
View File
@@ -311,7 +311,7 @@ async def test_query_calls_without_calls(
): ):
hf, ag = hfp_connections hf, ag = hfp_connections
await hf.query_current_calls() == [] assert await hf.query_current_calls() == []
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -331,7 +331,7 @@ async def test_query_calls_with_calls(
) )
) )
await hf.query_current_calls() == ag.calls assert await hf.query_current_calls() == ag.calls
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+31 -1
View File
@@ -32,6 +32,8 @@ from bumble.rfcomm import (
RFCOMM_PSM, RFCOMM_PSM,
) )
_TIMEOUT = 0.1
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def basic_frame_check(x): def basic_frame_check(x):
@@ -60,7 +62,7 @@ def test_frames():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_basic_connection() -> None: async def test_connection_and_disconnection() -> None:
devices = test_utils.TwoDevices() devices = test_utils.TwoDevices()
await devices.setup_connection() await devices.setup_connection()
@@ -81,6 +83,34 @@ async def test_basic_connection() -> None:
dlcs[1].write(b'Lorem ipsum dolor sit amet') dlcs[1].write(b'Lorem ipsum dolor sit amet')
assert await queues[0].get() == b'Lorem ipsum dolor sit amet' assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
closed = asyncio.Event()
dlcs[1].on('close', closed.set)
await dlcs[1].disconnect()
await closed.wait()
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_receive_pdu_before_open_dlc_returns() -> None:
devices = await test_utils.TwoDevices.create_with_connection()
DATA = b'123'
accept_future: asyncio.Future[DLC] = asyncio.get_running_loop().create_future()
channel = Server(devices[0]).listen(acceptor=accept_future.set_result)
assert devices.connections[1]
multiplexer = await Client(devices.connections[1]).start()
open_dlc_task = asyncio.create_task(multiplexer.open_dlc(channel))
dlc_responder = await accept_future
dlc_responder.write(DATA)
dlc_initiator = await open_dlc_task
dlc_initiator_queue = asyncio.Queue() # type: ignore[var-annotated]
dlc_initiator.sink = dlc_initiator_queue.put_nowait
assert await asyncio.wait_for(dlc_initiator_queue.get(), timeout=_TIMEOUT) == DATA
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
+8 -1
View File
@@ -16,7 +16,8 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
from typing import List, Optional from typing import List, Optional, Type
from typing_extensions import Self
from bumble.controller import Controller from bumble.controller import Controller
from bumble.link import LocalLink from bumble.link import LocalLink
@@ -81,6 +82,12 @@ class TwoDevices:
def __getitem__(self, index: int) -> Device: def __getitem__(self, index: int) -> Device:
return self.devices[index] return self.devices[index]
@classmethod
async def create_with_connection(cls: Type[Self]) -> Self:
devices = cls()
await devices.setup_connection()
return devices
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def async_barrier(): async def async_barrier():
+3
View File
@@ -0,0 +1,3 @@
# files created by invoke web.build
*.whl
packageFile
+5
View File
@@ -24,9 +24,14 @@ controller using some other transport (ex: `python apps/hci_bridge.py ws-server:
For HTTP, start an HTTP server with the `web` directory as its For HTTP, start an HTTP server with the `web` directory as its
root. You can use the invoke task `inv web.serve` for convenience. root. You can use the invoke task `inv web.serve` for convenience.
`inv web.build` will build the local copy of bumble and automatically copy the `.whl` file
to the web directory. To use this build, include the param `?packageFile=true` to the URL.
In a browser, open either `scanner/scanner.html` or `speaker/speaker.html`. In a browser, open either `scanner/scanner.html` or `speaker/speaker.html`.
You can pass optional query parameters: You can pass optional query parameters:
* `packageFile=true` will automatically use the bumble package built via the
`inv web.build` command.
* `package` may be set to point to a local build of Bumble (`.whl` files). * `package` may be set to point to a local build of Bumble (`.whl` files).
The filename must be URL-encoded of course, and must be located under The filename must be URL-encoded of course, and must be located under
the `web` directory (the HTTP server won't serve files not under its the `web` directory (the HTTP server won't serve files not under its
+36 -6
View File
@@ -24,6 +24,11 @@ class PacketSource {
} }
class PacketSink { class PacketSink {
constructor() {
this.queue = [];
this.isProcessing = false;
}
on_packet(packet) { on_packet(packet) {
if (!this.writer) { if (!this.writer) {
return; return;
@@ -31,11 +36,24 @@ class PacketSink {
const buffer = packet.toJs({create_proxies : false}); const buffer = packet.toJs({create_proxies : false});
packet.destroy(); packet.destroy();
//console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`); //console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
// TODO: create an async queue here instead of blindly calling write without awaiting this.queue.push(buffer);
this.writer(buffer); this.processQueue();
}
async processQueue() {
if (this.isProcessing) {
return;
}
this.isProcessing = true;
while (this.queue.length > 0) {
const buffer = this.queue.shift();
await this.writer(buffer);
}
this.isProcessing = false;
} }
} }
class LogEvent extends Event { class LogEvent extends Event {
constructor(message) { constructor(message) {
super('log'); super('log');
@@ -57,7 +75,6 @@ export class Bumble extends EventTarget {
} }
// Load the Bumble module // Load the Bumble module
bumblePackage ||= 'bumble';
console.log('Installing micropip'); console.log('Installing micropip');
this.log(`Installing ${bumblePackage}`) this.log(`Installing ${bumblePackage}`)
await this.pyodide.loadPackage('micropip'); await this.pyodide.loadPackage('micropip');
@@ -148,6 +165,20 @@ export class Bumble extends EventTarget {
} }
} }
async function getBumblePackage() {
const params = (new URL(document.location)).searchParams;
// First check the packageFile override param
if (params.has('packageFile')) {
return await (await fetch('/packageFile')).text()
}
// Then check the package override param
if (params.has('package')) {
return params.get('package')
}
// If no override params, default to the main package
return 'bumble'
}
export async function setupSimpleApp(appUrl, bumbleControls, log) { export async function setupSimpleApp(appUrl, bumbleControls, log) {
// Load Bumble // Load Bumble
log('Loading Bumble'); log('Loading Bumble');
@@ -155,8 +186,7 @@ export async function setupSimpleApp(appUrl, bumbleControls, log) {
bumble.addEventListener('log', (event) => { bumble.addEventListener('log', (event) => {
log(event.message); log(event.message);
}) })
const params = (new URL(document.location)).searchParams; await bumble.loadRuntime(await getBumblePackage());
await bumble.loadRuntime(params.get('package'));
log('Bumble is ready!') log('Bumble is ready!')
const app = await bumble.loadApp(appUrl); const app = await bumble.loadApp(appUrl);
@@ -185,4 +215,4 @@ export async function setupSimpleApp(appUrl, bumbleControls, log) {
bumbleControls.onBumbleLoaded(); bumbleControls.onBumbleLoaded();
return app; return app;
} }