Compare commits

...

98 Commits

Author SHA1 Message Date
Charlie Boutier
3514d0fca3 cryptography: bump version to 44.0.3 to fix python parsing
Bug: 404336381
2025-05-07 14:49:06 -07:00
Gilles Boccon-Gibod
8e84b528ce Merge pull request #679 from google/gbg/pairing-ios 2025-05-05 09:50:49 -07:00
Gilles Boccon-Gibod
8b59b4f515 address PR comments 2025-05-04 17:50:00 -07:00
Gilles Boccon-Gibod
dcc72e49a2 forward legacy constants 2025-05-04 11:34:11 -07:00
Gilles Boccon-Gibod
ce04c163db fix merge conflict 2025-05-04 11:32:25 -07:00
Gilles Boccon-Gibod
9f1e95d87f more merge fixes 2025-05-04 11:31:15 -07:00
Gilles Boccon-Gibod
088bcbed0b resolve merge conflicts 2025-05-04 11:31:15 -07:00
Gilles Boccon-Gibod
57fbad6fa4 add LE advertisement and HR service 2025-05-04 11:31:15 -07:00
Gilles Boccon-Gibod
6926d5cb70 Merge pull request #678 from google/gbg/fix-timescales
fix a few timescale adjustments
2025-05-04 11:19:05 -07:00
Gilles Boccon-Gibod
00c7df6a11 update pyee version 2025-05-03 12:24:59 -07:00
Gilles Boccon-Gibod
fbd03ed4a5 fix a few timescale adjustments 2025-05-03 12:07:53 -07:00
Gilles Boccon-Gibod
d3bd5a759f Revert "fix a few timescale adjustments"
This reverts commit dedef79bef.
2025-05-03 12:05:31 -07:00
Gilles Boccon-Gibod
dedef79bef fix a few timescale adjustments 2025-05-03 12:00:34 -07:00
zxzxwu
8db974877e Merge pull request #677 from zxzxwu/java-workflow
Add a workflow to build btbench
2025-04-26 09:44:50 -07:00
Josh Wu
e7d1531eae Add a workflow to build btbench 2025-04-26 18:51:19 +08:00
zxzxwu
4785fe6002 Merge pull request #674 from zxzxwu/event
Declare emitted events as constants
2025-04-26 02:45:50 -07:00
Josh Wu
22d6a7bf05 Declare emitted events as constants 2025-04-26 03:55:31 +08:00
Gilles Boccon-Gibod
97757c0c3d Merge pull request #676 from google/gbg/bt-bench-fixes
fix numeric entries and phy request
2025-04-24 17:27:55 -07:00
Gilles Boccon-Gibod
ab60b42b85 minor fix 2025-04-24 17:22:25 -07:00
Gilles Boccon-Gibod
febed8179b fix numeric entries and phy request 2025-04-22 17:14:39 -07:00
zxzxwu
1bd83273e8 Merge pull request #671 from zxzxwu/gatt_typing
Add missing characteristic type parameters
2025-04-16 10:06:51 -07:00
Josh Wu
5e9fc89f80 Add missing characteristic type parameters 2025-04-16 20:34:12 +08:00
zxzxwu
2686663eb2 Merge pull request #670 from zxzxwu/ee
Make all event emitters abortable and async
2025-04-15 22:33:51 -07:00
Josh Wu
55801bc2ca Make all event emitters async
* Also remove AbortableEventEmitter
2025-04-16 12:40:57 +08:00
zxzxwu
6cecc16519 Merge pull request #669 from zxzxwu/import
Cleanup relative imports
2025-04-14 10:07:13 -07:00
Josh Wu
a57cf13e2e Cleanup relative imports 2025-04-12 23:06:52 +08:00
zxzxwu
58f153afc4 Merge pull request #667 from zxzxwu/transport
Replace legacy transport and role constants
2025-04-10 12:02:27 +08:00
Josh Wu
7569da37e4 Replace legacy transport and role constants 2025-04-09 19:04:02 +08:00
Gilles Boccon-Gibod
a8019a70da Merge pull request #666 from canatella/fix-l2cap-signaling-packet-identifiers
Fix L2CAP signaling packet identifiers
2025-04-08 14:49:43 -04:00
Damien Merenne
685f1dc43e Fix L2CAP signaling packet identifiers
According to the Bluetooth Core Spec, Volume 3, Part A, Section 4, 0x00 is an invalid identifier:

 4. Signaling packet formats
...
    Identifier (1 octet)

    ... Signaling identifier 0x00 is an invalid identifier and shall never be used in any command.
2025-04-08 14:37:02 +00:00
Gilles Boccon-Gibod
220b3b0236 Merge pull request #664 from google/gbg/auracast-broadcast-code
add broadcast code encoding
2025-03-20 14:33:05 -04:00
Gilles Boccon-Gibod
3495eb52ba reset parser before raising exception 2025-03-19 11:32:51 -04:00
zxzxwu
1f7a1401eb Merge pull request #644 from zxzxwu/pasync
Advertising Set Info Transfer
2025-03-18 22:12:23 +08:00
Josh Wu
ce2b02b62a Advertising Set Info Transfer 2025-03-18 21:59:35 +08:00
Gilles Boccon-Gibod
5e55c0e358 add broadcast code encoding 2025-03-17 19:56:02 -04:00
Gilles Boccon-Gibod
ebeb0dc9f1 Merge pull request #663 from google/gbg/ancs
Initial support for ANCS client functionality
2025-03-14 14:07:14 -04:00
Gilles Boccon-Gibod
776bdae519 Initial support for ANCS client functionality 2025-03-12 15:44:13 -04:00
zxzxwu
b2d9541f8f Merge pull request #332 from zxzxwu/role
Enumify: PhysicalTransport, Role, AddressType
2025-03-10 00:04:18 +08:00
Josh Wu
637224d5bc Enum: PhysicalTransport, Role, AddressType 2025-03-09 23:34:01 +08:00
Gilles Boccon-Gibod
92ab171013 Merge pull request #659 from pcondoleon/le_scan_interval_fix
Fixed le_scan_interval incorrectly being set with scan_window
2025-02-28 15:04:03 -05:00
Peter Condoleon
592475e2ed Fixed le_scan_interval incorrectly being set with scan_window 2025-02-27 13:54:20 +10:00
Gilles Boccon-Gibod
12bcdb7770 Merge pull request #658 from google/gbg/auracast-doc
add auracast doc
2025-02-25 07:18:38 -08:00
Gilles Boccon-Gibod
7a58f36020 add auracast doc 2025-02-24 09:10:03 -08:00
Gilles Boccon-Gibod
ed0eb912c5 Merge pull request #650 from google/gbg/gatt-adapter-typing
new GATT adapter classes with proper typing support
2025-02-23 18:06:16 -08:00
Gilles Boccon-Gibod
752ce6c830 Merge pull request #657 from google/gbg/auracast-iso-data-path-refactor
use bis link API
2025-02-23 07:42:13 -08:00
Gilles Boccon-Gibod
8e509c18c9 remove unused import 2025-02-22 13:34:57 -08:00
Gilles Boccon-Gibod
cc21ed27c7 use bis link API 2025-02-22 13:32:58 -08:00
Gilles Boccon-Gibod
82d825071c address PR comments 2025-02-22 12:43:38 -08:00
Gilles Boccon-Gibod
b932bafe6d Merge pull request #655 from markusjellitsch/fix/acl_packet_queue
FIX - acl_packet_queue.flush
2025-02-22 11:33:37 -08:00
markus
4e35aba033 fix acl_packet_queue flush when controller does not support HCI_READ_BUFFER_SIZE_COMMAND 2025-02-22 08:37:12 +01:00
zxzxwu
0060ee8ee2 Merge pull request #646 from zxzxwu/ad
Improve AdvertisingData type annotations
2025-02-22 02:52:09 +08:00
zxzxwu
3263d71f54 Merge pull request #654 from zxzxwu/init
Fix mutable default values
2025-02-22 02:51:11 +08:00
Josh Wu
f321143837 Improve AdvertisingData type annotations
* Add overloads to provide better return type hints
* Make advertising data type enum so they can be considered constants
2025-02-21 17:12:14 +08:00
Josh Wu
bac6f5baaf Fix mutable default values 2025-02-21 16:00:18 +08:00
Gilles Boccon-Gibod
e027bcb57a Merge pull request #651 from google/gbg/update-lc3-and-rootcanal
update rootcanal and lc3 dependencies
2025-02-18 14:08:42 -08:00
Gilles Boccon-Gibod
eeb9de31ed update dependencies 2025-02-18 12:52:57 -08:00
Gilles Boccon-Gibod
4befc5bbae fix doc strings 2025-02-18 09:50:15 -08:00
Gilles Boccon-Gibod
2c3af5b2bb Merge pull request #649 from google/gbg/async-read-phy
make connection phy async
2025-02-18 07:25:06 -08:00
Gilles Boccon-Gibod
dfb92e8ed1 fix pandora connection waiting 2025-02-17 19:50:57 -08:00
Gilles Boccon-Gibod
73d2b54e30 make connection phy async 2025-02-17 19:24:18 -08:00
Gilles Boccon-Gibod
8315a60f24 Merge pull request #648 from pstrueb/fix/auracast_transmit
Add setup_data_path for iso queues
2025-02-17 17:22:47 -08:00
185d5fd577 reformatting 2025-02-17 20:11:17 +01:00
pstrueb
ae5f9cf690 Remove little accident 2025-02-17 18:21:16 +01:00
pstrueb
4b66a38fe6 Refractoring again 2025-02-17 09:49:09 +01:00
pstrueb
f526f549ee refractoring 2025-02-17 08:51:28 +01:00
Gilles Boccon-Gibod
da029a1749 new adapter classes 2025-02-16 16:26:13 -08:00
pstrueb
8761129677 Add setup_data_path for iso queues 2025-02-16 14:38:05 +01:00
Gilles Boccon-Gibod
3f6f036270 Merge pull request #643 from google/gbg/auracast-audio-io
auracast audio io
2025-02-08 18:19:24 -05:00
zxzxwu
c02c1f33d2 Merge pull request #642 from zxzxwu/btbench
Add missing permissions in btbench
2025-02-07 04:48:46 +08:00
Josh Wu
c08449d9db Add missing permissions in btbench 2025-02-07 03:13:53 +08:00
Gilles Boccon-Gibod
3c8718bb5b Merge pull request #608 from google/gbg/bench-android-enhancements
add startupDelay and connectionPriority params to BtBench snippets
2025-02-06 10:37:55 -05:00
zxzxwu
26d38a855c Merge pull request #641 from zxzxwu/pasync
Receive Periodic Advertising Sync Transfer
2025-02-06 05:18:47 +08:00
Josh Wu
7360a887d9 Receive Periodic Advertising Sync Transfer 2025-02-06 05:12:22 +08:00
Gilles Boccon-Gibod
d6100755b1 add bond listener 2025-02-04 17:47:55 -05:00
Gilles Boccon-Gibod
a66eef6630 Merge pull request #640 from whitevegagabriel/cleanup
Rust library cleanup
2025-02-04 12:35:37 -05:00
Gabriel White-Vega
ae23ef7b9b Rust library cleanup
* Fix error code extraction from Python to Rust
* Add documentation for dealing with HCI packets
2025-02-04 12:23:06 -05:00
Gilles Boccon-Gibod
f368b5e518 wip 2025-02-03 18:02:14 -05:00
Gilles Boccon-Gibod
5293d32dc6 fix linter config 2025-02-03 18:02:14 -05:00
Gilles Boccon-Gibod
6d9a0bf4e1 fix linter config 2025-02-03 18:02:14 -05:00
Gilles Boccon-Gibod
3c7b5df7c5 add startupDelay and connectionPriority params to BtBench snippets 2025-02-03 18:00:46 -05:00
zxzxwu
dedc0aca54 Merge pull request #639 from zxzxwu/sdp
Correct SDP_ALL_ATTRIBUTES_RANGE value
2025-02-04 00:53:27 +08:00
Gilles Boccon-Gibod
7c019b574f Merge pull request #633 from markusjellitsch/fix/legacy-adv-params
fix advertising parameter usage for legacy advertising
2025-02-03 10:29:52 -05:00
markus
9b485fd943 revert python-avatar.yml 2025-02-03 15:17:22 +01:00
Josh Wu
fdee8269ec Correct SDP_ALL_ATTRIBUTES_RANGE value 2025-02-03 21:40:39 +08:00
zxzxwu
0767f2d4ae Merge pull request #638 from zxzxwu/avatar
Update actions/upload-artifact to v4
2025-02-03 21:31:42 +08:00
Josh Wu
c4a0846727 Update actions/upload-artifact to v4 2025-02-03 16:41:09 +08:00
zxzxwu
83ac70e426 Merge pull request #619 from zxzxwu/cs
Channel Sounding
2025-02-01 03:46:59 +08:00
markus
01cce3525f update avatar to github actions v4 2025-01-30 23:55:15 +01:00
markus
b9d35aea47 revert advertising_interval to type int 2025-01-30 19:47:20 +01:00
zxzxwu
079cf6b896 Merge pull request #624 from zxzxwu/gatt
Support GATT Service
2025-01-28 20:02:43 +08:00
Markus Jellitsch
180655088c run linter 2025-01-27 22:17:31 +01:00
Gilles Boccon-Gibod
a1bade6f20 Merge pull request #632 from markusjellitsch/fix/adapt-param-types
Adapt scanning and connection parameters type
2025-01-27 10:46:08 -05:00
Gilles Boccon-Gibod
5d80e7fd80 Merge pull request #634 from jmdietrich-gcx/fix_missing_await_for_update_rpa
Add missing await for update_rpa()
2025-01-27 10:45:42 -05:00
Jan-Marcel Dietrich
2198692961 Add missing await for update_rpa() 2025-01-27 15:14:52 +01:00
Markus Jellitsch
9023407ee4 fix advertising parameters for legacy advertising 2025-01-23 15:14:54 +01:00
Markus Jellitsch
54d961bbe5 adapt scanning and connection parameters type 2025-01-23 14:53:20 +01:00
Josh Wu
745e107849 Channel Sounding device handlers 2025-01-22 23:38:44 +08:00
Josh Wu
1eb9d8d055 Support GATT Service 2025-01-15 02:13:25 +08:00
138 changed files with 7187 additions and 2566 deletions

26
.github/ci-gradle.properties vendored Normal file
View File

@@ -0,0 +1,26 @@
#
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://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.
#
org.gradle.configureondemand=true
org.gradle.caching=true
org.gradle.parallel=true
# Declare we support AndroidX
android.useAndroidX=true
org.gradle.jvmargs=-Xmx4608m -XX:MaxMetaspaceSize=1536m -XX:+HeapDumpOnOutOfMemoryError
kotlin.compiler.execution.strategy=in-process

33
.github/workflows/gradle-btbench.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Gradle Android Build & test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
paths:
- 'extras/android/BtBench/**'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Check out from Git
uses: actions/checkout@v3
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Build with Gradle
run: cd extras/android/BtBench && ./gradlew build

View File

@@ -44,7 +44,7 @@ jobs:
run: cat rootcanal.log
- name: Upload Mobly logs
if: always()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: mobly-logs
name: mobly-logs-${{ strategy.job-index }}
path: /tmp/logs/mobly/bumble.bumbles/

View File

@@ -18,7 +18,6 @@
from __future__ import annotations
import asyncio
import asyncio.subprocess
import collections
import contextlib
import dataclasses
@@ -27,7 +26,6 @@ import logging
import os
import struct
from typing import (
cast,
Any,
AsyncGenerator,
Coroutine,
@@ -37,7 +35,6 @@ from typing import (
)
import click
import pyee
try:
import lc3 # type: ignore # pylint: disable=E0401
@@ -100,12 +97,31 @@ def codec_config_string(
return '\n'.join(indent + line for line in lines)
def broadcast_code_bytes(broadcast_code: str) -> bytes:
"""
Convert a broadcast code string to a 16-byte value.
If `broadcast_code` is `0x` followed by 32 hex characters, it is interpreted as a
raw 16-byte raw broadcast code in big-endian byte order.
Otherwise, `broadcast_code` is converted to a 16-byte value as specified in
BLUETOOTH CORE SPECIFICATION Version 6.0 | Vol 3, Part C , section 3.2.6.3
"""
if broadcast_code.startswith("0x") and len(broadcast_code) == 34:
return bytes.fromhex(broadcast_code[2:])[::-1]
broadcast_code_utf8 = broadcast_code.encode("utf-8")
if len(broadcast_code_utf8) > 16:
raise ValueError("broadcast code must be <= 16 bytes in utf-8 encoding")
padding = bytes(16 - len(broadcast_code_utf8))
return broadcast_code_utf8 + padding
# -----------------------------------------------------------------------------
# Scan For Broadcasts
# -----------------------------------------------------------------------------
class BroadcastScanner(pyee.EventEmitter):
class BroadcastScanner(bumble.utils.EventEmitter):
@dataclasses.dataclass
class Broadcast(pyee.EventEmitter):
class Broadcast(bumble.utils.EventEmitter):
name: str | None
sync: bumble.device.PeriodicAdvertisingSync
broadcast_id: int
@@ -127,11 +143,9 @@ class BroadcastScanner(pyee.EventEmitter):
def update(self, advertisement: bumble.device.Advertisement) -> None:
self.rssi = advertisement.rssi
for service_data in advertisement.data.get_all(
core.AdvertisingData.SERVICE_DATA
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
):
assert isinstance(service_data, tuple)
service_uuid, data = service_data
assert isinstance(data, bytes)
if service_uuid == gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE:
self.public_broadcast_announcement = (
@@ -145,16 +159,14 @@ class BroadcastScanner(pyee.EventEmitter):
)
continue
self.appearance = advertisement.data.get( # type: ignore[assignment]
core.AdvertisingData.APPEARANCE
self.appearance = advertisement.data.get(
core.AdvertisingData.Type.APPEARANCE
)
if manufacturer_data := advertisement.data.get(
core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
core.AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA
):
assert isinstance(manufacturer_data, tuple)
company_id = cast(int, manufacturer_data[0])
data = cast(bytes, manufacturer_data[1])
company_id, data = manufacturer_data
self.manufacturer_data = (
company_ids.COMPANY_IDENTIFIERS.get(
company_id, f'0x{company_id:04X}'
@@ -239,22 +251,14 @@ class BroadcastScanner(pyee.EventEmitter):
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,
)
print(color(' Number of BIS:', 'magenta'), self.biginfo.num_bis)
print(color(' ISO Interval: ', 'magenta'), self.biginfo.iso_interval)
print(color(' Max PDU: ', 'magenta'), self.biginfo.max_pdu)
print(color(' SDU Interval: ', 'magenta'), self.biginfo.sdu_interval)
print(color(' Max SDU: ', 'magenta'), self.biginfo.max_sdu)
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.emit('sync_establishment')
@@ -271,11 +275,9 @@ class BroadcastScanner(pyee.EventEmitter):
return
for service_data in advertisement.data.get_all(
core.AdvertisingData.SERVICE_DATA
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
):
assert isinstance(service_data, tuple)
service_uuid, data = service_data
assert isinstance(data, bytes)
if service_uuid == gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
self.basic_audio_announcement = (
@@ -316,24 +318,23 @@ class BroadcastScanner(pyee.EventEmitter):
def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
if not (
ads := advertisement.data.get_all(
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
)
) or not (
broadcast_audio_announcement := next(
(
ad
for ad in ads
if isinstance(ad, tuple)
and ad[0] == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
if ad[0] == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
),
None,
)
):
return
broadcast_name = advertisement.data.get(core.AdvertisingData.BROADCAST_NAME)
assert isinstance(broadcast_name, str) or broadcast_name is None
assert isinstance(broadcast_audio_announcement[1], bytes)
broadcast_name = advertisement.data.get_all(
core.AdvertisingData.Type.BROADCAST_NAME
)
if broadcast := self.broadcasts.get(advertisement.address):
broadcast.update(advertisement)
@@ -341,7 +342,7 @@ class BroadcastScanner(pyee.EventEmitter):
bumble.utils.AsyncRunner.spawn(
self.on_new_broadcast(
broadcast_name,
broadcast_name[0] if broadcast_name else None,
advertisement,
bap.BroadcastAudioAnnouncement.from_bytes(
broadcast_audio_announcement[1]
@@ -373,7 +374,7 @@ class BroadcastScanner(pyee.EventEmitter):
self.emit('broadcast_loss', broadcast)
class PrintingBroadcastScanner(pyee.EventEmitter):
class PrintingBroadcastScanner(bumble.utils.EventEmitter):
def __init__(
self, device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
) -> None:
@@ -522,14 +523,19 @@ async def run_assist(
return
# Subscribe to and read the broadcast receive state characteristics
def on_broadcast_receive_state_update(
value: bass.BroadcastReceiveState, index: int
) -> None:
print(
f"{color(f'Broadcast Receive State Update [{index}]:', 'green')} {value}"
)
for i, broadcast_receive_state in enumerate(
bass_client.broadcast_receive_states
):
try:
await broadcast_receive_state.subscribe(
lambda value, i=i: print(
f"{color(f'Broadcast Receive State Update [{i}]:', 'green')} {value}"
)
functools.partial(on_broadcast_receive_state_update, index=i)
)
except core.ProtocolError as error:
print(
@@ -705,14 +711,13 @@ async def run_receive(
def on_change() -> None:
if (
broadcast.basic_audio_announcement
and not basic_audio_announcement_scanned.is_set()
):
broadcast.basic_audio_announcement and broadcast.biginfo
) and not basic_audio_announcement_scanned.is_set():
basic_audio_announcement_scanned.set()
broadcast.on('change', on_change)
if not broadcast.basic_audio_announcement:
print('Wait for Basic Audio Announcement...')
if not broadcast.basic_audio_announcement or not broadcast.biginfo:
print('Wait for Basic Audio Announcement and BIG Info...')
await basic_audio_announcement_scanned.wait()
print('Basic Audio Announcement found')
broadcast.print()
@@ -733,7 +738,7 @@ async def run_receive(
big_sync_timeout=0x4000,
bis=[bis.index for bis in subgroup.bis],
broadcast_code=(
bytes.fromhex(broadcast_code) if broadcast_code else None
broadcast_code_bytes(broadcast_code) if broadcast_code else None
),
),
)
@@ -790,16 +795,8 @@ async def run_receive(
for i, bis_link in enumerate(big_sync.bis_links):
print(f'Setup ISO for BIS {bis_link.handle}')
bis_link.sink = functools.partial(sink, lc3_queues[i])
await device.send_command(
hci.HCI_LE_Setup_ISO_Data_Path_Command(
connection_handle=bis_link.handle,
data_path_direction=hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST,
data_path_id=0,
codec_id=hci.CodingFormat(codec_id=hci.CodecID.TRANSPARENT),
controller_delay=0,
codec_configuration=b'',
),
check_result=True,
await bis_link.setup_data_path(
direction=bis_link.Direction.CONTROLLER_TO_HOST
)
terminated = asyncio.Event()
@@ -955,14 +952,19 @@ async def run_transmit(
max_transport_latency=65,
rtn=4,
broadcast_code=(
bytes.fromhex(broadcast_code) if broadcast_code else None
broadcast_code_bytes(broadcast_code) if broadcast_code else None
),
),
)
for bis_link in big.bis_links:
print(f'Setup ISO for BIS {bis_link.handle}')
await bis_link.setup_data_path(
direction=bis_link.Direction.HOST_TO_CONTROLLER
)
iso_queues = [
bumble.device.IsoPacketStream(big.bis_links[0], 64),
bumble.device.IsoPacketStream(big.bis_links[1], 64),
bumble.device.IsoPacketStream(bis_link, 64)
for bis_link in big.bis_links
]
def on_flow():
@@ -1094,7 +1096,7 @@ def pair(ctx, transport, address):
'--broadcast-code',
metavar='BROADCAST_CODE',
type=str,
help='Broadcast encryption code in hex format',
help='Broadcast encryption code (string or raw hex format prefixed with 0x)',
)
@click.option(
'--sync-timeout',

View File

@@ -16,6 +16,7 @@
# Imports
# -----------------------------------------------------------------------------
import asyncio
import dataclasses
import enum
import logging
import os
@@ -27,8 +28,7 @@ import click
from bumble import l2cap
from bumble.core import (
BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT,
PhysicalTransport,
BT_L2CAP_PROTOCOL_ID,
BT_RFCOMM_PROTOCOL_ID,
UUID,
@@ -41,8 +41,7 @@ from bumble.hci import (
HCI_LE_1M_PHY,
HCI_LE_2M_PHY,
HCI_LE_CODED_PHY,
HCI_CENTRAL_ROLE,
HCI_PERIPHERAL_ROLE,
Role,
HCI_Constant,
HCI_Error,
HCI_StatusError,
@@ -97,49 +96,22 @@ DEFAULT_RFCOMM_MTU = 2048
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
def parse_packet(packet):
if len(packet) < 1:
logging.info(
color(f'!!! Packet too short (got {len(packet)} bytes, need >= 1)', 'red')
)
raise ValueError('packet too short')
try:
packet_type = PacketType(packet[0])
except ValueError:
logging.info(color(f'!!! Invalid packet type 0x{packet[0]:02X}', 'red'))
raise
return (packet_type, packet[1:])
def parse_packet_sequence(packet_data):
if len(packet_data) < 5:
logging.info(
color(
f'!!!Packet too short (got {len(packet_data)} bytes, need >= 5)',
'red',
)
)
raise ValueError('packet too short')
return struct.unpack_from('>bI', packet_data, 0)
def le_phy_name(phy_id):
return {HCI_LE_1M_PHY: '1M', HCI_LE_2M_PHY: '2M', HCI_LE_CODED_PHY: 'CODED'}.get(
phy_id, HCI_Constant.le_phy_name(phy_id)
)
def print_connection_phy(phy):
logging.info(
color('@@@ PHY: ', 'yellow') + f'TX:{le_phy_name(phy.tx_phy)}/'
f'RX:{le_phy_name(phy.rx_phy)}'
)
def print_connection(connection):
params = []
if connection.transport == BT_LE_TRANSPORT:
params.append(
'PHY='
f'TX:{le_phy_name(connection.phy.tx_phy)}/'
f'RX:{le_phy_name(connection.phy.rx_phy)}'
)
if connection.transport == PhysicalTransport.LE:
params.append(
'DL=('
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
@@ -149,9 +121,9 @@ def print_connection(connection):
params.append(
'Parameters='
f'{connection.parameters.connection_interval * 1.25:.2f}/'
f'{connection.parameters.connection_interval:.2f}/'
f'{connection.parameters.peripheral_latency}/'
f'{connection.parameters.supervision_timeout * 10} '
f'{connection.parameters.supervision_timeout:.2f} '
)
params.append(f'MTU={connection.att_mtu}')
@@ -215,7 +187,7 @@ def log_stats(title, stats, precision=2):
async def switch_roles(connection, role):
target_role = HCI_CENTRAL_ROLE if role == "central" else HCI_PERIPHERAL_ROLE
target_role = Role.CENTRAL if role == "central" else Role.PERIPHERAL
if connection.role != target_role:
logging.info(f'{color("### Switching roles to:", "cyan")} {role}')
try:
@@ -225,13 +197,135 @@ async def switch_roles(connection, role):
logging.info(f'{color("### Role switch failed:", "red")} {error}')
class PacketType(enum.IntEnum):
RESET = 0
SEQUENCE = 1
ACK = 2
# -----------------------------------------------------------------------------
# Packet
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class Packet:
class PacketType(enum.IntEnum):
RESET = 0
SEQUENCE = 1
ACK = 2
class PacketFlags(enum.IntFlag):
LAST = 1
packet_type: PacketType
flags: PacketFlags = PacketFlags(0)
sequence: int = 0
timestamp: int = 0
payload: bytes = b""
@classmethod
def from_bytes(cls, data: bytes):
if len(data) < 1:
logging.warning(
color(f'!!! Packet too short (got {len(data)} bytes, need >= 1)', 'red')
)
raise ValueError('packet too short')
try:
packet_type = cls.PacketType(data[0])
except ValueError:
logging.warning(color(f'!!! Invalid packet type 0x{data[0]:02X}', 'red'))
raise
if packet_type == cls.PacketType.RESET:
return cls(packet_type)
flags = cls.PacketFlags(data[1])
(sequence,) = struct.unpack_from("<I", data, 2)
if packet_type == cls.PacketType.ACK:
if len(data) < 6:
logging.warning(
color(
f'!!! Packet too short (got {len(data)} bytes, need >= 6)',
'red',
)
)
return cls(packet_type, flags, sequence)
if len(data) < 10:
logging.warning(
color(
f'!!! Packet too short (got {len(data)} bytes, need >= 10)', 'red'
)
)
raise ValueError('packet too short')
(timestamp,) = struct.unpack_from("<I", data, 6)
return cls(packet_type, flags, sequence, timestamp, data[10:])
def __bytes__(self):
if self.packet_type == self.PacketType.RESET:
return bytes([self.packet_type])
if self.packet_type == self.PacketType.ACK:
return struct.pack("<BBI", self.packet_type, self.flags, self.sequence)
return (
struct.pack(
"<BBII", self.packet_type, self.flags, self.sequence, self.timestamp
)
+ self.payload
)
PACKET_FLAG_LAST = 1
# -----------------------------------------------------------------------------
# Jitter Stats
# -----------------------------------------------------------------------------
class JitterStats:
def __init__(self):
self.reset()
def reset(self):
self.packets = []
self.receive_times = []
self.jitter = []
def on_packet_received(self, packet):
now = time.time()
self.packets.append(packet)
self.receive_times.append(now)
if packet.timestamp and len(self.packets) > 1:
expected_time = (
self.receive_times[0]
+ (packet.timestamp - self.packets[0].timestamp) / 1000000
)
jitter = now - expected_time
else:
jitter = 0.0
self.jitter.append(jitter)
return jitter
def show_stats(self):
if len(self.jitter) < 3:
return
average = sum(self.jitter) / len(self.jitter)
adjusted = [jitter - average for jitter in self.jitter]
log_stats('Jitter (signed)', adjusted, 3)
log_stats('Jitter (absolute)', [abs(jitter) for jitter in adjusted], 3)
# Show a histogram
bin_count = 20
bins = [0] * bin_count
interval_min = min(adjusted)
interval_max = max(adjusted)
interval_range = interval_max - interval_min
bin_thresholds = [
interval_min + i * (interval_range / bin_count) for i in range(bin_count)
]
for jitter in adjusted:
for i in reversed(range(bin_count)):
if jitter >= bin_thresholds[i]:
bins[i] += 1
break
for i in range(bin_count):
logging.info(f'@@@ >= {bin_thresholds[i]:.4f}: {bins[i]}')
# -----------------------------------------------------------------------------
@@ -281,19 +375,37 @@ class Sender:
await asyncio.sleep(self.tx_start_delay)
logging.info(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET]))
await self.packet_io.send_packet(
bytes(Packet(packet_type=Packet.PacketType.RESET))
)
self.start_time = time.time()
self.bytes_sent = 0
for tx_i in range(self.tx_packet_count):
packet_flags = (
PACKET_FLAG_LAST if tx_i == self.tx_packet_count - 1 else 0
if self.pace > 0:
# Wait until it is time to send the next packet
target_time = self.start_time + (tx_i * self.pace / 1000)
now = time.time()
if now < target_time:
await asyncio.sleep(target_time - now)
else:
await self.packet_io.drain()
packet = bytes(
Packet(
packet_type=Packet.PacketType.SEQUENCE,
flags=(
Packet.PacketFlags.LAST
if tx_i == self.tx_packet_count - 1
else 0
),
sequence=tx_i,
timestamp=int((time.time() - self.start_time) * 1000000),
payload=bytes(
self.tx_packet_size - 10 - self.packet_io.overhead_size
),
)
)
packet = struct.pack(
'>bbI',
PacketType.SEQUENCE,
packet_flags,
tx_i,
) + bytes(self.tx_packet_size - 6 - self.packet_io.overhead_size)
logging.info(
color(
f'Sending packet {tx_i}: {self.tx_packet_size} bytes', 'yellow'
@@ -302,14 +414,6 @@ class Sender:
self.bytes_sent += len(packet)
await self.packet_io.send_packet(packet)
if self.pace is None:
continue
if self.pace > 0:
await asyncio.sleep(self.pace / 1000)
else:
await self.packet_io.drain()
await self.done.wait()
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
@@ -321,13 +425,13 @@ class Sender:
if self.repeat:
logging.info(color('--- End of runs', 'blue'))
def on_packet_received(self, packet):
def on_packet_received(self, data):
try:
packet_type, _ = parse_packet(packet)
packet = Packet.from_bytes(data)
except ValueError:
return
if packet_type == PacketType.ACK:
if packet.packet_type == Packet.PacketType.ACK:
elapsed = time.time() - self.start_time
average_tx_speed = self.bytes_sent / elapsed
self.stats.append(average_tx_speed)
@@ -350,52 +454,53 @@ class Receiver:
last_timestamp: float
def __init__(self, packet_io, linger):
self.reset()
self.jitter_stats = JitterStats()
self.packet_io = packet_io
self.packet_io.packet_listener = self
self.linger = linger
self.done = asyncio.Event()
self.reset()
def reset(self):
self.expected_packet_index = 0
self.measurements = [(time.time(), 0)]
self.total_bytes_received = 0
self.jitter_stats.reset()
def on_packet_received(self, packet):
def on_packet_received(self, data):
try:
packet_type, packet_data = parse_packet(packet)
packet = Packet.from_bytes(data)
except ValueError:
logging.exception("invalid packet")
return
if packet_type == PacketType.RESET:
if packet.packet_type == Packet.PacketType.RESET:
logging.info(color('=== Received RESET', 'magenta'))
self.reset()
return
try:
packet_flags, packet_index = parse_packet_sequence(packet_data)
except ValueError:
return
jitter = self.jitter_stats.on_packet_received(packet)
logging.info(
f'<<< Received packet {packet_index}: '
f'flags=0x{packet_flags:02X}, '
f'{len(packet) + self.packet_io.overhead_size} bytes'
f'<<< Received packet {packet.sequence}: '
f'flags={packet.flags}, '
f'jitter={jitter:.4f}, '
f'{len(data) + self.packet_io.overhead_size} bytes',
)
if packet_index != self.expected_packet_index:
if packet.sequence != self.expected_packet_index:
logging.info(
color(
f'!!! Unexpected packet, expected {self.expected_packet_index} '
f'but received {packet_index}'
f'but received {packet.sequence}'
)
)
now = time.time()
elapsed_since_start = now - self.measurements[0][0]
elapsed_since_last = now - self.measurements[-1][0]
self.measurements.append((now, len(packet)))
self.total_bytes_received += len(packet)
instant_rx_speed = len(packet) / elapsed_since_last
self.measurements.append((now, len(data)))
self.total_bytes_received += len(data)
instant_rx_speed = len(data) / elapsed_since_last
average_rx_speed = self.total_bytes_received / elapsed_since_start
window = self.measurements[-64:]
windowed_rx_speed = sum(measurement[1] for measurement in window[1:]) / (
@@ -411,15 +516,17 @@ class Receiver:
)
)
self.expected_packet_index = packet_index + 1
self.expected_packet_index = packet.sequence + 1
if packet_flags & PACKET_FLAG_LAST:
if packet.flags & Packet.PacketFlags.LAST:
AsyncRunner.spawn(
self.packet_io.send_packet(
struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index)
bytes(Packet(Packet.PacketType.ACK, packet.flags, packet.sequence))
)
)
logging.info(color('@@@ Received last packet', 'green'))
self.jitter_stats.show_stats()
if not self.linger:
self.done.set()
@@ -479,25 +586,32 @@ class Ping:
await asyncio.sleep(self.tx_start_delay)
logging.info(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET]))
await self.packet_io.send_packet(bytes(Packet(Packet.PacketType.RESET)))
packet_interval = self.pace / 1000
start_time = time.time()
self.next_expected_packet_index = 0
for i in range(self.tx_packet_count):
target_time = start_time + (i * packet_interval)
target_time = start_time + (i * self.pace / 1000)
now = time.time()
if now < target_time:
await asyncio.sleep(target_time - now)
now = time.time()
packet = struct.pack(
'>bbI',
PacketType.SEQUENCE,
(PACKET_FLAG_LAST if i == self.tx_packet_count - 1 else 0),
i,
) + bytes(self.tx_packet_size - 6)
packet = bytes(
Packet(
packet_type=Packet.PacketType.SEQUENCE,
flags=(
Packet.PacketFlags.LAST
if i == self.tx_packet_count - 1
else 0
),
sequence=i,
timestamp=int((now - start_time) * 1000000),
payload=bytes(self.tx_packet_size - 10),
)
)
logging.info(color(f'Sending packet {i}', 'yellow'))
self.ping_times.append(time.time())
self.ping_times.append(now)
await self.packet_io.send_packet(packet)
await self.done.wait()
@@ -531,40 +645,35 @@ class Ping:
if self.repeat:
logging.info(color('--- End of runs', 'blue'))
def on_packet_received(self, packet):
def on_packet_received(self, data):
try:
packet_type, packet_data = parse_packet(packet)
packet = Packet.from_bytes(data)
except ValueError:
return
try:
packet_flags, packet_index = parse_packet_sequence(packet_data)
except ValueError:
return
if packet_type == PacketType.ACK:
elapsed = time.time() - self.ping_times[packet_index]
if packet.packet_type == Packet.PacketType.ACK:
elapsed = time.time() - self.ping_times[packet.sequence]
rtt = elapsed * 1000
self.rtts.append(rtt)
logging.info(
color(
f'<<< Received ACK [{packet_index}], RTT={rtt:.2f}ms',
f'<<< Received ACK [{packet.sequence}], RTT={rtt:.2f}ms',
'green',
)
)
if packet_index == self.next_expected_packet_index:
if packet.sequence == self.next_expected_packet_index:
self.next_expected_packet_index += 1
else:
logging.info(
color(
f'!!! Unexpected packet, '
f'expected {self.next_expected_packet_index} '
f'but received {packet_index}'
f'but received {packet.sequence}'
)
)
if packet_flags & PACKET_FLAG_LAST:
if packet.flags & Packet.PacketFlags.LAST:
self.done.set()
return
@@ -576,89 +685,56 @@ class Pong:
expected_packet_index: int
def __init__(self, packet_io, linger):
self.reset()
self.jitter_stats = JitterStats()
self.packet_io = packet_io
self.packet_io.packet_listener = self
self.linger = linger
self.done = asyncio.Event()
self.reset()
def reset(self):
self.expected_packet_index = 0
self.receive_times = []
def on_packet_received(self, packet):
self.receive_times.append(time.time())
self.jitter_stats.reset()
def on_packet_received(self, data):
try:
packet_type, packet_data = parse_packet(packet)
packet = Packet.from_bytes(data)
except ValueError:
return
if packet_type == PacketType.RESET:
if packet.packet_type == Packet.PacketType.RESET:
logging.info(color('=== Received RESET', 'magenta'))
self.reset()
return
try:
packet_flags, packet_index = parse_packet_sequence(packet_data)
except ValueError:
return
interval = (
self.receive_times[-1] - self.receive_times[-2]
if len(self.receive_times) >= 2
else 0
)
jitter = self.jitter_stats.on_packet_received(packet)
logging.info(
color(
f'<<< Received packet {packet_index}: '
f'flags=0x{packet_flags:02X}, {len(packet)} bytes, '
f'interval={interval:.4f}',
f'<<< Received packet {packet.sequence}: '
f'flags={packet.flags}, {len(data)} bytes, '
f'jitter={jitter:.4f}',
'green',
)
)
if packet_index != self.expected_packet_index:
if packet.sequence != self.expected_packet_index:
logging.info(
color(
f'!!! Unexpected packet, expected {self.expected_packet_index} '
f'but received {packet_index}'
f'but received {packet.sequence}'
)
)
self.expected_packet_index = packet_index + 1
self.expected_packet_index = packet.sequence + 1
AsyncRunner.spawn(
self.packet_io.send_packet(
struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index)
bytes(Packet(Packet.PacketType.ACK, packet.flags, packet.sequence))
)
)
if packet_flags & PACKET_FLAG_LAST:
if len(self.receive_times) >= 3:
# Show basic stats
intervals = [
self.receive_times[i + 1] - self.receive_times[i]
for i in range(len(self.receive_times) - 1)
]
log_stats('Packet intervals', intervals, 3)
# Show a histogram
bin_count = 20
bins = [0] * bin_count
interval_min = min(intervals)
interval_max = max(intervals)
interval_range = interval_max - interval_min
bin_thresholds = [
interval_min + i * (interval_range / bin_count)
for i in range(bin_count)
]
for interval in intervals:
for i in reversed(range(bin_count)):
if interval >= bin_thresholds[i]:
bins[i] += 1
break
for i in range(bin_count):
logging.info(f'@@@ >= {bin_thresholds[i]:.4f}: {bins[i]}')
if packet.flags & Packet.PacketFlags.LAST:
self.jitter_stats.show_stats()
if not self.linger:
self.done.set()
@@ -1180,6 +1256,7 @@ class Central(Connection.Listener):
self.device.classic_enabled = self.classic
# Set up a pairing config factory with minimal requirements.
self.device.config.keystore = "JsonKeyStore"
self.device.pairing_config_factory = lambda _: PairingConfig(
sc=False, mitm=False, bonding=False
)
@@ -1197,7 +1274,11 @@ class Central(Connection.Listener):
self.connection = await self.device.connect(
self.peripheral_address,
connection_parameters_preferences=self.connection_parameter_preferences,
transport=BT_BR_EDR_TRANSPORT if self.classic else BT_LE_TRANSPORT,
transport=(
PhysicalTransport.BR_EDR
if self.classic
else PhysicalTransport.LE
),
)
except CommandTimeoutError:
logging.info(color('!!! Connection timed out', 'red'))
@@ -1212,6 +1293,10 @@ class Central(Connection.Listener):
self.connection.listener = self
print_connection(self.connection)
if not self.classic:
phy = await self.connection.get_phy()
print_connection_phy(phy)
# Switch roles if needed.
if self.role_switch:
await switch_roles(self.connection, self.role_switch)
@@ -1268,8 +1353,8 @@ class Central(Connection.Listener):
def on_connection_parameters_update(self):
print_connection(self.connection)
def on_connection_phy_update(self):
print_connection(self.connection)
def on_connection_phy_update(self, phy):
print_connection_phy(phy)
def on_connection_att_mtu_update(self):
print_connection(self.connection)
@@ -1324,6 +1409,7 @@ class Peripheral(Device.Listener, Connection.Listener):
self.device.classic_enabled = self.classic
# Set up a pairing config factory with minimal requirements.
self.device.config.keystore = "JsonKeyStore"
self.device.pairing_config_factory = lambda _: PairingConfig(
sc=False, mitm=False, bonding=False
)
@@ -1395,8 +1481,8 @@ class Peripheral(Device.Listener, Connection.Listener):
def on_connection_parameters_update(self):
print_connection(self.connection)
def on_connection_phy_update(self):
print_connection(self.connection)
def on_connection_phy_update(self, phy):
print_connection_phy(phy)
def on_connection_att_mtu_update(self):
print_connection(self.connection)
@@ -1471,7 +1557,7 @@ def create_mode_factory(ctx, default_mode):
def create_scenario_factory(ctx, default_scenario):
scenario = ctx.obj['scenario']
if scenario is None:
scenarion = default_scenario
scenario = default_scenario
def create_scenario(packet_io):
if scenario == 'send':
@@ -1530,6 +1616,7 @@ def create_scenario_factory(ctx, default_scenario):
'--att-mtu',
metavar='MTU',
type=click.IntRange(23, 517),
default=517,
help='GATT MTU (gatt-client mode)',
)
@click.option(
@@ -1605,7 +1692,7 @@ def create_scenario_factory(ctx, default_scenario):
'--packet-size',
'-s',
metavar='SIZE',
type=click.IntRange(8, 8192),
type=click.IntRange(10, 8192),
default=500,
help='Packet size (send or ping scenario)',
)

View File

@@ -22,7 +22,6 @@
import asyncio
import logging
import os
import random
import re
import humanize
from typing import Optional, Union
@@ -56,8 +55,14 @@ from prompt_toolkit.layout import (
from bumble import __version__
import bumble.core
from bumble import colors
from bumble.core import UUID, AdvertisingData, BT_LE_TRANSPORT
from bumble.device import ConnectionParametersPreferences, Device, Connection, Peer
from bumble.core import UUID, AdvertisingData, PhysicalTransport
from bumble.device import (
ConnectionParametersPreferences,
ConnectionPHY,
Device,
Connection,
Peer,
)
from bumble.utils import AsyncRunner
from bumble.transport import open_transport_or_link
from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
@@ -125,6 +130,7 @@ def parse_phys(phys):
# -----------------------------------------------------------------------------
class ConsoleApp:
connected_peer: Optional[Peer]
connection_phy: Optional[ConnectionPHY]
def __init__(self):
self.known_addresses = set()
@@ -132,6 +138,7 @@ class ConsoleApp:
self.known_local_attributes = []
self.device = None
self.connected_peer = None
self.connection_phy = None
self.top_tab = 'device'
self.monitor_rssi = False
self.connection_rssi = None
@@ -328,14 +335,14 @@ class ConsoleApp:
elif self.connected_peer:
connection = self.connected_peer.connection
connection_parameters = (
f'{connection.parameters.connection_interval}/'
f'{connection.parameters.connection_interval:.2f}/'
f'{connection.parameters.peripheral_latency}/'
f'{connection.parameters.supervision_timeout}'
f'{connection.parameters.supervision_timeout:.2f}'
)
if connection.transport == BT_LE_TRANSPORT:
if self.connection_phy is not None:
phy_state = (
f' RX={le_phy_name(connection.phy.rx_phy)}/'
f'TX={le_phy_name(connection.phy.tx_phy)}'
f' RX={le_phy_name(self.connection_phy.rx_phy)}/'
f'TX={le_phy_name(self.connection_phy.tx_phy)}'
)
else:
phy_state = ''
@@ -654,11 +661,12 @@ class ConsoleApp:
self.append_to_output('connecting...')
try:
await self.device.connect(
connection = await self.device.connect(
params[0],
connection_parameters_preferences=connection_parameters_preferences,
timeout=DEFAULT_CONNECTION_TIMEOUT,
)
self.connection_phy = await connection.get_phy()
self.top_tab = 'services'
except bumble.core.TimeoutError:
self.show_error('connection timed out')
@@ -838,8 +846,8 @@ class ConsoleApp:
phy = await self.connected_peer.connection.get_phy()
self.append_to_output(
f'PHY: RX={HCI_Constant.le_phy_name(phy[0])}, '
f'TX={HCI_Constant.le_phy_name(phy[1])}'
f'PHY: RX={HCI_Constant.le_phy_name(phy.rx_phy)}, '
f'TX={HCI_Constant.le_phy_name(phy.tx_phy)}'
)
async def do_request_mtu(self, params):
@@ -1076,10 +1084,9 @@ class DeviceListener(Device.Listener, Connection.Listener):
f'{self.app.connected_peer.connection.parameters}'
)
def on_connection_phy_update(self):
self.app.append_to_output(
f'connection phy update: {self.app.connected_peer.connection.phy}'
)
def on_connection_phy_update(self, phy):
self.app.connection_phy = phy
self.app.append_to_output(f'connection phy update: {phy}')
def on_connection_att_mtu_update(self):
self.app.append_to_output(

View File

@@ -234,7 +234,7 @@ class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
Characteristic.WRITEABLE,
CharacteristicValue(write=self.on_rx_write),
)
self.tx_characteristic = Characteristic(
self.tx_characteristic: Characteristic[bytes] = Characteristic(
GG_GATTLINK_TX_CHARACTERISTIC_UUID,
Characteristic.Properties.NOTIFY,
Characteristic.READABLE,

View File

@@ -37,6 +37,7 @@ import click
import aiohttp.web
import bumble
from bumble import utils
from bumble.core import AdvertisingData
from bumble.colors import color
from bumble.device import Device, DeviceConfiguration, AdvertisingParameters, CisLink
@@ -359,7 +360,9 @@ class Speaker:
pcm = decoder.decode(
pdu.iso_sdu_fragment, bit_depth=DEFAULT_PCM_BYTES_PER_SAMPLE * 8
)
self.device.abort_on('disconnection', self.ui_server.send_audio(pcm))
utils.cancel_on_event(
self.device, 'disconnection', self.ui_server.send_audio(pcm)
)
def on_ase_state_change(ase: ascs.AseStateMachine) -> None:
codec_config = ase.codec_specific_configuration
@@ -373,7 +376,8 @@ class Speaker:
or codec_config.codec_frames_per_sdu is None
):
return
ase.cis_link.abort_on(
utils.cancel_on_event(
ase.cis_link,
'disconnection',
lc3_source_task(
filename=self.lc3_input_file_path,

View File

@@ -18,9 +18,12 @@
import asyncio
import os
import logging
import struct
import click
from prompt_toolkit.shortcuts import PromptSession
from bumble.a2dp import make_audio_sink_service_sdp_records
from bumble.colors import color
from bumble.device import Device, Peer
from bumble.transport import open_transport_or_link
@@ -30,17 +33,20 @@ from bumble.smp import error_name as smp_error_name
from bumble.keys import JsonKeyStore
from bumble.core import (
AdvertisingData,
Appearance,
ProtocolError,
BT_LE_TRANSPORT,
BT_BR_EDR_TRANSPORT,
PhysicalTransport,
UUID,
)
from bumble.gatt import (
GATT_DEVICE_NAME_CHARACTERISTIC,
GATT_GENERIC_ACCESS_SERVICE,
GATT_HEART_RATE_SERVICE,
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
Service,
Characteristic,
CharacteristicValue,
)
from bumble.hci import OwnAddressType
from bumble.att import (
ATT_Error,
ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
@@ -63,7 +69,7 @@ class Waiter:
self.linger = linger
def terminate(self):
if not self.linger:
if not self.linger and not self.done.done:
self.done.set_result(None)
async def wait_until_terminated(self):
@@ -194,7 +200,7 @@ class Delegate(PairingDelegate):
# -----------------------------------------------------------------------------
async def get_peer_name(peer, mode):
if mode == 'classic':
if peer.connection.transport == PhysicalTransport.BR_EDR:
return await peer.request_name()
# Try to get the peer name from GATT
@@ -226,13 +232,14 @@ def read_with_error(connection):
raise ATT_Error(ATT_INSUFFICIENT_AUTHENTICATION_ERROR)
def write_with_error(connection, _value):
if not connection.is_encrypted:
raise ATT_Error(ATT_INSUFFICIENT_ENCRYPTION_ERROR)
if not AUTHENTICATION_ERROR_RETURNED[1]:
AUTHENTICATION_ERROR_RETURNED[1] = True
raise ATT_Error(ATT_INSUFFICIENT_AUTHENTICATION_ERROR)
# -----------------------------------------------------------------------------
def sdp_records():
service_record_handle = 0x00010001
return {
service_record_handle: make_audio_sink_service_sdp_records(
service_record_handle
)
}
# -----------------------------------------------------------------------------
@@ -240,15 +247,19 @@ def on_connection(connection, request):
print(color(f'<<< Connection: {connection}', 'green'))
# Listen for pairing events
connection.on('pairing_start', on_pairing_start)
connection.on('pairing', lambda keys: on_pairing(connection, keys))
connection.on(connection.EVENT_PAIRING_START, on_pairing_start)
connection.on(connection.EVENT_PAIRING, lambda keys: on_pairing(connection, keys))
connection.on(
'pairing_failure', lambda reason: on_pairing_failure(connection, reason)
connection.EVENT_CLASSIC_PAIRING, lambda: on_classic_pairing(connection)
)
connection.on(
connection.EVENT_PAIRING_FAILURE,
lambda reason: on_pairing_failure(connection, reason),
)
# Listen for encryption changes
connection.on(
'connection_encryption_change',
connection.EVENT_CONNECTION_ENCRYPTION_CHANGE,
lambda: on_connection_encryption_change(connection),
)
@@ -289,6 +300,20 @@ async def on_pairing(connection, keys):
Waiter.instance.terminate()
# -----------------------------------------------------------------------------
@AsyncRunner.run_in_task()
async def on_classic_pairing(connection):
print(color('***-----------------------------------', 'cyan'))
print(
color(
f'*** Paired [Classic]! (peer identity={connection.peer_address})', 'cyan'
)
)
print(color('***-----------------------------------', 'cyan'))
await asyncio.sleep(POST_PAIRING_DELAY)
Waiter.instance.terminate()
# -----------------------------------------------------------------------------
@AsyncRunner.run_in_task()
async def on_pairing_failure(connection, reason):
@@ -306,6 +331,7 @@ async def pair(
mitm,
bond,
ctkd,
advertising_address,
identity_address,
linger,
io,
@@ -314,6 +340,8 @@ async def pair(
request,
print_keys,
keystore_file,
advertise_service_uuids,
advertise_appearance,
device_config,
hci_transport,
address_or_name,
@@ -329,29 +357,33 @@ async def pair(
# Expose a GATT characteristic that can be used to trigger pairing by
# responding with an authentication error when read
if mode == 'le':
device.le_enabled = True
if mode in ('le', 'dual'):
device.add_service(
Service(
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
GATT_HEART_RATE_SERVICE,
[
Characteristic(
'552957FB-CF1F-4A31-9535-E78847E1A714',
Characteristic.Properties.READ
| Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue(
read=read_with_error, write=write_with_error
),
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
Characteristic.Properties.READ,
Characteristic.READ_REQUIRES_AUTHENTICATION,
bytes(1),
)
],
)
)
# Select LE or Classic
if mode == 'classic':
# LE and Classic support
if mode in ('classic', 'dual'):
device.classic_enabled = True
device.classic_smp_enabled = ctkd
if mode in ('le', 'dual'):
device.le_enabled = True
if mode == 'dual':
device.le_simultaneous_enabled = True
# Setup SDP
if mode in ('classic', 'dual'):
device.sdp_service_records = sdp_records()
# Get things going
await device.power_on()
@@ -422,7 +454,9 @@ async def pair(
print(color(f'=== Connecting to {address_or_name}...', 'green'))
connection = await device.connect(
address_or_name,
transport=BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT,
transport=(
PhysicalTransport.LE if mode == 'le' else PhysicalTransport.BR_EDR
),
)
if not request:
@@ -435,13 +469,109 @@ async def pair(
print(color(f'Pairing failed: {error}', 'red'))
else:
if mode == 'le':
# Advertise so that peers can find us and connect
await device.start_advertising(auto_restart=True)
else:
if mode in ('le', 'dual'):
# Advertise so that peers can find us and connect.
# Include the heart rate service UUID in the advertisement data
# so that devices like iPhones can show this device in their
# Bluetooth selector.
service_uuids_16 = []
service_uuids_32 = []
service_uuids_128 = []
if advertise_service_uuids:
for uuid in advertise_service_uuids:
uuid = uuid.replace("-", "")
if len(uuid) == 4:
service_uuids_16.append(UUID(uuid))
elif len(uuid) == 8:
service_uuids_32.append(UUID(uuid))
elif len(uuid) == 32:
service_uuids_128.append(UUID(uuid))
else:
print(color('Invalid UUID format', 'red'))
return
else:
service_uuids_16.append(GATT_HEART_RATE_SERVICE)
flags = AdvertisingData.Flags.LE_LIMITED_DISCOVERABLE_MODE
if mode == 'le':
flags |= AdvertisingData.Flags.BR_EDR_NOT_SUPPORTED
if mode == 'dual':
flags |= AdvertisingData.Flags.SIMULTANEOUS_LE_BR_EDR_CAPABLE
ad_structs = [
(
AdvertisingData.FLAGS,
bytes([flags]),
),
(AdvertisingData.COMPLETE_LOCAL_NAME, 'Bumble'.encode()),
]
if service_uuids_16:
ad_structs.append(
(
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
b"".join(bytes(uuid) for uuid in service_uuids_16),
)
)
if service_uuids_32:
ad_structs.append(
(
AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
b"".join(bytes(uuid) for uuid in service_uuids_32),
)
)
if service_uuids_128:
ad_structs.append(
(
AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
b"".join(bytes(uuid) for uuid in service_uuids_128),
)
)
if advertise_appearance:
advertise_appearance = advertise_appearance.upper()
try:
advertise_appearance_int = int(advertise_appearance)
except ValueError:
category, subcategory = advertise_appearance.split('/')
try:
category_enum = Appearance.Category[category]
except ValueError:
print(
color(f'Invalid appearance category {category}', 'red')
)
return
subcategory_class = Appearance.SUBCATEGORY_CLASSES[
category_enum
]
try:
subcategory_enum = subcategory_class[subcategory]
except ValueError:
print(color(f'Invalid subcategory {subcategory}', 'red'))
return
advertise_appearance_int = int(
Appearance(category_enum, subcategory_enum)
)
ad_structs.append(
(
AdvertisingData.APPEARANCE,
struct.pack('<H', advertise_appearance_int),
)
)
device.advertising_data = bytes(AdvertisingData(ad_structs))
await device.start_advertising(
auto_restart=True,
own_address_type=(
OwnAddressType.PUBLIC
if advertising_address == 'public'
else OwnAddressType.RANDOM
),
)
if mode in ('classic', 'dual'):
# Become discoverable and connectable
await device.set_discoverable(True)
await device.set_connectable(True)
print(color('Ready for connections on', 'blue'), device.public_address)
# Run until the user asks to exit
await Waiter.instance.wait_until_terminated()
@@ -461,7 +591,10 @@ class LogHandler(logging.Handler):
# -----------------------------------------------------------------------------
@click.command()
@click.option(
'--mode', type=click.Choice(['le', 'classic']), default='le', show_default=True
'--mode',
type=click.Choice(['le', 'classic', 'dual']),
default='le',
show_default=True,
)
@click.option(
'--sc',
@@ -483,6 +616,10 @@ class LogHandler(logging.Handler):
help='Enable CTKD',
show_default=True,
)
@click.option(
'--advertising-address',
type=click.Choice(['random', 'public']),
)
@click.option(
'--identity-address',
type=click.Choice(['random', 'public']),
@@ -511,9 +648,20 @@ class LogHandler(logging.Handler):
@click.option('--print-keys', is_flag=True, help='Print the bond keys before pairing')
@click.option(
'--keystore-file',
metavar='<filename>',
metavar='FILENAME',
help='File in which to store the pairing keys',
)
@click.option(
'--advertise-service-uuid',
metavar="UUID",
multiple=True,
help="Advertise a GATT service UUID (may be specified more than once)",
)
@click.option(
'--advertise-appearance',
metavar='APPEARANCE',
help='Advertise an Appearance ID (int value or string)',
)
@click.argument('device-config')
@click.argument('hci_transport')
@click.argument('address-or-name', required=False)
@@ -523,6 +671,7 @@ def main(
mitm,
bond,
ctkd,
advertising_address,
identity_address,
linger,
io,
@@ -531,6 +680,8 @@ def main(
request,
print_keys,
keystore_file,
advertise_service_uuid,
advertise_appearance,
device_config,
hci_transport,
address_or_name,
@@ -549,6 +700,7 @@ def main(
mitm,
bond,
ctkd,
advertising_address,
identity_address,
linger,
io,
@@ -557,6 +709,8 @@ def main(
request,
print_keys,
keystore_file,
advertise_service_uuid,
advertise_appearance,
device_config,
hci_transport,
address_or_name,

View File

@@ -56,7 +56,7 @@ from bumble.core import (
AdvertisingData,
ConnectionError as BumbleConnectionError,
DeviceClass,
BT_BR_EDR_TRANSPORT,
PhysicalTransport,
)
from bumble.device import Connection, Device, DeviceConfiguration
from bumble.hci import Address, HCI_CONNECTION_ALREADY_EXISTS_ERROR, HCI_Constant
@@ -286,7 +286,7 @@ class Player:
async def connect(self, device: Device, address: str) -> Connection:
print(color(f"Connecting to {address}...", "green"))
connection = await device.connect(address, transport=BT_BR_EDR_TRANSPORT)
connection = await device.connect(address, transport=PhysicalTransport.BR_EDR)
# Request authentication
if self.authenticate:
@@ -402,7 +402,7 @@ class Player:
async def pair(self, device: Device, address: str) -> None:
print(color(f"Connecting to {address}...", "green"))
connection = await device.connect(address, transport=BT_BR_EDR_TRANSPORT)
connection = await device.connect(address, transport=PhysicalTransport.BR_EDR)
print(color("Pairing...", "magenta"))
await connection.authenticate()

View File

@@ -271,7 +271,7 @@ class ClientBridge:
print(color(f"@@@ Connecting to Bluetooth {self.address}", "blue"))
assert self.device
self.connection = await self.device.connect(
self.address, transport=core.BT_BR_EDR_TRANSPORT
self.address, transport=core.PhysicalTransport.BR_EDR
)
print(color(f"@@@ Bluetooth connection: {self.connection}", "blue"))
self.connection.on("disconnection", self.on_disconnection)

View File

@@ -34,7 +34,7 @@ from aiohttp import web
import bumble
from bumble.colors import color
from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
from bumble.core import PhysicalTransport, CommandTimeoutError
from bumble.device import Connection, Device, DeviceConfiguration
from bumble.hci import HCI_StatusError
from bumble.pairing import PairingConfig
@@ -568,7 +568,9 @@ class Speaker:
async def connect(self, address):
# Connect to the source
print(f'=== Connecting to {address}...')
connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT)
connection = await self.device.connect(
address, transport=PhysicalTransport.BR_EDR
)
print(f'=== Connected to {connection.peer_address}')
# Request authentication

View File

@@ -26,9 +26,9 @@ from typing import Awaitable, Callable
from typing_extensions import ClassVar, Self
from .codecs import AacAudioRtpPacket
from .company_ids import COMPANY_IDENTIFIERS
from .sdp import (
from bumble.codecs import AacAudioRtpPacket
from bumble.company_ids import COMPANY_IDENTIFIERS
from bumble.sdp import (
DataElement,
ServiceAttribute,
SDP_PUBLIC_BROWSE_ROOT,
@@ -38,7 +38,7 @@ from .sdp import (
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
)
from .core import (
from bumble.core import (
BT_L2CAP_PROTOCOL_ID,
BT_AUDIO_SOURCE_SERVICE,
BT_AUDIO_SINK_SERVICE,
@@ -46,7 +46,7 @@ from .core import (
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
name_or_number,
)
from .rtp import MediaPacket
from bumble.rtp import MediaPacket
# -----------------------------------------------------------------------------
@@ -155,7 +155,7 @@ def flags_to_list(flags, values):
# -----------------------------------------------------------------------------
def make_audio_source_service_sdp_records(service_record_handle, version=(1, 3)):
# pylint: disable=import-outside-toplevel
from .avdtp import AVDTP_PSM
from bumble.avdtp import AVDTP_PSM
version_int = version[0] << 8 | version[1]
return [
@@ -209,7 +209,7 @@ def make_audio_source_service_sdp_records(service_record_handle, version=(1, 3))
# -----------------------------------------------------------------------------
def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
# pylint: disable=import-outside-toplevel
from .avdtp import AVDTP_PSM
from bumble.avdtp import AVDTP_PSM
version_int = version[0] << 8 | version[1]
return [

View File

@@ -29,27 +29,32 @@ import functools
import inspect
import struct
from typing import (
Any,
Awaitable,
Callable,
Generic,
Dict,
List,
Optional,
Type,
TypeVar,
Union,
TYPE_CHECKING,
)
from pyee import EventEmitter
from bumble import utils
from bumble.core import UUID, name_or_number, ProtocolError
from bumble.core import UUID, name_or_number, InvalidOperationError, ProtocolError
from bumble.hci import HCI_Object, key_with_value
from bumble.colors import color
# -----------------------------------------------------------------------------
# Typing
# -----------------------------------------------------------------------------
if TYPE_CHECKING:
from bumble.device import Connection
_T = TypeVar('_T')
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
@@ -217,7 +222,12 @@ UUID_2_FIELD_SPEC = lambda x, y: UUID.parse_uuid_2(x, y) # noqa: E731
# Exceptions
# -----------------------------------------------------------------------------
class ATT_Error(ProtocolError):
def __init__(self, error_code, att_handle=0x0000, message=''):
error_code: int
att_handle: int
def __init__(
self, error_code: int, att_handle: int = 0x0000, message: str = ''
) -> None:
super().__init__(
error_code,
error_namespace='att',
@@ -227,7 +237,10 @@ class ATT_Error(ProtocolError):
self.message = message
def __str__(self):
return f'ATT_Error(error={self.error_name}, handle={self.att_handle:04X}): {self.message}'
return (
f'ATT_Error(error={self.error_name}, '
f'handle={self.att_handle:04X}): {self.message}'
)
# -----------------------------------------------------------------------------
@@ -748,7 +761,7 @@ class ATT_Handle_Value_Confirmation(ATT_PDU):
# -----------------------------------------------------------------------------
class AttributeValue:
class AttributeValue(Generic[_T]):
'''
Attribute value where reading and/or writing is delegated to functions
passed as arguments to the constructor.
@@ -757,33 +770,34 @@ class AttributeValue:
def __init__(
self,
read: Union[
Callable[[Optional[Connection]], Any],
Callable[[Optional[Connection]], Awaitable[Any]],
Callable[[Optional[Connection]], _T],
Callable[[Optional[Connection]], Awaitable[_T]],
None,
] = None,
write: Union[
Callable[[Optional[Connection], Any], None],
Callable[[Optional[Connection], Any], Awaitable[None]],
Callable[[Optional[Connection], _T], None],
Callable[[Optional[Connection], _T], Awaitable[None]],
None,
] = None,
):
self._read = read
self._write = write
def read(self, connection: Optional[Connection]) -> Union[bytes, Awaitable[bytes]]:
return self._read(connection) if self._read else b''
def read(self, connection: Optional[Connection]) -> Union[_T, Awaitable[_T]]:
if self._read is None:
raise InvalidOperationError('AttributeValue has no read function')
return self._read(connection)
def write(
self, connection: Optional[Connection], value: bytes
self, connection: Optional[Connection], value: _T
) -> Union[Awaitable[None], None]:
if self._write:
return self._write(connection, value)
return None
if self._write is None:
raise InvalidOperationError('AttributeValue has no write function')
return self._write(connection, value)
# -----------------------------------------------------------------------------
class Attribute(EventEmitter):
class Attribute(utils.EventEmitter, Generic[_T]):
class Permissions(enum.IntFlag):
READABLE = 0x01
WRITEABLE = 0x02
@@ -822,15 +836,18 @@ class Attribute(EventEmitter):
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
value: Any
EVENT_READ = "read"
EVENT_WRITE = "write"
value: Union[AttributeValue[_T], _T, None]
def __init__(
self,
attribute_type: Union[str, bytes, UUID],
permissions: Union[str, Attribute.Permissions],
value: Any = b'',
value: Union[AttributeValue[_T], _T, None] = None,
) -> None:
EventEmitter.__init__(self)
utils.EventEmitter.__init__(self)
self.handle = 0
self.end_group_handle = 0
if isinstance(permissions, str):
@@ -848,11 +865,11 @@ class Attribute(EventEmitter):
self.value = value
def encode_value(self, value: Any) -> bytes:
return value
def encode_value(self, value: _T) -> bytes:
return value # type: ignore
def decode_value(self, value_bytes: bytes) -> Any:
return value_bytes
def decode_value(self, value: bytes) -> _T:
return value # type: ignore
async def read_value(self, connection: Optional[Connection]) -> bytes:
if (
@@ -877,11 +894,14 @@ class Attribute(EventEmitter):
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
)
if hasattr(self.value, 'read'):
value: Union[_T, None]
if isinstance(self.value, AttributeValue):
try:
value = self.value.read(connection)
if inspect.isawaitable(value):
value = await value
read_value = self.value.read(connection)
if inspect.isawaitable(read_value):
value = await read_value
else:
value = read_value
except ATT_Error as error:
raise ATT_Error(
error_code=error.error_code, att_handle=self.handle
@@ -889,20 +909,24 @@ class Attribute(EventEmitter):
else:
value = self.value
self.emit('read', connection, value)
self.emit(self.EVENT_READ, connection, b'' if value is None else value)
return self.encode_value(value)
return b'' if value is None else self.encode_value(value)
async def write_value(self, connection: Connection, value_bytes: bytes) -> None:
async def write_value(self, connection: Optional[Connection], value: bytes) -> None:
if (
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
) and not connection.encryption:
(self.permissions & self.WRITE_REQUIRES_ENCRYPTION)
and connection is not None
and not connection.encryption
):
raise ATT_Error(
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
)
if (
self.permissions & self.WRITE_REQUIRES_AUTHENTICATION
) and not connection.authenticated:
(self.permissions & self.WRITE_REQUIRES_AUTHENTICATION)
and connection is not None
and not connection.authenticated
):
raise ATT_Error(
error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
)
@@ -912,11 +936,11 @@ class Attribute(EventEmitter):
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
)
value = self.decode_value(value_bytes)
decoded_value = self.decode_value(value)
if hasattr(self.value, 'write'):
if isinstance(self.value, AttributeValue):
try:
result = self.value.write(connection, value)
result = self.value.write(connection, decoded_value)
if inspect.isawaitable(result):
await result
except ATT_Error as error:
@@ -924,9 +948,9 @@ class Attribute(EventEmitter):
error_code=error.error_code, att_handle=self.handle
) from error
else:
self.value = value
self.value = decoded_value
self.emit('write', connection, value)
self.emit(self.EVENT_WRITE, connection, decoded_value)
def __repr__(self):
if isinstance(self.value, bytes):

View File

@@ -21,7 +21,7 @@ import struct
from typing import Dict, Type, Union, Tuple
from bumble import core
from bumble.utils import OpenIntEnum
from bumble import utils
# -----------------------------------------------------------------------------
@@ -43,7 +43,7 @@ class Frame:
EXTENDED = 0x1E
UNIT = 0x1F
class OperationCode(OpenIntEnum):
class OperationCode(utils.OpenIntEnum):
# 0x00 - 0x0F: Unit and subunit commands
VENDOR_DEPENDENT = 0x00
RESERVE = 0x01
@@ -204,7 +204,7 @@ class Frame:
# -----------------------------------------------------------------------------
class CommandFrame(Frame):
class CommandType(OpenIntEnum):
class CommandType(utils.OpenIntEnum):
# AV/C Digital Interface Command Set General Specification Version 4.1
# Table 7.1
CONTROL = 0x00
@@ -240,7 +240,7 @@ class CommandFrame(Frame):
# -----------------------------------------------------------------------------
class ResponseFrame(Frame):
class ResponseCode(OpenIntEnum):
class ResponseCode(utils.OpenIntEnum):
# AV/C Digital Interface Command Set General Specification Version 4.1
# Table 7.2
NOT_IMPLEMENTED = 0x08
@@ -368,7 +368,7 @@ class PassThroughFrame:
PRESSED = 0
RELEASED = 1
class OperationId(OpenIntEnum):
class OperationId(utils.OpenIntEnum):
SELECT = 0x00
UP = 0x01
DOWN = 0x01

View File

@@ -166,8 +166,8 @@ class Protocol:
# Register to receive PDUs from the channel
l2cap_channel.sink = self.on_pdu
l2cap_channel.on("open", self.on_l2cap_channel_open)
l2cap_channel.on("close", self.on_l2cap_channel_close)
l2cap_channel.on(l2cap_channel.EVENT_OPEN, self.on_l2cap_channel_open)
l2cap_channel.on(l2cap_channel.EVENT_CLOSE, self.on_l2cap_channel_close)
def on_l2cap_channel_open(self):
logger.debug(color("<<< AVCTP channel open", "magenta"))

View File

@@ -37,16 +37,15 @@ from typing import (
cast,
)
from pyee import EventEmitter
from .core import (
from bumble.core import (
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
InvalidStateError,
ProtocolError,
InvalidArgumentError,
name_or_number,
)
from .a2dp import (
from bumble.a2dp import (
A2DP_CODEC_TYPE_NAMES,
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
A2DP_NON_A2DP_CODEC_TYPE,
@@ -56,9 +55,9 @@ from .a2dp import (
SbcMediaCodecInformation,
VendorSpecificMediaCodecInformation,
)
from .rtp import MediaPacket
from . import sdp, device, l2cap
from .colors import color
from bumble.rtp import MediaPacket
from bumble import sdp, device, l2cap, utils
from bumble.colors import color
# -----------------------------------------------------------------------------
@@ -897,7 +896,7 @@ class Set_Configuration_Reject(Message):
self.service_category = self.payload[0]
self.error_code = self.payload[1]
def __init__(self, service_category, error_code):
def __init__(self, error_code: int, service_category: int = 0) -> None:
super().__init__(payload=bytes([service_category, error_code]))
self.service_category = service_category
self.error_code = error_code
@@ -1133,6 +1132,14 @@ class Security_Control_Command(Message):
See Bluetooth AVDTP spec - 8.17.1 Security Control Command
'''
def init_from_payload(self):
# pylint: disable=attribute-defined-outside-init
self.acp_seid = self.payload[0] >> 2
self.data = self.payload[1:]
def __str__(self) -> str:
return self.to_string([f'ACP_SEID: {self.acp_seid}', f'data: {self.data}'])
# -----------------------------------------------------------------------------
@Message.subclass
@@ -1194,13 +1201,16 @@ class DelayReport_Reject(Simple_Reject):
# -----------------------------------------------------------------------------
class Protocol(EventEmitter):
class Protocol(utils.EventEmitter):
local_endpoints: List[LocalStreamEndPoint]
remote_endpoints: Dict[int, DiscoveredStreamEndPoint]
streams: Dict[int, Stream]
transaction_results: List[Optional[asyncio.Future[Message]]]
channel_connector: Callable[[], Awaitable[l2cap.ClassicChannel]]
EVENT_OPEN = "open"
EVENT_CLOSE = "close"
class PacketType(enum.IntEnum):
SINGLE_PACKET = 0
START_PACKET = 1
@@ -1240,8 +1250,8 @@ class Protocol(EventEmitter):
# Register to receive PDUs from the channel
l2cap_channel.sink = self.on_pdu
l2cap_channel.on('open', self.on_l2cap_channel_open)
l2cap_channel.on('close', self.on_l2cap_channel_close)
l2cap_channel.on(l2cap_channel.EVENT_OPEN, self.on_l2cap_channel_open)
l2cap_channel.on(l2cap_channel.EVENT_CLOSE, self.on_l2cap_channel_close)
def get_local_endpoint_by_seid(self, seid: int) -> Optional[LocalStreamEndPoint]:
if 0 < seid <= len(self.local_endpoints):
@@ -1411,20 +1421,20 @@ class Protocol(EventEmitter):
self.transaction_results[transaction_label] = None
self.transaction_semaphore.release()
def on_l2cap_connection(self, channel):
def on_l2cap_connection(self, channel: l2cap.ClassicChannel) -> None:
# Forward the channel to the endpoint that's expecting it
if self.channel_acceptor is None:
logger.warning(color('!!! l2cap connection with no acceptor', 'red'))
return
self.channel_acceptor.on_l2cap_connection(channel)
def on_l2cap_channel_open(self):
def on_l2cap_channel_open(self) -> None:
logger.debug(color('<<< L2CAP channel open', 'magenta'))
self.emit('open')
self.emit(self.EVENT_OPEN)
def on_l2cap_channel_close(self):
def on_l2cap_channel_close(self) -> None:
logger.debug(color('<<< L2CAP channel close', 'magenta'))
self.emit('close')
self.emit(self.EVENT_CLOSE)
def send_message(self, transaction_label: int, message: Message) -> None:
logger.debug(
@@ -1542,28 +1552,34 @@ class Protocol(EventEmitter):
async def abort(self, seid: int) -> Abort_Response:
return await self.send_command(Abort_Command(seid))
def on_discover_command(self, _command):
def on_discover_command(self, command: Discover_Command) -> Optional[Message]:
endpoint_infos = [
EndPointInfo(endpoint.seid, 0, endpoint.media_type, endpoint.tsep)
for endpoint in self.local_endpoints
]
return Discover_Response(endpoint_infos)
def on_get_capabilities_command(self, command):
def on_get_capabilities_command(
self, command: Get_Capabilities_Command
) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None:
return Get_Capabilities_Reject(AVDTP_BAD_ACP_SEID_ERROR)
return Get_Capabilities_Response(endpoint.capabilities)
def on_get_all_capabilities_command(self, command):
def on_get_all_capabilities_command(
self, command: Get_All_Capabilities_Command
) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None:
return Get_All_Capabilities_Reject(AVDTP_BAD_ACP_SEID_ERROR)
return Get_All_Capabilities_Response(endpoint.capabilities)
def on_set_configuration_command(self, command):
def on_set_configuration_command(
self, command: Set_Configuration_Command
) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None:
return Set_Configuration_Reject(AVDTP_BAD_ACP_SEID_ERROR)
@@ -1579,7 +1595,9 @@ class Protocol(EventEmitter):
result = stream.on_set_configuration_command(command.capabilities)
return result or Set_Configuration_Response()
def on_get_configuration_command(self, command):
def on_get_configuration_command(
self, command: Get_Configuration_Command
) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None:
return Get_Configuration_Reject(AVDTP_BAD_ACP_SEID_ERROR)
@@ -1588,7 +1606,7 @@ class Protocol(EventEmitter):
return endpoint.stream.on_get_configuration_command()
def on_reconfigure_command(self, command):
def on_reconfigure_command(self, command: Reconfigure_Command) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None:
return Reconfigure_Reject(0, AVDTP_BAD_ACP_SEID_ERROR)
@@ -1598,7 +1616,7 @@ class Protocol(EventEmitter):
result = endpoint.stream.on_reconfigure_command(command.capabilities)
return result or Reconfigure_Response()
def on_open_command(self, command):
def on_open_command(self, command: Open_Command) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None:
return Open_Reject(AVDTP_BAD_ACP_SEID_ERROR)
@@ -1608,25 +1626,26 @@ class Protocol(EventEmitter):
result = endpoint.stream.on_open_command()
return result or Open_Response()
def on_start_command(self, command):
def on_start_command(self, command: Start_Command) -> Optional[Message]:
for seid in command.acp_seids:
endpoint = self.get_local_endpoint_by_seid(seid)
if endpoint is None:
return Start_Reject(seid, AVDTP_BAD_ACP_SEID_ERROR)
if endpoint.stream is None:
return Start_Reject(AVDTP_BAD_STATE_ERROR)
return Start_Reject(seid, AVDTP_BAD_STATE_ERROR)
# Start all streams
# TODO: deal with partial failures
for seid in command.acp_seids:
endpoint = self.get_local_endpoint_by_seid(seid)
result = endpoint.stream.on_start_command()
if result is not None:
if not endpoint or not endpoint.stream:
raise InvalidStateError("Should already be checked!")
if (result := endpoint.stream.on_start_command()) is not None:
return result
return Start_Response()
def on_suspend_command(self, command):
def on_suspend_command(self, command: Suspend_Command) -> Optional[Message]:
for seid in command.acp_seids:
endpoint = self.get_local_endpoint_by_seid(seid)
if endpoint is None:
@@ -1638,13 +1657,14 @@ class Protocol(EventEmitter):
# TODO: deal with partial failures
for seid in command.acp_seids:
endpoint = self.get_local_endpoint_by_seid(seid)
result = endpoint.stream.on_suspend_command()
if result is not None:
if not endpoint or not endpoint.stream:
raise InvalidStateError("Should already be checked!")
if (result := endpoint.stream.on_suspend_command()) is not None:
return result
return Suspend_Response()
def on_close_command(self, command):
def on_close_command(self, command: Close_Command) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None:
return Close_Reject(AVDTP_BAD_ACP_SEID_ERROR)
@@ -1654,7 +1674,7 @@ class Protocol(EventEmitter):
result = endpoint.stream.on_close_command()
return result or Close_Response()
def on_abort_command(self, command):
def on_abort_command(self, command: Abort_Command) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None or endpoint.stream is None:
return Abort_Response()
@@ -1662,15 +1682,17 @@ class Protocol(EventEmitter):
endpoint.stream.on_abort_command()
return Abort_Response()
def on_security_control_command(self, command):
def on_security_control_command(
self, command: Security_Control_Command
) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None:
return Security_Control_Reject(AVDTP_BAD_ACP_SEID_ERROR)
result = endpoint.on_security_control_command(command.payload)
result = endpoint.on_security_control_command(command.data)
return result or Security_Control_Response()
def on_delayreport_command(self, command):
def on_delayreport_command(self, command: DelayReport_Command) -> Optional[Message]:
endpoint = self.get_local_endpoint_by_seid(command.acp_seid)
if endpoint is None:
return DelayReport_Reject(AVDTP_BAD_ACP_SEID_ERROR)
@@ -1680,9 +1702,11 @@ class Protocol(EventEmitter):
# -----------------------------------------------------------------------------
class Listener(EventEmitter):
class Listener(utils.EventEmitter):
servers: Dict[int, Protocol]
EVENT_CONNECTION = "connection"
@staticmethod
def create_registrar(device: device.Device):
warnings.warn("Please use Listener.for_device()", DeprecationWarning)
@@ -1717,7 +1741,7 @@ class Listener(EventEmitter):
l2cap_server = device.create_l2cap_server(
spec=l2cap.ClassicChannelSpec(psm=AVDTP_PSM)
)
l2cap_server.on('connection', listener.on_l2cap_connection)
l2cap_server.on(l2cap_server.EVENT_CONNECTION, listener.on_l2cap_connection)
return listener
def on_l2cap_connection(self, channel: l2cap.ClassicChannel) -> None:
@@ -1733,14 +1757,14 @@ class Listener(EventEmitter):
logger.debug('setting up new Protocol for the connection')
server = Protocol(channel, self.version)
self.set_server(channel.connection, server)
self.emit('connection', server)
self.emit(self.EVENT_CONNECTION, server)
def on_channel_close():
logger.debug('removing Protocol for the connection')
self.remove_server(channel.connection)
channel.on('open', on_channel_open)
channel.on('close', on_channel_close)
channel.on(channel.EVENT_OPEN, on_channel_open)
channel.on(channel.EVENT_CLOSE, on_channel_close)
# -----------------------------------------------------------------------------
@@ -1789,6 +1813,7 @@ class Stream:
)
async def start(self) -> None:
"""[Source] Start streaming."""
# Auto-open if needed
if self.state == AVDTP_CONFIGURED_STATE:
await self.open()
@@ -1805,6 +1830,7 @@ class Stream:
self.change_state(AVDTP_STREAMING_STATE)
async def stop(self) -> None:
"""[Source] Stop streaming and transit to OPEN state."""
if self.state != AVDTP_STREAMING_STATE:
raise InvalidStateError('current state is not STREAMING')
@@ -1817,6 +1843,7 @@ class Stream:
self.change_state(AVDTP_OPEN_STATE)
async def close(self) -> None:
"""[Source] Close channel and transit to IDLE state."""
if self.state not in (AVDTP_OPEN_STATE, AVDTP_STREAMING_STATE):
raise InvalidStateError('current state is not OPEN or STREAMING')
@@ -1848,7 +1875,7 @@ class Stream:
self.change_state(AVDTP_CONFIGURED_STATE)
return None
def on_get_configuration_command(self, configuration):
def on_get_configuration_command(self):
if self.state not in (
AVDTP_CONFIGURED_STATE,
AVDTP_OPEN_STATE,
@@ -1856,7 +1883,7 @@ class Stream:
):
return Get_Configuration_Reject(AVDTP_BAD_STATE_ERROR)
return self.local_endpoint.on_get_configuration_command(configuration)
return self.local_endpoint.on_get_configuration_command()
def on_reconfigure_command(self, configuration):
if self.state != AVDTP_OPEN_STATE:
@@ -1936,20 +1963,20 @@ class Stream:
# Wait for the RTP channel to be closed
self.change_state(AVDTP_ABORTING_STATE)
def on_l2cap_connection(self, channel):
def on_l2cap_connection(self, channel: l2cap.ClassicChannel) -> None:
logger.debug(color('<<< stream channel connected', 'magenta'))
self.rtp_channel = channel
channel.on('open', self.on_l2cap_channel_open)
channel.on('close', self.on_l2cap_channel_close)
channel.on(channel.EVENT_OPEN, self.on_l2cap_channel_open)
channel.on(channel.EVENT_CLOSE, self.on_l2cap_channel_close)
# We don't need more channels
self.protocol.channel_acceptor = None
def on_l2cap_channel_open(self):
def on_l2cap_channel_open(self) -> None:
logger.debug(color('<<< stream channel open', 'magenta'))
self.local_endpoint.on_rtp_channel_open()
def on_l2cap_channel_close(self):
def on_l2cap_channel_close(self) -> None:
logger.debug(color('<<< stream channel closed', 'magenta'))
self.local_endpoint.on_rtp_channel_close()
self.local_endpoint.in_use = 0
@@ -2063,9 +2090,22 @@ class DiscoveredStreamEndPoint(StreamEndPoint, StreamEndPointProxy):
# -----------------------------------------------------------------------------
class LocalStreamEndPoint(StreamEndPoint, EventEmitter):
class LocalStreamEndPoint(StreamEndPoint, utils.EventEmitter):
stream: Optional[Stream]
EVENT_CONFIGURATION = "configuration"
EVENT_OPEN = "open"
EVENT_START = "start"
EVENT_STOP = "stop"
EVENT_RTP_PACKET = "rtp_packet"
EVENT_SUSPEND = "suspend"
EVENT_CLOSE = "close"
EVENT_ABORT = "abort"
EVENT_DELAY_REPORT = "delay_report"
EVENT_SECURITY_CONTROL = "security_control"
EVENT_RTP_CHANNEL_OPEN = "rtp_channel_open"
EVENT_RTP_CHANNEL_CLOSE = "rtp_channel_close"
def __init__(
self,
protocol: Protocol,
@@ -2076,57 +2116,70 @@ class LocalStreamEndPoint(StreamEndPoint, EventEmitter):
configuration: Optional[Iterable[ServiceCapabilities]] = None,
):
StreamEndPoint.__init__(self, seid, media_type, tsep, 0, capabilities)
EventEmitter.__init__(self)
utils.EventEmitter.__init__(self)
self.protocol = protocol
self.configuration = configuration if configuration is not None else []
self.stream = None
async def start(self):
pass
async def start(self) -> None:
"""[Source Only] Handles when receiving start command."""
async def stop(self):
pass
async def stop(self) -> None:
"""[Source Only] Handles when receiving stop command."""
async def close(self):
pass
async def close(self) -> None:
"""[Source Only] Handles when receiving close command."""
def on_reconfigure_command(self, command):
pass
def on_reconfigure_command(self, command) -> Optional[Message]:
return None
def on_set_configuration_command(self, configuration):
def on_set_configuration_command(self, configuration) -> Optional[Message]:
logger.debug(
'<<< received configuration: '
f'{",".join([str(capability) for capability in configuration])}'
)
self.configuration = configuration
self.emit('configuration')
self.emit(self.EVENT_CONFIGURATION)
return None
def on_get_configuration_command(self):
def on_get_configuration_command(self) -> Optional[Message]:
return Get_Configuration_Response(self.configuration)
def on_open_command(self):
self.emit('open')
def on_open_command(self) -> Optional[Message]:
self.emit(self.EVENT_OPEN)
return None
def on_start_command(self):
self.emit('start')
def on_start_command(self) -> Optional[Message]:
self.emit(self.EVENT_START)
return None
def on_suspend_command(self):
self.emit('suspend')
def on_suspend_command(self) -> Optional[Message]:
self.emit(self.EVENT_SUSPEND)
return None
def on_close_command(self):
self.emit('close')
def on_close_command(self) -> Optional[Message]:
self.emit(self.EVENT_CLOSE)
return None
def on_abort_command(self):
self.emit('abort')
def on_abort_command(self) -> Optional[Message]:
self.emit(self.EVENT_ABORT)
return None
def on_delayreport_command(self, delay: int):
self.emit('delay_report', delay)
def on_delayreport_command(self, delay: int) -> Optional[Message]:
self.emit(self.EVENT_DELAY_REPORT, delay)
return None
def on_rtp_channel_open(self):
self.emit('rtp_channel_open')
def on_security_control_command(self, data: bytes) -> Optional[Message]:
self.emit(self.EVENT_SECURITY_CONTROL, data)
return None
def on_rtp_channel_close(self):
self.emit('rtp_channel_close')
def on_rtp_channel_open(self) -> None:
self.emit(self.EVENT_RTP_CHANNEL_OPEN)
return None
def on_rtp_channel_close(self) -> None:
self.emit(self.EVENT_RTP_CHANNEL_CLOSE)
return None
# -----------------------------------------------------------------------------
@@ -2157,13 +2210,13 @@ class LocalSource(LocalStreamEndPoint):
if self.packet_pump and self.stream and self.stream.rtp_channel:
return await self.packet_pump.start(self.stream.rtp_channel)
self.emit('start')
self.emit(self.EVENT_START)
async def stop(self) -> None:
if self.packet_pump:
return await self.packet_pump.stop()
self.emit('stop')
self.emit(self.EVENT_STOP)
def on_start_command(self):
asyncio.create_task(self.start())
@@ -2204,4 +2257,4 @@ class LocalSink(LocalStreamEndPoint):
f'{color("<<< RTP Packet:", "green")} '
f'{rtp_packet} {rtp_packet.payload[:16].hex()}'
)
self.emit('rtp_packet', rtp_packet)
self.emit(self.EVENT_RTP_PACKET, rtp_packet)

View File

@@ -38,7 +38,6 @@ from typing import (
Union,
)
import pyee
from bumble.colors import color
from bumble.device import Device, Connection
@@ -53,7 +52,7 @@ from bumble.sdp import (
DataElement,
ServiceAttribute,
)
from bumble.utils import AsyncRunner, OpenIntEnum
from bumble import utils
from bumble.core import (
InvalidArgumentError,
ProtocolError,
@@ -307,7 +306,7 @@ class Command:
# -----------------------------------------------------------------------------
class GetCapabilitiesCommand(Command):
class CapabilityId(OpenIntEnum):
class CapabilityId(utils.OpenIntEnum):
COMPANY_ID = 0x02
EVENTS_SUPPORTED = 0x03
@@ -637,7 +636,7 @@ class RegisterNotificationResponse(Response):
# -----------------------------------------------------------------------------
class EventId(OpenIntEnum):
class EventId(utils.OpenIntEnum):
PLAYBACK_STATUS_CHANGED = 0x01
TRACK_CHANGED = 0x02
TRACK_REACHED_END = 0x03
@@ -657,12 +656,12 @@ class EventId(OpenIntEnum):
# -----------------------------------------------------------------------------
class CharacterSetId(OpenIntEnum):
class CharacterSetId(utils.OpenIntEnum):
UTF_8 = 0x06
# -----------------------------------------------------------------------------
class MediaAttributeId(OpenIntEnum):
class MediaAttributeId(utils.OpenIntEnum):
TITLE = 0x01
ARTIST_NAME = 0x02
ALBUM_NAME = 0x03
@@ -682,7 +681,7 @@ class MediaAttribute:
# -----------------------------------------------------------------------------
class PlayStatus(OpenIntEnum):
class PlayStatus(utils.OpenIntEnum):
STOPPED = 0x00
PLAYING = 0x01
PAUSED = 0x02
@@ -701,33 +700,33 @@ class SongAndPlayStatus:
# -----------------------------------------------------------------------------
class ApplicationSetting:
class AttributeId(OpenIntEnum):
class AttributeId(utils.OpenIntEnum):
EQUALIZER_ON_OFF = 0x01
REPEAT_MODE = 0x02
SHUFFLE_ON_OFF = 0x03
SCAN_ON_OFF = 0x04
class EqualizerOnOffStatus(OpenIntEnum):
class EqualizerOnOffStatus(utils.OpenIntEnum):
OFF = 0x01
ON = 0x02
class RepeatModeStatus(OpenIntEnum):
class RepeatModeStatus(utils.OpenIntEnum):
OFF = 0x01
SINGLE_TRACK_REPEAT = 0x02
ALL_TRACK_REPEAT = 0x03
GROUP_REPEAT = 0x04
class ShuffleOnOffStatus(OpenIntEnum):
class ShuffleOnOffStatus(utils.OpenIntEnum):
OFF = 0x01
ALL_TRACKS_SHUFFLE = 0x02
GROUP_SHUFFLE = 0x03
class ScanOnOffStatus(OpenIntEnum):
class ScanOnOffStatus(utils.OpenIntEnum):
OFF = 0x01
ALL_TRACKS_SCAN = 0x02
GROUP_SCAN = 0x03
class GenericValue(OpenIntEnum):
class GenericValue(utils.OpenIntEnum):
pass
@@ -816,7 +815,7 @@ class PlayerApplicationSettingChangedEvent(Event):
@dataclass
class Setting:
attribute_id: ApplicationSetting.AttributeId
value_id: OpenIntEnum
value_id: utils.OpenIntEnum
player_application_settings: List[Setting]
@@ -824,7 +823,7 @@ class PlayerApplicationSettingChangedEvent(Event):
def from_bytes(cls, pdu: bytes) -> PlayerApplicationSettingChangedEvent:
def setting(attribute_id_int: int, value_id_int: int):
attribute_id = ApplicationSetting.AttributeId(attribute_id_int)
value_id: OpenIntEnum
value_id: utils.OpenIntEnum
if attribute_id == ApplicationSetting.AttributeId.EQUALIZER_ON_OFF:
value_id = ApplicationSetting.EqualizerOnOffStatus(value_id_int)
elif attribute_id == ApplicationSetting.AttributeId.REPEAT_MODE:
@@ -994,16 +993,20 @@ class Delegate:
# -----------------------------------------------------------------------------
class Protocol(pyee.EventEmitter):
class Protocol(utils.EventEmitter):
"""AVRCP Controller and Target protocol."""
EVENT_CONNECTION = "connection"
EVENT_START = "start"
EVENT_STOP = "stop"
class PacketType(enum.IntEnum):
SINGLE = 0b00
START = 0b01
CONTINUE = 0b10
END = 0b11
class PduId(OpenIntEnum):
class PduId(utils.OpenIntEnum):
GET_CAPABILITIES = 0x10
LIST_PLAYER_APPLICATION_SETTING_ATTRIBUTES = 0x11
LIST_PLAYER_APPLICATION_SETTING_VALUES = 0x12
@@ -1024,7 +1027,7 @@ class Protocol(pyee.EventEmitter):
GET_FOLDER_ITEMS = 0x71
GET_TOTAL_NUMBER_OF_ITEMS = 0x75
class StatusCode(OpenIntEnum):
class StatusCode(utils.OpenIntEnum):
INVALID_COMMAND = 0x00
INVALID_PARAMETER = 0x01
PARAMETER_CONTENT_ERROR = 0x02
@@ -1457,16 +1460,18 @@ class Protocol(pyee.EventEmitter):
def _on_avctp_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug("AVCTP connection established")
l2cap_channel.on("open", lambda: self._on_avctp_channel_open(l2cap_channel))
l2cap_channel.on(
l2cap_channel.EVENT_OPEN, lambda: self._on_avctp_channel_open(l2cap_channel)
)
self.emit("connection")
self.emit(self.EVENT_CONNECTION)
def _on_avctp_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug("AVCTP channel open")
if self.avctp_protocol is not None:
# TODO: find a better strategy instead of just closing
logger.warning("AVCTP protocol already active, closing connection")
AsyncRunner.spawn(l2cap_channel.disconnect())
utils.AsyncRunner.spawn(l2cap_channel.disconnect())
return
self.avctp_protocol = avctp.Protocol(l2cap_channel)
@@ -1474,15 +1479,15 @@ class Protocol(pyee.EventEmitter):
self.avctp_protocol.register_response_handler(
AVRCP_PID, self._on_avctp_response
)
l2cap_channel.on("close", self._on_avctp_channel_close)
l2cap_channel.on(l2cap_channel.EVENT_CLOSE, self._on_avctp_channel_close)
self.emit("start")
self.emit(self.EVENT_START)
def _on_avctp_channel_close(self) -> None:
logger.debug("AVCTP channel closed")
self.avctp_protocol = None
self.emit("stop")
self.emit(self.EVENT_STOP)
def _on_avctp_command(
self, transaction_label: int, command: avc.CommandFrame

View File

@@ -17,8 +17,8 @@
# -----------------------------------------------------------------------------
import logging
from .hci import HCI_Packet
from .helpers import PacketTracer
from bumble.hci import HCI_Packet
from bumble.helpers import PacketTracer
# -----------------------------------------------------------------------------
# Logging

View File

@@ -25,10 +25,7 @@ import random
import struct
from bumble.colors import color
from bumble.core import (
BT_CENTRAL_ROLE,
BT_PERIPHERAL_ROLE,
BT_LE_TRANSPORT,
BT_BR_EDR_TRANSPORT,
PhysicalTransport,
)
from bumble.hci import (
@@ -47,6 +44,7 @@ from bumble.hci import (
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
HCI_VERSION_BLUETOOTH_CORE_5_0,
Address,
Role,
HCI_AclDataPacket,
HCI_AclDataPacketAssembler,
HCI_Command_Complete_Event,
@@ -98,7 +96,7 @@ class CisLink:
class Connection:
controller: Controller
handle: int
role: int
role: Role
peer_address: Address
link: Any
transport: int
@@ -390,10 +388,10 @@ class Controller:
connection = Connection(
controller=self,
handle=connection_handle,
role=BT_PERIPHERAL_ROLE,
role=Role.PERIPHERAL,
peer_address=peer_address,
link=self.link,
transport=BT_LE_TRANSPORT,
transport=PhysicalTransport.LE,
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
)
self.peripheral_connections[peer_address] = connection
@@ -450,10 +448,10 @@ class Controller:
connection = Connection(
controller=self,
handle=connection_handle,
role=BT_CENTRAL_ROLE,
role=Role.CENTRAL,
peer_address=peer_address,
link=self.link,
transport=BT_LE_TRANSPORT,
transport=PhysicalTransport.LE,
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
)
self.central_connections[peer_address] = connection
@@ -469,7 +467,7 @@ class Controller:
HCI_LE_Connection_Complete_Event(
status=status,
connection_handle=connection.handle if connection else 0,
role=BT_CENTRAL_ROLE,
role=Role.CENTRAL,
peer_address_type=le_create_connection_command.peer_address_type,
peer_address=le_create_connection_command.peer_address,
connection_interval=le_create_connection_command.connection_interval_min,
@@ -531,7 +529,7 @@ class Controller:
def on_link_acl_data(self, sender_address, transport, data):
# Look for the connection to which this data belongs
if transport == BT_LE_TRANSPORT:
if transport == PhysicalTransport.LE:
connection = self.find_le_connection_by_address(sender_address)
else:
connection = self.find_classic_connection_by_address(sender_address)
@@ -693,10 +691,10 @@ class Controller:
controller=self,
handle=connection_handle,
# Role doesn't matter in Classic because they are managed by HCI_Role_Change and HCI_Role_Discovery
role=BT_CENTRAL_ROLE,
role=Role.CENTRAL,
peer_address=peer_address,
link=self.link,
transport=BT_BR_EDR_TRANSPORT,
transport=PhysicalTransport.BR_EDR,
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
)
self.classic_connections[peer_address] = connection
@@ -761,10 +759,10 @@ class Controller:
controller=self,
handle=connection_handle,
# Role doesn't matter in SCO.
role=BT_CENTRAL_ROLE,
role=Role.CENTRAL,
peer_address=peer_address,
link=self.link,
transport=BT_BR_EDR_TRANSPORT,
transport=PhysicalTransport.BR_EDR,
link_type=link_type,
)
self.classic_connections[peer_address] = connection

View File

@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC
# Copyright 2021-2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -16,14 +16,14 @@
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import dataclasses
import enum
import struct
from typing import List, Optional, Tuple, Union, cast, Dict
from typing import cast, overload, Literal, Union, Optional
from typing_extensions import Self
from bumble.company_ids import COMPANY_IDENTIFIERS
from bumble.utils import OpenIntEnum
from bumble import utils
# -----------------------------------------------------------------------------
@@ -31,11 +31,12 @@ from bumble.utils import OpenIntEnum
# -----------------------------------------------------------------------------
# fmt: off
BT_CENTRAL_ROLE = 0
BT_PERIPHERAL_ROLE = 1
class PhysicalTransport(enum.IntEnum):
BR_EDR = 0
LE = 1
BT_BR_EDR_TRANSPORT = 0
BT_LE_TRANSPORT = 1
BT_BR_EDR_TRANSPORT = PhysicalTransport.BR_EDR
BT_LE_TRANSPORT = PhysicalTransport.LE
# fmt: on
@@ -57,7 +58,7 @@ def bit_flags_to_strings(bits, bit_flag_names):
return names
def name_or_number(dictionary: Dict[int, str], number: int, width: int = 2) -> str:
def name_or_number(dictionary: dict[int, str], number: int, width: int = 2) -> str:
name = dictionary.get(number)
if name is not None:
return name
@@ -200,7 +201,7 @@ class UUID:
'''
BASE_UUID = bytes.fromhex('00001000800000805F9B34FB')[::-1] # little-endian
UUIDS: List[UUID] = [] # Registry of all instances created
UUIDS: list[UUID] = [] # Registry of all instances created
uuid_bytes: bytes
name: Optional[str]
@@ -259,11 +260,11 @@ class UUID:
return cls.from_bytes(struct.pack('<I', uuid_32), name)
@classmethod
def parse_uuid(cls, uuid_as_bytes: bytes, offset: int) -> Tuple[int, UUID]:
def parse_uuid(cls, uuid_as_bytes: bytes, offset: int) -> tuple[int, UUID]:
return len(uuid_as_bytes), cls.from_bytes(uuid_as_bytes[offset:])
@classmethod
def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> Tuple[int, UUID]:
def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> tuple[int, UUID]:
return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2])
def to_bytes(self, force_128: bool = False) -> bytes:
@@ -729,7 +730,7 @@ class DeviceClass:
# Appearance
# -----------------------------------------------------------------------------
class Appearance:
class Category(OpenIntEnum):
class Category(utils.OpenIntEnum):
UNKNOWN = 0x0000
PHONE = 0x0001
COMPUTER = 0x0002
@@ -783,13 +784,13 @@ class Appearance:
SPIROMETER = 0x0037
OUTDOOR_SPORTS_ACTIVITY = 0x0051
class UnknownSubcategory(OpenIntEnum):
class UnknownSubcategory(utils.OpenIntEnum):
GENERIC_UNKNOWN = 0x00
class PhoneSubcategory(OpenIntEnum):
class PhoneSubcategory(utils.OpenIntEnum):
GENERIC_PHONE = 0x00
class ComputerSubcategory(OpenIntEnum):
class ComputerSubcategory(utils.OpenIntEnum):
GENERIC_COMPUTER = 0x00
DESKTOP_WORKSTATION = 0x01
SERVER_CLASS_COMPUTER = 0x02
@@ -807,49 +808,49 @@ class Appearance:
MINI_PC = 0x0E
STICK_PC = 0x0F
class WatchSubcategory(OpenIntEnum):
GENENERIC_WATCH = 0x00
class WatchSubcategory(utils.OpenIntEnum):
GENERIC_WATCH = 0x00
SPORTS_WATCH = 0x01
SMARTWATCH = 0x02
class ClockSubcategory(OpenIntEnum):
class ClockSubcategory(utils.OpenIntEnum):
GENERIC_CLOCK = 0x00
class DisplaySubcategory(OpenIntEnum):
class DisplaySubcategory(utils.OpenIntEnum):
GENERIC_DISPLAY = 0x00
class RemoteControlSubcategory(OpenIntEnum):
class RemoteControlSubcategory(utils.OpenIntEnum):
GENERIC_REMOTE_CONTROL = 0x00
class EyeglassesSubcategory(OpenIntEnum):
class EyeglassesSubcategory(utils.OpenIntEnum):
GENERIC_EYEGLASSES = 0x00
class TagSubcategory(OpenIntEnum):
class TagSubcategory(utils.OpenIntEnum):
GENERIC_TAG = 0x00
class KeyringSubcategory(OpenIntEnum):
class KeyringSubcategory(utils.OpenIntEnum):
GENERIC_KEYRING = 0x00
class MediaPlayerSubcategory(OpenIntEnum):
class MediaPlayerSubcategory(utils.OpenIntEnum):
GENERIC_MEDIA_PLAYER = 0x00
class BarcodeScannerSubcategory(OpenIntEnum):
class BarcodeScannerSubcategory(utils.OpenIntEnum):
GENERIC_BARCODE_SCANNER = 0x00
class ThermometerSubcategory(OpenIntEnum):
class ThermometerSubcategory(utils.OpenIntEnum):
GENERIC_THERMOMETER = 0x00
EAR_THERMOMETER = 0x01
class HeartRateSensorSubcategory(OpenIntEnum):
class HeartRateSensorSubcategory(utils.OpenIntEnum):
GENERIC_HEART_RATE_SENSOR = 0x00
HEART_RATE_BELT = 0x01
class BloodPressureSubcategory(OpenIntEnum):
class BloodPressureSubcategory(utils.OpenIntEnum):
GENERIC_BLOOD_PRESSURE = 0x00
ARM_BLOOD_PRESSURE = 0x01
WRIST_BLOOD_PRESSURE = 0x02
class HumanInterfaceDeviceSubcategory(OpenIntEnum):
class HumanInterfaceDeviceSubcategory(utils.OpenIntEnum):
GENERIC_HUMAN_INTERFACE_DEVICE = 0x00
KEYBOARD = 0x01
MOUSE = 0x02
@@ -862,16 +863,16 @@ class Appearance:
TOUCHPAD = 0x09
PRESENTATION_REMOTE = 0x0A
class GlucoseMeterSubcategory(OpenIntEnum):
class GlucoseMeterSubcategory(utils.OpenIntEnum):
GENERIC_GLUCOSE_METER = 0x00
class RunningWalkingSensorSubcategory(OpenIntEnum):
class RunningWalkingSensorSubcategory(utils.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):
class CyclingSubcategory(utils.OpenIntEnum):
GENERIC_CYCLING = 0x00
CYCLING_COMPUTER = 0x01
SPEED_SENSOR = 0x02
@@ -879,7 +880,7 @@ class Appearance:
POWER_SENSOR = 0x04
SPEED_AND_CADENCE_SENSOR = 0x05
class ControlDeviceSubcategory(OpenIntEnum):
class ControlDeviceSubcategory(utils.OpenIntEnum):
GENERIC_CONTROL_DEVICE = 0x00
SWITCH = 0x01
MULTI_SWITCH = 0x02
@@ -894,13 +895,13 @@ class Appearance:
ENERGY_HARVESTING_SWITCH = 0x0B
PUSH_BUTTON = 0x0C
class NetworkDeviceSubcategory(OpenIntEnum):
class NetworkDeviceSubcategory(utils.OpenIntEnum):
GENERIC_NETWORK_DEVICE = 0x00
ACCESS_POINT = 0x01
MESH_DEVICE = 0x02
MESH_NETWORK_PROXY = 0x03
class SensorSubcategory(OpenIntEnum):
class SensorSubcategory(utils.OpenIntEnum):
GENERIC_SENSOR = 0x00
MOTION_SENSOR = 0x01
AIR_QUALITY_SENSOR = 0x02
@@ -928,7 +929,7 @@ class Appearance:
FLAME_DETECTOR = 0x18
VEHICLE_TIRE_PRESSURE_SENSOR = 0x19
class LightFixturesSubcategory(OpenIntEnum):
class LightFixturesSubcategory(utils.OpenIntEnum):
GENERIC_LIGHT_FIXTURES = 0x00
WALL_LIGHT = 0x01
CEILING_LIGHT = 0x02
@@ -956,7 +957,7 @@ class Appearance:
LOW_BAY_LIGHT = 0x18
HIGH_BAY_LIGHT = 0x19
class FanSubcategory(OpenIntEnum):
class FanSubcategory(utils.OpenIntEnum):
GENERIC_FAN = 0x00
CEILING_FAN = 0x01
AXIAL_FAN = 0x02
@@ -965,7 +966,7 @@ class Appearance:
DESK_FAN = 0x05
WALL_FAN = 0x06
class HvacSubcategory(OpenIntEnum):
class HvacSubcategory(utils.OpenIntEnum):
GENERIC_HVAC = 0x00
THERMOSTAT = 0x01
HUMIDIFIER = 0x02
@@ -979,13 +980,13 @@ class Appearance:
FAN_HEATER = 0x0A
AIR_CURTAIN = 0x0B
class AirConditioningSubcategory(OpenIntEnum):
class AirConditioningSubcategory(utils.OpenIntEnum):
GENERIC_AIR_CONDITIONING = 0x00
class HumidifierSubcategory(OpenIntEnum):
class HumidifierSubcategory(utils.OpenIntEnum):
GENERIC_HUMIDIFIER = 0x00
class HeatingSubcategory(OpenIntEnum):
class HeatingSubcategory(utils.OpenIntEnum):
GENERIC_HEATING = 0x00
RADIATOR = 0x01
BOILER = 0x02
@@ -995,7 +996,7 @@ class Appearance:
FAN_HEATER = 0x06
AIR_CURTAIN = 0x07
class AccessControlSubcategory(OpenIntEnum):
class AccessControlSubcategory(utils.OpenIntEnum):
GENERIC_ACCESS_CONTROL = 0x00
ACCESS_DOOR = 0x01
GARAGE_DOOR = 0x02
@@ -1007,7 +1008,7 @@ class Appearance:
DOOR_LOCK = 0x08
LOCKER = 0x09
class MotorizedDeviceSubcategory(OpenIntEnum):
class MotorizedDeviceSubcategory(utils.OpenIntEnum):
GENERIC_MOTORIZED_DEVICE = 0x00
MOTORIZED_GATE = 0x01
AWNING = 0x02
@@ -1015,7 +1016,7 @@ class Appearance:
CURTAINS = 0x04
SCREEN = 0x05
class PowerDeviceSubcategory(OpenIntEnum):
class PowerDeviceSubcategory(utils.OpenIntEnum):
GENERIC_POWER_DEVICE = 0x00
POWER_OUTLET = 0x01
POWER_STRIP = 0x02
@@ -1027,7 +1028,7 @@ class Appearance:
CHARGE_CASE = 0x08
POWER_BANK = 0x09
class LightSourceSubcategory(OpenIntEnum):
class LightSourceSubcategory(utils.OpenIntEnum):
GENERIC_LIGHT_SOURCE = 0x00
INCANDESCENT_LIGHT_BULB = 0x01
LED_LAMP = 0x02
@@ -1038,7 +1039,7 @@ class Appearance:
LOW_VOLTAGE_HALOGEN = 0x07
ORGANIC_LIGHT_EMITTING_DIODE = 0x08
class WindowCoveringSubcategory(OpenIntEnum):
class WindowCoveringSubcategory(utils.OpenIntEnum):
GENERIC_WINDOW_COVERING = 0x00
WINDOW_SHADES = 0x01
WINDOW_BLINDS = 0x02
@@ -1047,7 +1048,7 @@ class Appearance:
EXTERIOR_SHUTTER = 0x05
EXTERIOR_SCREEN = 0x06
class AudioSinkSubcategory(OpenIntEnum):
class AudioSinkSubcategory(utils.OpenIntEnum):
GENERIC_AUDIO_SINK = 0x00
STANDALONE_SPEAKER = 0x01
SOUNDBAR = 0x02
@@ -1055,7 +1056,7 @@ class Appearance:
STANDMOUNTED_SPEAKER = 0x04
SPEAKERPHONE = 0x05
class AudioSourceSubcategory(OpenIntEnum):
class AudioSourceSubcategory(utils.OpenIntEnum):
GENERIC_AUDIO_SOURCE = 0x00
MICROPHONE = 0x01
ALARM = 0x02
@@ -1067,7 +1068,7 @@ class Appearance:
BROADCASTING_ROOM = 0x08
AUDITORIUM = 0x09
class MotorizedVehicleSubcategory(OpenIntEnum):
class MotorizedVehicleSubcategory(utils.OpenIntEnum):
GENERIC_MOTORIZED_VEHICLE = 0x00
CAR = 0x01
LARGE_GOODS_VEHICLE = 0x02
@@ -1085,7 +1086,7 @@ class Appearance:
CAMPER_CARAVAN = 0x0E
RECREATIONAL_VEHICLE_MOTOR_HOME = 0x0F
class DomesticApplianceSubcategory(OpenIntEnum):
class DomesticApplianceSubcategory(utils.OpenIntEnum):
GENERIC_DOMESTIC_APPLIANCE = 0x00
REFRIGERATOR = 0x01
FREEZER = 0x02
@@ -1103,21 +1104,21 @@ class Appearance:
RICE_COOKER = 0x0E
CLOTHES_STEAMER = 0x0F
class WearableAudioDeviceSubcategory(OpenIntEnum):
class WearableAudioDeviceSubcategory(utils.OpenIntEnum):
GENERIC_WEARABLE_AUDIO_DEVICE = 0x00
EARBUD = 0x01
HEADSET = 0x02
HEADPHONES = 0x03
NECK_BAND = 0x04
class AircraftSubcategory(OpenIntEnum):
class AircraftSubcategory(utils.OpenIntEnum):
GENERIC_AIRCRAFT = 0x00
LIGHT_AIRCRAFT = 0x01
MICROLIGHT = 0x02
PARAGLIDER = 0x03
LARGE_PASSENGER_AIRCRAFT = 0x04
class AvEquipmentSubcategory(OpenIntEnum):
class AvEquipmentSubcategory(utils.OpenIntEnum):
GENERIC_AV_EQUIPMENT = 0x00
AMPLIFIER = 0x01
RECEIVER = 0x02
@@ -1126,69 +1127,69 @@ class Appearance:
TURNTABLE = 0x05
CD_PLAYER = 0x06
DVD_PLAYER = 0x07
BLUERAY_PLAYER = 0x08
BLURAY_PLAYER = 0x08
OPTICAL_DISC_PLAYER = 0x09
SET_TOP_BOX = 0x0A
class DisplayEquipmentSubcategory(OpenIntEnum):
class DisplayEquipmentSubcategory(utils.OpenIntEnum):
GENERIC_DISPLAY_EQUIPMENT = 0x00
TELEVISION = 0x01
MONITOR = 0x02
PROJECTOR = 0x03
class HearingAidSubcategory(OpenIntEnum):
class HearingAidSubcategory(utils.OpenIntEnum):
GENERIC_HEARING_AID = 0x00
IN_EAR_HEARING_AID = 0x01
BEHIND_EAR_HEARING_AID = 0x02
COCHLEAR_IMPLANT = 0x03
class GamingSubcategory(OpenIntEnum):
class GamingSubcategory(utils.OpenIntEnum):
GENERIC_GAMING = 0x00
HOME_VIDEO_GAME_CONSOLE = 0x01
PORTABLE_HANDHELD_CONSOLE = 0x02
class SignageSubcategory(OpenIntEnum):
class SignageSubcategory(utils.OpenIntEnum):
GENERIC_SIGNAGE = 0x00
DIGITAL_SIGNAGE = 0x01
ELECTRONIC_LABEL = 0x02
class PulseOximeterSubcategory(OpenIntEnum):
class PulseOximeterSubcategory(utils.OpenIntEnum):
GENERIC_PULSE_OXIMETER = 0x00
FINGERTIP_PULSE_OXIMETER = 0x01
WRIST_WORN_PULSE_OXIMETER = 0x02
class WeightScaleSubcategory(OpenIntEnum):
class WeightScaleSubcategory(utils.OpenIntEnum):
GENERIC_WEIGHT_SCALE = 0x00
class PersonalMobilityDeviceSubcategory(OpenIntEnum):
class PersonalMobilityDeviceSubcategory(utils.OpenIntEnum):
GENERIC_PERSONAL_MOBILITY_DEVICE = 0x00
POWERED_WHEELCHAIR = 0x01
MOBILITY_SCOOTER = 0x02
class ContinuousGlucoseMonitorSubcategory(OpenIntEnum):
class ContinuousGlucoseMonitorSubcategory(utils.OpenIntEnum):
GENERIC_CONTINUOUS_GLUCOSE_MONITOR = 0x00
class InsulinPumpSubcategory(OpenIntEnum):
class InsulinPumpSubcategory(utils.OpenIntEnum):
GENERIC_INSULIN_PUMP = 0x00
INSULIN_PUMP_DURABLE_PUMP = 0x01
INSULIN_PUMP_PATCH_PUMP = 0x02
INSULIN_PEN = 0x03
class MedicationDeliverySubcategory(OpenIntEnum):
class MedicationDeliverySubcategory(utils.OpenIntEnum):
GENERIC_MEDICATION_DELIVERY = 0x00
class SpirometerSubcategory(OpenIntEnum):
class SpirometerSubcategory(utils.OpenIntEnum):
GENERIC_SPIROMETER = 0x00
HANDHELD_SPIROMETER = 0x01
class OutdoorSportsActivitySubcategory(OpenIntEnum):
class OutdoorSportsActivitySubcategory(utils.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):
class _OpenSubcategory(utils.OpenIntEnum):
GENERIC = 0x00
SUBCATEGORY_CLASSES = {
@@ -1280,13 +1281,13 @@ class Appearance:
# Advertising Data
# -----------------------------------------------------------------------------
AdvertisingDataObject = Union[
List[UUID],
Tuple[UUID, bytes],
list[UUID],
tuple[UUID, bytes],
bytes,
str,
int,
Tuple[int, int],
Tuple[int, bytes],
tuple[int, int],
tuple[int, bytes],
Appearance,
]
@@ -1295,129 +1296,135 @@ class AdvertisingData:
# fmt: off
# pylint: disable=line-too-long
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
class Type(utils.OpenIntEnum):
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_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_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'
}
class Flags(enum.IntFlag):
LE_LIMITED_DISCOVERABLE_MODE = 1 << 0
LE_GENERAL_DISCOVERABLE_MODE = 1 << 1
BR_EDR_NOT_SUPPORTED = 1 << 2
SIMULTANEOUS_LE_BR_EDR_CAPABLE = 1 << 3
LE_LIMITED_DISCOVERABLE_MODE_FLAG = 0x01
LE_GENERAL_DISCOVERABLE_MODE_FLAG = 0x02
BR_EDR_NOT_SUPPORTED_FLAG = 0x04
BR_EDR_CONTROLLER_FLAG = 0x08
BR_EDR_HOST_FLAG = 0x10
# For backward-compatibility
FLAGS = Type.FLAGS
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = Type.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = Type.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = Type.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = Type.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = Type.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS
SHORTENED_LOCAL_NAME = Type.SHORTENED_LOCAL_NAME
COMPLETE_LOCAL_NAME = Type.COMPLETE_LOCAL_NAME
TX_POWER_LEVEL = Type.TX_POWER_LEVEL
CLASS_OF_DEVICE = Type.CLASS_OF_DEVICE
SIMPLE_PAIRING_HASH_C = Type.SIMPLE_PAIRING_HASH_C
SIMPLE_PAIRING_HASH_C_192 = Type.SIMPLE_PAIRING_HASH_C_192
SIMPLE_PAIRING_RANDOMIZER_R = Type.SIMPLE_PAIRING_RANDOMIZER_R
SIMPLE_PAIRING_RANDOMIZER_R_192 = Type.SIMPLE_PAIRING_RANDOMIZER_R_192
DEVICE_ID = Type.DEVICE_ID
SECURITY_MANAGER_TK_VALUE = Type.SECURITY_MANAGER_TK_VALUE
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = Type.SECURITY_MANAGER_OUT_OF_BAND_FLAGS
PERIPHERAL_CONNECTION_INTERVAL_RANGE = Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = Type.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS
SERVICE_DATA = Type.SERVICE_DATA_16_BIT_UUID
SERVICE_DATA_16_BIT_UUID = Type.SERVICE_DATA_16_BIT_UUID
PUBLIC_TARGET_ADDRESS = Type.PUBLIC_TARGET_ADDRESS
RANDOM_TARGET_ADDRESS = Type.RANDOM_TARGET_ADDRESS
APPEARANCE = Type.APPEARANCE
ADVERTISING_INTERVAL = Type.ADVERTISING_INTERVAL
LE_BLUETOOTH_DEVICE_ADDRESS = Type.LE_BLUETOOTH_DEVICE_ADDRESS
LE_ROLE = Type.LE_ROLE
SIMPLE_PAIRING_HASH_C_256 = Type.SIMPLE_PAIRING_HASH_C_256
SIMPLE_PAIRING_RANDOMIZER_R_256 = Type.SIMPLE_PAIRING_RANDOMIZER_R_256
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = Type.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS
SERVICE_DATA_32_BIT_UUID = Type.SERVICE_DATA_32_BIT_UUID
SERVICE_DATA_128_BIT_UUID = Type.SERVICE_DATA_128_BIT_UUID
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = Type.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE
LE_SECURE_CONNECTIONS_RANDOM_VALUE = Type.LE_SECURE_CONNECTIONS_RANDOM_VALUE
URI = Type.URI
INDOOR_POSITIONING = Type.INDOOR_POSITIONING
TRANSPORT_DISCOVERY_DATA = Type.TRANSPORT_DISCOVERY_DATA
LE_SUPPORTED_FEATURES = Type.LE_SUPPORTED_FEATURES
CHANNEL_MAP_UPDATE_INDICATION = Type.CHANNEL_MAP_UPDATE_INDICATION
PB_ADV = Type.PB_ADV
MESH_MESSAGE = Type.MESH_MESSAGE
MESH_BEACON = Type.MESH_BEACON
BIGINFO = Type.BIGINFO
BROADCAST_CODE = Type.BROADCAST_CODE
RESOLVABLE_SET_IDENTIFIER = Type.RESOLVABLE_SET_IDENTIFIER
ADVERTISING_INTERVAL_LONG = Type.ADVERTISING_INTERVAL_LONG
BROADCAST_NAME = Type.BROADCAST_NAME
ENCRYPTED_ADVERTISING_DATA = Type.ENCRYPTED_ADVERTISING_DATA
PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION = Type.PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION
ELECTRONIC_SHELF_LABEL = Type.ELECTRONIC_SHELF_LABEL
THREE_D_INFORMATION_DATA = Type.THREE_D_INFORMATION_DATA
MANUFACTURER_SPECIFIC_DATA = Type.MANUFACTURER_SPECIFIC_DATA
ad_structures: List[Tuple[int, bytes]]
LE_LIMITED_DISCOVERABLE_MODE_FLAG = Flags.LE_LIMITED_DISCOVERABLE_MODE
LE_GENERAL_DISCOVERABLE_MODE_FLAG = Flags.LE_GENERAL_DISCOVERABLE_MODE
BR_EDR_NOT_SUPPORTED_FLAG = Flags.BR_EDR_NOT_SUPPORTED
BR_EDR_CONTROLLER_FLAG = Flags.SIMULTANEOUS_LE_BR_EDR_CAPABLE
BR_EDR_HOST_FLAG = 0x10 # Deprecated
ad_structures: list[tuple[int, bytes]]
# fmt: on
# pylint: enable=line-too-long
def __init__(self, ad_structures: Optional[List[Tuple[int, bytes]]] = None) -> None:
def __init__(self, ad_structures: Optional[list[tuple[int, bytes]]] = None) -> None:
if ad_structures is None:
ad_structures = []
self.ad_structures = ad_structures[:]
@@ -1444,7 +1451,7 @@ class AdvertisingData:
return ','.join(bit_flags_to_strings(flags, flag_names))
@staticmethod
def uuid_list_to_objects(ad_data: bytes, uuid_size: int) -> List[UUID]:
def uuid_list_to_objects(ad_data: bytes, uuid_size: int) -> list[UUID]:
uuids = []
offset = 0
while (offset + uuid_size) <= len(ad_data):
@@ -1461,8 +1468,8 @@ class AdvertisingData:
]
)
@staticmethod
def ad_data_to_string(ad_type, ad_data):
@classmethod
def ad_data_to_string(cls, ad_type: int, ad_data: bytes) -> str:
if ad_type == AdvertisingData.FLAGS:
ad_type_str = 'Flags'
ad_data_str = AdvertisingData.flags_to_string(ad_data[0], short=True)
@@ -1521,72 +1528,72 @@ class AdvertisingData:
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_type_str = AdvertisingData.Type(ad_type).name
ad_data_str = ad_data.hex()
return f'[{ad_type_str}]: {ad_data_str}'
# pylint: disable=too-many-return-statements
@staticmethod
def ad_data_to_object(ad_type: int, ad_data: bytes) -> AdvertisingDataObject:
@classmethod
def ad_data_to_object(cls, 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,
AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS,
AdvertisingData.Type.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS,
):
return AdvertisingData.uuid_list_to_objects(ad_data, 2)
if ad_type in (
AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS,
AdvertisingData.Type.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS,
):
return AdvertisingData.uuid_list_to_objects(ad_data, 4)
if ad_type in (
AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
AdvertisingData.Type.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
):
return AdvertisingData.uuid_list_to_objects(ad_data, 16)
if ad_type == AdvertisingData.SERVICE_DATA_16_BIT_UUID:
if ad_type == AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID:
return (UUID.from_bytes(ad_data[:2]), ad_data[2:])
if ad_type == AdvertisingData.SERVICE_DATA_32_BIT_UUID:
if ad_type == AdvertisingData.Type.SERVICE_DATA_32_BIT_UUID:
return (UUID.from_bytes(ad_data[:4]), ad_data[4:])
if ad_type == AdvertisingData.SERVICE_DATA_128_BIT_UUID:
if ad_type == AdvertisingData.Type.SERVICE_DATA_128_BIT_UUID:
return (UUID.from_bytes(ad_data[:16]), ad_data[16:])
if ad_type in (
AdvertisingData.SHORTENED_LOCAL_NAME,
AdvertisingData.COMPLETE_LOCAL_NAME,
AdvertisingData.URI,
AdvertisingData.BROADCAST_NAME,
AdvertisingData.Type.SHORTENED_LOCAL_NAME,
AdvertisingData.Type.COMPLETE_LOCAL_NAME,
AdvertisingData.Type.URI,
AdvertisingData.Type.BROADCAST_NAME,
):
return ad_data.decode("utf-8")
if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS):
if ad_type in (AdvertisingData.Type.TX_POWER_LEVEL, AdvertisingData.Type.FLAGS):
return cast(int, struct.unpack('B', ad_data)[0])
if ad_type in (AdvertisingData.ADVERTISING_INTERVAL,):
if ad_type in (AdvertisingData.Type.ADVERTISING_INTERVAL,):
return cast(int, struct.unpack('<H', ad_data)[0])
if ad_type == AdvertisingData.CLASS_OF_DEVICE:
if ad_type == AdvertisingData.Type.CLASS_OF_DEVICE:
return cast(int, struct.unpack('<I', bytes([*ad_data, 0]))[0])
if ad_type == AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE:
return cast(Tuple[int, int], struct.unpack('<HH', ad_data))
if ad_type == AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE:
return cast(tuple[int, int], struct.unpack('<HH', ad_data))
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:
if ad_type == AdvertisingData.Type.APPEARANCE:
return Appearance.from_int(
cast(int, struct.unpack_from('<H', ad_data, 0)[0])
)
if ad_type == AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA:
return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:])
return ad_data
def append(self, data: bytes) -> None:
@@ -1600,7 +1607,80 @@ class AdvertisingData:
self.ad_structures.append((ad_type, ad_data))
offset += length
def get_all(self, type_id: int, raw: bool = False) -> List[AdvertisingDataObject]:
@overload
def get_all(
self,
type_id: Literal[
AdvertisingData.Type.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS,
AdvertisingData.Type.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS,
AdvertisingData.Type.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
],
raw: Literal[False] = False,
) -> list[list[UUID]]: ...
@overload
def get_all(
self,
type_id: Literal[
AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID,
AdvertisingData.Type.SERVICE_DATA_32_BIT_UUID,
AdvertisingData.Type.SERVICE_DATA_128_BIT_UUID,
],
raw: Literal[False] = False,
) -> list[tuple[UUID, bytes]]: ...
@overload
def get_all(
self,
type_id: Literal[
AdvertisingData.Type.SHORTENED_LOCAL_NAME,
AdvertisingData.Type.COMPLETE_LOCAL_NAME,
AdvertisingData.Type.URI,
AdvertisingData.Type.BROADCAST_NAME,
],
raw: Literal[False] = False,
) -> list[str]: ...
@overload
def get_all(
self,
type_id: Literal[
AdvertisingData.Type.TX_POWER_LEVEL,
AdvertisingData.Type.FLAGS,
AdvertisingData.Type.ADVERTISING_INTERVAL,
AdvertisingData.Type.CLASS_OF_DEVICE,
],
raw: Literal[False] = False,
) -> list[int]: ...
@overload
def get_all(
self,
type_id: Literal[AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE,],
raw: Literal[False] = False,
) -> list[tuple[int, int]]: ...
@overload
def get_all(
self,
type_id: Literal[AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA,],
raw: Literal[False] = False,
) -> list[tuple[int, bytes]]: ...
@overload
def get_all(
self,
type_id: Literal[AdvertisingData.Type.APPEARANCE,],
raw: Literal[False] = False,
) -> list[Appearance]: ...
@overload
def get_all(self, type_id: int, raw: Literal[True]) -> list[bytes]: ...
@overload
def get_all(
self, type_id: int, raw: bool = False
) -> list[AdvertisingDataObject]: ...
def get_all(self, type_id: int, raw: bool = False) -> list[AdvertisingDataObject]: # type: ignore[misc]
'''
Get Advertising Data Structure(s) with a given type
@@ -1612,6 +1692,79 @@ class AdvertisingData:
return [process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id]
@overload
def get(
self,
type_id: Literal[
AdvertisingData.Type.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS,
AdvertisingData.Type.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS,
AdvertisingData.Type.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
],
raw: Literal[False] = False,
) -> Optional[list[UUID]]: ...
@overload
def get(
self,
type_id: Literal[
AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID,
AdvertisingData.Type.SERVICE_DATA_32_BIT_UUID,
AdvertisingData.Type.SERVICE_DATA_128_BIT_UUID,
],
raw: Literal[False] = False,
) -> Optional[tuple[UUID, bytes]]: ...
@overload
def get(
self,
type_id: Literal[
AdvertisingData.Type.SHORTENED_LOCAL_NAME,
AdvertisingData.Type.COMPLETE_LOCAL_NAME,
AdvertisingData.Type.URI,
AdvertisingData.Type.BROADCAST_NAME,
],
raw: Literal[False] = False,
) -> Optional[Optional[str]]: ...
@overload
def get(
self,
type_id: Literal[
AdvertisingData.Type.TX_POWER_LEVEL,
AdvertisingData.Type.FLAGS,
AdvertisingData.Type.ADVERTISING_INTERVAL,
AdvertisingData.Type.CLASS_OF_DEVICE,
],
raw: Literal[False] = False,
) -> Optional[int]: ...
@overload
def get(
self,
type_id: Literal[AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE,],
raw: Literal[False] = False,
) -> Optional[tuple[int, int]]: ...
@overload
def get(
self,
type_id: Literal[AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA,],
raw: Literal[False] = False,
) -> Optional[tuple[int, bytes]]: ...
@overload
def get(
self,
type_id: Literal[AdvertisingData.Type.APPEARANCE,],
raw: Literal[False] = False,
) -> Optional[Appearance]: ...
@overload
def get(self, type_id: int, raw: Literal[True]) -> Optional[bytes]: ...
@overload
def get(
self, type_id: int, raw: bool = False
) -> Optional[AdvertisingDataObject]: ...
def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingDataObject]:
'''
Get Advertising Data Structure(s) with a given type

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,8 @@ import pathlib
import platform
from typing import Dict, Iterable, Optional, Type, TYPE_CHECKING
from . import rtk, intel
from .common import Driver
from bumble.drivers import rtk, intel
from bumble.drivers.common import Driver
if TYPE_CHECKING:
from bumble.host import Host

View File

@@ -18,7 +18,7 @@
import logging
import struct
from .gatt import (
from bumble.gatt import (
Service,
Characteristic,
GATT_GENERIC_ACCESS_SERVICE,

View File

@@ -27,29 +27,16 @@ import enum
import functools
import logging
import struct
from typing import (
Any,
Callable,
Dict,
Iterable,
List,
Optional,
Sequence,
SupportsBytes,
Type,
Union,
TYPE_CHECKING,
)
from typing import Iterable, List, Optional, Sequence, TypeVar, Union
from bumble.colors import color
from bumble.core import BaseBumbleError, InvalidOperationError, UUID
from bumble.core import BaseBumbleError, UUID
from bumble.att import Attribute, AttributeValue
from bumble.utils import ByteSerializable
if TYPE_CHECKING:
from bumble.gatt_client import AttributeProxy
from bumble.device import Connection
# -----------------------------------------------------------------------------
# Typing
# -----------------------------------------------------------------------------
_T = TypeVar('_T')
# -----------------------------------------------------------------------------
# Logging
@@ -299,6 +286,22 @@ GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC = UUID('38663f1a-e711-4cac-b641-32
GATT_ASHA_VOLUME_CHARACTERISTIC = UUID('00e4ca9e-ab14-41e4-8823-f9e70c7e91df', 'Volume')
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC = UUID('2d410339-82b6-42aa-b34e-e2e01df8cc1a', 'LE_PSM_OUT')
# Apple Notification Center Service
GATT_ANCS_SERVICE = UUID('7905F431-B5CE-4E99-A40F-4B1E122D00D0', 'Apple Notification Center')
GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC = UUID('9FBF120D-6301-42D9-8C58-25E699A21DBD', 'Notification Source')
GATT_ANCS_CONTROL_POINT_CHARACTERISTIC = UUID('69D1D8F3-45E1-49A8-9821-9BBDFDAAD9D9', 'Control Point')
GATT_ANCS_DATA_SOURCE_CHARACTERISTIC = UUID('22EAC6E9-24D6-4BB5-BE44-B36ACE7C7BFB', 'Data Source')
# Apple Media Service
GATT_AMS_SERVICE = UUID('89D3502B-0F36-433A-8EF4-C502AD55F8DC', 'Apple Media')
GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC = UUID('9B3C81D8-57B1-4A8A-B8DF-0E56F7CA51C2', 'Remote Command')
GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC = UUID('2F7CABCE-808D-411F-9A0C-BB92BA96C102', 'Entity Update')
GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC = UUID('C6B2F38C-23AB-46D8-A6AB-A3A870BBD5D7', 'Entity Attribute')
# Misc Apple Services
GATT_APPLE_CONTINUITY_SERVICE = UUID('D0611E78-BBB4-4591-A5F8-487910AE4366', 'Apple Continuity')
GATT_APPLE_NEARBY_SERVICE = UUID('9FA480E0-4967-4542-9390-D343DC5D04AE', 'Apple Nearby')
# Misc
GATT_DEVICE_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2A00, 'Device Name')
GATT_APPEARANCE_CHARACTERISTIC = UUID.from_16_bits(0x2A01, 'Appearance')
@@ -437,7 +440,7 @@ class IncludedServiceDeclaration(Attribute):
# -----------------------------------------------------------------------------
class Characteristic(Attribute):
class Characteristic(Attribute[_T]):
'''
See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION
'''
@@ -445,6 +448,8 @@ class Characteristic(Attribute):
uuid: UUID
properties: Characteristic.Properties
EVENT_SUBSCRIPTION = "subscription"
class Properties(enum.IntFlag):
"""Property flags"""
@@ -500,7 +505,7 @@ class Characteristic(Attribute):
uuid: Union[str, bytes, UUID],
properties: Characteristic.Properties,
permissions: Union[str, Attribute.Permissions],
value: Any = b'',
value: Union[AttributeValue[_T], _T, None] = None,
descriptors: Sequence[Descriptor] = (),
):
super().__init__(uuid, permissions, value)
@@ -560,217 +565,10 @@ class CharacteristicDeclaration(Attribute):
# -----------------------------------------------------------------------------
class CharacteristicValue(AttributeValue):
class CharacteristicValue(AttributeValue[_T]):
"""Same as AttributeValue, for backward compatibility"""
# -----------------------------------------------------------------------------
class CharacteristicAdapter:
'''
An adapter that can adapt Characteristic and AttributeProxy objects
by wrapping their `read_value()` and `write_value()` methods with ones that
return/accept encoded/decoded values.
For proxies (i.e used by a GATT client), the adaptation is one where the return
value of `read_value()` is decoded and the value passed to `write_value()` is
encoded. The `subscribe()` method, is wrapped with one where the values are decoded
before being passed to the subscriber.
For local values (i.e hosted by a GATT server) the adaptation is one where the
return value of `read_value()` is encoded and the value passed to `write_value()`
is decoded.
'''
read_value: Callable
write_value: Callable
def __init__(self, characteristic: Union[Characteristic, AttributeProxy]):
self.wrapped_characteristic = characteristic
self.subscribers: Dict[Callable, Callable] = (
{}
) # Map from subscriber to proxy subscriber
if isinstance(characteristic, Characteristic):
self.read_value = self.read_encoded_value
self.write_value = self.write_encoded_value
else:
self.read_value = self.read_decoded_value
self.write_value = self.write_decoded_value
self.subscribe = self.wrapped_subscribe
self.unsubscribe = self.wrapped_unsubscribe
def __getattr__(self, name):
return getattr(self.wrapped_characteristic, name)
def __setattr__(self, name, value):
if name in (
'wrapped_characteristic',
'subscribers',
'read_value',
'write_value',
'subscribe',
'unsubscribe',
):
super().__setattr__(name, value)
else:
setattr(self.wrapped_characteristic, name, value)
async def read_encoded_value(self, connection):
return self.encode_value(
await self.wrapped_characteristic.read_value(connection)
)
async def write_encoded_value(self, connection, value):
return await self.wrapped_characteristic.write_value(
connection, self.decode_value(value)
)
async def read_decoded_value(self):
return self.decode_value(await self.wrapped_characteristic.read_value())
async def write_decoded_value(self, value, with_response=False):
return await self.wrapped_characteristic.write_value(
self.encode_value(value), with_response
)
def encode_value(self, value):
return value
def decode_value(self, value):
return value
def wrapped_subscribe(self, subscriber=None):
if subscriber is not None:
if subscriber in self.subscribers:
# We already have a proxy subscriber
subscriber = self.subscribers[subscriber]
else:
# Create and register a proxy that will decode the value
original_subscriber = subscriber
def on_change(value):
original_subscriber(self.decode_value(value))
self.subscribers[subscriber] = on_change
subscriber = on_change
return self.wrapped_characteristic.subscribe(subscriber)
def wrapped_unsubscribe(self, subscriber=None):
if subscriber in self.subscribers:
subscriber = self.subscribers.pop(subscriber)
return self.wrapped_characteristic.unsubscribe(subscriber)
def __str__(self) -> str:
wrapped = str(self.wrapped_characteristic)
return f'{self.__class__.__name__}({wrapped})'
# -----------------------------------------------------------------------------
class DelegatedCharacteristicAdapter(CharacteristicAdapter):
'''
Adapter that converts bytes values using an encode and a decode function.
'''
def __init__(self, characteristic, encode=None, decode=None):
super().__init__(characteristic)
self.encode = encode
self.decode = decode
def encode_value(self, value):
if self.encode is None:
raise InvalidOperationError('delegated adapter does not have an encoder')
return self.encode(value)
def decode_value(self, value):
if self.decode is None:
raise InvalidOperationError('delegate adapter does not have a decoder')
return self.decode(value)
# -----------------------------------------------------------------------------
class PackedCharacteristicAdapter(CharacteristicAdapter):
'''
Adapter that packs/unpacks characteristic values according to a standard
Python `struct` format.
For formats with a single value, the adapted `read_value` and `write_value`
methods return/accept single values. For formats with multiple values,
they return/accept a tuple with the same number of elements as is required for
the format.
'''
def __init__(self, characteristic, pack_format):
super().__init__(characteristic)
self.struct = struct.Struct(pack_format)
def pack(self, *values):
return self.struct.pack(*values)
def unpack(self, buffer):
return self.struct.unpack(buffer)
def encode_value(self, value):
return self.pack(*value if isinstance(value, tuple) else (value,))
def decode_value(self, value):
unpacked = self.unpack(value)
return unpacked[0] if len(unpacked) == 1 else unpacked
# -----------------------------------------------------------------------------
class MappedCharacteristicAdapter(PackedCharacteristicAdapter):
'''
Adapter that packs/unpacks characteristic values according to a standard
Python `struct` format.
The adapted `read_value` and `write_value` methods return/accept a dictionary which
is packed/unpacked according to format, with the arguments extracted from the
dictionary by key, in the same order as they occur in the `keys` parameter.
'''
def __init__(self, characteristic, pack_format, keys):
super().__init__(characteristic, pack_format)
self.keys = keys
# pylint: disable=arguments-differ
def pack(self, values):
return super().pack(*(values[key] for key in self.keys))
def unpack(self, buffer):
return dict(zip(self.keys, super().unpack(buffer)))
# -----------------------------------------------------------------------------
class UTF8CharacteristicAdapter(CharacteristicAdapter):
'''
Adapter that converts strings to/from bytes using UTF-8 encoding
'''
def encode_value(self, value: str) -> bytes:
return value.encode('utf-8')
def decode_value(self, value: bytes) -> str:
return value.decode('utf-8')
# -----------------------------------------------------------------------------
class SerializableCharacteristicAdapter(CharacteristicAdapter):
'''
Adapter that converts any class to/from bytes using the class'
`to_bytes` and `__bytes__` methods, respectively.
'''
def __init__(self, characteristic, cls: Type[ByteSerializable]):
super().__init__(characteristic)
self.cls = cls
def encode_value(self, value: SupportsBytes) -> bytes:
return bytes(value)
def decode_value(self, value: bytes) -> Any:
return self.cls.from_bytes(value)
# -----------------------------------------------------------------------------
class Descriptor(Attribute):
'''
@@ -805,3 +603,23 @@ class ClientCharacteristicConfigurationBits(enum.IntFlag):
DEFAULT = 0x0000
NOTIFICATION = 0x0001
INDICATION = 0x0002
# -----------------------------------------------------------------------------
class ClientSupportedFeatures(enum.IntFlag):
'''
See Vol 3, Part G - 7.2 - Table 7.6: Client Supported Features bit assignments.
'''
ROBUST_CACHING = 0x01
ENHANCED_ATT_BEARER = 0x02
MULTIPLE_HANDLE_VALUE_NOTIFICATIONS = 0x04
# -----------------------------------------------------------------------------
class ServerSupportedFeatures(enum.IntFlag):
'''
See Vol 3, Part G - 7.4 - Table 7.11: Server Supported Features bit assignments.
'''
EATT_SUPPORTED = 0x01

374
bumble/gatt_adapters.py Normal file
View File

@@ -0,0 +1,374 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# GATT - Type Adapters
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import struct
from typing import (
Any,
Callable,
Generic,
Iterable,
Literal,
Optional,
Type,
TypeVar,
)
from bumble.core import InvalidOperationError
from bumble.gatt import Characteristic
from bumble.gatt_client import CharacteristicProxy
from bumble import utils
# -----------------------------------------------------------------------------
# Typing
# -----------------------------------------------------------------------------
_T = TypeVar('_T')
_T2 = TypeVar('_T2', bound=utils.ByteSerializable)
_T3 = TypeVar('_T3', bound=utils.IntConvertible)
# -----------------------------------------------------------------------------
class CharacteristicAdapter(Characteristic, Generic[_T]):
'''Base class for GATT Characteristic adapters.'''
def __init__(self, characteristic: Characteristic) -> None:
super().__init__(
characteristic.uuid,
characteristic.properties,
characteristic.permissions,
characteristic.value,
characteristic.descriptors,
)
# -----------------------------------------------------------------------------
class CharacteristicProxyAdapter(CharacteristicProxy[_T]):
'''Base class for GATT CharacteristicProxy adapters.'''
def __init__(self, characteristic_proxy: CharacteristicProxy):
super().__init__(
characteristic_proxy.client,
characteristic_proxy.handle,
characteristic_proxy.end_group_handle,
characteristic_proxy.uuid,
characteristic_proxy.properties,
)
# -----------------------------------------------------------------------------
class DelegatedCharacteristicAdapter(CharacteristicAdapter[_T]):
'''
Adapter that converts bytes values using an encode and/or a decode function.
'''
def __init__(
self,
characteristic: Characteristic,
encode: Optional[Callable[[_T], bytes]] = None,
decode: Optional[Callable[[bytes], _T]] = None,
):
super().__init__(characteristic)
self.encode = encode
self.decode = decode
def encode_value(self, value: _T) -> bytes:
if self.encode is None:
raise InvalidOperationError('delegated adapter does not have an encoder')
return self.encode(value)
def decode_value(self, value: bytes) -> _T:
if self.decode is None:
raise InvalidOperationError('delegate adapter does not have a decoder')
return self.decode(value)
# -----------------------------------------------------------------------------
class DelegatedCharacteristicProxyAdapter(CharacteristicProxyAdapter[_T]):
'''
Adapter that converts bytes values using an encode and a decode function.
'''
def __init__(
self,
characteristic_proxy: CharacteristicProxy,
encode: Optional[Callable[[_T], bytes]] = None,
decode: Optional[Callable[[bytes], _T]] = None,
):
super().__init__(characteristic_proxy)
self.encode = encode
self.decode = decode
def encode_value(self, value: _T) -> bytes:
if self.encode is None:
raise InvalidOperationError('delegated adapter does not have an encoder')
return self.encode(value)
def decode_value(self, value: bytes) -> _T:
if self.decode is None:
raise InvalidOperationError('delegate adapter does not have a decoder')
return self.decode(value)
# -----------------------------------------------------------------------------
class PackedCharacteristicAdapter(CharacteristicAdapter):
'''
Adapter that packs/unpacks characteristic values according to a standard
Python `struct` format.
For formats with a single value, the adapted `read_value` and `write_value`
methods return/accept single values. For formats with multiple values,
they return/accept a tuple with the same number of elements as is required for
the format.
'''
def __init__(self, characteristic: Characteristic, pack_format: str) -> None:
super().__init__(characteristic)
self.struct = struct.Struct(pack_format)
def pack(self, *values) -> bytes:
return self.struct.pack(*values)
def unpack(self, buffer: bytes) -> tuple:
return self.struct.unpack(buffer)
def encode_value(self, value: Any) -> bytes:
return self.pack(*value if isinstance(value, tuple) else (value,))
def decode_value(self, value: bytes) -> Any:
unpacked = self.unpack(value)
return unpacked[0] if len(unpacked) == 1 else unpacked
# -----------------------------------------------------------------------------
class PackedCharacteristicProxyAdapter(CharacteristicProxyAdapter):
'''
Adapter that packs/unpacks characteristic values according to a standard
Python `struct` format.
For formats with a single value, the adapted `read_value` and `write_value`
methods return/accept single values. For formats with multiple values,
they return/accept a tuple with the same number of elements as is required for
the format.
'''
def __init__(self, characteristic_proxy, pack_format):
super().__init__(characteristic_proxy)
self.struct = struct.Struct(pack_format)
def pack(self, *values) -> bytes:
return self.struct.pack(*values)
def unpack(self, buffer: bytes) -> tuple:
return self.struct.unpack(buffer)
def encode_value(self, value: Any) -> bytes:
return self.pack(*value if isinstance(value, tuple) else (value,))
def decode_value(self, value: bytes) -> Any:
unpacked = self.unpack(value)
return unpacked[0] if len(unpacked) == 1 else unpacked
# -----------------------------------------------------------------------------
class MappedCharacteristicAdapter(PackedCharacteristicAdapter):
'''
Adapter that packs/unpacks characteristic values according to a standard
Python `struct` format.
The adapted `read_value` and `write_value` methods return/accept a dictionary which
is packed/unpacked according to format, with the arguments extracted from the
dictionary by key, in the same order as they occur in the `keys` parameter.
'''
def __init__(
self, characteristic: Characteristic, pack_format: str, keys: Iterable[str]
) -> None:
super().__init__(characteristic, pack_format)
self.keys = keys
# pylint: disable=arguments-differ
def pack(self, values) -> bytes:
return super().pack(*(values[key] for key in self.keys))
def unpack(self, buffer: bytes) -> Any:
return dict(zip(self.keys, super().unpack(buffer)))
# -----------------------------------------------------------------------------
class MappedCharacteristicProxyAdapter(PackedCharacteristicProxyAdapter):
'''
Adapter that packs/unpacks characteristic values according to a standard
Python `struct` format.
The adapted `read_value` and `write_value` methods return/accept a dictionary which
is packed/unpacked according to format, with the arguments extracted from the
dictionary by key, in the same order as they occur in the `keys` parameter.
'''
def __init__(
self,
characteristic_proxy: CharacteristicProxy,
pack_format: str,
keys: Iterable[str],
) -> None:
super().__init__(characteristic_proxy, pack_format)
self.keys = keys
# pylint: disable=arguments-differ
def pack(self, values) -> bytes:
return super().pack(*(values[key] for key in self.keys))
def unpack(self, buffer: bytes) -> Any:
return dict(zip(self.keys, super().unpack(buffer)))
# -----------------------------------------------------------------------------
class UTF8CharacteristicAdapter(CharacteristicAdapter[str]):
'''
Adapter that converts strings to/from bytes using UTF-8 encoding
'''
def encode_value(self, value: str) -> bytes:
return value.encode('utf-8')
def decode_value(self, value: bytes) -> str:
return value.decode('utf-8')
# -----------------------------------------------------------------------------
class UTF8CharacteristicProxyAdapter(CharacteristicProxyAdapter[str]):
'''
Adapter that converts strings to/from bytes using UTF-8 encoding
'''
def encode_value(self, value: str) -> bytes:
return value.encode('utf-8')
def decode_value(self, value: bytes) -> str:
return value.decode('utf-8')
# -----------------------------------------------------------------------------
class SerializableCharacteristicAdapter(CharacteristicAdapter[_T2]):
'''
Adapter that converts any class to/from bytes using the class'
`to_bytes` and `__bytes__` methods, respectively.
'''
def __init__(self, characteristic: Characteristic, cls: Type[_T2]) -> None:
super().__init__(characteristic)
self.cls = cls
def encode_value(self, value: _T2) -> bytes:
return bytes(value)
def decode_value(self, value: bytes) -> _T2:
return self.cls.from_bytes(value)
# -----------------------------------------------------------------------------
class SerializableCharacteristicProxyAdapter(CharacteristicProxyAdapter[_T2]):
'''
Adapter that converts any class to/from bytes using the class'
`to_bytes` and `__bytes__` methods, respectively.
'''
def __init__(
self, characteristic_proxy: CharacteristicProxy, cls: Type[_T2]
) -> None:
super().__init__(characteristic_proxy)
self.cls = cls
def encode_value(self, value: _T2) -> bytes:
return bytes(value)
def decode_value(self, value: bytes) -> _T2:
return self.cls.from_bytes(value)
# -----------------------------------------------------------------------------
class EnumCharacteristicAdapter(CharacteristicAdapter[_T3]):
'''
Adapter that converts int-enum-like classes to/from bytes using the class'
`int().to_bytes()` and `from_bytes()` methods, respectively.
'''
def __init__(
self,
characteristic: Characteristic,
cls: Type[_T3],
length: int,
byteorder: Literal['little', 'big'] = 'little',
):
"""
Initialize an instance.
Params:
characteristic: the Characteristic to adapt to/from
cls: the class to/from which to convert integer values
length: number of bytes used to represent integer values
byteorder: byte order of the byte representation of integers.
"""
super().__init__(characteristic)
self.cls = cls
self.length = length
self.byteorder = byteorder
def encode_value(self, value: _T3) -> bytes:
return int(value).to_bytes(self.length, self.byteorder)
def decode_value(self, value: bytes) -> _T3:
int_value = int.from_bytes(value, self.byteorder)
return self.cls(int_value)
# -----------------------------------------------------------------------------
class EnumCharacteristicProxyAdapter(CharacteristicProxyAdapter[_T3]):
'''
Adapter that converts int-enum-like classes to/from bytes using the class'
`int().to_bytes()` and `from_bytes()` methods, respectively.
'''
def __init__(
self,
characteristic_proxy: CharacteristicProxy,
cls: Type[_T3],
length: int,
byteorder: Literal['little', 'big'] = 'little',
):
"""
Initialize an instance.
Params:
characteristic_proxy: the CharacteristicProxy to adapt to/from
cls: the class to/from which to convert integer values
length: number of bytes used to represent integer values
byteorder: byte order of the byte representation of integers.
"""
super().__init__(characteristic_proxy)
self.cls = cls
self.length = length
self.byteorder = byteorder
def encode_value(self, value: _T3) -> bytes:
return int(value).to_bytes(self.length, self.byteorder)
def decode_value(self, value: bytes) -> _T3:
int_value = int.from_bytes(value, self.byteorder)
a = self.cls(int_value)
return self.cls(int_value)

View File

@@ -29,24 +29,25 @@ import logging
import struct
from datetime import datetime
from typing import (
Any,
Callable,
Dict,
Generic,
Iterable,
List,
Optional,
Dict,
Tuple,
Callable,
Union,
Any,
Iterable,
Type,
Set,
Tuple,
Union,
Type,
TypeVar,
TYPE_CHECKING,
)
from pyee import EventEmitter
from .colors import color
from .hci import HCI_Constant
from .att import (
from bumble.colors import color
from bumble.hci import HCI_Constant
from bumble.att import (
ATT_ATTRIBUTE_NOT_FOUND_ERROR,
ATT_ATTRIBUTE_NOT_LONG_ERROR,
ATT_CID,
@@ -67,9 +68,10 @@ from .att import (
ATT_Write_Request,
ATT_Error,
)
from . import core
from .core import UUID, InvalidStateError
from .gatt import (
from bumble import utils
from bumble import core
from bumble.core import UUID, InvalidStateError
from bumble.gatt import (
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
@@ -82,9 +84,14 @@ from .gatt import (
TemplateService,
)
# -----------------------------------------------------------------------------
# Typing
# -----------------------------------------------------------------------------
if TYPE_CHECKING:
from bumble.device import Connection
_T = TypeVar('_T')
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -110,31 +117,31 @@ def show_services(services: Iterable[ServiceProxy]) -> None:
# -----------------------------------------------------------------------------
# Proxies
# -----------------------------------------------------------------------------
class AttributeProxy(EventEmitter):
class AttributeProxy(utils.EventEmitter, Generic[_T]):
def __init__(
self, client: Client, handle: int, end_group_handle: int, attribute_type: UUID
) -> None:
EventEmitter.__init__(self)
utils.EventEmitter.__init__(self)
self.client = client
self.handle = handle
self.end_group_handle = end_group_handle
self.type = attribute_type
async def read_value(self, no_long_read: bool = False) -> bytes:
async def read_value(self, no_long_read: bool = False) -> _T:
return self.decode_value(
await self.client.read_value(self.handle, no_long_read)
)
async def write_value(self, value, with_response=False):
async def write_value(self, value: _T, with_response=False):
return await self.client.write_value(
self.handle, self.encode_value(value), with_response
)
def encode_value(self, value: Any) -> bytes:
return value
def encode_value(self, value: _T) -> bytes:
return value # type: ignore
def decode_value(self, value_bytes: bytes) -> Any:
return value_bytes
def decode_value(self, value: bytes) -> _T:
return value # type: ignore
def __str__(self) -> str:
return f'Attribute(handle=0x{self.handle:04X}, type={self.type})'
@@ -142,7 +149,7 @@ class AttributeProxy(EventEmitter):
class ServiceProxy(AttributeProxy):
uuid: UUID
characteristics: List[CharacteristicProxy]
characteristics: List[CharacteristicProxy[bytes]]
included_services: List[ServiceProxy]
@staticmethod
@@ -163,14 +170,20 @@ class ServiceProxy(AttributeProxy):
self.uuid = uuid
self.characteristics = []
async def discover_characteristics(self, uuids=()) -> list[CharacteristicProxy]:
async def discover_characteristics(
self, uuids=()
) -> list[CharacteristicProxy[bytes]]:
return await self.client.discover_characteristics(uuids, self)
def get_characteristics_by_uuid(self, uuid: UUID) -> list[CharacteristicProxy]:
def get_characteristics_by_uuid(
self, uuid: UUID
) -> list[CharacteristicProxy[bytes]]:
"""Get all the characteristics with a specified UUID."""
return self.client.get_characteristics_by_uuid(uuid, self)
def get_required_characteristic_by_uuid(self, uuid: UUID) -> CharacteristicProxy:
def get_required_characteristic_by_uuid(
self, uuid: UUID
) -> CharacteristicProxy[bytes]:
"""
Get the first characteristic with a specified UUID.
@@ -184,19 +197,21 @@ class ServiceProxy(AttributeProxy):
return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})'
class CharacteristicProxy(AttributeProxy):
class CharacteristicProxy(AttributeProxy[_T]):
properties: Characteristic.Properties
descriptors: List[DescriptorProxy]
subscribers: Dict[Any, Callable[[bytes], Any]]
subscribers: Dict[Any, Callable[[_T], Any]]
EVENT_UPDATE = "update"
def __init__(
self,
client,
handle,
end_group_handle,
uuid,
client: Client,
handle: int,
end_group_handle: int,
uuid: UUID,
properties: int,
):
) -> None:
super().__init__(client, handle, end_group_handle, uuid)
self.uuid = uuid
self.properties = Characteristic.Properties(properties)
@@ -204,21 +219,21 @@ class CharacteristicProxy(AttributeProxy):
self.descriptors_discovered = False
self.subscribers = {} # Map from subscriber to proxy subscriber
def get_descriptor(self, descriptor_type):
def get_descriptor(self, descriptor_type: UUID) -> Optional[DescriptorProxy]:
for descriptor in self.descriptors:
if descriptor.type == descriptor_type:
return descriptor
return None
async def discover_descriptors(self):
async def discover_descriptors(self) -> list[DescriptorProxy]:
return await self.client.discover_descriptors(self)
async def subscribe(
self,
subscriber: Optional[Callable[[bytes], Any]] = None,
subscriber: Optional[Callable[[_T], Any]] = None,
prefer_notify: bool = True,
):
) -> None:
if subscriber is not None:
if subscriber in self.subscribers:
# We already have a proxy subscriber
@@ -233,13 +248,13 @@ class CharacteristicProxy(AttributeProxy):
self.subscribers[subscriber] = on_change
subscriber = on_change
return await self.client.subscribe(self, subscriber, prefer_notify)
await self.client.subscribe(self, subscriber, prefer_notify)
async def unsubscribe(self, subscriber=None, force=False):
async def unsubscribe(self, subscriber=None, force=False) -> None:
if subscriber in self.subscribers:
subscriber = self.subscribers.pop(subscriber)
return await self.client.unsubscribe(self, subscriber, force)
await self.client.unsubscribe(self, subscriber, force)
def __str__(self) -> str:
return (
@@ -249,8 +264,8 @@ class CharacteristicProxy(AttributeProxy):
)
class DescriptorProxy(AttributeProxy):
def __init__(self, client, handle, descriptor_type):
class DescriptorProxy(AttributeProxy[bytes]):
def __init__(self, client: Client, handle: int, descriptor_type: UUID) -> None:
super().__init__(client, handle, 0, descriptor_type)
def __str__(self) -> str:
@@ -295,7 +310,7 @@ class Client:
self.services = []
self.cached_values = {}
connection.on('disconnection', self.on_disconnection)
connection.on(connection.EVENT_DISCONNECTION, self.on_disconnection)
def send_gatt_pdu(self, pdu: bytes) -> None:
self.connection.send_l2cap_pdu(ATT_CID, pdu)
@@ -369,7 +384,7 @@ class Client:
def get_characteristics_by_uuid(
self, uuid: UUID, service: Optional[ServiceProxy] = None
) -> List[CharacteristicProxy]:
) -> List[CharacteristicProxy[bytes]]:
services = [service] if service else self.services
return [
c
@@ -621,7 +636,7 @@ class Client:
async def discover_characteristics(
self, uuids, service: Optional[ServiceProxy]
) -> List[CharacteristicProxy]:
) -> List[CharacteristicProxy[bytes]]:
'''
See Vol 3, Part G - 4.6.1 Discover All Characteristics of a Service and 4.6.2
Discover Characteristics by UUID
@@ -634,12 +649,12 @@ class Client:
services = [service] if service else self.services
# Perform characteristic discovery for each service
discovered_characteristics: List[CharacteristicProxy] = []
discovered_characteristics: List[CharacteristicProxy[bytes]] = []
for service in services:
starting_handle = service.handle
ending_handle = service.end_group_handle
characteristics: List[CharacteristicProxy] = []
characteristics: List[CharacteristicProxy[bytes]] = []
while starting_handle <= ending_handle:
response = await self.send_request(
ATT_Read_By_Type_Request(
@@ -679,7 +694,7 @@ class Client:
properties, handle = struct.unpack_from('<BH', attribute_value)
characteristic_uuid = UUID.from_bytes(attribute_value[3:])
characteristic = CharacteristicProxy(
characteristic = CharacteristicProxy[bytes](
self, handle, 0, characteristic_uuid, properties
)
@@ -772,7 +787,7 @@ class Client:
return descriptors
async def discover_attributes(self) -> List[AttributeProxy]:
async def discover_attributes(self) -> List[AttributeProxy[bytes]]:
'''
Discover all attributes, regardless of type
'''
@@ -805,7 +820,7 @@ class Client:
logger.warning(f'bogus handle value: {attribute_handle}')
return []
attribute = AttributeProxy(
attribute = AttributeProxy[bytes](
self, attribute_handle, 0, UUID.from_bytes(attribute_uuid)
)
attributes.append(attribute)
@@ -818,7 +833,7 @@ class Client:
async def subscribe(
self,
characteristic: CharacteristicProxy,
subscriber: Optional[Callable[[bytes], Any]] = None,
subscriber: Optional[Callable[[Any], Any]] = None,
prefer_notify: bool = True,
) -> None:
# If we haven't already discovered the descriptors for this characteristic,
@@ -868,7 +883,7 @@ class Client:
async def unsubscribe(
self,
characteristic: CharacteristicProxy,
subscriber: Optional[Callable[[bytes], Any]] = None,
subscriber: Optional[Callable[[Any], Any]] = None,
force: bool = False,
) -> None:
'''
@@ -1129,7 +1144,7 @@ class Client:
if callable(subscriber):
subscriber(notification.attribute_value)
else:
subscriber.emit('update', notification.attribute_value)
subscriber.emit(subscriber.EVENT_UPDATE, notification.attribute_value)
def on_att_handle_value_indication(self, indication):
# Call all subscribers
@@ -1144,7 +1159,7 @@ class Client:
if callable(subscriber):
subscriber(indication.attribute_value)
else:
subscriber.emit('update', indication.attribute_value)
subscriber.emit(subscriber.EVENT_UPDATE, indication.attribute_value)
# Confirm that we received the indication
self.send_confirmation(ATT_Handle_Value_Confirmation())

View File

@@ -36,10 +36,8 @@ from typing import (
Tuple,
TypeVar,
Type,
Union,
TYPE_CHECKING,
)
from pyee import EventEmitter
from bumble.colors import color
from bumble.core import UUID
@@ -78,14 +76,13 @@ from bumble.gatt import (
GATT_REQUEST_TIMEOUT,
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
Characteristic,
CharacteristicAdapter,
CharacteristicDeclaration,
CharacteristicValue,
IncludedServiceDeclaration,
Descriptor,
Service,
)
from bumble.utils import AsyncRunner
from bumble import utils
if TYPE_CHECKING:
from bumble.device import Device, Connection
@@ -105,7 +102,7 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517
# -----------------------------------------------------------------------------
# GATT Server
# -----------------------------------------------------------------------------
class Server(EventEmitter):
class Server(utils.EventEmitter):
attributes: List[Attribute]
services: List[Service]
attributes_by_handle: Dict[int, Attribute]
@@ -113,6 +110,8 @@ class Server(EventEmitter):
indication_semaphores: defaultdict[int, asyncio.Semaphore]
pending_confirmations: defaultdict[int, Optional[asyncio.futures.Future]]
EVENT_CHARACTERISTIC_SUBSCRIPTION = "characteristic_subscription"
def __init__(self, device: Device) -> None:
super().__init__()
self.device = device
@@ -350,10 +349,13 @@ class Server(EventEmitter):
notify_enabled = value[0] & 0x01 != 0
indicate_enabled = value[0] & 0x02 != 0
characteristic.emit(
'subscription', connection, notify_enabled, indicate_enabled
characteristic.EVENT_SUBSCRIPTION,
connection,
notify_enabled,
indicate_enabled,
)
self.emit(
'characteristic_subscription',
self.EVENT_CHARACTERISTIC_SUBSCRIPTION,
connection,
characteristic,
notify_enabled,
@@ -469,7 +471,7 @@ class Server(EventEmitter):
finally:
self.pending_confirmations[connection.handle] = None
async def notify_or_indicate_subscribers(
async def _notify_or_indicate_subscribers(
self,
indicate: bool,
attribute: Attribute,
@@ -503,7 +505,9 @@ class Server(EventEmitter):
value: Optional[bytes] = None,
force: bool = False,
):
return await self.notify_or_indicate_subscribers(False, attribute, value, force)
return await self._notify_or_indicate_subscribers(
False, attribute, value, force
)
async def indicate_subscribers(
self,
@@ -511,7 +515,7 @@ class Server(EventEmitter):
value: Optional[bytes] = None,
force: bool = False,
):
return await self.notify_or_indicate_subscribers(True, attribute, value, force)
return await self._notify_or_indicate_subscribers(True, attribute, value, force)
def on_disconnection(self, connection: Connection) -> None:
if connection.handle in self.subscribers:
@@ -662,7 +666,7 @@ class Server(EventEmitter):
self.send_response(connection, response)
@AsyncRunner.run_in_task()
@utils.AsyncRunner.run_in_task()
async def on_att_find_by_type_value_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.3.3 Find By Type Value Request
@@ -715,7 +719,7 @@ class Server(EventEmitter):
self.send_response(connection, response)
@AsyncRunner.run_in_task()
@utils.AsyncRunner.run_in_task()
async def on_att_read_by_type_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.4.1 Read By Type Request
@@ -781,7 +785,7 @@ class Server(EventEmitter):
self.send_response(connection, response)
@AsyncRunner.run_in_task()
@utils.AsyncRunner.run_in_task()
async def on_att_read_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.4.3 Read Request
@@ -807,7 +811,7 @@ class Server(EventEmitter):
)
self.send_response(connection, response)
@AsyncRunner.run_in_task()
@utils.AsyncRunner.run_in_task()
async def on_att_read_blob_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.4.5 Read Blob Request
@@ -852,7 +856,7 @@ class Server(EventEmitter):
)
self.send_response(connection, response)
@AsyncRunner.run_in_task()
@utils.AsyncRunner.run_in_task()
async def on_att_read_by_group_type_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.4.9 Read by Group Type Request
@@ -920,7 +924,7 @@ class Server(EventEmitter):
self.send_response(connection, response)
@AsyncRunner.run_in_task()
@utils.AsyncRunner.run_in_task()
async def on_att_write_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.5.1 Write Request
@@ -967,7 +971,7 @@ class Server(EventEmitter):
response = ATT_Write_Response()
self.send_response(connection, response)
@AsyncRunner.run_in_task()
@utils.AsyncRunner.run_in_task()
async def on_att_write_command(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.5.3 Write Command

View File

@@ -24,21 +24,22 @@ import logging
import secrets
import struct
from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union, ClassVar
from typing_extensions import Self
from bumble import crypto
from bumble.colors import color
from bumble.core import (
BT_BR_EDR_TRANSPORT,
AdvertisingData,
DeviceClass,
InvalidArgumentError,
InvalidPacketError,
PhysicalTransport,
ProtocolError,
bit_flags_to_strings,
name_or_number,
padded_bytes,
)
from bumble.utils import OpenIntEnum
from bumble import utils
# -----------------------------------------------------------------------------
@@ -94,7 +95,7 @@ def map_class_of_device(class_of_device):
)
def phy_list_to_bits(phys: Optional[Iterable[int]]) -> int:
def phy_list_to_bits(phys: Optional[Iterable[Phy]]) -> int:
if phys is None:
return 0
@@ -223,6 +224,7 @@ HCI_CONNECTIONLESS_PERIPHERAL_BROADCAST_CHANNEL_MAP_CHANGE_EVENT = 0X55
HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT = 0X56
HCI_AUTHENTICATED_PAYLOAD_TIMEOUT_EXPIRED_EVENT = 0X57
HCI_SAM_STATUS_CHANGE_EVENT = 0X58
HCI_ENCRYPTION_CHANGE_V2_EVENT = 0x59
HCI_VENDOR_EVENT = 0xFF
@@ -700,30 +702,22 @@ HCI_ERROR_NAMES[HCI_SUCCESS] = 'HCI_SUCCESS'
HCI_COMMAND_STATUS_PENDING = 0
class Phy(enum.IntEnum):
LE_1M = 1
LE_2M = 2
LE_CODED = 3
# ACL
HCI_ACL_PB_FIRST_NON_FLUSHABLE = 0
HCI_ACL_PB_CONTINUATION = 1
HCI_ACL_PB_FIRST_FLUSHABLE = 2
HCI_ACK_PB_COMPLETE_L2CAP = 3
# Roles
HCI_CENTRAL_ROLE = 0
HCI_PERIPHERAL_ROLE = 1
HCI_ROLE_NAMES = {
HCI_CENTRAL_ROLE: 'CENTRAL',
HCI_PERIPHERAL_ROLE: 'PERIPHERAL'
}
# LE PHY Types
HCI_LE_1M_PHY = 1
HCI_LE_2M_PHY = 2
HCI_LE_CODED_PHY = 3
HCI_LE_PHY_NAMES = {
HCI_LE_1M_PHY: 'LE 1M',
HCI_LE_2M_PHY: 'LE 2M',
HCI_LE_CODED_PHY: 'LE Coded'
HCI_LE_PHY_NAMES: dict[int,str] = {
Phy.LE_1M: 'LE 1M',
Phy.LE_2M: 'LE 2M',
Phy.LE_CODED: 'LE Coded'
}
HCI_LE_1M_PHY_BIT = 0
@@ -732,26 +726,20 @@ HCI_LE_CODED_PHY_BIT = 2
HCI_LE_PHY_BIT_NAMES = ['LE_1M_PHY', 'LE_2M_PHY', 'LE_CODED_PHY']
HCI_LE_PHY_TYPE_TO_BIT = {
HCI_LE_1M_PHY: HCI_LE_1M_PHY_BIT,
HCI_LE_2M_PHY: HCI_LE_2M_PHY_BIT,
HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_BIT
HCI_LE_PHY_TYPE_TO_BIT: dict[Phy, int] = {
Phy.LE_1M: HCI_LE_1M_PHY_BIT,
Phy.LE_2M: HCI_LE_2M_PHY_BIT,
Phy.LE_CODED: HCI_LE_CODED_PHY_BIT,
}
class Phy(enum.IntEnum):
LE_1M = HCI_LE_1M_PHY
LE_2M = HCI_LE_2M_PHY
LE_CODED = HCI_LE_CODED_PHY
class PhyBit(enum.IntFlag):
LE_1M = 1 << HCI_LE_1M_PHY_BIT
LE_2M = 1 << HCI_LE_2M_PHY_BIT
LE_CODED = 1 << HCI_LE_CODED_PHY_BIT
class CsRole(OpenIntEnum):
class CsRole(utils.OpenIntEnum):
INITIATOR = 0x00
REFLECTOR = 0x01
@@ -761,7 +749,7 @@ class CsRoleMask(enum.IntFlag):
REFLECTOR = 0x02
class CsSyncPhy(OpenIntEnum):
class CsSyncPhy(utils.OpenIntEnum):
LE_1M = 1
LE_2M = 2
LE_2M_2BT = 3
@@ -772,7 +760,7 @@ class CsSyncPhySupported(enum.IntFlag):
LE_2M_2BT = 0x02
class RttType(OpenIntEnum):
class RttType(utils.OpenIntEnum):
AA_ONLY = 0x00
SOUNDING_SEQUENCE_32_BIT = 0x01
SOUNDING_SEQUENCE_96_BIT = 0x02
@@ -782,7 +770,7 @@ class RttType(OpenIntEnum):
RANDOM_SEQUENCE_128_BIT = 0x06
class CsSnr(OpenIntEnum):
class CsSnr(utils.OpenIntEnum):
SNR_18_DB = 0x00
SNR_21_DB = 0x01
SNR_24_DB = 0x02
@@ -791,6 +779,40 @@ class CsSnr(OpenIntEnum):
NOT_APPLIED = 0xFF
class CsDoneStatus(utils.OpenIntEnum):
ALL_RESULTS_COMPLETED = 0x00
PARTIAL = 0x01
ABORTED = 0x0F
class CsProcedureAbortReason(utils.OpenIntEnum):
NO_ABORT = 0x00
LOCAL_HOST_OR_REMOTE_REQUEST = 0x01
CHANNEL_MAP_UPDATE_INSTANT_PASSED = 0x02
UNSPECIFIED = 0x0F
class CsSubeventAbortReason(utils.OpenIntEnum):
NO_ABORT = 0x00
LOCAL_HOST_OR_REMOTE_REQUEST = 0x01
NO_CS_SYNC_RECEIVED = 0x02
SCHEDULING_CONFLICT_OR_LIMITED_RESOURCES = 0x03
UNSPECIFIED = 0x0F
class Role(enum.IntEnum):
CENTRAL = 0
PERIPHERAL = 1
# For Backward Compatibility.
HCI_CENTRAL_ROLE = Role.CENTRAL
HCI_PERIPHERAL_ROLE = Role.PERIPHERAL
HCI_LE_1M_PHY = Phy.LE_1M
HCI_LE_2M_PHY = Phy.LE_2M
HCI_LE_CODED_PHY = Phy.LE_CODED
# Connection Parameters
HCI_CONNECTION_INTERVAL_MS_PER_UNIT = 1.25
HCI_CONNECTION_LATENCY_MS_PER_UNIT = 1.25
@@ -868,10 +890,15 @@ HCI_LINK_TYPE_NAMES = {
}
# Address types
HCI_PUBLIC_DEVICE_ADDRESS_TYPE = 0x00
HCI_RANDOM_DEVICE_ADDRESS_TYPE = 0x01
HCI_PUBLIC_IDENTITY_ADDRESS_TYPE = 0x02
HCI_RANDOM_IDENTITY_ADDRESS_TYPE = 0x03
class AddressType(utils.OpenIntEnum):
PUBLIC_DEVICE = 0x00
RANDOM_DEVICE = 0x01
PUBLIC_IDENTITY = 0x02
RANDOM_IDENTITY = 0x03
# (Directed Only) Address is RPA, but controller cannot resolve.
UNABLE_TO_RESOLVE = 0xFE
# (Extended Only) No address.
ANONYMOUS = 0xFF
# Supported Commands Masks
# See Bluetooth spec @ 6.27 SUPPORTED COMMANDS
@@ -1212,7 +1239,7 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
# LE Supported Features
# See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
class LeFeature(OpenIntEnum):
class LeFeature(utils.OpenIntEnum):
LE_ENCRYPTION = 0
CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1
EXTENDED_REJECT_INDICATION = 2
@@ -1510,7 +1537,7 @@ RTT_TYPE_SPEC = {'size': 1, 'mapper': lambda x: RttType(x).name}
CS_SNR_SPEC = {'size': 1, 'mapper': lambda x: CsSnr(x).name}
class CodecID(OpenIntEnum):
class CodecID(utils.OpenIntEnum):
# fmt: off
U_LOG = 0x00
A_LOG = 0x01
@@ -1561,8 +1588,8 @@ class HCI_Constant:
return HCI_ERROR_NAMES.get(status, f'0x{status:02X}')
@staticmethod
def role_name(role):
return HCI_ROLE_NAMES.get(role, str(role))
def role_name(role: int) -> str:
return Role(role).name
@staticmethod
def le_phy_name(phy):
@@ -1928,17 +1955,10 @@ class Address:
address[0] is the LSB of the address, address[5] is the MSB.
'''
PUBLIC_DEVICE_ADDRESS = 0x00
RANDOM_DEVICE_ADDRESS = 0x01
PUBLIC_IDENTITY_ADDRESS = 0x02
RANDOM_IDENTITY_ADDRESS = 0x03
ADDRESS_TYPE_NAMES = {
PUBLIC_DEVICE_ADDRESS: 'PUBLIC_DEVICE_ADDRESS',
RANDOM_DEVICE_ADDRESS: 'RANDOM_DEVICE_ADDRESS',
PUBLIC_IDENTITY_ADDRESS: 'PUBLIC_IDENTITY_ADDRESS',
RANDOM_IDENTITY_ADDRESS: 'RANDOM_IDENTITY_ADDRESS',
}
PUBLIC_DEVICE_ADDRESS = AddressType.PUBLIC_DEVICE
RANDOM_DEVICE_ADDRESS = AddressType.RANDOM_DEVICE
PUBLIC_IDENTITY_ADDRESS = AddressType.PUBLIC_IDENTITY
RANDOM_IDENTITY_ADDRESS = AddressType.RANDOM_IDENTITY
# Type declarations
NIL: Address
@@ -1948,40 +1968,44 @@ class Address:
# pylint: disable-next=unnecessary-lambda
ADDRESS_TYPE_SPEC = {'size': 1, 'mapper': lambda x: Address.address_type_name(x)}
@staticmethod
def address_type_name(address_type):
return name_or_number(Address.ADDRESS_TYPE_NAMES, address_type)
@classmethod
def address_type_name(cls: type[Self], address_type: int) -> str:
return AddressType(address_type).name
@staticmethod
def from_string_for_transport(string, transport):
if transport == BT_BR_EDR_TRANSPORT:
@classmethod
def from_string_for_transport(
cls: type[Self], string: str, transport: PhysicalTransport
) -> Self:
if transport == PhysicalTransport.BR_EDR:
address_type = Address.PUBLIC_DEVICE_ADDRESS
else:
address_type = Address.RANDOM_DEVICE_ADDRESS
return Address(string, address_type)
return cls(string, address_type)
@staticmethod
def parse_address(data, offset):
@classmethod
def parse_address(cls: type[Self], data: bytes, offset: int) -> tuple[int, Self]:
# Fix the type to a default value. This is used for parsing type-less Classic
# addresses
return Address.parse_address_with_type(
data, offset, Address.PUBLIC_DEVICE_ADDRESS
)
return cls.parse_address_with_type(data, offset, Address.PUBLIC_DEVICE_ADDRESS)
@staticmethod
def parse_random_address(data, offset):
return Address.parse_address_with_type(
data, offset, Address.RANDOM_DEVICE_ADDRESS
)
@classmethod
def parse_random_address(
cls: type[Self], data: bytes, offset: int
) -> tuple[int, Self]:
return cls.parse_address_with_type(data, offset, Address.RANDOM_DEVICE_ADDRESS)
@staticmethod
def parse_address_with_type(data, offset, address_type):
return offset + 6, Address(data[offset : offset + 6], address_type)
@classmethod
def parse_address_with_type(
cls: type[Self], data: bytes, offset: int, address_type: AddressType
) -> tuple[int, Self]:
return offset + 6, cls(data[offset : offset + 6], address_type)
@staticmethod
def parse_address_preceded_by_type(data, offset):
address_type = data[offset - 1]
return Address.parse_address_with_type(data, offset, address_type)
@classmethod
def parse_address_preceded_by_type(
cls: type[Self], data: bytes, offset: int
) -> tuple[int, Self]:
address_type = AddressType(data[offset - 1])
return cls.parse_address_with_type(data, offset, address_type)
@classmethod
def generate_static_address(cls) -> Address:
@@ -2021,8 +2045,10 @@ class Address:
)
def __init__(
self, address: Union[bytes, str], address_type: int = RANDOM_DEVICE_ADDRESS
):
self,
address: Union[bytes, str],
address_type: AddressType = RANDOM_DEVICE_ADDRESS,
) -> None:
'''
Initialize an instance. `address` may be a byte array in little-endian
format, or a hex string in big-endian format (with optional ':'
@@ -3338,6 +3364,20 @@ class HCI_Set_Event_Mask_Page_2_Command(HCI_Command):
See Bluetooth spec @ 7.3.69 Set Event Mask Page 2 Command
'''
@staticmethod
def mask(event_codes: Iterable[int]) -> bytes:
'''
Compute the event mask value for a list of events.
'''
# NOTE: this implementation takes advantage of the fact that as of version 6.0
# of the core specification, the bit number for each event code is equal to 64
# less than the event code.
# If future versions of the specification deviate from that, a different
# implementation would be needed.
return sum((1 << event_code - 64) for event_code in event_codes).to_bytes(
8, 'little'
)
# -----------------------------------------------------------------------------
@HCI_Command.command(
@@ -4857,6 +4897,76 @@ class HCI_LE_Periodic_Advertising_Sync_Transfer_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[('connection_handle', 2), ('service_data', 2), ('advertising_handle', 1)],
return_parameters_fields=[
('status', STATUS_SPEC),
('connection_handle', 2),
],
)
class HCI_LE_Periodic_Advertising_Set_Info_Transfer_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.90 LE Periodic Advertising Set Info Transfer Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('connection_handle', 2),
('mode', 1),
('skip', 2),
('sync_timeout', 2),
(
'cte_type',
{
'size': 1,
'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.CteType(
x
).name,
},
),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('connection_handle', 2),
],
)
class HCI_LE_Set_Periodic_Advertising_Sync_Transfer_Parameters_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.91 LE Set Periodic Advertising Sync Transfer Parameters command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('mode', 1),
('skip', 2),
('sync_timeout', 2),
(
'cte_type',
{
'size': 1,
'mapper': lambda x: HCI_LE_Periodic_Advertising_Report_Event.CteType(
x
).name,
},
),
],
return_parameters_fields=[
('status', STATUS_SPEC),
],
)
class HCI_LE_Set_Default_Periodic_Advertising_Sync_Transfer_Parameters_Command(
HCI_Command
):
'''
See Bluetooth spec @ 7.8.92 LE Set Default Periodic Advertising Sync Transfer Parameters command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
@@ -5274,11 +5384,11 @@ class HCI_LE_CS_Create_Config_Command(HCI_Command):
See Bluetooth spec @ 7.8.137 LE CS Create Config command
'''
class ChannelSelectionType(OpenIntEnum):
class ChannelSelectionType(utils.OpenIntEnum):
ALGO_3B = 0
ALGO_3C = 1
class Ch3cShape(OpenIntEnum):
class Ch3cShape(utils.OpenIntEnum):
HAT = 0x00
X = 0x01
@@ -5729,12 +5839,18 @@ class HCI_LE_Advertising_Report_Event(HCI_LE_Meta_Event):
return HCI_LE_Advertising_Report_Event.event_type_name(self.event_type)
def to_string(self, indentation='', _=None):
def data_to_str(data):
try:
return data.hex() + ': ' + str(AdvertisingData.from_bytes(data))
except Exception:
return data.hex()
return super().to_string(
indentation,
{
'event_type': HCI_LE_Advertising_Report_Event.event_type_name,
'address_type': Address.address_type_name,
'data': lambda x: str(AdvertisingData.from_bytes(x)),
'data': data_to_str,
},
)
@@ -5869,6 +5985,33 @@ class HCI_LE_Enhanced_Connection_Complete_Event(HCI_LE_Meta_Event):
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
(
'role',
{'size': 1, 'mapper': lambda x: 'CENTRAL' if x == 0 else 'PERIPHERAL'},
),
('peer_address_type', Address.ADDRESS_TYPE_SPEC),
('peer_address', Address.parse_address_preceded_by_type),
('local_resolvable_private_address', Address.parse_random_address),
('peer_resolvable_private_address', Address.parse_random_address),
('connection_interval', 2),
('peripheral_latency', 2),
('supervision_timeout', 2),
('central_clock_accuracy', 1),
('advertising_handle', 1),
('sync_handle', 2),
]
)
class HCI_LE_Enhanced_Connection_Complete_V2_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.10 LE Enhanced Connection Complete Event
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
@@ -5959,12 +6102,18 @@ class HCI_LE_Extended_Advertising_Report_Event(HCI_LE_Meta_Event):
def to_string(self, indentation='', _=None):
# pylint: disable=line-too-long
def data_to_str(data):
try:
return data.hex() + ': ' + str(AdvertisingData.from_bytes(data))
except Exception:
return data.hex()
return super().to_string(
indentation,
{
'event_type': HCI_LE_Extended_Advertising_Report_Event.event_type_string,
'address_type': Address.address_type_name,
'data': lambda x: str(AdvertisingData.from_bytes(x)),
'data': data_to_str,
},
)
@@ -6107,13 +6256,13 @@ class HCI_LE_Periodic_Advertising_Report_Event(HCI_LE_Meta_Event):
TX_POWER_INFORMATION_NOT_AVAILABLE = 0x7F
RSSI_NOT_AVAILABLE = 0x7F
class CteType(OpenIntEnum):
class CteType(utils.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):
class DataStatus(utils.OpenIntEnum):
DATA_COMPLETE = 0x00
DATA_INCOMPLETE_MORE_TO_COME = 0x01
DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME = 0x02
@@ -6210,6 +6359,31 @@ class HCI_LE_Periodic_Advertising_Sync_Transfer_Received_Event(HCI_LE_Meta_Event
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
('service_data', 2),
('sync_handle', 2),
('advertising_sid', 1),
('advertiser_address_type', Address.ADDRESS_TYPE_SPEC),
('advertiser_address', Address.parse_address_preceded_by_type),
('advertiser_phy', 1),
('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_Transfer_Received_V2_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.24 LE Periodic Advertising Sync Transfer Received Event
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
@@ -6469,7 +6643,7 @@ class HCI_LE_CS_Config_Complete_Event(HCI_LE_Meta_Event):
See Bluetooth spec @ 7.7.65.42 LE CS Config Complete event
'''
class Action(OpenIntEnum):
class Action(utils.OpenIntEnum):
REMOVED = 0
CREATED = 1
@@ -6506,7 +6680,7 @@ class HCI_LE_CS_Config_Complete_Event(HCI_LE_Meta_Event):
('config_id', 1),
('state', 1),
('tone_antenna_config_selection', 1),
('selected_tx_power', 1),
('selected_tx_power', -1),
('subevent_len', 3),
('subevents_per_event', 1),
('subevent_interval', 2),
@@ -6521,7 +6695,7 @@ class HCI_LE_CS_Procedure_Enable_Complete_Event(HCI_LE_Meta_Event):
See Bluetooth spec @ 7.7.65.43 LE CS Procedure Enable Complete event
'''
class State(OpenIntEnum):
class State(utils.OpenIntEnum):
DISABLED = 0
ENABLED = 1
@@ -6548,7 +6722,7 @@ class HCI_LE_CS_Procedure_Enable_Complete_Event(HCI_LE_Meta_Event):
('start_acl_conn_event_counter', 2),
('procedure_counter', 2),
('frequency_compensation', 2),
('reference_power_level', 1),
('reference_power_level', -1),
('procedure_done_status', 1),
('subevent_done_status', 1),
('abort_reason', 1),
@@ -6565,7 +6739,7 @@ class HCI_LE_CS_Subevent_Result_Event(HCI_LE_Meta_Event):
See Bluetooth spec @ 7.7.65.44 LE CS Subevent Result event
'''
status: int
connection_handle: int
config_id: int
start_acl_conn_event_counter: int
procedure_counter: int
@@ -6601,7 +6775,7 @@ class HCI_LE_CS_Subevent_Result_Continue_Event(HCI_LE_Meta_Event):
See Bluetooth spec @ 7.7.65.45 LE CS Subevent Result Continue event
'''
status: int
connection_handle: int
config_id: int
procedure_done_status: int
subevent_done_status: int
@@ -6817,6 +6991,30 @@ class HCI_Encryption_Change_Event(HCI_Event):
)
# -----------------------------------------------------------------------------
@HCI_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
(
'encryption_enabled',
{
'size': 1,
# pylint: disable-next=unnecessary-lambda
'mapper': lambda x: HCI_Encryption_Change_Event.encryption_enabled_name(
x
),
},
),
('encryption_key_size', 1),
]
)
class HCI_Encryption_Change_V2_Event(HCI_Event):
'''
See Bluetooth spec @ 7.7.8 Encryption Change Event
'''
# -----------------------------------------------------------------------------
@HCI_Event.event(
[('status', STATUS_SPEC), ('connection_handle', 2), ('lmp_features', 8)]
@@ -6863,7 +7061,7 @@ class HCI_QOS_Setup_Complete_Event(HCI_Event):
See Bluetooth spec @ 7.7.13 QoS Setup Complete Event
'''
class ServiceType(OpenIntEnum):
class ServiceType(utils.OpenIntEnum):
NO_TRAFFIC_AVAILABLE = 0x00
BEST_EFFORT_AVAILABLE = 0x01
GUARANTEED_AVAILABLE = 0x02

View File

@@ -24,7 +24,6 @@ import asyncio
import dataclasses
import enum
import traceback
import pyee
import re
from typing import (
Dict,
@@ -45,6 +44,7 @@ from bumble import at
from bumble import device
from bumble import rfcomm
from bumble import sdp
from bumble import utils
from bumble.colors import color
from bumble.core import (
ProtocolError,
@@ -690,7 +690,7 @@ class HfIndicatorState:
current_status: int = 0
class HfProtocol(pyee.EventEmitter):
class HfProtocol(utils.EventEmitter):
"""
Implementation for the Hands-Free side of the Hands-Free profile.
@@ -720,6 +720,14 @@ class HfProtocol(pyee.EventEmitter):
vrec: VoiceRecognitionState
"""
EVENT_CODEC_NEGOTIATION = "codec_negotiation"
EVENT_AG_INDICATOR = "ag_indicator"
EVENT_SPEAKER_VOLUME = "speaker_volume"
EVENT_MICROPHONE_VOLUME = "microphone_volume"
EVENT_RING = "ring"
EVENT_CLI_NOTIFICATION = "cli_notification"
EVENT_VOICE_RECOGNITION = "voice_recognition"
class HfLoopTermination(HfpProtocolError):
"""Termination signal for run() loop."""
@@ -777,7 +785,8 @@ class HfProtocol(pyee.EventEmitter):
self.dlc.sink = self._read_at
# Stop the run() loop when L2CAP is closed.
self.dlc.multiplexer.l2cap_channel.on(
'close', lambda: self.unsolicited_queue.put_nowait(None)
self.dlc.multiplexer.l2cap_channel.EVENT_CLOSE,
lambda: self.unsolicited_queue.put_nowait(None),
)
def supports_hf_feature(self, feature: HfFeature) -> bool:
@@ -1034,7 +1043,7 @@ class HfProtocol(pyee.EventEmitter):
# ID. The HF shall be ready to accept the synchronous connection
# establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
self.active_codec = AudioCodec(codec_id)
self.emit('codec_negotiation', self.active_codec)
self.emit(self.EVENT_CODEC_NEGOTIATION, self.active_codec)
logger.info("codec connection setup completed")
@@ -1095,7 +1104,7 @@ class HfProtocol(pyee.EventEmitter):
# CIEV is in 1-index, while ag_indicators is in 0-index.
ag_indicator = self.ag_indicators[index - 1]
ag_indicator.current_status = value
self.emit('ag_indicator', ag_indicator)
self.emit(self.EVENT_AG_INDICATOR, ag_indicator)
logger.info(f"AG indicator updated: {ag_indicator.indicator}, {value}")
async def handle_unsolicited(self):
@@ -1110,19 +1119,21 @@ class HfProtocol(pyee.EventEmitter):
int(result.parameters[0]), int(result.parameters[1])
)
elif result.code == "+VGS":
self.emit('speaker_volume', int(result.parameters[0]))
self.emit(self.EVENT_SPEAKER_VOLUME, int(result.parameters[0]))
elif result.code == "+VGM":
self.emit('microphone_volume', int(result.parameters[0]))
self.emit(self.EVENT_MICROPHONE_VOLUME, int(result.parameters[0]))
elif result.code == "RING":
self.emit('ring')
self.emit(self.EVENT_RING)
elif result.code == "+CLIP":
self.emit(
'cli_notification', CallLineIdentification.parse_from(result.parameters)
self.EVENT_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]))
self.EVENT_VOICE_RECOGNITION,
VoiceRecognitionState(int(result.parameters[0])),
)
else:
logging.info(f"unhandled unsolicited response {result.code}")
@@ -1146,7 +1157,7 @@ class HfProtocol(pyee.EventEmitter):
logger.error(traceback.format_exc())
class AgProtocol(pyee.EventEmitter):
class AgProtocol(utils.EventEmitter):
"""
Implementation for the Audio-Gateway side of the Hands-Free profile.
@@ -1179,6 +1190,19 @@ class AgProtocol(pyee.EventEmitter):
volume: Int
"""
EVENT_SLC_COMPLETE = "slc_complete"
EVENT_SUPPORTED_AUDIO_CODECS = "supported_audio_codecs"
EVENT_CODEC_NEGOTIATION = "codec_negotiation"
EVENT_VOICE_RECOGNITION = "voice_recognition"
EVENT_CALL_HOLD = "call_hold"
EVENT_HF_INDICATOR = "hf_indicator"
EVENT_CODEC_CONNECTION_REQUEST = "codec_connection_request"
EVENT_ANSWER = "answer"
EVENT_DIAL = "dial"
EVENT_HANG_UP = "hang_up"
EVENT_SPEAKER_VOLUME = "speaker_volume"
EVENT_MICROPHONE_VOLUME = "microphone_volume"
supported_hf_features: int
supported_hf_indicators: Set[HfIndicator]
supported_audio_codecs: List[AudioCodec]
@@ -1371,7 +1395,7 @@ class AgProtocol(pyee.EventEmitter):
def _check_remained_slc_commands(self) -> None:
if not self._remained_slc_setup_features:
self.emit('slc_complete')
self.emit(self.EVENT_SLC_COMPLETE)
def _on_brsf(self, hf_features: bytes) -> None:
self.supported_hf_features = int(hf_features)
@@ -1390,17 +1414,17 @@ class AgProtocol(pyee.EventEmitter):
def _on_bac(self, *args) -> None:
self.supported_audio_codecs = [AudioCodec(int(value)) for value in args]
self.emit('supported_audio_codecs', self.supported_audio_codecs)
self.emit(self.EVENT_SUPPORTED_AUDIO_CODECS, self.supported_audio_codecs)
self.send_ok()
def _on_bcs(self, codec: bytes) -> None:
self.active_codec = AudioCodec(int(codec))
self.send_ok()
self.emit('codec_negotiation', self.active_codec)
self.emit(self.EVENT_CODEC_NEGOTIATION, self.active_codec)
def _on_bvra(self, vrec: bytes) -> None:
self.send_ok()
self.emit('voice_recognition', VoiceRecognitionState(int(vrec)))
self.emit(self.EVENT_VOICE_RECOGNITION, VoiceRecognitionState(int(vrec)))
def _on_chld(self, operation_code: bytes) -> None:
call_index: Optional[int] = None
@@ -1427,7 +1451,7 @@ class AgProtocol(pyee.EventEmitter):
# 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)
self.emit(self.EVENT_CALL_HOLD, operation, call_index)
def _on_chld_test(self) -> None:
if not self.supports_ag_feature(AgFeature.THREE_WAY_CALLING):
@@ -1553,7 +1577,7 @@ class AgProtocol(pyee.EventEmitter):
return
self.hf_indicators[index].current_status = int(value_bytes)
self.emit('hf_indicator', self.hf_indicators[index])
self.emit(self.EVENT_HF_INDICATOR, self.hf_indicators[index])
self.send_ok()
def _on_bia(self, *args) -> None:
@@ -1562,21 +1586,21 @@ class AgProtocol(pyee.EventEmitter):
self.send_ok()
def _on_bcc(self) -> None:
self.emit('codec_connection_request')
self.emit(self.EVENT_CODEC_CONNECTION_REQUEST)
self.send_ok()
def _on_a(self) -> None:
"""ATA handler."""
self.emit('answer')
self.emit(self.EVENT_ANSWER)
self.send_ok()
def _on_d(self, number: bytes) -> None:
"""ATD handler."""
self.emit('dial', number.decode())
self.emit(self.EVENT_DIAL, number.decode())
self.send_ok()
def _on_chup(self) -> None:
self.emit('hang_up')
self.emit(self.EVENT_HANG_UP)
self.send_ok()
def _on_clcc(self) -> None:
@@ -1602,11 +1626,11 @@ class AgProtocol(pyee.EventEmitter):
self.send_ok()
def _on_vgs(self, level: bytes) -> None:
self.emit('speaker_volume', int(level))
self.emit(self.EVENT_SPEAKER_VOLUME, int(level))
self.send_ok()
def _on_vgm(self, level: bytes) -> None:
self.emit('microphone_volume', int(level))
self.emit(self.EVENT_MICROPHONE_VOLUME, int(level))
self.send_ok()

View File

@@ -22,11 +22,12 @@ import enum
import struct
from abc import ABC, abstractmethod
from pyee import EventEmitter
from typing import Optional, Callable
from typing_extensions import override
from bumble import l2cap, device
from bumble import l2cap
from bumble import device
from bumble import utils
from bumble.core import InvalidStateError, ProtocolError
from bumble.hci import Address
@@ -195,11 +196,18 @@ class SendHandshakeMessage(Message):
# -----------------------------------------------------------------------------
class HID(ABC, EventEmitter):
class HID(ABC, utils.EventEmitter):
l2cap_ctrl_channel: Optional[l2cap.ClassicChannel] = None
l2cap_intr_channel: Optional[l2cap.ClassicChannel] = None
connection: Optional[device.Connection] = None
EVENT_INTERRUPT_DATA = "interrupt_data"
EVENT_CONTROL_DATA = "control_data"
EVENT_SUSPEND = "suspend"
EVENT_EXIT_SUSPEND = "exit_suspend"
EVENT_VIRTUAL_CABLE_UNPLUG = "virtual_cable_unplug"
EVENT_HANDSHAKE = "handshake"
class Role(enum.IntEnum):
HOST = 0x00
DEVICE = 0x01
@@ -214,7 +222,7 @@ class HID(ABC, EventEmitter):
device.register_l2cap_server(HID_CONTROL_PSM, self.on_l2cap_connection)
device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_l2cap_connection)
device.on('connection', self.on_device_connection)
device.on(device.EVENT_CONNECTION, self.on_device_connection)
async def connect_control_channel(self) -> None:
# Create a new L2CAP connection - control channel
@@ -257,15 +265,20 @@ class HID(ABC, EventEmitter):
def on_device_connection(self, connection: device.Connection) -> None:
self.connection = connection
self.remote_device_bd_address = connection.peer_address
connection.on('disconnection', self.on_device_disconnection)
connection.on(connection.EVENT_DISCONNECTION, self.on_device_disconnection)
def on_device_disconnection(self, reason: int) -> None:
self.connection = None
def on_l2cap_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'+++ New L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
l2cap_channel.on('close', lambda: self.on_l2cap_channel_close(l2cap_channel))
l2cap_channel.on(
l2cap_channel.EVENT_OPEN, lambda: self.on_l2cap_channel_open(l2cap_channel)
)
l2cap_channel.on(
l2cap_channel.EVENT_CLOSE,
lambda: self.on_l2cap_channel_close(l2cap_channel),
)
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
if l2cap_channel.psm == HID_CONTROL_PSM:
@@ -289,7 +302,7 @@ class HID(ABC, EventEmitter):
def on_intr_pdu(self, pdu: bytes) -> None:
logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
self.emit("interrupt_data", pdu)
self.emit(self.EVENT_INTERRUPT_DATA, pdu)
def send_pdu_on_ctrl(self, msg: bytes) -> None:
assert self.l2cap_ctrl_channel
@@ -362,17 +375,17 @@ class Device(HID):
self.handle_set_protocol(pdu)
elif message_type == Message.MessageType.DATA:
logger.debug('<<< HID CONTROL DATA')
self.emit('control_data', pdu)
self.emit(self.EVENT_CONTROL_DATA, pdu)
elif message_type == Message.MessageType.CONTROL:
if param == Message.ControlCommand.SUSPEND:
logger.debug('<<< HID SUSPEND')
self.emit('suspend')
self.emit(self.EVENT_SUSPEND)
elif param == Message.ControlCommand.EXIT_SUSPEND:
logger.debug('<<< HID EXIT SUSPEND')
self.emit('exit_suspend')
self.emit(self.EVENT_EXIT_SUSPEND)
elif param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG:
logger.debug('<<< HID VIRTUAL CABLE UNPLUG')
self.emit('virtual_cable_unplug')
self.emit(self.EVENT_VIRTUAL_CABLE_UNPLUG)
else:
logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
else:
@@ -537,14 +550,14 @@ class Host(HID):
message_type = pdu[0] >> 4
if message_type == Message.MessageType.HANDSHAKE:
logger.debug(f'<<< HID HANDSHAKE: {Message.Handshake(param).name}')
self.emit('handshake', Message.Handshake(param))
self.emit(self.EVENT_HANDSHAKE, Message.Handshake(param))
elif message_type == Message.MessageType.DATA:
logger.debug('<<< HID CONTROL DATA')
self.emit('control_data', pdu)
self.emit(self.EVENT_CONTROL_DATA, pdu)
elif message_type == Message.MessageType.CONTROL:
if param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG:
logger.debug('<<< HID VIRTUAL CABLE UNPLUG')
self.emit('virtual_cable_unplug')
self.emit(self.EVENT_VIRTUAL_CABLE_UNPLUG)
else:
logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
else:

View File

@@ -34,7 +34,6 @@ from typing import (
TYPE_CHECKING,
)
import pyee
from bumble.colors import color
from bumble.l2cap import L2CAP_PDU
@@ -42,16 +41,16 @@ from bumble.snoop import Snooper
from bumble import drivers
from bumble import hci
from bumble.core import (
BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT,
PhysicalTransport,
PhysicalTransport,
ConnectionPHY,
ConnectionParameters,
)
from bumble.utils import AbortableEventEmitter
from bumble import utils
from bumble.transport.common import TransportLostError
if TYPE_CHECKING:
from .transport.common import TransportSink, TransportSource
from bumble.transport.common import TransportSink, TransportSource
# -----------------------------------------------------------------------------
@@ -61,7 +60,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
class DataPacketQueue(pyee.EventEmitter):
class DataPacketQueue(utils.EventEmitter):
"""
Flow-control queue for host->controller data packets (ACL, ISO).
@@ -186,7 +185,11 @@ class DataPacketQueue(pyee.EventEmitter):
# -----------------------------------------------------------------------------
class Connection:
def __init__(
self, host: Host, handle: int, peer_address: hci.Address, transport: int
self,
host: Host,
handle: int,
peer_address: hci.Address,
transport: PhysicalTransport,
):
self.host = host
self.handle = handle
@@ -195,7 +198,7 @@ class Connection:
self.transport = transport
acl_packet_queue: Optional[DataPacketQueue] = (
host.le_acl_packet_queue
if transport == BT_LE_TRANSPORT
if transport == PhysicalTransport.LE
else host.acl_packet_queue
)
assert acl_packet_queue
@@ -230,12 +233,12 @@ class IsoLink:
# -----------------------------------------------------------------------------
class Host(AbortableEventEmitter):
class Host(utils.EventEmitter):
connections: Dict[int, Connection]
cis_links: Dict[int, IsoLink]
bis_links: Dict[int, IsoLink]
sco_links: Dict[int, ScoLink]
bigs: dict[int, set[int]] = {} # BIG Handle to BIS Handles
bigs: dict[int, set[int]]
acl_packet_queue: Optional[DataPacketQueue] = None
le_acl_packet_queue: Optional[DataPacketQueue] = None
iso_packet_queue: Optional[DataPacketQueue] = None
@@ -259,6 +262,7 @@ class Host(AbortableEventEmitter):
self.cis_links = {} # CIS links, by connection handle
self.bis_links = {} # BIS links, by connection handle
self.sco_links = {} # SCO links, by connection handle
self.bigs = {} # BIG Handle to BIS Handles
self.pending_command = None
self.pending_response: Optional[asyncio.Future[Any]] = None
self.number_of_supported_advertising_sets = 0
@@ -431,6 +435,14 @@ class Host(AbortableEventEmitter):
)
)
)
if self.supports_command(hci.HCI_SET_EVENT_MASK_PAGE_2_COMMAND):
await self.send_command(
hci.HCI_Set_Event_Mask_Page_2_Command(
event_mask_page_2=hci.HCI_Set_Event_Mask_Page_2_Command.mask(
[hci.HCI_ENCRYPTION_CHANGE_V2_EVENT]
)
)
)
if (
self.local_version is not None
@@ -452,6 +464,7 @@ class Host(AbortableEventEmitter):
hci.HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMPLETE_EVENT,
hci.HCI_LE_GENERATE_DHKEY_COMPLETE_EVENT,
hci.HCI_LE_ENHANCED_CONNECTION_COMPLETE_EVENT,
hci.HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT,
hci.HCI_LE_DIRECTED_ADVERTISING_REPORT_EVENT,
hci.HCI_LE_PHY_UPDATE_COMPLETE_EVENT,
hci.HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT,
@@ -476,6 +489,12 @@ class Host(AbortableEventEmitter):
hci.HCI_LE_TRANSMIT_POWER_REPORTING_EVENT,
hci.HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT,
hci.HCI_LE_SUBRATE_CHANGE_EVENT,
hci.HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMPLETE_EVENT,
hci.HCI_LE_CS_PROCEDURE_ENABLE_COMPLETE_EVENT,
hci.HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT,
hci.HCI_LE_CS_CONFIG_COMPLETE_EVENT,
hci.HCI_LE_CS_SUBEVENT_RESULT_EVENT,
hci.HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT,
]
)
@@ -955,7 +974,7 @@ class Host(AbortableEventEmitter):
self,
event.connection_handle,
event.peer_address,
BT_LE_TRANSPORT,
PhysicalTransport.LE,
)
self.connections[event.connection_handle] = connection
@@ -968,11 +987,11 @@ class Host(AbortableEventEmitter):
self.emit(
'connection',
event.connection_handle,
BT_LE_TRANSPORT,
PhysicalTransport.LE,
event.peer_address,
getattr(event, 'local_resolvable_private_address', None),
getattr(event, 'peer_resolvable_private_address', None),
event.role,
hci.Role(event.role),
connection_parameters,
)
else:
@@ -980,7 +999,10 @@ class Host(AbortableEventEmitter):
# Notify the listeners
self.emit(
'connection_failure', BT_LE_TRANSPORT, event.peer_address, event.status
'connection_failure',
PhysicalTransport.LE,
event.peer_address,
event.status,
)
def on_hci_le_enhanced_connection_complete_event(self, event):
@@ -1005,7 +1027,7 @@ class Host(AbortableEventEmitter):
self,
event.connection_handle,
event.bd_addr,
BT_BR_EDR_TRANSPORT,
PhysicalTransport.BR_EDR,
)
self.connections[event.connection_handle] = connection
@@ -1013,7 +1035,7 @@ class Host(AbortableEventEmitter):
self.emit(
'connection',
event.connection_handle,
BT_BR_EDR_TRANSPORT,
PhysicalTransport.BR_EDR,
event.bd_addr,
None,
None,
@@ -1025,7 +1047,10 @@ class Host(AbortableEventEmitter):
# Notify the client
self.emit(
'connection_failure', BT_BR_EDR_TRANSPORT, event.bd_addr, event.status
'connection_failure',
PhysicalTransport.BR_EDR,
event.bd_addr,
event.status,
)
def on_hci_disconnection_complete_event(self, event):
@@ -1055,8 +1080,10 @@ class Host(AbortableEventEmitter):
)
# Flush the data queues
self.acl_packet_queue.flush(handle)
self.le_acl_packet_queue.flush(handle)
if self.acl_packet_queue:
self.acl_packet_queue.flush(handle)
if self.le_acl_packet_queue:
self.le_acl_packet_queue.flush(handle)
if self.iso_packet_queue:
self.iso_packet_queue.flush(handle)
else:
@@ -1092,8 +1119,11 @@ class Host(AbortableEventEmitter):
# Notify the client
if event.status == hci.HCI_SUCCESS:
connection_phy = ConnectionPHY(event.tx_phy, event.rx_phy)
self.emit('connection_phy_update', connection.handle, connection_phy)
self.emit(
'connection_phy_update',
connection.handle,
ConnectionPHY(event.tx_phy, event.rx_phy),
)
else:
self.emit('connection_phy_update_failure', connection.handle, event.status)
@@ -1198,6 +1228,32 @@ class Host(AbortableEventEmitter):
self.remove_big(event.big_handle)
self.emit('big_termination', event.reason, event.big_handle)
def on_hci_le_periodic_advertising_sync_transfer_received_event(self, event):
self.emit(
'periodic_advertising_sync_transfer',
event.status,
event.connection_handle,
event.sync_handle,
event.advertising_sid,
event.advertiser_address,
event.advertiser_phy,
event.periodic_advertising_interval,
event.advertiser_clock_accuracy,
)
def on_hci_le_periodic_advertising_sync_transfer_received_v2_event(self, event):
self.emit(
'periodic_advertising_sync_transfer',
event.status,
event.connection_handle,
event.sync_handle,
event.advertising_sid,
event.advertiser_address,
event.advertiser_phy,
event.periodic_advertising_interval,
event.advertiser_clock_accuracy,
)
def on_hci_le_cis_established_event(self, event):
# The remaining parameters are unused for now.
if event.status == hci.HCI_SUCCESS:
@@ -1241,7 +1297,8 @@ class Host(AbortableEventEmitter):
logger.debug('no long term key provider')
long_term_key = None
else:
long_term_key = await self.abort_on(
long_term_key = await utils.cancel_on_event(
self,
'flush',
# pylint: disable-next=not-callable
self.long_term_key_provider(
@@ -1299,7 +1356,7 @@ class Host(AbortableEventEmitter):
f'role change for {event.bd_addr}: '
f'{hci.HCI_Constant.role_name(event.new_role)}'
)
self.emit('role_change', event.bd_addr, event.new_role)
self.emit('role_change', event.bd_addr, hci.Role(event.new_role))
else:
logger.debug(
f'role change for {event.bd_addr} failed: '
@@ -1335,6 +1392,21 @@ class Host(AbortableEventEmitter):
'connection_encryption_change',
event.connection_handle,
event.encryption_enabled,
0,
)
else:
self.emit(
'connection_encryption_failure', event.connection_handle, event.status
)
def on_hci_encryption_change_v2_event(self, event):
# Notify the client
if event.status == hci.HCI_SUCCESS:
self.emit(
'connection_encryption_change',
event.connection_handle,
event.encryption_enabled,
event.encryption_key_size,
)
else:
self.emit(
@@ -1399,7 +1471,8 @@ class Host(AbortableEventEmitter):
logger.debug('no link key provider')
link_key = None
else:
link_key = await self.abort_on(
link_key = await utils.cancel_on_event(
self,
'flush',
# pylint: disable-next=not-callable
self.link_key_provider(event.bd_addr),
@@ -1495,5 +1568,23 @@ class Host(AbortableEventEmitter):
int.from_bytes(event.le_features, 'little'),
)
def on_hci_le_cs_read_remote_supported_capabilities_complete_event(self, event):
self.emit('cs_remote_supported_capabilities', event)
def on_hci_le_cs_security_enable_complete_event(self, event):
self.emit('cs_security', event)
def on_hci_le_cs_config_complete_event(self, event):
self.emit('cs_config', event)
def on_hci_le_cs_procedure_enable_complete_event(self, event):
self.emit('cs_procedure', event)
def on_hci_le_cs_subevent_result_event(self, event):
self.emit('cs_subevent_result', event)
def on_hci_le_cs_subevent_result_continue_event(self, event):
self.emit('cs_subevent_result_continue', event)
def on_hci_vendor_event(self, event):
self.emit('vendor_event', event)

View File

@@ -28,11 +28,11 @@ import json
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type
from typing_extensions import Self
from .colors import color
from .hci import Address
from bumble.colors import color
from bumble.hci import Address
if TYPE_CHECKING:
from .device import Device
from bumble.device import Device
# -----------------------------------------------------------------------------

View File

@@ -23,7 +23,6 @@ import logging
import struct
from collections import deque
from pyee import EventEmitter
from typing import (
Dict,
Type,
@@ -39,19 +38,19 @@ from typing import (
TYPE_CHECKING,
)
from .utils import deprecated
from .colors import color
from .core import (
BT_CENTRAL_ROLE,
from bumble import utils
from bumble.colors import color
from bumble.core import (
InvalidStateError,
InvalidArgumentError,
InvalidPacketError,
OutOfResourcesError,
ProtocolError,
)
from .hci import (
from bumble.hci import (
HCI_LE_Connection_Update_Command,
HCI_Object,
Role,
key_with_value,
name_or_number,
)
@@ -720,7 +719,7 @@ class L2CAP_LE_Flow_Control_Credit(L2CAP_Control_Frame):
# -----------------------------------------------------------------------------
class ClassicChannel(EventEmitter):
class ClassicChannel(utils.EventEmitter):
class State(enum.IntEnum):
# States
CLOSED = 0x00
@@ -745,6 +744,9 @@ class ClassicChannel(EventEmitter):
WAIT_FINAL_RSP = 0x16
WAIT_CONTROL_IND = 0x17
EVENT_OPEN = "open"
EVENT_CLOSE = "close"
connection_result: Optional[asyncio.Future[None]]
disconnection_result: Optional[asyncio.Future[None]]
response: Optional[asyncio.Future[bytes]]
@@ -821,8 +823,8 @@ class ClassicChannel(EventEmitter):
# Wait for the connection to succeed or fail
try:
return await self.connection.abort_on(
'disconnection', self.connection_result
return await utils.cancel_on_event(
self.connection, 'disconnection', self.connection_result
)
finally:
self.connection_result = None
@@ -848,7 +850,7 @@ class ClassicChannel(EventEmitter):
def abort(self) -> None:
if self.state == self.State.OPEN:
self._change_state(self.State.CLOSED)
self.emit('close')
self.emit(self.EVENT_CLOSE)
def send_configure_request(self) -> None:
options = L2CAP_Control_Frame.encode_configuration_options(
@@ -941,7 +943,7 @@ class ClassicChannel(EventEmitter):
if self.connection_result:
self.connection_result.set_result(None)
self.connection_result = None
self.emit('open')
self.emit(self.EVENT_OPEN)
elif self.state == self.State.WAIT_CONFIG_REQ_RSP:
self._change_state(self.State.WAIT_CONFIG_RSP)
@@ -957,7 +959,7 @@ class ClassicChannel(EventEmitter):
if self.connection_result:
self.connection_result.set_result(None)
self.connection_result = None
self.emit('open')
self.emit(self.EVENT_OPEN)
else:
logger.warning(color('invalid state', 'red'))
elif (
@@ -992,7 +994,7 @@ class ClassicChannel(EventEmitter):
)
)
self._change_state(self.State.CLOSED)
self.emit('close')
self.emit(self.EVENT_CLOSE)
self.manager.on_channel_closed(self)
else:
logger.warning(color('invalid state', 'red'))
@@ -1013,7 +1015,7 @@ class ClassicChannel(EventEmitter):
if self.disconnection_result:
self.disconnection_result.set_result(None)
self.disconnection_result = None
self.emit('close')
self.emit(self.EVENT_CLOSE)
self.manager.on_channel_closed(self)
def __str__(self) -> str:
@@ -1026,7 +1028,7 @@ class ClassicChannel(EventEmitter):
# -----------------------------------------------------------------------------
class LeCreditBasedChannel(EventEmitter):
class LeCreditBasedChannel(utils.EventEmitter):
"""
LE Credit-based Connection Oriented Channel
"""
@@ -1048,6 +1050,9 @@ class LeCreditBasedChannel(EventEmitter):
connection: Connection
sink: Optional[Callable[[bytes], Any]]
EVENT_OPEN = "open"
EVENT_CLOSE = "close"
def __init__(
self,
manager: ChannelManager,
@@ -1099,9 +1104,9 @@ class LeCreditBasedChannel(EventEmitter):
self.state = new_state
if new_state == self.State.CONNECTED:
self.emit('open')
self.emit(self.EVENT_OPEN)
elif new_state == self.State.DISCONNECTED:
self.emit('close')
self.emit(self.EVENT_CLOSE)
def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
@@ -1381,7 +1386,9 @@ class LeCreditBasedChannel(EventEmitter):
# -----------------------------------------------------------------------------
class ClassicChannelServer(EventEmitter):
class ClassicChannelServer(utils.EventEmitter):
EVENT_CONNECTION = "connection"
def __init__(
self,
manager: ChannelManager,
@@ -1396,7 +1403,7 @@ class ClassicChannelServer(EventEmitter):
self.mtu = mtu
def on_connection(self, channel: ClassicChannel) -> None:
self.emit('connection', channel)
self.emit(self.EVENT_CONNECTION, channel)
if self.handler:
self.handler(channel)
@@ -1406,7 +1413,9 @@ class ClassicChannelServer(EventEmitter):
# -----------------------------------------------------------------------------
class LeCreditBasedChannelServer(EventEmitter):
class LeCreditBasedChannelServer(utils.EventEmitter):
EVENT_CONNECTION = "connection"
def __init__(
self,
manager: ChannelManager,
@@ -1425,7 +1434,7 @@ class LeCreditBasedChannelServer(EventEmitter):
self.mps = mps
def on_connection(self, channel: LeCreditBasedChannel) -> None:
self.emit('connection', channel)
self.emit(self.EVENT_CONNECTION, channel)
if self.handler:
self.handler(channel)
@@ -1521,6 +1530,9 @@ class ChannelManager:
def next_identifier(self, connection: Connection) -> int:
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
# 0x00 is an invalid ID (BT Core Spec, Vol 3, Part A, Sect 4
if identifier == 0:
identifier = 1
self.identifiers[connection.handle] = identifier
return identifier
@@ -1533,7 +1545,7 @@ class ChannelManager:
if cid in self.fixed_channels:
del self.fixed_channels[cid]
@deprecated("Please use create_classic_server")
@utils.deprecated("Please use create_classic_server")
def register_server(
self,
psm: int,
@@ -1579,7 +1591,7 @@ class ChannelManager:
return self.servers[spec.psm]
@deprecated("Please use create_le_credit_based_server()")
@utils.deprecated("Please use create_le_credit_based_server()")
def register_le_coc_server(
self,
psm: int,
@@ -1908,7 +1920,7 @@ class ChannelManager:
def on_l2cap_connection_parameter_update_request(
self, connection: Connection, cid: int, request
):
if connection.role == BT_CENTRAL_ROLE:
if connection.role == Role.CENTRAL:
self.send_control_frame(
connection,
cid,
@@ -2123,7 +2135,7 @@ class ChannelManager:
if channel.source_cid in connection_channels:
del connection_channels[channel.source_cid]
@deprecated("Please use create_le_credit_based_channel()")
@utils.deprecated("Please use create_le_credit_based_channel()")
async def open_le_coc(
self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
) -> LeCreditBasedChannel:
@@ -2180,7 +2192,7 @@ class ChannelManager:
return channel
@deprecated("Please use create_classic_channel()")
@utils.deprecated("Please use create_classic_channel()")
async def connect(self, connection: Connection, psm: int) -> ClassicChannel:
return await self.create_classic_channel(
connection=connection, spec=ClassicChannelSpec(psm=psm)
@@ -2230,12 +2242,12 @@ class ChannelManager:
class Channel(ClassicChannel):
@deprecated("Please use ClassicChannel")
@utils.deprecated("Please use ClassicChannel")
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
class LeConnectionOrientedChannel(LeCreditBasedChannel):
@deprecated("Please use LeCreditBasedChannel")
@utils.deprecated("Please use LeCreditBasedChannel")
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

View File

@@ -20,14 +20,13 @@ import asyncio
from functools import partial
from bumble.core import (
BT_PERIPHERAL_ROLE,
BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT,
PhysicalTransport,
InvalidStateError,
)
from bumble.colors import color
from bumble.hci import (
Address,
Role,
HCI_SUCCESS,
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
HCI_CONNECTION_TIMEOUT_ERROR,
@@ -116,10 +115,10 @@ class LocalLink:
def send_acl_data(self, sender_controller, destination_address, transport, data):
# Send the data to the first controller with a matching address
if transport == BT_LE_TRANSPORT:
if transport == PhysicalTransport.LE:
destination_controller = self.find_controller(destination_address)
source_address = sender_controller.random_address
elif transport == BT_BR_EDR_TRANSPORT:
elif transport == PhysicalTransport.BR_EDR:
destination_controller = self.find_classic_controller(destination_address)
source_address = sender_controller.public_address
else:
@@ -292,7 +291,7 @@ class LocalLink:
return
async def task():
if responder_role != BT_PERIPHERAL_ROLE:
if responder_role != Role.PERIPHERAL:
initiator_controller.on_classic_role_change(
responder_controller.public_address, int(not (responder_role))
)

View File

@@ -1,4 +1,4 @@
# Copyright 2021-2023 Google LLC
# Copyright 2021-2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,14 +20,14 @@ import enum
from dataclasses import dataclass
from typing import Optional, Tuple
from .hci import (
from bumble.hci import (
Address,
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
HCI_DISPLAY_ONLY_IO_CAPABILITY,
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
HCI_KEYBOARD_ONLY_IO_CAPABILITY,
)
from .smp import (
from bumble.smp import (
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
SMP_KEYBOARD_ONLY_IO_CAPABILITY,
SMP_DISPLAY_ONLY_IO_CAPABILITY,
@@ -41,7 +41,7 @@ from .smp import (
OobLegacyContext,
OobSharedData,
)
from .core import AdvertisingData, LeRole
from bumble.core import AdvertisingData, LeRole
# -----------------------------------------------------------------------------
@@ -76,18 +76,18 @@ class OobData:
return instance
def to_ad(self) -> AdvertisingData:
ad_structures = []
ad_structures: list[tuple[int, bytes]] = []
if self.address is not None:
ad_structures.append(
(AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS, bytes(self.address))
(AdvertisingData.Type.LE_BLUETOOTH_DEVICE_ADDRESS, bytes(self.address))
)
if self.role is not None:
ad_structures.append((AdvertisingData.LE_ROLE, bytes([self.role])))
ad_structures.append((AdvertisingData.Type.LE_ROLE, bytes([self.role])))
if self.shared_data is not None:
ad_structures.extend(self.shared_data.to_ad().ad_structures)
if self.legacy_context is not None:
ad_structures.append(
(AdvertisingData.SECURITY_MANAGER_TK_VALUE, self.legacy_context.tk)
(AdvertisingData.Type.SECURITY_MANAGER_TK_VALUE, self.legacy_context.tk)
)
return AdvertisingData(ad_structures)

View File

@@ -22,11 +22,11 @@ __version__ = "0.0.1"
import grpc
import grpc.aio
from .config import Config
from .device import PandoraDevice
from .host import HostService
from .l2cap import L2CAPService
from .security import SecurityService, SecurityStorageService
from bumble.pandora.config import Config
from bumble.pandora.device import PandoraDevice
from bumble.pandora.host import HostService
from bumble.pandora.l2cap import L2CAPService
from bumble.pandora.security import SecurityService, SecurityStorageService
from pandora.host_grpc_aio import add_HostServicer_to_server
from pandora.l2cap_grpc_aio import add_L2CAPServicer_to_server
from pandora.security_grpc_aio import (

View File

@@ -20,12 +20,11 @@ import grpc.aio
import logging
import struct
from . import utils
from .config import Config
import bumble.utils
from bumble.pandora import utils
from bumble.pandora.config import Config
from bumble.core import (
BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT,
BT_PERIPHERAL_ROLE,
PhysicalTransport,
UUID,
AdvertisingData,
Appearance,
@@ -47,6 +46,8 @@ from bumble.hci import (
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
Address,
Phy,
Role,
OwnAddressType,
)
from google.protobuf import any_pb2 # pytype: disable=pyi-error
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
@@ -114,11 +115,11 @@ SECONDARY_PHY_TO_BUMBLE_PHY_MAP: Dict[SecondaryPhy, Phy] = {
SECONDARY_CODED: Phy.LE_CODED,
}
OWN_ADDRESS_MAP: Dict[host_pb2.OwnAddressType, bumble.hci.OwnAddressType] = {
host_pb2.PUBLIC: bumble.hci.OwnAddressType.PUBLIC,
host_pb2.RANDOM: bumble.hci.OwnAddressType.RANDOM,
host_pb2.RESOLVABLE_OR_PUBLIC: bumble.hci.OwnAddressType.RESOLVABLE_OR_PUBLIC,
host_pb2.RESOLVABLE_OR_RANDOM: bumble.hci.OwnAddressType.RESOLVABLE_OR_RANDOM,
OWN_ADDRESS_MAP: Dict[host_pb2.OwnAddressType, OwnAddressType] = {
host_pb2.PUBLIC: OwnAddressType.PUBLIC,
host_pb2.RANDOM: OwnAddressType.RANDOM,
host_pb2.RESOLVABLE_OR_PUBLIC: OwnAddressType.RESOLVABLE_OR_PUBLIC,
host_pb2.RESOLVABLE_OR_RANDOM: OwnAddressType.RESOLVABLE_OR_RANDOM,
}
@@ -184,7 +185,7 @@ class HostService(HostServicer):
try:
connection = await self.device.connect(
address, transport=BT_BR_EDR_TRANSPORT
address, transport=PhysicalTransport.BR_EDR
)
except ConnectionError as e:
if e.error_code == HCI_PAGE_TIMEOUT_ERROR:
@@ -217,7 +218,7 @@ class HostService(HostServicer):
self.log.debug(f"WaitConnection from {address}...")
connection = self.device.find_connection_by_bd_addr(
address, transport=BT_BR_EDR_TRANSPORT
address, transport=PhysicalTransport.BR_EDR
)
if connection and id(connection) in self.waited_connections:
# this connection was already returned: wait for a new one.
@@ -249,8 +250,8 @@ class HostService(HostServicer):
try:
connection = await self.device.connect(
address,
transport=BT_LE_TRANSPORT,
own_address_type=request.own_address_type,
transport=PhysicalTransport.LE,
own_address_type=OwnAddressType(request.own_address_type),
)
except ConnectionError as e:
if e.error_code == HCI_PAGE_TIMEOUT_ERROR:
@@ -295,12 +296,12 @@ class HostService(HostServicer):
def on_disconnection(_: None) -> None:
disconnection_future.set_result(None)
connection.on('disconnection', on_disconnection)
connection.on(connection.EVENT_DISCONNECTION, on_disconnection)
try:
await disconnection_future
self.log.debug("Disconnected")
finally:
connection.remove_listener('disconnection', on_disconnection) # type: ignore
connection.remove_listener(connection.EVENT_DISCONNECTION, on_disconnection) # type: ignore
return empty_pb2.Empty()
@@ -371,20 +372,18 @@ class HostService(HostServicer):
scan_response_data=scan_response_data,
)
pending_connection: asyncio.Future[bumble.device.Connection] = (
asyncio.get_running_loop().create_future()
)
connections: asyncio.Queue[bumble.device.Connection] = asyncio.Queue()
if request.connectable:
def on_connection(connection: bumble.device.Connection) -> None:
if (
connection.transport == BT_LE_TRANSPORT
and connection.role == BT_PERIPHERAL_ROLE
connection.transport == PhysicalTransport.LE
and connection.role == Role.PERIPHERAL
):
pending_connection.set_result(connection)
connections.put_nowait(connection)
self.device.on('connection', on_connection)
self.device.on(self.device.EVENT_CONNECTION, on_connection)
try:
# Advertise until RPC is canceled
@@ -397,8 +396,7 @@ class HostService(HostServicer):
await asyncio.sleep(1)
continue
connection = await pending_connection
pending_connection = asyncio.get_running_loop().create_future()
connection = await connections.get()
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
yield AdvertiseResponse(connection=Connection(cookie=cookie))
@@ -492,16 +490,18 @@ class HostService(HostServicer):
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
connections: asyncio.Queue[bumble.device.Connection] = asyncio.Queue()
if request.connectable:
def on_connection(connection: bumble.device.Connection) -> None:
if (
connection.transport == BT_LE_TRANSPORT
and connection.role == BT_PERIPHERAL_ROLE
connection.transport == PhysicalTransport.LE
and connection.role == Role.PERIPHERAL
):
pending_connection.set_result(connection)
connections.put_nowait(connection)
self.device.on('connection', on_connection)
self.device.on(self.device.EVENT_CONNECTION, on_connection)
try:
while True:
@@ -510,19 +510,15 @@ class HostService(HostServicer):
await self.device.start_advertising(
target=target,
advertising_type=advertising_type,
own_address_type=request.own_address_type,
own_address_type=OwnAddressType(request.own_address_type),
)
if not request.connectable:
await asyncio.sleep(1)
continue
pending_connection: asyncio.Future[bumble.device.Connection] = (
asyncio.get_running_loop().create_future()
)
self.log.debug('Wait for LE connection...')
connection = await pending_connection
connection = await connections.get()
self.log.debug(
f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
@@ -535,11 +531,13 @@ class HostService(HostServicer):
await asyncio.sleep(1)
finally:
if request.connectable:
self.device.remove_listener('connection', on_connection) # type: ignore
self.device.remove_listener(self.device.EVENT_CONNECTION, on_connection) # type: ignore
try:
self.log.debug('Stop advertising')
await self.device.abort_on('flush', self.device.stop_advertising())
await bumble.utils.cancel_on_event(
self.device, 'flush', self.device.stop_advertising()
)
except:
pass
@@ -559,11 +557,11 @@ class HostService(HostServicer):
scanning_phys = [int(Phy.LE_1M), int(Phy.LE_CODED)]
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
handler = self.device.on('advertisement', scan_queue.put_nowait)
handler = self.device.on(self.device.EVENT_ADVERTISEMENT, scan_queue.put_nowait)
await self.device.start_scanning(
legacy=request.legacy,
active=not request.passive,
own_address_type=request.own_address_type,
own_address_type=OwnAddressType(request.own_address_type),
scan_interval=(
int(request.interval)
if request.interval
@@ -604,10 +602,12 @@ class HostService(HostServicer):
yield sr
finally:
self.device.remove_listener('advertisement', handler) # type: ignore
self.device.remove_listener(self.device.EVENT_ADVERTISEMENT, handler) # type: ignore
try:
self.log.debug('Stop scanning')
await self.device.abort_on('flush', self.device.stop_scanning())
await bumble.utils.cancel_on_event(
self.device, 'flush', self.device.stop_scanning()
)
except:
pass
@@ -621,10 +621,10 @@ class HostService(HostServicer):
Optional[Tuple[Address, int, AdvertisingData, int]]
] = asyncio.Queue()
complete_handler = self.device.on(
'inquiry_complete', lambda: inquiry_queue.put_nowait(None)
self.device.EVENT_INQUIRY_COMPLETE, lambda: inquiry_queue.put_nowait(None)
)
result_handler = self.device.on( # type: ignore
'inquiry_result',
self.device.EVENT_INQUIRY_RESULT,
lambda address, class_of_device, eir_data, rssi: inquiry_queue.put_nowait( # type: ignore
(address, class_of_device, eir_data, rssi) # type: ignore
),
@@ -643,11 +643,13 @@ class HostService(HostServicer):
)
finally:
self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore
self.device.remove_listener('inquiry_result', result_handler) # type: ignore
self.device.remove_listener(self.device.EVENT_INQUIRY_COMPLETE, complete_handler) # type: ignore
self.device.remove_listener(self.device.EVENT_INQUIRY_RESULT, result_handler) # type: ignore
try:
self.log.debug('Stop inquiry')
await self.device.abort_on('flush', self.device.stop_discovery())
await bumble.utils.cancel_on_event(
self.device, 'flush', self.device.stop_discovery()
)
except:
pass

View File

@@ -19,8 +19,8 @@ import logging
from asyncio import Queue as AsyncQueue, Future
from . import utils
from .config import Config
from bumble.pandora import utils
from bumble.pandora.config import Config
from bumble.core import OutOfResourcesError, InvalidArgumentError
from bumble.device import Device
from bumble.l2cap import (
@@ -83,7 +83,7 @@ class L2CAPService(L2CAPServicer):
close_future.set_result(None)
l2cap_channel.sink = on_channel_sdu
l2cap_channel.on('close', on_close)
l2cap_channel.on(l2cap_channel.EVENT_CLOSE, on_close)
return ChannelContext(close_future, sdu_queue)
@@ -151,7 +151,7 @@ class L2CAPService(L2CAPServicer):
spec=spec, handler=on_l2cap_channel
)
else:
l2cap_server.on('connection', on_l2cap_channel)
l2cap_server.on(l2cap_server.EVENT_CONNECTION, on_l2cap_channel)
try:
self.log.debug('Waiting for a channel connection.')

View File

@@ -18,18 +18,16 @@ import contextlib
import grpc
import logging
from . import utils
from .config import Config
from bumble.pandora import utils
from bumble.pandora.config import Config
from bumble import hci
from bumble.core import (
BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT,
BT_PERIPHERAL_ROLE,
PhysicalTransport,
ProtocolError,
)
import bumble.utils
from bumble.device import Connection as BumbleConnection, Device
from bumble.hci import HCI_Error
from bumble.utils import EventWatcher
from bumble.hci import HCI_Error, Role
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
from google.protobuf import any_pb2 # pytype: disable=pyi-error
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
@@ -95,7 +93,7 @@ class PairingDelegate(BasePairingDelegate):
else:
# In BR/EDR, connection may not be complete,
# use address instead
assert self.connection.transport == BT_BR_EDR_TRANSPORT
assert self.connection.transport == PhysicalTransport.BR_EDR
ev.address = bytes(reversed(bytes(self.connection.peer_address)))
return ev
@@ -174,7 +172,7 @@ class PairingDelegate(BasePairingDelegate):
async def display_number(self, number: int, digits: int = 6) -> None:
if (
self.connection.transport == BT_BR_EDR_TRANSPORT
self.connection.transport == PhysicalTransport.BR_EDR
and self.io_capability == BasePairingDelegate.DISPLAY_OUTPUT_ONLY
):
return
@@ -287,7 +285,7 @@ class SecurityService(SecurityServicer):
oneof = request.WhichOneof('level')
level = getattr(request, oneof)
assert {BT_BR_EDR_TRANSPORT: 'classic', BT_LE_TRANSPORT: 'le'}[
assert {PhysicalTransport.BR_EDR: 'classic', PhysicalTransport.LE: 'le'}[
connection.transport
] == oneof
@@ -302,23 +300,23 @@ class SecurityService(SecurityServicer):
security_result = asyncio.get_running_loop().create_future()
with contextlib.closing(EventWatcher()) as watcher:
with contextlib.closing(bumble.utils.EventWatcher()) as watcher:
@watcher.on(connection, 'pairing')
@watcher.on(connection, connection.EVENT_PAIRING)
def on_pairing(*_: Any) -> None:
security_result.set_result('success')
@watcher.on(connection, 'pairing_failure')
@watcher.on(connection, connection.EVENT_PAIRING_FAILURE)
def on_pairing_failure(*_: Any) -> None:
security_result.set_result('pairing_failure')
@watcher.on(connection, 'disconnection')
@watcher.on(connection, connection.EVENT_DISCONNECTION)
def on_disconnection(*_: Any) -> None:
security_result.set_result('connection_died')
if (
connection.transport == BT_LE_TRANSPORT
and connection.role == BT_PERIPHERAL_ROLE
connection.transport == PhysicalTransport.LE
and connection.role == Role.PERIPHERAL
):
connection.request_pairing()
else:
@@ -379,7 +377,7 @@ class SecurityService(SecurityServicer):
assert request.level
level = request.level
assert {BT_BR_EDR_TRANSPORT: 'classic', BT_LE_TRANSPORT: 'le'}[
assert {PhysicalTransport.BR_EDR: 'classic', PhysicalTransport.LE: 'le'}[
connection.transport
] == request.level_variant()
@@ -427,7 +425,7 @@ class SecurityService(SecurityServicer):
self.log.debug('Wait for security: done')
wait_for_security.set_result('success')
elif (
connection.transport == BT_BR_EDR_TRANSPORT
connection.transport == PhysicalTransport.BR_EDR
and self.need_authentication(connection, level)
):
nonlocal authenticate_task
@@ -451,7 +449,7 @@ class SecurityService(SecurityServicer):
'security_request': pair,
}
with contextlib.closing(EventWatcher()) as watcher:
with contextlib.closing(bumble.utils.EventWatcher()) as watcher:
# register event handlers
for event, listener in listeners.items():
watcher.on(connection, event, listener)
@@ -505,12 +503,12 @@ class SecurityService(SecurityServicer):
return BR_LEVEL_REACHED[level](connection)
def need_pairing(self, connection: BumbleConnection, level: int) -> bool:
if connection.transport == BT_LE_TRANSPORT:
if connection.transport == PhysicalTransport.LE:
return level >= LE_LEVEL3 and not connection.authenticated
return False
def need_authentication(self, connection: BumbleConnection, level: int) -> bool:
if connection.transport == BT_LE_TRANSPORT:
if connection.transport == PhysicalTransport.LE:
return False
if level == LEVEL2 and connection.encryption != 0:
return not connection.authenticated
@@ -518,7 +516,7 @@ class SecurityService(SecurityServicer):
def need_encryption(self, connection: BumbleConnection, level: int) -> bool:
# TODO(abel): need to support MITM
if connection.transport == BT_LE_TRANSPORT:
if connection.transport == PhysicalTransport.LE:
return level == LE_LEVEL2 and not connection.encryption
return level >= LEVEL2 and not connection.encryption

View File

@@ -20,11 +20,11 @@ import inspect
import logging
from bumble.device import Device
from bumble.hci import Address
from bumble.hci import Address, AddressType
from google.protobuf.message import Message # pytype: disable=pyi-error
from typing import Any, Dict, Generator, MutableMapping, Optional, Tuple
ADDRESS_TYPES: Dict[str, int] = {
ADDRESS_TYPES: Dict[str, AddressType] = {
"public": Address.PUBLIC_DEVICE_ADDRESS,
"random": Address.RANDOM_DEVICE_ADDRESS,
"public_identity": Address.PUBLIC_IDENTITY_ADDRESS,

View File

@@ -24,16 +24,13 @@ import struct
from dataclasses import dataclass
from typing import Optional
from bumble import gatt
from bumble.device import Connection
from bumble.att import ATT_Error
from bumble.gatt import (
Attribute,
Characteristic,
SerializableCharacteristicAdapter,
PackedCharacteristicAdapter,
TemplateService,
CharacteristicValue,
UTF8CharacteristicAdapter,
GATT_AUDIO_INPUT_CONTROL_SERVICE,
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
@@ -42,8 +39,16 @@ from bumble.gatt import (
GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
)
from bumble.gatt_adapters import (
CharacteristicProxy,
PackedCharacteristicProxyAdapter,
SerializableCharacteristicAdapter,
SerializableCharacteristicProxyAdapter,
UTF8CharacteristicAdapter,
UTF8CharacteristicProxyAdapter,
)
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
from bumble.utils import OpenIntEnum
from bumble import utils
# -----------------------------------------------------------------------------
# Logging
@@ -59,7 +64,7 @@ GAIN_SETTINGS_MIN_VALUE = 0
GAIN_SETTINGS_MAX_VALUE = 255
class ErrorCode(OpenIntEnum):
class ErrorCode(utils.OpenIntEnum):
'''
Cf. 1.6 Application error codes
'''
@@ -71,7 +76,7 @@ class ErrorCode(OpenIntEnum):
GAIN_MODE_CHANGE_NOT_ALLOWED = 0x84
class Mute(OpenIntEnum):
class Mute(utils.OpenIntEnum):
'''
Cf. 2.2.1.2 Mute Field
'''
@@ -81,7 +86,7 @@ class Mute(OpenIntEnum):
DISABLED = 0x02
class GainMode(OpenIntEnum):
class GainMode(utils.OpenIntEnum):
'''
Cf. 2.2.1.3 Gain Mode
'''
@@ -92,7 +97,7 @@ class GainMode(OpenIntEnum):
AUTOMATIC = 0x03
class AudioInputStatus(OpenIntEnum):
class AudioInputStatus(utils.OpenIntEnum):
'''
Cf. 3.4 Audio Input Status
'''
@@ -101,7 +106,7 @@ class AudioInputStatus(OpenIntEnum):
ACTIVE = 0x01
class AudioInputControlPointOpCode(OpenIntEnum):
class AudioInputControlPointOpCode(utils.OpenIntEnum):
'''
Cf. 3.5.1 Audio Input Control Point procedure requirements
'''
@@ -124,7 +129,7 @@ class AudioInputState:
mute: Mute = Mute.NOT_MUTED
gain_mode: GainMode = GainMode.MANUAL
change_counter: int = 0
attribute_value: Optional[CharacteristicValue] = None
attribute: Optional[Attribute] = None
def __bytes__(self) -> bytes:
return bytes(
@@ -151,10 +156,8 @@ class AudioInputState:
self.change_counter = (self.change_counter + 1) % (CHANGE_COUNTER_MAX_VALUE + 1)
async def notify_subscribers_via_connection(self, connection: Connection) -> None:
assert self.attribute_value is not None
await connection.device.notify_subscribers(
attribute=self.attribute_value, value=bytes(self)
)
assert self.attribute is not None
await connection.device.notify_subscribers(attribute=self.attribute)
@dataclass
@@ -315,24 +318,28 @@ class AudioInputDescription:
'''
audio_input_description: str = "Bluetooth"
attribute_value: Optional[CharacteristicValue] = None
attribute: Optional[Attribute] = None
def on_read(self, _connection: Optional[Connection]) -> str:
return self.audio_input_description
async def on_write(self, connection: Optional[Connection], value: str) -> None:
assert connection
assert self.attribute_value
assert self.attribute
self.audio_input_description = value
await connection.device.notify_subscribers(
attribute=self.attribute_value, value=value
)
await connection.device.notify_subscribers(attribute=self.attribute)
class AICSService(TemplateService):
UUID = GATT_AUDIO_INPUT_CONTROL_SERVICE
audio_input_state_characteristic: Characteristic[AudioInputState]
audio_input_type_characteristic: Characteristic[bytes]
audio_input_status_characteristic: Characteristic[bytes]
audio_input_control_point_characteristic: Characteristic[bytes]
gain_settings_properties_characteristic: Characteristic[GainSettingsProperties]
def __init__(
self,
audio_input_state: Optional[AudioInputState] = None,
@@ -374,9 +381,7 @@ class AICSService(TemplateService):
),
AudioInputState,
)
self.audio_input_state.attribute_value = (
self.audio_input_state_characteristic.value
)
self.audio_input_state.attribute = self.audio_input_state_characteristic
self.gain_settings_properties_characteristic = (
SerializableCharacteristicAdapter(
@@ -425,8 +430,8 @@ class AICSService(TemplateService):
),
)
)
self.audio_input_description.attribute_value = (
self.audio_input_control_point_characteristic.value
self.audio_input_description.attribute = (
self.audio_input_control_point_characteristic
)
super().__init__(
@@ -448,24 +453,29 @@ class AICSService(TemplateService):
class AICSServiceProxy(ProfileServiceProxy):
SERVICE_CLASS = AICSService
audio_input_state: CharacteristicProxy[AudioInputState]
gain_settings_properties: CharacteristicProxy[GainSettingsProperties]
audio_input_status: CharacteristicProxy[int]
audio_input_control_point: CharacteristicProxy[bytes]
def __init__(self, service_proxy: ServiceProxy) -> None:
self.service_proxy = service_proxy
self.audio_input_state = SerializableCharacteristicAdapter(
self.audio_input_state = SerializableCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC
),
AudioInputState,
)
self.gain_settings_properties = SerializableCharacteristicAdapter(
self.gain_settings_properties = SerializableCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC
),
GainSettingsProperties,
)
self.audio_input_status = PackedCharacteristicAdapter(
self.audio_input_status = PackedCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC
),
@@ -478,7 +488,7 @@ class AICSServiceProxy(ProfileServiceProxy):
)
)
self.audio_input_description = UTF8CharacteristicAdapter(
self.audio_input_description = UTF8CharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC
)

515
bumble/profiles/ancs.py Normal file
View File

@@ -0,0 +1,515 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Apple Notification Center Service (ANCS).
"""
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import dataclasses
import datetime
import enum
import logging
import struct
from typing import Optional, Sequence, Union
from bumble.att import ATT_Error
from bumble.device import Peer
from bumble.gatt import (
Characteristic,
GATT_ANCS_SERVICE,
GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC,
GATT_ANCS_CONTROL_POINT_CHARACTERISTIC,
GATT_ANCS_DATA_SOURCE_CHARACTERISTIC,
TemplateService,
)
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
from bumble.gatt_adapters import SerializableCharacteristicProxyAdapter
from bumble import utils
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
_DEFAULT_ATTRIBUTE_MAX_LENGTH = 65535
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Protocol
# -----------------------------------------------------------------------------
class ActionId(utils.OpenIntEnum):
POSITIVE = 0
NEGATIVE = 1
class AppAttributeId(utils.OpenIntEnum):
DISPLAY_NAME = 0
class CategoryId(utils.OpenIntEnum):
OTHER = 0
INCOMING_CALL = 1
MISSED_CALL = 2
VOICEMAIL = 3
SOCIAL = 4
SCHEDULE = 5
EMAIL = 6
NEWS = 7
HEALTH_AND_FITNESS = 8
BUSINESS_AND_FINANCE = 9
LOCATION = 10
ENTERTAINMENT = 11
class CommandId(utils.OpenIntEnum):
GET_NOTIFICATION_ATTRIBUTES = 0
GET_APP_ATTRIBUTES = 1
PERFORM_NOTIFICATION_ACTION = 2
class EventId(utils.OpenIntEnum):
NOTIFICATION_ADDED = 0
NOTIFICATION_MODIFIED = 1
NOTIFICATION_REMOVED = 2
class EventFlags(enum.IntFlag):
SILENT = 1 << 0
IMPORTANT = 1 << 1
PRE_EXISTING = 1 << 2
POSITIVE_ACTION = 1 << 3
NEGATIVE_ACTION = 1 << 4
class NotificationAttributeId(utils.OpenIntEnum):
APP_IDENTIFIER = 0
TITLE = 1
SUBTITLE = 2
MESSAGE = 3
MESSAGE_SIZE = 4
DATE = 5
POSITIVE_ACTION_LABEL = 6
NEGATIVE_ACTION_LABEL = 7
@dataclasses.dataclass
class NotificationAttribute:
attribute_id: NotificationAttributeId
value: Union[str, int, datetime.datetime]
@dataclasses.dataclass
class AppAttribute:
attribute_id: AppAttributeId
value: str
@dataclasses.dataclass
class Notification:
event_id: EventId
event_flags: EventFlags
category_id: CategoryId
category_count: int
notification_uid: int
@classmethod
def from_bytes(cls, data: bytes) -> Notification:
return cls(
event_id=EventId(data[0]),
event_flags=EventFlags(data[1]),
category_id=CategoryId(data[2]),
category_count=data[3],
notification_uid=int.from_bytes(data[4:8], 'little'),
)
def __bytes__(self) -> bytes:
return struct.pack(
"<BBBBI",
self.event_id,
self.event_flags,
self.category_id,
self.category_count,
self.notification_uid,
)
class ErrorCode(utils.OpenIntEnum):
UNKNOWN_COMMAND = 0xA0
INVALID_COMMAND = 0xA1
INVALID_PARAMETER = 0xA2
ACTION_FAILED = 0xA3
class ProtocolError(Exception):
pass
class CommandError(Exception):
def __init__(self, error_code: ErrorCode) -> None:
self.error_code = error_code
def __str__(self) -> str:
return f"CommandError(error_code={self.error_code.name})"
# -----------------------------------------------------------------------------
# GATT Server-side
# -----------------------------------------------------------------------------
class Ancs(TemplateService):
UUID = GATT_ANCS_SERVICE
notification_source_characteristic: Characteristic
data_source_characteristic: Characteristic
control_point_characteristic: Characteristic
def __init__(self) -> None:
# TODO not the final implementation
self.notification_source_characteristic = Characteristic(
GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC,
Characteristic.Properties.NOTIFY,
Characteristic.Permissions.READABLE,
)
# TODO not the final implementation
self.data_source_characteristic = Characteristic(
GATT_ANCS_DATA_SOURCE_CHARACTERISTIC,
Characteristic.Properties.NOTIFY,
Characteristic.Permissions.READABLE,
)
# TODO not the final implementation
self.control_point_characteristic = Characteristic(
GATT_ANCS_CONTROL_POINT_CHARACTERISTIC,
Characteristic.Properties.WRITE,
Characteristic.Permissions.WRITEABLE,
)
super().__init__(
[
self.notification_source_characteristic,
self.data_source_characteristic,
self.control_point_characteristic,
]
)
# -----------------------------------------------------------------------------
# GATT Client-side
# -----------------------------------------------------------------------------
class AncsProxy(ProfileServiceProxy):
SERVICE_CLASS = Ancs
notification_source: CharacteristicProxy[Notification]
data_source: CharacteristicProxy
control_point: CharacteristicProxy[bytes]
def __init__(self, service_proxy: ServiceProxy):
self.notification_source = SerializableCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC
),
Notification,
)
self.data_source = service_proxy.get_required_characteristic_by_uuid(
GATT_ANCS_DATA_SOURCE_CHARACTERISTIC
)
self.control_point = service_proxy.get_required_characteristic_by_uuid(
GATT_ANCS_CONTROL_POINT_CHARACTERISTIC
)
class AncsClient(utils.EventEmitter):
_expected_response_command_id: Optional[CommandId]
_expected_response_notification_uid: Optional[int]
_expected_response_app_identifier: Optional[str]
_expected_app_identifier: Optional[str]
_expected_response_tuples: int
_response_accumulator: bytes
EVENT_NOTIFICATION = "notification"
def __init__(self, ancs_proxy: AncsProxy) -> None:
super().__init__()
self._ancs_proxy = ancs_proxy
self._command_semaphore = asyncio.Semaphore()
self._response: Optional[asyncio.Future] = None
self._reset_response()
self._started = False
@classmethod
async def for_peer(cls, peer: Peer) -> Optional[AncsClient]:
ancs_proxy = await peer.discover_service_and_create_proxy(AncsProxy)
if ancs_proxy is None:
return None
return cls(ancs_proxy)
async def start(self) -> None:
await self._ancs_proxy.notification_source.subscribe(self._on_notification)
await self._ancs_proxy.data_source.subscribe(self._on_data)
self._started = True
async def stop(self) -> None:
await self._ancs_proxy.notification_source.unsubscribe(self._on_notification)
await self._ancs_proxy.data_source.unsubscribe(self._on_data)
self._started = False
def _reset_response(self) -> None:
self._expected_response_command_id = None
self._expected_response_notification_uid = None
self._expected_app_identifier = None
self._expected_response_tuples = 0
self._response_accumulator = b""
def _on_notification(self, notification: Notification) -> None:
logger.debug(f"ANCS NOTIFICATION: {notification}")
self.emit(self.EVENT_NOTIFICATION, notification)
def _on_data(self, data: bytes) -> None:
logger.debug(f"ANCS DATA: {data.hex()}")
if not self._response:
logger.warning("received unexpected data, discarding")
return
self._response_accumulator += data
# Try to parse the accumulated data until we have all we need.
if not self._response_accumulator:
logger.warning("empty data from data source")
return
command_id = self._response_accumulator[0]
if command_id != self._expected_response_command_id:
logger.warning(
"unexpected response command id: "
f"expected {self._expected_response_command_id} "
f"but got {command_id}"
)
self._reset_response()
if not self._response.done():
self._response.set_exception(ProtocolError())
if len(self._response_accumulator) < 5:
# Not enough data yet.
return
attributes: list[Union[NotificationAttribute, AppAttribute]] = []
if command_id == CommandId.GET_NOTIFICATION_ATTRIBUTES:
(notification_uid,) = struct.unpack_from(
"<I", self._response_accumulator, 1
)
if notification_uid != self._expected_response_notification_uid:
logger.warning(
"unexpected response notification uid: "
f"expected {self._expected_response_notification_uid} "
f"but got {notification_uid}"
)
self._reset_response()
if not self._response.done():
self._response.set_exception(ProtocolError())
attribute_data = self._response_accumulator[5:]
while len(attribute_data) >= 3:
attribute_id, attribute_data_length = struct.unpack_from(
"<BH", attribute_data, 0
)
if len(attribute_data) < 3 + attribute_data_length:
return
str_value = attribute_data[3 : 3 + attribute_data_length].decode(
"utf-8"
)
value: Union[str, int, datetime.datetime]
if attribute_id == NotificationAttributeId.MESSAGE_SIZE:
value = int(str_value)
elif attribute_id == NotificationAttributeId.DATE:
year = int(str_value[:4])
month = int(str_value[4:6])
day = int(str_value[6:8])
hour = int(str_value[9:11])
minute = int(str_value[11:13])
second = int(str_value[13:15])
value = datetime.datetime(year, month, day, hour, minute, second)
else:
value = str_value
attributes.append(
NotificationAttribute(NotificationAttributeId(attribute_id), value)
)
attribute_data = attribute_data[3 + attribute_data_length :]
elif command_id == CommandId.GET_APP_ATTRIBUTES:
if 0 not in self._response_accumulator[1:]:
# No null-terminated string yet.
return
app_identifier_length = self._response_accumulator.find(0, 1) - 1
app_identifier = self._response_accumulator[
1 : 1 + app_identifier_length
].decode("utf-8")
if app_identifier != self._expected_response_app_identifier:
logger.warning(
"unexpected response app identifier: "
f"expected {self._expected_response_app_identifier} "
f"but got {app_identifier}"
)
self._reset_response()
if not self._response.done():
self._response.set_exception(ProtocolError())
attribute_data = self._response_accumulator[1 + app_identifier_length + 1 :]
while len(attribute_data) >= 3:
attribute_id, attribute_data_length = struct.unpack_from(
"<BH", attribute_data, 0
)
if len(attribute_data) < 3 + attribute_data_length:
return
attributes.append(
AppAttribute(
AppAttributeId(attribute_id),
attribute_data[3 : 3 + attribute_data_length].decode("utf-8"),
)
)
attribute_data = attribute_data[3 + attribute_data_length :]
else:
logger.warning(f"unexpected response command id {command_id}")
return
if len(attributes) < self._expected_response_tuples:
# We have not received all the tuples yet.
return
if not self._response.done():
self._response.set_result(attributes)
async def _send_command(self, command: bytes) -> None:
try:
await self._ancs_proxy.control_point.write_value(
command, with_response=True
)
except ATT_Error as error:
raise CommandError(error_code=ErrorCode(error.error_code)) from error
async def get_notification_attributes(
self,
notification_uid: int,
attributes: Sequence[
Union[NotificationAttributeId, tuple[NotificationAttributeId, int]]
],
) -> list[NotificationAttribute]:
if not self._started:
raise RuntimeError("client not started")
command = struct.pack(
"<BI", CommandId.GET_NOTIFICATION_ATTRIBUTES, notification_uid
)
for attribute in attributes:
attribute_max_length = 0
if isinstance(attribute, tuple):
attribute_id, attribute_max_length = attribute
if attribute_id not in (
NotificationAttributeId.TITLE,
NotificationAttributeId.SUBTITLE,
NotificationAttributeId.MESSAGE,
):
raise ValueError(
"this attribute does not allow specifying a max length"
)
else:
attribute_id = attribute
if attribute_id in (
NotificationAttributeId.TITLE,
NotificationAttributeId.SUBTITLE,
NotificationAttributeId.MESSAGE,
):
attribute_max_length = _DEFAULT_ATTRIBUTE_MAX_LENGTH
if attribute_max_length:
command += struct.pack("<BH", attribute_id, attribute_max_length)
else:
command += struct.pack("B", attribute_id)
try:
async with self._command_semaphore:
self._expected_response_notification_uid = notification_uid
self._expected_response_tuples = len(attributes)
self._expected_response_command_id = (
CommandId.GET_NOTIFICATION_ATTRIBUTES
)
self._response = asyncio.Future()
# Send the command.
await self._send_command(command)
# Wait for the response.
return await self._response
finally:
self._reset_response()
async def get_app_attributes(
self, app_identifier: str, attributes: Sequence[AppAttributeId]
) -> list[AppAttribute]:
if not self._started:
raise RuntimeError("client not started")
command = (
bytes([CommandId.GET_APP_ATTRIBUTES])
+ app_identifier.encode("utf-8")
+ b"\0"
)
for attribute_id in attributes:
command += struct.pack("B", attribute_id)
try:
async with self._command_semaphore:
self._expected_response_app_identifier = app_identifier
self._expected_response_tuples = len(attributes)
self._expected_response_command_id = CommandId.GET_APP_ATTRIBUTES
self._response = asyncio.Future()
# Send the command.
await self._send_command(command)
# Wait for the response.
return await self._response
finally:
self._reset_response()
async def perform_action(self, notification_uid: int, action: ActionId) -> None:
if not self._started:
raise RuntimeError("client not started")
command = struct.pack(
"<BIB", CommandId.PERFORM_NOTIFICATION_ACTION, notification_uid, action
)
async with self._command_semaphore:
await self._send_command(command)
async def perform_positive_action(self, notification_uid: int) -> None:
return await self.perform_action(notification_uid, ActionId.POSITIVE)
async def perform_negative_action(self, notification_uid: int) -> None:
return await self.perform_action(notification_uid, ActionId.NEGATIVE)

View File

@@ -23,6 +23,7 @@ import logging
import struct
from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union
from bumble import utils
from bumble import colors
from bumble.profiles.bap import CodecSpecificConfiguration
from bumble.profiles import le_audio
@@ -275,6 +276,8 @@ class AseStateMachine(gatt.Characteristic):
DISABLING = 0x05
RELEASING = 0x06
EVENT_STATE_CHANGE = "state_change"
cis_link: Optional[device.CisLink] = None
# Additional parameters in CODEC_CONFIGURED State
@@ -301,7 +304,7 @@ class AseStateMachine(gatt.Characteristic):
presentation_delay = 0
# Additional parameters in ENABLING, STREAMING, DISABLING State
metadata = le_audio.Metadata()
metadata: le_audio.Metadata
def __init__(
self,
@@ -313,6 +316,7 @@ class AseStateMachine(gatt.Characteristic):
self.ase_id = ase_id
self._state = AseStateMachine.State.IDLE
self.role = role
self.metadata = le_audio.Metadata()
uuid = (
gatt.GATT_SINK_ASE_CHARACTERISTIC
@@ -327,8 +331,12 @@ class AseStateMachine(gatt.Characteristic):
value=gatt.CharacteristicValue(read=self.on_read),
)
self.service.device.on('cis_request', self.on_cis_request)
self.service.device.on('cis_establishment', self.on_cis_establishment)
self.service.device.on(
self.service.device.EVENT_CIS_REQUEST, self.on_cis_request
)
self.service.device.on(
self.service.device.EVENT_CIS_ESTABLISHMENT, self.on_cis_establishment
)
def on_cis_request(
self,
@@ -342,8 +350,10 @@ class AseStateMachine(gatt.Characteristic):
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)
utils.cancel_on_event(
acl_connection,
'flush',
self.service.device.accept_cis_request(cis_handle),
)
def on_cis_establishment(self, cis_link: device.CisLink) -> None:
@@ -352,7 +362,7 @@ class AseStateMachine(gatt.Characteristic):
and cis_link.cis_id == self.cis_id
and self.state == self.State.ENABLING
):
cis_link.on('disconnection', self.on_cis_disconnection)
cis_link.on(cis_link.EVENT_DISCONNECTION, self.on_cis_disconnection)
async def post_cis_established():
await cis_link.setup_data_path(direction=self.role)
@@ -360,7 +370,9 @@ class AseStateMachine(gatt.Characteristic):
self.state = self.State.STREAMING
await self.service.device.notify_subscribers(self, self.value)
cis_link.acl_connection.abort_on('flush', post_cis_established())
utils.cancel_on_event(
cis_link.acl_connection, 'flush', post_cis_established()
)
self.cis_link = cis_link
def on_cis_disconnection(self, _reason) -> None:
@@ -508,7 +520,7 @@ class AseStateMachine(gatt.Characteristic):
self.state = self.State.IDLE
await self.service.device.notify_subscribers(self, self.value)
self.service.device.abort_on('flush', remove_cis_async())
utils.cancel_on_event(self.service.device, 'flush', remove_cis_async())
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
@property
@@ -519,7 +531,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')
self.emit(self.EVENT_STATE_CHANGE)
@property
def value(self):
@@ -593,7 +605,7 @@ class AudioStreamControlService(gatt.TemplateService):
UUID = gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE
ase_state_machines: Dict[int, AseStateMachine]
ase_control_point: gatt.Characteristic
ase_control_point: gatt.Characteristic[bytes]
_active_client: Optional[device.Connection] = None
def __init__(
@@ -690,7 +702,8 @@ class AudioStreamControlService(gatt.TemplateService):
control_point_notification = bytes(
[operation.op_code, len(responses)]
) + b''.join(map(bytes, responses))
self.device.abort_on(
utils.cancel_on_event(
self.device,
'flush',
self.device.notify_subscribers(
self.ase_control_point, control_point_notification
@@ -699,7 +712,8 @@ class AudioStreamControlService(gatt.TemplateService):
for ase_id, *_ in responses:
if ase := self.ase_state_machines.get(ase_id):
self.device.abort_on(
utils.cancel_on_event(
self.device,
'flush',
self.device.notify_subscribers(ase, ase.value),
)
@@ -709,9 +723,9 @@ class AudioStreamControlService(gatt.TemplateService):
class AudioStreamControlServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = AudioStreamControlService
sink_ase: List[gatt_client.CharacteristicProxy]
source_ase: List[gatt_client.CharacteristicProxy]
ase_control_point: gatt_client.CharacteristicProxy
sink_ase: List[gatt_client.CharacteristicProxy[bytes]]
source_ase: List[gatt_client.CharacteristicProxy[bytes]]
ase_control_point: gatt_client.CharacteristicProxy[bytes]
def __init__(self, service_proxy: gatt_client.ServiceProxy):
self.service_proxy = service_proxy

View File

@@ -88,6 +88,11 @@ class AudioStatus(utils.OpenIntEnum):
class AshaService(gatt.TemplateService):
UUID = gatt.GATT_ASHA_SERVICE
EVENT_STARTED = "started"
EVENT_STOPPED = "stopped"
EVENT_DISCONNECTED = "disconnected"
EVENT_VOLUME_CHANGED = "volume_changed"
audio_sink: Optional[Callable[[bytes], Any]]
active_codec: Optional[Codec] = None
audio_type: Optional[AudioType] = None
@@ -134,12 +139,14 @@ class AshaService(gatt.TemplateService):
),
)
self.audio_control_point_characteristic = gatt.Characteristic(
gatt.GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
gatt.Characteristic.WRITEABLE,
gatt.CharacteristicValue(write=self._on_audio_control_point_write),
self.audio_control_point_characteristic: gatt.Characteristic[bytes] = (
gatt.Characteristic(
gatt.GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
gatt.Characteristic.WRITEABLE,
gatt.CharacteristicValue(write=self._on_audio_control_point_write),
)
)
self.audio_status_characteristic = gatt.Characteristic(
gatt.GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC,
@@ -147,7 +154,7 @@ class AshaService(gatt.TemplateService):
gatt.Characteristic.READABLE,
bytes([AudioStatus.OK]),
)
self.volume_characteristic = gatt.Characteristic(
self.volume_characteristic: gatt.Characteristic[bytes] = gatt.Characteristic(
gatt.GATT_ASHA_VOLUME_CHARACTERISTIC,
gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
gatt.Characteristic.WRITEABLE,
@@ -166,13 +173,13 @@ class AshaService(gatt.TemplateService):
struct.pack('<H', self.psm),
)
characteristics = [
characteristics = (
self.read_only_properties_characteristic,
self.audio_control_point_characteristic,
self.audio_status_characteristic,
self.volume_characteristic,
self.le_psm_out_characteristic,
]
)
super().__init__(characteristics)
@@ -209,14 +216,14 @@ class AshaService(gatt.TemplateService):
f'volume={self.volume}, '
f'other_state={self.other_state}'
)
self.emit('started')
self.emit(self.EVENT_STARTED)
elif opcode == OpCode.STOP:
_logger.debug('### STOP')
self.active_codec = None
self.audio_type = None
self.volume = None
self.other_state = None
self.emit('stopped')
self.emit(self.EVENT_STOPPED)
elif opcode == OpCode.STATUS:
_logger.debug('### STATUS: %s', PeripheralStatus(value[1]).name)
@@ -229,7 +236,7 @@ class AshaService(gatt.TemplateService):
self.audio_type = None
self.volume = None
self.other_state = None
self.emit('disconnected')
self.emit(self.EVENT_DISCONNECTED)
connection.once('disconnection', on_disconnection)
@@ -243,7 +250,7 @@ class AshaService(gatt.TemplateService):
def _on_volume_write(self, connection: Optional[Connection], value: bytes) -> None:
_logger.debug(f'--- VOLUME Write:{value[0]}')
self.volume = value[0]
self.emit('volume_changed')
self.emit(self.EVENT_VOLUME_CHANGED)
# Register an L2CAP CoC server
def _on_connection(self, channel: l2cap.LeCreditBasedChannel) -> None:
@@ -257,11 +264,11 @@ class AshaService(gatt.TemplateService):
# -----------------------------------------------------------------------------
class AshaServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = AshaService
read_only_properties_characteristic: gatt_client.CharacteristicProxy
audio_control_point_characteristic: gatt_client.CharacteristicProxy
audio_status_point_characteristic: gatt_client.CharacteristicProxy
volume_characteristic: gatt_client.CharacteristicProxy
psm_characteristic: gatt_client.CharacteristicProxy
read_only_properties_characteristic: gatt_client.CharacteristicProxy[bytes]
audio_control_point_characteristic: gatt_client.CharacteristicProxy[bytes]
audio_status_point_characteristic: gatt_client.CharacteristicProxy[bytes]
volume_characteristic: gatt_client.CharacteristicProxy[bytes]
psm_characteristic: gatt_client.CharacteristicProxy[bytes]
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
self.service_proxy = service_proxy

View File

@@ -20,11 +20,12 @@ from __future__ import annotations
import dataclasses
import logging
import struct
from typing import ClassVar, List, Optional, Sequence
from typing import ClassVar, Optional, Sequence
from bumble import core
from bumble import device
from bumble import gatt
from bumble import gatt_adapters
from bumble import gatt_client
from bumble import hci
from bumble import utils
@@ -52,7 +53,7 @@ def encode_subgroups(subgroups: Sequence[SubgroupInfo]) -> bytes:
)
def decode_subgroups(data: bytes) -> List[SubgroupInfo]:
def decode_subgroups(data: bytes) -> list[SubgroupInfo]:
num_subgroups = data[0]
offset = 1
subgroups = []
@@ -273,7 +274,7 @@ class BroadcastReceiveState:
pa_sync_state: PeriodicAdvertisingSyncState
big_encryption: BigEncryption
bad_code: bytes
subgroups: List[SubgroupInfo]
subgroups: list[SubgroupInfo]
@classmethod
def from_bytes(cls, data: bytes) -> BroadcastReceiveState:
@@ -353,8 +354,10 @@ class BroadcastAudioScanService(gatt.TemplateService):
class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = BroadcastAudioScanService
broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy
broadcast_receive_states: List[gatt.DelegatedCharacteristicAdapter]
broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy[bytes]
broadcast_receive_states: list[
gatt_client.CharacteristicProxy[Optional[BroadcastReceiveState]]
]
def __init__(self, service_proxy: gatt_client.ServiceProxy):
self.service_proxy = service_proxy
@@ -366,7 +369,7 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
)
self.broadcast_receive_states = [
gatt.DelegatedCharacteristicAdapter(
gatt_adapters.DelegatedCharacteristicProxyAdapter(
characteristic,
decode=lambda x: BroadcastReceiveState.from_bytes(x) if x else None,
)

View File

@@ -16,14 +16,20 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from ..gatt_client import ProfileServiceProxy
from ..gatt import (
from typing import Optional
from bumble.gatt_client import ProfileServiceProxy
from bumble.gatt import (
GATT_BATTERY_SERVICE,
GATT_BATTERY_LEVEL_CHARACTERISTIC,
TemplateService,
Characteristic,
CharacteristicValue,
)
from bumble.gatt_client import CharacteristicProxy
from bumble.gatt_adapters import (
PackedCharacteristicAdapter,
PackedCharacteristicProxyAdapter,
)
@@ -32,6 +38,8 @@ class BatteryService(TemplateService):
UUID = GATT_BATTERY_SERVICE
BATTERY_LEVEL_FORMAT = 'B'
battery_level_characteristic: Characteristic[int]
def __init__(self, read_battery_level):
self.battery_level_characteristic = PackedCharacteristicAdapter(
Characteristic(
@@ -49,13 +57,15 @@ class BatteryService(TemplateService):
class BatteryServiceProxy(ProfileServiceProxy):
SERVICE_CLASS = BatteryService
battery_level: Optional[CharacteristicProxy[int]]
def __init__(self, service_proxy):
self.service_proxy = service_proxy
if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_BATTERY_LEVEL_CHARACTERISTIC
):
self.battery_level = PackedCharacteristicAdapter(
self.battery_level = PackedCharacteristicProxyAdapter(
characteristics[0], pack_format=BatteryService.BATTERY_LEVEL_FORMAT
)
else:

View File

@@ -99,10 +99,10 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
UUID = gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE
set_identity_resolving_key: bytes
set_identity_resolving_key_characteristic: gatt.Characteristic
coordinated_set_size_characteristic: Optional[gatt.Characteristic] = None
set_member_lock_characteristic: Optional[gatt.Characteristic] = None
set_member_rank_characteristic: Optional[gatt.Characteristic] = None
set_identity_resolving_key_characteristic: gatt.Characteristic[bytes]
coordinated_set_size_characteristic: Optional[gatt.Characteristic[bytes]] = None
set_member_lock_characteristic: Optional[gatt.Characteristic[bytes]] = None
set_member_rank_characteristic: Optional[gatt.Characteristic[bytes]] = None
def __init__(
self,
@@ -170,7 +170,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
else:
assert connection
if connection.transport == core.BT_LE_TRANSPORT:
if connection.transport == core.PhysicalTransport.LE:
key = await connection.device.get_long_term_key(
connection_handle=connection.handle, rand=b'', ediv=0
)
@@ -203,10 +203,10 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = CoordinatedSetIdentificationService
set_identity_resolving_key: gatt_client.CharacteristicProxy
coordinated_set_size: Optional[gatt_client.CharacteristicProxy] = None
set_member_lock: Optional[gatt_client.CharacteristicProxy] = None
set_member_rank: Optional[gatt_client.CharacteristicProxy] = None
set_identity_resolving_key: gatt_client.CharacteristicProxy[bytes]
coordinated_set_size: Optional[gatt_client.CharacteristicProxy[bytes]] = None
set_member_lock: Optional[gatt_client.CharacteristicProxy[bytes]] = None
set_member_rank: Optional[gatt_client.CharacteristicProxy[bytes]] = None
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
self.service_proxy = service_proxy
@@ -242,7 +242,7 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
else:
connection = self.service_proxy.client.connection
device = connection.device
if connection.transport == core.BT_LE_TRANSPORT:
if connection.transport == core.PhysicalTransport.LE:
key = await device.get_long_term_key(
connection_handle=connection.handle, rand=b'', ediv=0
)

View File

@@ -19,7 +19,6 @@
import struct
from typing import Optional, Tuple
from bumble.gatt_client import ServiceProxy, ProfileServiceProxy, CharacteristicProxy
from bumble.gatt import (
GATT_DEVICE_INFORMATION_SERVICE,
GATT_FIRMWARE_REVISION_STRING_CHARACTERISTIC,
@@ -32,9 +31,12 @@ from bumble.gatt import (
GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC,
TemplateService,
Characteristic,
DelegatedCharacteristicAdapter,
UTF8CharacteristicAdapter,
)
from bumble.gatt_adapters import (
DelegatedCharacteristicProxyAdapter,
UTF8CharacteristicProxyAdapter,
)
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
# -----------------------------------------------------------------------------
@@ -62,7 +64,7 @@ class DeviceInformationService(TemplateService):
ieee_regulatory_certification_data_list: Optional[bytes] = None,
# TODO: pnp_id
):
characteristics = [
characteristics: list[Characteristic[bytes]] = [
Characteristic(
uuid,
Characteristic.Properties.READ,
@@ -107,14 +109,14 @@ class DeviceInformationService(TemplateService):
class DeviceInformationServiceProxy(ProfileServiceProxy):
SERVICE_CLASS = DeviceInformationService
manufacturer_name: Optional[UTF8CharacteristicAdapter]
model_number: Optional[UTF8CharacteristicAdapter]
serial_number: Optional[UTF8CharacteristicAdapter]
hardware_revision: Optional[UTF8CharacteristicAdapter]
firmware_revision: Optional[UTF8CharacteristicAdapter]
software_revision: Optional[UTF8CharacteristicAdapter]
system_id: Optional[DelegatedCharacteristicAdapter]
ieee_regulatory_certification_data_list: Optional[CharacteristicProxy]
manufacturer_name: Optional[CharacteristicProxy[str]]
model_number: Optional[CharacteristicProxy[str]]
serial_number: Optional[CharacteristicProxy[str]]
hardware_revision: Optional[CharacteristicProxy[str]]
firmware_revision: Optional[CharacteristicProxy[str]]
software_revision: Optional[CharacteristicProxy[str]]
system_id: Optional[CharacteristicProxy[tuple[int, int]]]
ieee_regulatory_certification_data_list: Optional[CharacteristicProxy[bytes]]
def __init__(self, service_proxy: ServiceProxy):
self.service_proxy = service_proxy
@@ -128,7 +130,7 @@ class DeviceInformationServiceProxy(ProfileServiceProxy):
('software_revision', GATT_SOFTWARE_REVISION_STRING_CHARACTERISTIC),
):
if characteristics := service_proxy.get_characteristics_by_uuid(uuid):
characteristic = UTF8CharacteristicAdapter(characteristics[0])
characteristic = UTF8CharacteristicProxyAdapter(characteristics[0])
else:
characteristic = None
self.__setattr__(field, characteristic)
@@ -136,7 +138,7 @@ class DeviceInformationServiceProxy(ProfileServiceProxy):
if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_SYSTEM_ID_CHARACTERISTIC
):
self.system_id = DelegatedCharacteristicAdapter(
self.system_id = DelegatedCharacteristicProxyAdapter(
characteristics[0],
encode=lambda v: DeviceInformationService.pack_system_id(*v),
decode=DeviceInformationService.unpack_system_id,

View File

@@ -25,14 +25,15 @@ from bumble.core import Appearance
from bumble.gatt import (
TemplateService,
Characteristic,
CharacteristicAdapter,
DelegatedCharacteristicAdapter,
UTF8CharacteristicAdapter,
GATT_GENERIC_ACCESS_SERVICE,
GATT_DEVICE_NAME_CHARACTERISTIC,
GATT_APPEARANCE_CHARACTERISTIC,
)
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
from bumble.gatt_adapters import (
DelegatedCharacteristicProxyAdapter,
UTF8CharacteristicProxyAdapter,
)
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
# -----------------------------------------------------------------------------
# Logging
@@ -49,6 +50,9 @@ logger = logging.getLogger(__name__)
class GenericAccessService(TemplateService):
UUID = GATT_GENERIC_ACCESS_SERVICE
device_name_characteristic: Characteristic[bytes]
appearance_characteristic: Characteristic[bytes]
def __init__(
self, device_name: str, appearance: Union[Appearance, Tuple[int, int], int] = 0
):
@@ -84,8 +88,8 @@ class GenericAccessService(TemplateService):
class GenericAccessServiceProxy(ProfileServiceProxy):
SERVICE_CLASS = GenericAccessService
device_name: Optional[CharacteristicAdapter]
appearance: Optional[DelegatedCharacteristicAdapter]
device_name: Optional[CharacteristicProxy[str]]
appearance: Optional[CharacteristicProxy[Appearance]]
def __init__(self, service_proxy: ServiceProxy):
self.service_proxy = service_proxy
@@ -93,14 +97,14 @@ class GenericAccessServiceProxy(ProfileServiceProxy):
if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_DEVICE_NAME_CHARACTERISTIC
):
self.device_name = UTF8CharacteristicAdapter(characteristics[0])
self.device_name = UTF8CharacteristicProxyAdapter(characteristics[0])
else:
self.device_name = None
if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_APPEARANCE_CHARACTERISTIC
):
self.appearance = DelegatedCharacteristicAdapter(
self.appearance = DelegatedCharacteristicProxyAdapter(
characteristics[0],
decode=lambda value: Appearance.from_int(
struct.unpack_from('<H', value, 0)[0],

View File

@@ -0,0 +1,167 @@
# Copyright 2021-2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import struct
from typing import TYPE_CHECKING
from bumble import att
from bumble import gatt
from bumble import gatt_client
from bumble import crypto
if TYPE_CHECKING:
from bumble import device
# -----------------------------------------------------------------------------
class GenericAttributeProfileService(gatt.TemplateService):
'''See Vol 3, Part G - 7 - DEFINED GENERIC ATTRIBUTE PROFILE SERVICE.'''
UUID = gatt.GATT_GENERIC_ATTRIBUTE_SERVICE
client_supported_features_characteristic: gatt.Characteristic[bytes] | None = None
server_supported_features_characteristic: gatt.Characteristic[bytes] | None = None
database_hash_characteristic: gatt.Characteristic[bytes] | None = None
service_changed_characteristic: gatt.Characteristic[bytes] | None = None
def __init__(
self,
server_supported_features: gatt.ServerSupportedFeatures | None = None,
database_hash_enabled: bool = True,
service_change_enabled: bool = True,
) -> None:
if server_supported_features is not None:
self.server_supported_features_characteristic = gatt.Characteristic(
uuid=gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ,
permissions=gatt.Characteristic.Permissions.READABLE,
value=bytes([server_supported_features]),
)
if database_hash_enabled:
self.database_hash_characteristic = gatt.Characteristic(
uuid=gatt.GATT_DATABASE_HASH_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ,
permissions=gatt.Characteristic.Permissions.READABLE,
value=gatt.CharacteristicValue(read=self.get_database_hash),
)
if service_change_enabled:
self.service_changed_characteristic = gatt.Characteristic(
uuid=gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.INDICATE,
permissions=gatt.Characteristic.Permissions(0),
value=b'',
)
if (database_hash_enabled and service_change_enabled) or (
server_supported_features
and (
server_supported_features & gatt.ServerSupportedFeatures.EATT_SUPPORTED
)
): # TODO: Support Multiple Handle Value Notifications
self.client_supported_features_characteristic = gatt.Characteristic(
uuid=gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC,
properties=(
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE
),
permissions=(
gatt.Characteristic.Permissions.READABLE
| gatt.Characteristic.Permissions.WRITEABLE
),
value=bytes(1),
)
super().__init__(
characteristics=[
c
for c in (
self.service_changed_characteristic,
self.client_supported_features_characteristic,
self.database_hash_characteristic,
self.server_supported_features_characteristic,
)
if c is not None
],
primary=True,
)
@classmethod
def get_attribute_data(cls, attribute: att.Attribute) -> bytes:
if attribute.type in (
gatt.GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
gatt.GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
gatt.GATT_INCLUDE_ATTRIBUTE_TYPE,
gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
gatt.GATT_CHARACTERISTIC_EXTENDED_PROPERTIES_DESCRIPTOR,
):
assert isinstance(attribute.value, bytes)
return (
struct.pack("<H", attribute.handle)
+ attribute.type.to_bytes()
+ attribute.value
)
elif attribute.type in (
gatt.GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR,
gatt.GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
gatt.GATT_SERVER_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
gatt.GATT_CHARACTERISTIC_PRESENTATION_FORMAT_DESCRIPTOR,
gatt.GATT_CHARACTERISTIC_AGGREGATE_FORMAT_DESCRIPTOR,
):
return struct.pack("<H", attribute.handle) + attribute.type.to_bytes()
return b''
def get_database_hash(self, connection: device.Connection | None) -> bytes:
assert connection
m = b''.join(
[
self.get_attribute_data(attribute)
for attribute in connection.device.gatt_server.attributes
]
)
return crypto.aes_cmac(m=m, k=bytes(16))
class GenericAttributeProfileServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = GenericAttributeProfileService
client_supported_features_characteristic: (
gatt_client.CharacteristicProxy[bytes] | None
) = None
server_supported_features_characteristic: (
gatt_client.CharacteristicProxy[bytes] | None
) = None
database_hash_characteristic: gatt_client.CharacteristicProxy[bytes] | None = None
service_changed_characteristic: gatt_client.CharacteristicProxy[bytes] | None = None
_CHARACTERISTICS = {
gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC: 'client_supported_features_characteristic',
gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC: 'server_supported_features_characteristic',
gatt.GATT_DATABASE_HASH_CHARACTERISTIC: 'database_hash_characteristic',
gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC: 'service_changed_characteristic',
}
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
self.service_proxy = service_proxy
for uuid, attribute_name in self._CHARACTERISTICS.items():
if characteristics := self.service_proxy.get_characteristics_by_uuid(uuid):
setattr(self, attribute_name, characteristics[0])

View File

@@ -22,7 +22,6 @@ from typing import Optional
from bumble.gatt import (
TemplateService,
DelegatedCharacteristicAdapter,
Characteristic,
GATT_GAMING_AUDIO_SERVICE,
GATT_GMAP_ROLE_CHARACTERISTIC,
@@ -31,7 +30,8 @@ from bumble.gatt import (
GATT_BGS_FEATURES_CHARACTERISTIC,
GATT_BGR_FEATURES_CHARACTERISTIC,
)
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
from bumble.gatt_adapters import DelegatedCharacteristicProxyAdapter
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
from enum import IntFlag
@@ -150,10 +150,15 @@ class GamingAudioService(TemplateService):
class GamingAudioServiceProxy(ProfileServiceProxy):
SERVICE_CLASS = GamingAudioService
ugg_features: Optional[CharacteristicProxy[UggFeatures]] = None
ugt_features: Optional[CharacteristicProxy[UgtFeatures]] = None
bgs_features: Optional[CharacteristicProxy[BgsFeatures]] = None
bgr_features: Optional[CharacteristicProxy[BgrFeatures]] = None
def __init__(self, service_proxy: ServiceProxy) -> None:
self.service_proxy = service_proxy
self.gmap_role = DelegatedCharacteristicAdapter(
self.gmap_role = DelegatedCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_GMAP_ROLE_CHARACTERISTIC
),
@@ -163,31 +168,31 @@ class GamingAudioServiceProxy(ProfileServiceProxy):
if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_UGG_FEATURES_CHARACTERISTIC
):
self.ugg_features = DelegatedCharacteristicAdapter(
characteristic=characteristics[0],
self.ugg_features = DelegatedCharacteristicProxyAdapter(
characteristics[0],
decode=lambda value: UggFeatures(value[0]),
)
if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_UGT_FEATURES_CHARACTERISTIC
):
self.ugt_features = DelegatedCharacteristicAdapter(
characteristic=characteristics[0],
self.ugt_features = DelegatedCharacteristicProxyAdapter(
characteristics[0],
decode=lambda value: UgtFeatures(value[0]),
)
if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_BGS_FEATURES_CHARACTERISTIC
):
self.bgs_features = DelegatedCharacteristicAdapter(
characteristic=characteristics[0],
self.bgs_features = DelegatedCharacteristicProxyAdapter(
characteristics[0],
decode=lambda value: BgsFeatures(value[0]),
)
if characteristics := service_proxy.get_characteristics_by_uuid(
GATT_BGR_FEATURES_CHARACTERISTIC
):
self.bgr_features = DelegatedCharacteristicAdapter(
characteristic=characteristics[0],
self.bgr_features = DelegatedCharacteristicProxyAdapter(
characteristics[0],
decode=lambda value: BgrFeatures(value[0]),
)

View File

@@ -18,20 +18,21 @@
from __future__ import annotations
import asyncio
import functools
from bumble import att, gatt, gatt_client
from bumble.core import InvalidArgumentError, InvalidStateError
from bumble.device import Device, Connection
from bumble.utils import AsyncRunner, OpenIntEnum
from bumble.hci import Address
from dataclasses import dataclass, field
import logging
from typing import Any, Dict, List, Optional, Set, Union
from bumble import att, gatt, gatt_adapters, gatt_client
from bumble.core import InvalidArgumentError, InvalidStateError
from bumble.device import Device, Connection
from bumble import utils
from bumble.hci import Address
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
class ErrorCode(OpenIntEnum):
class ErrorCode(utils.OpenIntEnum):
'''See Hearing Access Service 2.4. Attribute Profile error codes.'''
INVALID_OPCODE = 0x80
@@ -41,7 +42,7 @@ class ErrorCode(OpenIntEnum):
INVALID_PARAMETERS_LENGTH = 0x84
class HearingAidType(OpenIntEnum):
class HearingAidType(utils.OpenIntEnum):
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
BINAURAL_HEARING_AID = 0b00
@@ -49,35 +50,35 @@ class HearingAidType(OpenIntEnum):
BANDED_HEARING_AID = 0b10
class PresetSynchronizationSupport(OpenIntEnum):
class PresetSynchronizationSupport(utils.OpenIntEnum):
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED = 0b0
PRESET_SYNCHRONIZATION_IS_SUPPORTED = 0b1
class IndependentPresets(OpenIntEnum):
class IndependentPresets(utils.OpenIntEnum):
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
IDENTICAL_PRESET_RECORD = 0b0
DIFFERENT_PRESET_RECORD = 0b1
class DynamicPresets(OpenIntEnum):
class DynamicPresets(utils.OpenIntEnum):
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
PRESET_RECORDS_DOES_NOT_CHANGE = 0b0
PRESET_RECORDS_MAY_CHANGE = 0b1
class WritablePresetsSupport(OpenIntEnum):
class WritablePresetsSupport(utils.OpenIntEnum):
'''See Hearing Access Service 3.1. Hearing Aid Features.'''
WRITABLE_PRESET_RECORDS_NOT_SUPPORTED = 0b0
WRITABLE_PRESET_RECORDS_SUPPORTED = 0b1
class HearingAidPresetControlPointOpcode(OpenIntEnum):
class HearingAidPresetControlPointOpcode(utils.OpenIntEnum):
'''See Hearing Access Service 3.3.1 Hearing Aid Preset Control Point operation requirements.'''
# fmt: off
@@ -129,7 +130,7 @@ def HearingAidFeatures_from_bytes(data: int) -> HearingAidFeatures:
class PresetChangedOperation:
'''See Hearing Access Service 3.2.2.2. Preset Changed operation.'''
class ChangeId(OpenIntEnum):
class ChangeId(utils.OpenIntEnum):
# fmt: off
GENERIC_UPDATE = 0x00
PRESET_RECORD_DELETED = 0x01
@@ -189,11 +190,11 @@ class PresetRecord:
@dataclass
class Property:
class Writable(OpenIntEnum):
class Writable(utils.OpenIntEnum):
CANNOT_BE_WRITTEN = 0b0
CAN_BE_WRITTEN = 0b1
class IsAvailable(OpenIntEnum):
class IsAvailable(utils.OpenIntEnum):
IS_UNAVAILABLE = 0b0
IS_AVAILABLE = 0b1
@@ -223,9 +224,9 @@ class PresetRecord:
class HearingAccessService(gatt.TemplateService):
UUID = gatt.GATT_HEARING_ACCESS_SERVICE
hearing_aid_features_characteristic: gatt.Characteristic
hearing_aid_preset_control_point: gatt.Characteristic
active_preset_index_characteristic: gatt.Characteristic
hearing_aid_features_characteristic: gatt.Characteristic[bytes]
hearing_aid_preset_control_point: gatt.Characteristic[bytes]
active_preset_index_characteristic: gatt.Characteristic[bytes]
active_preset_index: int
active_preset_index_per_device: Dict[Address, int]
@@ -265,13 +266,13 @@ class HearingAccessService(gatt.TemplateService):
# associate the lowest index as the current active preset at startup
self.active_preset_index = sorted(self.preset_records.keys())[0]
@device.on('connection') # type: ignore
@device.on(device.EVENT_CONNECTION)
def on_connection(connection: Connection) -> None:
@connection.on('disconnection') # type: ignore
@connection.on(connection.EVENT_DISCONNECTION)
def on_disconnection(_reason) -> None:
self.currently_connected_clients.remove(connection)
@connection.on('pairing') # type: ignore
@connection.on(connection.EVENT_PAIRING)
def on_pairing(*_: Any) -> None:
self.on_incoming_paired_connection(connection)
@@ -332,7 +333,7 @@ class HearingAccessService(gatt.TemplateService):
# Update the active preset index if needed
await self.notify_active_preset_for_connection(connection)
connection.abort_on('disconnection', on_connection_async())
utils.cancel_on_event(connection, 'disconnection', on_connection_async())
def _on_read_active_preset_index(
self, __connection__: Optional[Connection]
@@ -381,7 +382,7 @@ class HearingAccessService(gatt.TemplateService):
if len(presets) == 0:
raise att.ATT_Error(att.ErrorCode.OUT_OF_RANGE)
AsyncRunner.spawn(self._read_preset_response(connection, presets))
utils.AsyncRunner.spawn(self._read_preset_response(connection, presets))
async def _read_preset_response(
self, connection: Connection, presets: List[PresetRecord]
@@ -631,11 +632,12 @@ class HearingAccessServiceProxy(gatt_client.ProfileServiceProxy):
hearing_aid_preset_control_point: gatt_client.CharacteristicProxy
preset_control_point_indications: asyncio.Queue
active_preset_index_notification: asyncio.Queue
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
self.service_proxy = service_proxy
self.server_features = gatt.PackedCharacteristicAdapter(
self.server_features = gatt_adapters.PackedCharacteristicProxyAdapter(
service_proxy.get_characteristics_by_uuid(
gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC
)[0],
@@ -648,7 +650,7 @@ class HearingAccessServiceProxy(gatt_client.ProfileServiceProxy):
)[0]
)
self.active_preset_index = gatt.PackedCharacteristicAdapter(
self.active_preset_index = gatt_adapters.PackedCharacteristicProxyAdapter(
service_proxy.get_characteristics_by_uuid(
gatt.GATT_ACTIVE_PRESET_INDEX_CHARACTERISTIC
)[0],

View File

@@ -16,13 +16,14 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
from enum import IntEnum
import struct
from typing import Optional
from bumble import core
from ..gatt_client import ProfileServiceProxy
from ..att import ATT_Error
from ..gatt import (
from bumble.att import ATT_Error
from bumble.gatt import (
GATT_HEART_RATE_SERVICE,
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
@@ -30,10 +31,13 @@ from ..gatt import (
TemplateService,
Characteristic,
CharacteristicValue,
SerializableCharacteristicAdapter,
)
from bumble.gatt_adapters import (
DelegatedCharacteristicAdapter,
PackedCharacteristicAdapter,
SerializableCharacteristicAdapter,
)
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy
# -----------------------------------------------------------------------------
@@ -43,6 +47,10 @@ class HeartRateService(TemplateService):
CONTROL_POINT_NOT_SUPPORTED = 0x80
RESET_ENERGY_EXPENDED = 0x01
heart_rate_measurement_characteristic: Characteristic[HeartRateMeasurement]
body_sensor_location_characteristic: Characteristic[BodySensorLocation]
heart_rate_control_point_characteristic: Characteristic[int]
class BodySensorLocation(IntEnum):
OTHER = 0
CHEST = 1
@@ -198,6 +206,14 @@ class HeartRateService(TemplateService):
class HeartRateServiceProxy(ProfileServiceProxy):
SERVICE_CLASS = HeartRateService
heart_rate_measurement: Optional[
CharacteristicProxy[HeartRateService.HeartRateMeasurement]
]
body_sensor_location: Optional[
CharacteristicProxy[HeartRateService.BodySensorLocation]
]
heart_rate_control_point: Optional[CharacteristicProxy[int]]
def __init__(self, service_proxy):
self.service_proxy = service_proxy

View File

@@ -208,7 +208,7 @@ class MediaControlService(gatt.TemplateService):
properties=gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.NOTIFY,
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
value=media_player_name or 'Bumble Player',
value=(media_player_name or 'Bumble Player').encode(),
)
self.track_changed_characteristic = gatt.Characteristic(
uuid=gatt.GATT_TRACK_CHANGED_CHARACTERISTIC,
@@ -247,14 +247,16 @@ class MediaControlService(gatt.TemplateService):
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
value=b'',
)
self.media_control_point_characteristic = gatt.Characteristic(
uuid=gatt.GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE
| gatt.Characteristic.Properties.NOTIFY,
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
| gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
value=gatt.CharacteristicValue(write=self.on_media_control_point),
self.media_control_point_characteristic: gatt.Characteristic[bytes] = (
gatt.Characteristic(
uuid=gatt.GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE
| gatt.Characteristic.Properties.NOTIFY,
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
| gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
value=gatt.CharacteristicValue(write=self.on_media_control_point),
)
)
self.media_control_point_opcodes_supported_characteristic = gatt.Characteristic(
uuid=gatt.GATT_MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_CHARACTERISTIC,
@@ -336,30 +338,38 @@ class MediaControlServiceProxy(
'content_control_id': gatt.GATT_CONTENT_CONTROL_ID_CHARACTERISTIC,
}
media_player_name: Optional[gatt_client.CharacteristicProxy] = None
media_player_icon_object_id: Optional[gatt_client.CharacteristicProxy] = None
media_player_icon_url: Optional[gatt_client.CharacteristicProxy] = None
track_changed: Optional[gatt_client.CharacteristicProxy] = None
track_title: Optional[gatt_client.CharacteristicProxy] = None
track_duration: Optional[gatt_client.CharacteristicProxy] = None
track_position: Optional[gatt_client.CharacteristicProxy] = None
playback_speed: Optional[gatt_client.CharacteristicProxy] = None
seeking_speed: Optional[gatt_client.CharacteristicProxy] = None
current_track_segments_object_id: Optional[gatt_client.CharacteristicProxy] = None
current_track_object_id: Optional[gatt_client.CharacteristicProxy] = None
next_track_object_id: Optional[gatt_client.CharacteristicProxy] = None
parent_group_object_id: Optional[gatt_client.CharacteristicProxy] = None
current_group_object_id: Optional[gatt_client.CharacteristicProxy] = None
playing_order: Optional[gatt_client.CharacteristicProxy] = None
playing_orders_supported: Optional[gatt_client.CharacteristicProxy] = None
media_state: Optional[gatt_client.CharacteristicProxy] = None
media_control_point: Optional[gatt_client.CharacteristicProxy] = None
media_control_point_opcodes_supported: Optional[gatt_client.CharacteristicProxy] = (
None
)
search_control_point: Optional[gatt_client.CharacteristicProxy] = None
search_results_object_id: Optional[gatt_client.CharacteristicProxy] = None
content_control_id: Optional[gatt_client.CharacteristicProxy] = None
EVENT_MEDIA_STATE = "media_state"
EVENT_TRACK_CHANGED = "track_changed"
EVENT_TRACK_TITLE = "track_title"
EVENT_TRACK_DURATION = "track_duration"
EVENT_TRACK_POSITION = "track_position"
media_player_name: Optional[gatt_client.CharacteristicProxy[bytes]] = None
media_player_icon_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
media_player_icon_url: Optional[gatt_client.CharacteristicProxy[bytes]] = None
track_changed: Optional[gatt_client.CharacteristicProxy[bytes]] = None
track_title: Optional[gatt_client.CharacteristicProxy[bytes]] = None
track_duration: Optional[gatt_client.CharacteristicProxy[bytes]] = None
track_position: Optional[gatt_client.CharacteristicProxy[bytes]] = None
playback_speed: Optional[gatt_client.CharacteristicProxy[bytes]] = None
seeking_speed: Optional[gatt_client.CharacteristicProxy[bytes]] = None
current_track_segments_object_id: Optional[
gatt_client.CharacteristicProxy[bytes]
] = None
current_track_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
next_track_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
parent_group_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
current_group_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
playing_order: Optional[gatt_client.CharacteristicProxy[bytes]] = None
playing_orders_supported: Optional[gatt_client.CharacteristicProxy[bytes]] = None
media_state: Optional[gatt_client.CharacteristicProxy[bytes]] = None
media_control_point: Optional[gatt_client.CharacteristicProxy[bytes]] = None
media_control_point_opcodes_supported: Optional[
gatt_client.CharacteristicProxy[bytes]
] = None
search_control_point: Optional[gatt_client.CharacteristicProxy[bytes]] = None
search_results_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
content_control_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
if TYPE_CHECKING:
media_control_point_notifications: asyncio.Queue[bytes]
@@ -428,20 +438,20 @@ class MediaControlServiceProxy(
self.media_control_point_notifications.put_nowait(data)
def _on_media_state(self, data: bytes) -> None:
self.emit('media_state', MediaState(data[0]))
self.emit(self.EVENT_MEDIA_STATE, MediaState(data[0]))
def _on_track_changed(self, data: bytes) -> None:
del data
self.emit('track_changed')
self.emit(self.EVENT_TRACK_CHANGED)
def _on_track_title(self, data: bytes) -> None:
self.emit('track_title', data.decode("utf-8"))
self.emit(self.EVENT_TRACK_TITLE, data.decode("utf-8"))
def _on_track_duration(self, data: bytes) -> None:
self.emit('track_duration', struct.unpack_from('<i', data)[0])
self.emit(self.EVENT_TRACK_DURATION, struct.unpack_from('<i', data)[0])
def _on_track_position(self, data: bytes) -> None:
self.emit('track_position', struct.unpack_from('<i', data)[0])
self.emit(self.EVENT_TRACK_POSITION, struct.unpack_from('<i', data)[0])
class GenericMediaControlServiceProxy(MediaControlServiceProxy):

View File

@@ -25,6 +25,7 @@ from typing import Optional, Sequence, Union
from bumble.profiles.bap import AudioLocation, CodecSpecificCapabilities, ContextType
from bumble.profiles import le_audio
from bumble import gatt
from bumble import gatt_adapters
from bumble import gatt_client
from bumble import hci
@@ -103,12 +104,12 @@ class PacRecord:
class PublishedAudioCapabilitiesService(gatt.TemplateService):
UUID = gatt.GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE
sink_pac: Optional[gatt.Characteristic]
sink_audio_locations: Optional[gatt.Characteristic]
source_pac: Optional[gatt.Characteristic]
source_audio_locations: Optional[gatt.Characteristic]
available_audio_contexts: gatt.Characteristic
supported_audio_contexts: gatt.Characteristic
sink_pac: Optional[gatt.Characteristic[bytes]]
sink_audio_locations: Optional[gatt.Characteristic[bytes]]
source_pac: Optional[gatt.Characteristic[bytes]]
source_audio_locations: Optional[gatt.Characteristic[bytes]]
available_audio_contexts: gatt.Characteristic[bytes]
supported_audio_contexts: gatt.Characteristic[bytes]
def __init__(
self,
@@ -185,34 +186,42 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService):
class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = PublishedAudioCapabilitiesService
sink_pac: Optional[gatt.DelegatedCharacteristicAdapter] = None
sink_audio_locations: Optional[gatt.DelegatedCharacteristicAdapter] = None
source_pac: Optional[gatt.DelegatedCharacteristicAdapter] = None
source_audio_locations: Optional[gatt.DelegatedCharacteristicAdapter] = None
available_audio_contexts: gatt.DelegatedCharacteristicAdapter
supported_audio_contexts: gatt.DelegatedCharacteristicAdapter
sink_pac: Optional[gatt_client.CharacteristicProxy[list[PacRecord]]] = None
sink_audio_locations: Optional[gatt_client.CharacteristicProxy[AudioLocation]] = (
None
)
source_pac: Optional[gatt_client.CharacteristicProxy[list[PacRecord]]] = None
source_audio_locations: Optional[gatt_client.CharacteristicProxy[AudioLocation]] = (
None
)
available_audio_contexts: gatt_client.CharacteristicProxy[tuple[ContextType, ...]]
supported_audio_contexts: gatt_client.CharacteristicProxy[tuple[ContextType, ...]]
def __init__(self, service_proxy: gatt_client.ServiceProxy):
self.service_proxy = service_proxy
self.available_audio_contexts = gatt.DelegatedCharacteristicAdapter(
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
),
decode=lambda x: tuple(map(ContextType, struct.unpack('<HH', x))),
self.available_audio_contexts = (
gatt_adapters.DelegatedCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
),
decode=lambda x: tuple(map(ContextType, struct.unpack('<HH', x))),
)
)
self.supported_audio_contexts = gatt.DelegatedCharacteristicAdapter(
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
),
decode=lambda x: tuple(map(ContextType, struct.unpack('<HH', x))),
self.supported_audio_contexts = (
gatt_adapters.DelegatedCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
),
decode=lambda x: tuple(map(ContextType, struct.unpack('<HH', x))),
)
)
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SINK_PAC_CHARACTERISTIC
):
self.sink_pac = gatt.DelegatedCharacteristicAdapter(
self.sink_pac = gatt_adapters.DelegatedCharacteristicProxyAdapter(
characteristics[0],
decode=PacRecord.list_from_bytes,
)
@@ -220,7 +229,7 @@ class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SOURCE_PAC_CHARACTERISTIC
):
self.source_pac = gatt.DelegatedCharacteristicAdapter(
self.source_pac = gatt_adapters.DelegatedCharacteristicProxyAdapter(
characteristics[0],
decode=PacRecord.list_from_bytes,
)
@@ -228,15 +237,19 @@ class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC
):
self.sink_audio_locations = gatt.DelegatedCharacteristicAdapter(
characteristics[0],
decode=lambda x: AudioLocation(struct.unpack('<I', x)[0]),
self.sink_audio_locations = (
gatt_adapters.DelegatedCharacteristicProxyAdapter(
characteristics[0],
decode=lambda x: AudioLocation(struct.unpack('<I', x)[0]),
)
)
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC
):
self.source_audio_locations = gatt.DelegatedCharacteristicAdapter(
characteristics[0],
decode=lambda x: AudioLocation(struct.unpack('<I', x)[0]),
self.source_audio_locations = (
gatt_adapters.DelegatedCharacteristicProxyAdapter(
characteristics[0],
decode=lambda x: AudioLocation(struct.unpack('<I', x)[0]),
)
)

View File

@@ -40,7 +40,7 @@ class PublicBroadcastAnnouncement:
def from_bytes(cls, data: bytes) -> Self:
features = cls.Features(data[0])
metadata_length = data[1]
metadata_ltv = data[1 : 1 + metadata_length]
metadata_ltv = data[2 : 2 + metadata_length]
return cls(
features=features, metadata=le_audio.Metadata.from_bytes(metadata_ltv)
)

View File

@@ -24,11 +24,11 @@ import struct
from bumble.gatt import (
TemplateService,
Characteristic,
DelegatedCharacteristicAdapter,
GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE,
GATT_TMAP_ROLE_CHARACTERISTIC,
)
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
from bumble.gatt_adapters import DelegatedCharacteristicProxyAdapter
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
# -----------------------------------------------------------------------------
@@ -53,6 +53,8 @@ class Role(enum.IntFlag):
class TelephonyAndMediaAudioService(TemplateService):
UUID = GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE
role_characteristic: Characteristic[bytes]
def __init__(self, role: Role):
self.role_characteristic = Characteristic(
GATT_TMAP_ROLE_CHARACTERISTIC,
@@ -68,12 +70,12 @@ class TelephonyAndMediaAudioService(TemplateService):
class TelephonyAndMediaAudioServiceProxy(ProfileServiceProxy):
SERVICE_CLASS = TelephonyAndMediaAudioService
role: DelegatedCharacteristicAdapter
role: CharacteristicProxy[Role]
def __init__(self, service_proxy: ServiceProxy):
self.service_proxy = service_proxy
self.role = DelegatedCharacteristicAdapter(
self.role = DelegatedCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_TMAP_ROLE_CHARACTERISTIC
),

View File

@@ -23,8 +23,10 @@ import enum
from typing import Optional, Sequence
from bumble import att
from bumble import utils
from bumble import device
from bumble import gatt
from bumble import gatt_adapters
from bumble import gatt_client
@@ -89,9 +91,11 @@ class VolumeState:
class VolumeControlService(gatt.TemplateService):
UUID = gatt.GATT_VOLUME_CONTROL_SERVICE
volume_state: gatt.Characteristic
volume_control_point: gatt.Characteristic
volume_flags: gatt.Characteristic
EVENT_VOLUME_STATE_CHANGE = "volume_state_change"
volume_state: gatt.Characteristic[bytes]
volume_control_point: gatt.Characteristic[bytes]
volume_flags: gatt.Characteristic[bytes]
volume_setting: int
muted: int
@@ -159,11 +163,12 @@ class VolumeControlService(gatt.TemplateService):
handler = getattr(self, '_on_' + opcode.name.lower())
if handler(*value[2:]):
self.change_counter = (self.change_counter + 1) % 256
connection.abort_on(
utils.cancel_on_event(
connection,
'disconnection',
connection.device.notify_subscribers(attribute=self.volume_state),
)
self.emit('volume_state_change')
self.emit(self.EVENT_VOLUME_STATE_CHANGE)
def _on_relative_volume_down(self) -> bool:
old_volume = self.volume_setting
@@ -209,14 +214,14 @@ class VolumeControlService(gatt.TemplateService):
class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = VolumeControlService
volume_control_point: gatt_client.CharacteristicProxy
volume_state: gatt.SerializableCharacteristicAdapter
volume_flags: gatt.DelegatedCharacteristicAdapter
volume_control_point: gatt_client.CharacteristicProxy[bytes]
volume_state: gatt_client.CharacteristicProxy[VolumeState]
volume_flags: gatt_client.CharacteristicProxy[VolumeFlags]
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
self.service_proxy = service_proxy
self.volume_state = gatt.SerializableCharacteristicAdapter(
self.volume_state = gatt_adapters.SerializableCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_VOLUME_STATE_CHARACTERISTIC
),
@@ -227,7 +232,7 @@ class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy):
gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC
)
self.volume_flags = gatt.DelegatedCharacteristicAdapter(
self.volume_flags = gatt_adapters.DelegatedCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC
),

View File

@@ -24,19 +24,21 @@ from bumble.device import Connection
from bumble.att import ATT_Error
from bumble.gatt import (
Characteristic,
DelegatedCharacteristicAdapter,
TemplateService,
CharacteristicValue,
SerializableCharacteristicAdapter,
UTF8CharacteristicAdapter,
GATT_VOLUME_OFFSET_CONTROL_SERVICE,
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
GATT_AUDIO_LOCATION_CHARACTERISTIC,
GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC,
GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
)
from bumble.gatt_adapters import (
DelegatedCharacteristicProxyAdapter,
SerializableCharacteristicProxyAdapter,
UTF8CharacteristicProxyAdapter,
)
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
from bumble.utils import OpenIntEnum
from bumble import utils
from bumble.profiles.bap import AudioLocation
# -----------------------------------------------------------------------------
@@ -48,11 +50,11 @@ MAX_VOLUME_OFFSET = 255
CHANGE_COUNTER_MAX_VALUE = 0xFF
class SetVolumeOffsetOpCode(OpenIntEnum):
class SetVolumeOffsetOpCode(utils.OpenIntEnum):
SET_VOLUME_OFFSET = 0x01
class ErrorCode(OpenIntEnum):
class ErrorCode(utils.OpenIntEnum):
"""
See Volume Offset Control Service 1.6. Application error codes.
"""
@@ -67,7 +69,7 @@ class ErrorCode(OpenIntEnum):
class VolumeOffsetState:
volume_offset: int = 0
change_counter: int = 0
attribute_value: Optional[CharacteristicValue] = None
attribute: Optional[Characteristic] = None
def __bytes__(self) -> bytes:
return struct.pack('<hB', self.volume_offset, self.change_counter)
@@ -81,8 +83,8 @@ class VolumeOffsetState:
self.change_counter = (self.change_counter + 1) % (CHANGE_COUNTER_MAX_VALUE + 1)
async def notify_subscribers_via_connection(self, connection: Connection) -> None:
assert self.attribute_value is not None
await connection.device.notify_subscribers(attribute=self.attribute_value)
assert self.attribute is not None
await connection.device.notify_subscribers(attribute=self.attribute)
def on_read(self, _connection: Optional[Connection]) -> bytes:
return bytes(self)
@@ -91,7 +93,7 @@ class VolumeOffsetState:
@dataclass
class VocsAudioLocation:
audio_location: AudioLocation = AudioLocation.NOT_ALLOWED
attribute_value: Optional[CharacteristicValue] = None
attribute: Optional[Characteristic] = None
def __bytes__(self) -> bytes:
return struct.pack('<I', self.audio_location)
@@ -106,10 +108,10 @@ class VocsAudioLocation:
async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
assert connection
assert self.attribute_value
assert self.attribute
self.audio_location = AudioLocation(int.from_bytes(value, 'little'))
await connection.device.notify_subscribers(attribute=self.attribute_value)
await connection.device.notify_subscribers(attribute=self.attribute)
@dataclass
@@ -148,7 +150,7 @@ class VolumeOffsetControlPoint:
@dataclass
class AudioOutputDescription:
audio_output_description: str = ''
attribute_value: Optional[CharacteristicValue] = None
attribute: Optional[Characteristic] = None
@classmethod
def from_bytes(cls, data: bytes):
@@ -162,10 +164,10 @@ class AudioOutputDescription:
async def on_write(self, connection: Optional[Connection], value: bytes) -> None:
assert connection
assert self.attribute_value
assert self.attribute
self.audio_output_description = value.decode('utf-8')
await connection.device.notify_subscribers(attribute=self.attribute_value)
await connection.device.notify_subscribers(attribute=self.attribute)
# -----------------------------------------------------------------------------
@@ -197,7 +199,7 @@ class VolumeOffsetControlService(TemplateService):
VolumeOffsetControlPoint(self.volume_offset_state)
)
self.volume_offset_state_characteristic = Characteristic(
self.volume_offset_state_characteristic: Characteristic[bytes] = Characteristic(
uuid=GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
properties=(
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY
@@ -206,7 +208,7 @@ class VolumeOffsetControlService(TemplateService):
value=CharacteristicValue(read=self.volume_offset_state.on_read),
)
self.audio_location_characteristic = Characteristic(
self.audio_location_characteristic: Characteristic[bytes] = Characteristic(
uuid=GATT_AUDIO_LOCATION_CHARACTERISTIC,
properties=(
Characteristic.Properties.READ
@@ -222,33 +224,39 @@ class VolumeOffsetControlService(TemplateService):
write=self.audio_location.on_write,
),
)
self.audio_location.attribute_value = self.audio_location_characteristic.value
self.audio_location.attribute = self.audio_location_characteristic
self.volume_offset_control_point_characteristic = Characteristic(
uuid=GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC,
properties=Characteristic.Properties.WRITE,
permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
value=CharacteristicValue(write=self.volume_offset_control_point.on_write),
self.volume_offset_control_point_characteristic: Characteristic[bytes] = (
Characteristic(
uuid=GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC,
properties=Characteristic.Properties.WRITE,
permissions=Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
value=CharacteristicValue(
write=self.volume_offset_control_point.on_write
),
)
)
self.audio_output_description_characteristic = Characteristic(
uuid=GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
properties=(
Characteristic.Properties.READ
| Characteristic.Properties.NOTIFY
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE
),
permissions=(
Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
| Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
),
value=CharacteristicValue(
read=self.audio_output_description.on_read,
write=self.audio_output_description.on_write,
),
self.audio_output_description_characteristic: Characteristic[bytes] = (
Characteristic(
uuid=GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
properties=(
Characteristic.Properties.READ
| Characteristic.Properties.NOTIFY
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE
),
permissions=(
Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
| Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
),
value=CharacteristicValue(
read=self.audio_output_description.on_read,
write=self.audio_output_description.on_write,
),
)
)
self.audio_output_description.attribute_value = (
self.audio_output_description_characteristic.value
self.audio_output_description.attribute = (
self.audio_output_description_characteristic
)
super().__init__(
@@ -271,14 +279,14 @@ class VolumeOffsetControlServiceProxy(ProfileServiceProxy):
def __init__(self, service_proxy: ServiceProxy) -> None:
self.service_proxy = service_proxy
self.volume_offset_state = SerializableCharacteristicAdapter(
self.volume_offset_state = SerializableCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC
),
VolumeOffsetState,
)
self.audio_location = DelegatedCharacteristicAdapter(
self.audio_location = DelegatedCharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_LOCATION_CHARACTERISTIC
),
@@ -292,7 +300,7 @@ class VolumeOffsetControlServiceProxy(ProfileServiceProxy):
)
)
self.audio_output_description = UTF8CharacteristicAdapter(
self.audio_output_description = UTF8CharacteristicProxyAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC
)

View File

@@ -25,16 +25,16 @@ import enum
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
from typing_extensions import Self
from pyee import EventEmitter
from bumble import core
from bumble import l2cap
from bumble import sdp
from .colors import color
from .core import (
from bumble import utils
from bumble.colors import color
from bumble.core import (
UUID,
BT_RFCOMM_PROTOCOL_ID,
BT_BR_EDR_TRANSPORT,
PhysicalTransport,
BT_L2CAP_PROTOCOL_ID,
InvalidArgumentError,
InvalidStateError,
@@ -441,7 +441,10 @@ class RFCOMM_MCC_MSC:
# -----------------------------------------------------------------------------
class DLC(EventEmitter):
class DLC(utils.EventEmitter):
EVENT_OPEN = "open"
EVENT_CLOSE = "close"
class State(enum.IntEnum):
INIT = 0x00
CONNECTING = 0x01
@@ -529,7 +532,7 @@ class DLC(EventEmitter):
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.State.CONNECTED)
self.emit('open')
self.emit(self.EVENT_OPEN)
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state == DLC.State.CONNECTING:
@@ -550,7 +553,7 @@ class DLC(EventEmitter):
self.disconnection_result.set_result(None)
self.disconnection_result = None
self.multiplexer.on_dlc_disconnection(self)
self.emit('close')
self.emit(self.EVENT_CLOSE)
else:
logger.warning(
color(
@@ -733,7 +736,7 @@ class DLC(EventEmitter):
self.disconnection_result.cancel()
self.disconnection_result = None
self.change_state(DLC.State.RESET)
self.emit('close')
self.emit(self.EVENT_CLOSE)
def __str__(self) -> str:
return (
@@ -749,7 +752,7 @@ class DLC(EventEmitter):
# -----------------------------------------------------------------------------
class Multiplexer(EventEmitter):
class Multiplexer(utils.EventEmitter):
class Role(enum.IntEnum):
INITIATOR = 0x00
RESPONDER = 0x01
@@ -763,6 +766,8 @@ class Multiplexer(EventEmitter):
DISCONNECTED = 0x05
RESET = 0x06
EVENT_DLC = "dlc"
connection_result: Optional[asyncio.Future]
disconnection_result: Optional[asyncio.Future]
open_result: Optional[asyncio.Future]
@@ -785,7 +790,7 @@ class Multiplexer(EventEmitter):
# Become a sink for the L2CAP channel
l2cap_channel.sink = self.on_pdu
l2cap_channel.on('close', self.on_l2cap_channel_close)
l2cap_channel.on(l2cap_channel.EVENT_CLOSE, self.on_l2cap_channel_close)
def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
@@ -845,7 +850,7 @@ class Multiplexer(EventEmitter):
self.open_result.set_exception(
core.ConnectionError(
core.ConnectionError.CONNECTION_REFUSED,
BT_BR_EDR_TRANSPORT,
PhysicalTransport.BR_EDR,
self.l2cap_channel.connection.peer_address,
'rfcomm',
)
@@ -901,7 +906,7 @@ class Multiplexer(EventEmitter):
self.dlcs[pn.dlci] = dlc
# Re-emit the handshake completion event
dlc.on('open', lambda: self.emit('dlc', dlc))
dlc.on(dlc.EVENT_OPEN, lambda: self.emit(self.EVENT_DLC, dlc))
# Respond to complete the handshake
dlc.accept()
@@ -1075,7 +1080,9 @@ class Client:
# -----------------------------------------------------------------------------
class Server(EventEmitter):
class Server(utils.EventEmitter):
EVENT_START = "start"
def __init__(
self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
) -> None:
@@ -1122,7 +1129,9 @@ class Server(EventEmitter):
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
l2cap_channel.on(
l2cap_channel.EVENT_OPEN, lambda: self.on_l2cap_channel_open(l2cap_channel)
)
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
@@ -1130,10 +1139,10 @@ class Server(EventEmitter):
# Create a new multiplexer for the channel
multiplexer = Multiplexer(l2cap_channel, Multiplexer.Role.RESPONDER)
multiplexer.acceptor = self.accept_dlc
multiplexer.on('dlc', self.on_dlc)
multiplexer.on(multiplexer.EVENT_DLC, self.on_dlc)
# Notify
self.emit('start', multiplexer)
self.emit(self.EVENT_START, multiplexer)
def accept_dlc(self, channel_number: int) -> Optional[Tuple[int, int]]:
return self.dlc_configs.get(channel_number)

View File

@@ -33,7 +33,7 @@ from bumble.core import (
from bumble.hci import HCI_Object, name_or_number, key_with_value
if TYPE_CHECKING:
from .device import Device, Connection
from bumble.device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
@@ -130,7 +130,7 @@ SDP_ATTRIBUTE_ID_NAMES = {
SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
# To be used in searches where an attribute ID list allows a range to be specified
SDP_ALL_ATTRIBUTES_RANGE = (0x0000FFFF, 4) # Express this as tuple so we can convey the desired encoding size
SDP_ALL_ATTRIBUTES_RANGE = (0x0000, 0xFFFF)
# fmt: on
# pylint: enable=line-too-long

View File

@@ -41,26 +41,25 @@ from typing import (
cast,
)
from pyee import EventEmitter
from .colors import color
from .hci import (
from bumble.colors import color
from bumble.hci import (
Address,
Role,
HCI_LE_Enable_Encryption_Command,
HCI_Object,
key_with_value,
)
from .core import (
BT_BR_EDR_TRANSPORT,
BT_CENTRAL_ROLE,
BT_LE_TRANSPORT,
from bumble.core import (
PhysicalTransport,
AdvertisingData,
InvalidArgumentError,
ProtocolError,
name_or_number,
)
from .keys import PairingKeys
from . import crypto
from bumble.keys import PairingKeys
from bumble import crypto
from bumble import utils
if TYPE_CHECKING:
from bumble.device import Connection, Device
@@ -725,12 +724,13 @@ class Session:
self.is_responder = not self.is_initiator
# Listen for connection events
connection.on('disconnection', self.on_disconnection)
connection.on(connection.EVENT_DISCONNECTION, self.on_disconnection)
connection.on(
'connection_encryption_change', self.on_connection_encryption_change
connection.EVENT_CONNECTION_ENCRYPTION_CHANGE,
self.on_connection_encryption_change,
)
connection.on(
'connection_encryption_key_refresh',
connection.EVENT_CONNECTION_ENCRYPTION_KEY_REFRESH,
self.on_connection_encryption_key_refresh,
)
@@ -857,7 +857,7 @@ class Session:
initiator_io_capability: int,
responder_io_capability: int,
) -> None:
if self.connection.transport == BT_BR_EDR_TRANSPORT:
if self.connection.transport == PhysicalTransport.BR_EDR:
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
return
if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0):
@@ -900,7 +900,7 @@ class Session:
self.send_pairing_failed(SMP_CONFIRM_VALUE_FAILED_ERROR)
self.connection.abort_on('disconnection', prompt())
utils.cancel_on_event(self.connection, 'disconnection', prompt())
def prompt_user_for_numeric_comparison(
self, code: int, next_steps: Callable[[], None]
@@ -919,7 +919,7 @@ class Session:
self.send_pairing_failed(SMP_CONFIRM_VALUE_FAILED_ERROR)
self.connection.abort_on('disconnection', prompt())
utils.cancel_on_event(self.connection, 'disconnection', prompt())
def prompt_user_for_number(self, next_steps: Callable[[int], None]) -> None:
async def prompt() -> None:
@@ -936,7 +936,7 @@ class Session:
logger.warning(f'exception while prompting: {error}')
self.send_pairing_failed(SMP_PASSKEY_ENTRY_FAILED_ERROR)
self.connection.abort_on('disconnection', prompt())
utils.cancel_on_event(self.connection, 'disconnection', prompt())
def display_passkey(self) -> None:
# Generate random Passkey/PIN code
@@ -951,7 +951,8 @@ class Session:
logger.debug(f'TK from passkey = {self.tk.hex()}')
try:
self.connection.abort_on(
utils.cancel_on_event(
self.connection,
'disconnection',
self.pairing_config.delegate.display_number(self.passkey, digits=6),
)
@@ -1050,7 +1051,7 @@ class Session:
)
# Perform the next steps asynchronously in case we need to wait for input
self.connection.abort_on('disconnection', next_steps())
utils.cancel_on_event(self.connection, 'disconnection', next_steps())
else:
confirm_value = crypto.c1(
self.tk,
@@ -1170,11 +1171,11 @@ class Session:
if self.is_initiator:
# CTKD: Derive LTK from LinkKey
if (
self.connection.transport == BT_BR_EDR_TRANSPORT
self.connection.transport == PhysicalTransport.BR_EDR
and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
):
self.ctkd_task = self.connection.abort_on(
'disconnection', self.get_link_key_and_derive_ltk()
self.ctkd_task = utils.cancel_on_event(
self.connection, 'disconnection', self.get_link_key_and_derive_ltk()
)
elif not self.sc:
# Distribute the LTK, EDIV and RAND
@@ -1209,11 +1210,11 @@ class Session:
else:
# CTKD: Derive LTK from LinkKey
if (
self.connection.transport == BT_BR_EDR_TRANSPORT
self.connection.transport == PhysicalTransport.BR_EDR
and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
):
self.ctkd_task = self.connection.abort_on(
'disconnection', self.get_link_key_and_derive_ltk()
self.ctkd_task = utils.cancel_on_event(
self.connection, 'disconnection', self.get_link_key_and_derive_ltk()
)
# Distribute the LTK, EDIV and RAND
elif not self.sc:
@@ -1248,7 +1249,7 @@ class Session:
def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None:
# Set our expectations for what to wait for in the key distribution phase
self.peer_expected_distributions = []
if not self.sc and self.connection.transport == BT_LE_TRANSPORT:
if not self.sc and self.connection.transport == PhysicalTransport.LE:
if key_distribution_flags & SMP_ENC_KEY_DISTRIBUTION_FLAG != 0:
self.peer_expected_distributions.append(
SMP_Encryption_Information_Command
@@ -1305,15 +1306,20 @@ class Session:
# Wait for the pairing process to finish
assert self.pairing_result
await self.connection.abort_on('disconnection', self.pairing_result)
await utils.cancel_on_event(
self.connection, 'disconnection', self.pairing_result
)
def on_disconnection(self, _: int) -> None:
self.connection.remove_listener('disconnection', self.on_disconnection)
self.connection.remove_listener(
'connection_encryption_change', self.on_connection_encryption_change
self.connection.EVENT_DISCONNECTION, self.on_disconnection
)
self.connection.remove_listener(
'connection_encryption_key_refresh',
self.connection.EVENT_CONNECTION_ENCRYPTION_CHANGE,
self.on_connection_encryption_change,
)
self.connection.remove_listener(
self.connection.EVENT_CONNECTION_ENCRYPTION_KEY_REFRESH,
self.on_connection_encryption_key_refresh,
)
self.manager.on_session_end(self)
@@ -1323,10 +1329,10 @@ class Session:
if self.is_initiator:
self.distribute_keys()
self.connection.abort_on('disconnection', self.on_pairing())
utils.cancel_on_event(self.connection, 'disconnection', self.on_pairing())
def on_connection_encryption_change(self) -> None:
if self.connection.is_encrypted:
if self.connection.is_encrypted and not self.completed:
if self.is_responder:
# The responder distributes its keys first, the initiator later
self.distribute_keys()
@@ -1365,7 +1371,7 @@ class Session:
keys = PairingKeys()
keys.address_type = peer_address.address_type
authenticated = self.pairing_method != PairingMethod.JUST_WORKS
if self.sc or self.connection.transport == BT_BR_EDR_TRANSPORT:
if self.sc or self.connection.transport == PhysicalTransport.BR_EDR:
keys.ltk = PairingKeys.Key(value=self.ltk, authenticated=authenticated)
else:
our_ltk_key = PairingKeys.Key(
@@ -1432,8 +1438,10 @@ class Session:
def on_smp_pairing_request_command(
self, command: SMP_Pairing_Request_Command
) -> None:
self.connection.abort_on(
'disconnection', self.on_smp_pairing_request_command_async(command)
utils.cancel_on_event(
self.connection,
'disconnection',
self.on_smp_pairing_request_command_async(command),
)
async def on_smp_pairing_request_command_async(
@@ -1506,7 +1514,7 @@ class Session:
# CTKD over BR/EDR should happen after the connection has been encrypted,
# so when receiving pairing requests, responder should start distributing keys
if (
self.connection.transport == BT_BR_EDR_TRANSPORT
self.connection.transport == PhysicalTransport.BR_EDR
and self.connection.is_encrypted
and self.is_responder
and accepted
@@ -1878,7 +1886,7 @@ class Session:
self.wait_before_continuing = None
self.send_pairing_dhkey_check_command()
self.connection.abort_on('disconnection', next_steps())
utils.cancel_on_event(self.connection, 'disconnection', next_steps())
else:
self.send_pairing_dhkey_check_command()
else:
@@ -1922,7 +1930,7 @@ class Session:
# -----------------------------------------------------------------------------
class Manager(EventEmitter):
class Manager(utils.EventEmitter):
'''
Implements the Initiator and Responder roles of the Security Manager Protocol
'''
@@ -1950,13 +1958,15 @@ class Manager(EventEmitter):
f'>>> Sending SMP Command on connection [0x{connection.handle:04X}] '
f'{connection.peer_address}: {command}'
)
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
cid = (
SMP_BR_CID if connection.transport == PhysicalTransport.BR_EDR else SMP_CID
)
connection.send_l2cap_pdu(cid, bytes(command))
def on_smp_security_request_command(
self, connection: Connection, request: SMP_Security_Request_Command
) -> None:
connection.emit('security_request', request.auth_req)
connection.emit(connection.EVENT_SECURITY_REQUEST, request.auth_req)
def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None:
# Parse the L2CAP payload into an SMP Command object
@@ -1975,7 +1985,7 @@ class Manager(EventEmitter):
# Look for a session with this connection, and create one if none exists
if not (session := self.sessions.get(connection.handle)):
if connection.role == BT_CENTRAL_ROLE:
if connection.role == Role.CENTRAL:
logger.warning('Remote starts pairing as Peripheral!')
pairing_config = self.pairing_config_factory(connection)
session = self.session_proxy(
@@ -1995,7 +2005,7 @@ class Manager(EventEmitter):
async def pair(self, connection: Connection) -> None:
# TODO: check if there's already a session for this connection
if connection.role != BT_CENTRAL_ROLE:
if connection.role != Role.CENTRAL:
logger.warning('Start pairing as Peripheral!')
pairing_config = self.pairing_config_factory(connection)
session = self.session_proxy(

View File

@@ -20,8 +20,13 @@ import logging
import os
from typing import Optional
from .common import Transport, AsyncPipeSink, SnoopingTransport, TransportSpecError
from ..snoop import create_snooper
from bumble.transport.common import (
Transport,
AsyncPipeSink,
SnoopingTransport,
TransportSpecError,
)
from bumble.snoop import create_snooper
# -----------------------------------------------------------------------------
# Logging
@@ -108,80 +113,80 @@ async def _open_transport(scheme: str, spec: Optional[str]) -> Transport:
# pylint: disable=too-many-return-statements
if scheme == 'serial' and spec:
from .serial import open_serial_transport
from bumble.transport.serial import open_serial_transport
return await open_serial_transport(spec)
if scheme == 'udp' and spec:
from .udp import open_udp_transport
from bumble.transport.udp import open_udp_transport
return await open_udp_transport(spec)
if scheme == 'tcp-client' and spec:
from .tcp_client import open_tcp_client_transport
from bumble.transport.tcp_client import open_tcp_client_transport
return await open_tcp_client_transport(spec)
if scheme == 'tcp-server' and spec:
from .tcp_server import open_tcp_server_transport
from bumble.transport.tcp_server import open_tcp_server_transport
return await open_tcp_server_transport(spec)
if scheme == 'ws-client' and spec:
from .ws_client import open_ws_client_transport
from bumble.transport.ws_client import open_ws_client_transport
return await open_ws_client_transport(spec)
if scheme == 'ws-server' and spec:
from .ws_server import open_ws_server_transport
from bumble.transport.ws_server import open_ws_server_transport
return await open_ws_server_transport(spec)
if scheme == 'pty':
from .pty import open_pty_transport
from bumble.transport.pty import open_pty_transport
return await open_pty_transport(spec)
if scheme == 'file':
from .file import open_file_transport
from bumble.transport.file import open_file_transport
assert spec is not None
return await open_file_transport(spec)
if scheme == 'vhci':
from .vhci import open_vhci_transport
from bumble.transport.vhci import open_vhci_transport
return await open_vhci_transport(spec)
if scheme == 'hci-socket':
from .hci_socket import open_hci_socket_transport
from bumble.transport.hci_socket import open_hci_socket_transport
return await open_hci_socket_transport(spec)
if scheme == 'usb':
from .usb import open_usb_transport
from bumble.transport.usb import open_usb_transport
assert spec
return await open_usb_transport(spec)
if scheme == 'pyusb':
from .pyusb import open_pyusb_transport
from bumble.transport.pyusb import open_pyusb_transport
assert spec
return await open_pyusb_transport(spec)
if scheme == 'android-emulator':
from .android_emulator import open_android_emulator_transport
from bumble.transport.android_emulator import open_android_emulator_transport
return await open_android_emulator_transport(spec)
if scheme == 'android-netsim':
from .android_netsim import open_android_netsim_transport
from bumble.transport.android_netsim import open_android_netsim_transport
return await open_android_netsim_transport(spec)
if scheme == 'unix':
from .unix import open_unix_client_transport
from bumble.transport.unix import open_unix_client_transport
assert spec
return await open_unix_client_transport(spec)
@@ -204,8 +209,8 @@ async def open_transport_or_link(name: str) -> Transport:
"""
if name.startswith('link-relay:'):
logger.warning('Link Relay has been deprecated.')
from ..controller import Controller
from ..link import RemoteLink # lazy import
from bumble.controller import Controller
from bumble.link import RemoteLink # lazy import
link = RemoteLink(name[11:])
await link.wait_until_connected()

View File

@@ -20,7 +20,7 @@ import grpc.aio
from typing import Optional, Union
from .common import (
from bumble.transport.common import (
PumpedTransport,
PumpedPacketSource,
PumpedPacketSink,
@@ -29,9 +29,13 @@ from .common import (
)
# pylint: disable=no-name-in-module
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
from .grpc_protobuf.emulated_bluetooth_packets_pb2 import HCIPacket
from .grpc_protobuf.emulated_bluetooth_vhci_pb2_grpc import VhciForwardingServiceStub
from bumble.transport.grpc_protobuf.emulated_bluetooth_pb2_grpc import (
EmulatedBluetoothServiceStub,
)
from bumble.transport.grpc_protobuf.emulated_bluetooth_packets_pb2 import HCIPacket
from bumble.transport.grpc_protobuf.emulated_bluetooth_vhci_pb2_grpc import (
VhciForwardingServiceStub,
)
# -----------------------------------------------------------------------------

View File

@@ -38,15 +38,18 @@ from bumble.transport.common import (
)
# pylint: disable=no-name-in-module
from .grpc_protobuf.netsim.packet_streamer_pb2_grpc import (
from bumble.transport.grpc_protobuf.netsim.packet_streamer_pb2_grpc import (
PacketStreamerStub,
PacketStreamerServicer,
add_PacketStreamerServicer_to_server,
)
from .grpc_protobuf.netsim.packet_streamer_pb2 import PacketRequest, PacketResponse
from .grpc_protobuf.netsim.hci_packet_pb2 import HCIPacket
from .grpc_protobuf.netsim.startup_pb2 import Chip, ChipInfo, DeviceInfo
from .grpc_protobuf.netsim.common_pb2 import ChipKind
from bumble.transport.grpc_protobuf.netsim.packet_streamer_pb2 import (
PacketRequest,
PacketResponse,
)
from bumble.transport.grpc_protobuf.netsim.hci_packet_pb2 import HCIPacket
from bumble.transport.grpc_protobuf.netsim.startup_pb2 import Chip, ChipInfo, DeviceInfo
from bumble.transport.grpc_protobuf.netsim.common_pb2 import ChipKind
# -----------------------------------------------------------------------------

View File

@@ -139,6 +139,7 @@ class PacketParser:
packet_type
) or self.extended_packet_info.get(packet_type)
if self.packet_info is None:
self.reset()
raise core.InvalidPacketError(
f'invalid packet type {packet_type}'
)
@@ -302,7 +303,10 @@ class ParserSource(BaseSource):
# -----------------------------------------------------------------------------
class StreamPacketSource(asyncio.Protocol, ParserSource):
def data_received(self, data: bytes) -> None:
self.parser.feed_data(data)
try:
self.parser.feed_data(data)
except core.InvalidPacketError:
logger.warning("invalid packet, ignoring data")
# -----------------------------------------------------------------------------

View File

@@ -19,7 +19,7 @@ import asyncio
import io
import logging
from .common import Transport, StreamPacketSource, StreamPacketSink
from bumble.transport.common import Transport, StreamPacketSource, StreamPacketSink
# -----------------------------------------------------------------------------
# Logging

View File

@@ -25,7 +25,7 @@ import collections
from typing import Optional
from .common import Transport, ParserSource
from bumble.transport.common import Transport, ParserSource
# -----------------------------------------------------------------------------

View File

@@ -25,7 +25,7 @@ import logging
from typing import Optional
from .common import Transport, StreamPacketSource, StreamPacketSink
from bumble.transport.common import Transport, StreamPacketSource, StreamPacketSink
# -----------------------------------------------------------------------------
# Logging

View File

@@ -29,9 +29,9 @@ from usb.core import USBError
from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
from usb.legacy import REQ_SET_FEATURE, REQ_CLEAR_FEATURE, CLASS_HUB
from .common import Transport, ParserSource, TransportInitError
from .. import hci
from ..colors import color
from bumble.transport.common import Transport, ParserSource, TransportInitError
from bumble import hci
from bumble.colors import color
# -----------------------------------------------------------------------------

View File

@@ -19,7 +19,7 @@ import asyncio
import logging
import serial_asyncio
from .common import Transport, StreamPacketSource, StreamPacketSink
from bumble.transport.common import Transport, StreamPacketSource, StreamPacketSink
# -----------------------------------------------------------------------------
# Logging

View File

@@ -18,7 +18,7 @@
import asyncio
import logging
from .common import Transport, StreamPacketSource, StreamPacketSink
from bumble.transport.common import Transport, StreamPacketSource, StreamPacketSink
# -----------------------------------------------------------------------------
# Logging

View File

@@ -20,7 +20,7 @@ import asyncio
import logging
import socket
from .common import Transport, StreamPacketSource
from bumble.transport.common import Transport, StreamPacketSource
# -----------------------------------------------------------------------------
# Logging

View File

@@ -18,7 +18,7 @@
import asyncio
import logging
from .common import Transport, ParserSource
from bumble.transport.common import Transport, ParserSource
# -----------------------------------------------------------------------------
# Logging

View File

@@ -18,7 +18,7 @@
import asyncio
import logging
from .common import Transport, StreamPacketSource, StreamPacketSink
from bumble.transport.common import Transport, StreamPacketSource, StreamPacketSink
# -----------------------------------------------------------------------------
# Logging

View File

@@ -115,9 +115,7 @@ async def open_usb_transport(spec: str) -> Transport:
self.acl_out = acl_out
self.acl_out_transfer = device.getTransfer()
self.acl_out_transfer_ready = asyncio.Semaphore(1)
self.packets: asyncio.Queue[bytes] = (
asyncio.Queue()
) # Queue of packets waiting to be sent
self.packets = asyncio.Queue[bytes]() # Queue of packets waiting to be sent
self.loop = asyncio.get_running_loop()
self.queue_task = None
self.cancel_done = self.loop.create_future()

View File

@@ -19,8 +19,8 @@ import logging
from typing import Optional
from .common import Transport
from .file import open_file_transport
from bumble.transport.common import Transport
from bumble.transport.file import open_file_transport
# -----------------------------------------------------------------------------
# Logging

View File

@@ -18,7 +18,12 @@
import logging
import websockets.client
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport, Transport
from bumble.transport.common import (
PumpedPacketSource,
PumpedPacketSink,
PumpedTransport,
Transport,
)
# -----------------------------------------------------------------------------
# Logging

View File

@@ -18,7 +18,7 @@
import logging
import websockets
from .common import Transport, ParserSource, PumpedPacketSink
from bumble.transport.common import Transport, ParserSource, PumpedPacketSink
# -----------------------------------------------------------------------------
# Logging

View File

@@ -38,9 +38,10 @@ from typing import (
)
from typing_extensions import Self
from pyee import EventEmitter
import pyee
import pyee.asyncio
from .colors import color
from bumble.colors import color
# -----------------------------------------------------------------------------
# Logging
@@ -56,6 +57,48 @@ def setup_event_forwarding(emitter, forwarder, event_name):
emitter.on(event_name, emit)
# -----------------------------------------------------------------------------
def wrap_async(function):
"""
Wraps the provided function in an async function.
"""
return functools.partial(async_call, function)
# -----------------------------------------------------------------------------
def deprecated(msg: str):
"""
Throw deprecation warning before execution.
"""
def wrapper(function):
@functools.wraps(function)
def inner(*args, **kwargs):
warnings.warn(msg, DeprecationWarning, stacklevel=2)
return function(*args, **kwargs)
return inner
return wrapper
# -----------------------------------------------------------------------------
def experimental(msg: str):
"""
Throws a future warning before execution.
"""
def wrapper(function):
@functools.wraps(function)
def inner(*args, **kwargs):
warnings.warn(msg, FutureWarning, stacklevel=2)
return function(*args, **kwargs)
return inner
return wrapper
# -----------------------------------------------------------------------------
def composite_listener(cls):
"""
@@ -113,21 +156,23 @@ class EventWatcher:
```
'''
handlers: List[Tuple[EventEmitter, str, Callable[..., Any]]]
handlers: List[Tuple[pyee.EventEmitter, str, Callable[..., Any]]]
def __init__(self) -> None:
self.handlers = []
@overload
def on(
self, emitter: EventEmitter, event: str
self, emitter: pyee.EventEmitter, event: str
) -> Callable[[_Handler], _Handler]: ...
@overload
def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler: ...
def on(
self, emitter: pyee.EventEmitter, event: str, handler: _Handler
) -> _Handler: ...
def on(
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
self, emitter: pyee.EventEmitter, event: str, handler: Optional[_Handler] = None
) -> Union[_Handler, Callable[[_Handler], _Handler]]:
'''Watch an event until the context is closed.
@@ -147,16 +192,16 @@ class EventWatcher:
@overload
def once(
self, emitter: EventEmitter, event: str
self, emitter: pyee.EventEmitter, event: str
) -> Callable[[_Handler], _Handler]: ...
@overload
def once(
self, emitter: EventEmitter, event: str, handler: _Handler
self, emitter: pyee.EventEmitter, event: str, handler: _Handler
) -> _Handler: ...
def once(
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
self, emitter: pyee.EventEmitter, event: str, handler: Optional[_Handler] = None
) -> Union[_Handler, Callable[[_Handler], _Handler]]:
'''Watch an event for once.
@@ -184,38 +229,48 @@ class EventWatcher:
_T = TypeVar('_T')
class AbortableEventEmitter(EventEmitter):
def abort_on(self, event: str, awaitable: Awaitable[_T]) -> Awaitable[_T]:
"""
Set a coroutine or future to abort when an event occur.
"""
future = asyncio.ensure_future(awaitable)
if future.done():
return future
def on_event(*_):
if future.done():
return
msg = f'abort: {event} event occurred.'
if isinstance(future, asyncio.Task):
# python < 3.9 does not support passing a message on `Task.cancel`
if sys.version_info < (3, 9, 0):
future.cancel()
else:
future.cancel(msg)
else:
future.set_exception(asyncio.CancelledError(msg))
def on_done(_):
self.remove_listener(event, on_event)
self.on(event, on_event)
future.add_done_callback(on_done)
def cancel_on_event(
emitter: pyee.EventEmitter, event: str, awaitable: Awaitable[_T]
) -> Awaitable[_T]:
"""Set a coroutine or future to cancel when an event occur."""
future = asyncio.ensure_future(awaitable)
if future.done():
return future
def on_event(*args, **kwargs) -> None:
del args, kwargs
if future.done():
return
msg = f'abort: {event} event occurred.'
if isinstance(future, asyncio.Task):
# python < 3.9 does not support passing a message on `Task.cancel`
if sys.version_info < (3, 9, 0):
future.cancel()
else:
future.cancel(msg)
else:
future.set_exception(asyncio.CancelledError(msg))
def on_done(_):
emitter.remove_listener(event, on_event)
emitter.on(event, on_event)
future.add_done_callback(on_done)
return future
# -----------------------------------------------------------------------------
class CompositeEventEmitter(AbortableEventEmitter):
class EventEmitter(pyee.asyncio.AsyncIOEventEmitter):
"""A Base EventEmitter for Bumble."""
@deprecated("Use `cancel_on_event` instead.")
def abort_on(self, event: str, awaitable: Awaitable[_T]) -> Awaitable[_T]:
"""Set a coroutine or future to abort when an event occur."""
return cancel_on_event(self, event, awaitable)
# -----------------------------------------------------------------------------
class CompositeEventEmitter(EventEmitter):
def __init__(self):
super().__init__()
self._listener = None
@@ -430,48 +485,6 @@ async def async_call(function, *args, **kwargs):
return function(*args, **kwargs)
# -----------------------------------------------------------------------------
def wrap_async(function):
"""
Wraps the provided function in an async function.
"""
return functools.partial(async_call, function)
# -----------------------------------------------------------------------------
def deprecated(msg: str):
"""
Throw deprecation warning before execution.
"""
def wrapper(function):
@functools.wraps(function)
def inner(*args, **kwargs):
warnings.warn(msg, DeprecationWarning)
return function(*args, **kwargs)
return inner
return wrapper
# -----------------------------------------------------------------------------
def experimental(msg: str):
"""
Throws a future warning before execution.
"""
def wrapper(function):
@functools.wraps(function)
def inner(*args, **kwargs):
warnings.warn(msg, FutureWarning)
return function(*args, **kwargs)
return inner
return wrapper
# -----------------------------------------------------------------------------
class OpenIntEnum(enum.IntEnum):
"""
@@ -502,3 +515,13 @@ class ByteSerializable(Protocol):
def from_bytes(cls, data: bytes) -> Self: ...
def __bytes__(self) -> bytes: ...
# -----------------------------------------------------------------------------
class IntConvertible(Protocol):
"""
Type protocol for classes that can be instantiated from int and converted to int.
"""
def __init__(self, value: int) -> None: ...
def __int__(self) -> int: ...

View File

@@ -0,0 +1,202 @@
AURACAST TOOL
=============
The "auracast" tool implements commands that implement broadcasting, receiving
and controlling LE Audio broadcasts.
=== "Running as an installed package"
```
$ bumble-auracast
```
=== "Running from source"
```
$ python3 apps/auracast.py <args>
```
# Python Dependencies
Try installing the optional `[auracast]` dependencies:
=== "From source"
```bash
$ python3 -m pip install ".[auracast]"
```
=== "From PyPI"
```bash
$ python3 -m pip install "bumble[auracast]"
```
## LC3
The `auracast` app depends on the `lc3` python module, which is available
either as PyPI module (currently only available for Linux x86_64).
When installing Bumble with the optional `auracast` dependency, the `lc3`
module will be installed from the `lc3py` PyPI package if available.
If not, you will need to install it separately. This can be done with:
```bash
$ python3 -m pip install "git+https://github.com/google/liblc3.git"
```
## SoundDevice
The `sounddevice` module is required for audio output to the host's sound
output device(s) and/or input from the host's input device(s).
If not installed, the `auracast` app is still functional, but will be limited
to non-device inputs and output (files, external processes, ...)
On macOS and Windows, the `sounddevice` module gets installed with the
native PortAudio libraries included.
For Linux, however, PortAudio must be installed separately.
This is typically done with a command like:
```bash
$ sudo apt install libportaudio2
```
Visit the [sounddevice documentation](https://python-sounddevice.readthedocs.io/)
for details.
# General Usage
```
Usage: bumble-auracast [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
assist Scan for broadcasts on behalf of an audio server
pair Pair with an audio server
receive Receive a broadcast source
scan Scan for public broadcasts
transmit Transmit a broadcast source
```
Use `bumble-auracast <command> --help` to get more detailed usage information
for a specific `<command>`.
## `assist`
Act as a broadcast assistant.
Use `bumble-auracast assist --help` for details on the commands and options.
The assistant commands are:
### `monitor-state`
Subscribe to the state characteristic and monitor changes.
### `add-source`
Add a broadcast source. This will instruct the device to start
receiving a broadcast.
### `modify-source`
Modify a broadcast source.
### `remove-source`
Remote a broadcast source.
## `pair`
Pair with a device.
## `receive`
Receive a broadcast source.
The `--output` option specifies where to send the decoded audio samples.
The following outputs are supported:
### Sound Device
The `--output` argument is either `device`, to send the audio to the hosts's default sound device, or `device:<DEVICE_ID>` where `<DEVICE_ID>`
is the integer ID of one of the available sound devices.
When invoked with `--output "device:?"`, a list of available devices and
their IDs is printed out.
### Standard Output
With `--output stdout`, the decoded audio samples are written to the
standard output (currently always as float32 PCM samples)
### FFPlay
With `--output ffplay`, the decoded audio samples are piped to `ffplay`
in a child process. This option is only available if `ffplay` is a command that is available on the host.
### File
With `--output <filename>` or `--output file:<filename>`, the decoded audio
samples are written to a file (currently always as float32 PCM)
## `transmit`
Broadcast an audio source as a transmitter.
The `--input` and `--input-format` options specify what audio input
source to transmit.
The following inputs are supported:
### Sound Device
The `--input` argument is either `device`, to use the host's default sound
device (typically a builtin microphone), or `device:<DEVICE_ID>` where
`<DEVICE_ID>` is the integer ID of one of the available sound devices.
When invoked with `--input "device:?"`, a list of available devices and their
IDs is printed out.
### Standard Input
With `--input stdout`, the audio samples are read from the standard input.
(currently always as int16 PCM).
### File
With `--input <filename>` or `--input file:<filename>`, the audio samples
are read from a .wav or raw PCM file.
Use the `--input-format <FORMAT>` option to specify the format of the audio
samples in raw PCM files. `<FORMAT>` is expressed as:
`<sample-type>,<sample-rate>,<channels>`
(the only supported <sample-type> currently is 'int16le' for 16 bit signed integers with little-endian byte order)
## `scan`
Scan for public broadcasts.
A live display of the available broadcasts is displayed continuously.
# Compatibility With Some Products
The `auracast` app has been tested for compatibility with a few products.
The list is still very limited. Please let us know if there are products
that are not working well, or if there are specific instructions that should
be shared to allow better compatibiity with certain products.
## Transmitters
The `receive` command has been tested to successfully receive broadcasts from
the following transmitters:
* JBL GO 4
* Flairmesh FlooGoo FMA120
* Eppfun AK3040Pro Max
* HIGHGAZE BA-25T
* Nexum Audio VOCE and USB dongle
## Receivers
### Pixel Buds Pro 2
The Pixel Buds Pro 2 can be used as a broadcast receiver, controlled by the
`auracast assist` command, instructing the buds to receive a broadcast.
Use the `assist --command add-source` command to tell the buds to receive a
broadcast.
Use the `assist --command monitor-state` command to monitor the current sync/receive
state of the buds.
### JBL
The JBL GO 4 and other JBL products that support the Auracast feature can be used
as transmitters or receivers.
When running in receiver mode (pressing the Auracast button while not already playing),
the JBL speaker will scan for broadcast advertisements with a specific manufacturer data.
Use the `--manufacturer-data` option of the `transmit` command in order to include data
that will let the speaker recognize the broadcast as a compatible source.
The manufacturer ID for JBL is 87.
Using an option like `--manufacturer-data 87:00000000000000000000000000000000dffd` should work (tested on the
JBL GO 4. The `dffd` value at the end of the payload may be different on other models?).
### Others
* Nexum Audio VOCE and USB dongle

View File

@@ -0,0 +1,9 @@
{
"name": "Bumble CS Initiator",
"address": "F0:F1:F2:F3:F4:F5",
"advertising_interval": 100,
"keystore": "JsonKeyStore",
"irk": "865F81FF5A8B486EAAE29A27AD9F77DC",
"identity_address_type": 1,
"channel_sounding_enabled": true
}

View File

@@ -0,0 +1,9 @@
{
"name": "Bumble CS Reflector",
"address": "F0:F1:F2:F3:F4:F6",
"advertising_interval": 100,
"keystore": "JsonKeyStore",
"irk": "0c7d74db03a1c98e7be691f76141d53d",
"identity_address_type": 1,
"channel_sounding_enabled": true
}

View File

@@ -102,7 +102,6 @@ async def main() -> None:
)
# Notify subscribers of the current value as soon as they subscribe
@heart_rate_service.heart_rate_measurement_characteristic.on('subscription')
def on_subscription(connection, notify_enabled, indicate_enabled):
if notify_enabled or indicate_enabled:
AsyncRunner.spawn(
@@ -112,6 +111,10 @@ async def main() -> None:
)
)
heart_rate_service.heart_rate_measurement_characteristic.on(
'subscription', on_subscription
)
# Go!
await device.power_on()
await device.start_advertising(auto_restart=True)

View File

@@ -28,7 +28,7 @@ class OneDeviceBenchTest(base_test.BaseTestClass):
def test_l2cap_client_ping(self):
runner = self.dut.bench.runL2capClient(
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100, "HIGH"
)
print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
@@ -36,12 +36,34 @@ class OneDeviceBenchTest(base_test.BaseTestClass):
def test_l2cap_client_send(self):
runner = self.dut.bench.runL2capClient(
"send", "7E:90:D0:F2:7A:11", 131, True, 100, 970, 0
"send",
"F1:F1:F1:F1:F1:F1",
128,
True,
100,
970,
0,
"HIGH",
10000,
)
print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
print("### Final status:", final_status)
def test_gatt_client_send(self):
runner = self.dut.bench.runGattClient(
"send", "F1:F1:F1:F1:F1:F1", 128, True, 100, 970, 100, "HIGH"
)
print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
print("### Final status:", final_status)
def test_gatt_server_receive(self):
runner = self.dut.bench.runGattServer("receive")
print("### Initial status:", runner)
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
print("### Final status:", final_status)
if __name__ == "__main__":
test_runner.main()

View File

@@ -2,8 +2,8 @@ TestBeds:
- Name: BenchTestBed
Controllers:
AndroidDevice:
- serial: 37211FDJG000DJ
- serial: emulator-5554
local_bt_address: 94:45:60:5E:03:B0
- serial: 23071FDEE001F7
local_bt_address: DC:E5:5B:E5:51:2C
#- serial: 23071FDEE001F7
# local_bt_address: DC:E5:5B:E5:51:2C

View File

@@ -24,7 +24,7 @@ from bumble.colors import color
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import (
BT_BR_EDR_TRANSPORT,
PhysicalTransport,
BT_AVDTP_PROTOCOL_ID,
BT_AUDIO_SINK_SERVICE,
BT_L2CAP_PROTOCOL_ID,
@@ -165,7 +165,9 @@ async def main() -> None:
# 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)
connection = await device.connect(
target_address, transport=PhysicalTransport.BR_EDR
)
print(f'=== Connected to {connection.peer_address}!')
# Request authentication

View File

@@ -23,7 +23,7 @@ from typing import Any, Dict
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import BT_BR_EDR_TRANSPORT
from bumble.core import PhysicalTransport
from bumble.avdtp import (
AVDTP_AUDIO_MEDIA_TYPE,
Protocol,
@@ -33,12 +33,6 @@ from bumble.avdtp import (
from bumble.a2dp import (
make_audio_sink_service_sdp_records,
A2DP_SBC_CODEC_TYPE,
SBC_MONO_CHANNEL_MODE,
SBC_DUAL_CHANNEL_MODE,
SBC_SNR_ALLOCATION_METHOD,
SBC_LOUDNESS_ALLOCATION_METHOD,
SBC_STEREO_CHANNEL_MODE,
SBC_JOINT_STEREO_CHANNEL_MODE,
SbcMediaCodecInformation,
)
@@ -145,7 +139,7 @@ async def main() -> None:
target_address = sys.argv[4]
print(f'=== Connecting to {target_address}...')
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
target_address, transport=PhysicalTransport.BR_EDR
)
print(f'=== Connected to {connection.peer_address}!')

View File

@@ -23,7 +23,7 @@ import logging
from bumble.colors import color
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import BT_BR_EDR_TRANSPORT
from bumble.core import PhysicalTransport
from bumble.avdtp import (
find_avdtp_service_with_connection,
AVDTP_AUDIO_MEDIA_TYPE,
@@ -146,7 +146,7 @@ async def main() -> None:
target_address = sys.argv[4]
print(f'=== Connecting to {target_address}...')
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
target_address, transport=PhysicalTransport.BR_EDR
)
print(f'=== Connected to {connection.peer_address}!')

215
examples/run_ancs_client.py Normal file
View File

@@ -0,0 +1,215 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
import sys
import os
import logging
from bumble.colors import color
from bumble.device import Device, Peer
from bumble.transport import open_transport
from bumble.profiles.ancs import (
AncsClient,
AppAttribute,
AppAttributeId,
EventFlags,
EventId,
Notification,
NotificationAttributeId,
)
# -----------------------------------------------------------------------------
_cached_app_names: dict[str, str] = {}
_notification_queue = asyncio.Queue[Notification]()
async def process_notifications(ancs_client: AncsClient):
while True:
notification = await _notification_queue.get()
prefix = " "
if notification.event_id == EventId.NOTIFICATION_ADDED:
print_color = "green"
if notification.event_flags & EventFlags.PRE_EXISTING:
prefix = " Existing "
else:
prefix = " New "
elif notification.event_id == EventId.NOTIFICATION_REMOVED:
print_color = "red"
elif notification.event_id == EventId.NOTIFICATION_MODIFIED:
print_color = "yellow"
else:
print_color = "white"
print(
color(
(
f"[{notification.event_id.name}]{prefix}Notification "
f"({notification.notification_uid}):"
),
print_color,
)
)
print(color(" Event ID: ", "yellow"), notification.event_id.name)
print(color(" Event Flags: ", "yellow"), notification.event_flags.name)
print(color(" Category ID: ", "yellow"), notification.category_id.name)
print(color(" Category Count:", "yellow"), notification.category_count)
if notification.event_id not in (
EventId.NOTIFICATION_ADDED,
EventId.NOTIFICATION_MODIFIED,
):
continue
requested_attributes = [
NotificationAttributeId.APP_IDENTIFIER,
NotificationAttributeId.TITLE,
NotificationAttributeId.SUBTITLE,
NotificationAttributeId.MESSAGE,
NotificationAttributeId.DATE,
]
if notification.event_flags & EventFlags.NEGATIVE_ACTION:
requested_attributes.append(NotificationAttributeId.NEGATIVE_ACTION_LABEL)
if notification.event_flags & EventFlags.POSITIVE_ACTION:
requested_attributes.append(NotificationAttributeId.POSITIVE_ACTION_LABEL)
attributes = await ancs_client.get_notification_attributes(
notification.notification_uid, requested_attributes
)
max_attribute_name_width = max(
(len(attribute.attribute_id.name) for attribute in attributes)
)
app_identifier = str(
next(
(
attribute.value
for attribute in attributes
if attribute.attribute_id == NotificationAttributeId.APP_IDENTIFIER
)
)
)
if app_identifier not in _cached_app_names:
app_attributes = await ancs_client.get_app_attributes(
app_identifier, [AppAttributeId.DISPLAY_NAME]
)
_cached_app_names[app_identifier] = app_attributes[0].value
app_name = _cached_app_names[app_identifier]
for attribute in attributes:
padding = ' ' * (
max_attribute_name_width - len(attribute.attribute_id.name)
)
suffix = (
f" ({app_name})"
if attribute.attribute_id == NotificationAttributeId.APP_IDENTIFIER
else ""
)
print(
color(f" {attribute.attribute_id.name}:{padding}", "blue"),
f"{attribute.value}{suffix}",
)
print()
def on_ancs_notification(notification: Notification) -> None:
_notification_queue.put_nowait(notification)
async def handle_command_client(
ancs_client: AncsClient, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
while True:
command = (await reader.readline()).decode("utf-8").strip()
try:
command_name, command_args = command.split(" ", 1)
if command_name == "+":
notification_uid = int(command_args)
await ancs_client.perform_positive_action(notification_uid)
elif command_name == "-":
notification_uid = int(command_args)
await ancs_client.perform_negative_action(notification_uid)
else:
writer.write(f"unknown command {command_name}".encode("utf-8"))
except Exception as error:
writer.write(f"ERROR: {error}\n".encode("utf-8"))
# -----------------------------------------------------------------------------
async def main() -> None:
if len(sys.argv) < 3:
print(
'Usage: run_ancs_client.py <device-config> <transport-spec> '
'<bluetooth-address> <mtu>'
)
print('example: run_ancs_client.py device1.json usb:0 E1:CA:72:48:C4:E8 512')
return
device_config, transport_spec, bluetooth_address, mtu = sys.argv[1:]
print('<<< connecting to HCI...')
async with await open_transport(transport_spec) as hci_transport:
print('<<< connected')
# Create a device to manage the host, with a custom listener
device = Device.from_config_file_with_hci(
device_config, hci_transport.source, hci_transport.sink
)
await device.power_on()
# Connect to the peer
print(f'=== Connecting to {bluetooth_address}...')
connection = await device.connect(bluetooth_address)
print(f'=== Connected: {connection}')
await connection.encrypt()
peer = Peer(connection)
mtu_int = int(mtu)
if mtu_int:
new_mtu = await peer.request_mtu(mtu_int)
print(f'ATT MTU = {new_mtu}')
ancs_client = await AncsClient.for_peer(peer)
if ancs_client is None:
print("!!! no ANCS service found")
return
await ancs_client.start()
print('Subscribing to updates')
ancs_client.on("notification", on_ancs_notification)
# Process all notifications in a task.
notification_processing_task = asyncio.create_task(
process_notifications(ancs_client)
)
# Accept a TCP connection to handle commands.
tcp_server = await asyncio.start_server(
lambda reader, writer: handle_command_client(ancs_client, reader, writer),
'127.0.0.1',
9000,
)
print("Accepting command client on port 9000")
async with tcp_server:
await tcp_server.serve_forever()
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(main())

View File

@@ -25,7 +25,7 @@ import websockets
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import BT_BR_EDR_TRANSPORT
from bumble.core import PhysicalTransport
from bumble import avc
from bumble import avrcp
from bumble import avdtp
@@ -379,7 +379,7 @@ async def main() -> None:
target_address = sys.argv[4]
print(f'=== Connecting to {target_address}...')
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
target_address, transport=PhysicalTransport.BR_EDR
)
print(f'=== Connected to {connection.peer_address}!')

View File

@@ -0,0 +1,154 @@
# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import logging
import sys
import os
import functools
from bumble import core
from bumble import hci
from bumble.device import Connection, Device, ChannelSoundingCapabilities
from bumble.transport import open_transport_or_link
# From https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/system/gd/hci/distance_measurement_manager.cc.
CS_TONE_ANTENNA_CONFIG_MAPPING_TABLE = [
[0, 4, 5, 6],
[1, 7, 7, 7],
[2, 7, 7, 7],
[3, 7, 7, 7],
]
CS_PREFERRED_PEER_ANTENNA_MAPPING_TABLE = [1, 1, 1, 1, 3, 7, 15, 3]
CS_ANTENNA_PERMUTATION_ARRAY = [
[1, 2, 3, 4],
[2, 1, 3, 4],
[1, 3, 2, 4],
[3, 1, 2, 4],
[3, 2, 1, 4],
[2, 3, 1, 4],
[1, 2, 4, 3],
[2, 1, 4, 3],
[1, 4, 2, 3],
[4, 1, 2, 3],
[4, 2, 1, 3],
[2, 4, 1, 3],
[1, 4, 3, 2],
[4, 1, 3, 2],
[1, 3, 4, 2],
[3, 1, 4, 2],
[3, 4, 1, 2],
[4, 3, 1, 2],
[4, 2, 3, 1],
[2, 4, 3, 1],
[4, 3, 2, 1],
[3, 4, 2, 1],
[3, 2, 4, 1],
[2, 3, 4, 1],
]
# -----------------------------------------------------------------------------
async def main() -> None:
if len(sys.argv) < 3:
print(
'Usage: run_channel_sounding.py <config-file> <transport-spec-for-device>'
'[target_address](If missing, run as reflector)'
)
print('example: run_channel_sounding.py cs_reflector.json usb:0')
print(
'example: run_channel_sounding.py cs_initiator.json usb:0 F0:F1:F2:F3:F4:F5'
)
return
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected')
device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
await device.power_on()
assert (local_cs_capabilities := device.cs_capabilities)
if len(sys.argv) == 3:
print('<<< Start Advertising')
await device.start_advertising(
own_address_type=hci.OwnAddressType.RANDOM, auto_restart=True
)
def on_cs_capabilities(
connection: Connection, capabilities: ChannelSoundingCapabilities
):
del capabilities
print('<<< Set CS Settings')
asyncio.create_task(device.set_default_cs_settings(connection))
device.on(
'connection',
lambda connection: connection.on(
'channel_sounding_capabilities',
functools.partial(on_cs_capabilities, connection),
),
)
else:
target_address = hci.Address(sys.argv[3])
print(f'<<< Connecting to {target_address}')
connection = await device.connect(
target_address, transport=core.PhysicalTransport.LE
)
print('<<< ACL Connected')
if not (await device.get_long_term_key(connection.handle, b'', 0)):
print('<<< No bond, start pairing')
await connection.pair()
print('<<< Pairing complete')
print('<<< Encrypting Connection')
await connection.encrypt()
print('<<< Getting remote CS Capabilities...')
remote_capabilities = await device.get_remote_cs_capabilities(connection)
print('<<< Set CS Settings...')
await device.set_default_cs_settings(connection)
print('<<< Set CS Config...')
config = await device.create_cs_config(connection)
print('<<< Enable CS Security...')
await device.enable_cs_security(connection)
tone_antenna_config_selection = CS_TONE_ANTENNA_CONFIG_MAPPING_TABLE[
local_cs_capabilities.num_antennas_supported - 1
][remote_capabilities.num_antennas_supported - 1]
print('<<< Set CS Procedure Parameters...')
await device.set_cs_procedure_parameters(
connection=connection,
config=config,
tone_antenna_config_selection=tone_antenna_config_selection,
preferred_peer_antenna=CS_PREFERRED_PEER_ANTENNA_MAPPING_TABLE[
tone_antenna_config_selection
],
)
print('<<< Enable CS Procedure...')
await device.enable_cs_procedure(connection=connection, config=config)
await hci_transport.source.terminated
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
asyncio.run(main())

View File

@@ -19,12 +19,8 @@ import asyncio
import logging
import sys
import os
from bumble.device import (
Device,
Connection,
AdvertisingParameters,
AdvertisingEventProperties,
)
from bumble import utils
from bumble.device import Device, Connection
from bumble.hci import (
OwnAddressType,
)
@@ -79,7 +75,9 @@ async def main() -> None:
def on_cis_request(
connection: Connection, cis_handle: int, _cig_id: int, _cis_id: int
):
connection.abort_on('disconnection', devices[0].accept_cis_request(cis_handle))
utils.cancel_on_event(
connection, 'disconnection', devices[0].accept_cis_request(cis_handle)
)
devices[0].on('cis_request', on_cis_request)

View File

@@ -23,7 +23,7 @@ from bumble.colors import color
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID, CommandTimeoutError
from bumble.core import PhysicalTransport, BT_L2CAP_PROTOCOL_ID, CommandTimeoutError
from bumble.sdp import (
Client as SDP_Client,
SDP_PUBLIC_BROWSE_ROOT,
@@ -57,7 +57,7 @@ async def main() -> None:
print(f'=== Connecting to {target_address}...')
try:
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
target_address, transport=PhysicalTransport.BR_EDR
)
except CommandTimeoutError:
print('!!! Connection timed out')

View File

@@ -16,11 +16,10 @@
# Imports
# -----------------------------------------------------------------------------
import asyncio
import dataclasses
import logging
import sys
import os
from bumble.core import BT_BR_EDR_TRANSPORT
from bumble.core import PhysicalTransport
from bumble.device import Device, ScoLink
from bumble.hci import HCI_Enhanced_Setup_Synchronous_Connection_Command
from bumble.hfp import DefaultCodecParameters, ESCO_PARAMETERS
@@ -61,7 +60,9 @@ async def main() -> None:
connections = await asyncio.gather(
devices[0].accept(devices[1].public_address),
devices[1].connect(devices[0].public_address, transport=BT_BR_EDR_TRANSPORT),
devices[1].connect(
devices[0].public_address, transport=PhysicalTransport.BR_EDR
),
)
def on_sco(sco_link: ScoLink):

View File

@@ -70,13 +70,13 @@ async def main() -> None:
descriptor = Descriptor(
GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR,
Descriptor.READABLE,
'My Description',
'My Description'.encode(),
)
manufacturer_name_characteristic = Characteristic(
manufacturer_name_characteristic = Characteristic[bytes](
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
Characteristic.Properties.READ,
Characteristic.READABLE,
"Fitbit",
"Fitbit".encode(),
[descriptor],
)
device_info_service = Service(

View File

@@ -94,13 +94,13 @@ async def main() -> None:
descriptor = Descriptor(
GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR,
Descriptor.READABLE,
'My Description',
'My Description'.encode(),
)
manufacturer_name_characteristic = Characteristic(
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
Characteristic.Properties.READ,
Characteristic.READABLE,
'Fitbit',
'Fitbit'.encode(),
[descriptor],
)
device_info_service = Service(

View File

@@ -1,4 +1,4 @@
# Copyright 2024 Google LLC
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -18,6 +18,8 @@
from __future__ import annotations
import asyncio
import dataclasses
import functools
import enum
import logging
import os
import random
@@ -28,6 +30,8 @@ from typing import Any, List, Union
from bumble.device import Device, Peer
from bumble import transport
from bumble import gatt
from bumble import gatt_adapters
from bumble import gatt_client
from bumble import hci
from bumble import core
@@ -36,6 +40,9 @@ from bumble import core
SERVICE_UUID = core.UUID("50DB505C-8AC4-4738-8448-3B1D9CC09CC5")
CHARACTERISTIC_UUID_BASE = "D901B45B-4916-412E-ACCA-0000000000"
DEFAULT_CLIENT_ADDRESS = "F0:F1:F2:F3:F4:F5"
DEFAULT_SERVER_ADDRESS = "F1:F2:F3:F4:F5:F6"
# -----------------------------------------------------------------------------
@dataclasses.dataclass
@@ -65,6 +72,12 @@ class CustomClass:
return struct.pack(">II", self.a, self.b)
# -----------------------------------------------------------------------------
class CustomEnum(enum.IntEnum):
FOO = 1234
BAR = 5678
# -----------------------------------------------------------------------------
async def client(device: Device, address: hci.Address) -> None:
print(f'=== Connecting to {address}...')
@@ -78,8 +91,8 @@ async def client(device: Device, address: hci.Address) -> None:
print("*** Discovery complete")
service = peer.get_services_by_uuid(SERVICE_UUID)[0]
characteristics = []
for index in range(1, 9):
characteristics: list[gatt_client.CharacteristicProxy] = []
for index in range(1, 10):
characteristics.append(
service.get_characteristics_by_uuid(
core.UUID(CHARACTERISTIC_UUID_BASE + f"{index:02X}")
@@ -91,59 +104,92 @@ async def client(device: Device, address: hci.Address) -> None:
value = await characteristic.read_value()
print(f"### {characteristic} = {value!r} ({value.hex()})")
# Subscribe to all characteristics as a raw bytes listener.
def on_raw_characteristic_update(characteristic, value):
print(f"^^^ Update[RAW] {characteristic.uuid} value = {value.hex()}")
for characteristic in characteristics:
await characteristic.subscribe(
functools.partial(on_raw_characteristic_update, characteristic)
)
# Function to subscribe to adapted characteristics
def on_adapted_characteristic_update(characteristic, value):
print(
f"^^^ Update[ADAPTED] {characteristic.uuid} value = {value!r}, "
f"type={type(value)}"
)
# Static characteristic with a bytes value.
c1 = characteristics[0]
c1_value = await c1.read_value()
print(f"@@@ C1 {c1} value = {c1_value!r} (type={type(c1_value)})")
await c1.write_value("happy π day".encode("utf-8"))
await c1.subscribe(functools.partial(on_adapted_characteristic_update, c1))
# Static characteristic with a string value.
c2 = gatt.UTF8CharacteristicAdapter(characteristics[1])
c2 = gatt_adapters.UTF8CharacteristicProxyAdapter(characteristics[1])
c2_value = await c2.read_value()
print(f"@@@ C2 {c2} value = {c2_value} (type={type(c2_value)})")
await c2.write_value("happy π day")
await c2.subscribe(functools.partial(on_adapted_characteristic_update, c2))
# Static characteristic with a tuple value.
c3 = gatt.PackedCharacteristicAdapter(characteristics[2], ">III")
c3 = gatt_adapters.PackedCharacteristicProxyAdapter(characteristics[2], ">III")
c3_value = await c3.read_value()
print(f"@@@ C3 {c3} value = {c3_value} (type={type(c3_value)})")
await c3.write_value((2001, 2002, 2003))
await c3.subscribe(functools.partial(on_adapted_characteristic_update, c3))
# Static characteristic with a named tuple value.
c4 = gatt.MappedCharacteristicAdapter(
c4 = gatt_adapters.MappedCharacteristicProxyAdapter(
characteristics[3], ">III", ["f1", "f2", "f3"]
)
c4_value = await c4.read_value()
print(f"@@@ C4 {c4} value = {c4_value} (type={type(c4_value)})")
await c4.write_value({"f1": 4001, "f2": 4002, "f3": 4003})
await c4.subscribe(functools.partial(on_adapted_characteristic_update, c4))
# Static characteristic with a serializable value.
c5 = gatt.SerializableCharacteristicAdapter(
c5 = gatt_adapters.SerializableCharacteristicProxyAdapter(
characteristics[4], CustomSerializableClass
)
c5_value = await c5.read_value()
print(f"@@@ C5 {c5} value = {c5_value} (type={type(c5_value)})")
await c5.write_value(CustomSerializableClass(56, 57))
await c5.subscribe(functools.partial(on_adapted_characteristic_update, c5))
# Static characteristic with a delegated value.
c6 = gatt.DelegatedCharacteristicAdapter(
c6 = gatt_adapters.DelegatedCharacteristicProxyAdapter(
characteristics[5], encode=CustomClass.encode, decode=CustomClass.decode
)
c6_value = await c6.read_value()
print(f"@@@ C6 {c6} value = {c6_value} (type={type(c6_value)})")
await c6.write_value(CustomClass(6, 7))
await c6.subscribe(functools.partial(on_adapted_characteristic_update, c6))
# Dynamic characteristic with a bytes value.
c7 = characteristics[6]
c7_value = await c7.read_value()
print(f"@@@ C7 {c7} value = {c7_value!r} (type={type(c7_value)})")
await c7.write_value(bytes.fromhex("01020304"))
await c7.subscribe(functools.partial(on_adapted_characteristic_update, c7))
# Dynamic characteristic with a string value.
c8 = gatt.UTF8CharacteristicAdapter(characteristics[7])
c8 = gatt_adapters.UTF8CharacteristicProxyAdapter(characteristics[7])
c8_value = await c8.read_value()
print(f"@@@ C8 {c8} value = {c8_value} (type={type(c8_value)})")
await c8.write_value("howdy")
await c8.subscribe(functools.partial(on_adapted_characteristic_update, c8))
# Static characteristic with an enum value
c9 = gatt_adapters.EnumCharacteristicProxyAdapter(
characteristics[8], CustomEnum, 3, 'big'
)
c9_value = await c9.read_value()
print(f"@@@ C9 {c9} value = {c9_value.name} (type={type(c9_value)})")
await c9.write_value(CustomEnum.BAR)
await c9.subscribe(functools.partial(on_adapted_characteristic_update, c9))
# -----------------------------------------------------------------------------
@@ -175,142 +221,213 @@ def on_characteristic_write(characteristic: gatt.Characteristic, value: Any) ->
print(f"<<< WRITE: {characteristic} <- {value} ({type(value)})")
# -----------------------------------------------------------------------------
async def server(device: Device) -> None:
# Static characteristic with a bytes value.
c1 = gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "01",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.NOTIFY,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
b'hello',
)
# Static characteristic with a string value.
c2 = gatt_adapters.UTF8CharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "02",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.NOTIFY,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
'hello',
)
)
# Static characteristic with a tuple value.
c3 = gatt_adapters.PackedCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "03",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.NOTIFY,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
(1007, 1008, 1009),
),
">III",
)
# Static characteristic with a named tuple value.
c4 = gatt_adapters.MappedCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "04",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.NOTIFY,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
{"f1": 3007, "f2": 3008, "f3": 3009},
),
">III",
["f1", "f2", "f3"],
)
# Static characteristic with a serializable value.
c5 = gatt_adapters.SerializableCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "05",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.NOTIFY,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
CustomSerializableClass(11, 12),
),
CustomSerializableClass,
)
# Static characteristic with a delegated value.
c6 = gatt_adapters.DelegatedCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "06",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.NOTIFY,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
CustomClass(1, 2),
),
encode=CustomClass.encode,
decode=CustomClass.decode,
)
# Dynamic characteristic with a bytes value.
c7 = gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "07",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.NOTIFY,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
gatt.CharacteristicValue(
read=lambda connection: dynamic_read("bytes"),
write=lambda connection, value: dynamic_write("bytes", value),
),
)
# Dynamic characteristic with a string value.
c8 = gatt_adapters.UTF8CharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "08",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.NOTIFY,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
gatt.CharacteristicValue(
read=lambda connection: dynamic_read("string"),
write=lambda connection, value: dynamic_write("string", value),
),
)
)
# Static characteristic with an enum value
c9 = gatt_adapters.EnumCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "09",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE
| gatt.Characteristic.Properties.NOTIFY,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
CustomEnum.FOO,
),
cls=CustomEnum,
length=3,
byteorder='big',
)
characteristics: List[gatt.Characteristic] = [
c1,
c2,
c3,
c4,
c5,
c6,
c7,
c8,
c9,
]
# Listen for read and write events.
for characteristic in characteristics:
characteristic.on(
"read",
lambda _, value, c=characteristic: on_characteristic_read(c, value),
)
characteristic.on(
"write",
lambda _, value, c=characteristic: on_characteristic_write(c, value),
)
device.add_service(gatt.Service(SERVICE_UUID, characteristics))
# Notify every 3 seconds
i = 0
while True:
await asyncio.sleep(3)
# Notifying can be done with the characteristic's current value, or
# by explicitly passing a value to notify with. Both variants are used
# here: for c1..c4 we set the value and then notify, for c4..c9 we notify
# with an explicit value.
c1.value = f'hello c1 {i}'.encode()
await device.notify_subscribers(c1)
c2.value = f'hello c2 {i}'
await device.notify_subscribers(c2)
c3.value = (1000 + i, 2000 + i, 3000 + i)
await device.notify_subscribers(c3)
c4.value = {"f1": 4000 + i, "f2": 5000 + i, "f3": 6000 + i}
await device.notify_subscribers(c4)
await device.notify_subscribers(c5, CustomSerializableClass(1000 + i, 2000 + i))
await device.notify_subscribers(c6, CustomClass(3000 + i, 4000 + i))
await device.notify_subscribers(c7, bytes([1, 2, 3, i % 256]))
await device.notify_subscribers(c8, f'hello c8 {i}')
await device.notify_subscribers(
c9, CustomEnum.FOO if i % 2 == 0 else CustomEnum.BAR
)
i += 1
# -----------------------------------------------------------------------------
async def main() -> None:
if len(sys.argv) < 2:
print("Usage: run_gatt_with_adapters.py <transport-spec> [<bluetooth-address>]")
print("example: run_gatt_with_adapters.py usb:0 E1:CA:72:48:C4:E8")
print("Usage: run_gatt_with_adapters.py <transport-spec> client|server")
print("example: run_gatt_with_adapters.py usb:0 F0:F1:F2:F3:F4:F5")
return
async with await transport.open_transport(sys.argv[1]) as hci_transport:
is_client = sys.argv[2] == "client"
# Create a device to manage the host
device = Device.with_hci(
"Bumble",
hci.Address("F0:F1:F2:F3:F4:F5"),
hci.Address(
DEFAULT_CLIENT_ADDRESS if is_client else DEFAULT_SERVER_ADDRESS
),
hci_transport.source,
hci_transport.sink,
)
# Static characteristic with a bytes value.
c1 = gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "01",
gatt.Characteristic.Properties.READ | gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
b'hello',
)
# Static characteristic with a string value.
c2 = gatt.UTF8CharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "02",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
'hello',
)
)
# Static characteristic with a tuple value.
c3 = gatt.PackedCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "03",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
(1007, 1008, 1009),
),
">III",
)
# Static characteristic with a named tuple value.
c4 = gatt.MappedCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "04",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
{"f1": 3007, "f2": 3008, "f3": 3009},
),
">III",
["f1", "f2", "f3"],
)
# Static characteristic with a serializable value.
c5 = gatt.SerializableCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "05",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
CustomSerializableClass(11, 12),
),
CustomSerializableClass,
)
# Static characteristic with a delegated value.
c6 = gatt.DelegatedCharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "06",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
CustomClass(1, 2),
),
encode=CustomClass.encode,
decode=CustomClass.decode,
)
# Dynamic characteristic with a bytes value.
c7 = gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "07",
gatt.Characteristic.Properties.READ | gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
gatt.CharacteristicValue(
read=lambda connection: dynamic_read("bytes"),
write=lambda connection, value: dynamic_write("bytes", value),
),
)
# Dynamic characteristic with a string value.
c8 = gatt.UTF8CharacteristicAdapter(
gatt.Characteristic(
CHARACTERISTIC_UUID_BASE + "08",
gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.WRITE,
gatt.Characteristic.READABLE | gatt.Characteristic.WRITEABLE,
gatt.CharacteristicValue(
read=lambda connection: dynamic_read("string"),
write=lambda connection, value: dynamic_write("string", value),
),
)
)
characteristics: List[
Union[gatt.Characteristic, gatt.CharacteristicAdapter]
] = [c1, c2, c3, c4, c5, c6, c7, c8]
# Listen for read and write events.
for characteristic in characteristics:
characteristic.on(
"read",
lambda _, value, c=characteristic: on_characteristic_read(c, value),
)
characteristic.on(
"write",
lambda _, value, c=characteristic: on_characteristic_write(c, value),
)
device.add_service(gatt.Service(SERVICE_UUID, characteristics)) # type: ignore
# Get things going
await device.power_on()
# Connect to a peer
if len(sys.argv) > 2:
await client(device, hci.Address(sys.argv[2]))
if is_client:
# Connect a client to a peer
await client(device, hci.Address(DEFAULT_SERVER_ADDRESS))
else:
# Advertise so a peer can connect
await device.start_advertising(auto_restart=True)
# Setup a server
await server(device)
await hci_transport.source.wait_for_termination()

View File

@@ -28,9 +28,7 @@ import websockets
import bumble.core
from bumble.device import Device, ScoLink
from bumble.transport import open_transport_or_link
from bumble.core import (
BT_BR_EDR_TRANSPORT,
)
from bumble.core import PhysicalTransport
from bumble import hci, rfcomm, hfp
@@ -234,7 +232,7 @@ async def main() -> None:
target_address = sys.argv[3]
print(f'=== Connecting to {target_address}...')
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
target_address, transport=PhysicalTransport.BR_EDR
)
print(f'=== Connected to {connection.peer_address}!')

Some files were not shown because too many files have changed in this diff Show More