forked from auracaster/bumble_mirror
Compare commits
24 Commits
v0.0.192
...
gbg/basic-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
658f641a53 | ||
|
|
dea907be86 | ||
|
|
090309302f | ||
|
|
28e6229b24 | ||
|
|
1b66f03dbe | ||
|
|
e34f6b5fd3 | ||
|
|
8a0482c947 | ||
|
|
938a189f3f | ||
|
|
2005b4a11b | ||
|
|
951fdc8bdd | ||
|
|
12af7a526c | ||
|
|
8781943646 | ||
|
|
7fbfdb634c | ||
|
|
9682077f6b | ||
|
|
22eb405fde | ||
|
|
593c61973f | ||
|
|
ccff32102f | ||
|
|
851d62c6c9 | ||
|
|
a5ac5f26e2 | ||
|
|
090158820f | ||
|
|
26e6650038 | ||
|
|
c48568aabe | ||
|
|
1b33c9eb74 | ||
|
|
6633228975 |
2
.github/workflows/code-check.yml
vendored
2
.github/workflows/code-check.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
|
||||
4
.github/workflows/python-build-test.yml
vendored
4
.github/workflows/python-build-test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
|
||||
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
|
||||
rust-version: [ "1.76.0", "stable" ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"Abortable",
|
||||
"aiohttp",
|
||||
"altsetting",
|
||||
"ansiblue",
|
||||
"ansicyan",
|
||||
@@ -9,6 +10,7 @@
|
||||
"ansired",
|
||||
"ansiyellow",
|
||||
"appendleft",
|
||||
"ascs",
|
||||
"ASHA",
|
||||
"asyncio",
|
||||
"ATRAC",
|
||||
@@ -43,6 +45,7 @@
|
||||
"keyup",
|
||||
"levelname",
|
||||
"libc",
|
||||
"liblc",
|
||||
"libusb",
|
||||
"MITM",
|
||||
"MSBC",
|
||||
@@ -78,6 +81,7 @@
|
||||
"unmuted",
|
||||
"usbmodem",
|
||||
"vhci",
|
||||
"wasmtime",
|
||||
"websockets",
|
||||
"xcursor",
|
||||
"ycursor"
|
||||
|
||||
407
apps/auracast.py
Normal file
407
apps/auracast.py
Normal 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
|
||||
577
apps/lea_unicast/app.py
Normal file
577
apps/lea_unicast/app.py
Normal 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
apps/lea_unicast/index.html
Normal file
68
apps/lea_unicast/index.html
Normal 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
apps/lea_unicast/liblc3.wasm
Executable file
BIN
apps/lea_unicast/liblc3.wasm
Executable file
Binary file not shown.
804
bumble/core.py
804
bumble/core.py
@@ -16,11 +16,14 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import enum
|
||||
import struct
|
||||
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)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
|
||||
# -----------------------------------------------------------------------------
|
||||
AdvertisingObject = Union[
|
||||
List[UUID], Tuple[UUID, bytes], bytes, str, int, Tuple[int, int], Tuple[int, bytes]
|
||||
AdvertisingDataObject = Union[
|
||||
List[UUID],
|
||||
Tuple[UUID, bytes],
|
||||
bytes,
|
||||
str,
|
||||
int,
|
||||
Tuple[int, int],
|
||||
Tuple[int, bytes],
|
||||
Appearance,
|
||||
]
|
||||
|
||||
|
||||
@@ -704,109 +1265,115 @@ class AdvertisingData:
|
||||
# fmt: off
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
# This list is only partial, it still needs to be filled in from the spec
|
||||
FLAGS = 0x01
|
||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02
|
||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03
|
||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04
|
||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05
|
||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
|
||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07
|
||||
SHORTENED_LOCAL_NAME = 0x08
|
||||
COMPLETE_LOCAL_NAME = 0x09
|
||||
TX_POWER_LEVEL = 0x0A
|
||||
CLASS_OF_DEVICE = 0x0D
|
||||
SIMPLE_PAIRING_HASH_C = 0x0E
|
||||
SIMPLE_PAIRING_HASH_C_192 = 0x0E
|
||||
SIMPLE_PAIRING_RANDOMIZER_R = 0x0F
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F
|
||||
DEVICE_ID = 0x10
|
||||
SECURITY_MANAGER_TK_VALUE = 0x10
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12
|
||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14
|
||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15
|
||||
SERVICE_DATA = 0x16
|
||||
SERVICE_DATA_16_BIT_UUID = 0x16
|
||||
PUBLIC_TARGET_ADDRESS = 0x17
|
||||
RANDOM_TARGET_ADDRESS = 0x18
|
||||
APPEARANCE = 0x19
|
||||
ADVERTISING_INTERVAL = 0x1A
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B
|
||||
LE_ROLE = 0x1C
|
||||
SIMPLE_PAIRING_HASH_C_256 = 0x1D
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E
|
||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F
|
||||
SERVICE_DATA_32_BIT_UUID = 0x20
|
||||
SERVICE_DATA_128_BIT_UUID = 0x21
|
||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22
|
||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23
|
||||
URI = 0x24
|
||||
INDOOR_POSITIONING = 0x25
|
||||
TRANSPORT_DISCOVERY_DATA = 0x26
|
||||
LE_SUPPORTED_FEATURES = 0x27
|
||||
CHANNEL_MAP_UPDATE_INDICATION = 0x28
|
||||
PB_ADV = 0x29
|
||||
MESH_MESSAGE = 0x2A
|
||||
MESH_BEACON = 0x2B
|
||||
BIGINFO = 0x2C
|
||||
BROADCAST_CODE = 0x2D
|
||||
RESOLVABLE_SET_IDENTIFIER = 0x2E
|
||||
ADVERTISING_INTERVAL_LONG = 0x2F
|
||||
THREE_D_INFORMATION_DATA = 0x3D
|
||||
MANUFACTURER_SPECIFIC_DATA = 0xFF
|
||||
FLAGS = 0x01
|
||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02
|
||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03
|
||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04
|
||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05
|
||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
|
||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07
|
||||
SHORTENED_LOCAL_NAME = 0x08
|
||||
COMPLETE_LOCAL_NAME = 0x09
|
||||
TX_POWER_LEVEL = 0x0A
|
||||
CLASS_OF_DEVICE = 0x0D
|
||||
SIMPLE_PAIRING_HASH_C = 0x0E
|
||||
SIMPLE_PAIRING_HASH_C_192 = 0x0E
|
||||
SIMPLE_PAIRING_RANDOMIZER_R = 0x0F
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F
|
||||
DEVICE_ID = 0x10
|
||||
SECURITY_MANAGER_TK_VALUE = 0x10
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12
|
||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14
|
||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15
|
||||
SERVICE_DATA = 0x16
|
||||
SERVICE_DATA_16_BIT_UUID = 0x16
|
||||
PUBLIC_TARGET_ADDRESS = 0x17
|
||||
RANDOM_TARGET_ADDRESS = 0x18
|
||||
APPEARANCE = 0x19
|
||||
ADVERTISING_INTERVAL = 0x1A
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B
|
||||
LE_ROLE = 0x1C
|
||||
SIMPLE_PAIRING_HASH_C_256 = 0x1D
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E
|
||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F
|
||||
SERVICE_DATA_32_BIT_UUID = 0x20
|
||||
SERVICE_DATA_128_BIT_UUID = 0x21
|
||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22
|
||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23
|
||||
URI = 0x24
|
||||
INDOOR_POSITIONING = 0x25
|
||||
TRANSPORT_DISCOVERY_DATA = 0x26
|
||||
LE_SUPPORTED_FEATURES = 0x27
|
||||
CHANNEL_MAP_UPDATE_INDICATION = 0x28
|
||||
PB_ADV = 0x29
|
||||
MESH_MESSAGE = 0x2A
|
||||
MESH_BEACON = 0x2B
|
||||
BIGINFO = 0x2C
|
||||
BROADCAST_CODE = 0x2D
|
||||
RESOLVABLE_SET_IDENTIFIER = 0x2E
|
||||
ADVERTISING_INTERVAL_LONG = 0x2F
|
||||
BROADCAST_NAME = 0x30
|
||||
ENCRYPTED_ADVERTISING_DATA = 0X31
|
||||
PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION = 0X32
|
||||
ELECTRONIC_SHELF_LABEL = 0X34
|
||||
THREE_D_INFORMATION_DATA = 0x3D
|
||||
MANUFACTURER_SPECIFIC_DATA = 0xFF
|
||||
|
||||
AD_TYPE_NAMES = {
|
||||
FLAGS: 'FLAGS',
|
||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
|
||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
|
||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
|
||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
|
||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
|
||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
|
||||
SHORTENED_LOCAL_NAME: 'SHORTENED_LOCAL_NAME',
|
||||
COMPLETE_LOCAL_NAME: 'COMPLETE_LOCAL_NAME',
|
||||
TX_POWER_LEVEL: 'TX_POWER_LEVEL',
|
||||
CLASS_OF_DEVICE: 'CLASS_OF_DEVICE',
|
||||
SIMPLE_PAIRING_HASH_C: 'SIMPLE_PAIRING_HASH_C',
|
||||
SIMPLE_PAIRING_HASH_C_192: 'SIMPLE_PAIRING_HASH_C_192',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R: 'SIMPLE_PAIRING_RANDOMIZER_R',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192: 'SIMPLE_PAIRING_RANDOMIZER_R_192',
|
||||
DEVICE_ID: 'DEVICE_ID',
|
||||
SECURITY_MANAGER_TK_VALUE: 'SECURITY_MANAGER_TK_VALUE',
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS: 'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE: 'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
|
||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||
SERVICE_DATA: 'SERVICE_DATA',
|
||||
SERVICE_DATA_16_BIT_UUID: 'SERVICE_DATA_16_BIT_UUID',
|
||||
PUBLIC_TARGET_ADDRESS: 'PUBLIC_TARGET_ADDRESS',
|
||||
RANDOM_TARGET_ADDRESS: 'RANDOM_TARGET_ADDRESS',
|
||||
APPEARANCE: 'APPEARANCE',
|
||||
ADVERTISING_INTERVAL: 'ADVERTISING_INTERVAL',
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS: 'LE_BLUETOOTH_DEVICE_ADDRESS',
|
||||
LE_ROLE: 'LE_ROLE',
|
||||
SIMPLE_PAIRING_HASH_C_256: 'SIMPLE_PAIRING_HASH_C_256',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_256: 'SIMPLE_PAIRING_RANDOMIZER_R_256',
|
||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||
SERVICE_DATA_32_BIT_UUID: 'SERVICE_DATA_32_BIT_UUID',
|
||||
SERVICE_DATA_128_BIT_UUID: 'SERVICE_DATA_128_BIT_UUID',
|
||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE: 'LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE',
|
||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE: 'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
|
||||
URI: 'URI',
|
||||
INDOOR_POSITIONING: 'INDOOR_POSITIONING',
|
||||
TRANSPORT_DISCOVERY_DATA: 'TRANSPORT_DISCOVERY_DATA',
|
||||
LE_SUPPORTED_FEATURES: 'LE_SUPPORTED_FEATURES',
|
||||
CHANNEL_MAP_UPDATE_INDICATION: 'CHANNEL_MAP_UPDATE_INDICATION',
|
||||
PB_ADV: 'PB_ADV',
|
||||
MESH_MESSAGE: 'MESH_MESSAGE',
|
||||
MESH_BEACON: 'MESH_BEACON',
|
||||
BIGINFO: 'BIGINFO',
|
||||
BROADCAST_CODE: 'BROADCAST_CODE',
|
||||
RESOLVABLE_SET_IDENTIFIER: 'RESOLVABLE_SET_IDENTIFIER',
|
||||
ADVERTISING_INTERVAL_LONG: 'ADVERTISING_INTERVAL_LONG',
|
||||
THREE_D_INFORMATION_DATA: 'THREE_D_INFORMATION_DATA',
|
||||
MANUFACTURER_SPECIFIC_DATA: 'MANUFACTURER_SPECIFIC_DATA'
|
||||
FLAGS: 'FLAGS',
|
||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
|
||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
|
||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
|
||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
|
||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
|
||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
|
||||
SHORTENED_LOCAL_NAME: 'SHORTENED_LOCAL_NAME',
|
||||
COMPLETE_LOCAL_NAME: 'COMPLETE_LOCAL_NAME',
|
||||
TX_POWER_LEVEL: 'TX_POWER_LEVEL',
|
||||
CLASS_OF_DEVICE: 'CLASS_OF_DEVICE',
|
||||
SIMPLE_PAIRING_HASH_C: 'SIMPLE_PAIRING_HASH_C',
|
||||
SIMPLE_PAIRING_HASH_C_192: 'SIMPLE_PAIRING_HASH_C_192',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R: 'SIMPLE_PAIRING_RANDOMIZER_R',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192: 'SIMPLE_PAIRING_RANDOMIZER_R_192',
|
||||
DEVICE_ID: 'DEVICE_ID',
|
||||
SECURITY_MANAGER_TK_VALUE: 'SECURITY_MANAGER_TK_VALUE',
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS: 'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE: 'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
|
||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||
SERVICE_DATA_16_BIT_UUID: 'SERVICE_DATA_16_BIT_UUID',
|
||||
PUBLIC_TARGET_ADDRESS: 'PUBLIC_TARGET_ADDRESS',
|
||||
RANDOM_TARGET_ADDRESS: 'RANDOM_TARGET_ADDRESS',
|
||||
APPEARANCE: 'APPEARANCE',
|
||||
ADVERTISING_INTERVAL: 'ADVERTISING_INTERVAL',
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS: 'LE_BLUETOOTH_DEVICE_ADDRESS',
|
||||
LE_ROLE: 'LE_ROLE',
|
||||
SIMPLE_PAIRING_HASH_C_256: 'SIMPLE_PAIRING_HASH_C_256',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_256: 'SIMPLE_PAIRING_RANDOMIZER_R_256',
|
||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||
SERVICE_DATA_32_BIT_UUID: 'SERVICE_DATA_32_BIT_UUID',
|
||||
SERVICE_DATA_128_BIT_UUID: 'SERVICE_DATA_128_BIT_UUID',
|
||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE: 'LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE',
|
||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE: 'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
|
||||
URI: 'URI',
|
||||
INDOOR_POSITIONING: 'INDOOR_POSITIONING',
|
||||
TRANSPORT_DISCOVERY_DATA: 'TRANSPORT_DISCOVERY_DATA',
|
||||
LE_SUPPORTED_FEATURES: 'LE_SUPPORTED_FEATURES',
|
||||
CHANNEL_MAP_UPDATE_INDICATION: 'CHANNEL_MAP_UPDATE_INDICATION',
|
||||
PB_ADV: 'PB_ADV',
|
||||
MESH_MESSAGE: 'MESH_MESSAGE',
|
||||
MESH_BEACON: 'MESH_BEACON',
|
||||
BIGINFO: 'BIGINFO',
|
||||
BROADCAST_CODE: 'BROADCAST_CODE',
|
||||
RESOLVABLE_SET_IDENTIFIER: 'RESOLVABLE_SET_IDENTIFIER',
|
||||
ADVERTISING_INTERVAL_LONG: 'ADVERTISING_INTERVAL_LONG',
|
||||
BROADCAST_NAME: 'BROADCAST_NAME',
|
||||
ENCRYPTED_ADVERTISING_DATA: 'ENCRYPTED_ADVERTISING_DATA',
|
||||
PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION: 'PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION',
|
||||
ELECTRONIC_SHELF_LABEL: 'ELECTRONIC_SHELF_LABEL',
|
||||
THREE_D_INFORMATION_DATA: 'THREE_D_INFORMATION_DATA',
|
||||
MANUFACTURER_SPECIFIC_DATA: 'MANUFACTURER_SPECIFIC_DATA'
|
||||
}
|
||||
|
||||
LE_LIMITED_DISCOVERABLE_MODE_FLAG = 0x01
|
||||
@@ -915,7 +1482,11 @@ class AdvertisingData:
|
||||
ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
|
||||
elif ad_type == AdvertisingData.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:
|
||||
ad_type_str = AdvertisingData.AD_TYPE_NAMES.get(ad_type, f'0x{ad_type:02X}')
|
||||
ad_data_str = ad_data.hex()
|
||||
@@ -924,7 +1495,7 @@ class AdvertisingData:
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
@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 (
|
||||
AdvertisingData.COMPLETE_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.COMPLETE_LOCAL_NAME,
|
||||
AdvertisingData.URI,
|
||||
AdvertisingData.BROADCAST_NAME,
|
||||
):
|
||||
return ad_data.decode("utf-8")
|
||||
|
||||
if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS):
|
||||
return cast(int, struct.unpack('B', ad_data)[0])
|
||||
|
||||
if ad_type in (
|
||||
AdvertisingData.APPEARANCE,
|
||||
AdvertisingData.ADVERTISING_INTERVAL,
|
||||
):
|
||||
if ad_type in (AdvertisingData.ADVERTISING_INTERVAL,):
|
||||
return cast(int, struct.unpack('<H', ad_data)[0])
|
||||
|
||||
if ad_type == AdvertisingData.CLASS_OF_DEVICE:
|
||||
@@ -980,6 +1549,11 @@ class AdvertisingData:
|
||||
if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
|
||||
return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:])
|
||||
|
||||
if ad_type == AdvertisingData.APPEARANCE:
|
||||
return Appearance.from_int(
|
||||
cast(int, struct.unpack_from('<H', ad_data, 0)[0])
|
||||
)
|
||||
|
||||
return ad_data
|
||||
|
||||
def append(self, data: bytes) -> None:
|
||||
@@ -993,27 +1567,27 @@ class AdvertisingData:
|
||||
self.ad_structures.append((ad_type, ad_data))
|
||||
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
|
||||
|
||||
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 [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
|
||||
|
||||
Returns the first entry, or None if no structure matches.
|
||||
'''
|
||||
|
||||
all = self.get_all(type_id, raw=raw)
|
||||
return all[0] if all else None
|
||||
all_objects = self.get_all(type_id, raw=raw)
|
||||
return all_objects[0] if all_objects else None
|
||||
|
||||
def __bytes__(self):
|
||||
return b''.join(
|
||||
|
||||
669
bumble/device.py
669
bumble/device.py
@@ -16,15 +16,21 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
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 json
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager, AsyncExitStack, closing
|
||||
from dataclasses import dataclass, field
|
||||
from collections.abc import Iterable
|
||||
import sys
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
@@ -40,6 +46,7 @@ from typing import (
|
||||
overload,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
from typing_extensions import Self
|
||||
|
||||
from pyee import EventEmitter
|
||||
|
||||
@@ -73,6 +80,7 @@ from .hci import (
|
||||
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
|
||||
HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
|
||||
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||
HCI_OPERATION_CANCELLED_BY_HOST_ERROR,
|
||||
HCI_R2_PAGE_SCAN_REPETITION_MODE,
|
||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||
HCI_SUCCESS,
|
||||
@@ -94,11 +102,16 @@ from .hci import (
|
||||
HCI_LE_Accept_CIS_Request_Command,
|
||||
HCI_LE_Add_Device_To_Resolving_List_Command,
|
||||
HCI_LE_Advertising_Report_Event,
|
||||
HCI_LE_BIGInfo_Advertising_Report_Event,
|
||||
HCI_LE_Clear_Resolving_List_Command,
|
||||
HCI_LE_Connection_Update_Command,
|
||||
HCI_LE_Create_Connection_Cancel_Command,
|
||||
HCI_LE_Create_Connection_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_Extended_Advertising_Report_Event,
|
||||
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 = (
|
||||
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
|
||||
# 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
|
||||
AdvertisingChannelMap = HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
|
||||
@@ -787,6 +866,201 @@ class AdvertisingSet(EventEmitter):
|
||||
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:
|
||||
# Coded PHY preference
|
||||
@@ -959,8 +1233,9 @@ class ScoLink(CompositeEventEmitter):
|
||||
acl_connection: Connection
|
||||
handle: int
|
||||
link_type: int
|
||||
sink: Optional[Callable[[HCI_SynchronousDataPacket], Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
async def disconnect(
|
||||
@@ -982,8 +1257,9 @@ class CisLink(CompositeEventEmitter):
|
||||
cis_id: int # CIS ID assigned by Central device
|
||||
cig_id: int # CIG ID assigned by Central device
|
||||
state: State = State.PENDING
|
||||
sink: Optional[Callable[[HCI_IsoDataPacket], Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
def __post_init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
async def disconnect(
|
||||
@@ -1252,75 +1528,47 @@ class Connection(CompositeEventEmitter):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class DeviceConfiguration:
|
||||
def __init__(self) -> None:
|
||||
# Setup defaults
|
||||
self.name = DEVICE_DEFAULT_NAME
|
||||
self.address = Address(DEVICE_DEFAULT_ADDRESS)
|
||||
self.class_of_device = DEVICE_DEFAULT_CLASS_OF_DEVICE
|
||||
self.scan_response_data = DEVICE_DEFAULT_SCAN_RESPONSE_DATA
|
||||
self.advertising_interval_min = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
||||
self.advertising_interval_max = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
||||
self.le_enabled = True
|
||||
# LE host enable 2nd parameter
|
||||
self.le_simultaneous_enabled = False
|
||||
self.classic_enabled = False
|
||||
self.classic_sc_enabled = True
|
||||
self.classic_ssp_enabled = True
|
||||
self.classic_smp_enabled = True
|
||||
self.classic_accept_any = True
|
||||
self.connectable = True
|
||||
self.discoverable = True
|
||||
self.advertising_data = bytes(
|
||||
AdvertisingData(
|
||||
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
|
||||
)
|
||||
# Setup defaults
|
||||
name: str = DEVICE_DEFAULT_NAME
|
||||
address: Address = Address(DEVICE_DEFAULT_ADDRESS)
|
||||
class_of_device: int = DEVICE_DEFAULT_CLASS_OF_DEVICE
|
||||
scan_response_data: bytes = DEVICE_DEFAULT_SCAN_RESPONSE_DATA
|
||||
advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
||||
advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
||||
le_enabled: bool = True
|
||||
# LE host enable 2nd parameter
|
||||
le_simultaneous_enabled: bool = False
|
||||
classic_enabled: bool = False
|
||||
classic_sc_enabled: bool = True
|
||||
classic_ssp_enabled: bool = True
|
||||
classic_smp_enabled: bool = True
|
||||
classic_accept_any: bool = True
|
||||
connectable: bool = True
|
||||
discoverable: bool = True
|
||||
advertising_data: bytes = bytes(
|
||||
AdvertisingData(
|
||||
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(DEVICE_DEFAULT_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.address_resolution_offload = False
|
||||
self.cis_enabled = False
|
||||
|
||||
def load_from_dict(self, config: Dict[str, Any]) -> None:
|
||||
config = copy.deepcopy(config)
|
||||
|
||||
# Load simple properties
|
||||
self.name = config.get('name', self.name)
|
||||
if address := config.get('address', None):
|
||||
if address := config.pop('address', None):
|
||||
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
|
||||
irk = config.get('irk')
|
||||
if irk:
|
||||
if irk := config.pop('irk', None):
|
||||
self.irk = bytes.fromhex(irk)
|
||||
elif self.address != Address(DEVICE_DEFAULT_ADDRESS):
|
||||
# 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.
|
||||
self.irk = secrets.token_bytes(16)
|
||||
|
||||
if (name := config.pop('name', None)) is not None:
|
||||
self.name = name
|
||||
|
||||
# Load advertising data
|
||||
advertising_data = config.get('advertising_data')
|
||||
if advertising_data:
|
||||
if advertising_data := config.pop('advertising_data', None):
|
||||
self.advertising_data = bytes.fromhex(advertising_data)
|
||||
elif config.get('name') is not None:
|
||||
elif name is not None:
|
||||
self.advertising_data = bytes(
|
||||
AdvertisingData(
|
||||
[(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:
|
||||
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
|
||||
@@ -1395,6 +1675,20 @@ def try_with_connection_from_address(function):
|
||||
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.
|
||||
# This assumes that the method name starts with `on_`
|
||||
def host_event_handler(function):
|
||||
@@ -1425,6 +1719,7 @@ class Device(CompositeEventEmitter):
|
||||
Address, List[asyncio.Future[Union[Connection, Tuple[Address, int, int]]]]
|
||||
]
|
||||
advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator]
|
||||
periodic_advertising_syncs: List[PeriodicAdvertisingSync]
|
||||
config: DeviceConfiguration
|
||||
legacy_advertiser: Optional[LegacyAdvertiser]
|
||||
sco_links: Dict[int, ScoLink]
|
||||
@@ -1470,8 +1765,7 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
@classmethod
|
||||
def from_config_file(cls, filename: str) -> Device:
|
||||
config = DeviceConfiguration()
|
||||
config.load_from_file(filename)
|
||||
config = DeviceConfiguration.from_file(filename)
|
||||
return cls(config=config)
|
||||
|
||||
@classmethod
|
||||
@@ -1488,8 +1782,7 @@ class Device(CompositeEventEmitter):
|
||||
def from_config_file_with_hci(
|
||||
cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
|
||||
) -> Device:
|
||||
config = DeviceConfiguration()
|
||||
config.load_from_file(filename)
|
||||
config = DeviceConfiguration.from_file(filename)
|
||||
return cls.from_config_with_hci(config, hci_source, hci_sink)
|
||||
|
||||
def __init__(
|
||||
@@ -1512,6 +1805,7 @@ class Device(CompositeEventEmitter):
|
||||
[l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS]
|
||||
)
|
||||
self.advertisement_accumulators = {} # Accumulators, by address
|
||||
self.periodic_advertising_syncs = []
|
||||
self.scanning = False
|
||||
self.scanning_is_passive = False
|
||||
self.discovering = False
|
||||
@@ -1529,6 +1823,12 @@ class Device(CompositeEventEmitter):
|
||||
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
|
||||
self.connect_own_address_type = None
|
||||
|
||||
@@ -1688,6 +1988,18 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
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()")
|
||||
def register_l2cap_server(self, psm, server) -> int:
|
||||
return self.l2cap_channel_manager.register_server(psm, server)
|
||||
@@ -2184,7 +2496,7 @@ class Device(CompositeEventEmitter):
|
||||
# controller.
|
||||
await self.send_command(
|
||||
HCI_LE_Remove_Advertising_Set_Command(
|
||||
advertising_handle=advertising_data
|
||||
advertising_handle=advertising_handle
|
||||
),
|
||||
check_result=False,
|
||||
)
|
||||
@@ -2350,6 +2662,116 @@ class Device(CompositeEventEmitter):
|
||||
if advertisement := accumulator.update(report):
|
||||
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:
|
||||
await self.send_command(
|
||||
HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE),
|
||||
@@ -3402,49 +3824,71 @@ class Device(CompositeEventEmitter):
|
||||
for cis_handle, _ in cis_acl_pairs
|
||||
}
|
||||
|
||||
@watcher.on(self, 'cis_establishment')
|
||||
def on_cis_establishment(cis_link: CisLink) -> None:
|
||||
if pending_future := pending_cis_establishments.get(cis_link.handle):
|
||||
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(
|
||||
cis_connection_handle=[p[0] 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())
|
||||
|
||||
# [LE only]
|
||||
@experimental('Only for testing.')
|
||||
async def accept_cis_request(self, handle: int) -> CisLink:
|
||||
result = await self.send_command(
|
||||
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)
|
||||
"""[LE Only] Accepts an incoming CIS request.
|
||||
|
||||
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')
|
||||
def on_cis_establishment(cis_link: CisLink) -> None:
|
||||
if cis_link.handle == handle:
|
||||
pending_cis_establishment.set_result(cis_link)
|
||||
Returns:
|
||||
CIS link object on the given handle.
|
||||
"""
|
||||
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]
|
||||
@experimental('Only for testing.')
|
||||
@@ -3453,15 +3897,10 @@ class Device(CompositeEventEmitter):
|
||||
handle: int,
|
||||
reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||
) -> None:
|
||||
result = await self.send_command(
|
||||
await self.send_command(
|
||||
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:
|
||||
"""[LE Only] Reads remote LE supported features.
|
||||
@@ -3481,11 +3920,17 @@ class Device(CompositeEventEmitter):
|
||||
if handle == connection.handle:
|
||||
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_failure', on_failure)
|
||||
await self.send_command(
|
||||
HCI_LE_Read_Remote_Features_Command(
|
||||
connection_handle=connection.handle
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
return await read_feature_future
|
||||
|
||||
@@ -4107,8 +4552,8 @@ class Device(CompositeEventEmitter):
|
||||
@host_event_handler
|
||||
@experimental('Only for testing')
|
||||
def on_sco_packet(self, sco_handle: int, packet: HCI_SynchronousDataPacket) -> None:
|
||||
if sco_link := self.sco_links.get(sco_handle):
|
||||
sco_link.emit('pdu', packet)
|
||||
if (sco_link := self.sco_links.get(sco_handle)) and sco_link.sink:
|
||||
sco_link.sink(packet)
|
||||
|
||||
# [LE only]
|
||||
@host_event_handler
|
||||
@@ -4164,15 +4609,15 @@ class Device(CompositeEventEmitter):
|
||||
def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None:
|
||||
logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***')
|
||||
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)
|
||||
|
||||
# [LE only]
|
||||
@host_event_handler
|
||||
@experimental('Only for testing')
|
||||
def on_iso_packet(self, handle: int, packet: HCI_IsoDataPacket) -> None:
|
||||
if cis_link := self.cis_links.get(handle):
|
||||
cis_link.emit('pdu', packet)
|
||||
if (cis_link := self.cis_links.get(handle)) and cis_link.sink:
|
||||
cis_link.sink(packet)
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
|
||||
299
bumble/hci.py
299
bumble/hci.py
@@ -23,11 +23,11 @@ import functools
|
||||
import logging
|
||||
import secrets
|
||||
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 .colors import color
|
||||
from .core import (
|
||||
from bumble.colors import color
|
||||
from bumble.core import (
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
AdvertisingData,
|
||||
DeviceClass,
|
||||
@@ -36,6 +36,7 @@ from .core import (
|
||||
name_or_number,
|
||||
padded_bytes,
|
||||
)
|
||||
from bumble.utils import OpenIntEnum
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1380,7 +1381,7 @@ class LmpFeatureMask(enum.IntFlag):
|
||||
STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)}
|
||||
|
||||
|
||||
class CodecID(enum.IntEnum):
|
||||
class CodecID(OpenIntEnum):
|
||||
# fmt: off
|
||||
U_LOG = 0x00
|
||||
A_LOG = 0x01
|
||||
@@ -1967,6 +1968,9 @@ class Address:
|
||||
def __str__(self):
|
||||
return self.to_string()
|
||||
|
||||
def __repr__(self):
|
||||
return f'Address({self.to_string(False)}/{self.address_type_name(self.address_type)})'
|
||||
|
||||
|
||||
# Predefined address values
|
||||
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
|
||||
'''
|
||||
|
||||
hci_packet_type: int
|
||||
hci_packet_type: ClassVar[int]
|
||||
|
||||
@staticmethod
|
||||
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(
|
||||
[
|
||||
@@ -4487,14 +4565,6 @@ class HCI_LE_Set_Privacy_Mode_Command(HCI_Command):
|
||||
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(
|
||||
fields=[
|
||||
@@ -4655,6 +4725,14 @@ class HCI_LE_Remove_ISO_Data_Path_Command(HCI_Command):
|
||||
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
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -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(
|
||||
[
|
||||
@@ -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)])
|
||||
class HCI_Inquiry_Complete_Event(HCI_Event):
|
||||
@@ -6192,12 +6430,23 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class HCI_IsoDataPacket(HCI_Packet):
|
||||
'''
|
||||
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
|
||||
def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
|
||||
@@ -6241,28 +6490,6 @@ class HCI_IsoDataPacket(HCI_Packet):
|
||||
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:
|
||||
return self.to_bytes()
|
||||
|
||||
|
||||
310
bumble/hfp.py
310
bumble/hfp.py
@@ -50,7 +50,7 @@ from bumble.core import (
|
||||
ProtocolError,
|
||||
BT_GENERIC_AUDIO_SERVICE,
|
||||
BT_HANDSFREE_SERVICE,
|
||||
BT_HEADSET_AUDIO_GATEWAY_SERVICE,
|
||||
BT_HANDSFREE_AUDIO_GATEWAY_SERVICE,
|
||||
BT_L2CAP_PROTOCOL_ID,
|
||||
BT_RFCOMM_PROTOCOL_ID,
|
||||
)
|
||||
@@ -204,17 +204,22 @@ class HfIndicator(enum.IntEnum):
|
||||
BATTERY_LEVEL = 0x02 # Battery level feature
|
||||
|
||||
|
||||
class CallHoldOperation(enum.IntEnum):
|
||||
class CallHoldOperation(enum.Enum):
|
||||
"""
|
||||
Call Hold supported operations (normative).
|
||||
|
||||
AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services.
|
||||
"""
|
||||
|
||||
RELEASE_ALL_HELD_CALLS = 0 # Release all held calls
|
||||
RELEASE_ALL_ACTIVE_CALLS = 1 # Release all active calls, accept other
|
||||
HOLD_ALL_ACTIVE_CALLS = 2 # Place all active calls on hold, accept other
|
||||
ADD_HELD_CALL = 3 # Adds a held call to conversation
|
||||
RELEASE_ALL_HELD_CALLS = "0" # Release all held calls
|
||||
RELEASE_ALL_ACTIVE_CALLS = "1" # Release all active calls, accept other
|
||||
RELEASE_SPECIFIC_CALL = "1x" # Release a specific call X
|
||||
HOLD_ALL_ACTIVE_CALLS = "2" # Place all active calls on hold, accept other
|
||||
HOLD_ALL_CALLS_EXCEPT = "2x" # Place all active calls except call X
|
||||
ADD_HELD_CALL = "3" # Adds a held call to conversation
|
||||
CONNECT_TWO_CALLS = (
|
||||
"4" # Connects the two calls and disconnects the subscriber from both calls
|
||||
)
|
||||
|
||||
|
||||
class ResponseHoldStatus(enum.IntEnum):
|
||||
@@ -335,10 +340,82 @@ class CallInfo:
|
||||
status: CallInfoStatus
|
||||
mode: CallInfoMode
|
||||
multi_party: CallInfoMultiParty
|
||||
number: Optional[int] = None
|
||||
number: Optional[str] = None
|
||||
type: Optional[int] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CallLineIdentification:
|
||||
"""
|
||||
Calling Line Identification notification.
|
||||
|
||||
TS 127 007 - V6.8.0, 7.6 Calling line identification presentation +CLIP, but only
|
||||
number, type and alpha are meaningful in HFP.
|
||||
|
||||
Attributes:
|
||||
number: String type phone number of format specified by `type`.
|
||||
type: Type of address octet in integer format (refer TS 24.008 [8] subclause
|
||||
10.5.4.7).
|
||||
subaddr: String type subaddress of format specified by `satype`.
|
||||
satype: Type of subaddress octet in integer format (refer TS 24.008 [8]
|
||||
subclause 10.5.4.8).
|
||||
alpha: Optional string type alphanumeric representation of number corresponding
|
||||
to the entry found in phonebook; used character set should be the one selected
|
||||
with command Select TE Character Set +CSCS.
|
||||
cli_validity: 0 CLI valid, 1 CLI has been withheld by the originator, 2 CLI is
|
||||
not available due to interworking problems or limitations of originating
|
||||
network.
|
||||
"""
|
||||
|
||||
number: str
|
||||
type: int
|
||||
subaddr: Optional[str] = None
|
||||
satype: Optional[int] = None
|
||||
alpha: Optional[str] = None
|
||||
cli_validity: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def parse_from(cls: Type[Self], parameters: List[bytes]) -> Self:
|
||||
return cls(
|
||||
number=parameters[0].decode(),
|
||||
type=int(parameters[1]),
|
||||
subaddr=parameters[2].decode() if len(parameters) >= 3 else None,
|
||||
satype=(
|
||||
int(parameters[3]) if len(parameters) >= 4 and parameters[3] else None
|
||||
),
|
||||
alpha=parameters[4].decode() if len(parameters) >= 5 else None,
|
||||
cli_validity=(
|
||||
int(parameters[5]) if len(parameters) >= 6 and parameters[5] else None
|
||||
),
|
||||
)
|
||||
|
||||
def to_clip_string(self) -> str:
|
||||
return ','.join(
|
||||
str(arg) if arg else ''
|
||||
for arg in [
|
||||
self.number,
|
||||
self.type,
|
||||
self.subaddr,
|
||||
self.satype,
|
||||
self.alpha,
|
||||
self.cli_validity,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class VoiceRecognitionState(enum.IntEnum):
|
||||
"""
|
||||
vrec values provided in AT+BVRA command.
|
||||
|
||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
"""
|
||||
|
||||
DISABLE = 0
|
||||
ENABLE = 1
|
||||
# (Enhanced Voice Recognition Status only) HF is ready to accept audio.
|
||||
ENHANCED_READY = 2
|
||||
|
||||
|
||||
class CmeError(enum.IntEnum):
|
||||
"""
|
||||
CME ERROR codes (partial listed).
|
||||
@@ -359,7 +436,7 @@ class CmeError(enum.IntEnum):
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Response codes.
|
||||
RESPONSE_CODES = [
|
||||
RESPONSE_CODES = {
|
||||
"+APLSIRI",
|
||||
"+BAC",
|
||||
"+BCC",
|
||||
@@ -390,10 +467,10 @@ RESPONSE_CODES = [
|
||||
"+XAPL",
|
||||
"A",
|
||||
"D",
|
||||
]
|
||||
}
|
||||
|
||||
# Unsolicited responses and statuses.
|
||||
UNSOLICITED_CODES = [
|
||||
UNSOLICITED_CODES = {
|
||||
"+APLSIRI",
|
||||
"+BCS",
|
||||
"+BIND",
|
||||
@@ -411,10 +488,10 @@ UNSOLICITED_CODES = [
|
||||
"NO ANSWER",
|
||||
"NO CARRIER",
|
||||
"RING",
|
||||
]
|
||||
}
|
||||
|
||||
# Status codes
|
||||
STATUS_CODES = [
|
||||
STATUS_CODES = {
|
||||
"+CME ERROR",
|
||||
"BLACKLISTED",
|
||||
"BUSY",
|
||||
@@ -423,7 +500,7 @@ STATUS_CODES = [
|
||||
"NO ANSWER",
|
||||
"NO CARRIER",
|
||||
"OK",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -626,10 +703,25 @@ class HfProtocol(pyee.EventEmitter):
|
||||
ag_indicator: When AG update their indicators, notify the new state.
|
||||
Args:
|
||||
ag_indicator: AgIndicator
|
||||
speaker_volume: Emitted when AG update speaker volume autonomously.
|
||||
Args:
|
||||
volume: Int
|
||||
microphone_volume: Emitted when AG update microphone volume autonomously.
|
||||
Args:
|
||||
volume: Int
|
||||
microphone_volume: Emitted when AG sends a ringtone request.
|
||||
Args:
|
||||
None
|
||||
cli_notification: Emitted when notify the call metadata on line.
|
||||
Args:
|
||||
cli_notification: CallLineIdentification
|
||||
voice_recognition: Emitted when AG starts voice recognition autonomously.
|
||||
Args:
|
||||
vrec: VoiceRecognitionState
|
||||
"""
|
||||
|
||||
class HfLoopTermination(HfpProtocolError): ...
|
||||
"""Termination signal for run() loop."""
|
||||
class HfLoopTermination(HfpProtocolError):
|
||||
"""Termination signal for run() loop."""
|
||||
|
||||
supported_hf_features: int
|
||||
supported_audio_codecs: List[AudioCodec]
|
||||
@@ -651,7 +743,11 @@ class HfProtocol(pyee.EventEmitter):
|
||||
read_buffer: bytearray
|
||||
active_codec: AudioCodec
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC, configuration: HfConfiguration) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
dlc: rfcomm.DLC,
|
||||
configuration: HfConfiguration,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
# Configure internal state.
|
||||
@@ -841,7 +937,7 @@ class HfProtocol(pyee.EventEmitter):
|
||||
|
||||
if self.supports_hf_feature(
|
||||
HfFeature.THREE_WAY_CALLING
|
||||
) and self.supports_ag_feature(HfFeature.THREE_WAY_CALLING):
|
||||
) and self.supports_ag_feature(AgFeature.THREE_WAY_CALLING):
|
||||
# After the HF has enabled the “Indicators status update” function in
|
||||
# the AG, and if the “Call waiting and 3-way calling” bit was set in the
|
||||
# supported features bitmap by both the HF and the AG, the HF shall
|
||||
@@ -854,9 +950,8 @@ class HfProtocol(pyee.EventEmitter):
|
||||
)
|
||||
|
||||
self.supported_ag_call_hold_operations = [
|
||||
CallHoldOperation(int(operation))
|
||||
CallHoldOperation(operation.decode())
|
||||
for operation in response.parameters[0]
|
||||
if not b'x' in operation
|
||||
]
|
||||
|
||||
# 4.2.1.4 HF Indicators
|
||||
@@ -986,8 +1081,9 @@ class HfProtocol(pyee.EventEmitter):
|
||||
mode=CallInfoMode(int(response.parameters[3])),
|
||||
multi_party=CallInfoMultiParty(int(response.parameters[4])),
|
||||
)
|
||||
if len(response.parameters) >= 6:
|
||||
call_info.number = response.parameters[5].decode()
|
||||
if len(response.parameters) >= 7:
|
||||
call_info.number = int(response.parameters[5])
|
||||
call_info.type = int(response.parameters[6])
|
||||
calls.append(call_info)
|
||||
return calls
|
||||
@@ -1010,6 +1106,21 @@ class HfProtocol(pyee.EventEmitter):
|
||||
await self.update_ag_indicator(
|
||||
int(result.parameters[0]), int(result.parameters[1])
|
||||
)
|
||||
elif result.code == "+VGS":
|
||||
self.emit('speaker_volume', int(result.parameters[0]))
|
||||
elif result.code == "+VGM":
|
||||
self.emit('microphone_volume', int(result.parameters[0]))
|
||||
elif result.code == "RING":
|
||||
self.emit('ring')
|
||||
elif result.code == "+CLIP":
|
||||
self.emit(
|
||||
'cli_notification', CallLineIdentification.parse_from(result.parameters)
|
||||
)
|
||||
elif result.code == "+BVRA":
|
||||
# TODO: Support Enhanced Voice Recognition.
|
||||
self.emit(
|
||||
'voice_recognition', VoiceRecognitionState(int(result.parameters[0]))
|
||||
)
|
||||
else:
|
||||
logging.info(f"unhandled unsolicited response {result.code}")
|
||||
|
||||
@@ -1045,11 +1156,24 @@ class AgProtocol(pyee.EventEmitter):
|
||||
active_codec: AudioCodec
|
||||
hf_indicator: When HF update their indicators, notify the new state.
|
||||
Args:
|
||||
hf_indicator: HfIndicator
|
||||
hf_indicator: HfIndicatorState
|
||||
codec_connection_request: Emit when HF sends AT+BCC to request codec connection.
|
||||
answer: Emit when HF sends ATA to answer phone call.
|
||||
hang_up: Emit when HF sends AT+CHUP to hang up phone call.
|
||||
dial: Emit when HF sends ATD to dial phone call.
|
||||
voice_recognition: Emit when HF requests voice recognition state.
|
||||
Args:
|
||||
vrec: VoiceRecognitionState
|
||||
call_hold: Emit when HF requests call hold operation.
|
||||
Args:
|
||||
operation: CallHoldOperation
|
||||
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
|
||||
@@ -1066,10 +1190,13 @@ class AgProtocol(pyee.EventEmitter):
|
||||
|
||||
read_buffer: bytearray
|
||||
active_codec: AudioCodec
|
||||
calls: List[CallInfo]
|
||||
|
||||
indicator_report_enabled: bool
|
||||
inband_ringtone_enabled: bool
|
||||
cme_error_enabled: bool
|
||||
cli_notification_enabled: bool
|
||||
call_waiting_enabled: bool
|
||||
_remained_slc_setup_features: Set[HfFeature]
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC, configuration: AgConfiguration) -> None:
|
||||
@@ -1079,6 +1206,7 @@ class AgProtocol(pyee.EventEmitter):
|
||||
self.dlc = dlc
|
||||
self.read_buffer = bytearray()
|
||||
self.active_codec = AudioCodec.CVSD
|
||||
self.calls = []
|
||||
|
||||
# Build local features.
|
||||
self.supported_ag_features = sum(configuration.supported_ag_features)
|
||||
@@ -1095,6 +1223,8 @@ class AgProtocol(pyee.EventEmitter):
|
||||
self.supported_audio_codecs = []
|
||||
self.indicator_report_enabled = False
|
||||
self.cme_error_enabled = False
|
||||
self.cli_notification_enabled = False
|
||||
self.call_waiting_enabled = False
|
||||
|
||||
self.hf_indicators = collections.OrderedDict()
|
||||
|
||||
@@ -1168,6 +1298,21 @@ class AgProtocol(pyee.EventEmitter):
|
||||
self.inband_ringtone_enabled = enabled
|
||||
self.send_response(f'+BSIR: {1 if enabled else 0}')
|
||||
|
||||
def set_speaker_volume(self, level: int) -> None:
|
||||
"""Reports speaker volume."""
|
||||
|
||||
self.send_response(f'+VGS: {level}')
|
||||
|
||||
def set_microphone_volume(self, level: int) -> None:
|
||||
"""Reports microphone volume."""
|
||||
|
||||
self.send_response(f'+VGM: {level}')
|
||||
|
||||
def send_ring(self) -> None:
|
||||
"""Sends RING command to trigger ringtone on HF."""
|
||||
|
||||
self.send_response('RING')
|
||||
|
||||
def update_ag_indicator(self, indicator: AgIndicator, value: int) -> None:
|
||||
"""Updates AG indicator.
|
||||
|
||||
@@ -1212,6 +1357,14 @@ class AgProtocol(pyee.EventEmitter):
|
||||
if (new_codec := await at_bcs_future) != codec:
|
||||
raise HfpProtocolError(f'Expect codec: {codec}, but get {new_codec}')
|
||||
|
||||
def send_cli_notification(self, cli: CallLineIdentification) -> None:
|
||||
"""Sends +CLIP CLI notification."""
|
||||
|
||||
if not self.cli_notification_enabled:
|
||||
logger.warning('Try to send CLIP while CLI notification is not enabled')
|
||||
|
||||
self.send_response(f'+CLIP: {cli.to_clip_string()}')
|
||||
|
||||
def _check_remained_slc_commands(self) -> None:
|
||||
if not self._remained_slc_setup_features:
|
||||
self.emit('slc_complete')
|
||||
@@ -1240,6 +1393,54 @@ class AgProtocol(pyee.EventEmitter):
|
||||
self.send_ok()
|
||||
self.emit('codec_negotiation', self.active_codec)
|
||||
|
||||
def _on_bvra(self, vrec: bytes) -> None:
|
||||
self.send_ok()
|
||||
self.emit('voice_recognition', VoiceRecognitionState(int(vrec)))
|
||||
|
||||
def _on_chld(self, operation_code: bytes) -> None:
|
||||
call_index: Optional[int] = None
|
||||
if len(operation_code) > 1:
|
||||
call_index = int(operation_code[1:])
|
||||
operation_code = operation_code[:1] + b'x'
|
||||
try:
|
||||
operation = CallHoldOperation(operation_code.decode())
|
||||
except:
|
||||
logger.error(f'Invalid operation: {operation_code.decode()}')
|
||||
self.send_cme_error(CmeError.OPERATION_NOT_SUPPORTED)
|
||||
return
|
||||
|
||||
if operation not in self.supported_ag_call_hold_operations:
|
||||
logger.error(f'Unsupported operation: {operation_code.decode()}')
|
||||
self.send_cme_error(CmeError.OPERATION_NOT_SUPPORTED)
|
||||
|
||||
if call_index is not None and not any(
|
||||
call.index == call_index for call in self.calls
|
||||
):
|
||||
logger.error(f'No matching call {call_index}')
|
||||
self.send_cme_error(CmeError.INVALID_INDEX)
|
||||
|
||||
# Real three-way calls have more complicated situations, but this is not a popular issue - let users to handle the remaining :)
|
||||
|
||||
self.send_ok()
|
||||
self.emit('call_hold', operation, call_index)
|
||||
|
||||
def _on_chld_test(self) -> None:
|
||||
if not self.supports_ag_feature(AgFeature.THREE_WAY_CALLING):
|
||||
self.send_error()
|
||||
return
|
||||
|
||||
self.send_response(
|
||||
'+CHLD: ({})'.format(
|
||||
','.join(
|
||||
operation.value
|
||||
for operation in self.supported_ag_call_hold_operations
|
||||
)
|
||||
)
|
||||
)
|
||||
self.send_ok()
|
||||
self._remained_slc_setup_features.remove(HfFeature.THREE_WAY_CALLING)
|
||||
self._check_remained_slc_commands()
|
||||
|
||||
def _on_cind_test(self) -> None:
|
||||
if not self.ag_indicators:
|
||||
self.send_cme_error(CmeError.NOT_FOUND)
|
||||
@@ -1271,7 +1472,12 @@ class AgProtocol(pyee.EventEmitter):
|
||||
display: Optional[bytes] = None,
|
||||
indicator: bytes = b'',
|
||||
) -> 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(
|
||||
f'Unexpected values: mode={mode!r}, keypad={keypad!r}, '
|
||||
f'display={display!r}, indicator={indicator!r}'
|
||||
@@ -1285,6 +1491,10 @@ class AgProtocol(pyee.EventEmitter):
|
||||
self.cme_error_enabled = bool(int(enabled))
|
||||
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:
|
||||
if not self.supports_ag_feature(AgFeature.HF_INDICATORS):
|
||||
self.send_error()
|
||||
@@ -1364,6 +1574,36 @@ class AgProtocol(pyee.EventEmitter):
|
||||
self.emit('hang_up')
|
||||
self.send_ok()
|
||||
|
||||
def _on_clcc(self) -> None:
|
||||
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 = (
|
||||
f'+CLCC: {call.index}'
|
||||
f',{call.direction.value}'
|
||||
f',{call.status.value}'
|
||||
f',{call.mode.value}'
|
||||
f',{call.multi_party.value}'
|
||||
f'{number_text}'
|
||||
f'{type_text}'
|
||||
)
|
||||
self.send_response(response)
|
||||
self.send_ok()
|
||||
|
||||
def _on_clip(self, enabled: bytes) -> None:
|
||||
if not self.supports_hf_feature(HfFeature.CLI_PRESENTATION_CAPABILITY):
|
||||
logger.error('Remote doesn not support CLI but sends AT+CLIP')
|
||||
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()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Normative SDP definitions
|
||||
@@ -1546,7 +1786,7 @@ def make_ag_sdp_records(
|
||||
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
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),
|
||||
]
|
||||
),
|
||||
@@ -1573,7 +1813,7 @@ def make_ag_sdp_records(
|
||||
[
|
||||
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),
|
||||
]
|
||||
)
|
||||
@@ -1596,7 +1836,7 @@ async def find_hf_sdp_record(
|
||||
connection: ACL connection to make SDP search.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping from channel number to service class UUID list.
|
||||
Tuple of (<RFCOMM channel>, <Profile Version>, <HF SDP features>)
|
||||
"""
|
||||
async with sdp.Client(connection) as sdp_client:
|
||||
search_result = await sdp_client.search_attributes(
|
||||
@@ -1605,6 +1845,7 @@ async def find_hf_sdp_record(
|
||||
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
|
||||
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
],
|
||||
)
|
||||
for attribute_lists in search_result:
|
||||
@@ -1624,10 +1865,17 @@ async def find_hf_sdp_record(
|
||||
version = ProfileVersion(profile_descriptor_list[0].value[1].value)
|
||||
elif attribute.id == sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID:
|
||||
features = HfSdpFeature(attribute.value.value)
|
||||
if not channel or not version or features is None:
|
||||
logger.warning(f"Bad result {attribute_lists}.")
|
||||
return None
|
||||
return (channel, version, features)
|
||||
elif attribute.id == sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
|
||||
class_id_list = attribute.value.value
|
||||
uuid = class_id_list[0].value
|
||||
# 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
|
||||
|
||||
|
||||
@@ -1640,11 +1888,11 @@ async def find_ag_sdp_record(
|
||||
connection: ACL connection to make SDP search.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping from channel number to service class UUID list.
|
||||
Tuple of (<RFCOMM channel>, <Profile Version>, <AG SDP features>)
|
||||
"""
|
||||
async with sdp.Client(connection) as sdp_client:
|
||||
search_result = await sdp_client.search_attributes(
|
||||
uuids=[BT_HEADSET_AUDIO_GATEWAY_SERVICE],
|
||||
uuids=[BT_HANDSFREE_AUDIO_GATEWAY_SERVICE],
|
||||
attribute_ids=[
|
||||
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
|
||||
@@ -530,7 +530,9 @@ class Host(AbortableEventEmitter):
|
||||
|
||||
# Check the return parameters if required
|
||||
if check_result:
|
||||
if isinstance(response.return_parameters, int):
|
||||
if isinstance(response, hci.HCI_Command_Status_Event):
|
||||
status = response.status
|
||||
elif isinstance(response.return_parameters, int):
|
||||
status = response.return_parameters
|
||||
elif isinstance(response.return_parameters, bytes):
|
||||
# return parameters first field is a one byte status code
|
||||
@@ -719,14 +721,16 @@ class Host(AbortableEventEmitter):
|
||||
for connection_handle, num_completed_packets in zip(
|
||||
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(
|
||||
'received packet completion event for unknown handle '
|
||||
f'0x{connection_handle:04X}'
|
||||
)
|
||||
continue
|
||||
|
||||
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
|
||||
|
||||
# Classic only
|
||||
def on_hci_connection_request_event(self, event):
|
||||
@@ -901,6 +905,27 @@ class Host(AbortableEventEmitter):
|
||||
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):
|
||||
self.emit(
|
||||
'cis_request',
|
||||
|
||||
@@ -25,7 +25,8 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
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 .hci import Address
|
||||
@@ -253,8 +254,10 @@ class JsonKeyStore(KeyStore):
|
||||
|
||||
logger.debug(f'JSON keystore: {self.filename}')
|
||||
|
||||
@staticmethod
|
||||
def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]:
|
||||
@classmethod
|
||||
def from_device(
|
||||
cls: Type[Self], device: Device, filename: Optional[str] = None
|
||||
) -> Self:
|
||||
if not filename:
|
||||
# Extract the filename from the config if there is one
|
||||
if device.config.keystore is not None:
|
||||
@@ -270,7 +273,7 @@ class JsonKeyStore(KeyStore):
|
||||
else:
|
||||
namespace = JsonKeyStore.DEFAULT_NAMESPACE
|
||||
|
||||
return JsonKeyStore(namespace, filename)
|
||||
return cls(namespace, filename)
|
||||
|
||||
async def load(self):
|
||||
# Try to open the file, without failing. If the file does not exist, it
|
||||
|
||||
@@ -28,6 +28,7 @@ from bumble.core import (
|
||||
BT_PERIPHERAL_ROLE,
|
||||
UUID,
|
||||
AdvertisingData,
|
||||
Appearance,
|
||||
ConnectionError,
|
||||
)
|
||||
from bumble.device import (
|
||||
@@ -988,8 +989,8 @@ class HostService(HostServicer):
|
||||
dt.random_target_addresses.extend(
|
||||
[data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]
|
||||
)
|
||||
if i := cast(int, ad.get(AdvertisingData.APPEARANCE)):
|
||||
dt.appearance = i
|
||||
if appearance := cast(Appearance, ad.get(AdvertisingData.APPEARANCE)):
|
||||
dt.appearance = int(appearance)
|
||||
if i := cast(int, ad.get(AdvertisingData.ADVERTISING_INTERVAL)):
|
||||
dt.advertising_interval = i
|
||||
if s := cast(str, ad.get(AdvertisingData.URI)):
|
||||
|
||||
@@ -25,6 +25,7 @@ import struct
|
||||
import functools
|
||||
import logging
|
||||
from typing import Optional, List, Union, Type, Dict, Any, Tuple
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble import core
|
||||
from bumble import colors
|
||||
@@ -32,6 +33,8 @@ from bumble import device
|
||||
from bumble import hci
|
||||
from bumble import gatt
|
||||
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
|
||||
RIGHT_SURROUND = 0x08000000
|
||||
|
||||
@property
|
||||
def channel_count(self) -> int:
|
||||
return bin(self.value).count('1')
|
||||
|
||||
|
||||
class AudioInputType(enum.IntEnum):
|
||||
'''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type'''
|
||||
@@ -111,7 +118,7 @@ class ContextType(enum.IntFlag):
|
||||
EMERGENCY_ALARM = 0x0800
|
||||
|
||||
|
||||
class SamplingFrequency(enum.IntEnum):
|
||||
class SamplingFrequency(utils.OpenIntEnum):
|
||||
'''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency'''
|
||||
|
||||
# fmt: off
|
||||
@@ -218,6 +225,13 @@ class FrameDuration(enum.IntEnum):
|
||||
DURATION_7500_US = 0x00
|
||||
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):
|
||||
'''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration'''
|
||||
@@ -229,7 +243,7 @@ class SupportedFrameDuration(enum.IntFlag):
|
||||
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'''
|
||||
|
||||
# fmt: off
|
||||
@@ -534,7 +548,7 @@ class CodecSpecificCapabilities:
|
||||
|
||||
supported_sampling_frequencies: SupportedSamplingFrequency
|
||||
supported_frame_durations: SupportedFrameDuration
|
||||
supported_audio_channel_counts: Sequence[int]
|
||||
supported_audio_channel_count: Sequence[int]
|
||||
min_octets_per_codec_frame: int
|
||||
max_octets_per_codec_frame: int
|
||||
supported_max_codec_frames_per_sdu: int
|
||||
@@ -543,7 +557,7 @@ class CodecSpecificCapabilities:
|
||||
def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities:
|
||||
offset = 0
|
||||
# Allowed default values.
|
||||
supported_audio_channel_counts = [1]
|
||||
supported_audio_channel_count = [1]
|
||||
supported_max_codec_frames_per_sdu = 1
|
||||
while offset < len(data):
|
||||
length, type = struct.unpack_from('BB', data, offset)
|
||||
@@ -556,7 +570,7 @@ class CodecSpecificCapabilities:
|
||||
elif type == CodecSpecificCapabilities.Type.FRAME_DURATION:
|
||||
supported_frame_durations = SupportedFrameDuration(value)
|
||||
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:
|
||||
min_octets_per_sample = value & 0xFFFF
|
||||
max_octets_per_sample = value >> 16
|
||||
@@ -567,7 +581,7 @@ class CodecSpecificCapabilities:
|
||||
return CodecSpecificCapabilities(
|
||||
supported_sampling_frequencies=supported_sampling_frequencies,
|
||||
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,
|
||||
max_octets_per_codec_frame=max_octets_per_sample,
|
||||
supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu,
|
||||
@@ -584,7 +598,7 @@ class CodecSpecificCapabilities:
|
||||
self.supported_frame_durations,
|
||||
2,
|
||||
CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT,
|
||||
channel_counts_to_bits(self.supported_audio_channel_counts),
|
||||
channel_counts_to_bits(self.supported_audio_channel_count),
|
||||
5,
|
||||
CodecSpecificCapabilities.Type.OCTETS_PER_FRAME,
|
||||
self.min_octets_per_codec_frame,
|
||||
@@ -602,7 +616,7 @@ class CodecSpecificConfiguration:
|
||||
* Basic Audio Profile, 4.3.2 - Codec_Specific_Capabilities LTV requirements
|
||||
'''
|
||||
|
||||
class Type(enum.IntEnum):
|
||||
class Type(utils.OpenIntEnum):
|
||||
# fmt: off
|
||||
SAMPLING_FREQUENCY = 0x01
|
||||
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
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -733,9 +840,9 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService):
|
||||
supported_sink_context: ContextType,
|
||||
available_source_context: ContextType,
|
||||
available_sink_context: ContextType,
|
||||
sink_pac: Sequence[PacRecord] = [],
|
||||
sink_pac: Sequence[PacRecord] = (),
|
||||
sink_audio_locations: Optional[AudioLocation] = None,
|
||||
source_pac: Sequence[PacRecord] = [],
|
||||
source_pac: Sequence[PacRecord] = (),
|
||||
source_audio_locations: Optional[AudioLocation] = None,
|
||||
) -> None:
|
||||
characteristics = []
|
||||
@@ -870,15 +977,22 @@ class AseStateMachine(gatt.Characteristic):
|
||||
cig_id: int,
|
||||
cis_id: int,
|
||||
) -> 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(
|
||||
'flush', self.service.device.accept_cis_request(cis_handle)
|
||||
)
|
||||
|
||||
def on_cis_establishment(self, cis_link: device.CisLink) -> None:
|
||||
if cis_link.cis_id == self.cis_id and self.state == self.State.ENABLING:
|
||||
self.state = self.State.STREAMING
|
||||
self.cis_link = cis_link
|
||||
if (
|
||||
cis_link.cig_id == self.cig_id
|
||||
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():
|
||||
await self.service.device.send_command(
|
||||
@@ -891,9 +1005,15 @@ class AseStateMachine(gatt.Characteristic):
|
||||
codec_configuration=b'',
|
||||
)
|
||||
)
|
||||
if self.role == AudioRole.SINK:
|
||||
self.state = self.State.STREAMING
|
||||
await self.service.device.notify_subscribers(self, self.value)
|
||||
|
||||
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(
|
||||
self,
|
||||
@@ -991,11 +1111,17 @@ class AseStateMachine(gatt.Characteristic):
|
||||
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
|
||||
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)
|
||||
|
||||
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 (
|
||||
AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
|
||||
AseReasonCode.NONE,
|
||||
@@ -1046,6 +1172,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
def state(self, new_state: State) -> None:
|
||||
logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}')
|
||||
self._state = new_state
|
||||
self.emit('state_change')
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
@@ -1118,6 +1245,7 @@ class AudioStreamControlService(gatt.TemplateService):
|
||||
|
||||
ase_state_machines: Dict[int, AseStateMachine]
|
||||
ase_control_point: gatt.Characteristic
|
||||
_active_client: Optional[device.Connection] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1155,7 +1283,16 @@ class AudioStreamControlService(gatt.TemplateService):
|
||||
else:
|
||||
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):
|
||||
if not self._active_client and connection:
|
||||
self._active_client = connection
|
||||
connection.once('disconnection', self._on_client_disconnected)
|
||||
|
||||
operation = ASE_Operation.from_bytes(data)
|
||||
responses = []
|
||||
logger.debug(f'*** ASCS Write {operation} ***')
|
||||
|
||||
49
bumble/profiles/le_audio.py
Normal file
49
bumble/profiles/le_audio.py
Normal 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
bumble/profiles/pbp.py
Normal file
46
bumble/profiles/pbp.py
Normal 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)
|
||||
)
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import collections
|
||||
import dataclasses
|
||||
import enum
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||
@@ -54,6 +55,7 @@ logger = logging.getLogger(__name__)
|
||||
# fmt: off
|
||||
|
||||
RFCOMM_PSM = 0x0003
|
||||
DEFAULT_RX_QUEUE_SIZE = 32
|
||||
|
||||
class FrameType(enum.IntEnum):
|
||||
SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
|
||||
@@ -445,7 +447,8 @@ class DLC(EventEmitter):
|
||||
RESET = 0x05
|
||||
|
||||
connection_result: Optional[asyncio.Future]
|
||||
sink: Optional[Callable[[bytes], None]]
|
||||
_sink: Optional[Callable[[bytes], None]]
|
||||
_enqueued_rx_packets: collections.deque[bytes]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -466,10 +469,12 @@ class DLC(EventEmitter):
|
||||
self.state = DLC.State.INIT
|
||||
self.role = multiplexer.role
|
||||
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
||||
self.sink = None
|
||||
self.connection_result = None
|
||||
self.drained = asyncio.Event()
|
||||
self.drained.set()
|
||||
# Queued packets when sink is not set.
|
||||
self._enqueued_rx_packets = collections.deque(maxlen=DEFAULT_RX_QUEUE_SIZE)
|
||||
self._sink = None
|
||||
|
||||
# Compute the MTU
|
||||
max_overhead = 4 + 1 # header with 2-byte length + fcs
|
||||
@@ -477,6 +482,19 @@ class DLC(EventEmitter):
|
||||
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:
|
||||
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
|
||||
self.state = new_state
|
||||
@@ -549,8 +567,15 @@ class DLC(EventEmitter):
|
||||
f'rx_credits={self.rx_credits}: {data.hex()}'
|
||||
)
|
||||
if data:
|
||||
if self.sink:
|
||||
self.sink(data) # pylint: disable=not-callable
|
||||
if self._sink:
|
||||
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
|
||||
if self.rx_credits > 0:
|
||||
|
||||
@@ -425,6 +425,10 @@ class SnoopingTransport(Transport):
|
||||
class Source:
|
||||
sink: TransportSink
|
||||
|
||||
@property
|
||||
def metadata(self) -> dict[str, Any]:
|
||||
return getattr(self.source, 'metadata', {})
|
||||
|
||||
def __init__(self, source: TransportSource, snooper: Snooper):
|
||||
self.source = source
|
||||
self.snooper = snooper
|
||||
|
||||
350
examples/hfp_gateway.html
Normal file
350
examples/hfp_gateway.html
Normal 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>
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"name": "Bumble Phone",
|
||||
"class_of_device": 6291980
|
||||
"class_of_device": 6291980,
|
||||
"keystore": "JsonKeyStore"
|
||||
}
|
||||
|
||||
@@ -16,22 +16,30 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import io
|
||||
import logging
|
||||
import websockets
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import bumble.core
|
||||
from bumble.device import Device
|
||||
from bumble.device import Device, ScoLink
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.core import (
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
)
|
||||
from bumble import rfcomm, hfp
|
||||
from bumble.hci import HCI_SynchronousDataPacket
|
||||
from bumble import hci, rfcomm, hfp
|
||||
|
||||
|
||||
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:
|
||||
return hfp.AgConfiguration(
|
||||
@@ -41,12 +49,13 @@ def _default_configuration() -> hfp.AgConfiguration:
|
||||
hfp.AgFeature.REJECT_CALL,
|
||||
hfp.AgFeature.CODEC_NEGOTIATION,
|
||||
hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED,
|
||||
hfp.AgFeature.ENHANCED_CALL_STATUS,
|
||||
],
|
||||
supported_ag_indicators=[
|
||||
hfp.AgIndicatorState.call(),
|
||||
hfp.AgIndicatorState.callsetup(),
|
||||
hfp.AgIndicatorState.callheld(),
|
||||
hfp.AgIndicatorState.service(),
|
||||
hfp.AgIndicatorState.callsetup(),
|
||||
hfp.AgIndicatorState.callsetup(),
|
||||
hfp.AgIndicatorState.signal(),
|
||||
hfp.AgIndicatorState.roam(),
|
||||
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:
|
||||
if len(sys.argv) < 4:
|
||||
if len(sys.argv) < 3:
|
||||
print(
|
||||
'Usage: run_hfp_gateway.py <device-config> <transport-spec> '
|
||||
'<bluetooth-address>'
|
||||
'[bluetooth-address] [wav-file-for-source]'
|
||||
)
|
||||
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
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
@@ -84,56 +199,85 @@ async def main() -> None:
|
||||
device.classic_enabled = True
|
||||
await device.power_on()
|
||||
|
||||
# Connect to a peer
|
||||
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}!')
|
||||
rfcomm_server = rfcomm.Server(device)
|
||||
configuration = _default_configuration()
|
||||
|
||||
# Get a list of all the Handsfree services (should only be 1)
|
||||
if not (hfp_record := await hfp.find_hf_sdp_record(connection)):
|
||||
print('!!! no service found')
|
||||
return
|
||||
def on_dlc(dlc: rfcomm.DLC):
|
||||
global ag_protocol
|
||||
ag_protocol = hfp.AgProtocol(dlc, configuration)
|
||||
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, version, hf_sdp_features = hfp_record
|
||||
print(f'HF version: {version}')
|
||||
print(f'HF features: {hf_sdp_features}')
|
||||
channel = rfcomm_server.listen(on_dlc)
|
||||
device.sdp_service_records = {
|
||||
1: hfp.make_ag_sdp_records(1, channel, configuration)
|
||||
}
|
||||
|
||||
# Request authentication
|
||||
print('*** Authenticating...')
|
||||
await connection.authenticate()
|
||||
print('*** Authenticated')
|
||||
def on_sco_connection(sco_link: ScoLink):
|
||||
assert ag_protocol
|
||||
on_sco_state_change(ag_protocol.active_codec)
|
||||
sco_link.on('disconnection', lambda _: on_sco_state_change(0))
|
||||
sco_link.sink = on_sco_packet
|
||||
|
||||
# Enable encryption
|
||||
print('*** Enabling encryption...')
|
||||
await connection.encrypt()
|
||||
print('*** Encryption on')
|
||||
device.on('sco_connection', on_sco_connection)
|
||||
if len(sys.argv) >= 4:
|
||||
# Connect to a peer
|
||||
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
|
||||
print('@@@ Starting to RFCOMM client...')
|
||||
rfcomm_client = rfcomm.Client(connection)
|
||||
rfcomm_mux = await rfcomm_client.start()
|
||||
print('@@@ Started')
|
||||
# Get a list of all the Handsfree services (should only be 1)
|
||||
if not (hfp_record := await hfp.find_hf_sdp_record(connection)):
|
||||
print('!!! no service found')
|
||||
return
|
||||
|
||||
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
|
||||
# Pick the first one
|
||||
channel, version, hf_sdp_features = hfp_record
|
||||
print(f'HF version: {version}')
|
||||
print(f'HF features: {hf_sdp_features}')
|
||||
|
||||
def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket):
|
||||
# Reset packet and loopback
|
||||
packet.packet_status = 0
|
||||
device.host.send_hci_packet(packet)
|
||||
# Request authentication
|
||||
print('*** Authenticating...')
|
||||
await connection.authenticate()
|
||||
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
|
||||
|
||||
|
||||
@@ -16,20 +16,28 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import datetime
|
||||
import functools
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import io
|
||||
import struct
|
||||
import secrets
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.device import Device, CisLink
|
||||
from bumble.device import Device
|
||||
from bumble.hci import (
|
||||
CodecID,
|
||||
CodingFormat,
|
||||
HCI_IsoDataPacket,
|
||||
)
|
||||
from bumble.profiles.bap import (
|
||||
AseStateMachine,
|
||||
UnicastServerAdvertisingData,
|
||||
CodecSpecificConfiguration,
|
||||
CodecSpecificCapabilities,
|
||||
ContextType,
|
||||
AudioLocation,
|
||||
@@ -45,6 +53,32 @@ from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
|
||||
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:
|
||||
if len(sys.argv) < 3:
|
||||
@@ -71,49 +105,17 @@ async def main() -> None:
|
||||
PublishedAudioCapabilitiesService(
|
||||
supported_source_context=ContextType.PROHIBITED,
|
||||
available_source_context=ContextType.PROHIBITED,
|
||||
supported_sink_context=ContextType.MEDIA,
|
||||
available_sink_context=ContextType.MEDIA,
|
||||
supported_sink_context=ContextType(0xFF), # All context types
|
||||
available_sink_context=ContextType(0xFF), # All context types
|
||||
sink_audio_locations=(
|
||||
AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
|
||||
),
|
||||
sink_pac=[
|
||||
# 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
sink_pac=[_sink_pac_record()],
|
||||
)
|
||||
)
|
||||
|
||||
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 = (
|
||||
bytes(
|
||||
@@ -143,44 +145,57 @@ async def main() -> None:
|
||||
+ csis.get_advertising_data()
|
||||
+ 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
|
||||
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):
|
||||
def on_pdu(ase: AseStateMachine, pdu: HCI_IsoDataPacket):
|
||||
# LC3 format: |frame_length(2)| + |frame(length)|.
|
||||
sdu = b''
|
||||
if pdu.iso_sdu_length:
|
||||
stdin.write(struct.pack('<H', pdu.iso_sdu_length))
|
||||
stdin.write(pdu.iso_sdu_fragment)
|
||||
sdu = struct.pack('<H', pdu.iso_sdu_length)
|
||||
sdu += pdu.iso_sdu_fragment
|
||||
file_outputs[ase].write(sdu)
|
||||
|
||||
def on_cis(cis_link: CisLink):
|
||||
cis_link.on('pdu', on_pdu)
|
||||
def on_ase_state_change(
|
||||
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(
|
||||
advertising_data=advertising_data,
|
||||
auto_restart=True,
|
||||
)
|
||||
|
||||
await hci_transport.source.terminated
|
||||
|
||||
@@ -102,7 +102,7 @@ async def main() -> None:
|
||||
supported_frame_durations=(
|
||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||
),
|
||||
supported_audio_channel_counts=[1],
|
||||
supported_audio_channel_count=[1],
|
||||
min_octets_per_codec_frame=120,
|
||||
max_octets_per_codec_frame=120,
|
||||
supported_max_codec_frames_per_sdu=1,
|
||||
|
||||
@@ -81,7 +81,7 @@ console_scripts =
|
||||
build =
|
||||
build >= 0.7
|
||||
test =
|
||||
pytest >= 8.0
|
||||
pytest >= 8.2
|
||||
pytest-asyncio >= 0.23.5
|
||||
pytest-html >= 3.2.0
|
||||
coverage >= 6.4
|
||||
@@ -89,13 +89,14 @@ development =
|
||||
black == 24.3
|
||||
grpcio-tools >= 1.62.1
|
||||
invoke >= 1.7.3
|
||||
mypy == 1.8.0
|
||||
mypy == 1.10.0
|
||||
nox >= 2022
|
||||
pylint == 2.15.8
|
||||
pylint == 3.1.0
|
||||
pyyaml >= 6.0
|
||||
types-appdirs >= 1.4.3
|
||||
types-invoke >= 1.7.3
|
||||
types-protobuf >= 4.21.0
|
||||
wasmtime == 20.0.0
|
||||
avatar =
|
||||
pandora-avatar == 0.0.9
|
||||
rootcanal == 1.10.0 ; python_version>='3.10'
|
||||
|
||||
@@ -72,7 +72,7 @@ def test_codec_specific_capabilities() -> None:
|
||||
cap = CodecSpecificCapabilities(
|
||||
supported_sampling_frequencies=SAMPLE_FREQUENCY,
|
||||
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,
|
||||
max_octets_per_codec_frame=40,
|
||||
supported_max_codec_frames_per_sdu=1,
|
||||
@@ -88,7 +88,7 @@ def test_pac_record() -> None:
|
||||
cap = CodecSpecificCapabilities(
|
||||
supported_sampling_frequencies=SAMPLE_FREQUENCY,
|
||||
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,
|
||||
max_octets_per_codec_frame=40,
|
||||
supported_max_codec_frames_per_sdu=1,
|
||||
@@ -216,7 +216,7 @@ async def test_pacs():
|
||||
supported_frame_durations=(
|
||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||
),
|
||||
supported_audio_channel_counts=[1],
|
||||
supported_audio_channel_count=[1],
|
||||
min_octets_per_codec_frame=40,
|
||||
max_octets_per_codec_frame=40,
|
||||
supported_max_codec_frames_per_sdu=1,
|
||||
@@ -232,7 +232,7 @@ async def test_pacs():
|
||||
supported_frame_durations=(
|
||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||
),
|
||||
supported_audio_channel_counts=[1],
|
||||
supported_audio_channel_count=[1],
|
||||
min_octets_per_codec_frame=60,
|
||||
max_octets_per_codec_frame=60,
|
||||
supported_max_codec_frames_per_sdu=1,
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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__':
|
||||
test_ad_data()
|
||||
test_get_dict_key_by_value()
|
||||
test_uuid_to_hex_str()
|
||||
test_appearance()
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
from types import LambdaType
|
||||
@@ -35,12 +36,14 @@ from bumble.hci import (
|
||||
HCI_COMMAND_STATUS_PENDING,
|
||||
HCI_CREATE_CONNECTION_COMMAND,
|
||||
HCI_SUCCESS,
|
||||
HCI_CONNECTION_FAILED_TO_BE_ESTABLISHED_ERROR,
|
||||
Address,
|
||||
OwnAddressType,
|
||||
HCI_Command_Complete_Event,
|
||||
HCI_Command_Status_Event,
|
||||
HCI_Connection_Complete_Event,
|
||||
HCI_Connection_Request_Event,
|
||||
HCI_Error,
|
||||
HCI_Packet,
|
||||
)
|
||||
from bumble.gatt import (
|
||||
@@ -52,6 +55,10 @@ from bumble.gatt import (
|
||||
|
||||
from .test_utils import TwoDevices, async_barrier
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
_TIMEOUT = 0.1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -214,6 +221,12 @@ async def test_device_connect_parallel():
|
||||
d1.host.set_packet_sink(Sink(d1_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(
|
||||
*[
|
||||
asyncio.create_task(
|
||||
@@ -222,8 +235,8 @@ async def test_device_connect_parallel():
|
||||
asyncio.create_task(
|
||||
d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT)
|
||||
),
|
||||
asyncio.create_task(d1.accept(peer_address=d0.public_address)),
|
||||
asyncio.create_task(d2.accept()),
|
||||
d1_accept_task,
|
||||
d2_accept_task,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -385,6 +398,29 @@ async def test_get_remote_le_features():
|
||||
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
|
||||
async def test_cis():
|
||||
@@ -433,6 +469,65 @@ async def test_cis():
|
||||
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():
|
||||
device = Device(host=Host(None, None))
|
||||
|
||||
@@ -43,6 +43,9 @@ def _default_hf_configuration() -> hfp.HfConfiguration:
|
||||
hfp.HfFeature.CODEC_NEGOTIATION,
|
||||
hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
|
||||
hfp.HfFeature.HF_INDICATORS,
|
||||
hfp.HfFeature.ENHANCED_CALL_STATUS,
|
||||
hfp.HfFeature.THREE_WAY_CALLING,
|
||||
hfp.HfFeature.CLI_PRESENTATION_CAPABILITY,
|
||||
],
|
||||
supported_hf_indicators=[
|
||||
hfp.HfIndicator.ENHANCED_SAFETY,
|
||||
@@ -57,7 +60,11 @@ def _default_hf_configuration() -> hfp.HfConfiguration:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def _default_hf_sdp_features() -> hfp.HfSdpFeature:
|
||||
return hfp.HfSdpFeature.WIDE_BAND
|
||||
return (
|
||||
hfp.HfSdpFeature.WIDE_BAND
|
||||
| hfp.HfSdpFeature.THREE_WAY_CALLING
|
||||
| hfp.HfSdpFeature.CLI_PRESENTATION_CAPABILITY
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -69,6 +76,8 @@ def _default_ag_configuration() -> hfp.AgConfiguration:
|
||||
hfp.AgFeature.REJECT_CALL,
|
||||
hfp.AgFeature.CODEC_NEGOTIATION,
|
||||
hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED,
|
||||
hfp.AgFeature.ENHANCED_CALL_STATUS,
|
||||
hfp.AgFeature.THREE_WAY_CALLING,
|
||||
],
|
||||
supported_ag_indicators=[
|
||||
hfp.AgIndicatorState.call(),
|
||||
@@ -83,14 +92,26 @@ def _default_ag_configuration() -> hfp.AgConfiguration:
|
||||
hfp.HfIndicator.ENHANCED_SAFETY,
|
||||
hfp.HfIndicator.BATTERY_LEVEL,
|
||||
],
|
||||
supported_ag_call_hold_operations=[],
|
||||
supported_ag_call_hold_operations=[
|
||||
hfp.CallHoldOperation.ADD_HELD_CALL,
|
||||
hfp.CallHoldOperation.HOLD_ALL_ACTIVE_CALLS,
|
||||
hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT,
|
||||
hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS,
|
||||
hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS,
|
||||
hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL,
|
||||
hfp.CallHoldOperation.CONNECT_TWO_CALLS,
|
||||
],
|
||||
supported_audio_codecs=[hfp.AudioCodec.CVSD, hfp.AudioCodec.MSBC],
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def _default_ag_sdp_features() -> hfp.AgSdpFeature:
|
||||
return hfp.AgSdpFeature.WIDE_BAND | hfp.AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
|
||||
return (
|
||||
hfp.AgSdpFeature.WIDE_BAND
|
||||
| hfp.AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
|
||||
| hfp.AgSdpFeature.THREE_WAY_CALLING
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -165,6 +186,7 @@ async def test_slc_with_minimal_features():
|
||||
|
||||
assert hf.supported_ag_features == ag.supported_ag_features
|
||||
assert hf.supported_hf_features == ag.supported_hf_features
|
||||
assert hf.supported_ag_call_hold_operations == ag.supported_ag_call_hold_operations
|
||||
for a, b in zip(hf.ag_indicators, ag.ag_indicators):
|
||||
assert a.indicator == b.indicator
|
||||
assert a.current_status == b.current_status
|
||||
@@ -177,6 +199,7 @@ async def test_slc(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||
|
||||
assert hf.supported_ag_features == ag.supported_ag_features
|
||||
assert hf.supported_hf_features == ag.supported_hf_features
|
||||
assert hf.supported_ag_call_hold_operations == ag.supported_ag_call_hold_operations
|
||||
for a, b in zip(hf.ag_indicators, ag.ag_indicators):
|
||||
assert a.indicator == b.indicator
|
||||
assert a.current_status == b.current_status
|
||||
@@ -281,6 +304,175 @@ async def test_terminate_call(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProto
|
||||
await future
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_calls_without_calls(
|
||||
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||
):
|
||||
hf, ag = hfp_connections
|
||||
|
||||
assert await hf.query_current_calls() == []
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_calls_with_calls(
|
||||
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||
):
|
||||
hf, ag = hfp_connections
|
||||
ag.calls.append(
|
||||
hfp.CallInfo(
|
||||
index=1,
|
||||
direction=hfp.CallInfoDirection.MOBILE_ORIGINATED_CALL,
|
||||
status=hfp.CallInfoStatus.ACTIVE,
|
||||
mode=hfp.CallInfoMode.VOICE,
|
||||
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
|
||||
number='123456789',
|
||||
)
|
||||
)
|
||||
|
||||
assert await hf.query_current_calls() == ag.calls
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"operation,",
|
||||
(
|
||||
hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS,
|
||||
hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS,
|
||||
hfp.CallHoldOperation.HOLD_ALL_ACTIVE_CALLS,
|
||||
hfp.CallHoldOperation.ADD_HELD_CALL,
|
||||
hfp.CallHoldOperation.CONNECT_TWO_CALLS,
|
||||
),
|
||||
)
|
||||
async def test_hold_call_without_call_index(
|
||||
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol],
|
||||
operation: hfp.CallHoldOperation,
|
||||
):
|
||||
hf, ag = hfp_connections
|
||||
call_hold_future = asyncio.get_running_loop().create_future()
|
||||
ag.on("call_hold", lambda op, index: call_hold_future.set_result((op, index)))
|
||||
|
||||
await hf.execute_command(f"AT+CHLD={operation.value}")
|
||||
|
||||
assert (await call_hold_future) == (operation, None)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"operation,",
|
||||
(
|
||||
hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL,
|
||||
hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT,
|
||||
),
|
||||
)
|
||||
async def test_hold_call_with_call_index(
|
||||
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol],
|
||||
operation: hfp.CallHoldOperation,
|
||||
):
|
||||
hf, ag = hfp_connections
|
||||
call_hold_future = asyncio.get_running_loop().create_future()
|
||||
ag.on("call_hold", lambda op, index: call_hold_future.set_result((op, index)))
|
||||
ag.calls.append(
|
||||
hfp.CallInfo(
|
||||
index=1,
|
||||
direction=hfp.CallInfoDirection.MOBILE_ORIGINATED_CALL,
|
||||
status=hfp.CallInfoStatus.ACTIVE,
|
||||
mode=hfp.CallInfoMode.VOICE,
|
||||
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
|
||||
number='123456789',
|
||||
)
|
||||
)
|
||||
|
||||
await hf.execute_command(f"AT+CHLD={operation.value.replace('x', '1')}")
|
||||
|
||||
assert (await call_hold_future) == (operation, 1)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_ring(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||
hf, ag = hfp_connections
|
||||
ring_future = asyncio.get_running_loop().create_future()
|
||||
hf.on("ring", lambda: ring_future.set_result(None))
|
||||
|
||||
ag.send_ring()
|
||||
|
||||
await ring_future
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_speaker_volume(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||
hf, ag = hfp_connections
|
||||
speaker_volume_future = asyncio.get_running_loop().create_future()
|
||||
hf.on("speaker_volume", speaker_volume_future.set_result)
|
||||
|
||||
ag.set_speaker_volume(10)
|
||||
|
||||
assert await speaker_volume_future == 10
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_microphone_volume(
|
||||
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||
):
|
||||
hf, ag = hfp_connections
|
||||
microphone_volume_future = asyncio.get_running_loop().create_future()
|
||||
hf.on("microphone_volume", microphone_volume_future.set_result)
|
||||
|
||||
ag.set_microphone_volume(10)
|
||||
|
||||
assert await microphone_volume_future == 10
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_notification(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
|
||||
hf, ag = hfp_connections
|
||||
cli_notification_future = asyncio.get_running_loop().create_future()
|
||||
hf.on("cli_notification", cli_notification_future.set_result)
|
||||
|
||||
ag.send_cli_notification(
|
||||
hfp.CallLineIdentification(number="\"123456789\"", type=129, alpha="\"Bumble\"")
|
||||
)
|
||||
|
||||
assert await cli_notification_future == hfp.CallLineIdentification(
|
||||
number="123456789", type=129, alpha="Bumble", subaddr="", satype=None
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_recognition_from_hf(
|
||||
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||
):
|
||||
hf, ag = hfp_connections
|
||||
voice_recognition_future = asyncio.get_running_loop().create_future()
|
||||
ag.on("voice_recognition", voice_recognition_future.set_result)
|
||||
|
||||
await hf.execute_command("AT+BVRA=1")
|
||||
|
||||
assert await voice_recognition_future == hfp.VoiceRecognitionState.ENABLE
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_recognition_from_ag(
|
||||
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
|
||||
):
|
||||
hf, ag = hfp_connections
|
||||
voice_recognition_future = asyncio.get_running_loop().create_future()
|
||||
hf.on("voice_recognition", voice_recognition_future.set_result)
|
||||
|
||||
ag.send_response("+BVRA: 1")
|
||||
|
||||
assert await voice_recognition_future == hfp.VoiceRecognitionState.ENABLE
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_hf_sdp_record():
|
||||
|
||||
@@ -32,6 +32,8 @@ from bumble.rfcomm import (
|
||||
RFCOMM_PSM,
|
||||
)
|
||||
|
||||
_TIMEOUT = 0.1
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def basic_frame_check(x):
|
||||
@@ -82,6 +84,29 @@ async def test_basic_connection() -> None:
|
||||
assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@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
|
||||
async def test_service_record():
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
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.link import LocalLink
|
||||
@@ -81,6 +82,12 @@ class TwoDevices:
|
||||
def __getitem__(self, index: int) -> Device:
|
||||
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():
|
||||
|
||||
@@ -24,6 +24,11 @@ class PacketSource {
|
||||
}
|
||||
|
||||
class PacketSink {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
on_packet(packet) {
|
||||
if (!this.writer) {
|
||||
return;
|
||||
@@ -31,11 +36,24 @@ class PacketSink {
|
||||
const buffer = packet.toJs({create_proxies : false});
|
||||
packet.destroy();
|
||||
//console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
|
||||
// TODO: create an async queue here instead of blindly calling write without awaiting
|
||||
this.writer(buffer);
|
||||
this.queue.push(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 {
|
||||
constructor(message) {
|
||||
super('log');
|
||||
@@ -185,4 +203,4 @@ export async function setupSimpleApp(appUrl, bumbleControls, log) {
|
||||
bumbleControls.onBumbleLoaded();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user