aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCharlie Boutier <charliebout@google.com>2024-04-01 21:14:04 +0000
committerGerrit Code Review <noreply-gerritcodereview@google.com>2024-04-01 21:14:04 +0000
commit8b1d05f92118bbec104cf32cab3a706319a27e52 (patch)
treef81269e5aa592f318324db9cd40dd1a7ba19ff7d
parent9d79c98700563c904fb581c4b00d04c06636cfd4 (diff)
parent60c8cd62e12c4118c4d6e285142e2c62bcc49710 (diff)
downloadbumble-8b1d05f92118bbec104cf32cab3a706319a27e52.tar.gz
Merge "Merge remote-tracking branch 'aosp/upstream-main'" into mainHEADmastermain
-rw-r--r--.github/workflows/code-check.yml2
-rw-r--r--.github/workflows/python-build-test.yml10
-rw-r--r--.gitignore5
-rw-r--r--.vscode/settings.json13
-rw-r--r--apps/bench.py713
-rw-r--r--apps/ble_rpa_tool.py63
-rw-r--r--apps/console.py8
-rw-r--r--apps/controller_info.py88
-rw-r--r--apps/controller_loopback.py205
-rw-r--r--apps/l2cap_bridge.py56
-rw-r--r--apps/pair.py77
-rw-r--r--apps/scan.py52
-rw-r--r--apps/show.py75
-rw-r--r--bumble/a2dp.py167
-rw-r--r--bumble/att.py64
-rw-r--r--bumble/avc.py520
-rw-r--r--bumble/avctp.py291
-rw-r--r--bumble/avdtp.py18
-rw-r--r--bumble/avrcp.py1916
-rw-r--r--bumble/controller.py469
-rw-r--r--bumble/core.py35
-rw-r--r--bumble/crypto.py158
-rw-r--r--bumble/device.py1620
-rw-r--r--bumble/drivers/__init__.py60
-rw-r--r--bumble/drivers/common.py45
-rw-r--r--bumble/drivers/intel.py102
-rw-r--r--bumble/drivers/rtk.py15
-rw-r--r--bumble/gatt.py241
-rw-r--r--bumble/gatt_client.py78
-rw-r--r--bumble/gatt_server.py54
-rw-r--r--bumble/hci.py2046
-rw-r--r--bumble/helpers.py220
-rw-r--r--bumble/hfp.py462
-rw-r--r--bumble/hid.py346
-rw-r--r--bumble/host.py711
-rw-r--r--bumble/l2cap.py40
-rw-r--r--bumble/link.py110
-rw-r--r--bumble/pairing.py68
-rw-r--r--bumble/pandora/config.py1
-rw-r--r--bumble/pandora/device.py1
-rw-r--r--bumble/pandora/host.py177
-rw-r--r--bumble/pandora/security.py9
-rw-r--r--bumble/pandora/utils.py1
-rw-r--r--bumble/profiles/asha_service.py4
-rw-r--r--bumble/profiles/bap.py1247
-rw-r--r--bumble/profiles/cap.py52
-rw-r--r--bumble/profiles/csip.py257
-rw-r--r--bumble/profiles/heart_rate_service.py12
-rw-r--r--bumble/profiles/vcp.py228
-rw-r--r--bumble/rfcomm.py349
-rw-r--r--bumble/sdp.py22
-rw-r--r--bumble/smp.py292
-rw-r--r--bumble/transport/__init__.py72
-rw-r--r--bumble/transport/android_emulator.py2
-rw-r--r--bumble/transport/common.py7
-rw-r--r--bumble/transport/hci_socket.py5
-rw-r--r--bumble/transport/pyusb.py17
-rw-r--r--bumble/transport/usb.py131
-rw-r--r--bumble/utils.py97
-rw-r--r--docs/mkdocs/mkdocs.yml26
-rw-r--r--docs/mkdocs/src/apps_and_tools/bench.md39
-rw-r--r--docs/mkdocs/src/apps_and_tools/hci_bridge.md14
-rw-r--r--docs/mkdocs/src/drivers/index.md9
-rw-r--r--docs/mkdocs/src/drivers/realtek.md7
-rw-r--r--docs/mkdocs/src/extras/android_bt_bench.md64
-rw-r--r--docs/mkdocs/src/extras/android_remote_hci.md66
-rw-r--r--docs/mkdocs/src/extras/index.md10
-rw-r--r--docs/mkdocs/src/hive/index.md59
-rw-r--r--docs/mkdocs/src/hive/index.toml21
l---------docs/mkdocs/src/hive/web/bumble.js1
l---------docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html1
l---------docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js1
l---------docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py1
l---------docs/mkdocs/src/hive/web/scanner/scanner.css1
l---------docs/mkdocs/src/hive/web/scanner/scanner.html1
l---------docs/mkdocs/src/hive/web/scanner/scanner.js1
l---------docs/mkdocs/src/hive/web/scanner/scanner.py1
l---------docs/mkdocs/src/hive/web/speaker/logo.svg1
l---------docs/mkdocs/src/hive/web/speaker/speaker.css1
l---------docs/mkdocs/src/hive/web/speaker/speaker.html1
l---------docs/mkdocs/src/hive/web/speaker/speaker.js1
l---------docs/mkdocs/src/hive/web/speaker/speaker.py1
l---------docs/mkdocs/src/hive/web/ui.js1
-rw-r--r--docs/mkdocs/src/index.md12
-rw-r--r--docs/mkdocs/src/transports/android_emulator.md2
-rw-r--r--docs/mkdocs/src/transports/usb.md6
-rw-r--r--examples/avrcp_as_sink.html274
-rw-r--r--examples/heart_rate_server.py12
-rw-r--r--examples/hid_keyboard.json5
-rw-r--r--examples/keyboard.html6
-rw-r--r--examples/leaudio.json7
-rw-r--r--examples/leaudio_with_classic.json9
-rw-r--r--examples/run_a2dp_info.py8
-rw-r--r--examples/run_a2dp_source.py8
-rw-r--r--examples/run_advertiser.py14
-rw-r--r--examples/run_avrcp.py408
-rw-r--r--examples/run_cig_setup.py103
-rw-r--r--examples/run_classic_connect.py4
-rw-r--r--examples/run_csis_servers.py110
-rw-r--r--examples/run_esco_connection.py86
-rw-r--r--examples/run_extended_advertiser.py73
-rw-r--r--examples/run_extended_advertiser_2.py99
-rw-r--r--examples/run_hfp_gateway.py14
-rw-r--r--examples/run_hfp_handsfree.py42
-rw-r--r--examples/run_hid_device.py748
-rw-r--r--examples/run_hid_host.py84
-rw-r--r--examples/run_rfcomm_client.py10
-rw-r--r--examples/run_unicast_server.py190
-rw-r--r--examples/run_vcp_renderer.py191
-rw-r--r--examples/vcp_renderer.html103
-rw-r--r--extras/android/BtBench/.gitignore15
-rw-r--r--extras/android/BtBench/app/.gitignore1
-rw-r--r--extras/android/BtBench/app/build.gradle.kts70
-rw-r--r--extras/android/BtBench/app/proguard-rules.pro21
-rw-r--r--extras/android/BtBench/app/src/main/AndroidManifest.xml39
-rw-r--r--extras/android/BtBench/app/src/main/ic_launcher-playstore.pngbin0 -> 43530 bytes
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt101
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt61
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt347
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt169
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt178
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt60
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt37
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt35
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt84
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt69
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt67
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Color.kt11
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Theme.kt63
-rw-r--r--extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Type.kt33
-rw-r--r--extras/android/BtBench/app/src/main/res/drawable/ic_launcher_background.xml74
-rw-r--r--extras/android/BtBench/app/src/main/res/drawable/ic_launcher_foreground.xml30
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml5
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webpbin0 -> 3562 bytes
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_round.webpbin0 -> 4268 bytes
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webpbin0 -> 2378 bytes
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_round.webpbin0 -> 2748 bytes
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webpbin0 -> 4722 bytes
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webpbin0 -> 6248 bytes
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webpbin0 -> 7090 bytes
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webpbin0 -> 9588 bytes
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webpbin0 -> 9766 bytes
-rw-r--r--extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webpbin0 -> 13350 bytes
-rw-r--r--extras/android/BtBench/app/src/main/res/values/colors.xml10
-rw-r--r--extras/android/BtBench/app/src/main/res/values/ic_launcher_background.xml4
-rw-r--r--extras/android/BtBench/app/src/main/res/values/strings.xml3
-rw-r--r--extras/android/BtBench/app/src/main/res/values/themes.xml5
-rw-r--r--extras/android/BtBench/app/src/main/res/xml/backup_rules.xml13
-rw-r--r--extras/android/BtBench/app/src/main/res/xml/data_extraction_rules.xml19
-rw-r--r--extras/android/BtBench/build.gradle.kts7
-rw-r--r--extras/android/BtBench/gradle.properties23
-rw-r--r--extras/android/BtBench/gradle/libs.versions.toml31
-rw-r--r--extras/android/BtBench/gradle/wrapper/gradle-wrapper.jarbin0 -> 59203 bytes
-rw-r--r--extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties6
-rwxr-xr-xextras/android/BtBench/gradlew185
-rw-r--r--extras/android/BtBench/gradlew.bat89
-rw-r--r--extras/android/BtBench/settings.gradle.kts24
-rw-r--r--extras/android/RemoteHCI/app/build.gradle.kts2
-rw-r--r--extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/CommandLineInterface.kt57
-rw-r--r--extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciHal.java21
-rw-r--r--extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciServer.java13
-rw-r--r--extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/MainActivity.kt33
-rw-r--r--extras/android/RemoteHCI/gradle/libs.versions.toml2
-rw-r--r--extras/android/RemoteHCI/gradle/wrapper/gradle-wrapper.properties2
-rw-r--r--rust/Cargo.lock8
-rw-r--r--rust/Cargo.toml23
-rw-r--r--rust/examples/battery_client.rs9
-rw-r--r--rust/examples/broadcast.rs26
-rw-r--r--rust/examples/scanner.rs12
-rw-r--r--rust/pytests/wrapper.rs77
-rw-r--r--rust/pytests/wrapper/drivers.rs22
-rw-r--r--rust/pytests/wrapper/hci.rs86
-rw-r--r--rust/pytests/wrapper/mod.rs17
-rw-r--r--rust/pytests/wrapper/transport.rs31
-rw-r--r--rust/src/internal/hci/mod.rs12
-rw-r--r--rust/src/internal/hci/tests.rs17
-rw-r--r--rust/src/wrapper/device/mod.rs (renamed from rust/src/wrapper/device.rs)233
-rw-r--r--rust/src/wrapper/device/tests.rs23
-rw-r--r--rust/src/wrapper/hci.rs87
-rw-r--r--rust/src/wrapper/mod.rs9
-rw-r--r--rust/src/wrapper/transport.rs7
-rw-r--r--setup.cfg18
-rw-r--r--tasks.py2
-rw-r--r--tests/avrcp_test.py246
-rw-r--r--tests/bap_test.py403
-rw-r--r--tests/cap_test.py71
-rw-r--r--tests/csip_test.py120
-rw-r--r--tests/device_test.py207
-rw-r--r--tests/gatt_test.py192
-rw-r--r--tests/hci_test.py61
-rw-r--r--tests/hfp_test.py70
-rw-r--r--tests/host_test.py62
-rw-r--r--tests/l2cap_test.py22
-rw-r--r--tests/rfcomm_test.py80
-rw-r--r--tests/sdp_test.py27
-rw-r--r--tests/self_test.py97
-rw-r--r--tests/smp_test.py171
-rw-r--r--tests/test_utils.py24
-rw-r--r--tests/utils_test.py36
-rw-r--r--tests/vcp_test.py120
-rw-r--r--web/bumble.js201
-rw-r--r--web/heart_rate_monitor/heart_rate_monitor.html29
-rw-r--r--web/heart_rate_monitor/heart_rate_monitor.js30
-rw-r--r--web/heart_rate_monitor/heart_rate_monitor.py119
-rw-r--r--web/scanner/scanner.css3
-rw-r--r--web/scanner/scanner.html136
-rw-r--r--web/scanner/scanner.js68
-rw-r--r--web/scanner/scanner.py70
-rw-r--r--web/speaker/speaker.css11
-rw-r--r--web/speaker/speaker.html10
-rw-r--r--web/speaker/speaker.js142
-rw-r--r--web/speaker/speaker.py64
-rw-r--r--web/ui.js102
214 files changed, 19840 insertions, 3160 deletions
diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml
index 021b1e4..d65c650 100644
--- a/.github/workflows/code-check.yml
+++ b/.github/workflows/code-check.yml
@@ -29,7 +29,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v3
with:
- python-version: '3.10'
+ python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
diff --git a/.github/workflows/python-build-test.yml b/.github/workflows/python-build-test.yml
index 4cc3e73..f1a8105 100644
--- a/.github/workflows/python-build-test.yml
+++ b/.github/workflows/python-build-test.yml
@@ -47,7 +47,7 @@ jobs:
strategy:
matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
- rust-version: [ "1.70.0", "stable" ]
+ rust-version: [ "1.76.0", "stable" ]
fail-fast: false
steps:
- name: Check out from Git
@@ -56,7 +56,7 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- - name: Install dependencies
+ - name: Install Python dependencies
run: |
python -m pip install --upgrade pip
python -m pip install ".[build,test,development,documentation]"
@@ -65,15 +65,17 @@ jobs:
with:
components: clippy,rustfmt
toolchain: ${{ matrix.rust-version }}
+ - name: Install Rust dependencies
+ run: cargo install cargo-all-features # allows building/testing combinations of features
- name: Check License Headers
run: cd rust && cargo run --features dev-tools --bin file-header check-all
- name: Rust Build
- run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
+ run: cd rust && cargo build --all-targets && cargo build-all-features --all-targets
# Lints after build so what clippy needs is already built
- name: Rust Lints
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
- name: Rust Tests
- run: cd rust && cargo test
+ run: cd rust && cargo test-all-features
# At some point, hook up publishing the binary. For now, just make sure it builds.
# Once we're ready to publish binaries, this should be built with `--release`.
- name: Build Bumble CLI
diff --git a/.gitignore b/.gitignore
index 97dc64d..1a5fb9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,9 @@ __pycache__
# generated by setuptools_scm
bumble/_version.py
.vscode/launch.json
+.vscode/settings.json
/.idea
+venv/
+.venv/
+# snoop logs
+out/
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 57e682a..b535ada 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -12,7 +12,9 @@
"ASHA",
"asyncio",
"ATRAC",
+ "avctp",
"avdtp",
+ "avrcp",
"bitpool",
"bitstruct",
"BSCP",
@@ -21,7 +23,10 @@
"cccds",
"cmac",
"CONNECTIONLESS",
+ "csip",
+ "csis",
"csrcs",
+ "CVSD",
"datagram",
"DATALINK",
"delayreport",
@@ -29,6 +34,8 @@
"deregistration",
"dhkey",
"diversifier",
+ "endianness",
+ "ESCO",
"Fitbit",
"GATTLINK",
"HANDSFREE",
@@ -38,15 +45,18 @@
"libc",
"libusb",
"MITM",
+ "MSBC",
"NDIS",
"netsim",
"NONBLOCK",
"NONCONN",
"OXIMETER",
"popleft",
+ "PRAND",
"protobuf",
"psms",
"pyee",
+ "Pyodide",
"pyusb",
"rfcomm",
"ROHC",
@@ -54,6 +64,7 @@
"SEID",
"seids",
"SERV",
+ "SIRK",
"ssrc",
"strerror",
"subband",
@@ -63,6 +74,8 @@
"substates",
"tobytes",
"tsep",
+ "UNMUTE",
+ "unmuted",
"usbmodem",
"vhci",
"websockets",
diff --git a/apps/bench.py b/apps/bench.py
index 8b37883..83625f0 100644
--- a/apps/bench.py
+++ b/apps/bench.py
@@ -77,14 +77,17 @@ SPEED_SERVICE_UUID = '50DB505C-8AC4-4738-8448-3B1D9CC09CC5'
SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53'
SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D'
-DEFAULT_L2CAP_PSM = 1234
+DEFAULT_RFCOMM_UUID = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
+DEFAULT_L2CAP_PSM = 128
DEFAULT_L2CAP_MAX_CREDITS = 128
-DEFAULT_L2CAP_MTU = 1022
+DEFAULT_L2CAP_MTU = 1024
DEFAULT_L2CAP_MPS = 1024
DEFAULT_LINGER_TIME = 1.0
+DEFAULT_POST_CONNECTION_WAIT_TIME = 1.0
DEFAULT_RFCOMM_CHANNEL = 8
+DEFAULT_RFCOMM_MTU = 2048
# -----------------------------------------------------------------------------
@@ -92,7 +95,7 @@ DEFAULT_RFCOMM_CHANNEL = 8
# -----------------------------------------------------------------------------
def parse_packet(packet):
if len(packet) < 1:
- print(
+ logging.info(
color(f'!!! Packet too short (got {len(packet)} bytes, need >= 1)', 'red')
)
raise ValueError('packet too short')
@@ -100,7 +103,7 @@ def parse_packet(packet):
try:
packet_type = PacketType(packet[0])
except ValueError:
- print(color(f'!!! Invalid packet type 0x{packet[0]:02X}', 'red'))
+ logging.info(color(f'!!! Invalid packet type 0x{packet[0]:02X}', 'red'))
raise
return (packet_type, packet[1:])
@@ -108,7 +111,7 @@ def parse_packet(packet):
def parse_packet_sequence(packet_data):
if len(packet_data) < 5:
- print(
+ logging.info(
color(
f'!!!Packet too short (got {len(packet_data)} bytes, need >= 5)',
'red',
@@ -128,11 +131,16 @@ def print_connection(connection):
if connection.transport == BT_LE_TRANSPORT:
phy_state = (
'PHY='
- f'RX:{le_phy_name(connection.phy.rx_phy)}/'
- f'TX:{le_phy_name(connection.phy.tx_phy)}'
+ f'TX:{le_phy_name(connection.phy.tx_phy)}/'
+ f'RX:{le_phy_name(connection.phy.rx_phy)}'
)
- data_length = f'DL={connection.data_length}'
+ data_length = (
+ 'DL=('
+ f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
+ f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
+ ')'
+ )
connection_parameters = (
'Parameters='
f'{connection.parameters.connection_interval * 1.25:.2f}/'
@@ -147,7 +155,7 @@ def print_connection(connection):
mtu = connection.att_mtu
- print(
+ logging.info(
f'{color("@@@ Connection:", "yellow")} '
f'{connection_parameters} '
f'{data_length} '
@@ -169,9 +177,7 @@ def make_sdp_records(channel):
),
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
- DataElement.sequence(
- [DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
- ),
+ DataElement.sequence([DataElement.uuid(UUID(DEFAULT_RFCOMM_UUID))]),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
@@ -191,6 +197,23 @@ def make_sdp_records(channel):
}
+def log_stats(title, stats):
+ stats_min = min(stats)
+ stats_max = max(stats)
+ stats_avg = sum(stats) / len(stats)
+ logging.info(
+ color(
+ (
+ f'### {title} stats: '
+ f'min={stats_min:.2f}, '
+ f'max={stats_max:.2f}, '
+ f'average={stats_avg:.2f}'
+ ),
+ 'cyan',
+ )
+ )
+
+
class PacketType(enum.IntEnum):
RESET = 0
SEQUENCE = 1
@@ -204,45 +227,88 @@ PACKET_FLAG_LAST = 1
# Sender
# -----------------------------------------------------------------------------
class Sender:
- def __init__(self, packet_io, start_delay, packet_size, packet_count):
+ def __init__(
+ self,
+ packet_io,
+ start_delay,
+ repeat,
+ repeat_delay,
+ pace,
+ packet_size,
+ packet_count,
+ ):
self.tx_start_delay = start_delay
self.tx_packet_size = packet_size
self.tx_packet_count = packet_count
self.packet_io = packet_io
self.packet_io.packet_listener = self
+ self.repeat = repeat
+ self.repeat_delay = repeat_delay
+ self.pace = pace
self.start_time = 0
self.bytes_sent = 0
+ self.stats = []
self.done = asyncio.Event()
def reset(self):
pass
async def run(self):
- print(color('--- Waiting for I/O to be ready...', 'blue'))
+ logging.info(color('--- Waiting for I/O to be ready...', 'blue'))
await self.packet_io.ready.wait()
- print(color('--- Go!', 'blue'))
-
- if self.tx_start_delay:
- print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
- await asyncio.sleep(self.tx_start_delay) # FIXME
-
- print(color('=== Sending RESET', 'magenta'))
- await self.packet_io.send_packet(bytes([PacketType.RESET]))
- self.start_time = time.time()
- for tx_i in range(self.tx_packet_count):
- packet_flags = PACKET_FLAG_LAST if tx_i == self.tx_packet_count - 1 else 0
- packet = struct.pack(
- '>bbI',
- PacketType.SEQUENCE,
- packet_flags,
- tx_i,
- ) + bytes(self.tx_packet_size - 6)
- print(color(f'Sending packet {tx_i}: {len(packet)} bytes', 'yellow'))
- self.bytes_sent += len(packet)
- await self.packet_io.send_packet(packet)
+ logging.info(color('--- Go!', 'blue'))
+
+ for run in range(self.repeat + 1):
+ self.done.clear()
+
+ if run > 0 and self.repeat and self.repeat_delay:
+ logging.info(color(f'*** Repeat delay: {self.repeat_delay}', 'green'))
+ await asyncio.sleep(self.repeat_delay)
+
+ if self.tx_start_delay:
+ logging.info(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
+ await asyncio.sleep(self.tx_start_delay)
+
+ logging.info(color('=== Sending RESET', 'magenta'))
+ await self.packet_io.send_packet(bytes([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
+ )
+ 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'
+ )
+ )
+ self.bytes_sent += len(packet)
+ await self.packet_io.send_packet(packet)
- await self.done.wait()
- print(color('=== Done!', 'magenta'))
+ 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 ''
+ logging.info(color(f'=== {run_counter} Done!', 'magenta'))
+
+ if self.repeat:
+ log_stats('Run', self.stats)
+
+ if self.repeat:
+ logging.info(color('--- End of runs', 'blue'))
def on_packet_received(self, packet):
try:
@@ -253,7 +319,8 @@ class Sender:
if packet_type == PacketType.ACK:
elapsed = time.time() - self.start_time
average_tx_speed = self.bytes_sent / elapsed
- print(
+ self.stats.append(average_tx_speed)
+ logging.info(
color(
f'@@@ Received ACK. Speed: average={average_tx_speed:.4f}'
f' ({self.bytes_sent} bytes in {elapsed:.2f} seconds)',
@@ -267,17 +334,21 @@ class Sender:
# Receiver
# -----------------------------------------------------------------------------
class Receiver:
- def __init__(self, packet_io):
+ expected_packet_index: int
+ start_timestamp: float
+ last_timestamp: float
+
+ def __init__(self, packet_io, linger):
self.reset()
self.packet_io = packet_io
self.packet_io.packet_listener = self
+ self.linger = linger
self.done = asyncio.Event()
def reset(self):
self.expected_packet_index = 0
- self.start_timestamp = 0.0
- self.last_timestamp = 0.0
- self.bytes_received = 0
+ self.measurements = [(time.time(), 0)]
+ self.total_bytes_received = 0
def on_packet_received(self, packet):
try:
@@ -285,44 +356,50 @@ class Receiver:
except ValueError:
return
- now = time.time()
-
if packet_type == PacketType.RESET:
- print(color('=== Received RESET', 'magenta'))
+ logging.info(color('=== Received RESET', 'magenta'))
self.reset()
- self.start_timestamp = now
return
try:
packet_flags, packet_index = parse_packet_sequence(packet_data)
except ValueError:
return
- print(
+ logging.info(
f'<<< Received packet {packet_index}: '
- f'flags=0x{packet_flags:02X}, {len(packet)} bytes'
+ f'flags=0x{packet_flags:02X}, '
+ f'{len(packet) + self.packet_io.overhead_size} bytes'
)
if packet_index != self.expected_packet_index:
- print(
+ logging.info(
color(
f'!!! Unexpected packet, expected {self.expected_packet_index} '
f'but received {packet_index}'
)
)
- elapsed_since_start = now - self.start_timestamp
- elapsed_since_last = now - self.last_timestamp
- self.bytes_received += len(packet)
+ 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
- average_rx_speed = self.bytes_received / elapsed_since_start
- print(
+ 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:]) / (
+ window[-1][0] - window[0][0]
+ )
+ logging.info(
color(
- f'Speed: instant={instant_rx_speed:.4f}, average={average_rx_speed:.4f}',
+ 'Speed: '
+ f'instant={instant_rx_speed:.4f}, '
+ f'windowed={windowed_rx_speed:.4f}, '
+ f'average={average_rx_speed:.4f}',
'yellow',
)
)
- self.last_timestamp = now
self.expected_packet_index = packet_index + 1
if packet_flags & PACKET_FLAG_LAST:
@@ -331,52 +408,104 @@ class Receiver:
struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index)
)
)
- print(color('@@@ Received last packet', 'green'))
- self.done.set()
+ logging.info(color('@@@ Received last packet', 'green'))
+ if not self.linger:
+ self.done.set()
async def run(self):
await self.done.wait()
- print(color('=== Done!', 'magenta'))
+ logging.info(color('=== Done!', 'magenta'))
# -----------------------------------------------------------------------------
# Ping
# -----------------------------------------------------------------------------
class Ping:
- def __init__(self, packet_io, start_delay, packet_size, packet_count):
+ def __init__(
+ self,
+ packet_io,
+ start_delay,
+ repeat,
+ repeat_delay,
+ pace,
+ packet_size,
+ packet_count,
+ ):
self.tx_start_delay = start_delay
self.tx_packet_size = packet_size
self.tx_packet_count = packet_count
self.packet_io = packet_io
self.packet_io.packet_listener = self
+ self.repeat = repeat
+ self.repeat_delay = repeat_delay
+ self.pace = pace
self.done = asyncio.Event()
self.current_packet_index = 0
self.ping_sent_time = 0.0
self.latencies = []
+ self.min_stats = []
+ self.max_stats = []
+ self.avg_stats = []
def reset(self):
pass
async def run(self):
- print(color('--- Waiting for I/O to be ready...', 'blue'))
+ logging.info(color('--- Waiting for I/O to be ready...', 'blue'))
await self.packet_io.ready.wait()
- print(color('--- Go!', 'blue'))
+ logging.info(color('--- Go!', 'blue'))
- if self.tx_start_delay:
- print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
- await asyncio.sleep(self.tx_start_delay) # FIXME
+ for run in range(self.repeat + 1):
+ self.done.clear()
- print(color('=== Sending RESET', 'magenta'))
- await self.packet_io.send_packet(bytes([PacketType.RESET]))
+ if run > 0 and self.repeat and self.repeat_delay:
+ logging.info(color(f'*** Repeat delay: {self.repeat_delay}', 'green'))
+ await asyncio.sleep(self.repeat_delay)
- await self.send_next_ping()
+ if self.tx_start_delay:
+ logging.info(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
+ await asyncio.sleep(self.tx_start_delay)
- await self.done.wait()
- average_latency = sum(self.latencies) / len(self.latencies)
- print(color(f'@@@ Average latency: {average_latency:.2f}'))
- print(color('=== Done!', 'magenta'))
+ logging.info(color('=== Sending RESET', 'magenta'))
+ await self.packet_io.send_packet(bytes([PacketType.RESET]))
+
+ self.current_packet_index = 0
+ self.latencies = []
+ await self.send_next_ping()
+
+ await self.done.wait()
+
+ min_latency = min(self.latencies)
+ max_latency = max(self.latencies)
+ avg_latency = sum(self.latencies) / len(self.latencies)
+ logging.info(
+ color(
+ '@@@ Latencies: '
+ f'min={min_latency:.2f}, '
+ f'max={max_latency:.2f}, '
+ f'average={avg_latency:.2f}'
+ )
+ )
+
+ self.min_stats.append(min_latency)
+ self.max_stats.append(max_latency)
+ self.avg_stats.append(avg_latency)
+
+ run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
+ logging.info(color(f'=== {run_counter} Done!', 'magenta'))
+
+ if self.repeat:
+ log_stats('Min Latency', self.min_stats)
+ log_stats('Max Latency', self.max_stats)
+ log_stats('Average Latency', self.avg_stats)
+
+ if self.repeat:
+ logging.info(color('--- End of runs', 'blue'))
async def send_next_ping(self):
+ if self.pace:
+ await asyncio.sleep(self.pace / 1000)
+
packet = struct.pack(
'>bbI',
PacketType.SEQUENCE,
@@ -385,7 +514,7 @@ class Ping:
else 0,
self.current_packet_index,
) + bytes(self.tx_packet_size - 6)
- print(color(f'Sending packet {self.current_packet_index}', 'yellow'))
+ logging.info(color(f'Sending packet {self.current_packet_index}', 'yellow'))
self.ping_sent_time = time.time()
await self.packet_io.send_packet(packet)
@@ -405,7 +534,7 @@ class Ping:
if packet_type == PacketType.ACK:
latency = elapsed * 1000
self.latencies.append(latency)
- print(
+ logging.info(
color(
f'<<< Received ACK [{packet_index}], latency={latency:.2f}ms',
'green',
@@ -415,7 +544,7 @@ class Ping:
if packet_index == self.current_packet_index:
self.current_packet_index += 1
else:
- print(
+ logging.info(
color(
f'!!! Unexpected packet, expected {self.current_packet_index} '
f'but received {packet_index}'
@@ -433,10 +562,13 @@ class Ping:
# Pong
# -----------------------------------------------------------------------------
class Pong:
- def __init__(self, packet_io):
+ expected_packet_index: int
+
+ def __init__(self, packet_io, linger):
self.reset()
self.packet_io = packet_io
self.packet_io.packet_listener = self
+ self.linger = linger
self.done = asyncio.Event()
def reset(self):
@@ -449,7 +581,7 @@ class Pong:
return
if packet_type == PacketType.RESET:
- print(color('=== Received RESET', 'magenta'))
+ logging.info(color('=== Received RESET', 'magenta'))
self.reset()
return
@@ -457,7 +589,7 @@ class Pong:
packet_flags, packet_index = parse_packet_sequence(packet_data)
except ValueError:
return
- print(
+ logging.info(
color(
f'<<< Received packet {packet_index}: '
f'flags=0x{packet_flags:02X}, {len(packet)} bytes',
@@ -466,7 +598,7 @@ class Pong:
)
if packet_index != self.expected_packet_index:
- print(
+ logging.info(
color(
f'!!! Unexpected packet, expected {self.expected_packet_index} '
f'but received {packet_index}'
@@ -481,12 +613,12 @@ class Pong:
)
)
- if packet_flags & PACKET_FLAG_LAST:
+ if packet_flags & PACKET_FLAG_LAST and not self.linger:
self.done.set()
async def run(self):
await self.done.wait()
- print(color('=== Done!', 'magenta'))
+ logging.info(color('=== Done!', 'magenta'))
# -----------------------------------------------------------------------------
@@ -499,41 +631,42 @@ class GattClient:
self.speed_tx = None
self.packet_listener = None
self.ready = asyncio.Event()
+ self.overhead_size = 0
async def on_connection(self, connection):
peer = Peer(connection)
if self.att_mtu:
- print(color(f'*** Requesting MTU update: {self.att_mtu}', 'blue'))
+ logging.info(color(f'*** Requesting MTU update: {self.att_mtu}', 'blue'))
await peer.request_mtu(self.att_mtu)
- print(color('*** Discovering services...', 'blue'))
+ logging.info(color('*** Discovering services...', 'blue'))
await peer.discover_services()
speed_services = peer.get_services_by_uuid(SPEED_SERVICE_UUID)
if not speed_services:
- print(color('!!! Speed Service not found', 'red'))
+ logging.info(color('!!! Speed Service not found', 'red'))
return
speed_service = speed_services[0]
- print(color('*** Discovering characteristics...', 'blue'))
+ logging.info(color('*** Discovering characteristics...', 'blue'))
await speed_service.discover_characteristics()
speed_txs = speed_service.get_characteristics_by_uuid(SPEED_TX_UUID)
if not speed_txs:
- print(color('!!! Speed TX not found', 'red'))
+ logging.info(color('!!! Speed TX not found', 'red'))
return
self.speed_tx = speed_txs[0]
speed_rxs = speed_service.get_characteristics_by_uuid(SPEED_RX_UUID)
if not speed_rxs:
- print(color('!!! Speed RX not found', 'red'))
+ logging.info(color('!!! Speed RX not found', 'red'))
return
self.speed_rx = speed_rxs[0]
- print(color('*** Subscribing to RX', 'blue'))
+ logging.info(color('*** Subscribing to RX', 'blue'))
await self.speed_rx.subscribe(self.on_packet_received)
- print(color('*** Discovery complete', 'blue'))
+ logging.info(color('*** Discovery complete', 'blue'))
connection.on('disconnection', self.on_disconnection)
self.ready.set()
@@ -548,6 +681,9 @@ class GattClient:
async def send_packet(self, packet):
await self.speed_tx.write_value(packet)
+ async def drain(self):
+ pass
+
# -----------------------------------------------------------------------------
# GattServer
@@ -557,6 +693,7 @@ class GattServer:
self.device = device
self.packet_listener = None
self.ready = asyncio.Event()
+ self.overhead_size = 0
# Setup the GATT service
self.speed_tx = Characteristic(
@@ -585,10 +722,10 @@ class GattServer:
def on_rx_subscription(self, _connection, notify_enabled, _indicate_enabled):
if notify_enabled:
- print(color('*** RX subscription', 'blue'))
+ logging.info(color('*** RX subscription', 'blue'))
self.ready.set()
else:
- print(color('*** RX un-subscription', 'blue'))
+ logging.info(color('*** RX un-subscription', 'blue'))
self.ready.clear()
def on_tx_write(self, _, value):
@@ -598,6 +735,9 @@ class GattServer:
async def send_packet(self, packet):
await self.device.notify_subscribers(self.speed_rx, packet)
+ async def drain(self):
+ pass
+
# -----------------------------------------------------------------------------
# StreamedPacketIO
@@ -609,6 +749,7 @@ class StreamedPacketIO:
self.rx_packet = b''
self.rx_packet_header = b''
self.rx_packet_need = 0
+ self.overhead_size = 2
def on_packet(self, packet):
while packet:
@@ -636,7 +777,7 @@ class StreamedPacketIO:
async def send_packet(self, packet):
if not self.io_sink:
- print(color('!!! No sink, dropping packet', 'red'))
+ logging.info(color('!!! No sink, dropping packet', 'red'))
return
# pylint: disable-next=not-callable
@@ -660,13 +801,14 @@ class L2capClient(StreamedPacketIO):
self.max_credits = max_credits
self.mtu = mtu
self.mps = mps
+ self.l2cap_channel = None
self.ready = asyncio.Event()
async def on_connection(self, connection: Connection) -> None:
connection.on('disconnection', self.on_disconnection)
# Connect a new L2CAP channel
- print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
+ logging.info(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
try:
l2cap_channel = await connection.create_l2cap_channel(
spec=l2cap.LeCreditBasedChannelSpec(
@@ -676,14 +818,15 @@ class L2capClient(StreamedPacketIO):
mps=self.mps,
)
)
- print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
+ logging.info(color(f'*** L2CAP channel: {l2cap_channel}', 'cyan'))
except Exception as error:
- print(color(f'!!! Connection failed: {error}', 'red'))
+ logging.info(color(f'!!! Connection failed: {error}', 'red'))
return
- l2cap_channel.sink = self.on_packet
- l2cap_channel.on('close', self.on_l2cap_close)
self.io_sink = l2cap_channel.write
+ self.l2cap_channel = l2cap_channel
+ l2cap_channel.on('close', self.on_l2cap_close)
+ l2cap_channel.sink = self.on_packet
self.ready.set()
@@ -691,7 +834,11 @@ class L2capClient(StreamedPacketIO):
pass
def on_l2cap_close(self):
- print(color('*** L2CAP channel closed', 'red'))
+ logging.info(color('*** L2CAP channel closed', 'red'))
+
+ async def drain(self):
+ assert self.l2cap_channel
+ await self.l2cap_channel.drain()
# -----------------------------------------------------------------------------
@@ -710,14 +857,16 @@ class L2capServer(StreamedPacketIO):
self.l2cap_channel = None
self.ready = asyncio.Event()
- # Listen for incoming L2CAP CoC connections
+ # Listen for incoming L2CAP connections
device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(
psm=psm, mtu=mtu, mps=mps, max_credits=max_credits
),
handler=self.on_l2cap_channel,
)
- print(color(f'### Listening for CoC connection on PSM {psm}', 'yellow'))
+ logging.info(
+ color(f'### Listening for L2CAP connection on PSM {psm}', 'yellow')
+ )
async def on_connection(self, connection):
connection.on('disconnection', self.on_disconnection)
@@ -726,74 +875,116 @@ class L2capServer(StreamedPacketIO):
pass
def on_l2cap_channel(self, l2cap_channel):
- print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
+ logging.info(color(f'*** L2CAP channel: {l2cap_channel}', 'cyan'))
self.io_sink = l2cap_channel.write
+ self.l2cap_channel = l2cap_channel
l2cap_channel.on('close', self.on_l2cap_close)
l2cap_channel.sink = self.on_packet
self.ready.set()
def on_l2cap_close(self):
- print(color('*** L2CAP channel closed', 'red'))
+ logging.info(color('*** L2CAP channel closed', 'red'))
self.l2cap_channel = None
+ async def drain(self):
+ assert self.l2cap_channel
+ await self.l2cap_channel.drain()
+
# -----------------------------------------------------------------------------
# RfcommClient
# -----------------------------------------------------------------------------
class RfcommClient(StreamedPacketIO):
- def __init__(self, device):
+ def __init__(self, device, channel, uuid, l2cap_mtu, max_frame_size, window_size):
super().__init__()
self.device = device
+ self.channel = channel
+ self.uuid = uuid
+ self.l2cap_mtu = l2cap_mtu
+ self.max_frame_size = max_frame_size
+ self.window_size = window_size
+ self.rfcomm_session = None
self.ready = asyncio.Event()
async def on_connection(self, connection):
connection.on('disconnection', self.on_disconnection)
+ # Find the channel number if not specified
+ channel = self.channel
+ if channel == 0:
+ logging.info(
+ color(f'@@@ Discovering channel number from UUID {self.uuid}', 'cyan')
+ )
+ channel = await bumble.rfcomm.find_rfcomm_channel_with_uuid(
+ connection, self.uuid
+ )
+ logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
+ if channel == 0:
+ logging.info(color('!!! No RFComm service with this UUID found', 'red'))
+ await connection.disconnect()
+ return
+
# Create a client and start it
- print(color('*** Starting RFCOMM client...', 'blue'))
- rfcomm_client = bumble.rfcomm.Client(self.device, connection)
+ logging.info(color('*** Starting RFCOMM client...', 'blue'))
+ rfcomm_options = {}
+ if self.l2cap_mtu:
+ rfcomm_options['l2cap_mtu'] = self.l2cap_mtu
+ rfcomm_client = bumble.rfcomm.Client(connection, **rfcomm_options)
rfcomm_mux = await rfcomm_client.start()
- print(color('*** Started', 'blue'))
+ logging.info(color('*** Started', 'blue'))
- channel = DEFAULT_RFCOMM_CHANNEL
- print(color(f'### Opening session for channel {channel}...', 'yellow'))
+ logging.info(color(f'### Opening session for channel {channel}...', 'yellow'))
try:
- rfcomm_session = await rfcomm_mux.open_dlc(channel)
- print(color('### Session open', 'yellow'), rfcomm_session)
+ dlc_options = {}
+ if self.max_frame_size:
+ dlc_options['max_frame_size'] = self.max_frame_size
+ if self.window_size:
+ dlc_options['window_size'] = self.window_size
+ rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options)
+ logging.info(color(f'### Session open: {rfcomm_session}', 'yellow'))
except bumble.core.ConnectionError as error:
- print(color(f'!!! Session open failed: {error}', 'red'))
+ logging.info(color(f'!!! Session open failed: {error}', 'red'))
await rfcomm_mux.disconnect()
return
rfcomm_session.sink = self.on_packet
self.io_sink = rfcomm_session.write
+ self.rfcomm_session = rfcomm_session
self.ready.set()
def on_disconnection(self, _):
pass
+ async def drain(self):
+ assert self.rfcomm_session
+ await self.rfcomm_session.drain()
+
# -----------------------------------------------------------------------------
# RfcommServer
# -----------------------------------------------------------------------------
class RfcommServer(StreamedPacketIO):
- def __init__(self, device):
+ def __init__(self, device, channel, l2cap_mtu):
super().__init__()
+ self.dlc = None
self.ready = asyncio.Event()
# Create and register a server
- rfcomm_server = bumble.rfcomm.Server(device)
+ server_options = {}
+ if l2cap_mtu:
+ server_options['l2cap_mtu'] = l2cap_mtu
+ rfcomm_server = bumble.rfcomm.Server(device, **server_options)
# Listen for incoming DLC connections
- channel_number = rfcomm_server.listen(self.on_dlc, DEFAULT_RFCOMM_CHANNEL)
+ channel_number = rfcomm_server.listen(self.on_dlc, channel)
# Setup the SDP to advertise this channel
device.sdp_service_records = make_sdp_records(channel_number)
- print(
+ logging.info(
color(
f'### Listening for RFComm connection on channel {channel_number}',
'yellow',
@@ -807,9 +998,14 @@ class RfcommServer(StreamedPacketIO):
pass
def on_dlc(self, dlc):
- print(color('*** DLC connected:', 'blue'), dlc)
+ logging.info(color(f'*** DLC connected: {dlc}', 'blue'))
dlc.sink = self.on_packet
self.io_sink = dlc.write
+ self.dlc = dlc
+
+ async def drain(self):
+ assert self.dlc
+ await self.dlc.drain()
# -----------------------------------------------------------------------------
@@ -825,6 +1021,9 @@ class Central(Connection.Listener):
mode_factory,
connection_interval,
phy,
+ authenticate,
+ encrypt,
+ extended_data_length,
):
super().__init__()
self.transport = transport
@@ -832,6 +1031,9 @@ class Central(Connection.Listener):
self.classic = classic
self.role_factory = role_factory
self.mode_factory = mode_factory
+ self.authenticate = authenticate
+ self.encrypt = encrypt or authenticate
+ self.extended_data_length = extended_data_length
self.device = None
self.connection = None
@@ -867,12 +1069,12 @@ class Central(Connection.Listener):
self.connection_parameter_preferences = None
async def run(self):
- print(color('>>> Connecting to HCI...', 'green'))
+ logging.info(color('>>> Connecting to HCI...', 'green'))
async with await open_transport_or_link(self.transport) as (
hci_source,
hci_sink,
):
- print(color('>>> Connected', 'green'))
+ logging.info(color('>>> Connected', 'green'))
central_address = DEFAULT_CENTRAL_ADDRESS
self.device = Device.with_hci(
@@ -884,7 +1086,13 @@ class Central(Connection.Listener):
await self.device.power_on()
- print(color(f'### Connecting to {self.peripheral_address}...', 'cyan'))
+ if self.classic:
+ await self.device.set_discoverable(False)
+ await self.device.set_connectable(False)
+
+ logging.info(
+ color(f'### Connecting to {self.peripheral_address}...', 'cyan')
+ )
try:
self.connection = await self.device.connect(
self.peripheral_address,
@@ -892,19 +1100,43 @@ class Central(Connection.Listener):
transport=BT_BR_EDR_TRANSPORT if self.classic else BT_LE_TRANSPORT,
)
except CommandTimeoutError:
- print(color('!!! Connection timed out', 'red'))
+ logging.info(color('!!! Connection timed out', 'red'))
return
except bumble.core.ConnectionError as error:
- print(color(f'!!! Connection error: {error}', 'red'))
+ logging.info(color(f'!!! Connection error: {error}', 'red'))
return
except HCI_StatusError as error:
- print(color(f'!!! Connection failed: {error.error_name}'))
+ logging.info(color(f'!!! Connection failed: {error.error_name}'))
return
- print(color('### Connected', 'cyan'))
+ logging.info(color('### Connected', 'cyan'))
self.connection.listener = self
print_connection(self.connection)
- await mode.on_connection(self.connection)
+ # Wait a bit after the connection, some controllers aren't very good when
+ # we start sending data right away while some connection parameters are
+ # updated post connection
+ await asyncio.sleep(DEFAULT_POST_CONNECTION_WAIT_TIME)
+
+ # Request a new data length if requested
+ if self.extended_data_length:
+ logging.info(color('+++ Requesting extended data length', 'cyan'))
+ await self.connection.set_data_length(
+ self.extended_data_length[0], self.extended_data_length[1]
+ )
+
+ # Authenticate if requested
+ if self.authenticate:
+ # Request authentication
+ logging.info(color('*** Authenticating...', 'cyan'))
+ await self.connection.authenticate()
+ logging.info(color('*** Authenticated', 'cyan'))
+
+ # Encrypt if requested
+ if self.encrypt:
+ # Enable encryption
+ logging.info(color('*** Enabling encryption...', 'cyan'))
+ await self.connection.encrypt()
+ logging.info(color('*** Encryption on', 'cyan'))
# Set the PHY if requested
if self.phy is not None:
@@ -913,17 +1145,20 @@ class Central(Connection.Listener):
tx_phys=[self.phy], rx_phys=[self.phy]
)
except HCI_Error as error:
- print(
+ logging.info(
color(
f'!!! Unable to set the PHY: {error.error_name}', 'yellow'
)
)
+ await mode.on_connection(self.connection)
+
await role.run()
await asyncio.sleep(DEFAULT_LINGER_TIME)
+ await self.connection.disconnect()
def on_disconnection(self, reason):
- print(color(f'!!! Disconnection: reason={reason}', 'red'))
+ logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
self.connection = None
def on_connection_parameters_update(self):
@@ -943,9 +1178,12 @@ class Central(Connection.Listener):
# Peripheral
# -----------------------------------------------------------------------------
class Peripheral(Device.Listener, Connection.Listener):
- def __init__(self, transport, classic, role_factory, mode_factory):
+ def __init__(
+ self, transport, classic, extended_data_length, role_factory, mode_factory
+ ):
self.transport = transport
self.classic = classic
+ self.extended_data_length = extended_data_length
self.role_factory = role_factory
self.role = None
self.mode_factory = mode_factory
@@ -955,12 +1193,12 @@ class Peripheral(Device.Listener, Connection.Listener):
self.connected = asyncio.Event()
async def run(self):
- print(color('>>> Connecting to HCI...', 'green'))
+ logging.info(color('>>> Connecting to HCI...', 'green'))
async with await open_transport_or_link(self.transport) as (
hci_source,
hci_sink,
):
- print(color('>>> Connected', 'green'))
+ logging.info(color('>>> Connected', 'green'))
peripheral_address = DEFAULT_PERIPHERAL_ADDRESS
self.device = Device.with_hci(
@@ -980,7 +1218,7 @@ class Peripheral(Device.Listener, Connection.Listener):
await self.device.start_advertising(auto_restart=True)
if self.classic:
- print(
+ logging.info(
color(
'### Waiting for connection on'
f' {self.device.public_address}...',
@@ -988,14 +1226,14 @@ class Peripheral(Device.Listener, Connection.Listener):
)
)
else:
- print(
+ logging.info(
color(
f'### Waiting for connection on {peripheral_address}...',
'cyan',
)
)
await self.connected.wait()
- print(color('### Connected', 'cyan'))
+ logging.info(color('### Connected', 'cyan'))
await self.mode.on_connection(self.connection)
await self.role.run()
@@ -1006,11 +1244,29 @@ class Peripheral(Device.Listener, Connection.Listener):
self.connection = connection
self.connected.set()
+ # Stop being discoverable and connectable
+ if self.classic:
+ AsyncRunner.spawn(self.device.set_discoverable(False))
+ AsyncRunner.spawn(self.device.set_connectable(False))
+
+ # Request a new data length if needed
+ if self.extended_data_length:
+ logging.info("+++ Requesting extended data length")
+ AsyncRunner.spawn(
+ connection.set_data_length(
+ self.extended_data_length[0], self.extended_data_length[1]
+ )
+ )
+
def on_disconnection(self, reason):
- print(color(f'!!! Disconnection: reason={reason}', 'red'))
+ logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
self.connection = None
self.role.reset()
+ if self.classic:
+ AsyncRunner.spawn(self.device.set_discoverable(True))
+ AsyncRunner.spawn(self.device.set_connectable(True))
+
def on_connection_parameters_update(self):
print_connection(self.connection)
@@ -1038,16 +1294,39 @@ def create_mode_factory(ctx, default_mode):
return GattServer(device)
if mode == 'l2cap-client':
- return L2capClient(device)
+ return L2capClient(
+ device,
+ psm=ctx.obj['l2cap_psm'],
+ mtu=ctx.obj['l2cap_mtu'],
+ mps=ctx.obj['l2cap_mps'],
+ max_credits=ctx.obj['l2cap_max_credits'],
+ )
if mode == 'l2cap-server':
- return L2capServer(device)
+ return L2capServer(
+ device,
+ psm=ctx.obj['l2cap_psm'],
+ mtu=ctx.obj['l2cap_mtu'],
+ mps=ctx.obj['l2cap_mps'],
+ max_credits=ctx.obj['l2cap_max_credits'],
+ )
if mode == 'rfcomm-client':
- return RfcommClient(device)
+ return RfcommClient(
+ device,
+ channel=ctx.obj['rfcomm_channel'],
+ uuid=ctx.obj['rfcomm_uuid'],
+ l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
+ max_frame_size=ctx.obj['rfcomm_max_frame_size'],
+ window_size=ctx.obj['rfcomm_window_size'],
+ )
if mode == 'rfcomm-server':
- return RfcommServer(device)
+ return RfcommServer(
+ device,
+ channel=ctx.obj['rfcomm_channel'],
+ l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
+ )
raise ValueError('invalid mode')
@@ -1065,23 +1344,29 @@ def create_role_factory(ctx, default_role):
return Sender(
packet_io,
start_delay=ctx.obj['start_delay'],
+ repeat=ctx.obj['repeat'],
+ repeat_delay=ctx.obj['repeat_delay'],
+ pace=ctx.obj['pace'],
packet_size=ctx.obj['packet_size'],
packet_count=ctx.obj['packet_count'],
)
if role == 'receiver':
- return Receiver(packet_io)
+ return Receiver(packet_io, ctx.obj['linger'])
if role == 'ping':
return Ping(
packet_io,
start_delay=ctx.obj['start_delay'],
+ repeat=ctx.obj['repeat'],
+ repeat_delay=ctx.obj['repeat_delay'],
+ pace=ctx.obj['pace'],
packet_size=ctx.obj['packet_size'],
packet_count=ctx.obj['packet_count'],
)
if role == 'pong':
- return Pong(packet_io)
+ return Pong(packet_io, ctx.obj['linger'])
raise ValueError('invalid role')
@@ -1114,12 +1399,66 @@ def create_role_factory(ctx, default_role):
help='GATT MTU (gatt-client mode)',
)
@click.option(
+ '--extended-data-length',
+ help='Request a data length upon connection, specified as tx_octets/tx_time',
+)
+@click.option(
+ '--rfcomm-channel',
+ type=int,
+ default=DEFAULT_RFCOMM_CHANNEL,
+ help='RFComm channel to use',
+)
+@click.option(
+ '--rfcomm-uuid',
+ default=DEFAULT_RFCOMM_UUID,
+ help='RFComm service UUID to use (ignored if --rfcomm-channel is not 0)',
+)
+@click.option(
+ '--rfcomm-l2cap-mtu',
+ type=int,
+ help='RFComm L2CAP MTU',
+)
+@click.option(
+ '--rfcomm-max-frame-size',
+ type=int,
+ help='RFComm maximum frame size',
+)
+@click.option(
+ '--rfcomm-window-size',
+ type=int,
+ help='RFComm window size',
+)
+@click.option(
+ '--l2cap-psm',
+ type=int,
+ default=DEFAULT_L2CAP_PSM,
+ help='L2CAP PSM to use',
+)
+@click.option(
+ '--l2cap-mtu',
+ type=int,
+ default=DEFAULT_L2CAP_MTU,
+ help='L2CAP MTU to use',
+)
+@click.option(
+ '--l2cap-mps',
+ type=int,
+ default=DEFAULT_L2CAP_MPS,
+ help='L2CAP MPS to use',
+)
+@click.option(
+ '--l2cap-max-credits',
+ type=int,
+ default=DEFAULT_L2CAP_MAX_CREDITS,
+ help='L2CAP maximum number of credits allowed for the peer',
+)
+@click.option(
'--packet-size',
'-s',
metavar='SIZE',
type=click.IntRange(8, 4096),
default=500,
- help='Packet size (server role)',
+ help='Packet size (client or ping role)',
)
@click.option(
'--packet-count',
@@ -1127,7 +1466,7 @@ def create_role_factory(ctx, default_role):
metavar='COUNT',
type=int,
default=10,
- help='Packet count (server role)',
+ help='Packet count (client or ping role)',
)
@click.option(
'--start-delay',
@@ -1135,21 +1474,92 @@ def create_role_factory(ctx, default_role):
metavar='SECONDS',
type=int,
default=1,
- help='Start delay (server role)',
+ help='Start delay (client or ping role)',
+)
+@click.option(
+ '--repeat',
+ metavar='N',
+ type=int,
+ default=0,
+ help=(
+ 'Repeat the run N times (client and ping roles)'
+ '(0, which is the fault, to run just once) '
+ ),
+)
+@click.option(
+ '--repeat-delay',
+ metavar='SECONDS',
+ type=int,
+ default=1,
+ help=('Delay, in seconds, between repeats'),
+)
+@click.option(
+ '--pace',
+ metavar='MILLISECONDS',
+ type=int,
+ default=0,
+ help=(
+ 'Wait N milliseconds between packets '
+ '(0, which is the fault, to send as fast as possible) '
+ ),
+)
+@click.option(
+ '--linger',
+ is_flag=True,
+ help="Don't exit at the end of a run (server and pong roles)",
)
@click.pass_context
def bench(
- ctx, device_config, role, mode, att_mtu, packet_size, packet_count, start_delay
+ ctx,
+ device_config,
+ role,
+ mode,
+ att_mtu,
+ extended_data_length,
+ packet_size,
+ packet_count,
+ start_delay,
+ repeat,
+ repeat_delay,
+ pace,
+ linger,
+ rfcomm_channel,
+ rfcomm_uuid,
+ rfcomm_l2cap_mtu,
+ rfcomm_max_frame_size,
+ rfcomm_window_size,
+ l2cap_psm,
+ l2cap_mtu,
+ l2cap_mps,
+ l2cap_max_credits,
):
ctx.ensure_object(dict)
ctx.obj['device_config'] = device_config
ctx.obj['role'] = role
ctx.obj['mode'] = mode
ctx.obj['att_mtu'] = att_mtu
+ ctx.obj['rfcomm_channel'] = rfcomm_channel
+ ctx.obj['rfcomm_uuid'] = rfcomm_uuid
+ ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu
+ ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size
+ ctx.obj['rfcomm_window_size'] = rfcomm_window_size
+ ctx.obj['l2cap_psm'] = l2cap_psm
+ ctx.obj['l2cap_mtu'] = l2cap_mtu
+ ctx.obj['l2cap_mps'] = l2cap_mps
+ ctx.obj['l2cap_max_credits'] = l2cap_max_credits
ctx.obj['packet_size'] = packet_size
ctx.obj['packet_count'] = packet_count
ctx.obj['start_delay'] = start_delay
-
+ ctx.obj['repeat'] = repeat
+ ctx.obj['repeat_delay'] = repeat_delay
+ ctx.obj['pace'] = pace
+ ctx.obj['linger'] = linger
+
+ ctx.obj['extended_data_length'] = (
+ [int(x) for x in extended_data_length.split('/')]
+ if extended_data_length
+ else None
+ )
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
@@ -1170,8 +1580,12 @@ def bench(
help='Connection interval (in ms)',
)
@click.option('--phy', type=click.Choice(['1m', '2m', 'coded']), help='PHY to use')
+@click.option('--authenticate', is_flag=True, help='Authenticate (RFComm only)')
+@click.option('--encrypt', is_flag=True, help='Encrypt the connection (RFComm only)')
@click.pass_context
-def central(ctx, transport, peripheral_address, connection_interval, phy):
+def central(
+ ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
+):
"""Run as a central (initiates the connection)"""
role_factory = create_role_factory(ctx, 'sender')
mode_factory = create_mode_factory(ctx, 'gatt-client')
@@ -1186,6 +1600,9 @@ def central(ctx, transport, peripheral_address, connection_interval, phy):
mode_factory,
connection_interval,
phy,
+ authenticate,
+ encrypt or authenticate,
+ ctx.obj['extended_data_length'],
).run()
)
@@ -1199,7 +1616,13 @@ def peripheral(ctx, transport):
mode_factory = create_mode_factory(ctx, 'gatt-server')
asyncio.run(
- Peripheral(transport, ctx.obj['classic'], role_factory, mode_factory).run()
+ Peripheral(
+ transport,
+ ctx.obj['classic'],
+ ctx.obj['extended_data_length'],
+ role_factory,
+ mode_factory,
+ ).run()
)
diff --git a/apps/ble_rpa_tool.py b/apps/ble_rpa_tool.py
new file mode 100644
index 0000000..07d89a3
--- /dev/null
+++ b/apps/ble_rpa_tool.py
@@ -0,0 +1,63 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import click
+from bumble.colors import color
+from bumble.hci import Address
+from bumble.helpers import generate_irk, verify_rpa_with_irk
+
+
+@click.group()
+def cli():
+ '''
+ This is a tool for generating IRK, RPA,
+ and verifying IRK/RPA pairs
+ '''
+
+
+@click.command()
+def gen_irk() -> None:
+ print(generate_irk().hex())
+
+
+@click.command()
+@click.argument("irk", type=str)
+def gen_rpa(irk: str) -> None:
+ irk_bytes = bytes.fromhex(irk)
+ rpa = Address.generate_private_address(irk_bytes)
+ print(rpa.to_string(with_type_qualifier=False))
+
+
+@click.command()
+@click.argument("irk", type=str)
+@click.argument("rpa", type=str)
+def verify_rpa(irk: str, rpa: str) -> None:
+ address = Address(rpa)
+ irk_bytes = bytes.fromhex(irk)
+ if verify_rpa_with_irk(address, irk_bytes):
+ print(color("Verified", "green"))
+ else:
+ print(color("Not Verified", "red"))
+
+
+def main():
+ cli.add_command(gen_irk)
+ cli.add_command(gen_rpa)
+ cli.add_command(verify_rpa)
+ cli()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ main()
diff --git a/apps/console.py b/apps/console.py
index 9a529dd..5d04636 100644
--- a/apps/console.py
+++ b/apps/console.py
@@ -777,7 +777,7 @@ class ConsoleApp:
if not service:
continue
values = [
- attribute.read_value(connection)
+ await attribute.read_value(connection)
for connection in self.device.connections.values()
]
if not values:
@@ -796,11 +796,11 @@ class ConsoleApp:
if not characteristic:
continue
values = [
- attribute.read_value(connection)
+ await attribute.read_value(connection)
for connection in self.device.connections.values()
]
if not values:
- values = [attribute.read_value(None)]
+ values = [await attribute.read_value(None)]
# TODO: future optimization: convert CCCD value to human readable string
@@ -944,7 +944,7 @@ class ConsoleApp:
# send data to any subscribers
if isinstance(attribute, Characteristic):
- attribute.write_value(None, value)
+ await attribute.write_value(None, value)
if attribute.has_properties(Characteristic.NOTIFY):
await self.device.gatt_server.notify_subscribers(attribute)
if attribute.has_properties(Characteristic.INDICATE):
diff --git a/apps/controller_info.py b/apps/controller_info.py
index 5be4f3d..83ac3bb 100644
--- a/apps/controller_info.py
+++ b/apps/controller_info.py
@@ -18,30 +18,39 @@
import asyncio
import os
import logging
+import time
+
import click
-from bumble.company_ids import COMPANY_IDENTIFIERS
+from bumble.company_ids import COMPANY_IDENTIFIERS
from bumble.colors import color
from bumble.core import name_or_number
from bumble.hci import (
map_null_terminated_utf8_string,
+ LeFeatureMask,
HCI_SUCCESS,
- HCI_LE_SUPPORTED_FEATURES_NAMES,
HCI_VERSION_NAMES,
LMP_VERSION_NAMES,
HCI_Command,
HCI_Command_Complete_Event,
HCI_Command_Status_Event,
+ HCI_READ_BUFFER_SIZE_COMMAND,
+ HCI_Read_Buffer_Size_Command,
HCI_READ_BD_ADDR_COMMAND,
HCI_Read_BD_ADDR_Command,
HCI_READ_LOCAL_NAME_COMMAND,
HCI_Read_Local_Name_Command,
+ HCI_LE_READ_BUFFER_SIZE_COMMAND,
+ HCI_LE_Read_Buffer_Size_Command,
HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
HCI_LE_Read_Maximum_Data_Length_Command,
HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command,
HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
+ HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
+ HCI_LE_Read_Suggested_Default_Data_Length_Command,
+ HCI_Read_Local_Version_Information_Command,
)
from bumble.host import Host
from bumble.transport import open_transport_or_link
@@ -57,7 +66,7 @@ def command_succeeded(response):
# -----------------------------------------------------------------------------
-async def get_classic_info(host):
+async def get_classic_info(host: Host) -> None:
if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
response = await host.send_command(HCI_Read_BD_ADDR_Command())
if command_succeeded(response):
@@ -78,7 +87,7 @@ async def get_classic_info(host):
# -----------------------------------------------------------------------------
-async def get_le_info(host):
+async def get_le_info(host: Host) -> None:
print()
if host.supports_command(HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND):
@@ -117,13 +126,50 @@ async def get_le_info(host):
'\n',
)
+ if host.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
+ response = await host.send_command(
+ HCI_LE_Read_Suggested_Default_Data_Length_Command()
+ )
+ if command_succeeded(response):
+ print(
+ color('Suggested Default Data Length:', 'yellow'),
+ f'{response.return_parameters.suggested_max_tx_octets}/'
+ f'{response.return_parameters.suggested_max_tx_time}',
+ '\n',
+ )
+
print(color('LE Features:', 'yellow'))
for feature in host.supported_le_features:
- print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))
+ print(LeFeatureMask(feature).name)
+
+
+# -----------------------------------------------------------------------------
+async def get_acl_flow_control_info(host: Host) -> None:
+ print()
+
+ if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
+ response = await host.send_command(
+ HCI_Read_Buffer_Size_Command(), check_result=True
+ )
+ print(
+ color('ACL Flow Control:', 'yellow'),
+ f'{response.return_parameters.hc_total_num_acl_data_packets} '
+ f'packets of size {response.return_parameters.hc_acl_data_packet_length}',
+ )
+
+ if host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
+ response = await host.send_command(
+ HCI_LE_Read_Buffer_Size_Command(), check_result=True
+ )
+ print(
+ color('LE ACL Flow Control:', 'yellow'),
+ f'{response.return_parameters.hc_total_num_le_acl_data_packets} '
+ f'packets of size {response.return_parameters.hc_le_acl_data_packet_length}',
+ )
# -----------------------------------------------------------------------------
-async def async_main(transport):
+async def async_main(latency_probes, transport):
print('<<< connecting to HCI...')
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
print('<<< connected')
@@ -131,6 +177,23 @@ async def async_main(transport):
host = Host(hci_source, hci_sink)
await host.reset()
+ # Measure the latency if requested
+ latencies = []
+ if latency_probes:
+ for _ in range(latency_probes):
+ start = time.time()
+ await host.send_command(HCI_Read_Local_Version_Information_Command())
+ latencies.append(1000 * (time.time() - start))
+ print(
+ color('HCI Command Latency:', 'yellow'),
+ (
+ f'min={min(latencies):.2f}, '
+ f'max={max(latencies):.2f}, '
+ f'average={sum(latencies)/len(latencies):.2f}'
+ ),
+ '\n',
+ )
+
# Print version
print(color('Version:', 'yellow'))
print(
@@ -154,6 +217,9 @@ async def async_main(transport):
# Get the LE info
await get_le_info(host)
+ # Print the ACL flow control info
+ await get_acl_flow_control_info(host)
+
# Print the list of commands supported by the controller
print()
print(color('Supported Commands:', 'yellow'))
@@ -163,10 +229,16 @@ async def async_main(transport):
# -----------------------------------------------------------------------------
@click.command()
+@click.option(
+ '--latency-probes',
+ metavar='N',
+ type=int,
+ help='Send N commands to measure HCI transport latency statistics',
+)
@click.argument('transport')
-def main(transport):
+def main(latency_probes, transport):
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
- asyncio.run(async_main(transport))
+ asyncio.run(async_main(latency_probes, transport))
# -----------------------------------------------------------------------------
diff --git a/apps/controller_loopback.py b/apps/controller_loopback.py
new file mode 100644
index 0000000..2d16bb9
--- /dev/null
+++ b/apps/controller_loopback.py
@@ -0,0 +1,205 @@
+# Copyright 2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import os
+import time
+from typing import Optional
+from bumble.colors import color
+from bumble.hci import (
+ HCI_READ_LOOPBACK_MODE_COMMAND,
+ HCI_Read_Loopback_Mode_Command,
+ HCI_WRITE_LOOPBACK_MODE_COMMAND,
+ HCI_Write_Loopback_Mode_Command,
+ LoopbackMode,
+)
+from bumble.host import Host
+from bumble.transport import open_transport_or_link
+import click
+
+
+class Loopback:
+ """Send and receive ACL data packets in local loopback mode"""
+
+ def __init__(self, packet_size: int, packet_count: int, transport: str):
+ self.transport = transport
+ self.packet_size = packet_size
+ self.packet_count = packet_count
+ self.connection_handle: Optional[int] = None
+ self.connection_event = asyncio.Event()
+ self.done = asyncio.Event()
+ self.expected_cid = 0
+ self.bytes_received = 0
+ self.start_timestamp = 0.0
+ self.last_timestamp = 0.0
+
+ def on_connection(self, connection_handle: int, *args):
+ """Retrieve connection handle from new connection event"""
+ if not self.connection_event.is_set():
+ # save first connection handle for ACL
+ # subsequent connections are SCO
+ self.connection_handle = connection_handle
+ self.connection_event.set()
+
+ def on_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes):
+ """Calculate packet receive speed"""
+ now = time.time()
+ print(f'<<< Received packet {cid}: {len(pdu)} bytes')
+ assert connection_handle == self.connection_handle
+ assert cid == self.expected_cid
+ self.expected_cid += 1
+ if cid == 0:
+ self.start_timestamp = now
+ else:
+ elapsed_since_start = now - self.start_timestamp
+ elapsed_since_last = now - self.last_timestamp
+ self.bytes_received += len(pdu)
+ instant_rx_speed = len(pdu) / elapsed_since_last
+ average_rx_speed = self.bytes_received / elapsed_since_start
+ print(
+ color(
+ f'@@@ RX speed: instant={instant_rx_speed:.4f},'
+ f' average={average_rx_speed:.4f}',
+ 'cyan',
+ )
+ )
+
+ self.last_timestamp = now
+
+ if self.expected_cid == self.packet_count:
+ print(color('@@@ Received last packet', 'green'))
+ self.done.set()
+
+ async def run(self):
+ """Run a loopback throughput test"""
+ print(color('>>> Connecting to HCI...', 'green'))
+ async with await open_transport_or_link(self.transport) as (
+ hci_source,
+ hci_sink,
+ ):
+ print(color('>>> Connected', 'green'))
+
+ host = Host(hci_source, hci_sink)
+ await host.reset()
+
+ # make sure data can fit in one l2cap pdu
+ l2cap_header_size = 4
+
+ max_packet_size = (
+ host.acl_packet_queue
+ if host.acl_packet_queue
+ else host.le_acl_packet_queue
+ ).max_packet_size - l2cap_header_size
+ if self.packet_size > max_packet_size:
+ print(
+ color(
+ f'!!! Packet size ({self.packet_size}) larger than max supported'
+ f' size ({max_packet_size})',
+ 'red',
+ )
+ )
+ return
+
+ if not host.supports_command(
+ HCI_WRITE_LOOPBACK_MODE_COMMAND
+ ) or not host.supports_command(HCI_READ_LOOPBACK_MODE_COMMAND):
+ print(color('!!! Loopback mode not supported', 'red'))
+ return
+
+ # set event callbacks
+ host.on('connection', self.on_connection)
+ host.on('l2cap_pdu', self.on_l2cap_pdu)
+
+ loopback_mode = LoopbackMode.LOCAL
+
+ print(color('### Setting loopback mode', 'blue'))
+ await host.send_command(
+ HCI_Write_Loopback_Mode_Command(loopback_mode=LoopbackMode.LOCAL),
+ check_result=True,
+ )
+
+ print(color('### Checking loopback mode', 'blue'))
+ response = await host.send_command(
+ HCI_Read_Loopback_Mode_Command(), check_result=True
+ )
+ if response.return_parameters.loopback_mode != loopback_mode:
+ print(color('!!! Loopback mode mismatch', 'red'))
+ return
+
+ await self.connection_event.wait()
+ print(color('### Connected', 'cyan'))
+
+ print(color('=== Start sending', 'magenta'))
+ start_time = time.time()
+ bytes_sent = 0
+ for cid in range(0, self.packet_count):
+ # using the cid as an incremental index
+ host.send_l2cap_pdu(
+ self.connection_handle, cid, bytes(self.packet_size)
+ )
+ print(
+ color(
+ f'>>> Sending packet {cid}: {self.packet_size} bytes', 'yellow'
+ )
+ )
+ bytes_sent += self.packet_size # don't count L2CAP or HCI header sizes
+ await asyncio.sleep(0) # yield to allow packet receive
+
+ await self.done.wait()
+ print(color('=== Done!', 'magenta'))
+
+ elapsed = time.time() - start_time
+ average_tx_speed = bytes_sent / elapsed
+ print(
+ color(
+ f'@@@ TX speed: average={average_tx_speed:.4f} ({bytes_sent} bytes'
+ f' in {elapsed:.2f} seconds)',
+ 'green',
+ )
+ )
+
+
+# -----------------------------------------------------------------------------
+@click.command()
+@click.option(
+ '--packet-size',
+ '-s',
+ metavar='SIZE',
+ type=click.IntRange(8, 4096),
+ default=500,
+ help='Packet size',
+)
+@click.option(
+ '--packet-count',
+ '-c',
+ metavar='COUNT',
+ type=click.IntRange(1, 65535),
+ default=10,
+ help='Packet count',
+)
+@click.argument('transport')
+def main(packet_size, packet_count, transport):
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
+
+ loopback = Loopback(packet_size, packet_count, transport)
+ asyncio.run(loopback.run())
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ main()
diff --git a/apps/l2cap_bridge.py b/apps/l2cap_bridge.py
index 14bd759..7d744bc 100644
--- a/apps/l2cap_bridge.py
+++ b/apps/l2cap_bridge.py
@@ -49,14 +49,16 @@ class ServerBridge:
self.tcp_port = tcp_port
async def start(self, device: Device) -> None:
- # Listen for incoming L2CAP CoC connections
+ # Listen for incoming L2CAP channel connections
device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(
psm=self.psm, mtu=self.mtu, mps=self.mps, max_credits=self.max_credits
),
- handler=self.on_coc,
+ handler=self.on_channel,
+ )
+ print(
+ color(f'### Listening for channel connection on PSM {self.psm}', 'yellow')
)
- print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow'))
def on_ble_connection(connection):
def on_ble_disconnection(reason):
@@ -73,7 +75,7 @@ class ServerBridge:
await device.start_advertising(auto_restart=True)
# Called when a new L2CAP connection is established
- def on_coc(self, l2cap_channel):
+ def on_channel(self, l2cap_channel):
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
class Pipe:
@@ -83,7 +85,7 @@ class ServerBridge:
self.l2cap_channel = l2cap_channel
l2cap_channel.on('close', self.on_l2cap_close)
- l2cap_channel.sink = self.on_coc_sdu
+ l2cap_channel.sink = self.on_channel_sdu
async def connect_to_tcp(self):
# Connect to the TCP server
@@ -128,7 +130,7 @@ class ServerBridge:
if self.tcp_transport is not None:
self.tcp_transport.close()
- def on_coc_sdu(self, sdu):
+ def on_channel_sdu(self, sdu):
print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
if self.tcp_transport is None:
print(color('!!! TCP socket not open, dropping', 'red'))
@@ -183,7 +185,7 @@ class ClientBridge:
peer_name = writer.get_extra_info('peer_name')
print(color(f'<<< TCP connection from {peer_name}', 'magenta'))
- def on_coc_sdu(sdu):
+ def on_channel_sdu(sdu):
print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
l2cap_to_tcp_pipe.write(sdu)
@@ -209,7 +211,7 @@ class ClientBridge:
writer.close()
return
- l2cap_channel.sink = on_coc_sdu
+ l2cap_channel.sink = on_channel_sdu
l2cap_channel.on('close', on_l2cap_close)
# Start a flow control pipe from L2CAP to TCP
@@ -274,23 +276,29 @@ async def run(device_config, hci_transport, bridge):
@click.pass_context
@click.option('--device-config', help='Device configuration file', required=True)
@click.option('--hci-transport', help='HCI transport', required=True)
-@click.option('--psm', help='PSM for L2CAP CoC', type=int, default=1234)
+@click.option('--psm', help='PSM for L2CAP', type=int, default=1234)
@click.option(
- '--l2cap-coc-max-credits',
- help='Maximum L2CAP CoC Credits',
+ '--l2cap-max-credits',
+ help='Maximum L2CAP Credits',
type=click.IntRange(1, 65535),
default=128,
)
@click.option(
- '--l2cap-coc-mtu',
- help='L2CAP CoC MTU',
- type=click.IntRange(23, 65535),
- default=1022,
+ '--l2cap-mtu',
+ help='L2CAP MTU',
+ type=click.IntRange(
+ l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU,
+ l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU,
+ ),
+ default=1024,
)
@click.option(
- '--l2cap-coc-mps',
- help='L2CAP CoC MPS',
- type=click.IntRange(23, 65533),
+ '--l2cap-mps',
+ help='L2CAP MPS',
+ type=click.IntRange(
+ l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS,
+ l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS,
+ ),
default=1024,
)
def cli(
@@ -298,17 +306,17 @@ def cli(
device_config,
hci_transport,
psm,
- l2cap_coc_max_credits,
- l2cap_coc_mtu,
- l2cap_coc_mps,
+ l2cap_max_credits,
+ l2cap_mtu,
+ l2cap_mps,
):
context.ensure_object(dict)
context.obj['device_config'] = device_config
context.obj['hci_transport'] = hci_transport
context.obj['psm'] = psm
- context.obj['max_credits'] = l2cap_coc_max_credits
- context.obj['mtu'] = l2cap_coc_mtu
- context.obj['mps'] = l2cap_coc_mps
+ context.obj['max_credits'] = l2cap_max_credits
+ context.obj['mtu'] = l2cap_mtu
+ context.obj['mps'] = l2cap_mps
# -----------------------------------------------------------------------------
diff --git a/apps/pair.py b/apps/pair.py
index 39ee4fe..c1ea332 100644
--- a/apps/pair.py
+++ b/apps/pair.py
@@ -24,10 +24,16 @@ from prompt_toolkit.shortcuts import PromptSession
from bumble.colors import color
from bumble.device import Device, Peer
from bumble.transport import open_transport_or_link
-from bumble.pairing import PairingDelegate, PairingConfig
+from bumble.pairing import OobData, PairingDelegate, PairingConfig
+from bumble.smp import OobContext, OobLegacyContext
from bumble.smp import error_name as smp_error_name
from bumble.keys import JsonKeyStore
-from bumble.core import ProtocolError
+from bumble.core import (
+ AdvertisingData,
+ ProtocolError,
+ BT_LE_TRANSPORT,
+ BT_BR_EDR_TRANSPORT,
+)
from bumble.gatt import (
GATT_DEVICE_NAME_CHARACTERISTIC,
GATT_GENERIC_ACCESS_SERVICE,
@@ -46,11 +52,13 @@ from bumble.att import (
class Waiter:
instance = None
- def __init__(self):
+ def __init__(self, linger=False):
self.done = asyncio.get_running_loop().create_future()
+ self.linger = linger
def terminate(self):
- self.done.set_result(None)
+ if not self.linger:
+ self.done.set_result(None)
async def wait_until_terminated(self):
return await self.done
@@ -60,7 +68,7 @@ class Waiter:
class Delegate(PairingDelegate):
def __init__(self, mode, connection, capability_string, do_prompt):
super().__init__(
- {
+ io_capability={
'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY,
'display': PairingDelegate.DISPLAY_OUTPUT_ONLY,
'display+keyboard': PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
@@ -285,7 +293,9 @@ async def pair(
mitm,
bond,
ctkd,
+ linger,
io,
+ oob,
prompt,
request,
print_keys,
@@ -294,7 +304,7 @@ async def pair(
hci_transport,
address_or_name,
):
- Waiter.instance = Waiter()
+ Waiter.instance = Waiter(linger=linger)
print('<<< connecting to HCI...')
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
@@ -343,16 +353,51 @@ async def pair(
await device.keystore.print(prefix=color('@@@ ', 'blue'))
print(color('@@@-----------------------------------', 'blue'))
+ # Create an OOB context if needed
+ if oob:
+ our_oob_context = OobContext()
+ shared_data = (
+ None
+ if oob == '-'
+ else OobData.from_ad(AdvertisingData.from_bytes(bytes.fromhex(oob)))
+ )
+ legacy_context = OobLegacyContext()
+ oob_contexts = PairingConfig.OobConfig(
+ our_context=our_oob_context,
+ peer_data=shared_data,
+ legacy_context=legacy_context,
+ )
+ oob_data = OobData(
+ address=device.random_address,
+ shared_data=shared_data,
+ legacy_context=legacy_context,
+ )
+ print(color('@@@-----------------------------------', 'yellow'))
+ print(color('@@@ OOB Data:', 'yellow'))
+ print(color(f'@@@ {our_oob_context.share()}', 'yellow'))
+ print(color(f'@@@ TK={legacy_context.tk.hex()}', 'yellow'))
+ print(color(f'@@@ HEX: ({bytes(oob_data.to_ad()).hex()})', 'yellow'))
+ print(color('@@@-----------------------------------', 'yellow'))
+ else:
+ oob_contexts = None
+
# Set up a pairing config factory
device.pairing_config_factory = lambda connection: PairingConfig(
- sc, mitm, bond, Delegate(mode, connection, io, prompt)
+ sc=sc,
+ mitm=mitm,
+ bonding=bond,
+ oob=oob_contexts,
+ delegate=Delegate(mode, connection, io, prompt),
)
# Connect to a peer or wait for a connection
device.on('connection', lambda connection: on_connection(connection, request))
if address_or_name is not None:
print(color(f'=== Connecting to {address_or_name}...', 'green'))
- connection = await device.connect(address_or_name)
+ connection = await device.connect(
+ address_or_name,
+ transport=BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT,
+ )
if not request:
try:
@@ -360,10 +405,9 @@ async def pair(
await connection.pair()
else:
await connection.authenticate()
- return
except ProtocolError as error:
print(color(f'Pairing failed: {error}', 'red'))
- return
+
else:
if mode == 'le':
# Advertise so that peers can find us and connect
@@ -413,6 +457,7 @@ class LogHandler(logging.Handler):
help='Enable CTKD',
show_default=True,
)
+@click.option('--linger', default=False, is_flag=True, help='Linger after pairing')
@click.option(
'--io',
type=click.Choice(
@@ -421,6 +466,14 @@ class LogHandler(logging.Handler):
default='display+keyboard',
show_default=True,
)
+@click.option(
+ '--oob',
+ metavar='<oob-data-hex>',
+ help=(
+ 'Use OOB pairing with this data from the peer '
+ '(use "-" to enable OOB without peer data)'
+ ),
+)
@click.option('--prompt', is_flag=True, help='Prompt to accept/reject pairing request')
@click.option(
'--request', is_flag=True, help='Request that the connecting peer initiate pairing'
@@ -440,7 +493,9 @@ def main(
mitm,
bond,
ctkd,
+ linger,
io,
+ oob,
prompt,
request,
print_keys,
@@ -463,7 +518,9 @@ def main(
mitm,
bond,
ctkd,
+ linger,
io,
+ oob,
prompt,
request,
print_keys,
diff --git a/apps/scan.py b/apps/scan.py
index 268912f..9780551 100644
--- a/apps/scan.py
+++ b/apps/scan.py
@@ -26,7 +26,7 @@ from bumble.transport import open_transport_or_link
from bumble.keys import JsonKeyStore
from bumble.smp import AddressResolver
from bumble.device import Advertisement
-from bumble.hci import HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
+from bumble.hci import Address, HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
# -----------------------------------------------------------------------------
@@ -66,10 +66,15 @@ class AdvertisementPrinter:
address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[
address.address_type
]
- if address.is_public:
- type_color = 'cyan'
+ if address.address_type in (
+ Address.RANDOM_IDENTITY_ADDRESS,
+ Address.PUBLIC_IDENTITY_ADDRESS,
+ ):
+ type_color = 'yellow'
else:
- if address.is_static:
+ if address.is_public:
+ type_color = 'cyan'
+ elif address.is_static:
type_color = 'green'
address_qualifier = '(static)'
elif address.is_resolvable:
@@ -116,6 +121,7 @@ async def scan(
phy,
filter_duplicates,
raw,
+ irks,
keystore_file,
device_config,
transport,
@@ -140,9 +146,21 @@ async def scan(
if device.keystore:
resolving_keys = await device.keystore.get_resolving_keys()
- resolver = AddressResolver(resolving_keys)
else:
- resolver = None
+ resolving_keys = []
+
+ for irk_and_address in irks:
+ if ':' not in irk_and_address:
+ raise ValueError('invalid IRK:ADDRESS value')
+ irk_hex, address_str = irk_and_address.split(':', 1)
+ resolving_keys.append(
+ (
+ bytes.fromhex(irk_hex),
+ Address(address_str, Address.RANDOM_DEVICE_ADDRESS),
+ )
+ )
+
+ resolver = AddressResolver(resolving_keys) if resolving_keys else None
printer = AdvertisementPrinter(min_rssi, resolver)
if raw:
@@ -187,8 +205,24 @@ async def scan(
default=False,
help='Listen for raw advertising reports instead of processed ones',
)
-@click.option('--keystore-file', help='Keystore file to use when resolving addresses')
-@click.option('--device-config', help='Device config file for the scanning device')
+@click.option(
+ '--irk',
+ metavar='<IRK_HEX>:<ADDRESS>',
+ help=(
+ 'Use this IRK for resolving private addresses ' '(may be used more than once)'
+ ),
+ multiple=True,
+)
+@click.option(
+ '--keystore-file',
+ metavar='FILE_PATH',
+ help='Keystore file to use when resolving addresses',
+)
+@click.option(
+ '--device-config',
+ metavar='FILE_PATH',
+ help='Device config file for the scanning device',
+)
@click.argument('transport')
def main(
min_rssi,
@@ -198,6 +232,7 @@ def main(
phy,
filter_duplicates,
raw,
+ irk,
keystore_file,
device_config,
transport,
@@ -212,6 +247,7 @@ def main(
phy,
filter_duplicates,
raw,
+ irk,
keystore_file,
device_config,
transport,
diff --git a/apps/show.py b/apps/show.py
index f849e3a..97640a3 100644
--- a/apps/show.py
+++ b/apps/show.py
@@ -15,7 +15,11 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+import datetime
+import logging
+import os
import struct
+
import click
from bumble.colors import color
@@ -25,6 +29,14 @@ from bumble.helpers import PacketTracer
# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
class SnoopPacketReader:
'''
Reader that reads HCI packets from a "snoop" file (based on RFC 1761, but not
@@ -36,12 +48,18 @@ class SnoopPacketReader:
DATALINK_BSCP = 1003
DATALINK_H5 = 1004
+ IDENTIFICATION_PATTERN = b'btsnoop\0'
+ TIMESTAMP_ANCHOR = datetime.datetime(2000, 1, 1)
+ TIMESTAMP_DELTA = 0x00E03AB44A676000
+ ONE_MICROSECOND = datetime.timedelta(microseconds=1)
+
def __init__(self, source):
self.source = source
+ self.at_end = False
# Read the header
identification_pattern = source.read(8)
- if identification_pattern.hex().lower() != '6274736e6f6f7000':
+ if identification_pattern != self.IDENTIFICATION_PATTERN:
raise ValueError(
'not a valid snoop file, unexpected identification pattern'
)
@@ -55,19 +73,32 @@ class SnoopPacketReader:
# Read the record header
header = self.source.read(24)
if len(header) < 24:
- return (0, None)
+ self.at_end = True
+ return (None, 0, None)
+
+ # Parse the header
(
original_length,
included_length,
packet_flags,
_cumulative_drops,
- _timestamp_seconds,
- _timestamp_microsecond,
- ) = struct.unpack('>IIIIII', header)
+ timestamp,
+ ) = struct.unpack('>IIIIQ', header)
- # Abort on truncated packets
+ # Skip truncated packets
if original_length != included_length:
- return (0, None)
+ print(
+ color(
+ f"!!! truncated packet ({included_length}/{original_length})", "red"
+ )
+ )
+ self.source.read(included_length)
+ return (None, 0, None)
+
+ # Convert the timestamp to a datetime object.
+ ts_dt = self.TIMESTAMP_ANCHOR + datetime.timedelta(
+ microseconds=timestamp - self.TIMESTAMP_DELTA
+ )
if self.data_link_type == self.DATALINK_H1:
# The packet is un-encapsulated, look at the flags to figure out its type
@@ -89,7 +120,17 @@ class SnoopPacketReader:
bytes([packet_type]) + self.source.read(included_length),
)
- return (packet_flags & 1, self.source.read(included_length))
+ return (ts_dt, packet_flags & 1, self.source.read(included_length))
+
+
+# -----------------------------------------------------------------------------
+class Printer:
+ def __init__(self):
+ self.index = 0
+
+ def print(self, message: str) -> None:
+ self.index += 1
+ print(f"[{self.index:8}]{message}")
# -----------------------------------------------------------------------------
@@ -122,24 +163,28 @@ def main(format, vendors, filename):
packet_reader = PacketReader(input)
def read_next_packet():
- return (0, packet_reader.next_packet())
+ return (None, 0, packet_reader.next_packet())
else:
packet_reader = SnoopPacketReader(input)
read_next_packet = packet_reader.next_packet
- tracer = PacketTracer(emit_message=print)
+ printer = Printer()
+ tracer = PacketTracer(emit_message=printer.print)
- while True:
+ while not packet_reader.at_end:
try:
- (direction, packet) = read_next_packet()
- if packet is None:
- break
- tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
+ (timestamp, direction, packet) = read_next_packet()
+ if packet:
+ tracer.trace(hci.HCI_Packet.from_bytes(packet), direction, timestamp)
+ else:
+ printer.print(color("[TRUNCATED]", "red"))
except Exception as error:
+ logger.exception()
print(color(f'!!! {error}', 'red'))
# -----------------------------------------------------------------------------
if __name__ == '__main__':
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
main() # pylint: disable=no-value-for-parameter
diff --git a/bumble/a2dp.py b/bumble/a2dp.py
index eeecb1e..653a042 100644
--- a/bumble/a2dp.py
+++ b/bumble/a2dp.py
@@ -15,9 +15,13 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
+
+import dataclasses
import struct
import logging
-from collections import namedtuple
+from collections.abc import AsyncGenerator
+from typing import List, Callable, Awaitable
from .company_ids import COMPANY_IDENTIFIERS
from .sdp import (
@@ -180,8 +184,12 @@ def make_audio_source_service_sdp_records(service_record_handle, version=(1, 3))
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
- DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
- DataElement.unsigned_integer_16(version_int),
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
+ DataElement.unsigned_integer_16(version_int),
+ ]
+ )
]
),
),
@@ -230,8 +238,12 @@ def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
- DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
- DataElement.unsigned_integer_16(version_int),
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
+ DataElement.unsigned_integer_16(version_int),
+ ]
+ )
]
),
),
@@ -239,24 +251,20 @@ def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
# -----------------------------------------------------------------------------
-class SbcMediaCodecInformation(
- namedtuple(
- 'SbcMediaCodecInformation',
- [
- 'sampling_frequency',
- 'channel_mode',
- 'block_length',
- 'subbands',
- 'allocation_method',
- 'minimum_bitpool_value',
- 'maximum_bitpool_value',
- ],
- )
-):
+@dataclasses.dataclass
+class SbcMediaCodecInformation:
'''
A2DP spec - 4.3.2 Codec Specific Information Elements
'''
+ sampling_frequency: int
+ channel_mode: int
+ block_length: int
+ subbands: int
+ allocation_method: int
+ minimum_bitpool_value: int
+ maximum_bitpool_value: int
+
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
CHANNEL_MODE_BITS = {
SBC_MONO_CHANNEL_MODE: 1 << 3,
@@ -272,7 +280,7 @@ class SbcMediaCodecInformation(
}
@staticmethod
- def from_bytes(data: bytes) -> 'SbcMediaCodecInformation':
+ def from_bytes(data: bytes) -> SbcMediaCodecInformation:
sampling_frequency = (data[0] >> 4) & 0x0F
channel_mode = (data[0] >> 0) & 0x0F
block_length = (data[1] >> 4) & 0x0F
@@ -293,14 +301,14 @@ class SbcMediaCodecInformation(
@classmethod
def from_discrete_values(
cls,
- sampling_frequency,
- channel_mode,
- block_length,
- subbands,
- allocation_method,
- minimum_bitpool_value,
- maximum_bitpool_value,
- ):
+ sampling_frequency: int,
+ channel_mode: int,
+ block_length: int,
+ subbands: int,
+ allocation_method: int,
+ minimum_bitpool_value: int,
+ maximum_bitpool_value: int,
+ ) -> SbcMediaCodecInformation:
return SbcMediaCodecInformation(
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
channel_mode=cls.CHANNEL_MODE_BITS[channel_mode],
@@ -314,14 +322,14 @@ class SbcMediaCodecInformation(
@classmethod
def from_lists(
cls,
- sampling_frequencies,
- channel_modes,
- block_lengths,
- subbands,
- allocation_methods,
- minimum_bitpool_value,
- maximum_bitpool_value,
- ):
+ sampling_frequencies: List[int],
+ channel_modes: List[int],
+ block_lengths: List[int],
+ subbands: List[int],
+ allocation_methods: List[int],
+ minimum_bitpool_value: int,
+ maximum_bitpool_value: int,
+ ) -> SbcMediaCodecInformation:
return SbcMediaCodecInformation(
sampling_frequency=sum(
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
@@ -348,7 +356,7 @@ class SbcMediaCodecInformation(
]
)
- def __str__(self):
+ def __str__(self) -> str:
channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
allocation_methods = ['SNR', 'Loudness']
return '\n'.join(
@@ -367,16 +375,19 @@ class SbcMediaCodecInformation(
# -----------------------------------------------------------------------------
-class AacMediaCodecInformation(
- namedtuple(
- 'AacMediaCodecInformation',
- ['object_type', 'sampling_frequency', 'channels', 'rfa', 'vbr', 'bitrate'],
- )
-):
+@dataclasses.dataclass
+class AacMediaCodecInformation:
'''
A2DP spec - 4.5.2 Codec Specific Information Elements
'''
+ object_type: int
+ sampling_frequency: int
+ channels: int
+ rfa: int
+ vbr: int
+ bitrate: int
+
OBJECT_TYPE_BITS = {
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
@@ -400,7 +411,7 @@ class AacMediaCodecInformation(
CHANNELS_BITS = {1: 1 << 1, 2: 1}
@staticmethod
- def from_bytes(data: bytes) -> 'AacMediaCodecInformation':
+ def from_bytes(data: bytes) -> AacMediaCodecInformation:
object_type = data[0]
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
channels = (data[2] >> 2) & 0x03
@@ -413,8 +424,13 @@ class AacMediaCodecInformation(
@classmethod
def from_discrete_values(
- cls, object_type, sampling_frequency, channels, vbr, bitrate
- ):
+ cls,
+ object_type: int,
+ sampling_frequency: int,
+ channels: int,
+ vbr: int,
+ bitrate: int,
+ ) -> AacMediaCodecInformation:
return AacMediaCodecInformation(
object_type=cls.OBJECT_TYPE_BITS[object_type],
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
@@ -425,7 +441,14 @@ class AacMediaCodecInformation(
)
@classmethod
- def from_lists(cls, object_types, sampling_frequencies, channels, vbr, bitrate):
+ def from_lists(
+ cls,
+ object_types: List[int],
+ sampling_frequencies: List[int],
+ channels: List[int],
+ vbr: int,
+ bitrate: int,
+ ) -> AacMediaCodecInformation:
return AacMediaCodecInformation(
object_type=sum(cls.OBJECT_TYPE_BITS[x] for x in object_types),
sampling_frequency=sum(
@@ -449,7 +472,7 @@ class AacMediaCodecInformation(
]
)
- def __str__(self):
+ def __str__(self) -> str:
object_types = [
'MPEG_2_AAC_LC',
'MPEG_4_AAC_LC',
@@ -474,26 +497,26 @@ class AacMediaCodecInformation(
)
+@dataclasses.dataclass
# -----------------------------------------------------------------------------
class VendorSpecificMediaCodecInformation:
'''
A2DP spec - 4.7.2 Codec Specific Information Elements
'''
+ vendor_id: int
+ codec_id: int
+ value: bytes
+
@staticmethod
- def from_bytes(data):
+ def from_bytes(data: bytes) -> VendorSpecificMediaCodecInformation:
(vendor_id, codec_id) = struct.unpack_from('<IH', data, 0)
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
- def __init__(self, vendor_id, codec_id, value):
- self.vendor_id = vendor_id
- self.codec_id = codec_id
- self.value = value
-
- def __bytes__(self):
+ def __bytes__(self) -> bytes:
return struct.pack('<IH', self.vendor_id, self.codec_id, self.value)
- def __str__(self):
+ def __str__(self) -> str:
# pylint: disable=line-too-long
return '\n'.join(
[
@@ -506,29 +529,27 @@ class VendorSpecificMediaCodecInformation:
# -----------------------------------------------------------------------------
+@dataclasses.dataclass
class SbcFrame:
- def __init__(
- self, sampling_frequency, block_count, channel_mode, subband_count, payload
- ):
- self.sampling_frequency = sampling_frequency
- self.block_count = block_count
- self.channel_mode = channel_mode
- self.subband_count = subband_count
- self.payload = payload
+ sampling_frequency: int
+ block_count: int
+ channel_mode: int
+ subband_count: int
+ payload: bytes
@property
- def sample_count(self):
+ def sample_count(self) -> int:
return self.subband_count * self.block_count
@property
- def bitrate(self):
+ def bitrate(self) -> int:
return 8 * ((len(self.payload) * self.sampling_frequency) // self.sample_count)
@property
- def duration(self):
+ def duration(self) -> float:
return self.sample_count / self.sampling_frequency
- def __str__(self):
+ def __str__(self) -> str:
return (
f'SBC(sf={self.sampling_frequency},'
f'cm={self.channel_mode},'
@@ -540,12 +561,12 @@ class SbcFrame:
# -----------------------------------------------------------------------------
class SbcParser:
- def __init__(self, read):
+ def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None:
self.read = read
@property
- def frames(self):
- async def generate_frames():
+ def frames(self) -> AsyncGenerator[SbcFrame, None]:
+ async def generate_frames() -> AsyncGenerator[SbcFrame, None]:
while True:
# Read 4 bytes of header
header = await self.read(4)
@@ -589,7 +610,9 @@ class SbcParser:
# -----------------------------------------------------------------------------
class SbcPacketSource:
- def __init__(self, read, mtu, codec_capabilities):
+ def __init__(
+ self, read: Callable[[int], Awaitable[bytes]], mtu: int, codec_capabilities
+ ) -> None:
self.read = read
self.mtu = mtu
self.codec_capabilities = codec_capabilities
diff --git a/bumble/att.py b/bumble/att.py
index db8d2ba..2bec4ea 100644
--- a/bumble/att.py
+++ b/bumble/att.py
@@ -25,9 +25,21 @@
from __future__ import annotations
import enum
import functools
+import inspect
import struct
+from typing import (
+ Any,
+ Awaitable,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ Type,
+ Union,
+ TYPE_CHECKING,
+)
+
from pyee import EventEmitter
-from typing import Dict, Type, List, Protocol, Union, Optional, Any, TYPE_CHECKING
from bumble.core import UUID, name_or_number, ProtocolError
from bumble.hci import HCI_Object, key_with_value
@@ -722,12 +734,38 @@ class ATT_Handle_Value_Confirmation(ATT_PDU):
# -----------------------------------------------------------------------------
-class ConnectionValue(Protocol):
- def read(self, connection) -> bytes:
- ...
+class AttributeValue:
+ '''
+ Attribute value where reading and/or writing is delegated to functions
+ passed as arguments to the constructor.
+ '''
+
+ def __init__(
+ self,
+ read: Union[
+ Callable[[Optional[Connection]], bytes],
+ Callable[[Optional[Connection]], Awaitable[bytes]],
+ None,
+ ] = None,
+ write: Union[
+ Callable[[Optional[Connection], bytes], None],
+ Callable[[Optional[Connection], bytes], 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 write(
+ self, connection: Optional[Connection], value: bytes
+ ) -> Union[Awaitable[None], None]:
+ if self._write:
+ return self._write(connection, value)
- def write(self, connection, value: bytes) -> None:
- ...
+ return None
# -----------------------------------------------------------------------------
@@ -770,13 +808,13 @@ class Attribute(EventEmitter):
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
- value: Union[str, bytes, ConnectionValue]
+ value: Union[bytes, AttributeValue]
def __init__(
self,
attribute_type: Union[str, bytes, UUID],
permissions: Union[str, Attribute.Permissions],
- value: Union[str, bytes, ConnectionValue] = b'',
+ value: Union[str, bytes, AttributeValue] = b'',
) -> None:
EventEmitter.__init__(self)
self.handle = 0
@@ -806,7 +844,7 @@ class Attribute(EventEmitter):
def decode_value(self, value_bytes: bytes) -> Any:
return value_bytes
- def read_value(self, connection: Optional[Connection]) -> bytes:
+ async def read_value(self, connection: Optional[Connection]) -> bytes:
if (
(self.permissions & self.READ_REQUIRES_ENCRYPTION)
and connection is not None
@@ -832,6 +870,8 @@ class Attribute(EventEmitter):
if hasattr(self.value, 'read'):
try:
value = self.value.read(connection)
+ if inspect.isawaitable(value):
+ value = await value
except ATT_Error as error:
raise ATT_Error(
error_code=error.error_code, att_handle=self.handle
@@ -841,7 +881,7 @@ class Attribute(EventEmitter):
return self.encode_value(value)
- def write_value(self, connection: Connection, value_bytes: bytes) -> None:
+ async def write_value(self, connection: Connection, value_bytes: bytes) -> None:
if (
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
) and not connection.encryption:
@@ -864,7 +904,9 @@ class Attribute(EventEmitter):
if hasattr(self.value, 'write'):
try:
- self.value.write(connection, value) # pylint: disable=not-callable
+ result = self.value.write(connection, value)
+ if inspect.isawaitable(result):
+ await result
except ATT_Error as error:
raise ATT_Error(
error_code=error.error_code, att_handle=self.handle
diff --git a/bumble/avc.py b/bumble/avc.py
new file mode 100644
index 0000000..1d0a7dc
--- /dev/null
+++ b/bumble/avc.py
@@ -0,0 +1,520 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import enum
+import struct
+from typing import Dict, Type, Union, Tuple
+
+from bumble.utils import OpenIntEnum
+
+
+# -----------------------------------------------------------------------------
+class Frame:
+ class SubunitType(enum.IntEnum):
+ # AV/C Digital Interface Command Set General Specification Version 4.1
+ # Table 7.4
+ MONITOR = 0x00
+ AUDIO = 0x01
+ PRINTER = 0x02
+ DISC = 0x03
+ TAPE_RECORDER_OR_PLAYER = 0x04
+ TUNER = 0x05
+ CA = 0x06
+ CAMERA = 0x07
+ PANEL = 0x09
+ BULLETIN_BOARD = 0x0A
+ VENDOR_UNIQUE = 0x1C
+ EXTENDED = 0x1E
+ UNIT = 0x1F
+
+ class OperationCode(OpenIntEnum):
+ # 0x00 - 0x0F: Unit and subunit commands
+ VENDOR_DEPENDENT = 0x00
+ RESERVE = 0x01
+ PLUG_INFO = 0x02
+
+ # 0x10 - 0x3F: Unit commands
+ DIGITAL_OUTPUT = 0x10
+ DIGITAL_INPUT = 0x11
+ CHANNEL_USAGE = 0x12
+ OUTPUT_PLUG_SIGNAL_FORMAT = 0x18
+ INPUT_PLUG_SIGNAL_FORMAT = 0x19
+ GENERAL_BUS_SETUP = 0x1F
+ CONNECT_AV = 0x20
+ DISCONNECT_AV = 0x21
+ CONNECTIONS = 0x22
+ CONNECT = 0x24
+ DISCONNECT = 0x25
+ UNIT_INFO = 0x30
+ SUBUNIT_INFO = 0x31
+
+ # 0x40 - 0x7F: Subunit commands
+ PASS_THROUGH = 0x7C
+ GUI_UPDATE = 0x7D
+ PUSH_GUI_DATA = 0x7E
+ USER_ACTION = 0x7F
+
+ # 0xA0 - 0xBF: Unit and subunit commands
+ VERSION = 0xB0
+ POWER = 0xB2
+
+ subunit_type: SubunitType
+ subunit_id: int
+ opcode: OperationCode
+ operands: bytes
+
+ @staticmethod
+ def subclass(subclass):
+ # Infer the opcode from the class name
+ if subclass.__name__.endswith("CommandFrame"):
+ short_name = subclass.__name__.replace("CommandFrame", "")
+ category_class = CommandFrame
+ elif subclass.__name__.endswith("ResponseFrame"):
+ short_name = subclass.__name__.replace("ResponseFrame", "")
+ category_class = ResponseFrame
+ else:
+ raise ValueError(f"invalid subclass name {subclass.__name__}")
+
+ uppercase_indexes = [
+ i for i in range(len(short_name)) if short_name[i].isupper()
+ ]
+ uppercase_indexes.append(len(short_name))
+ words = [
+ short_name[uppercase_indexes[i] : uppercase_indexes[i + 1]].upper()
+ for i in range(len(uppercase_indexes) - 1)
+ ]
+ opcode_name = "_".join(words)
+ opcode = Frame.OperationCode[opcode_name]
+ category_class.subclasses[opcode] = subclass
+ return subclass
+
+ @staticmethod
+ def from_bytes(data: bytes) -> Frame:
+ if data[0] >> 4 != 0:
+ raise ValueError("first 4 bits must be 0s")
+
+ ctype_or_response = data[0] & 0xF
+ subunit_type = Frame.SubunitType(data[1] >> 3)
+ subunit_id = data[1] & 7
+
+ if subunit_type == Frame.SubunitType.EXTENDED:
+ # Not supported
+ raise NotImplementedError("extended subunit types not supported")
+
+ if subunit_id < 5:
+ opcode_offset = 2
+ elif subunit_id == 5:
+ # Extended to the next byte
+ extension = data[2]
+ if extension == 0:
+ raise ValueError("extended subunit ID value reserved")
+ if extension == 0xFF:
+ subunit_id = 5 + 254 + data[3]
+ opcode_offset = 4
+ else:
+ subunit_id = 5 + extension
+ opcode_offset = 3
+
+ elif subunit_id == 6:
+ raise ValueError("reserved subunit ID")
+
+ opcode = Frame.OperationCode(data[opcode_offset])
+ operands = data[opcode_offset + 1 :]
+
+ # Look for a registered subclass
+ if ctype_or_response < 8:
+ # Command
+ ctype = CommandFrame.CommandType(ctype_or_response)
+ if c_subclass := CommandFrame.subclasses.get(opcode):
+ return c_subclass(
+ ctype,
+ subunit_type,
+ subunit_id,
+ *c_subclass.parse_operands(operands),
+ )
+ return CommandFrame(ctype, subunit_type, subunit_id, opcode, operands)
+ else:
+ # Response
+ response = ResponseFrame.ResponseCode(ctype_or_response)
+ if r_subclass := ResponseFrame.subclasses.get(opcode):
+ return r_subclass(
+ response,
+ subunit_type,
+ subunit_id,
+ *r_subclass.parse_operands(operands),
+ )
+ return ResponseFrame(response, subunit_type, subunit_id, opcode, operands)
+
+ def to_bytes(
+ self,
+ ctype_or_response: Union[CommandFrame.CommandType, ResponseFrame.ResponseCode],
+ ) -> bytes:
+ # TODO: support extended subunit types and ids.
+ return (
+ bytes(
+ [
+ ctype_or_response,
+ self.subunit_type << 3 | self.subunit_id,
+ self.opcode,
+ ]
+ )
+ + self.operands
+ )
+
+ def to_string(self, extra: str) -> str:
+ return (
+ f"{self.__class__.__name__}({extra}"
+ f"subunit_type={self.subunit_type.name}, "
+ f"subunit_id=0x{self.subunit_id:02X}, "
+ f"opcode={self.opcode.name}, "
+ f"operands={self.operands.hex()})"
+ )
+
+ def __init__(
+ self,
+ subunit_type: SubunitType,
+ subunit_id: int,
+ opcode: OperationCode,
+ operands: bytes,
+ ) -> None:
+ self.subunit_type = subunit_type
+ self.subunit_id = subunit_id
+ self.opcode = opcode
+ self.operands = operands
+
+
+# -----------------------------------------------------------------------------
+class CommandFrame(Frame):
+ class CommandType(OpenIntEnum):
+ # AV/C Digital Interface Command Set General Specification Version 4.1
+ # Table 7.1
+ CONTROL = 0x00
+ STATUS = 0x01
+ SPECIFIC_INQUIRY = 0x02
+ NOTIFY = 0x03
+ GENERAL_INQUIRY = 0x04
+
+ subclasses: Dict[Frame.OperationCode, Type[CommandFrame]] = {}
+ ctype: CommandType
+
+ @staticmethod
+ def parse_operands(operands: bytes) -> Tuple:
+ raise NotImplementedError
+
+ def __init__(
+ self,
+ ctype: CommandType,
+ subunit_type: Frame.SubunitType,
+ subunit_id: int,
+ opcode: Frame.OperationCode,
+ operands: bytes,
+ ) -> None:
+ super().__init__(subunit_type, subunit_id, opcode, operands)
+ self.ctype = ctype
+
+ def __bytes__(self):
+ return self.to_bytes(self.ctype)
+
+ def __str__(self):
+ return self.to_string(f"ctype={self.ctype.name}, ")
+
+
+# -----------------------------------------------------------------------------
+class ResponseFrame(Frame):
+ class ResponseCode(OpenIntEnum):
+ # AV/C Digital Interface Command Set General Specification Version 4.1
+ # Table 7.2
+ NOT_IMPLEMENTED = 0x08
+ ACCEPTED = 0x09
+ REJECTED = 0x0A
+ IN_TRANSITION = 0x0B
+ IMPLEMENTED_OR_STABLE = 0x0C
+ CHANGED = 0x0D
+ INTERIM = 0x0F
+
+ subclasses: Dict[Frame.OperationCode, Type[ResponseFrame]] = {}
+ response: ResponseCode
+
+ @staticmethod
+ def parse_operands(operands: bytes) -> Tuple:
+ raise NotImplementedError
+
+ def __init__(
+ self,
+ response: ResponseCode,
+ subunit_type: Frame.SubunitType,
+ subunit_id: int,
+ opcode: Frame.OperationCode,
+ operands: bytes,
+ ) -> None:
+ super().__init__(subunit_type, subunit_id, opcode, operands)
+ self.response = response
+
+ def __bytes__(self):
+ return self.to_bytes(self.response)
+
+ def __str__(self):
+ return self.to_string(f"response={self.response.name}, ")
+
+
+# -----------------------------------------------------------------------------
+class VendorDependentFrame:
+ company_id: int
+ vendor_dependent_data: bytes
+
+ @staticmethod
+ def parse_operands(operands: bytes) -> Tuple:
+ return (
+ struct.unpack(">I", b"\x00" + operands[:3])[0],
+ operands[3:],
+ )
+
+ def make_operands(self) -> bytes:
+ return struct.pack(">I", self.company_id)[1:] + self.vendor_dependent_data
+
+ def __init__(self, company_id: int, vendor_dependent_data: bytes):
+ self.company_id = company_id
+ self.vendor_dependent_data = vendor_dependent_data
+
+
+# -----------------------------------------------------------------------------
+@Frame.subclass
+class VendorDependentCommandFrame(VendorDependentFrame, CommandFrame):
+ def __init__(
+ self,
+ ctype: CommandFrame.CommandType,
+ subunit_type: Frame.SubunitType,
+ subunit_id: int,
+ company_id: int,
+ vendor_dependent_data: bytes,
+ ) -> None:
+ VendorDependentFrame.__init__(self, company_id, vendor_dependent_data)
+ CommandFrame.__init__(
+ self,
+ ctype,
+ subunit_type,
+ subunit_id,
+ Frame.OperationCode.VENDOR_DEPENDENT,
+ self.make_operands(),
+ )
+
+ def __str__(self):
+ return (
+ f"VendorDependentCommandFrame(ctype={self.ctype.name}, "
+ f"subunit_type={self.subunit_type.name}, "
+ f"subunit_id=0x{self.subunit_id:02X}, "
+ f"company_id=0x{self.company_id:06X}, "
+ f"vendor_dependent_data={self.vendor_dependent_data.hex()})"
+ )
+
+
+# -----------------------------------------------------------------------------
+@Frame.subclass
+class VendorDependentResponseFrame(VendorDependentFrame, ResponseFrame):
+ def __init__(
+ self,
+ response: ResponseFrame.ResponseCode,
+ subunit_type: Frame.SubunitType,
+ subunit_id: int,
+ company_id: int,
+ vendor_dependent_data: bytes,
+ ) -> None:
+ VendorDependentFrame.__init__(self, company_id, vendor_dependent_data)
+ ResponseFrame.__init__(
+ self,
+ response,
+ subunit_type,
+ subunit_id,
+ Frame.OperationCode.VENDOR_DEPENDENT,
+ self.make_operands(),
+ )
+
+ def __str__(self):
+ return (
+ f"VendorDependentResponseFrame(response={self.response.name}, "
+ f"subunit_type={self.subunit_type.name}, "
+ f"subunit_id=0x{self.subunit_id:02X}, "
+ f"company_id=0x{self.company_id:06X}, "
+ f"vendor_dependent_data={self.vendor_dependent_data.hex()})"
+ )
+
+
+# -----------------------------------------------------------------------------
+class PassThroughFrame:
+ """
+ See AV/C Panel Subunit Specification 1.1 - 9.4 PASS THROUGH control command
+ """
+
+ class StateFlag(enum.IntEnum):
+ PRESSED = 0
+ RELEASED = 1
+
+ class OperationId(OpenIntEnum):
+ SELECT = 0x00
+ UP = 0x01
+ DOWN = 0x01
+ LEFT = 0x03
+ RIGHT = 0x04
+ RIGHT_UP = 0x05
+ RIGHT_DOWN = 0x06
+ LEFT_UP = 0x07
+ LEFT_DOWN = 0x08
+ ROOT_MENU = 0x09
+ SETUP_MENU = 0x0A
+ CONTENTS_MENU = 0x0B
+ FAVORITE_MENU = 0x0C
+ EXIT = 0x0D
+ NUMBER_0 = 0x20
+ NUMBER_1 = 0x21
+ NUMBER_2 = 0x22
+ NUMBER_3 = 0x23
+ NUMBER_4 = 0x24
+ NUMBER_5 = 0x25
+ NUMBER_6 = 0x26
+ NUMBER_7 = 0x27
+ NUMBER_8 = 0x28
+ NUMBER_9 = 0x29
+ DOT = 0x2A
+ ENTER = 0x2B
+ CLEAR = 0x2C
+ CHANNEL_UP = 0x30
+ CHANNEL_DOWN = 0x31
+ PREVIOUS_CHANNEL = 0x32
+ SOUND_SELECT = 0x33
+ INPUT_SELECT = 0x34
+ DISPLAY_INFORMATION = 0x35
+ HELP = 0x36
+ PAGE_UP = 0x37
+ PAGE_DOWN = 0x38
+ POWER = 0x40
+ VOLUME_UP = 0x41
+ VOLUME_DOWN = 0x42
+ MUTE = 0x43
+ PLAY = 0x44
+ STOP = 0x45
+ PAUSE = 0x46
+ RECORD = 0x47
+ REWIND = 0x48
+ FAST_FORWARD = 0x49
+ EJECT = 0x4A
+ FORWARD = 0x4B
+ BACKWARD = 0x4C
+ ANGLE = 0x50
+ SUBPICTURE = 0x51
+ F1 = 0x71
+ F2 = 0x72
+ F3 = 0x73
+ F4 = 0x74
+ F5 = 0x75
+ VENDOR_UNIQUE = 0x7E
+
+ state_flag: StateFlag
+ operation_id: OperationId
+ operation_data: bytes
+
+ @staticmethod
+ def parse_operands(operands: bytes) -> Tuple:
+ return (
+ PassThroughFrame.StateFlag(operands[0] >> 7),
+ PassThroughFrame.OperationId(operands[0] & 0x7F),
+ operands[1 : 1 + operands[1]],
+ )
+
+ def make_operands(self):
+ return (
+ bytes([self.state_flag << 7 | self.operation_id, len(self.operation_data)])
+ + self.operation_data
+ )
+
+ def __init__(
+ self,
+ state_flag: StateFlag,
+ operation_id: OperationId,
+ operation_data: bytes,
+ ) -> None:
+ if len(operation_data) > 255:
+ raise ValueError("operation data must be <= 255 bytes")
+ self.state_flag = state_flag
+ self.operation_id = operation_id
+ self.operation_data = operation_data
+
+
+# -----------------------------------------------------------------------------
+@Frame.subclass
+class PassThroughCommandFrame(PassThroughFrame, CommandFrame):
+ def __init__(
+ self,
+ ctype: CommandFrame.CommandType,
+ subunit_type: Frame.SubunitType,
+ subunit_id: int,
+ state_flag: PassThroughFrame.StateFlag,
+ operation_id: PassThroughFrame.OperationId,
+ operation_data: bytes,
+ ) -> None:
+ PassThroughFrame.__init__(self, state_flag, operation_id, operation_data)
+ CommandFrame.__init__(
+ self,
+ ctype,
+ subunit_type,
+ subunit_id,
+ Frame.OperationCode.PASS_THROUGH,
+ self.make_operands(),
+ )
+
+ def __str__(self):
+ return (
+ f"PassThroughCommandFrame(ctype={self.ctype.name}, "
+ f"subunit_type={self.subunit_type.name}, "
+ f"subunit_id=0x{self.subunit_id:02X}, "
+ f"state_flag={self.state_flag.name}, "
+ f"operation_id={self.operation_id.name}, "
+ f"operation_data={self.operation_data.hex()})"
+ )
+
+
+# -----------------------------------------------------------------------------
+@Frame.subclass
+class PassThroughResponseFrame(PassThroughFrame, ResponseFrame):
+ def __init__(
+ self,
+ response: ResponseFrame.ResponseCode,
+ subunit_type: Frame.SubunitType,
+ subunit_id: int,
+ state_flag: PassThroughFrame.StateFlag,
+ operation_id: PassThroughFrame.OperationId,
+ operation_data: bytes,
+ ) -> None:
+ PassThroughFrame.__init__(self, state_flag, operation_id, operation_data)
+ ResponseFrame.__init__(
+ self,
+ response,
+ subunit_type,
+ subunit_id,
+ Frame.OperationCode.PASS_THROUGH,
+ self.make_operands(),
+ )
+
+ def __str__(self):
+ return (
+ f"PassThroughResponseFrame(response={self.response.name}, "
+ f"subunit_type={self.subunit_type.name}, "
+ f"subunit_id=0x{self.subunit_id:02X}, "
+ f"state_flag={self.state_flag.name}, "
+ f"operation_id={self.operation_id.name}, "
+ f"operation_data={self.operation_data.hex()})"
+ )
diff --git a/bumble/avctp.py b/bumble/avctp.py
new file mode 100644
index 0000000..2271324
--- /dev/null
+++ b/bumble/avctp.py
@@ -0,0 +1,291 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+from enum import IntEnum
+import logging
+import struct
+from typing import Callable, cast, Dict, Optional
+
+from bumble.colors import color
+from bumble import avc
+from bumble import l2cap
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+AVCTP_PSM = 0x0017
+AVCTP_BROWSING_PSM = 0x001B
+
+
+# -----------------------------------------------------------------------------
+class MessageAssembler:
+ Callback = Callable[[int, bool, bool, int, bytes], None]
+
+ transaction_label: int
+ pid: int
+ c_r: int
+ ipid: int
+ payload: bytes
+ number_of_packets: int
+ packets_received: int
+
+ def __init__(self, callback: Callback) -> None:
+ self.callback = callback
+ self.reset()
+
+ def reset(self) -> None:
+ self.packets_received = 0
+ self.transaction_label = -1
+ self.pid = -1
+ self.c_r = -1
+ self.ipid = -1
+ self.payload = b''
+ self.number_of_packets = 0
+ self.packet_count = 0
+
+ def on_pdu(self, pdu: bytes) -> None:
+ self.packets_received += 1
+
+ transaction_label = pdu[0] >> 4
+ packet_type = Protocol.PacketType((pdu[0] >> 2) & 3)
+ c_r = (pdu[0] >> 1) & 1
+ ipid = pdu[0] & 1
+
+ if c_r == 0 and ipid != 0:
+ logger.warning("invalid IPID in command frame")
+ self.reset()
+ return
+
+ pid_offset = 1
+ if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.START):
+ if self.transaction_label >= 0:
+ # We are already in a transaction
+ logger.warning("received START or SINGLE fragment while in transaction")
+ self.reset()
+ self.packets_received = 1
+
+ if packet_type == Protocol.PacketType.START:
+ self.number_of_packets = pdu[1]
+ pid_offset = 2
+
+ pid = struct.unpack_from(">H", pdu, pid_offset)[0]
+ self.payload += pdu[pid_offset + 2 :]
+
+ if packet_type in (Protocol.PacketType.CONTINUE, Protocol.PacketType.END):
+ if transaction_label != self.transaction_label:
+ logger.warning("transaction label does not match")
+ self.reset()
+ return
+
+ if pid != self.pid:
+ logger.warning("PID does not match")
+ self.reset()
+ return
+
+ if c_r != self.c_r:
+ logger.warning("C/R does not match")
+ self.reset()
+ return
+
+ if self.packets_received > self.number_of_packets:
+ logger.warning("too many fragments in transaction")
+ self.reset()
+ return
+
+ if packet_type == Protocol.PacketType.END:
+ if self.packets_received != self.number_of_packets:
+ logger.warning("premature END")
+ self.reset()
+ return
+ else:
+ self.transaction_label = transaction_label
+ self.c_r = c_r
+ self.ipid = ipid
+ self.pid = pid
+
+ if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.END):
+ self.on_message_complete()
+
+ def on_message_complete(self):
+ try:
+ self.callback(
+ self.transaction_label,
+ self.c_r == 0,
+ self.ipid != 0,
+ self.pid,
+ self.payload,
+ )
+ except Exception as error:
+ logger.exception(color(f"!!! exception in callback: {error}", "red"))
+
+ self.reset()
+
+
+# -----------------------------------------------------------------------------
+class Protocol:
+ CommandHandler = Callable[[int, avc.CommandFrame], None]
+ command_handlers: Dict[int, CommandHandler] # Command handlers, by PID
+ ResponseHandler = Callable[[int, Optional[avc.ResponseFrame]], None]
+ response_handlers: Dict[int, ResponseHandler] # Response handlers, by PID
+ next_transaction_label: int
+ message_assembler: MessageAssembler
+
+ class PacketType(IntEnum):
+ SINGLE = 0b00
+ START = 0b01
+ CONTINUE = 0b10
+ END = 0b11
+
+ def __init__(self, l2cap_channel: l2cap.ClassicChannel) -> None:
+ self.command_handlers = {}
+ self.response_handlers = {}
+ self.l2cap_channel = l2cap_channel
+ self.message_assembler = MessageAssembler(self.on_message)
+
+ # 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)
+
+ def on_l2cap_channel_open(self):
+ logger.debug(color("<<< AVCTP channel open", "magenta"))
+
+ def on_l2cap_channel_close(self):
+ logger.debug(color("<<< AVCTP channel closed", "magenta"))
+
+ def on_pdu(self, pdu: bytes) -> None:
+ self.message_assembler.on_pdu(pdu)
+
+ def on_message(
+ self,
+ transaction_label: int,
+ is_command: bool,
+ ipid: bool,
+ pid: int,
+ payload: bytes,
+ ) -> None:
+ logger.debug(
+ f"<<< AVCTP Message: pid={pid}, "
+ f"transaction_label={transaction_label}, "
+ f"is_command={is_command}, "
+ f"ipid={ipid}, "
+ f"payload={payload.hex()}"
+ )
+
+ # Check for invalid PID responses.
+ if ipid:
+ logger.debug(f"received IPID for PID={pid}")
+
+ # Find the appropriate handler.
+ if is_command:
+ if pid not in self.command_handlers:
+ logger.warning(f"no command handler for PID {pid}")
+ self.send_ipid(transaction_label, pid)
+ return
+
+ command_frame = cast(avc.CommandFrame, avc.Frame.from_bytes(payload))
+ self.command_handlers[pid](transaction_label, command_frame)
+ else:
+ if pid not in self.response_handlers:
+ logger.warning(f"no response handler for PID {pid}")
+ return
+
+ # By convention, for an ipid, send a None payload to the response handler.
+ if ipid:
+ response_frame = None
+ else:
+ response_frame = cast(avc.ResponseFrame, avc.Frame.from_bytes(payload))
+
+ self.response_handlers[pid](transaction_label, response_frame)
+
+ def send_message(
+ self,
+ transaction_label: int,
+ is_command: bool,
+ ipid: bool,
+ pid: int,
+ payload: bytes,
+ ):
+ # TODO: fragment large messages
+ packet_type = Protocol.PacketType.SINGLE
+ pdu = (
+ struct.pack(
+ ">BH",
+ transaction_label << 4
+ | packet_type << 2
+ | (0 if is_command else 1) << 1
+ | (1 if ipid else 0),
+ pid,
+ )
+ + payload
+ )
+ self.l2cap_channel.send_pdu(pdu)
+
+ def send_command(self, transaction_label: int, pid: int, payload: bytes) -> None:
+ logger.debug(
+ ">>> AVCTP command: "
+ f"transaction_label={transaction_label}, "
+ f"pid={pid}, "
+ f"payload={payload.hex()}"
+ )
+ self.send_message(transaction_label, True, False, pid, payload)
+
+ def send_response(self, transaction_label: int, pid: int, payload: bytes):
+ logger.debug(
+ ">>> AVCTP response: "
+ f"transaction_label={transaction_label}, "
+ f"pid={pid}, "
+ f"payload={payload.hex()}"
+ )
+ self.send_message(transaction_label, False, False, pid, payload)
+
+ def send_ipid(self, transaction_label: int, pid: int) -> None:
+ logger.debug(
+ ">>> AVCTP ipid: " f"transaction_label={transaction_label}, " f"pid={pid}"
+ )
+ self.send_message(transaction_label, False, True, pid, b'')
+
+ def register_command_handler(
+ self, pid: int, handler: Protocol.CommandHandler
+ ) -> None:
+ self.command_handlers[pid] = handler
+
+ def unregister_command_handler(
+ self, pid: int, handler: Protocol.CommandHandler
+ ) -> None:
+ if pid not in self.command_handlers or self.command_handlers[pid] != handler:
+ raise ValueError("command handler not registered")
+ del self.command_handlers[pid]
+
+ def register_response_handler(
+ self, pid: int, handler: Protocol.ResponseHandler
+ ) -> None:
+ self.response_handlers[pid] = handler
+
+ def unregister_response_handler(
+ self, pid: int, handler: Protocol.ResponseHandler
+ ) -> None:
+ if pid not in self.response_handlers or self.response_handlers[pid] != handler:
+ raise ValueError("response handler not registered")
+ del self.response_handlers[pid]
diff --git a/bumble/avdtp.py b/bumble/avdtp.py
index 9a332f4..f785109 100644
--- a/bumble/avdtp.py
+++ b/bumble/avdtp.py
@@ -241,7 +241,10 @@ async def find_avdtp_service_with_sdp_client(
)
if profile_descriptor_list:
for profile_descriptor in profile_descriptor_list.value:
- if len(profile_descriptor.value) >= 2:
+ if (
+ profile_descriptor.type == sdp.DataElement.SEQUENCE
+ and len(profile_descriptor.value) >= 2
+ ):
avdtp_version_major = profile_descriptor.value[1].value >> 8
avdtp_version_minor = profile_descriptor.value[1].value & 0xFF
return (avdtp_version_major, avdtp_version_minor)
@@ -250,15 +253,15 @@ async def find_avdtp_service_with_sdp_client(
# -----------------------------------------------------------------------------
async def find_avdtp_service_with_connection(
- device: device.Device, connection: device.Connection
+ connection: device.Connection,
) -> Optional[Tuple[int, int]]:
'''
Find an AVDTP service, for a connection, and return its version,
or None if none is found
'''
- sdp_client = sdp.Client(device)
- await sdp_client.connect(connection)
+ sdp_client = sdp.Client(connection)
+ await sdp_client.connect()
service_version = await find_avdtp_service_with_sdp_client(sdp_client)
await sdp_client.disconnect()
@@ -511,7 +514,8 @@ class MessageAssembler:
try:
self.callback(self.transaction_label, message)
except Exception as error:
- logger.warning(color(f'!!! exception in callback: {error}'))
+ logger.exception(color(f'!!! exception in callback: {error}', 'red'))
+
self.reset()
@@ -1466,10 +1470,10 @@ class Protocol(EventEmitter):
f'[{transaction_label}] {message}'
)
max_fragment_size = (
- self.l2cap_channel.mtu - 3
+ self.l2cap_channel.peer_mtu - 3
) # Enough space for a 3-byte start packet header
payload = message.payload
- if len(payload) + 2 <= self.l2cap_channel.mtu:
+ if len(payload) + 2 <= self.l2cap_channel.peer_mtu:
# Fits in a single packet
packet_type = self.PacketType.SINGLE_PACKET
else:
diff --git a/bumble/avrcp.py b/bumble/avrcp.py
new file mode 100644
index 0000000..fec2b2c
--- /dev/null
+++ b/bumble/avrcp.py
@@ -0,0 +1,1916 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import asyncio
+from dataclasses import dataclass
+import enum
+import logging
+import struct
+from typing import (
+ AsyncIterator,
+ Awaitable,
+ Callable,
+ cast,
+ Dict,
+ Iterable,
+ List,
+ Optional,
+ Sequence,
+ SupportsBytes,
+ Tuple,
+ Type,
+ TypeVar,
+ Union,
+)
+
+import pyee
+
+from bumble.colors import color
+from bumble.device import Device, Connection
+from bumble.sdp import (
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+ SDP_PUBLIC_BROWSE_ROOT,
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
+ DataElement,
+ ServiceAttribute,
+)
+from bumble.utils import AsyncRunner, OpenIntEnum
+from bumble.core import (
+ ProtocolError,
+ BT_L2CAP_PROTOCOL_ID,
+ BT_AVCTP_PROTOCOL_ID,
+ BT_AV_REMOTE_CONTROL_SERVICE,
+ BT_AV_REMOTE_CONTROL_CONTROLLER_SERVICE,
+ BT_AV_REMOTE_CONTROL_TARGET_SERVICE,
+)
+from bumble import l2cap
+from bumble import avc
+from bumble import avctp
+from bumble import utils
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+AVRCP_PID = 0x110E
+AVRCP_BLUETOOTH_SIG_COMPANY_ID = 0x001958
+
+
+# -----------------------------------------------------------------------------
+def make_controller_service_sdp_records(
+ service_record_handle: int,
+ avctp_version: Tuple[int, int] = (1, 4),
+ avrcp_version: Tuple[int, int] = (1, 6),
+ supported_features: int = 1,
+) -> List[ServiceAttribute]:
+ # TODO: support a way to compute the supported features from a feature list
+ avctp_version_int = avctp_version[0] << 8 | avctp_version[1]
+ avrcp_version_int = avrcp_version[0] << 8 | avrcp_version[1]
+
+ return [
+ ServiceAttribute(
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_32(service_record_handle),
+ ),
+ ServiceAttribute(
+ SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+ DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+ ),
+ ServiceAttribute(
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_AV_REMOTE_CONTROL_SERVICE),
+ DataElement.uuid(BT_AV_REMOTE_CONTROL_CONTROLLER_SERVICE),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
+ DataElement.unsigned_integer_16(avctp.AVCTP_PSM),
+ ]
+ ),
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_AVCTP_PROTOCOL_ID),
+ DataElement.unsigned_integer_16(avctp_version_int),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_AV_REMOTE_CONTROL_SERVICE),
+ DataElement.unsigned_integer_16(avrcp_version_int),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_16(supported_features),
+ ),
+ ]
+
+
+# -----------------------------------------------------------------------------
+def make_target_service_sdp_records(
+ service_record_handle: int,
+ avctp_version: Tuple[int, int] = (1, 4),
+ avrcp_version: Tuple[int, int] = (1, 6),
+ supported_features: int = 0x23,
+) -> List[ServiceAttribute]:
+ # TODO: support a way to compute the supported features from a feature list
+ avctp_version_int = avctp_version[0] << 8 | avctp_version[1]
+ avrcp_version_int = avrcp_version[0] << 8 | avrcp_version[1]
+
+ return [
+ ServiceAttribute(
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_32(service_record_handle),
+ ),
+ ServiceAttribute(
+ SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+ DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+ ),
+ ServiceAttribute(
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_AV_REMOTE_CONTROL_TARGET_SERVICE),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
+ DataElement.unsigned_integer_16(avctp.AVCTP_PSM),
+ ]
+ ),
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_AVCTP_PROTOCOL_ID),
+ DataElement.unsigned_integer_16(avctp_version_int),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_AV_REMOTE_CONTROL_SERVICE),
+ DataElement.unsigned_integer_16(avrcp_version_int),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_16(supported_features),
+ ),
+ ]
+
+
+# -----------------------------------------------------------------------------
+def _decode_attribute_value(value: bytes, character_set: CharacterSetId) -> str:
+ try:
+ if character_set == CharacterSetId.UTF_8:
+ return value.decode("utf-8")
+ return value.decode("ascii")
+ except UnicodeDecodeError:
+ logger.warning(f"cannot decode string with bytes: {value.hex()}")
+ return ""
+
+
+# -----------------------------------------------------------------------------
+class PduAssembler:
+ """
+ PDU Assembler to support fragmented PDUs are defined in:
+ Audio/Video Remote Control / Profile Specification
+ 6.3.1 AVRCP specific AV//C commands
+ """
+
+ pdu_id: Optional[Protocol.PduId]
+ payload: bytes
+
+ def __init__(self, callback: Callable[[Protocol.PduId, bytes], None]) -> None:
+ self.callback = callback
+ self.reset()
+
+ def reset(self) -> None:
+ self.pdu_id = None
+ self.parameter = b''
+
+ def on_pdu(self, pdu: bytes) -> None:
+ pdu_id = Protocol.PduId(pdu[0])
+ packet_type = Protocol.PacketType(pdu[1] & 3)
+ parameter_length = struct.unpack_from('>H', pdu, 2)[0]
+ parameter = pdu[4 : 4 + parameter_length]
+ if len(parameter) != parameter_length:
+ logger.warning("parameter length exceeds pdu size")
+ self.reset()
+ return
+
+ if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.START):
+ if self.pdu_id is not None:
+ # We are already in a PDU
+ logger.warning("received START or SINGLE fragment while in pdu")
+ self.reset()
+
+ if packet_type in (Protocol.PacketType.CONTINUE, Protocol.PacketType.END):
+ if pdu_id != self.pdu_id:
+ logger.warning("PID does not match")
+ self.reset()
+ return
+ else:
+ self.pdu_id = pdu_id
+
+ self.parameter += parameter
+
+ if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.END):
+ self.on_pdu_complete()
+
+ def on_pdu_complete(self) -> None:
+ assert self.pdu_id is not None
+ try:
+ self.callback(self.pdu_id, self.parameter)
+ except Exception as error:
+ logger.exception(color(f'!!! exception in callback: {error}', 'red'))
+
+ self.reset()
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class Command:
+ pdu_id: Protocol.PduId
+ parameter: bytes
+
+ def to_string(self, properties: Dict[str, str]) -> str:
+ properties_str = ",".join(
+ [f"{name}={value}" for name, value in properties.items()]
+ )
+ return f"Command[{self.pdu_id.name}]({properties_str})"
+
+ def __str__(self) -> str:
+ return self.to_string({"parameters": self.parameter.hex()})
+
+ def __repr__(self) -> str:
+ return str(self)
+
+
+# -----------------------------------------------------------------------------
+class GetCapabilitiesCommand(Command):
+ class CapabilityId(OpenIntEnum):
+ COMPANY_ID = 0x02
+ EVENTS_SUPPORTED = 0x03
+
+ capability_id: CapabilityId
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> GetCapabilitiesCommand:
+ return cls(cls.CapabilityId(pdu[0]))
+
+ def __init__(self, capability_id: CapabilityId) -> None:
+ super().__init__(Protocol.PduId.GET_CAPABILITIES, bytes([capability_id]))
+ self.capability_id = capability_id
+
+ def __str__(self) -> str:
+ return self.to_string({"capability_id": self.capability_id.name})
+
+
+# -----------------------------------------------------------------------------
+class GetPlayStatusCommand(Command):
+ @classmethod
+ def from_bytes(cls, _: bytes) -> GetPlayStatusCommand:
+ return cls()
+
+ def __init__(self) -> None:
+ super().__init__(Protocol.PduId.GET_PLAY_STATUS, b'')
+
+
+# -----------------------------------------------------------------------------
+class GetElementAttributesCommand(Command):
+ identifier: int
+ attribute_ids: List[MediaAttributeId]
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> GetElementAttributesCommand:
+ identifier = struct.unpack_from(">Q", pdu)[0]
+ num_attributes = pdu[8]
+ attribute_ids = [MediaAttributeId(pdu[9 + i]) for i in range(num_attributes)]
+ return cls(identifier, attribute_ids)
+
+ def __init__(
+ self, identifier: int, attribute_ids: Sequence[MediaAttributeId]
+ ) -> None:
+ parameter = struct.pack(">QB", identifier, len(attribute_ids)) + b''.join(
+ [struct.pack(">I", int(attribute_id)) for attribute_id in attribute_ids]
+ )
+ super().__init__(Protocol.PduId.GET_ELEMENT_ATTRIBUTES, parameter)
+ self.identifier = identifier
+ self.attribute_ids = list(attribute_ids)
+
+
+# -----------------------------------------------------------------------------
+class SetAbsoluteVolumeCommand(Command):
+ MAXIMUM_VOLUME = 0x7F
+
+ volume: int
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> SetAbsoluteVolumeCommand:
+ return cls(pdu[0])
+
+ def __init__(self, volume: int) -> None:
+ super().__init__(Protocol.PduId.SET_ABSOLUTE_VOLUME, bytes([volume]))
+ self.volume = volume
+
+ def __str__(self) -> str:
+ return self.to_string({"volume": str(self.volume)})
+
+
+# -----------------------------------------------------------------------------
+class RegisterNotificationCommand(Command):
+ event_id: EventId
+ playback_interval: int
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> RegisterNotificationCommand:
+ event_id = EventId(pdu[0])
+ playback_interval = struct.unpack_from(">I", pdu, 1)[0]
+ return cls(event_id, playback_interval)
+
+ def __init__(self, event_id: EventId, playback_interval: int) -> None:
+ super().__init__(
+ Protocol.PduId.REGISTER_NOTIFICATION,
+ struct.pack(">BI", int(event_id), playback_interval),
+ )
+ self.event_id = event_id
+ self.playback_interval = playback_interval
+
+ def __str__(self) -> str:
+ return self.to_string(
+ {
+ "event_id": self.event_id.name,
+ "playback_interval": str(self.playback_interval),
+ }
+ )
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class Response:
+ pdu_id: Protocol.PduId
+ parameter: bytes
+
+ def to_string(self, properties: Dict[str, str]) -> str:
+ properties_str = ",".join(
+ [f"{name}={value}" for name, value in properties.items()]
+ )
+ return f"Response[{self.pdu_id.name}]({properties_str})"
+
+ def __str__(self) -> str:
+ return self.to_string({"parameter": self.parameter.hex()})
+
+ def __repr__(self) -> str:
+ return str(self)
+
+
+# -----------------------------------------------------------------------------
+class RejectedResponse(Response):
+ status_code: Protocol.StatusCode
+
+ @classmethod
+ def from_bytes(cls, pdu_id: Protocol.PduId, pdu: bytes) -> RejectedResponse:
+ return cls(pdu_id, Protocol.StatusCode(pdu[0]))
+
+ def __init__(
+ self, pdu_id: Protocol.PduId, status_code: Protocol.StatusCode
+ ) -> None:
+ super().__init__(pdu_id, bytes([int(status_code)]))
+ self.status_code = status_code
+
+ def __str__(self) -> str:
+ return self.to_string(
+ {
+ "status_code": self.status_code.name,
+ }
+ )
+
+
+# -----------------------------------------------------------------------------
+class NotImplementedResponse(Response):
+ @classmethod
+ def from_bytes(cls, pdu_id: Protocol.PduId, pdu: bytes) -> NotImplementedResponse:
+ return cls(pdu_id, pdu[1:])
+
+
+# -----------------------------------------------------------------------------
+class GetCapabilitiesResponse(Response):
+ capability_id: GetCapabilitiesCommand.CapabilityId
+ capabilities: List[Union[SupportsBytes, bytes]]
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> GetCapabilitiesResponse:
+ if len(pdu) < 2:
+ # Possibly a reject response.
+ return cls(GetCapabilitiesCommand.CapabilityId(0), [])
+
+ # Assume that the payloads all follow the same pattern:
+ # <CapabilityID><CapabilityCount><Capability*>
+ capability_id = GetCapabilitiesCommand.CapabilityId(pdu[0])
+ capability_count = pdu[1]
+
+ capabilities: List[Union[SupportsBytes, bytes]]
+ if capability_id == GetCapabilitiesCommand.CapabilityId.EVENTS_SUPPORTED:
+ capabilities = [EventId(pdu[2 + x]) for x in range(capability_count)]
+ else:
+ capability_size = (len(pdu) - 2) // capability_count
+ capabilities = [
+ pdu[x : x + capability_size]
+ for x in range(2, len(pdu), capability_size)
+ ]
+
+ return cls(capability_id, capabilities)
+
+ def __init__(
+ self,
+ capability_id: GetCapabilitiesCommand.CapabilityId,
+ capabilities: Sequence[Union[SupportsBytes, bytes]],
+ ) -> None:
+ super().__init__(
+ Protocol.PduId.GET_CAPABILITIES,
+ bytes([capability_id, len(capabilities)])
+ + b''.join(bytes(capability) for capability in capabilities),
+ )
+ self.capability_id = capability_id
+ self.capabilities = list(capabilities)
+
+ def __str__(self) -> str:
+ return self.to_string(
+ {
+ "capability_id": self.capability_id.name,
+ "capabilities": str(self.capabilities),
+ }
+ )
+
+
+# -----------------------------------------------------------------------------
+class GetPlayStatusResponse(Response):
+ song_length: int
+ song_position: int
+ play_status: PlayStatus
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> GetPlayStatusResponse:
+ (song_length, song_position) = struct.unpack_from(">II", pdu, 0)
+ play_status = PlayStatus(pdu[8])
+
+ return cls(song_length, song_position, play_status)
+
+ def __init__(
+ self,
+ song_length: int,
+ song_position: int,
+ play_status: PlayStatus,
+ ) -> None:
+ super().__init__(
+ Protocol.PduId.GET_PLAY_STATUS,
+ struct.pack(">IIB", song_length, song_position, int(play_status)),
+ )
+ self.song_length = song_length
+ self.song_position = song_position
+ self.play_status = play_status
+
+ def __str__(self) -> str:
+ return self.to_string(
+ {
+ "song_length": str(self.song_length),
+ "song_position": str(self.song_position),
+ "play_status": self.play_status.name,
+ }
+ )
+
+
+# -----------------------------------------------------------------------------
+class GetElementAttributesResponse(Response):
+ attributes: List[MediaAttribute]
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> GetElementAttributesResponse:
+ num_attributes = pdu[0]
+ offset = 1
+ attributes: List[MediaAttribute] = []
+ for _ in range(num_attributes):
+ (
+ attribute_id_int,
+ character_set_id_int,
+ attribute_value_length,
+ ) = struct.unpack_from(">IHH", pdu, offset)
+ attribute_value_bytes = pdu[
+ offset + 8 : offset + 8 + attribute_value_length
+ ]
+ attribute_id = MediaAttributeId(attribute_id_int)
+ character_set_id = CharacterSetId(character_set_id_int)
+ attribute_value = _decode_attribute_value(
+ attribute_value_bytes, character_set_id
+ )
+ attributes.append(
+ MediaAttribute(attribute_id, character_set_id, attribute_value)
+ )
+ offset += 8 + attribute_value_length
+
+ return cls(attributes)
+
+ def __init__(self, attributes: Sequence[MediaAttribute]) -> None:
+ parameter = bytes([len(attributes)])
+ for attribute in attributes:
+ attribute_value_bytes = attribute.attribute_value.encode("utf-8")
+ parameter += (
+ struct.pack(
+ ">IHH",
+ int(attribute.attribute_id),
+ int(CharacterSetId.UTF_8),
+ len(attribute_value_bytes),
+ )
+ + attribute_value_bytes
+ )
+ super().__init__(
+ Protocol.PduId.GET_ELEMENT_ATTRIBUTES,
+ parameter,
+ )
+ self.attributes = list(attributes)
+
+ def __str__(self) -> str:
+ attribute_strs = [str(attribute) for attribute in self.attributes]
+ return self.to_string(
+ {
+ "attributes": f"[{', '.join(attribute_strs)}]",
+ }
+ )
+
+
+# -----------------------------------------------------------------------------
+class SetAbsoluteVolumeResponse(Response):
+ volume: int
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> SetAbsoluteVolumeResponse:
+ return cls(pdu[0])
+
+ def __init__(self, volume: int) -> None:
+ super().__init__(Protocol.PduId.SET_ABSOLUTE_VOLUME, bytes([volume]))
+ self.volume = volume
+
+ def __str__(self) -> str:
+ return self.to_string({"volume": str(self.volume)})
+
+
+# -----------------------------------------------------------------------------
+class RegisterNotificationResponse(Response):
+ event: Event
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> RegisterNotificationResponse:
+ return cls(Event.from_bytes(pdu))
+
+ def __init__(self, event: Event) -> None:
+ super().__init__(
+ Protocol.PduId.REGISTER_NOTIFICATION,
+ bytes(event),
+ )
+ self.event = event
+
+ def __str__(self) -> str:
+ return self.to_string(
+ {
+ "event": str(self.event),
+ }
+ )
+
+
+# -----------------------------------------------------------------------------
+class EventId(OpenIntEnum):
+ PLAYBACK_STATUS_CHANGED = 0x01
+ TRACK_CHANGED = 0x02
+ TRACK_REACHED_END = 0x03
+ TRACK_REACHED_START = 0x04
+ PLAYBACK_POS_CHANGED = 0x05
+ BATT_STATUS_CHANGED = 0x06
+ SYSTEM_STATUS_CHANGED = 0x07
+ PLAYER_APPLICATION_SETTING_CHANGED = 0x08
+ NOW_PLAYING_CONTENT_CHANGED = 0x09
+ AVAILABLE_PLAYERS_CHANGED = 0x0A
+ ADDRESSED_PLAYER_CHANGED = 0x0B
+ UIDS_CHANGED = 0x0C
+ VOLUME_CHANGED = 0x0D
+
+ def __bytes__(self) -> bytes:
+ return bytes([int(self)])
+
+
+# -----------------------------------------------------------------------------
+class CharacterSetId(OpenIntEnum):
+ UTF_8 = 0x06
+
+
+# -----------------------------------------------------------------------------
+class MediaAttributeId(OpenIntEnum):
+ TITLE = 0x01
+ ARTIST_NAME = 0x02
+ ALBUM_NAME = 0x03
+ TRACK_NUMBER = 0x04
+ TOTAL_NUMBER_OF_TRACKS = 0x05
+ GENRE = 0x06
+ PLAYING_TIME = 0x07
+ DEFAULT_COVER_ART = 0x08
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class MediaAttribute:
+ attribute_id: MediaAttributeId
+ character_set_id: CharacterSetId
+ attribute_value: str
+
+
+# -----------------------------------------------------------------------------
+class PlayStatus(OpenIntEnum):
+ STOPPED = 0x00
+ PLAYING = 0x01
+ PAUSED = 0x02
+ FWD_SEEK = 0x03
+ REV_SEEK = 0x04
+ ERROR = 0xFF
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class SongAndPlayStatus:
+ song_length: int
+ song_position: int
+ play_status: PlayStatus
+
+
+# -----------------------------------------------------------------------------
+class ApplicationSetting:
+ class AttributeId(OpenIntEnum):
+ EQUALIZER_ON_OFF = 0x01
+ REPEAT_MODE = 0x02
+ SHUFFLE_ON_OFF = 0x03
+ SCAN_ON_OFF = 0x04
+
+ class EqualizerOnOffStatus(OpenIntEnum):
+ OFF = 0x01
+ ON = 0x02
+
+ class RepeatModeStatus(OpenIntEnum):
+ OFF = 0x01
+ SINGLE_TRACK_REPEAT = 0x02
+ ALL_TRACK_REPEAT = 0x03
+ GROUP_REPEAT = 0x04
+
+ class ShuffleOnOffStatus(OpenIntEnum):
+ OFF = 0x01
+ ALL_TRACKS_SHUFFLE = 0x02
+ GROUP_SHUFFLE = 0x03
+
+ class ScanOnOffStatus(OpenIntEnum):
+ OFF = 0x01
+ ALL_TRACKS_SCAN = 0x02
+ GROUP_SCAN = 0x03
+
+ class GenericValue(OpenIntEnum):
+ pass
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class Event:
+ event_id: EventId
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> Event:
+ event_id = EventId(pdu[0])
+ subclass = EVENT_SUBCLASSES.get(event_id, GenericEvent)
+ return subclass.from_bytes(pdu)
+
+ def __bytes__(self) -> bytes:
+ return bytes([self.event_id])
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class GenericEvent(Event):
+ data: bytes
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> GenericEvent:
+ return cls(event_id=EventId(pdu[0]), data=pdu[1:])
+
+ def __bytes__(self) -> bytes:
+ return bytes([self.event_id]) + self.data
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class PlaybackStatusChangedEvent(Event):
+ play_status: PlayStatus
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> PlaybackStatusChangedEvent:
+ return cls(play_status=PlayStatus(pdu[1]))
+
+ def __init__(self, play_status: PlayStatus) -> None:
+ super().__init__(EventId.PLAYBACK_STATUS_CHANGED)
+ self.play_status = play_status
+
+ def __bytes__(self) -> bytes:
+ return bytes([self.event_id]) + bytes([self.play_status])
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class PlaybackPositionChangedEvent(Event):
+ playback_position: int
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> PlaybackPositionChangedEvent:
+ return cls(playback_position=struct.unpack_from(">I", pdu, 1)[0])
+
+ def __init__(self, playback_position: int) -> None:
+ super().__init__(EventId.PLAYBACK_POS_CHANGED)
+ self.playback_position = playback_position
+
+ def __bytes__(self) -> bytes:
+ return bytes([self.event_id]) + struct.pack(">I", self.playback_position)
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class TrackChangedEvent(Event):
+ identifier: bytes
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> TrackChangedEvent:
+ return cls(identifier=pdu[1:])
+
+ def __init__(self, identifier: bytes) -> None:
+ super().__init__(EventId.TRACK_CHANGED)
+ self.identifier = identifier
+
+ def __bytes__(self) -> bytes:
+ return bytes([self.event_id]) + self.identifier
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class PlayerApplicationSettingChangedEvent(Event):
+ @dataclass
+ class Setting:
+ attribute_id: ApplicationSetting.AttributeId
+ value_id: OpenIntEnum
+
+ player_application_settings: List[Setting]
+
+ @classmethod
+ 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
+ if attribute_id == ApplicationSetting.AttributeId.EQUALIZER_ON_OFF:
+ value_id = ApplicationSetting.EqualizerOnOffStatus(value_id_int)
+ elif attribute_id == ApplicationSetting.AttributeId.REPEAT_MODE:
+ value_id = ApplicationSetting.RepeatModeStatus(value_id_int)
+ elif attribute_id == ApplicationSetting.AttributeId.SHUFFLE_ON_OFF:
+ value_id = ApplicationSetting.ShuffleOnOffStatus(value_id_int)
+ elif attribute_id == ApplicationSetting.AttributeId.SCAN_ON_OFF:
+ value_id = ApplicationSetting.ScanOnOffStatus(value_id_int)
+ else:
+ value_id = ApplicationSetting.GenericValue(value_id_int)
+
+ return cls.Setting(attribute_id, value_id)
+
+ settings = [
+ setting(pdu[2 + (i * 2)], pdu[2 + (i * 2) + 1]) for i in range(pdu[1])
+ ]
+ return cls(player_application_settings=settings)
+
+ def __init__(self, player_application_settings: Sequence[Setting]) -> None:
+ super().__init__(EventId.PLAYER_APPLICATION_SETTING_CHANGED)
+ self.player_application_settings = list(player_application_settings)
+
+ def __bytes__(self) -> bytes:
+ return (
+ bytes([self.event_id])
+ + bytes([len(self.player_application_settings)])
+ + b''.join(
+ [
+ bytes([setting.attribute_id, setting.value_id])
+ for setting in self.player_application_settings
+ ]
+ )
+ )
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class NowPlayingContentChangedEvent(Event):
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> NowPlayingContentChangedEvent:
+ return cls()
+
+ def __init__(self) -> None:
+ super().__init__(EventId.NOW_PLAYING_CONTENT_CHANGED)
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class AvailablePlayersChangedEvent(Event):
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> AvailablePlayersChangedEvent:
+ return cls()
+
+ def __init__(self) -> None:
+ super().__init__(EventId.AVAILABLE_PLAYERS_CHANGED)
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class AddressedPlayerChangedEvent(Event):
+ @dataclass
+ class Player:
+ player_id: int
+ uid_counter: int
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> AddressedPlayerChangedEvent:
+ player_id, uid_counter = struct.unpack_from("<HH", pdu, 1)
+ return cls(cls.Player(player_id, uid_counter))
+
+ def __init__(self, player: Player) -> None:
+ super().__init__(EventId.ADDRESSED_PLAYER_CHANGED)
+ self.player = player
+
+ def __bytes__(self) -> bytes:
+ return bytes([self.event_id]) + struct.pack(
+ ">HH", self.player.player_id, self.player.uid_counter
+ )
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class UidsChangedEvent(Event):
+ uid_counter: int
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> UidsChangedEvent:
+ return cls(uid_counter=struct.unpack_from(">H", pdu, 1)[0])
+
+ def __init__(self, uid_counter: int) -> None:
+ super().__init__(EventId.UIDS_CHANGED)
+ self.uid_counter = uid_counter
+
+ def __bytes__(self) -> bytes:
+ return bytes([self.event_id]) + struct.pack(">H", self.uid_counter)
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class VolumeChangedEvent(Event):
+ volume: int
+
+ @classmethod
+ def from_bytes(cls, pdu: bytes) -> VolumeChangedEvent:
+ return cls(volume=pdu[1])
+
+ def __init__(self, volume: int) -> None:
+ super().__init__(EventId.VOLUME_CHANGED)
+ self.volume = volume
+
+ def __bytes__(self) -> bytes:
+ return bytes([self.event_id]) + bytes([self.volume])
+
+
+# -----------------------------------------------------------------------------
+EVENT_SUBCLASSES: Dict[EventId, Type[Event]] = {
+ EventId.PLAYBACK_STATUS_CHANGED: PlaybackStatusChangedEvent,
+ EventId.PLAYBACK_POS_CHANGED: PlaybackPositionChangedEvent,
+ EventId.TRACK_CHANGED: TrackChangedEvent,
+ EventId.PLAYER_APPLICATION_SETTING_CHANGED: PlayerApplicationSettingChangedEvent,
+ EventId.NOW_PLAYING_CONTENT_CHANGED: NowPlayingContentChangedEvent,
+ EventId.AVAILABLE_PLAYERS_CHANGED: AvailablePlayersChangedEvent,
+ EventId.ADDRESSED_PLAYER_CHANGED: AddressedPlayerChangedEvent,
+ EventId.UIDS_CHANGED: UidsChangedEvent,
+ EventId.VOLUME_CHANGED: VolumeChangedEvent,
+}
+
+
+# -----------------------------------------------------------------------------
+class Delegate:
+ """
+ Base class for AVRCP delegates.
+
+ All the methods are async, even if they don't always need to be, so that
+ delegates that do need to wait for an async result may do so.
+ """
+
+ class Error(Exception):
+ """The delegate method failed, with a specified status code."""
+
+ def __init__(self, status_code: Protocol.StatusCode) -> None:
+ self.status_code = status_code
+
+ supported_events: List[EventId]
+ volume: int
+
+ def __init__(self, supported_events: Iterable[EventId] = ()) -> None:
+ self.supported_events = list(supported_events)
+ self.volume = 0
+
+ async def get_supported_events(self) -> List[EventId]:
+ return self.supported_events
+
+ async def set_absolute_volume(self, volume: int) -> None:
+ """
+ Set the absolute volume.
+
+ Returns: the effective volume that was set.
+ """
+ logger.debug(f"@@@ set_absolute_volume: volume={volume}")
+ self.volume = volume
+
+ async def get_absolute_volume(self) -> int:
+ return self.volume
+
+ # TODO add other delegate methods
+
+
+# -----------------------------------------------------------------------------
+class Protocol(pyee.EventEmitter):
+ """AVRCP Controller and Target protocol."""
+
+ class PacketType(enum.IntEnum):
+ SINGLE = 0b00
+ START = 0b01
+ CONTINUE = 0b10
+ END = 0b11
+
+ class PduId(OpenIntEnum):
+ GET_CAPABILITIES = 0x10
+ LIST_PLAYER_APPLICATION_SETTING_ATTRIBUTES = 0x11
+ LIST_PLAYER_APPLICATION_SETTING_VALUES = 0x12
+ GET_CURRENT_PLAYER_APPLICATION_SETTING_VALUE = 0x13
+ SET_PLAYER_APPLICATION_SETTING_VALUE = 0x14
+ GET_PLAYER_APPLICATION_SETTING_ATTRIBUTE_TEXT = 0x15
+ GET_PLAYER_APPLICATION_SETTING_VALUE_TEXT = 0x16
+ INFORM_DISPLAYABLE_CHARACTER_SET = 0x17
+ INFORM_BATTERY_STATUS_OF_CT = 0x18
+ GET_ELEMENT_ATTRIBUTES = 0x20
+ GET_PLAY_STATUS = 0x30
+ REGISTER_NOTIFICATION = 0x31
+ REQUEST_CONTINUING_RESPONSE = 0x40
+ ABORT_CONTINUING_RESPONSE = 0x41
+ SET_ABSOLUTE_VOLUME = 0x50
+ SET_ADDRESSED_PLAYER = 0x60
+ SET_BROWSED_PLAYER = 0x70
+ GET_FOLDER_ITEMS = 0x71
+ GET_TOTAL_NUMBER_OF_ITEMS = 0x75
+
+ class StatusCode(OpenIntEnum):
+ INVALID_COMMAND = 0x00
+ INVALID_PARAMETER = 0x01
+ PARAMETER_CONTENT_ERROR = 0x02
+ INTERNAL_ERROR = 0x03
+ OPERATION_COMPLETED = 0x04
+ UID_CHANGED = 0x05
+ INVALID_DIRECTION = 0x07
+ NOT_A_DIRECTORY = 0x08
+ DOES_NOT_EXIST = 0x09
+ INVALID_SCOPE = 0x0A
+ RANGE_OUT_OF_BOUNDS = 0x0B
+ FOLDER_ITEM_IS_NOT_PLAYABLE = 0x0C
+ MEDIA_IN_USE = 0x0D
+ NOW_PLAYING_LIST_FULL = 0x0E
+ SEARCH_NOT_SUPPORTED = 0x0F
+ SEARCH_IN_PROGRESS = 0x10
+ INVALID_PLAYER_ID = 0x11
+ PLAYER_NOT_BROWSABLE = 0x12
+ PLAYER_NOT_ADDRESSED = 0x13
+ NO_VALID_SEARCH_RESULTS = 0x14
+ NO_AVAILABLE_PLAYERS = 0x15
+ ADDRESSED_PLAYER_CHANGED = 0x16
+
+ class InvalidPidError(Exception):
+ """A response frame with ipid==1 was received."""
+
+ class NotPendingError(Exception):
+ """There is no pending command for a transaction label."""
+
+ class MismatchedResponseError(Exception):
+ """The response type does not corresponding to the request type."""
+
+ def __init__(self, response: Response) -> None:
+ self.response = response
+
+ class UnexpectedResponseTypeError(Exception):
+ """The response type is not the expected one."""
+
+ def __init__(self, response: Protocol.ResponseContext) -> None:
+ self.response = response
+
+ class UnexpectedResponseCodeError(Exception):
+ """The response code was not the expected one."""
+
+ def __init__(
+ self, response_code: avc.ResponseFrame.ResponseCode, response: Response
+ ) -> None:
+ self.response_code = response_code
+ self.response = response
+
+ class PendingCommand:
+ response: asyncio.Future
+
+ def __init__(self, transaction_label: int) -> None:
+ self.transaction_label = transaction_label
+ self.reset()
+
+ def reset(self):
+ self.response = asyncio.get_running_loop().create_future()
+
+ @dataclass
+ class ReceiveCommandState:
+ transaction_label: int
+ command_type: avc.CommandFrame.CommandType
+
+ @dataclass
+ class ReceiveResponseState:
+ transaction_label: int
+ response_code: avc.ResponseFrame.ResponseCode
+
+ @dataclass
+ class ResponseContext:
+ transaction_label: int
+ response: Response
+
+ @dataclass
+ class FinalResponse(ResponseContext):
+ response_code: avc.ResponseFrame.ResponseCode
+
+ @dataclass
+ class InterimResponse(ResponseContext):
+ final: Awaitable[Protocol.FinalResponse]
+
+ @dataclass
+ class NotificationListener:
+ transaction_label: int
+ register_notification_command: RegisterNotificationCommand
+
+ delegate: Delegate
+ send_transaction_label: int
+ command_pdu_assembler: PduAssembler
+ receive_command_state: Optional[ReceiveCommandState]
+ response_pdu_assembler: PduAssembler
+ receive_response_state: Optional[ReceiveResponseState]
+ avctp_protocol: Optional[avctp.Protocol]
+ free_commands: asyncio.Queue
+ pending_commands: Dict[int, PendingCommand] # Pending commands, by label
+ notification_listeners: Dict[EventId, NotificationListener]
+
+ @staticmethod
+ def _check_vendor_dependent_frame(
+ frame: Union[avc.VendorDependentCommandFrame, avc.VendorDependentResponseFrame]
+ ) -> bool:
+ if frame.company_id != AVRCP_BLUETOOTH_SIG_COMPANY_ID:
+ logger.debug("unsupported company id, ignoring")
+ return False
+
+ if frame.subunit_type != avc.Frame.SubunitType.PANEL or frame.subunit_id != 0:
+ logger.debug("unsupported subunit")
+ return False
+
+ return True
+
+ def __init__(self, delegate: Optional[Delegate] = None) -> None:
+ super().__init__()
+ self.delegate = delegate if delegate else Delegate()
+ self.command_pdu_assembler = PduAssembler(self._on_command_pdu)
+ self.receive_command_state = None
+ self.response_pdu_assembler = PduAssembler(self._on_response_pdu)
+ self.receive_response_state = None
+ self.avctp_protocol = None
+ self.notification_listeners = {}
+
+ # Create an initial pool of free commands
+ self.pending_commands = {}
+ self.free_commands = asyncio.Queue()
+ for transaction_label in range(16):
+ self.free_commands.put_nowait(self.PendingCommand(transaction_label))
+
+ def listen(self, device: Device) -> None:
+ """
+ Listen for incoming connections.
+
+ A 'connection' event will be emitted when a connection is made, and a 'start'
+ event will be emitted when the protocol is ready to be used on that connection.
+ """
+ device.register_l2cap_server(avctp.AVCTP_PSM, self._on_avctp_connection)
+
+ async def connect(self, connection: Connection) -> None:
+ """
+ Connect to a peer.
+ """
+ avctp_channel = await connection.create_l2cap_channel(
+ l2cap.ClassicChannelSpec(psm=avctp.AVCTP_PSM)
+ )
+ self._on_avctp_channel_open(avctp_channel)
+
+ async def _obtain_pending_command(self) -> PendingCommand:
+ pending_command = await self.free_commands.get()
+ self.pending_commands[pending_command.transaction_label] = pending_command
+ return pending_command
+
+ def recycle_pending_command(self, pending_command: PendingCommand) -> None:
+ pending_command.reset()
+ del self.pending_commands[pending_command.transaction_label]
+ self.free_commands.put_nowait(pending_command)
+ logger.debug(f"recycled pending command, {self.free_commands.qsize()} free")
+
+ _R = TypeVar('_R')
+
+ @staticmethod
+ def _check_response(
+ response_context: ResponseContext, expected_type: Type[_R]
+ ) -> _R:
+ if isinstance(response_context, Protocol.FinalResponse):
+ if (
+ response_context.response_code
+ != avc.ResponseFrame.ResponseCode.IMPLEMENTED_OR_STABLE
+ ):
+ raise Protocol.UnexpectedResponseCodeError(
+ response_context.response_code, response_context.response
+ )
+
+ if not (isinstance(response_context.response, expected_type)):
+ raise Protocol.MismatchedResponseError(response_context.response)
+
+ return response_context.response
+
+ raise Protocol.UnexpectedResponseTypeError(response_context)
+
+ def _delegate_command(
+ self, transaction_label: int, command: Command, method: Awaitable
+ ) -> None:
+ async def call():
+ try:
+ await method
+ except Delegate.Error as error:
+ self.send_rejected_avrcp_response(
+ transaction_label,
+ command.pdu_id,
+ error.status_code,
+ )
+ except Exception:
+ logger.exception("delegate method raised exception")
+ self.send_rejected_avrcp_response(
+ transaction_label,
+ command.pdu_id,
+ Protocol.StatusCode.INTERNAL_ERROR,
+ )
+
+ utils.AsyncRunner.spawn(call())
+
+ async def get_supported_events(self) -> List[EventId]:
+ """Get the list of events supported by the connected peer."""
+ response_context = await self.send_avrcp_command(
+ avc.CommandFrame.CommandType.STATUS,
+ GetCapabilitiesCommand(
+ GetCapabilitiesCommand.CapabilityId.EVENTS_SUPPORTED
+ ),
+ )
+ response = self._check_response(response_context, GetCapabilitiesResponse)
+ return cast(List[EventId], response.capabilities)
+
+ async def get_play_status(self) -> SongAndPlayStatus:
+ """Get the play status of the connected peer."""
+ response_context = await self.send_avrcp_command(
+ avc.CommandFrame.CommandType.STATUS, GetPlayStatusCommand()
+ )
+ response = self._check_response(response_context, GetPlayStatusResponse)
+ return SongAndPlayStatus(
+ response.song_length, response.song_position, response.play_status
+ )
+
+ async def get_element_attributes(
+ self, element_identifier: int, attribute_ids: Sequence[MediaAttributeId]
+ ) -> List[MediaAttribute]:
+ """Get element attributes from the connected peer."""
+ response_context = await self.send_avrcp_command(
+ avc.CommandFrame.CommandType.STATUS,
+ GetElementAttributesCommand(element_identifier, attribute_ids),
+ )
+ response = self._check_response(response_context, GetElementAttributesResponse)
+ return response.attributes
+
+ async def monitor_events(
+ self, event_id: EventId, playback_interval: int = 0
+ ) -> AsyncIterator[Event]:
+ """
+ Monitor events emitted from a peer.
+
+ This generator yields Event objects.
+ """
+
+ def check_response(response) -> Event:
+ if not isinstance(response, RegisterNotificationResponse):
+ raise self.MismatchedResponseError(response)
+
+ return response.event
+
+ while True:
+ response = await self.send_avrcp_command(
+ avc.CommandFrame.CommandType.NOTIFY,
+ RegisterNotificationCommand(event_id, playback_interval),
+ )
+
+ if isinstance(response, self.InterimResponse):
+ logger.debug(f"interim: {response}")
+ yield check_response(response.response)
+
+ logger.debug("waiting for final response")
+ response = await response.final
+
+ if not isinstance(response, self.FinalResponse):
+ raise self.UnexpectedResponseTypeError(response)
+
+ logger.debug(f"final: {response}")
+ if response.response_code != avc.ResponseFrame.ResponseCode.CHANGED:
+ raise self.UnexpectedResponseCodeError(
+ response.response_code, response.response
+ )
+
+ yield check_response(response.response)
+
+ async def monitor_playback_status(
+ self,
+ ) -> AsyncIterator[PlayStatus]:
+ """Monitor Playback Status changes from the connected peer."""
+ async for event in self.monitor_events(EventId.PLAYBACK_STATUS_CHANGED, 0):
+ if not isinstance(event, PlaybackStatusChangedEvent):
+ logger.warning("unexpected event class")
+ continue
+ yield event.play_status
+
+ async def monitor_track_changed(
+ self,
+ ) -> AsyncIterator[bytes]:
+ """Monitor Track changes from the connected peer."""
+ async for event in self.monitor_events(EventId.TRACK_CHANGED, 0):
+ if not isinstance(event, TrackChangedEvent):
+ logger.warning("unexpected event class")
+ continue
+ yield event.identifier
+
+ async def monitor_playback_position(
+ self, playback_interval: int
+ ) -> AsyncIterator[int]:
+ """Monitor Playback Position changes from the connected peer."""
+ async for event in self.monitor_events(
+ EventId.PLAYBACK_POS_CHANGED, playback_interval
+ ):
+ if not isinstance(event, PlaybackPositionChangedEvent):
+ logger.warning("unexpected event class")
+ continue
+ yield event.playback_position
+
+ async def monitor_player_application_settings(
+ self,
+ ) -> AsyncIterator[List[PlayerApplicationSettingChangedEvent.Setting]]:
+ """Monitor Player Application Setting changes from the connected peer."""
+ async for event in self.monitor_events(
+ EventId.PLAYER_APPLICATION_SETTING_CHANGED, 0
+ ):
+ if not isinstance(event, PlayerApplicationSettingChangedEvent):
+ logger.warning("unexpected event class")
+ continue
+ yield event.player_application_settings
+
+ async def monitor_now_playing_content(self) -> AsyncIterator[None]:
+ """Monitor Now Playing changes from the connected peer."""
+ async for event in self.monitor_events(EventId.NOW_PLAYING_CONTENT_CHANGED, 0):
+ if not isinstance(event, NowPlayingContentChangedEvent):
+ logger.warning("unexpected event class")
+ continue
+ yield None
+
+ async def monitor_available_players(self) -> AsyncIterator[None]:
+ """Monitor Available Players changes from the connected peer."""
+ async for event in self.monitor_events(EventId.AVAILABLE_PLAYERS_CHANGED, 0):
+ if not isinstance(event, AvailablePlayersChangedEvent):
+ logger.warning("unexpected event class")
+ continue
+ yield None
+
+ async def monitor_addressed_player(
+ self,
+ ) -> AsyncIterator[AddressedPlayerChangedEvent.Player]:
+ """Monitor Addressed Player changes from the connected peer."""
+ async for event in self.monitor_events(EventId.ADDRESSED_PLAYER_CHANGED, 0):
+ if not isinstance(event, AddressedPlayerChangedEvent):
+ logger.warning("unexpected event class")
+ continue
+ yield event.player
+
+ async def monitor_uids(
+ self,
+ ) -> AsyncIterator[int]:
+ """Monitor UID changes from the connected peer."""
+ async for event in self.monitor_events(EventId.UIDS_CHANGED, 0):
+ if not isinstance(event, UidsChangedEvent):
+ logger.warning("unexpected event class")
+ continue
+ yield event.uid_counter
+
+ async def monitor_volume(
+ self,
+ ) -> AsyncIterator[int]:
+ """Monitor Volume changes from the connected peer."""
+ async for event in self.monitor_events(EventId.VOLUME_CHANGED, 0):
+ if not isinstance(event, VolumeChangedEvent):
+ logger.warning("unexpected event class")
+ continue
+ yield event.volume
+
+ def notify_event(self, event: Event):
+ """Notify an event to the connected peer."""
+ if (listener := self.notification_listeners.get(event.event_id)) is None:
+ logger.debug(f"no listener for {event.event_id.name}")
+ return
+
+ # Emit the notification.
+ notification = RegisterNotificationResponse(event)
+ self.send_avrcp_response(
+ listener.transaction_label,
+ avc.ResponseFrame.ResponseCode.CHANGED,
+ notification,
+ )
+
+ # Remove the listener (they will need to re-register).
+ del self.notification_listeners[event.event_id]
+
+ def notify_playback_status_changed(self, status: PlayStatus) -> None:
+ """Notify the connected peer of a Playback Status change."""
+ self.notify_event(PlaybackStatusChangedEvent(status))
+
+ def notify_track_changed(self, identifier: bytes) -> None:
+ """Notify the connected peer of a Track change."""
+ if len(identifier) != 8:
+ raise ValueError("identifier must be 8 bytes")
+ self.notify_event(TrackChangedEvent(identifier))
+
+ def notify_playback_position_changed(self, position: int) -> None:
+ """Notify the connected peer of a Position change."""
+ self.notify_event(PlaybackPositionChangedEvent(position))
+
+ def notify_player_application_settings_changed(
+ self, settings: Sequence[PlayerApplicationSettingChangedEvent.Setting]
+ ) -> None:
+ """Notify the connected peer of an Player Application Setting change."""
+ self.notify_event(
+ PlayerApplicationSettingChangedEvent(settings),
+ )
+
+ def notify_now_playing_content_changed(self) -> None:
+ """Notify the connected peer of a Now Playing change."""
+ self.notify_event(NowPlayingContentChangedEvent())
+
+ def notify_available_players_changed(self) -> None:
+ """Notify the connected peer of an Available Players change."""
+ self.notify_event(AvailablePlayersChangedEvent())
+
+ def notify_addressed_player_changed(
+ self, player: AddressedPlayerChangedEvent.Player
+ ) -> None:
+ """Notify the connected peer of an Addressed Player change."""
+ self.notify_event(AddressedPlayerChangedEvent(player))
+
+ def notify_uids_changed(self, uid_counter: int) -> None:
+ """Notify the connected peer of a UID change."""
+ self.notify_event(UidsChangedEvent(uid_counter))
+
+ def notify_volume_changed(self, volume: int) -> None:
+ """Notify the connected peer of a Volume change."""
+ self.notify_event(VolumeChangedEvent(volume))
+
+ def _register_notification_listener(
+ self, transaction_label: int, command: RegisterNotificationCommand
+ ) -> None:
+ listener = self.NotificationListener(transaction_label, command)
+ self.notification_listeners[command.event_id] = listener
+
+ 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))
+
+ self.emit("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())
+ return
+
+ self.avctp_protocol = avctp.Protocol(l2cap_channel)
+ self.avctp_protocol.register_command_handler(AVRCP_PID, self._on_avctp_command)
+ self.avctp_protocol.register_response_handler(
+ AVRCP_PID, self._on_avctp_response
+ )
+ l2cap_channel.on("close", self._on_avctp_channel_close)
+
+ self.emit("start")
+
+ def _on_avctp_channel_close(self) -> None:
+ logger.debug("AVCTP channel closed")
+ self.avctp_protocol = None
+
+ self.emit("stop")
+
+ def _on_avctp_command(
+ self, transaction_label: int, command: avc.CommandFrame
+ ) -> None:
+ logger.debug(
+ f"<<< AVCTP Command, transaction_label={transaction_label}: " f"{command}"
+ )
+
+ # Only the PANEL subunit type with subunit ID 0 is supported in this profile.
+ if (
+ command.subunit_type != avc.Frame.SubunitType.PANEL
+ or command.subunit_id != 0
+ ):
+ logger.debug("subunit not supported")
+ self.send_not_implemented_response(transaction_label, command)
+ return
+
+ if isinstance(command, avc.VendorDependentCommandFrame):
+ if not self._check_vendor_dependent_frame(command):
+ return
+
+ if self.receive_command_state is None:
+ self.receive_command_state = self.ReceiveCommandState(
+ transaction_label=transaction_label, command_type=command.ctype
+ )
+ elif (
+ self.receive_command_state.transaction_label != transaction_label
+ or self.receive_command_state.command_type != command.ctype
+ ):
+ # We're in the middle of some other PDU
+ logger.warning("received interleaved PDU, resetting state")
+ self.command_pdu_assembler.reset()
+ self.receive_command_state = None
+ return
+ else:
+ self.receive_command_state.command_type = command.ctype
+ self.receive_command_state.transaction_label = transaction_label
+
+ self.command_pdu_assembler.on_pdu(command.vendor_dependent_data)
+ return
+
+ if isinstance(command, avc.PassThroughCommandFrame):
+ # TODO: delegate
+ response = avc.PassThroughResponseFrame(
+ avc.ResponseFrame.ResponseCode.ACCEPTED,
+ avc.Frame.SubunitType.PANEL,
+ 0,
+ command.state_flag,
+ command.operation_id,
+ command.operation_data,
+ )
+ self.send_response(transaction_label, response)
+ return
+
+ # TODO handle other types
+ self.send_not_implemented_response(transaction_label, command)
+
+ def _on_avctp_response(
+ self, transaction_label: int, response: Optional[avc.ResponseFrame]
+ ) -> None:
+ logger.debug(
+ f"<<< AVCTP Response, transaction_label={transaction_label}: {response}"
+ )
+
+ # Check that we have a pending command that matches this response.
+ if not (pending_command := self.pending_commands.get(transaction_label)):
+ logger.warning("no pending command with this transaction label")
+ return
+
+ # A None response means an invalid PID was used in the request.
+ if response is None:
+ pending_command.response.set_exception(self.InvalidPidError())
+
+ if isinstance(response, avc.VendorDependentResponseFrame):
+ if not self._check_vendor_dependent_frame(response):
+ return
+
+ if self.receive_response_state is None:
+ self.receive_response_state = self.ReceiveResponseState(
+ transaction_label=transaction_label, response_code=response.response
+ )
+ elif (
+ self.receive_response_state.transaction_label != transaction_label
+ or self.receive_response_state.response_code != response.response
+ ):
+ # We're in the middle of some other PDU
+ logger.warning("received interleaved PDU, resetting state")
+ self.response_pdu_assembler.reset()
+ self.receive_response_state = None
+ return
+ else:
+ self.receive_response_state.response_code = response.response
+ self.receive_response_state.transaction_label = transaction_label
+
+ self.response_pdu_assembler.on_pdu(response.vendor_dependent_data)
+ return
+
+ if isinstance(response, avc.PassThroughResponseFrame):
+ pending_command.response.set_result(response)
+
+ # TODO handle other types
+
+ self.recycle_pending_command(pending_command)
+
+ def _on_command_pdu(self, pdu_id: PduId, pdu: bytes) -> None:
+ logger.debug(f"<<< AVRCP command PDU [pdu_id={pdu_id.name}]: {pdu.hex()}")
+
+ assert self.receive_command_state is not None
+ transaction_label = self.receive_command_state.transaction_label
+
+ # Dispatch the command.
+ # NOTE: with a small number of supported commands, a manual dispatch like this
+ # is Ok, but if/when more commands are supported, a lookup dispatch mechanism
+ # would be more appropriate.
+ # TODO: switch on ctype
+ if self.receive_command_state.command_type in (
+ avc.CommandFrame.CommandType.CONTROL,
+ avc.CommandFrame.CommandType.STATUS,
+ avc.CommandFrame.CommandType.NOTIFY,
+ ):
+ # TODO: catch exceptions from delegates
+ if pdu_id == self.PduId.GET_CAPABILITIES:
+ self._on_get_capabilities_command(
+ transaction_label, GetCapabilitiesCommand.from_bytes(pdu)
+ )
+ elif pdu_id == self.PduId.SET_ABSOLUTE_VOLUME:
+ self._on_set_absolute_volume_command(
+ transaction_label, SetAbsoluteVolumeCommand.from_bytes(pdu)
+ )
+ elif pdu_id == self.PduId.REGISTER_NOTIFICATION:
+ self._on_register_notification_command(
+ transaction_label, RegisterNotificationCommand.from_bytes(pdu)
+ )
+ else:
+ # Not supported.
+ # TODO: check that this is the right way to respond in this case.
+ logger.debug("unsupported PDU ID")
+ self.send_rejected_avrcp_response(
+ transaction_label, pdu_id, self.StatusCode.INVALID_PARAMETER
+ )
+ else:
+ logger.debug("unsupported command type")
+ self.send_rejected_avrcp_response(
+ transaction_label, pdu_id, self.StatusCode.INVALID_COMMAND
+ )
+
+ self.receive_command_state = None
+
+ def _on_response_pdu(self, pdu_id: PduId, pdu: bytes) -> None:
+ logger.debug(f"<<< AVRCP response PDU [pdu_id={pdu_id.name}]: {pdu.hex()}")
+
+ assert self.receive_response_state is not None
+
+ transaction_label = self.receive_response_state.transaction_label
+ response_code = self.receive_response_state.response_code
+ self.receive_response_state = None
+
+ # Check that we have a pending command that matches this response.
+ if not (pending_command := self.pending_commands.get(transaction_label)):
+ logger.warning("no pending command with this transaction label")
+ return
+
+ # Convert the PDU bytes into a response object.
+ # NOTE: with a small number of supported responses, a manual switch like this
+ # is Ok, but if/when more responses are supported, a lookup mechanism would be
+ # more appropriate.
+ response: Optional[Response] = None
+ if response_code == avc.ResponseFrame.ResponseCode.REJECTED:
+ response = RejectedResponse.from_bytes(pdu_id, pdu)
+ elif response_code == avc.ResponseFrame.ResponseCode.NOT_IMPLEMENTED:
+ response = NotImplementedResponse.from_bytes(pdu_id, pdu)
+ elif response_code in (
+ avc.ResponseFrame.ResponseCode.IMPLEMENTED_OR_STABLE,
+ avc.ResponseFrame.ResponseCode.INTERIM,
+ avc.ResponseFrame.ResponseCode.CHANGED,
+ avc.ResponseFrame.ResponseCode.ACCEPTED,
+ ):
+ if pdu_id == self.PduId.GET_CAPABILITIES:
+ response = GetCapabilitiesResponse.from_bytes(pdu)
+ elif pdu_id == self.PduId.GET_PLAY_STATUS:
+ response = GetPlayStatusResponse.from_bytes(pdu)
+ elif pdu_id == self.PduId.GET_ELEMENT_ATTRIBUTES:
+ response = GetElementAttributesResponse.from_bytes(pdu)
+ elif pdu_id == self.PduId.SET_ABSOLUTE_VOLUME:
+ response = SetAbsoluteVolumeResponse.from_bytes(pdu)
+ elif pdu_id == self.PduId.REGISTER_NOTIFICATION:
+ response = RegisterNotificationResponse.from_bytes(pdu)
+ else:
+ logger.debug("unexpected PDU ID")
+ pending_command.response.set_exception(
+ ProtocolError(
+ error_code=None,
+ error_namespace="avrcp",
+ details="unexpected PDU ID",
+ )
+ )
+ else:
+ logger.debug("unexpected response code")
+ pending_command.response.set_exception(
+ ProtocolError(
+ error_code=None,
+ error_namespace="avrcp",
+ details="unexpected response code",
+ )
+ )
+
+ if response is None:
+ self.recycle_pending_command(pending_command)
+ return
+
+ logger.debug(f"<<< AVRCP response: {response}")
+
+ # Make the response available to the waiter.
+ if response_code == avc.ResponseFrame.ResponseCode.INTERIM:
+ pending_interim_response = pending_command.response
+ pending_command.reset()
+ pending_interim_response.set_result(
+ self.InterimResponse(
+ pending_command.transaction_label,
+ response,
+ pending_command.response,
+ )
+ )
+ else:
+ pending_command.response.set_result(
+ self.FinalResponse(
+ pending_command.transaction_label,
+ response,
+ response_code,
+ )
+ )
+ self.recycle_pending_command(pending_command)
+
+ def send_command(self, transaction_label: int, command: avc.CommandFrame) -> None:
+ logger.debug(f">>> AVRCP command: {command}")
+
+ if self.avctp_protocol is None:
+ logger.warning("trying to send command while avctp_protocol is None")
+ return
+
+ self.avctp_protocol.send_command(transaction_label, AVRCP_PID, bytes(command))
+
+ async def send_passthrough_command(
+ self, command: avc.PassThroughCommandFrame
+ ) -> avc.PassThroughResponseFrame:
+ # Wait for a free command slot.
+ pending_command = await self._obtain_pending_command()
+
+ # Send the command.
+ self.send_command(pending_command.transaction_label, command)
+
+ # Wait for the response.
+ return await pending_command.response
+
+ async def send_key_event(
+ self, key: avc.PassThroughCommandFrame.OperationId, pressed: bool
+ ) -> avc.PassThroughResponseFrame:
+ """Send a key event to the connected peer."""
+ return await self.send_passthrough_command(
+ avc.PassThroughCommandFrame(
+ avc.CommandFrame.CommandType.CONTROL,
+ avc.Frame.SubunitType.PANEL,
+ 0,
+ avc.PassThroughFrame.StateFlag.PRESSED
+ if pressed
+ else avc.PassThroughFrame.StateFlag.RELEASED,
+ key,
+ b'',
+ )
+ )
+
+ async def send_avrcp_command(
+ self, command_type: avc.CommandFrame.CommandType, command: Command
+ ) -> ResponseContext:
+ # Wait for a free command slot.
+ pending_command = await self._obtain_pending_command()
+
+ # TODO: fragmentation
+ # Send the command.
+ logger.debug(f">>> AVRCP command PDU: {command}")
+ pdu = (
+ struct.pack(">BBH", command.pdu_id, 0, len(command.parameter))
+ + command.parameter
+ )
+ command_frame = avc.VendorDependentCommandFrame(
+ command_type,
+ avc.Frame.SubunitType.PANEL,
+ 0,
+ AVRCP_BLUETOOTH_SIG_COMPANY_ID,
+ pdu,
+ )
+ self.send_command(pending_command.transaction_label, command_frame)
+
+ # Wait for the response.
+ return await pending_command.response
+
+ def send_response(
+ self, transaction_label: int, response: avc.ResponseFrame
+ ) -> None:
+ assert self.avctp_protocol is not None
+ logger.debug(f">>> AVRCP response: {response}")
+ self.avctp_protocol.send_response(transaction_label, AVRCP_PID, bytes(response))
+
+ def send_passthrough_response(
+ self,
+ transaction_label: int,
+ command: avc.PassThroughCommandFrame,
+ response_code: avc.ResponseFrame.ResponseCode,
+ ):
+ response = avc.PassThroughResponseFrame(
+ response_code,
+ avc.Frame.SubunitType.PANEL,
+ 0,
+ command.state_flag,
+ command.operation_id,
+ command.operation_data,
+ )
+ self.send_response(transaction_label, response)
+
+ def send_avrcp_response(
+ self,
+ transaction_label: int,
+ response_code: avc.ResponseFrame.ResponseCode,
+ response: Response,
+ ) -> None:
+ # TODO: fragmentation
+ logger.debug(f">>> AVRCP response PDU: {response}")
+ pdu = (
+ struct.pack(">BBH", response.pdu_id, 0, len(response.parameter))
+ + response.parameter
+ )
+ response_frame = avc.VendorDependentResponseFrame(
+ response_code,
+ avc.Frame.SubunitType.PANEL,
+ 0,
+ AVRCP_BLUETOOTH_SIG_COMPANY_ID,
+ pdu,
+ )
+ self.send_response(transaction_label, response_frame)
+
+ def send_not_implemented_response(
+ self, transaction_label: int, command: avc.CommandFrame
+ ) -> None:
+ response = avc.ResponseFrame(
+ avc.ResponseFrame.ResponseCode.NOT_IMPLEMENTED,
+ command.subunit_type,
+ command.subunit_id,
+ command.opcode,
+ command.operands,
+ )
+ self.send_response(transaction_label, response)
+
+ def send_rejected_avrcp_response(
+ self, transaction_label: int, pdu_id: Protocol.PduId, status_code: StatusCode
+ ) -> None:
+ self.send_avrcp_response(
+ transaction_label,
+ avc.ResponseFrame.ResponseCode.REJECTED,
+ RejectedResponse(pdu_id, status_code),
+ )
+
+ def _on_get_capabilities_command(
+ self, transaction_label: int, command: GetCapabilitiesCommand
+ ) -> None:
+ logger.debug(f"<<< AVRCP command PDU: {command}")
+
+ async def get_supported_events():
+ if (
+ command.capability_id
+ != GetCapabilitiesCommand.CapabilityId.EVENTS_SUPPORTED
+ ):
+ raise Protocol.InvalidParameterError
+
+ supported_events = await self.delegate.get_supported_events()
+ self.send_avrcp_response(
+ transaction_label,
+ avc.ResponseFrame.ResponseCode.IMPLEMENTED_OR_STABLE,
+ GetCapabilitiesResponse(command.capability_id, supported_events),
+ )
+
+ self._delegate_command(transaction_label, command, get_supported_events())
+
+ def _on_set_absolute_volume_command(
+ self, transaction_label: int, command: SetAbsoluteVolumeCommand
+ ) -> None:
+ logger.debug(f"<<< AVRCP command PDU: {command}")
+
+ async def set_absolute_volume():
+ await self.delegate.set_absolute_volume(command.volume)
+ effective_volume = await self.delegate.get_absolute_volume()
+ self.send_avrcp_response(
+ transaction_label,
+ avc.ResponseFrame.ResponseCode.IMPLEMENTED_OR_STABLE,
+ SetAbsoluteVolumeResponse(effective_volume),
+ )
+
+ self._delegate_command(transaction_label, command, set_absolute_volume())
+
+ def _on_register_notification_command(
+ self, transaction_label: int, command: RegisterNotificationCommand
+ ) -> None:
+ logger.debug(f"<<< AVRCP command PDU: {command}")
+
+ async def register_notification():
+ # Check if the event is supported.
+ supported_events = await self.delegate.get_supported_events()
+ if command.event_id in supported_events:
+ if command.event_id == EventId.VOLUME_CHANGED:
+ volume = await self.delegate.get_absolute_volume()
+ response = RegisterNotificationResponse(VolumeChangedEvent(volume))
+ self.send_avrcp_response(
+ transaction_label,
+ avc.ResponseFrame.ResponseCode.INTERIM,
+ response,
+ )
+ self._register_notification_listener(transaction_label, command)
+ return
+
+ if command.event_id == EventId.PLAYBACK_STATUS_CHANGED:
+ # TODO: testing only, use delegate
+ response = RegisterNotificationResponse(
+ PlaybackStatusChangedEvent(play_status=PlayStatus.PLAYING)
+ )
+ self.send_avrcp_response(
+ transaction_label,
+ avc.ResponseFrame.ResponseCode.INTERIM,
+ response,
+ )
+ self._register_notification_listener(transaction_label, command)
+ return
+
+ self._delegate_command(transaction_label, command, register_notification())
diff --git a/bumble/controller.py b/bumble/controller.py
index 9b2960a..eb20292 100644
--- a/bumble/controller.py
+++ b/bumble/controller.py
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging
import asyncio
+import dataclasses
import itertools
import random
import struct
@@ -42,6 +43,7 @@ from bumble.hci import (
HCI_LE_1M_PHY,
HCI_SUCCESS,
HCI_UNKNOWN_HCI_COMMAND_ERROR,
+ HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
HCI_VERSION_BLUETOOTH_CORE_5_0,
Address,
@@ -53,17 +55,21 @@ from bumble.hci import (
HCI_Connection_Request_Event,
HCI_Disconnection_Complete_Event,
HCI_Encryption_Change_Event,
+ HCI_Synchronous_Connection_Complete_Event,
HCI_LE_Advertising_Report_Event,
+ HCI_LE_CIS_Established_Event,
+ HCI_LE_CIS_Request_Event,
HCI_LE_Connection_Complete_Event,
HCI_LE_Read_Remote_Features_Complete_Event,
HCI_Number_Of_Completed_Packets_Event,
HCI_Packet,
HCI_Role_Change_Event,
)
-from typing import Optional, Union, Dict, TYPE_CHECKING
+from typing import Optional, Union, Dict, Any, TYPE_CHECKING
if TYPE_CHECKING:
- from bumble.transport.common import TransportSink, TransportSource
+ from bumble.link import LocalLink
+ from bumble.transport.common import TransportSink
# -----------------------------------------------------------------------------
# Logging
@@ -79,15 +85,27 @@ class DataObject:
# -----------------------------------------------------------------------------
+@dataclasses.dataclass
+class CisLink:
+ handle: int
+ cis_id: int
+ cig_id: int
+ acl_connection: Optional[Connection] = None
+
+
+# -----------------------------------------------------------------------------
+@dataclasses.dataclass
class Connection:
- def __init__(self, controller, handle, role, peer_address, link, transport):
- self.controller = controller
- self.handle = handle
- self.role = role
- self.peer_address = peer_address
- self.link = link
+ controller: Controller
+ handle: int
+ role: int
+ peer_address: Address
+ link: Any
+ transport: int
+ link_type: int
+
+ def __post_init__(self):
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
- self.transport = transport
def on_hci_acl_data_packet(self, packet):
self.assembler.feed_packet(packet)
@@ -106,10 +124,10 @@ class Connection:
class Controller:
def __init__(
self,
- name,
+ name: str,
host_source=None,
host_sink: Optional[TransportSink] = None,
- link=None,
+ link: Optional[LocalLink] = None,
public_address: Optional[Union[bytes, str, Address]] = None,
):
self.name = name
@@ -125,6 +143,8 @@ class Controller:
self.classic_connections: Dict[
Address, Connection
] = {} # Connections in BR/EDR
+ self.central_cis_links: Dict[int, CisLink] = {} # CIS links by handle
+ self.peripheral_cis_links: Dict[int, CisLink] = {} # CIS links by handle
self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
self.hci_revision = 0
@@ -134,12 +154,14 @@ class Controller:
'0000000060000000'
) # BR/EDR Not Supported, LE Supported (Controller)
self.manufacturer_name = 0xFFFF
+ self.hc_data_packet_length = 27
+ self.hc_total_num_data_packets = 64
self.hc_le_data_packet_length = 27
self.hc_total_num_le_data_packets = 64
self.event_mask = 0
self.event_mask_page_2 = 0
self.supported_commands = bytes.fromhex(
- '2000800000c000000000e40000002822000000000000040000f7ffff7f000000'
+ '2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
'30f0f9ff01008004000000000000000000000000000000000000000000000000'
)
self.le_event_mask = 0
@@ -301,7 +323,7 @@ class Controller:
############################################################
# Link connections
############################################################
- def allocate_connection_handle(self):
+ def allocate_connection_handle(self) -> int:
handle = 0
max_handle = 0
for connection in itertools.chain(
@@ -313,6 +335,13 @@ class Controller:
if connection.handle == handle:
# Already used, continue searching after the current max
handle = max_handle + 1
+ for cis_handle in itertools.chain(
+ self.central_cis_links.keys(), self.peripheral_cis_links.keys()
+ ):
+ max_handle = max(max_handle, cis_handle)
+ if cis_handle == handle:
+ # Already used, continue searching after the current max
+ handle = max_handle + 1
return handle
def find_le_connection_by_address(self, address):
@@ -357,12 +386,13 @@ class Controller:
if connection is None:
connection_handle = self.allocate_connection_handle()
connection = Connection(
- self,
- connection_handle,
- BT_PERIPHERAL_ROLE,
- peer_address,
- self.link,
- BT_LE_TRANSPORT,
+ controller=self,
+ handle=connection_handle,
+ role=BT_PERIPHERAL_ROLE,
+ peer_address=peer_address,
+ link=self.link,
+ transport=BT_LE_TRANSPORT,
+ link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
)
self.peripheral_connections[peer_address] = connection
logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}')
@@ -416,12 +446,13 @@ class Controller:
if connection is None:
connection_handle = self.allocate_connection_handle()
connection = Connection(
- self,
- connection_handle,
- BT_CENTRAL_ROLE,
- peer_address,
- self.link,
- BT_LE_TRANSPORT,
+ controller=self,
+ handle=connection_handle,
+ role=BT_CENTRAL_ROLE,
+ peer_address=peer_address,
+ link=self.link,
+ transport=BT_LE_TRANSPORT,
+ link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
)
self.central_connections[peer_address] = connection
logger.debug(
@@ -538,6 +569,104 @@ class Controller:
)
self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
+ def on_link_cis_request(
+ self, central_address: Address, cig_id: int, cis_id: int
+ ) -> None:
+ '''
+ Called when an incoming CIS request occurs from a central on the link
+ '''
+
+ connection = self.peripheral_connections.get(central_address)
+ assert connection
+
+ pending_cis_link = CisLink(
+ handle=self.allocate_connection_handle(),
+ cis_id=cis_id,
+ cig_id=cig_id,
+ acl_connection=connection,
+ )
+ self.peripheral_cis_links[pending_cis_link.handle] = pending_cis_link
+
+ self.send_hci_packet(
+ HCI_LE_CIS_Request_Event(
+ acl_connection_handle=connection.handle,
+ cis_connection_handle=pending_cis_link.handle,
+ cig_id=cig_id,
+ cis_id=cis_id,
+ )
+ )
+
+ def on_link_cis_established(self, cig_id: int, cis_id: int) -> None:
+ '''
+ Called when an incoming CIS established.
+ '''
+
+ cis_link = next(
+ cis_link
+ for cis_link in itertools.chain(
+ self.central_cis_links.values(), self.peripheral_cis_links.values()
+ )
+ if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
+ )
+
+ self.send_hci_packet(
+ HCI_LE_CIS_Established_Event(
+ status=HCI_SUCCESS,
+ connection_handle=cis_link.handle,
+ # CIS parameters are ignored.
+ cig_sync_delay=0,
+ cis_sync_delay=0,
+ transport_latency_c_to_p=0,
+ transport_latency_p_to_c=0,
+ phy_c_to_p=0,
+ phy_p_to_c=0,
+ nse=0,
+ bn_c_to_p=0,
+ bn_p_to_c=0,
+ ft_c_to_p=0,
+ ft_p_to_c=0,
+ max_pdu_c_to_p=0,
+ max_pdu_p_to_c=0,
+ iso_interval=0,
+ )
+ )
+
+ def on_link_cis_disconnected(self, cig_id: int, cis_id: int) -> None:
+ '''
+ Called when a CIS disconnected.
+ '''
+
+ if cis_link := next(
+ (
+ cis_link
+ for cis_link in self.peripheral_cis_links.values()
+ if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
+ ),
+ None,
+ ):
+ # Remove peripheral CIS on disconnection.
+ self.peripheral_cis_links.pop(cis_link.handle)
+ elif cis_link := next(
+ (
+ cis_link
+ for cis_link in self.central_cis_links.values()
+ if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
+ ),
+ None,
+ ):
+ # Keep central CIS on disconnection. They should be removed by HCI_LE_Remove_CIG_Command.
+ cis_link.acl_connection = None
+ else:
+ return
+
+ self.send_hci_packet(
+ HCI_Disconnection_Complete_Event(
+ status=HCI_SUCCESS,
+ connection_handle=cis_link.handle,
+ reason=HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
+ )
+ )
+
############################################################
# Classic link connections
############################################################
@@ -566,6 +695,7 @@ class Controller:
peer_address=peer_address,
link=self.link,
transport=BT_BR_EDR_TRANSPORT,
+ link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
)
self.classic_connections[peer_address] = connection
logger.debug(
@@ -619,6 +749,42 @@ class Controller:
)
)
+ def on_classic_sco_connection_complete(
+ self, peer_address: Address, status: int, link_type: int
+ ):
+ if status == HCI_SUCCESS:
+ # Allocate (or reuse) a connection handle
+ connection_handle = self.allocate_connection_handle()
+ connection = Connection(
+ controller=self,
+ handle=connection_handle,
+ # Role doesn't matter in SCO.
+ role=BT_CENTRAL_ROLE,
+ peer_address=peer_address,
+ link=self.link,
+ transport=BT_BR_EDR_TRANSPORT,
+ link_type=link_type,
+ )
+ self.classic_connections[peer_address] = connection
+ logger.debug(f'New SCO connection handle: 0x{connection_handle:04X}')
+ else:
+ connection_handle = 0
+
+ self.send_hci_packet(
+ HCI_Synchronous_Connection_Complete_Event(
+ status=status,
+ connection_handle=connection_handle,
+ bd_addr=peer_address,
+ link_type=link_type,
+ # TODO: Provide SCO connection parameters.
+ transmission_interval=0,
+ retransmission_window=0,
+ rx_packet_length=0,
+ tx_packet_length=0,
+ air_mode=0,
+ )
+ )
+
############################################################
# Advertising support
############################################################
@@ -721,6 +887,17 @@ class Controller:
else:
# Remove the connection
del self.classic_connections[connection.peer_address]
+ elif cis_link := (
+ self.central_cis_links.get(handle) or self.peripheral_cis_links.get(handle)
+ ):
+ if self.link:
+ self.link.disconnect_cis(
+ initiator_controller=self,
+ peer_address=cis_link.acl_connection.peer_address,
+ cig_id=cis_link.cig_id,
+ cis_id=cis_link.cis_id,
+ )
+ # Spec requires handle to be kept after disconnection.
def on_hci_accept_connection_request_command(self, command):
'''
@@ -738,6 +915,68 @@ class Controller:
)
self.link.classic_accept_connection(self, command.bd_addr, command.role)
+ def on_hci_enhanced_setup_synchronous_connection_command(self, command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.1.45 Enhanced Setup Synchronous Connection command
+ '''
+
+ if self.link is None:
+ return
+
+ if not (
+ connection := self.find_classic_connection_by_handle(
+ command.connection_handle
+ )
+ ):
+ self.send_hci_packet(
+ HCI_Command_Status_Event(
+ status=HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
+ num_hci_command_packets=1,
+ command_opcode=command.op_code,
+ )
+ )
+ return
+
+ self.send_hci_packet(
+ HCI_Command_Status_Event(
+ status=HCI_SUCCESS,
+ num_hci_command_packets=1,
+ command_opcode=command.op_code,
+ )
+ )
+ self.link.classic_sco_connect(
+ self, connection.peer_address, HCI_Connection_Complete_Event.ESCO_LINK_TYPE
+ )
+
+ def on_hci_enhanced_accept_synchronous_connection_request_command(self, command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.1.46 Enhanced Accept Synchronous Connection Request command
+ '''
+
+ if self.link is None:
+ return
+
+ if not (connection := self.find_classic_connection_by_address(command.bd_addr)):
+ self.send_hci_packet(
+ HCI_Command_Status_Event(
+ status=HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
+ num_hci_command_packets=1,
+ command_opcode=command.op_code,
+ )
+ )
+ return
+
+ self.send_hci_packet(
+ HCI_Command_Status_Event(
+ status=HCI_SUCCESS,
+ num_hci_command_packets=1,
+ command_opcode=command.op_code,
+ )
+ )
+ self.link.classic_accept_sco_connection(
+ self, connection.peer_address, HCI_Connection_Complete_Event.ESCO_LINK_TYPE
+ )
+
def on_hci_switch_role_command(self, command):
'''
See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
@@ -912,7 +1151,41 @@ class Controller:
'''
See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
'''
- return bytes([HCI_SUCCESS]) + self.lmp_features
+ return bytes([HCI_SUCCESS]) + self.lmp_features[:8]
+
+ def on_hci_read_local_extended_features_command(self, command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.4.4 Read Local Extended Features Command
+ '''
+ if command.page_number * 8 > len(self.lmp_features):
+ return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
+ return (
+ bytes(
+ [
+ # Status
+ HCI_SUCCESS,
+ # Page number
+ command.page_number,
+ # Max page number
+ len(self.lmp_features) // 8 - 1,
+ ]
+ )
+ # Features of the current page
+ + self.lmp_features[command.page_number * 8 : (command.page_number + 1) * 8]
+ )
+
+ def on_hci_read_buffer_size_command(self, _command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.4.5 Read Buffer Size Command
+ '''
+ return struct.pack(
+ '<BHBHH',
+ HCI_SUCCESS,
+ self.hc_data_packet_length,
+ 0,
+ self.hc_total_num_data_packets,
+ 0,
+ )
def on_hci_read_bd_addr_command(self, _command):
'''
@@ -1000,6 +1273,9 @@ class Controller:
'''
See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command
'''
+ if self.le_scan_enable:
+ return bytes([HCI_COMMAND_DISALLOWED_ERROR])
+
self.le_scan_type = command.le_scan_type
self.le_scan_interval = command.le_scan_interval
self.le_scan_window = command.le_scan_window
@@ -1086,6 +1362,18 @@ class Controller:
See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
'''
+ handle = command.connection_handle
+
+ if not self.find_connection_by_handle(handle):
+ self.send_hci_packet(
+ HCI_Command_Status_Event(
+ status=HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR,
+ num_hci_command_packets=1,
+ command_opcode=command.op_code,
+ )
+ )
+ return
+
# First, say that the command is pending
self.send_hci_packet(
HCI_Command_Status_Event(
@@ -1099,7 +1387,7 @@ class Controller:
self.send_hci_packet(
HCI_LE_Read_Remote_Features_Complete_Event(
status=HCI_SUCCESS,
- connection_handle=0,
+ connection_handle=handle,
le_features=bytes.fromhex('dd40000000000000'),
)
)
@@ -1255,8 +1543,135 @@ class Controller:
}
return bytes([HCI_SUCCESS])
+ def on_hci_le_read_maximum_advertising_data_length_command(self, _command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.8.57 LE Read Maximum Advertising Data
+ Length Command
+ '''
+ return struct.pack('<BH', HCI_SUCCESS, 0x0672)
+
+ def on_hci_le_read_number_of_supported_advertising_sets_command(self, _command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.8.58 LE Read Number of Supported
+ Advertising Set Command
+ '''
+ return struct.pack('<BB', HCI_SUCCESS, 0xF0)
+
def on_hci_le_read_transmit_power_command(self, _command):
'''
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
'''
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
+
+ def on_hci_le_set_cig_parameters_command(self, command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.8.97 LE Set CIG Parameter Command
+ '''
+
+ # Remove old CIG implicitly.
+ for handle, cis_link in self.central_cis_links.items():
+ if cis_link.cig_id == command.cig_id:
+ self.central_cis_links.pop(handle)
+
+ handles = []
+ for cis_id in command.cis_id:
+ handle = self.allocate_connection_handle()
+ handles.append(handle)
+ self.central_cis_links[handle] = CisLink(
+ cis_id=cis_id,
+ cig_id=command.cig_id,
+ handle=handle,
+ )
+ return struct.pack(
+ '<BBB', HCI_SUCCESS, command.cig_id, len(handles)
+ ) + b''.join([struct.pack('<H', handle) for handle in handles])
+
+ def on_hci_le_create_cis_command(self, command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.8.99 LE Create CIS Command
+ '''
+ if not self.link:
+ return
+
+ for cis_handle, acl_handle in zip(
+ command.cis_connection_handle, command.acl_connection_handle
+ ):
+ if not (connection := self.find_connection_by_handle(acl_handle)):
+ logger.error(f'Cannot find connection with handle={acl_handle}')
+ return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
+
+ if not (cis_link := self.central_cis_links.get(cis_handle)):
+ logger.error(f'Cannot find CIS with handle={cis_handle}')
+ return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
+
+ cis_link.acl_connection = connection
+
+ self.link.create_cis(
+ self,
+ peripheral_address=connection.peer_address,
+ cig_id=cis_link.cig_id,
+ cis_id=cis_link.cis_id,
+ )
+
+ self.send_hci_packet(
+ HCI_Command_Status_Event(
+ status=HCI_COMMAND_STATUS_PENDING,
+ num_hci_command_packets=1,
+ command_opcode=command.op_code,
+ )
+ )
+
+ def on_hci_le_remove_cig_command(self, command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.8.100 LE Remove CIG Command
+ '''
+
+ status = HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR
+
+ for cis_handle, cis_link in self.central_cis_links.items():
+ if cis_link.cig_id == command.cig_id:
+ self.central_cis_links.pop(cis_handle)
+ status = HCI_SUCCESS
+
+ return struct.pack('<BH', status, command.cig_id)
+
+ def on_hci_le_accept_cis_request_command(self, command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.8.101 LE Accept CIS Request Command
+ '''
+ if not self.link:
+ return
+
+ if not (
+ pending_cis_link := self.peripheral_cis_links.get(command.connection_handle)
+ ):
+ logger.error(f'Cannot find CIS with handle={command.connection_handle}')
+ return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
+
+ assert pending_cis_link.acl_connection
+ self.link.accept_cis(
+ peripheral_controller=self,
+ central_address=pending_cis_link.acl_connection.peer_address,
+ cig_id=pending_cis_link.cig_id,
+ cis_id=pending_cis_link.cis_id,
+ )
+
+ self.send_hci_packet(
+ HCI_Command_Status_Event(
+ status=HCI_COMMAND_STATUS_PENDING,
+ num_hci_command_packets=1,
+ command_opcode=command.op_code,
+ )
+ )
+
+ def on_hci_le_setup_iso_data_path_command(self, command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.8.109 LE Setup ISO Data Path Command
+ '''
+ return struct.pack('<BH', HCI_SUCCESS, command.connection_handle)
+
+ def on_hci_le_remove_iso_data_path_command(self, command):
+ '''
+ See Bluetooth spec Vol 4, Part E - 7.8.110 LE Remove ISO Data Path Command
+ '''
+ return struct.pack('<BH', HCI_SUCCESS, command.connection_handle)
diff --git a/bumble/core.py b/bumble/core.py
index 4a67d6e..dce721a 100644
--- a/bumble/core.py
+++ b/bumble/core.py
@@ -16,6 +16,7 @@
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
+import enum
import struct
from typing import List, Optional, Tuple, Union, cast, Dict
@@ -96,12 +97,16 @@ class BaseError(Exception):
namespace = f'{self.error_namespace}/'
else:
namespace = ''
- error_text = {
- (True, True): f'{self.error_name} [0x{self.error_code:X}]',
- (True, False): self.error_name,
- (False, True): f'0x{self.error_code:X}',
- (False, False): '',
- }[(self.error_name != '', self.error_code is not None)]
+ have_name = self.error_name != ''
+ have_code = self.error_code is not None
+ if have_name and have_code:
+ error_text = f'{self.error_name} [0x{self.error_code:X}]'
+ elif have_name and not have_code:
+ error_text = self.error_name
+ elif not have_name and have_code:
+ error_text = f'0x{self.error_code:X}'
+ else:
+ error_text = '<unspecified>'
return f'{type(self).__name__}({namespace}{error_text})'
@@ -318,7 +323,7 @@ BT_HIDP_PROTOCOL_ID = UUID.from_16_bits(0x0011, 'HIDP')
BT_HARDCOPY_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0012, 'HardcopyControlChannel')
BT_HARDCOPY_DATA_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0014, 'HardcopyDataChannel')
BT_HARDCOPY_NOTIFICATION_PROTOCOL_ID = UUID.from_16_bits(0x0016, 'HardcopyNotification')
-BT_AVTCP_PROTOCOL_ID = UUID.from_16_bits(0x0017, 'AVCTP')
+BT_AVCTP_PROTOCOL_ID = UUID.from_16_bits(0x0017, 'AVCTP')
BT_AVDTP_PROTOCOL_ID = UUID.from_16_bits(0x0019, 'AVDTP')
BT_CMTP_PROTOCOL_ID = UUID.from_16_bits(0x001B, 'CMTP')
BT_MCAP_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x001E, 'MCAPControlChannel')
@@ -820,8 +825,8 @@ class AdvertisingData:
ad_structures = []
self.ad_structures = ad_structures[:]
- @staticmethod
- def from_bytes(data):
+ @classmethod
+ def from_bytes(cls, data: bytes) -> AdvertisingData:
instance = AdvertisingData()
instance.append(data)
return instance
@@ -977,7 +982,7 @@ class AdvertisingData:
return ad_data
- def append(self, data):
+ def append(self, data: bytes) -> None:
offset = 0
while offset + 1 < len(data):
length = data[offset]
@@ -1051,3 +1056,13 @@ class ConnectionPHY:
def __str__(self):
return f'ConnectionPHY(tx_phy={self.tx_phy}, rx_phy={self.rx_phy})'
+
+
+# -----------------------------------------------------------------------------
+# LE Role
+# -----------------------------------------------------------------------------
+class LeRole(enum.IntEnum):
+ PERIPHERAL_ONLY = 0x00
+ CENTRAL_ONLY = 0x01
+ BOTH_PERIPHERAL_PREFERRED = 0x02
+ BOTH_CENTRAL_PREFERRED = 0x03
diff --git a/bumble/crypto.py b/bumble/crypto.py
index 852c675..af95160 100644
--- a/bumble/crypto.py
+++ b/bumble/crypto.py
@@ -21,6 +21,8 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
+
import logging
import operator
@@ -29,11 +31,13 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.asymmetric.ec import (
generate_private_key,
ECDH,
+ EllipticCurvePrivateKey,
EllipticCurvePublicNumbers,
EllipticCurvePrivateNumbers,
SECP256R1,
)
from cryptography.hazmat.primitives import cmac
+from typing import Tuple
# -----------------------------------------------------------------------------
@@ -46,16 +50,18 @@ logger = logging.getLogger(__name__)
# Classes
# -----------------------------------------------------------------------------
class EccKey:
- def __init__(self, private_key):
+ def __init__(self, private_key: EllipticCurvePrivateKey) -> None:
self.private_key = private_key
@classmethod
- def generate(cls):
+ def generate(cls) -> EccKey:
private_key = generate_private_key(SECP256R1())
return cls(private_key)
@classmethod
- def from_private_key_bytes(cls, d_bytes, x_bytes, y_bytes):
+ def from_private_key_bytes(
+ cls, d_bytes: bytes, x_bytes: bytes, y_bytes: bytes
+ ) -> EccKey:
d = int.from_bytes(d_bytes, byteorder='big', signed=False)
x = int.from_bytes(x_bytes, byteorder='big', signed=False)
y = int.from_bytes(y_bytes, byteorder='big', signed=False)
@@ -65,7 +71,7 @@ class EccKey:
return cls(private_key)
@property
- def x(self):
+ def x(self) -> bytes:
return (
self.private_key.public_key()
.public_numbers()
@@ -73,14 +79,14 @@ class EccKey:
)
@property
- def y(self):
+ def y(self) -> bytes:
return (
self.private_key.public_key()
.public_numbers()
.y.to_bytes(32, byteorder='big')
)
- def dh(self, public_key_x, public_key_y):
+ def dh(self, public_key_x: bytes, public_key_y: bytes) -> bytes:
x = int.from_bytes(public_key_x, byteorder='big', signed=False)
y = int.from_bytes(public_key_y, byteorder='big', signed=False)
public_key = EllipticCurvePublicNumbers(x, y, SECP256R1()).public_key()
@@ -93,14 +99,33 @@ class EccKey:
# Functions
# -----------------------------------------------------------------------------
+
+# -----------------------------------------------------------------------------
+def generate_prand() -> bytes:
+ '''Generates random 3 bytes, with the 2 most significant bits of 0b01.
+
+ See Bluetooth spec, Vol 6, Part E - Table 1.2.
+ '''
+ prand_bytes = secrets.token_bytes(6)
+ return prand_bytes[:2] + bytes([(prand_bytes[2] & 0b01111111) | 0b01000000])
+
+
# -----------------------------------------------------------------------------
-def xor(x, y):
+def xor(x: bytes, y: bytes) -> bytes:
assert len(x) == len(y)
return bytes(map(operator.xor, x, y))
# -----------------------------------------------------------------------------
-def r():
+def reverse(input: bytes) -> bytes:
+ '''
+ Returns bytes of input in reversed endianness.
+ '''
+ return input[::-1]
+
+
+# -----------------------------------------------------------------------------
+def r() -> bytes:
'''
Generate 16 bytes of random data
'''
@@ -108,20 +133,20 @@ def r():
# -----------------------------------------------------------------------------
-def e(key, data):
+def e(key: bytes, data: bytes) -> bytes:
'''
AES-128 ECB, expecting byte-swapped inputs and producing a byte-swapped output.
See Bluetooth spec Vol 3, Part H - 2.2.1 Security function e
'''
- cipher = Cipher(algorithms.AES(bytes(reversed(key))), modes.ECB())
+ cipher = Cipher(algorithms.AES(reverse(key)), modes.ECB())
encryptor = cipher.encryptor()
- return bytes(reversed(encryptor.update(bytes(reversed(data)))))
+ return reverse(encryptor.update(reverse(data)))
# -----------------------------------------------------------------------------
-def ah(k, r): # pylint: disable=redefined-outer-name
+def ah(k: bytes, r: bytes) -> bytes: # pylint: disable=redefined-outer-name
'''
See Bluetooth spec Vol 3, Part H - 2.2.2 Random Address Hash function ah
'''
@@ -132,7 +157,16 @@ def ah(k, r): # pylint: disable=redefined-outer-name
# -----------------------------------------------------------------------------
-def c1(k, r, preq, pres, iat, rat, ia, ra): # pylint: disable=redefined-outer-name
+def c1(
+ k: bytes,
+ r: bytes,
+ preq: bytes,
+ pres: bytes,
+ iat: int,
+ rat: int,
+ ia: bytes,
+ ra: bytes,
+) -> bytes: # pylint: disable=redefined-outer-name
'''
See Bluetooth spec, Vol 3, Part H - 2.2.3 Confirm value generation function c1 for
LE Legacy Pairing
@@ -144,7 +178,7 @@ def c1(k, r, preq, pres, iat, rat, ia, ra): # pylint: disable=redefined-outer-n
# -----------------------------------------------------------------------------
-def s1(k, r1, r2):
+def s1(k: bytes, r1: bytes, r2: bytes) -> bytes:
'''
See Bluetooth spec, Vol 3, Part H - 2.2.4 Key generation function s1 for LE Legacy
Pairing
@@ -154,7 +188,7 @@ def s1(k, r1, r2):
# -----------------------------------------------------------------------------
-def aes_cmac(m, k):
+def aes_cmac(m: bytes, k: bytes) -> bytes:
'''
See Bluetooth spec, Vol 3, Part H - 2.2.5 FunctionAES-CMAC
@@ -166,20 +200,16 @@ def aes_cmac(m, k):
# -----------------------------------------------------------------------------
-def f4(u, v, x, z):
+def f4(u: bytes, v: bytes, x: bytes, z: bytes) -> bytes:
'''
See Bluetooth spec, Vol 3, Part H - 2.2.6 LE Secure Connections Confirm Value
Generation Function f4
'''
- return bytes(
- reversed(
- aes_cmac(bytes(reversed(u)) + bytes(reversed(v)) + z, bytes(reversed(x)))
- )
- )
+ return reverse(aes_cmac(reverse(u) + reverse(v) + z, reverse(x)))
# -----------------------------------------------------------------------------
-def f5(w, n1, n2, a1, a2):
+def f5(w: bytes, n1: bytes, n2: bytes, a1: bytes, a2: bytes) -> Tuple[bytes, bytes]:
'''
See Bluetooth spec, Vol 3, Part H - 2.2.7 LE Secure Connections Key Generation
Function f5
@@ -187,87 +217,83 @@ def f5(w, n1, n2, a1, a2):
NOTE: this returns a tuple: (MacKey, LTK) in little-endian byte order
'''
salt = bytes.fromhex('6C888391AAF5A53860370BDB5A6083BE')
- t = aes_cmac(bytes(reversed(w)), salt)
+ t = aes_cmac(reverse(w), salt)
key_id = bytes([0x62, 0x74, 0x6C, 0x65])
return (
- bytes(
- reversed(
- aes_cmac(
- bytes([0])
- + key_id
- + bytes(reversed(n1))
- + bytes(reversed(n2))
- + bytes(reversed(a1))
- + bytes(reversed(a2))
- + bytes([1, 0]),
- t,
- )
+ reverse(
+ aes_cmac(
+ bytes([0])
+ + key_id
+ + reverse(n1)
+ + reverse(n2)
+ + reverse(a1)
+ + reverse(a2)
+ + bytes([1, 0]),
+ t,
)
),
- bytes(
- reversed(
- aes_cmac(
- bytes([1])
- + key_id
- + bytes(reversed(n1))
- + bytes(reversed(n2))
- + bytes(reversed(a1))
- + bytes(reversed(a2))
- + bytes([1, 0]),
- t,
- )
+ reverse(
+ aes_cmac(
+ bytes([1])
+ + key_id
+ + reverse(n1)
+ + reverse(n2)
+ + reverse(a1)
+ + reverse(a2)
+ + bytes([1, 0]),
+ t,
)
),
)
# -----------------------------------------------------------------------------
-def f6(w, n1, n2, r, io_cap, a1, a2): # pylint: disable=redefined-outer-name
+def f6(
+ w: bytes, n1: bytes, n2: bytes, r: bytes, io_cap: bytes, a1: bytes, a2: bytes
+) -> bytes: # pylint: disable=redefined-outer-name
'''
See Bluetooth spec, Vol 3, Part H - 2.2.8 LE Secure Connections Check Value
Generation Function f6
'''
- return bytes(
- reversed(
- aes_cmac(
- bytes(reversed(n1))
- + bytes(reversed(n2))
- + bytes(reversed(r))
- + bytes(reversed(io_cap))
- + bytes(reversed(a1))
- + bytes(reversed(a2)),
- bytes(reversed(w)),
- )
+ return reverse(
+ aes_cmac(
+ reverse(n1)
+ + reverse(n2)
+ + reverse(r)
+ + reverse(io_cap)
+ + reverse(a1)
+ + reverse(a2),
+ reverse(w),
)
)
# -----------------------------------------------------------------------------
-def g2(u, v, x, y):
+def g2(u: bytes, v: bytes, x: bytes, y: bytes) -> int:
'''
See Bluetooth spec, Vol 3, Part H - 2.2.9 LE Secure Connections Numeric Comparison
Value Generation Function g2
'''
return int.from_bytes(
aes_cmac(
- bytes(reversed(u)) + bytes(reversed(v)) + bytes(reversed(y)),
- bytes(reversed(x)),
+ reverse(u) + reverse(v) + reverse(y),
+ reverse(x),
)[-4:],
byteorder='big',
)
# -----------------------------------------------------------------------------
-def h6(w, key_id):
+def h6(w: bytes, key_id: bytes) -> bytes:
'''
See Bluetooth spec, Vol 3, Part H - 2.2.10 Link key conversion function h6
'''
- return aes_cmac(key_id, w)
+ return reverse(aes_cmac(key_id, reverse(w)))
# -----------------------------------------------------------------------------
-def h7(salt, w):
+def h7(salt: bytes, w: bytes) -> bytes:
'''
See Bluetooth spec, Vol 3, Part H - 2.2.11 Link key conversion function h7
'''
- return aes_cmac(w, salt)
+ return reverse(aes_cmac(reverse(w), salt))
diff --git a/bumble/device.py b/bumble/device.py
index 7f11012..48f9d58 100644
--- a/bumble/device.py
+++ b/bumble/device.py
@@ -21,8 +21,10 @@ import functools
import json
import asyncio
import logging
-from contextlib import asynccontextmanager, AsyncExitStack
-from dataclasses import dataclass
+import secrets
+from contextlib import asynccontextmanager, AsyncExitStack, closing
+from dataclasses import dataclass, field
+from collections.abc import Iterable
from typing import (
Any,
Callable,
@@ -32,12 +34,15 @@ from typing import (
Optional,
Tuple,
Type,
+ TypeVar,
Union,
cast,
overload,
TYPE_CHECKING,
)
+from pyee import EventEmitter
+
from .colors import color
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
from .gatt import Characteristic, Descriptor, Service
@@ -45,6 +50,7 @@ from .hci import (
HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
HCI_CENTRAL_ROLE,
+ HCI_PERIPHERAL_ROLE,
HCI_COMMAND_STATUS_PENDING,
HCI_CONNECTION_REJECTED_DUE_TO_LIMITED_RESOURCES_ERROR,
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
@@ -56,12 +62,8 @@ from .hci import (
HCI_LE_1M_PHY,
HCI_LE_1M_PHY_BIT,
HCI_LE_2M_PHY,
- HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE,
- HCI_LE_CLEAR_RESOLVING_LIST_COMMAND,
HCI_LE_CODED_PHY,
HCI_LE_CODED_PHY_BIT,
- HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE,
- HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE,
HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND,
HCI_LE_RAND_COMMAND,
HCI_LE_READ_PHY_COMMAND,
@@ -75,37 +77,52 @@ from .hci import (
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
HCI_SUCCESS,
HCI_WRITE_LE_HOST_SUPPORT_COMMAND,
- Address,
HCI_Accept_Connection_Request_Command,
HCI_Authentication_Requested_Command,
HCI_Command_Status_Event,
HCI_Constant,
HCI_Create_Connection_Cancel_Command,
HCI_Create_Connection_Command,
+ HCI_Connection_Complete_Event,
HCI_Disconnect_Command,
HCI_Encryption_Change_Event,
HCI_Error,
HCI_IO_Capability_Request_Reply_Command,
HCI_Inquiry_Cancel_Command,
HCI_Inquiry_Command,
+ HCI_IsoDataPacket,
+ HCI_LE_Accept_CIS_Request_Command,
HCI_LE_Add_Device_To_Resolving_List_Command,
HCI_LE_Advertising_Report_Event,
HCI_LE_Clear_Resolving_List_Command,
HCI_LE_Connection_Update_Command,
HCI_LE_Create_Connection_Cancel_Command,
HCI_LE_Create_Connection_Command,
+ HCI_LE_Create_CIS_Command,
HCI_LE_Enable_Encryption_Command,
HCI_LE_Extended_Advertising_Report_Event,
HCI_LE_Extended_Create_Connection_Command,
HCI_LE_Rand_Command,
HCI_LE_Read_PHY_Command,
+ HCI_LE_Read_Remote_Features_Command,
+ HCI_LE_Reject_CIS_Request_Command,
+ HCI_LE_Remove_Advertising_Set_Command,
HCI_LE_Set_Address_Resolution_Enable_Command,
HCI_LE_Set_Advertising_Data_Command,
HCI_LE_Set_Advertising_Enable_Command,
HCI_LE_Set_Advertising_Parameters_Command,
+ HCI_LE_Set_Advertising_Set_Random_Address_Command,
+ HCI_LE_Set_CIG_Parameters_Command,
+ HCI_LE_Set_Data_Length_Command,
HCI_LE_Set_Default_PHY_Command,
HCI_LE_Set_Extended_Scan_Enable_Command,
HCI_LE_Set_Extended_Scan_Parameters_Command,
+ HCI_LE_Set_Extended_Scan_Response_Data_Command,
+ HCI_LE_Set_Extended_Advertising_Data_Command,
+ HCI_LE_Set_Extended_Advertising_Enable_Command,
+ HCI_LE_Set_Extended_Advertising_Parameters_Command,
+ HCI_LE_Set_Host_Feature_Command,
+ HCI_LE_Set_Periodic_Advertising_Enable_Command,
HCI_LE_Set_PHY_Command,
HCI_LE_Set_Random_Address_Command,
HCI_LE_Set_Scan_Enable_Command,
@@ -120,6 +137,7 @@ from .hci import (
HCI_Switch_Role_Command,
HCI_Set_Connection_Encryption_Command,
HCI_StatusError,
+ HCI_SynchronousDataPacket,
HCI_User_Confirmation_Request_Negative_Reply_Command,
HCI_User_Confirmation_Request_Reply_Command,
HCI_User_Passkey_Request_Negative_Reply_Command,
@@ -132,7 +150,11 @@ from .hci import (
HCI_Write_Scan_Enable_Command,
HCI_Write_Secure_Connections_Host_Support_Command,
HCI_Write_Simple_Pairing_Mode_Command,
+ Address,
OwnAddressType,
+ LeFeature,
+ LeFeatureMask,
+ Phy,
phy_list_to_bits,
)
from .host import Host
@@ -151,9 +173,11 @@ from .core import (
from .utils import (
AsyncRunner,
CompositeEventEmitter,
+ EventWatcher,
setup_event_forwarding,
composite_listener,
deprecated,
+ experimental,
)
from .keys import (
KeyStore,
@@ -188,6 +212,8 @@ DEVICE_MIN_SCAN_WINDOW = 25
DEVICE_MAX_SCAN_WINDOW = 10240
DEVICE_MIN_LE_RSSI = -127
DEVICE_MAX_LE_RSSI = 20
+DEVICE_MIN_EXTENDED_ADVERTISING_SET_HANDLE = 0x00
+DEVICE_MAX_EXTENDED_ADVERTISING_SET_HANDLE = 0xEF
DEVICE_DEFAULT_ADDRESS = '00:00:00:00:00:00'
DEVICE_DEFAULT_ADVERTISING_INTERVAL = 1000 # ms
@@ -211,10 +237,16 @@ DEVICE_DEFAULT_CONNECTION_MAX_CE_LENGTH = 0 # ms
DEVICE_DEFAULT_L2CAP_COC_MTU = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU
DEVICE_DEFAULT_L2CAP_COC_MPS = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS
+DEVICE_DEFAULT_ADVERTISING_TX_POWER = (
+ HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE
+)
# fmt: on
# pylint: enable=line-too-long
+# As specified in 7.8.56 LE Set Extended Advertising Enable command
+DEVICE_MAX_HIGH_DUTY_CYCLE_CONNECTABLE_DIRECTED_ADVERTISING_DURATION = 1.28
+
# -----------------------------------------------------------------------------
# Classes
@@ -222,16 +254,40 @@ DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS = l2cap.L2CAP_LE_CREDIT_BASED_CONN
# -----------------------------------------------------------------------------
+@dataclass
class Advertisement:
+ # Attributes
address: Address
-
- TX_POWER_NOT_AVAILABLE = (
+ rssi: int = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
+ is_legacy: bool = False
+ is_anonymous: bool = False
+ is_connectable: bool = False
+ is_directed: bool = False
+ is_scannable: bool = False
+ is_scan_response: bool = False
+ is_complete: bool = True
+ is_truncated: bool = False
+ primary_phy: int = 0
+ secondary_phy: int = 0
+ tx_power: int = (
HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
)
- RSSI_NOT_AVAILABLE = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
+ sid: int = 0
+ data_bytes: bytes = b''
+
+ # Constants
+ TX_POWER_NOT_AVAILABLE: ClassVar[
+ int
+ ] = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
+ RSSI_NOT_AVAILABLE: ClassVar[
+ int
+ ] = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
+
+ def __post_init__(self) -> None:
+ self.data = AdvertisingData.from_bytes(self.data_bytes)
@classmethod
- def from_advertising_report(cls, report):
+ def from_advertising_report(cls, report) -> Optional[Advertisement]:
if isinstance(report, HCI_LE_Advertising_Report_Event.Report):
return LegacyAdvertisement.from_advertising_report(report)
@@ -240,41 +296,6 @@ class Advertisement:
return None
- # pylint: disable=line-too-long
- def __init__(
- self,
- address,
- rssi=HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE,
- is_legacy=False,
- is_anonymous=False,
- is_connectable=False,
- is_directed=False,
- is_scannable=False,
- is_scan_response=False,
- is_complete=True,
- is_truncated=False,
- primary_phy=0,
- secondary_phy=0,
- tx_power=HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE,
- sid=0,
- data=b'',
- ):
- self.address = address
- self.rssi = rssi
- self.is_legacy = is_legacy
- self.is_anonymous = is_anonymous
- self.is_connectable = is_connectable
- self.is_directed = is_directed
- self.is_scannable = is_scannable
- self.is_scan_response = is_scan_response
- self.is_complete = is_complete
- self.is_truncated = is_truncated
- self.primary_phy = primary_phy
- self.secondary_phy = secondary_phy
- self.tx_power = tx_power
- self.sid = sid
- self.data = AdvertisingData.from_bytes(data)
-
# -----------------------------------------------------------------------------
class LegacyAdvertisement(Advertisement):
@@ -298,7 +319,7 @@ class LegacyAdvertisement(Advertisement):
),
is_scan_response=report.event_type
== HCI_LE_Advertising_Report_Event.SCAN_RSP,
- data=report.data,
+ data_bytes=report.data,
)
@@ -323,7 +344,7 @@ class ExtendedAdvertisement(Advertisement):
secondary_phy = report.secondary_phy,
tx_power = report.tx_power,
sid = report.advertising_sid,
- data = report.data
+ data_bytes = report.data
)
# fmt: on
@@ -384,7 +405,7 @@ class AdvertisingType(IntEnum):
# fmt: on
@property
- def has_data(self):
+ def has_data(self) -> bool:
return self in (
AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
AdvertisingType.UNDIRECTED_SCANNABLE,
@@ -392,7 +413,7 @@ class AdvertisingType(IntEnum):
)
@property
- def is_connectable(self):
+ def is_connectable(self) -> bool:
return self in (
AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
@@ -400,19 +421,369 @@ class AdvertisingType(IntEnum):
)
@property
- def is_scannable(self):
+ def is_scannable(self) -> bool:
return self in (
AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
AdvertisingType.UNDIRECTED_SCANNABLE,
)
@property
- def is_directed(self):
+ def is_directed(self) -> bool:
return self in (
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY,
)
+ @property
+ def is_high_duty_cycle_directed_connectable(self):
+ return self == AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class LegacyAdvertiser:
+ device: Device
+ advertising_type: AdvertisingType
+ own_address_type: OwnAddressType
+ peer_address: Address
+ auto_restart: bool
+
+ async def start(self) -> None:
+ # Set/update the advertising data if the advertising type allows it
+ if self.advertising_type.has_data:
+ await self.device.send_command(
+ HCI_LE_Set_Advertising_Data_Command(
+ advertising_data=self.device.advertising_data
+ ),
+ check_result=True,
+ )
+
+ # Set/update the scan response data if the advertising is scannable
+ if self.advertising_type.is_scannable:
+ await self.device.send_command(
+ HCI_LE_Set_Scan_Response_Data_Command(
+ scan_response_data=self.device.scan_response_data
+ ),
+ check_result=True,
+ )
+
+ # Set the advertising parameters
+ await self.device.send_command(
+ HCI_LE_Set_Advertising_Parameters_Command(
+ advertising_interval_min=self.device.advertising_interval_min,
+ advertising_interval_max=self.device.advertising_interval_max,
+ advertising_type=int(self.advertising_type),
+ own_address_type=self.own_address_type,
+ peer_address_type=self.peer_address.address_type,
+ peer_address=self.peer_address,
+ advertising_channel_map=7,
+ advertising_filter_policy=0,
+ ),
+ check_result=True,
+ )
+
+ # Enable advertising
+ await self.device.send_command(
+ HCI_LE_Set_Advertising_Enable_Command(advertising_enable=1),
+ check_result=True,
+ )
+
+ async def stop(self) -> None:
+ # Disable advertising
+ await self.device.send_command(
+ HCI_LE_Set_Advertising_Enable_Command(advertising_enable=0),
+ check_result=True,
+ )
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class AdvertisingEventProperties:
+ is_connectable: bool = True
+ is_scannable: bool = False
+ is_directed: bool = False
+ is_high_duty_cycle_directed_connectable: bool = False
+ is_legacy: bool = False
+ is_anonymous: bool = False
+ include_tx_power: bool = False
+
+ def __int__(self) -> int:
+ properties = (
+ HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties(0)
+ )
+ if self.is_connectable:
+ properties |= properties.CONNECTABLE_ADVERTISING
+ if self.is_scannable:
+ properties |= properties.SCANNABLE_ADVERTISING
+ if self.is_directed:
+ properties |= properties.DIRECTED_ADVERTISING
+ if self.is_high_duty_cycle_directed_connectable:
+ properties |= properties.HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING
+ if self.is_legacy:
+ properties |= properties.USE_LEGACY_ADVERTISING_PDUS
+ if self.is_anonymous:
+ properties |= properties.ANONYMOUS_ADVERTISING
+ if self.include_tx_power:
+ properties |= properties.INCLUDE_TX_POWER
+
+ return int(properties)
+
+ @classmethod
+ def from_advertising_type(
+ cls: Type[AdvertisingEventProperties],
+ advertising_type: AdvertisingType,
+ ) -> AdvertisingEventProperties:
+ return cls(
+ is_connectable=advertising_type.is_connectable,
+ is_scannable=advertising_type.is_scannable,
+ is_directed=advertising_type.is_directed,
+ is_high_duty_cycle_directed_connectable=advertising_type.is_high_duty_cycle_directed_connectable,
+ is_legacy=True,
+ is_anonymous=False,
+ include_tx_power=False,
+ )
+
+
+# -----------------------------------------------------------------------------
+# TODO: replace with typing.TypeAlias when the code base is all Python >= 3.10
+AdvertisingChannelMap = HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class AdvertisingParameters:
+ # pylint: disable=line-too-long
+ advertising_event_properties: AdvertisingEventProperties = field(
+ default_factory=AdvertisingEventProperties
+ )
+ primary_advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
+ primary_advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
+ primary_advertising_channel_map: HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap = (
+ AdvertisingChannelMap.CHANNEL_37
+ | AdvertisingChannelMap.CHANNEL_38
+ | AdvertisingChannelMap.CHANNEL_39
+ )
+ own_address_type: OwnAddressType = OwnAddressType.RANDOM
+ peer_address: Address = Address.ANY
+ advertising_filter_policy: int = 0
+ advertising_tx_power: int = DEVICE_DEFAULT_ADVERTISING_TX_POWER
+ primary_advertising_phy: Phy = Phy.LE_1M
+ secondary_advertising_max_skip: int = 0
+ secondary_advertising_phy: Phy = Phy.LE_1M
+ advertising_sid: int = 0
+ enable_scan_request_notifications: bool = False
+ primary_advertising_phy_options: int = 0
+ secondary_advertising_phy_options: int = 0
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class PeriodicAdvertisingParameters:
+ # TODO implement this class
+ pass
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class AdvertisingSet(EventEmitter):
+ device: Device
+ advertising_handle: int
+ auto_restart: bool
+ random_address: Optional[Address]
+ advertising_parameters: AdvertisingParameters
+ advertising_data: bytes
+ scan_response_data: bytes
+ periodic_advertising_parameters: Optional[PeriodicAdvertisingParameters]
+ periodic_advertising_data: bytes
+ selected_tx_power: int = 0
+ enabled: bool = False
+
+ def __post_init__(self) -> None:
+ super().__init__()
+
+ async def set_advertising_parameters(
+ self, advertising_parameters: AdvertisingParameters
+ ) -> None:
+ # Compliance check
+ if (
+ not advertising_parameters.advertising_event_properties.is_legacy
+ and advertising_parameters.advertising_event_properties.is_connectable
+ and advertising_parameters.advertising_event_properties.is_scannable
+ ):
+ logger.warning(
+ "non-legacy extended advertising event properties may not be both "
+ "connectable and scannable"
+ )
+
+ response = await self.device.send_command(
+ HCI_LE_Set_Extended_Advertising_Parameters_Command(
+ advertising_handle=self.advertising_handle,
+ advertising_event_properties=int(
+ advertising_parameters.advertising_event_properties
+ ),
+ primary_advertising_interval_min=(
+ int(advertising_parameters.primary_advertising_interval_min / 0.625)
+ ),
+ primary_advertising_interval_max=(
+ int(advertising_parameters.primary_advertising_interval_min / 0.625)
+ ),
+ primary_advertising_channel_map=int(
+ advertising_parameters.primary_advertising_channel_map
+ ),
+ own_address_type=advertising_parameters.own_address_type,
+ peer_address_type=advertising_parameters.peer_address.address_type,
+ peer_address=advertising_parameters.peer_address,
+ advertising_tx_power=advertising_parameters.advertising_tx_power,
+ advertising_filter_policy=(
+ advertising_parameters.advertising_filter_policy
+ ),
+ primary_advertising_phy=advertising_parameters.primary_advertising_phy,
+ secondary_advertising_max_skip=(
+ advertising_parameters.secondary_advertising_max_skip
+ ),
+ secondary_advertising_phy=(
+ advertising_parameters.secondary_advertising_phy
+ ),
+ advertising_sid=advertising_parameters.advertising_sid,
+ scan_request_notification_enable=(
+ 1 if advertising_parameters.enable_scan_request_notifications else 0
+ ),
+ ),
+ check_result=True,
+ )
+ self.selected_tx_power = response.return_parameters.selected_tx_power
+ self.advertising_parameters = advertising_parameters
+
+ async def set_advertising_data(self, advertising_data: bytes) -> None:
+ # pylint: disable=line-too-long
+ await self.device.send_command(
+ HCI_LE_Set_Extended_Advertising_Data_Command(
+ advertising_handle=self.advertising_handle,
+ operation=HCI_LE_Set_Extended_Advertising_Data_Command.Operation.COMPLETE_DATA,
+ fragment_preference=HCI_LE_Set_Extended_Advertising_Parameters_Command.SHOULD_NOT_FRAGMENT,
+ advertising_data=advertising_data,
+ ),
+ check_result=True,
+ )
+ self.advertising_data = advertising_data
+
+ async def set_scan_response_data(self, scan_response_data: bytes) -> None:
+ # pylint: disable=line-too-long
+ if (
+ scan_response_data
+ and not self.advertising_parameters.advertising_event_properties.is_scannable
+ ):
+ logger.warning(
+ "ignoring attempt to set non-empty scan response data on non-scannable "
+ "advertising set"
+ )
+ return
+
+ await self.device.send_command(
+ HCI_LE_Set_Extended_Scan_Response_Data_Command(
+ advertising_handle=self.advertising_handle,
+ operation=HCI_LE_Set_Extended_Advertising_Data_Command.Operation.COMPLETE_DATA,
+ fragment_preference=HCI_LE_Set_Extended_Advertising_Parameters_Command.SHOULD_NOT_FRAGMENT,
+ scan_response_data=scan_response_data,
+ ),
+ check_result=True,
+ )
+ self.scan_response_data = scan_response_data
+
+ async def set_periodic_advertising_parameters(
+ self, advertising_parameters: PeriodicAdvertisingParameters
+ ) -> None:
+ # TODO: send command
+ self.periodic_advertising_parameters = advertising_parameters
+
+ async def set_periodic_advertising_data(self, advertising_data: bytes) -> None:
+ # TODO: send command
+ self.periodic_advertising_data = advertising_data
+
+ async def set_random_address(self, random_address: Address) -> None:
+ await self.device.send_command(
+ HCI_LE_Set_Advertising_Set_Random_Address_Command(
+ advertising_handle=self.advertising_handle,
+ random_address=(random_address or self.device.random_address),
+ ),
+ check_result=True,
+ )
+
+ async def start(
+ self, duration: float = 0.0, max_advertising_events: int = 0
+ ) -> None:
+ """
+ Start advertising.
+
+ Args:
+ duration: How long to advertise for, in seconds. Use 0 (the default) for
+ an unlimited duration, unless this advertising set is a High Duty Cycle
+ Directed Advertisement type.
+ max_advertising_events: Maximum number of events to advertise for. Use 0
+ (the default) for an unlimited number of advertisements.
+ """
+ await self.device.send_command(
+ HCI_LE_Set_Extended_Advertising_Enable_Command(
+ enable=1,
+ advertising_handles=[self.advertising_handle],
+ durations=[round(duration * 100)],
+ max_extended_advertising_events=[max_advertising_events],
+ ),
+ check_result=True,
+ )
+ self.enabled = True
+
+ self.emit('start')
+
+ async def start_periodic(self, include_adi: bool = False) -> None:
+ await self.device.send_command(
+ HCI_LE_Set_Periodic_Advertising_Enable_Command(
+ enable=1 | (2 if include_adi else 0),
+ advertising_handles=self.advertising_handle,
+ ),
+ check_result=True,
+ )
+
+ self.emit('start_periodic')
+
+ async def stop(self) -> None:
+ await self.device.send_command(
+ HCI_LE_Set_Extended_Advertising_Enable_Command(
+ enable=0,
+ advertising_handles=[self.advertising_handle],
+ durations=[0],
+ max_extended_advertising_events=[0],
+ ),
+ check_result=True,
+ )
+ self.enabled = False
+
+ self.emit('stop')
+
+ async def stop_periodic(self) -> None:
+ await self.device.send_command(
+ HCI_LE_Set_Periodic_Advertising_Enable_Command(
+ enable=0,
+ advertising_handles=self.advertising_handle,
+ ),
+ check_result=True,
+ )
+
+ self.emit('stop_periodic')
+
+ async def remove(self) -> None:
+ await self.device.send_command(
+ HCI_LE_Remove_Advertising_Set_Command(
+ advertising_handle=self.advertising_handle
+ ),
+ check_result=True,
+ )
+ del self.device.extended_advertising_sets[self.advertising_handle]
+
+ def on_termination(self, status: int) -> None:
+ self.enabled = False
+ self.emit('termination', status)
+
# -----------------------------------------------------------------------------
class LePhyOptions:
@@ -429,8 +800,11 @@ class LePhyOptions:
# -----------------------------------------------------------------------------
+_PROXY_CLASS = TypeVar('_PROXY_CLASS', bound=gatt_client.ProfileServiceProxy)
+
+
class Peer:
- def __init__(self, connection):
+ def __init__(self, connection: Connection) -> None:
self.connection = connection
# Create a GATT client for the connection
@@ -438,77 +812,113 @@ class Peer:
connection.gatt_client = self.gatt_client
@property
- def services(self):
+ def services(self) -> List[gatt_client.ServiceProxy]:
return self.gatt_client.services
- async def request_mtu(self, mtu):
+ async def request_mtu(self, mtu: int) -> int:
mtu = await self.gatt_client.request_mtu(mtu)
self.connection.emit('connection_att_mtu_update')
return mtu
- async def discover_service(self, uuid):
+ async def discover_service(
+ self, uuid: Union[core.UUID, str]
+ ) -> List[gatt_client.ServiceProxy]:
return await self.gatt_client.discover_service(uuid)
- async def discover_services(self, uuids=()):
+ async def discover_services(
+ self, uuids: Iterable[core.UUID] = ()
+ ) -> List[gatt_client.ServiceProxy]:
return await self.gatt_client.discover_services(uuids)
- async def discover_included_services(self, service):
+ async def discover_included_services(
+ self, service: gatt_client.ServiceProxy
+ ) -> List[gatt_client.ServiceProxy]:
return await self.gatt_client.discover_included_services(service)
- async def discover_characteristics(self, uuids=(), service=None):
+ async def discover_characteristics(
+ self,
+ uuids: Iterable[Union[core.UUID, str]] = (),
+ service: Optional[gatt_client.ServiceProxy] = None,
+ ) -> List[gatt_client.CharacteristicProxy]:
return await self.gatt_client.discover_characteristics(
uuids=uuids, service=service
)
async def discover_descriptors(
- self, characteristic=None, start_handle=None, end_handle=None
+ self,
+ characteristic: Optional[gatt_client.CharacteristicProxy] = None,
+ start_handle: Optional[int] = None,
+ end_handle: Optional[int] = None,
):
return await self.gatt_client.discover_descriptors(
characteristic, start_handle, end_handle
)
- async def discover_attributes(self):
+ async def discover_attributes(self) -> List[gatt_client.AttributeProxy]:
return await self.gatt_client.discover_attributes()
- async def subscribe(self, characteristic, subscriber=None, prefer_notify=True):
+ async def subscribe(
+ self,
+ characteristic: gatt_client.CharacteristicProxy,
+ subscriber: Optional[Callable[[bytes], Any]] = None,
+ prefer_notify: bool = True,
+ ) -> None:
return await self.gatt_client.subscribe(
characteristic, subscriber, prefer_notify
)
- async def unsubscribe(self, characteristic, subscriber=None):
+ async def unsubscribe(
+ self,
+ characteristic: gatt_client.CharacteristicProxy,
+ subscriber: Optional[Callable[[bytes], Any]] = None,
+ ) -> None:
return await self.gatt_client.unsubscribe(characteristic, subscriber)
- async def read_value(self, attribute):
+ async def read_value(
+ self, attribute: Union[int, gatt_client.AttributeProxy]
+ ) -> bytes:
return await self.gatt_client.read_value(attribute)
- async def write_value(self, attribute, value, with_response=False):
+ async def write_value(
+ self,
+ attribute: Union[int, gatt_client.AttributeProxy],
+ value: bytes,
+ with_response: bool = False,
+ ) -> None:
return await self.gatt_client.write_value(attribute, value, with_response)
- async def read_characteristics_by_uuid(self, uuid, service=None):
+ async def read_characteristics_by_uuid(
+ self, uuid: core.UUID, service: Optional[gatt_client.ServiceProxy] = None
+ ) -> List[bytes]:
return await self.gatt_client.read_characteristics_by_uuid(uuid, service)
- def get_services_by_uuid(self, uuid):
+ def get_services_by_uuid(self, uuid: core.UUID) -> List[gatt_client.ServiceProxy]:
return self.gatt_client.get_services_by_uuid(uuid)
- def get_characteristics_by_uuid(self, uuid, service=None):
+ def get_characteristics_by_uuid(
+ self, uuid: core.UUID, service: Optional[gatt_client.ServiceProxy] = None
+ ) -> List[gatt_client.CharacteristicProxy]:
return self.gatt_client.get_characteristics_by_uuid(uuid, service)
- def create_service_proxy(self, proxy_class):
- return proxy_class.from_client(self.gatt_client)
+ def create_service_proxy(self, proxy_class: Type[_PROXY_CLASS]) -> _PROXY_CLASS:
+ return cast(_PROXY_CLASS, proxy_class.from_client(self.gatt_client))
- async def discover_service_and_create_proxy(self, proxy_class):
+ async def discover_service_and_create_proxy(
+ self, proxy_class: Type[_PROXY_CLASS]
+ ) -> Optional[_PROXY_CLASS]:
# Discover the first matching service and its characteristics
services = await self.discover_service(proxy_class.SERVICE_CLASS.UUID)
if services:
service = services[0]
await service.discover_characteristics()
return self.create_service_proxy(proxy_class)
+ return None
- async def sustain(self, timeout=None):
+ async def sustain(self, timeout: Optional[float] = None) -> None:
await self.connection.sustain(timeout)
# [Classic only]
- async def request_name(self):
+ async def request_name(self) -> str:
return await self.connection.request_remote_name()
async def __aenter__(self):
@@ -521,7 +931,7 @@ class Peer:
async def __aexit__(self, exc_type, exc_value, traceback):
pass
- def __str__(self):
+ def __str__(self) -> str:
return f'{self.connection.peer_address} as {self.connection.role_name}'
@@ -541,6 +951,46 @@ ConnectionParametersPreferences.default = ConnectionParametersPreferences()
# -----------------------------------------------------------------------------
+@dataclass
+class ScoLink(CompositeEventEmitter):
+ device: Device
+ acl_connection: Connection
+ handle: int
+ link_type: int
+
+ def __post_init__(self):
+ super().__init__()
+
+ async def disconnect(
+ self, reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
+ ) -> None:
+ await self.device.disconnect(self, reason)
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class CisLink(CompositeEventEmitter):
+ class State(IntEnum):
+ PENDING = 0
+ ESTABLISHED = 1
+
+ device: Device
+ acl_connection: Connection # Based ACL connection
+ handle: int # CIS handle assigned by Controller (in LE_Set_CIG_Parameters Complete or LE_CIS_Request events)
+ cis_id: int # CIS ID assigned by Central device
+ cig_id: int # CIG ID assigned by Central device
+ state: State = State.PENDING
+
+ def __post_init__(self):
+ super().__init__()
+
+ async def disconnect(
+ self, reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
+ ) -> None:
+ await self.device.disconnect(self, reason)
+
+
+# -----------------------------------------------------------------------------
class Connection(CompositeEventEmitter):
device: Device
handle: int
@@ -548,6 +998,7 @@ class Connection(CompositeEventEmitter):
self_address: Address
peer_address: Address
peer_resolvable_address: Optional[Address]
+ peer_le_features: Optional[LeFeatureMask]
role: int
encryption: int
authenticated: bool
@@ -621,6 +1072,7 @@ class Connection(CompositeEventEmitter):
) # By default, use the device's shared server
self.pairing_peer_io_capability = None
self.pairing_peer_authentication_requirements = None
+ self.peer_le_features = None
# [Classic only]
@classmethod
@@ -721,7 +1173,7 @@ class Connection(CompositeEventEmitter):
async def switch_role(self, role: int) -> None:
return await self.device.switch_role(self, role)
- async def sustain(self, timeout=None):
+ async def sustain(self, timeout: Optional[float] = None) -> None:
"""Idles the current task waiting for a disconnect or timeout"""
abort = asyncio.get_running_loop().create_future()
@@ -736,6 +1188,9 @@ class Connection(CompositeEventEmitter):
self.remove_listener('disconnection', abort.set_result)
self.remove_listener('disconnection_failure', abort.set_exception)
+ async def set_data_length(self, tx_octets, tx_time) -> None:
+ return await self.device.set_data_length(self, tx_octets, tx_time)
+
async def update_parameters(
self,
connection_interval_min,
@@ -766,6 +1221,15 @@ class Connection(CompositeEventEmitter):
async def request_remote_name(self):
return await self.device.request_remote_name(self)
+ async def get_remote_le_features(self) -> LeFeatureMask:
+ """[LE Only] Reads remote LE supported features.
+
+ Returns:
+ LE features supported by the remote device.
+ """
+ self.peer_le_features = await self.device.get_remote_le_features(self)
+ return self.peer_le_features
+
async def __aenter__(self):
return self
@@ -782,7 +1246,8 @@ class Connection(CompositeEventEmitter):
return (
f'Connection(handle=0x{self.handle:04X}, '
f'role={self.role_name}, '
- f'address={self.peer_address})'
+ f'self_address={self.self_address}, '
+ f'peer_address={self.peer_address})'
)
@@ -815,6 +1280,7 @@ class DeviceConfiguration:
self.keystore = None
self.gatt_services: List[Dict[str, Any]] = []
self.address_resolution_offload = False
+ self.cis_enabled = False
def load_from_dict(self, config: Dict[str, Any]) -> None:
# Load simple properties
@@ -850,17 +1316,21 @@ class DeviceConfiguration:
self.address_resolution_offload = config.get(
'address_resolution_offload', self.address_resolution_offload
)
+ self.cis_enabled = config.get('cis_enabled', self.cis_enabled)
# Load or synthesize an IRK
irk = config.get('irk')
if irk:
self.irk = bytes.fromhex(irk)
- else:
+ elif self.address != Address(DEVICE_DEFAULT_ADDRESS):
# Construct an IRK from the address bytes
# NOTE: this is not secure, but will always give the same IRK for the same
# address
address_bytes = bytes(self.address)
self.irk = (address_bytes * 3)[:16]
+ else:
+ # Fallback - when both IRK and address are not set, randomly generate an IRK.
+ self.irk = secrets.token_bytes(16)
# Load advertising data
advertising_data = config.get('advertising_data')
@@ -890,7 +1360,7 @@ def with_connection_from_handle(function):
@functools.wraps(function)
def wrapper(self, connection_handle, *args, **kwargs):
if (connection := self.lookup_connection(connection_handle)) is None:
- raise ValueError(f"no connection for handle: 0x{connection_handle:04x}")
+ raise ValueError(f'no connection for handle: 0x{connection_handle:04x}')
return function(self, connection, *args, **kwargs)
return wrapper
@@ -956,6 +1426,10 @@ class Device(CompositeEventEmitter):
]
advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator]
config: DeviceConfiguration
+ legacy_advertiser: Optional[LegacyAdvertiser]
+ sco_links: Dict[int, ScoLink]
+ cis_links: Dict[int, CisLink]
+ _pending_cis: Dict[int, Tuple[int, int]]
@composite_listener
class Listener:
@@ -1030,10 +1504,7 @@ class Device(CompositeEventEmitter):
self._host = None
self.powered_on = False
- self.advertising = False
- self.advertising_type = None
self.auto_restart_inquiry = True
- self.auto_restart_advertising = False
self.command_timeout = 10 # seconds
self.gatt_server = gatt_server.Server(self)
self.sdp_server = sdp.Server(self)
@@ -1048,6 +1519,9 @@ class Device(CompositeEventEmitter):
self.disconnecting = False
self.connections = {} # Connections, by connection handle
self.pending_connections = {} # Connections, by BD address (BR/EDR only)
+ self.sco_links = {} # ScoLinks, by connection handle (BR/EDR only)
+ self.cis_links = {} # CisLinks, by connection handle (LE only)
+ self._pending_cis = {} # (CIS_ID, CIG_ID), by CIS_handle
self.classic_enabled = False
self.inquiry_response = None
self.address_resolver = None
@@ -1056,7 +1530,6 @@ class Device(CompositeEventEmitter):
} # Futures, by BD address OR [Futures] for Address.ANY
# Own address type cache
- self.advertising_own_address_type = None
self.connect_own_address_type = None
# Use the initial config or a default
@@ -1067,15 +1540,12 @@ class Device(CompositeEventEmitter):
self.name = config.name
self.random_address = config.address
self.class_of_device = config.class_of_device
- self.scan_response_data = config.scan_response_data
- self.advertising_data = config.advertising_data
- self.advertising_interval_min = config.advertising_interval_min
- self.advertising_interval_max = config.advertising_interval_max
self.keystore = None
self.irk = config.irk
self.le_enabled = config.le_enabled
self.classic_enabled = config.classic_enabled
self.le_simultaneous_enabled = config.le_simultaneous_enabled
+ self.cis_enabled = config.cis_enabled
self.classic_sc_enabled = config.classic_sc_enabled
self.classic_ssp_enabled = config.classic_ssp_enabled
self.classic_smp_enabled = config.classic_smp_enabled
@@ -1084,6 +1554,22 @@ class Device(CompositeEventEmitter):
self.classic_accept_any = config.classic_accept_any
self.address_resolution_offload = config.address_resolution_offload
+ # Extended advertising.
+ self.extended_advertising_sets: Dict[int, AdvertisingSet] = {}
+
+ # Legacy advertising.
+ # The advertising and scan response data, as well as the advertising interval
+ # values are stored as properties of this object for convenience so that they
+ # can be initialized from a config object, and for backward compatibility for
+ # client code that may set those values directly before calling
+ # start_advertising().
+ self.legacy_advertising_set: Optional[AdvertisingSet] = None
+ self.legacy_advertiser: Optional[LegacyAdvertiser] = None
+ self.advertising_data = config.advertising_data
+ self.scan_response_data = config.scan_response_data
+ self.advertising_interval_min = config.advertising_interval_min
+ self.advertising_interval_max = config.advertising_interval_max
+
for service in config.gatt_services:
characteristics = []
for characteristic in service.get("characteristics", []):
@@ -1092,7 +1578,8 @@ class Device(CompositeEventEmitter):
# Leave this check until 5/25/2023
if descriptor.get("permission", False):
raise Exception(
- "Error parsing Device Config's GATT Services. The key 'permission' must be renamed to 'permissions'"
+ "Error parsing Device Config's GATT Services. "
+ "The key 'permission' must be renamed to 'permissions'"
)
new_descriptor = Descriptor(
attribute_type=descriptor["descriptor_type"],
@@ -1308,7 +1795,7 @@ class Device(CompositeEventEmitter):
self.host.send_command(command, check_result), self.command_timeout
)
except asyncio.TimeoutError as error:
- logger.warning('!!! Command timed out')
+ logger.warning(f'!!! Command {command.name} timed out')
raise CommandTimeoutError() from error
async def power_on(self) -> None:
@@ -1316,7 +1803,7 @@ class Device(CompositeEventEmitter):
await self.host.reset()
# Try to get the public address from the controller
- response = await self.send_command(HCI_Read_BD_ADDR_Command()) # type: ignore[call-arg]
+ response = await self.send_command(HCI_Read_BD_ADDR_Command())
if response.return_parameters.status == HCI_SUCCESS:
logger.debug(
color(f'BD_ADDR: {response.return_parameters.bd_addr}', 'yellow')
@@ -1339,7 +1826,7 @@ class Device(CompositeEventEmitter):
HCI_Write_LE_Host_Support_Command(
le_supported_host=int(self.le_enabled),
simultaneous_le_host=int(self.le_simultaneous_enabled),
- ) # type: ignore[call-arg]
+ )
)
if self.le_enabled:
@@ -1349,7 +1836,7 @@ class Device(CompositeEventEmitter):
if self.host.supports_command(HCI_LE_RAND_COMMAND):
# Get 8 random bytes
response = await self.send_command(
- HCI_LE_Rand_Command(), check_result=True # type: ignore[call-arg]
+ HCI_LE_Rand_Command(), check_result=True
)
# Ensure the address bytes can be a static random address
@@ -1370,7 +1857,7 @@ class Device(CompositeEventEmitter):
await self.send_command(
HCI_LE_Set_Random_Address_Command(
random_address=self.random_address
- ), # type: ignore[call-arg]
+ ),
check_result=True,
)
@@ -1383,25 +1870,33 @@ class Device(CompositeEventEmitter):
await self.send_command(
HCI_LE_Set_Address_Resolution_Enable_Command(
address_resolution_enable=1
- ) # type: ignore[call-arg]
+ )
+ )
+
+ if self.cis_enabled:
+ await self.send_command(
+ HCI_LE_Set_Host_Feature_Command(
+ bit_number=LeFeature.CONNECTED_ISOCHRONOUS_STREAM,
+ bit_value=1,
+ )
)
if self.classic_enabled:
await self.send_command(
- HCI_Write_Local_Name_Command(local_name=self.name.encode('utf8')) # type: ignore[call-arg]
+ HCI_Write_Local_Name_Command(local_name=self.name.encode('utf8'))
)
await self.send_command(
- HCI_Write_Class_Of_Device_Command(class_of_device=self.class_of_device) # type: ignore[call-arg]
+ HCI_Write_Class_Of_Device_Command(class_of_device=self.class_of_device)
)
await self.send_command(
HCI_Write_Simple_Pairing_Mode_Command(
simple_pairing_mode=int(self.classic_ssp_enabled)
- ) # type: ignore[call-arg]
+ )
)
await self.send_command(
HCI_Write_Secure_Connections_Host_Support_Command(
secure_connections_host_support=int(self.classic_sc_enabled)
- ) # type: ignore[call-arg]
+ )
)
await self.set_connectable(self.connectable)
await self.set_discoverable(self.discoverable)
@@ -1409,6 +1904,9 @@ class Device(CompositeEventEmitter):
# Done
self.powered_on = True
+ async def reset(self) -> None:
+ await self.host.reset()
+
async def power_off(self) -> None:
if self.powered_on:
await self.host.flush()
@@ -1422,7 +1920,17 @@ class Device(CompositeEventEmitter):
self.address_resolver = smp.AddressResolver(resolving_keys)
if self.address_resolution_offload:
- await self.send_command(HCI_LE_Clear_Resolving_List_Command()) # type: ignore[call-arg]
+ await self.send_command(HCI_LE_Clear_Resolving_List_Command())
+
+ # Add an empty entry for non-directed address generation.
+ await self.send_command(
+ HCI_LE_Add_Device_To_Resolving_List_Command(
+ peer_identity_address_type=Address.ANY.address_type,
+ peer_identity_address=Address.ANY,
+ peer_irk=bytes(16),
+ local_irk=self.irk,
+ )
+ )
for irk, address in resolving_keys:
await self.send_command(
@@ -1431,24 +1939,28 @@ class Device(CompositeEventEmitter):
peer_identity_address=address,
peer_irk=irk,
local_irk=self.irk,
- ) # type: ignore[call-arg]
+ )
)
- def supports_le_feature(self, feature):
- return self.host.supports_le_feature(feature)
+ def supports_le_features(self, feature: LeFeatureMask) -> bool:
+ return self.host.supports_le_features(feature)
def supports_le_phy(self, phy):
if phy == HCI_LE_1M_PHY:
return True
feature_map = {
- HCI_LE_2M_PHY: HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE,
- HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE,
+ HCI_LE_2M_PHY: LeFeatureMask.LE_2M_PHY,
+ HCI_LE_CODED_PHY: LeFeatureMask.LE_CODED_PHY,
}
if phy not in feature_map:
raise ValueError('invalid PHY')
- return self.host.supports_le_feature(feature_map[phy])
+ return self.supports_le_features(feature_map[phy])
+
+ @property
+ def supports_le_extended_advertising(self):
+ return self.supports_le_features(LeFeatureMask.LE_EXTENDED_ADVERTISING)
async def start_advertising(
self,
@@ -1456,82 +1968,261 @@ class Device(CompositeEventEmitter):
target: Optional[Address] = None,
own_address_type: int = OwnAddressType.RANDOM,
auto_restart: bool = False,
+ advertising_data: Optional[bytes] = None,
+ scan_response_data: Optional[bytes] = None,
+ advertising_interval_min: Optional[int] = None,
+ advertising_interval_max: Optional[int] = None,
) -> None:
- # If we're advertising, stop first
- if self.advertising:
- await self.stop_advertising()
-
- # Set/update the advertising data if the advertising type allows it
- if advertising_type.has_data:
- await self.send_command(
- HCI_LE_Set_Advertising_Data_Command(
- advertising_data=self.advertising_data
- ), # type: ignore[call-arg]
- check_result=True,
- )
-
- # Set/update the scan response data if the advertising is scannable
- if advertising_type.is_scannable:
- await self.send_command(
- HCI_LE_Set_Scan_Response_Data_Command(
- scan_response_data=self.scan_response_data
- ), # type: ignore[call-arg]
- check_result=True,
- )
+ """Start legacy advertising.
+
+ If the controller supports it, extended advertising commands with legacy PDUs
+ will be used to advertise. If not, legacy advertising commands will be used.
+
+ Args:
+ advertising_type:
+ Type of advertising events.
+ target:
+ Peer address for directed advertising target.
+ (Ignored if `advertising_type` is not directed)
+ own_address_type:
+ Own address type to use in the advertising.
+ auto_restart:
+ Whether the advertisement will be restarted after disconnection.
+ advertising_data:
+ Raw advertising data. If None, the value of the property
+ self.advertising_data will be used.
+ scan_response_data:
+ Raw scan response. If None, the value of the property
+ self.scan_response_data will be used.
+ advertising_interval_min:
+ Minimum advertising interval, in milliseconds. If None, the value of the
+ property self.advertising_interval_min will be used.
+ advertising_interval_max:
+ Maximum advertising interval, in milliseconds. If None, the value of the
+ property self.advertising_interval_max will be used.
+ """
+ # Update backing properties.
+ if advertising_data is not None:
+ self.advertising_data = advertising_data
+ if scan_response_data is not None:
+ self.scan_response_data = scan_response_data
+ if advertising_interval_min is not None:
+ self.advertising_interval_min = advertising_interval_min
+ if advertising_interval_max is not None:
+ self.advertising_interval_max = advertising_interval_max
# Decide what peer address to use
if advertising_type.is_directed:
if target is None:
- raise ValueError('directed advertising requires a target address')
-
+ raise ValueError('directed advertising requires a target')
peer_address = target
- peer_address_type = target.address_type
else:
- peer_address = Address('00:00:00:00:00:00')
- peer_address_type = Address.PUBLIC_DEVICE_ADDRESS
-
- # Set the advertising parameters
- await self.send_command(
- HCI_LE_Set_Advertising_Parameters_Command(
- advertising_interval_min=self.advertising_interval_min,
- advertising_interval_max=self.advertising_interval_max,
- advertising_type=int(advertising_type),
- own_address_type=own_address_type,
- peer_address_type=peer_address_type,
+ peer_address = Address.ANY
+
+ # If we're already advertising, stop now because we'll be re-creating
+ # a new advertiser or advertising set.
+ await self.stop_advertising()
+ assert self.legacy_advertiser is None
+ assert self.legacy_advertising_set is None
+
+ if self.supports_le_extended_advertising:
+ # Use extended advertising commands with legacy PDUs.
+ self.legacy_advertising_set = await self.create_advertising_set(
+ auto_start=True,
+ auto_restart=auto_restart,
+ random_address=self.random_address,
+ advertising_parameters=AdvertisingParameters(
+ advertising_event_properties=(
+ AdvertisingEventProperties.from_advertising_type(
+ advertising_type
+ )
+ ),
+ primary_advertising_interval_min=self.advertising_interval_min,
+ primary_advertising_interval_max=self.advertising_interval_max,
+ own_address_type=OwnAddressType(own_address_type),
+ peer_address=peer_address,
+ ),
+ advertising_data=(
+ self.advertising_data if advertising_type.has_data else b''
+ ),
+ scan_response_data=(
+ self.scan_response_data if advertising_type.is_scannable else b''
+ ),
+ )
+ else:
+ # Use legacy commands.
+ self.legacy_advertiser = LegacyAdvertiser(
+ device=self,
+ advertising_type=advertising_type,
+ own_address_type=OwnAddressType(own_address_type),
peer_address=peer_address,
- advertising_channel_map=7,
- advertising_filter_policy=0,
- ), # type: ignore[call-arg]
- check_result=True,
- )
-
- # Enable advertising
- await self.send_command(
- HCI_LE_Set_Advertising_Enable_Command(advertising_enable=1), # type: ignore[call-arg]
- check_result=True,
- )
+ auto_restart=auto_restart,
+ )
- self.advertising_type = advertising_type
- self.advertising_own_address_type = own_address_type
- self.advertising = True
- self.auto_restart_advertising = auto_restart
+ await self.legacy_advertiser.start()
async def stop_advertising(self) -> None:
+ """Stop legacy advertising."""
# Disable advertising
- if self.advertising:
+ if self.legacy_advertising_set:
+ if self.legacy_advertising_set.enabled:
+ await self.legacy_advertising_set.stop()
+ await self.legacy_advertising_set.remove()
+ self.legacy_advertising_set = None
+ elif self.legacy_advertiser:
+ await self.legacy_advertiser.stop()
+ self.legacy_advertiser = None
+
+ async def create_advertising_set(
+ self,
+ advertising_parameters: Optional[AdvertisingParameters] = None,
+ random_address: Optional[Address] = None,
+ advertising_data: bytes = b'',
+ scan_response_data: bytes = b'',
+ periodic_advertising_parameters: Optional[PeriodicAdvertisingParameters] = None,
+ periodic_advertising_data: bytes = b'',
+ auto_start: bool = True,
+ auto_restart: bool = False,
+ ) -> AdvertisingSet:
+ """
+ Create an advertising set.
+
+ This method allows the creation of advertising sets for controllers that
+ support extended advertising.
+
+ Args:
+ advertising_parameters:
+ The parameters to use for this set. If None, default parameters are used.
+ random_address:
+ The random address to use (only relevant when the parameters specify that
+ own_address_type is random).
+ advertising_data:
+ Initial value for the set's advertising data.
+ scan_response_data:
+ Initial value for the set's scan response data.
+ periodic_advertising_parameters:
+ The parameters to use for periodic advertising (if needed).
+ periodic_advertising_data:
+ Initial value for the set's periodic advertising data.
+ auto_start:
+ True if the set should be automatically started upon creation.
+ auto_restart:
+ True if the set should be automatically restated after a disconnection.
+
+ Returns:
+ An AdvertisingSet instance.
+ """
+ # Instantiate default values
+ if advertising_parameters is None:
+ advertising_parameters = AdvertisingParameters()
+
+ if (
+ not advertising_parameters.advertising_event_properties.is_legacy
+ and advertising_data
+ and scan_response_data
+ ):
+ raise ValueError(
+ "Extended advertisements can't have both data and scan \
+ response data"
+ )
+
+ # Allocate a new handle
+ try:
+ advertising_handle = next(
+ handle
+ for handle in range(
+ DEVICE_MIN_EXTENDED_ADVERTISING_SET_HANDLE,
+ DEVICE_MAX_EXTENDED_ADVERTISING_SET_HANDLE + 1,
+ )
+ if handle not in self.extended_advertising_sets
+ )
+ except StopIteration as exc:
+ raise RuntimeError("all valid advertising handles already in use") from exc
+
+ # Use the device's random address if a random address is needed but none was
+ # provided.
+ if (
+ advertising_parameters.own_address_type
+ in (OwnAddressType.RANDOM, OwnAddressType.RESOLVABLE_OR_RANDOM)
+ and random_address is None
+ ):
+ random_address = self.random_address
+
+ # Create the object that represents the set.
+ advertising_set = AdvertisingSet(
+ device=self,
+ advertising_handle=advertising_handle,
+ auto_restart=auto_restart,
+ random_address=random_address,
+ advertising_parameters=advertising_parameters,
+ advertising_data=advertising_data,
+ scan_response_data=scan_response_data,
+ periodic_advertising_parameters=periodic_advertising_parameters,
+ periodic_advertising_data=periodic_advertising_data,
+ )
+
+ # Create the set in the controller.
+ await advertising_set.set_advertising_parameters(advertising_parameters)
+
+ # Update the set in the controller.
+ try:
+ if random_address:
+ await advertising_set.set_random_address(random_address)
+
+ if advertising_data:
+ await advertising_set.set_advertising_data(advertising_data)
+
+ if scan_response_data:
+ await advertising_set.set_scan_response_data(scan_response_data)
+
+ if periodic_advertising_parameters:
+ # TODO: call LE Set Periodic Advertising Parameters command
+ raise NotImplementedError('periodic advertising not yet supported')
+
+ if periodic_advertising_data:
+ # TODO: call LE Set Periodic Advertising Data command
+ raise NotImplementedError('periodic advertising not yet supported')
+
+ except HCI_Error as error:
+ # Remove the advertising set so that it doesn't stay dangling in the
+ # controller.
await self.send_command(
- HCI_LE_Set_Advertising_Enable_Command(advertising_enable=0), # type: ignore[call-arg]
- check_result=True,
+ HCI_LE_Remove_Advertising_Set_Command(
+ advertising_handle=advertising_data
+ ),
+ check_result=False,
)
+ raise error
+
+ # Remember the set.
+ self.extended_advertising_sets[advertising_handle] = advertising_set
- self.advertising_type = None
- self.advertising_own_address_type = None
- self.advertising = False
- self.auto_restart_advertising = False
+ # Try to start the set if requested.
+ if auto_start:
+ try:
+ # pylint: disable=line-too-long
+ duration = (
+ DEVICE_MAX_HIGH_DUTY_CYCLE_CONNECTABLE_DIRECTED_ADVERTISING_DURATION
+ if advertising_parameters.advertising_event_properties.is_high_duty_cycle_directed_connectable
+ else 0
+ )
+ await advertising_set.start(duration=duration)
+ except Exception as error:
+ logger.exception(f'failed to start advertising set: {error}')
+ await advertising_set.remove()
+ raise
+
+ return advertising_set
@property
def is_advertising(self):
- return self.advertising
+ if self.legacy_advertiser:
+ return True
+
+ return any(
+ advertising_set.enabled
+ for advertising_set in self.extended_advertising_sets.values()
+ )
async def start_scanning(
self,
@@ -1541,7 +2232,7 @@ class Device(CompositeEventEmitter):
scan_window: int = DEVICE_DEFAULT_SCAN_WINDOW, # Scan window in ms
own_address_type: int = OwnAddressType.RANDOM,
filter_duplicates: bool = False,
- scanning_phys: Tuple[int, int] = (HCI_LE_1M_PHY, HCI_LE_CODED_PHY),
+ scanning_phys: List[int] = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY],
) -> None:
# Check that the arguments are legal
if scan_interval < scan_window:
@@ -1558,9 +2249,7 @@ class Device(CompositeEventEmitter):
self.advertisement_accumulators = {}
# Enable scanning
- if not legacy and self.supports_le_feature(
- HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE
- ):
+ if not legacy and self.supports_le_extended_advertising:
# Set the scanning parameters
scan_type = (
HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING
@@ -1577,7 +2266,7 @@ class Device(CompositeEventEmitter):
scanning_phys_bits |= 1 << HCI_LE_1M_PHY_BIT
scanning_phy_count += 1
if HCI_LE_CODED_PHY in scanning_phys:
- if self.supports_le_feature(HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE):
+ if self.supports_le_features(LeFeatureMask.LE_CODED_PHY):
scanning_phys_bits |= 1 << HCI_LE_CODED_PHY_BIT
scanning_phy_count += 1
@@ -1592,7 +2281,7 @@ class Device(CompositeEventEmitter):
scan_types=[scan_type] * scanning_phy_count,
scan_intervals=[int(scan_window / 0.625)] * scanning_phy_count,
scan_windows=[int(scan_window / 0.625)] * scanning_phy_count,
- ), # type: ignore[call-arg]
+ ),
check_result=True,
)
@@ -1603,7 +2292,7 @@ class Device(CompositeEventEmitter):
filter_duplicates=1 if filter_duplicates else 0,
duration=0, # TODO allow other values
period=0, # TODO allow other values
- ), # type: ignore[call-arg]
+ ),
check_result=True,
)
else:
@@ -1621,7 +2310,7 @@ class Device(CompositeEventEmitter):
le_scan_window=int(scan_window / 0.625),
own_address_type=own_address_type,
scanning_filter_policy=HCI_LE_Set_Scan_Parameters_Command.BASIC_UNFILTERED_POLICY,
- ), # type: ignore[call-arg]
+ ),
check_result=True,
)
@@ -1629,25 +2318,25 @@ class Device(CompositeEventEmitter):
await self.send_command(
HCI_LE_Set_Scan_Enable_Command(
le_scan_enable=1, filter_duplicates=1 if filter_duplicates else 0
- ), # type: ignore[call-arg]
+ ),
check_result=True,
)
self.scanning_is_passive = not active
self.scanning = True
- async def stop_scanning(self) -> None:
+ async def stop_scanning(self, legacy: bool = False) -> None:
# Disable scanning
- if self.supports_le_feature(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE):
+ if not legacy and self.supports_le_extended_advertising:
await self.send_command(
HCI_LE_Set_Extended_Scan_Enable_Command(
enable=0, filter_duplicates=0, duration=0, period=0
- ), # type: ignore[call-arg]
+ ),
check_result=True,
)
else:
await self.send_command(
- HCI_LE_Set_Scan_Enable_Command(le_scan_enable=0, filter_duplicates=0), # type: ignore[call-arg]
+ HCI_LE_Set_Scan_Enable_Command(le_scan_enable=0, filter_duplicates=0),
check_result=True,
)
@@ -1667,7 +2356,7 @@ class Device(CompositeEventEmitter):
async def start_discovery(self, auto_restart: bool = True) -> None:
await self.send_command(
- HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE), # type: ignore[call-arg]
+ HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE),
check_result=True,
)
@@ -1676,7 +2365,7 @@ class Device(CompositeEventEmitter):
lap=HCI_GENERAL_INQUIRY_LAP,
inquiry_length=DEVICE_DEFAULT_INQUIRY_LENGTH,
num_responses=0, # Unlimited number of responses.
- ) # type: ignore[call-arg]
+ )
)
if response.status != HCI_Command_Status_Event.PENDING:
self.discovering = False
@@ -1687,7 +2376,7 @@ class Device(CompositeEventEmitter):
async def stop_discovery(self) -> None:
if self.discovering:
- await self.send_command(HCI_Inquiry_Cancel_Command(), check_result=True) # type: ignore[call-arg]
+ await self.send_command(HCI_Inquiry_Cancel_Command(), check_result=True)
self.auto_restart_inquiry = True
self.discovering = False
@@ -1735,7 +2424,7 @@ class Device(CompositeEventEmitter):
await self.send_command(
HCI_Write_Extended_Inquiry_Response_Command(
fec_required=0, extended_inquiry_response=self.inquiry_response
- ), # type: ignore[call-arg]
+ ),
check_result=True,
)
await self.set_scan_enable(
@@ -1924,7 +2613,7 @@ class Device(CompositeEventEmitter):
supervision_timeouts=supervision_timeouts,
min_ce_lengths=min_ce_lengths,
max_ce_lengths=max_ce_lengths,
- ) # type: ignore[call-arg]
+ )
)
else:
if HCI_LE_1M_PHY not in connection_parameters_preferences:
@@ -1953,7 +2642,7 @@ class Device(CompositeEventEmitter):
supervision_timeout=int(prefs.supervision_timeout / 10),
min_ce_length=int(prefs.min_ce_length / 0.625),
max_ce_length=int(prefs.max_ce_length / 0.625),
- ) # type: ignore[call-arg]
+ )
)
else:
# Save pending connection
@@ -1970,7 +2659,7 @@ class Device(CompositeEventEmitter):
clock_offset=0x0000,
allow_role_switch=0x01,
reserved=0,
- ) # type: ignore[call-arg]
+ )
)
if result.status != HCI_Command_Status_Event.PENDING:
@@ -1989,10 +2678,10 @@ class Device(CompositeEventEmitter):
)
except asyncio.TimeoutError:
if transport == BT_LE_TRANSPORT:
- await self.send_command(HCI_LE_Create_Connection_Cancel_Command()) # type: ignore[call-arg]
+ await self.send_command(HCI_LE_Create_Connection_Cancel_Command())
else:
await self.send_command(
- HCI_Create_Connection_Cancel_Command(bd_addr=peer_address) # type: ignore[call-arg]
+ HCI_Create_Connection_Cancel_Command(bd_addr=peer_address)
)
try:
@@ -2106,7 +2795,7 @@ class Device(CompositeEventEmitter):
try:
# Accept connection request
await self.send_command(
- HCI_Accept_Connection_Request_Command(bd_addr=peer_address, role=role) # type: ignore[call-arg]
+ HCI_Accept_Connection_Request_Command(bd_addr=peer_address, role=role)
)
# Wait for connection complete
@@ -2163,7 +2852,9 @@ class Device(CompositeEventEmitter):
check_result=True,
)
- async def disconnect(self, connection, reason):
+ async def disconnect(
+ self, connection: Union[Connection, ScoLink, CisLink], reason: int
+ ) -> None:
# Create a future so that we can wait for the disconnection's result
pending_disconnection = asyncio.get_running_loop().create_future()
connection.on('disconnection', pending_disconnection.set_result)
@@ -2190,6 +2881,22 @@ class Device(CompositeEventEmitter):
)
self.disconnecting = False
+ async def set_data_length(self, connection, tx_octets, tx_time) -> None:
+ if tx_octets < 0x001B or tx_octets > 0x00FB:
+ raise ValueError('tx_octets must be between 0x001B and 0x00FB')
+
+ if tx_time < 0x0148 or tx_time > 0x4290:
+ raise ValueError('tx_time must be between 0x0148 and 0x4290')
+
+ return await self.send_command(
+ HCI_LE_Set_Data_Length_Command(
+ connection_handle=connection.handle,
+ tx_octets=tx_octets,
+ tx_time=tx_time,
+ ),
+ check_result=True,
+ )
+
async def update_connection_parameters(
self,
connection,
@@ -2232,7 +2939,7 @@ class Device(CompositeEventEmitter):
supervision_timeout=supervision_timeout,
min_ce_length=min_ce_length,
max_ce_length=max_ce_length,
- ) # type: ignore[call-arg]
+ )
)
if result.status != HCI_Command_Status_Event.PENDING:
raise HCI_StatusError(result)
@@ -2560,7 +3267,7 @@ class Device(CompositeEventEmitter):
try:
result = await self.send_command(
- HCI_Switch_Role_Command(bd_addr=connection.peer_address, role=role) # type: ignore[call-arg]
+ HCI_Switch_Role_Command(bd_addr=connection.peer_address, role=role)
)
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warning(
@@ -2602,7 +3309,7 @@ class Device(CompositeEventEmitter):
page_scan_repetition_mode=HCI_Remote_Name_Request_Command.R2,
reserved=0,
clock_offset=0, # TODO investigate non-0 values
- ) # type: ignore[call-arg]
+ )
)
if result.status != HCI_COMMAND_STATUS_PENDING:
@@ -2618,6 +3325,172 @@ class Device(CompositeEventEmitter):
self.remove_listener('remote_name', handler)
self.remove_listener('remote_name_failure', failure_handler)
+ # [LE only]
+ @experimental('Only for testing.')
+ async def setup_cig(
+ self,
+ cig_id: int,
+ cis_id: List[int],
+ sdu_interval: Tuple[int, int],
+ framing: int,
+ max_sdu: Tuple[int, int],
+ retransmission_number: int,
+ max_transport_latency: Tuple[int, int],
+ ) -> List[int]:
+ """Sends HCI_LE_Set_CIG_Parameters_Command.
+
+ Args:
+ cig_id: CIG_ID.
+ cis_id: CID ID list.
+ sdu_interval: SDU intervals of (Central->Peripheral, Peripheral->Cental).
+ framing: Un-framing(0) or Framing(1).
+ max_sdu: Max SDU counts of (Central->Peripheral, Peripheral->Cental).
+ retransmission_number: retransmission_number.
+ max_transport_latency: Max transport latencies of
+ (Central->Peripheral, Peripheral->Cental).
+
+ Returns:
+ List of created CIS handles corresponding to the same order of [cid_id].
+ """
+ num_cis = len(cis_id)
+
+ response = await self.send_command(
+ HCI_LE_Set_CIG_Parameters_Command(
+ cig_id=cig_id,
+ sdu_interval_c_to_p=sdu_interval[0],
+ sdu_interval_p_to_c=sdu_interval[1],
+ worst_case_sca=0x00, # 251-500 ppm
+ packing=0x00, # Sequential
+ framing=framing,
+ max_transport_latency_c_to_p=max_transport_latency[0],
+ max_transport_latency_p_to_c=max_transport_latency[1],
+ cis_id=cis_id,
+ max_sdu_c_to_p=[max_sdu[0]] * num_cis,
+ max_sdu_p_to_c=[max_sdu[1]] * num_cis,
+ phy_c_to_p=[HCI_LE_2M_PHY] * num_cis,
+ phy_p_to_c=[HCI_LE_2M_PHY] * num_cis,
+ rtn_c_to_p=[retransmission_number] * num_cis,
+ rtn_p_to_c=[retransmission_number] * num_cis,
+ ),
+ check_result=True,
+ )
+
+ # Ideally, we should manage CIG lifecycle, but they are not useful for Unicast
+ # Server, so here it only provides a basic functionality for testing.
+ cis_handles = response.return_parameters.connection_handle[:]
+ for id, cis_handle in zip(cis_id, cis_handles):
+ self._pending_cis[cis_handle] = (id, cig_id)
+
+ return cis_handles
+
+ # [LE only]
+ @experimental('Only for testing.')
+ async def create_cis(self, cis_acl_pairs: List[Tuple[int, int]]) -> List[CisLink]:
+ for cis_handle, acl_handle in cis_acl_pairs:
+ acl_connection = self.lookup_connection(acl_handle)
+ assert acl_connection
+ cis_id, cig_id = self._pending_cis.pop(cis_handle)
+ self.cis_links[cis_handle] = CisLink(
+ device=self,
+ acl_connection=acl_connection,
+ handle=cis_handle,
+ cis_id=cis_id,
+ cig_id=cig_id,
+ )
+
+ with closing(EventWatcher()) as watcher:
+ pending_cis_establishments = {
+ cis_handle: asyncio.get_running_loop().create_future()
+ for cis_handle, _ in cis_acl_pairs
+ }
+
+ @watcher.on(self, 'cis_establishment')
+ def on_cis_establishment(cis_link: CisLink) -> None:
+ if pending_future := pending_cis_establishments.get(cis_link.handle):
+ pending_future.set_result(cis_link)
+
+ result = await self.send_command(
+ HCI_LE_Create_CIS_Command(
+ cis_connection_handle=[p[0] for p in cis_acl_pairs],
+ acl_connection_handle=[p[1] for p in cis_acl_pairs],
+ ),
+ )
+ if result.status != HCI_COMMAND_STATUS_PENDING:
+ logger.warning(
+ 'HCI_LE_Create_CIS_Command failed: '
+ f'{HCI_Constant.error_name(result.status)}'
+ )
+ raise HCI_StatusError(result)
+
+ return await asyncio.gather(*pending_cis_establishments.values())
+
+ # [LE only]
+ @experimental('Only for testing.')
+ async def accept_cis_request(self, handle: int) -> CisLink:
+ result = await self.send_command(
+ HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
+ )
+ if result.status != HCI_COMMAND_STATUS_PENDING:
+ logger.warning(
+ 'HCI_LE_Accept_CIS_Request_Command failed: '
+ f'{HCI_Constant.error_name(result.status)}'
+ )
+ raise HCI_StatusError(result)
+
+ pending_cis_establishment = asyncio.get_running_loop().create_future()
+
+ with closing(EventWatcher()) as watcher:
+
+ @watcher.on(self, 'cis_establishment')
+ def on_cis_establishment(cis_link: CisLink) -> None:
+ if cis_link.handle == handle:
+ pending_cis_establishment.set_result(cis_link)
+
+ return await pending_cis_establishment
+
+ # [LE only]
+ @experimental('Only for testing.')
+ async def reject_cis_request(
+ self,
+ handle: int,
+ reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
+ ) -> None:
+ result = await self.send_command(
+ HCI_LE_Reject_CIS_Request_Command(connection_handle=handle, reason=reason),
+ )
+ if result.status != HCI_COMMAND_STATUS_PENDING:
+ logger.warning(
+ 'HCI_LE_Reject_CIS_Request_Command failed: '
+ f'{HCI_Constant.error_name(result.status)}'
+ )
+ raise HCI_StatusError(result)
+
+ async def get_remote_le_features(self, connection: Connection) -> LeFeatureMask:
+ """[LE Only] Reads remote LE supported features.
+
+ Args:
+ handle: connection handle to read LE features.
+
+ Returns:
+ LE features supported by the remote device.
+ """
+ with closing(EventWatcher()) as watcher:
+ read_feature_future: asyncio.Future[
+ LeFeatureMask
+ ] = asyncio.get_running_loop().create_future()
+
+ def on_le_remote_features(handle: int, features: int):
+ if handle == connection.handle:
+ read_feature_future.set_result(LeFeatureMask(features))
+
+ watcher.on(self.host, 'le_remote_features', on_le_remote_features)
+ await self.send_command(
+ HCI_LE_Read_Remote_Features_Command(
+ connection_handle=connection.handle
+ ),
+ )
+ return await read_feature_future
+
@host_event_handler
def on_flush(self):
self.emit('flush')
@@ -2670,6 +3543,74 @@ class Device(CompositeEventEmitter):
await self.gatt_server.indicate_subscribers(attribute, value, force)
@host_event_handler
+ def on_advertising_set_termination(
+ self,
+ status,
+ advertising_handle,
+ connection_handle,
+ number_of_completed_extended_advertising_events,
+ ):
+ # Legacy advertising set is also one of extended advertising sets.
+ if not (
+ advertising_set := self.extended_advertising_sets.get(advertising_handle)
+ ):
+ logger.warning(f'advertising set {advertising_handle} not found')
+ return
+
+ advertising_set.on_termination(status)
+
+ if status != HCI_SUCCESS:
+ logger.debug(
+ f'advertising set {advertising_handle} '
+ f'terminated with status {status}'
+ )
+ return
+
+ if not (connection := self.lookup_connection(connection_handle)):
+ logger.warning(f'no connection for handle 0x{connection_handle:04x}')
+ return
+
+ # Update the connection address.
+ connection.self_address = (
+ advertising_set.random_address
+ if advertising_set.advertising_parameters.own_address_type
+ in (OwnAddressType.RANDOM, OwnAddressType.RESOLVABLE_OR_RANDOM)
+ else self.public_address
+ )
+
+ # Setup auto-restart of the advertising set if needed.
+ if advertising_set.auto_restart:
+ connection.once(
+ 'disconnection',
+ lambda _: self.abort_on('flush', advertising_set.start()),
+ )
+
+ self._emit_le_connection(connection)
+
+ def _emit_le_connection(self, connection: Connection) -> None:
+ # If supported, read which PHY we're connected with before
+ # notifying listeners of the new connection.
+ if self.host.supports_command(HCI_LE_READ_PHY_COMMAND):
+
+ async def read_phy():
+ result = await self.send_command(
+ HCI_LE_Read_PHY_Command(connection_handle=connection.handle),
+ check_result=True,
+ )
+ connection.phy = ConnectionPHY(
+ result.return_parameters.tx_phy, result.return_parameters.rx_phy
+ )
+ # Emit an event to notify listeners of the new connection
+ self.emit('connection', connection)
+
+ # Do so asynchronously to not block the current event handler
+ connection.abort_on('disconnection', read_phy())
+
+ return
+
+ self.emit('connection', connection)
+
+ @host_event_handler
def on_connection(
self,
connection_handle,
@@ -2687,8 +3628,6 @@ class Device(CompositeEventEmitter):
'new connection reuses the same handle as a previous connection'
)
- peer_resolvable_address = None
-
if transport == BT_BR_EDR_TRANSPORT:
# Create a new connection
connection = self.pending_connections.pop(peer_address)
@@ -2697,70 +3636,76 @@ class Device(CompositeEventEmitter):
# Emit an event to notify listeners of the new connection
self.emit('connection', connection)
- else:
- # Resolve the peer address if we can
- if self.address_resolver:
- if peer_address.is_resolvable:
- resolved_address = self.address_resolver.resolve(peer_address)
- if resolved_address is not None:
- logger.debug(f'*** Address resolved as {resolved_address}')
- peer_resolvable_address = peer_address
- peer_address = resolved_address
-
- # Guess which own address type is used for this connection.
- # This logic is somewhat correct but may need to be improved
- # when multiple advertising are run simultaneously.
- if self.connect_own_address_type is not None:
- own_address_type = self.connect_own_address_type
- else:
- own_address_type = self.advertising_own_address_type
- # We are no longer advertising
- self.advertising = False
+ return
- if own_address_type in (
- OwnAddressType.PUBLIC,
- OwnAddressType.RESOLVABLE_OR_PUBLIC,
- ):
- self_address = self.public_address
+ # Resolve the peer address if we can
+ peer_resolvable_address = None
+ if self.address_resolver:
+ if peer_address.is_resolvable:
+ resolved_address = self.address_resolver.resolve(peer_address)
+ if resolved_address is not None:
+ logger.debug(f'*** Address resolved as {resolved_address}')
+ peer_resolvable_address = peer_address
+ peer_address = resolved_address
+
+ self_address = None
+ if role == HCI_CENTRAL_ROLE:
+ own_address_type = self.connect_own_address_type
+ assert own_address_type is not None
+ else:
+ if self.supports_le_extended_advertising:
+ # We'll know the address when the advertising set terminates,
+ # Use a temporary placeholder value for self_address.
+ self_address = Address.ANY_RANDOM
else:
- self_address = self.random_address
-
- # Create a new connection
- connection = Connection(
- self,
- connection_handle,
- transport,
- self_address,
- peer_address,
- peer_resolvable_address,
- role,
- connection_parameters,
- ConnectionPHY(HCI_LE_1M_PHY, HCI_LE_1M_PHY),
+ # We were connected via a legacy advertisement.
+ if self.legacy_advertiser:
+ own_address_type = self.legacy_advertiser.own_address_type
+ self.legacy_advertiser = None
+ else:
+ # This should not happen, but just in case, pick a default.
+ logger.warning("connection without an advertiser")
+ self_address = self.random_address
+
+ if self_address is None:
+ self_address = (
+ self.public_address
+ if own_address_type
+ in (
+ OwnAddressType.PUBLIC,
+ OwnAddressType.RESOLVABLE_OR_PUBLIC,
+ )
+ else self.random_address
)
- self.connections[connection_handle] = connection
-
- # If supported, read which PHY we're connected with before
- # notifying listeners of the new connection.
- if self.host.supports_command(HCI_LE_READ_PHY_COMMAND):
- async def read_phy():
- result = await self.send_command(
- HCI_LE_Read_PHY_Command(connection_handle=connection_handle),
- check_result=True,
- )
- connection.phy = ConnectionPHY(
- result.return_parameters.tx_phy, result.return_parameters.rx_phy
- )
- # Emit an event to notify listeners of the new connection
- self.emit('connection', connection)
+ # Create a connection.
+ connection = Connection(
+ self,
+ connection_handle,
+ transport,
+ self_address,
+ peer_address,
+ peer_resolvable_address,
+ role,
+ connection_parameters,
+ ConnectionPHY(HCI_LE_1M_PHY, HCI_LE_1M_PHY),
+ )
+ self.connections[connection_handle] = connection
- # Do so asynchronously to not block the current event handler
- connection.abort_on('disconnection', read_phy())
+ if (
+ role == HCI_PERIPHERAL_ROLE
+ and self.legacy_advertiser
+ and self.legacy_advertiser.auto_restart
+ ):
+ connection.once(
+ 'disconnection',
+ lambda _: self.abort_on('flush', self.legacy_advertiser.start()),
+ )
- else:
- # Emit an event to notify listeners of the new connection
- self.emit('connection', connection)
+ if role == HCI_CENTRAL_ROLE or not self.supports_le_extended_advertising:
+ # We can emit now, we have all the info we need
+ self._emit_le_connection(connection)
@host_event_handler
def on_connection_failure(self, transport, peer_address, error_code):
@@ -2769,10 +3714,10 @@ class Device(CompositeEventEmitter):
# For directed advertising, this means a timeout
if (
transport == BT_LE_TRANSPORT
- and self.advertising
- and self.advertising_type.is_directed
+ and self.legacy_advertiser
+ and self.legacy_advertiser.advertising_type.is_directed
):
- self.advertising = False
+ self.legacy_advertiser = None
# Notify listeners
error = core.ConnectionError(
@@ -2789,8 +3734,21 @@ class Device(CompositeEventEmitter):
def on_connection_request(self, bd_addr, class_of_device, link_type):
logger.debug(f'*** Connection request: {bd_addr}')
+ # Handle SCO request.
+ if link_type in (
+ HCI_Connection_Complete_Event.SCO_LINK_TYPE,
+ HCI_Connection_Complete_Event.ESCO_LINK_TYPE,
+ ):
+ if connection := self.find_connection_by_bd_addr(
+ bd_addr, transport=BT_BR_EDR_TRANSPORT
+ ):
+ self.emit('sco_request', connection, link_type)
+ else:
+ logger.error(f'SCO request from a non-connected device {bd_addr}')
+ return
+
# match a pending future using `bd_addr`
- if bd_addr in self.classic_pending_accepts:
+ elif bd_addr in self.classic_pending_accepts:
future, *_ = self.classic_pending_accepts.pop(bd_addr)
future.set_result((bd_addr, class_of_device, link_type))
@@ -2822,30 +3780,23 @@ class Device(CompositeEventEmitter):
)
@host_event_handler
- @with_connection_from_handle
- def on_disconnection(self, connection, reason):
- logger.debug(
- f'*** Disconnection: [0x{connection.handle:04X}] '
- f'{connection.peer_address} as {connection.role_name}, reason={reason}'
- )
- connection.emit('disconnection', reason)
-
- # Remove the connection from the map
- del self.connections[connection.handle]
-
- # Cleanup subsystems that maintain per-connection state
- self.gatt_server.on_disconnection(connection)
-
- # Restart advertising if auto-restart is enabled
- if self.auto_restart_advertising:
- logger.debug('restarting advertising')
- self.abort_on(
- 'flush',
- self.start_advertising(
- advertising_type=self.advertising_type,
- own_address_type=self.advertising_own_address_type,
- auto_restart=True,
- ),
+ def on_disconnection(self, connection_handle: int, reason: int) -> None:
+ if connection := self.connections.pop(connection_handle, None):
+ logger.debug(
+ f'*** Disconnection: [0x{connection.handle:04X}] '
+ f'{connection.peer_address} as {connection.role_name}, reason={reason}'
+ )
+ connection.emit('disconnection', reason)
+
+ # Cleanup subsystems that maintain per-connection state
+ self.gatt_server.on_disconnection(connection)
+ elif sco_link := self.sco_links.pop(connection_handle, None):
+ sco_link.emit('disconnection', reason)
+ elif cis_link := self.cis_links.pop(connection_handle, None):
+ cis_link.emit('disconnection', reason)
+ else:
+ logger.error(
+ f'*** Unknown disconnection handle=0x{connection_handle}, reason={reason} ***'
)
@host_event_handler
@@ -2996,7 +3947,7 @@ class Device(CompositeEventEmitter):
try:
if await connection.abort_on('disconnection', method()):
await self.host.send_command(
- HCI_User_Confirmation_Request_Reply_Command( # type: ignore[call-arg]
+ HCI_User_Confirmation_Request_Reply_Command(
bd_addr=connection.peer_address
)
)
@@ -3005,7 +3956,7 @@ class Device(CompositeEventEmitter):
logger.warning(f'exception while confirming: {error}')
await self.host.send_command(
- HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg]
+ HCI_User_Confirmation_Request_Negative_Reply_Command(
bd_addr=connection.peer_address
)
)
@@ -3026,7 +3977,7 @@ class Device(CompositeEventEmitter):
)
if number is not None:
await self.host.send_command(
- HCI_User_Passkey_Request_Reply_Command( # type: ignore[call-arg]
+ HCI_User_Passkey_Request_Reply_Command(
bd_addr=connection.peer_address, numeric_value=number
)
)
@@ -3035,7 +3986,7 @@ class Device(CompositeEventEmitter):
logger.warning(f'exception while asking for pass-key: {error}')
await self.host.send_command(
- HCI_User_Passkey_Request_Negative_Reply_Command( # type: ignore[call-arg]
+ HCI_User_Passkey_Request_Negative_Reply_Command(
bd_addr=connection.peer_address
)
)
@@ -3124,6 +4075,107 @@ class Device(CompositeEventEmitter):
connection.emit('remote_name_failure', error)
self.emit('remote_name_failure', address, error)
+ # [Classic only]
+ @host_event_handler
+ @with_connection_from_address
+ @experimental('Only for testing.')
+ def on_sco_connection(
+ self, acl_connection: Connection, sco_handle: int, link_type: int
+ ) -> None:
+ logger.debug(
+ f'*** SCO connected: {acl_connection.peer_address}, '
+ f'sco_handle=[0x{sco_handle:04X}], '
+ f'link_type=[0x{link_type:02X}] ***'
+ )
+ sco_link = self.sco_links[sco_handle] = ScoLink(
+ device=self,
+ acl_connection=acl_connection,
+ handle=sco_handle,
+ link_type=link_type,
+ )
+ self.emit('sco_connection', sco_link)
+
+ # [Classic only]
+ @host_event_handler
+ @with_connection_from_address
+ @experimental('Only for testing.')
+ def on_sco_connection_failure(
+ self, acl_connection: Connection, status: int
+ ) -> None:
+ logger.debug(f'*** SCO connection failure: {acl_connection.peer_address}***')
+ self.emit('sco_connection_failure')
+
+ # [Classic only]
+ @host_event_handler
+ @experimental('Only for testing')
+ def on_sco_packet(self, sco_handle: int, packet: HCI_SynchronousDataPacket) -> None:
+ if sco_link := self.sco_links.get(sco_handle):
+ sco_link.emit('pdu', packet)
+
+ # [LE only]
+ @host_event_handler
+ @with_connection_from_handle
+ @experimental('Only for testing')
+ def on_cis_request(
+ self,
+ acl_connection: Connection,
+ cis_handle: int,
+ cig_id: int,
+ cis_id: int,
+ ) -> None:
+ logger.debug(
+ f'*** CIS Request '
+ f'acl_handle=[0x{acl_connection.handle:04X}]{acl_connection.peer_address}, '
+ f'cis_handle=[0x{cis_handle:04X}], '
+ f'cig_id=[0x{cig_id:02X}], '
+ f'cis_id=[0x{cis_id:02X}] ***'
+ )
+ # LE_CIS_Established event doesn't provide info, so we must store them here.
+ self.cis_links[cis_handle] = CisLink(
+ device=self,
+ acl_connection=acl_connection,
+ handle=cis_handle,
+ cig_id=cig_id,
+ cis_id=cis_id,
+ )
+ self.emit('cis_request', acl_connection, cis_handle, cig_id, cis_id)
+
+ # [LE only]
+ @host_event_handler
+ @experimental('Only for testing')
+ def on_cis_establishment(self, cis_handle: int) -> None:
+ cis_link = self.cis_links[cis_handle]
+ cis_link.state = CisLink.State.ESTABLISHED
+
+ assert cis_link.acl_connection
+
+ logger.debug(
+ f'*** CIS Establishment '
+ f'{cis_link.acl_connection.peer_address}, '
+ f'cis_handle=[0x{cis_handle:04X}], '
+ f'cig_id=[0x{cis_link.cig_id:02X}], '
+ f'cis_id=[0x{cis_link.cis_id:02X}] ***'
+ )
+
+ cis_link.emit('establishment')
+ self.emit('cis_establishment', cis_link)
+
+ # [LE only]
+ @host_event_handler
+ @experimental('Only for testing')
+ def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None:
+ logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***')
+ if cis_link := self.cis_links.pop(cis_handle):
+ cis_link.emit('establishment_failure')
+ self.emit('cis_establishment_failure', cis_handle, status)
+
+ # [LE only]
+ @host_event_handler
+ @experimental('Only for testing')
+ def on_iso_packet(self, handle: int, packet: HCI_IsoDataPacket) -> None:
+ if cis_link := self.cis_links.get(handle):
+ cis_link.emit('pdu', packet)
+
@host_event_handler
@with_connection_from_handle
def on_connection_encryption_change(self, connection, encryption):
@@ -3135,10 +4187,18 @@ class Device(CompositeEventEmitter):
connection.encryption = encryption
if (
not connection.authenticated
+ and connection.transport == BT_BR_EDR_TRANSPORT
and encryption == HCI_Encryption_Change_Event.AES_CCM
):
connection.authenticated = True
connection.sc = True
+ if (
+ not connection.authenticated
+ and connection.transport == BT_LE_TRANSPORT
+ and encryption == HCI_Encryption_Change_Event.E0_OR_AES_CCM
+ ):
+ connection.authenticated = True
+ connection.sc = True
connection.emit('connection_encryption_change')
@host_event_handler
diff --git a/bumble/drivers/__init__.py b/bumble/drivers/__init__.py
index d8ea06e..1e72665 100644
--- a/bumble/drivers/__init__.py
+++ b/bumble/drivers/__init__.py
@@ -19,12 +19,17 @@ like loading firmware after a cold start.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
-import abc
+from __future__ import annotations
import logging
import pathlib
import platform
-from . import rtk
+from typing import Dict, Iterable, Optional, Type, TYPE_CHECKING
+from . import rtk, intel
+from .common import Driver
+
+if TYPE_CHECKING:
+ from bumble.host import Host
# -----------------------------------------------------------------------------
# Logging
@@ -33,39 +38,30 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-# Classes
-# -----------------------------------------------------------------------------
-class Driver(abc.ABC):
- """Base class for drivers."""
-
- @staticmethod
- async def for_host(_host):
- """Return a driver instance for a host.
-
- Args:
- host: Host object for which a driver should be created.
-
- Returns:
- A Driver instance if a driver should be instantiated for this host, or
- None if no driver instance of this class is needed.
- """
- return None
-
- @abc.abstractmethod
- async def init_controller(self):
- """Initialize the controller."""
-
-
-# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
-async def get_driver_for_host(host):
- """Probe all known diver classes until one returns a valid instance for a host,
- or none is found.
+async def get_driver_for_host(host: Host) -> Optional[Driver]:
+ """Probe diver classes until one returns a valid instance for a host, or none is
+ found.
+ If a "driver" HCI metadata entry is present, only that driver class will be probed.
"""
- if driver := await rtk.Driver.for_host(host):
- logger.debug("Instantiated RTK driver")
- return driver
+ driver_classes: Dict[str, Type[Driver]] = {"rtk": rtk.Driver, "intel": intel.Driver}
+ probe_list: Iterable[str]
+ if driver_name := host.hci_metadata.get("driver"):
+ # Only probe a single driver
+ probe_list = [driver_name]
+ else:
+ # Probe all drivers
+ probe_list = driver_classes.keys()
+
+ for driver_name in probe_list:
+ if driver_class := driver_classes.get(driver_name):
+ logger.debug(f"Probing driver class: {driver_name}")
+ if driver := await driver_class.for_host(host):
+ logger.debug(f"Instantiated {driver_name} driver")
+ return driver
+ else:
+ logger.debug(f"Skipping unknown driver class: {driver_name}")
return None
diff --git a/bumble/drivers/common.py b/bumble/drivers/common.py
new file mode 100644
index 0000000..a4c0427
--- /dev/null
+++ b/bumble/drivers/common.py
@@ -0,0 +1,45 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Common types for drivers.
+"""
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import abc
+
+
+# -----------------------------------------------------------------------------
+# Classes
+# -----------------------------------------------------------------------------
+class Driver(abc.ABC):
+ """Base class for drivers."""
+
+ @staticmethod
+ async def for_host(_host):
+ """Return a driver instance for a host.
+
+ Args:
+ host: Host object for which a driver should be created.
+
+ Returns:
+ A Driver instance if a driver should be instantiated for this host, or
+ None if no driver instance of this class is needed.
+ """
+ return None
+
+ @abc.abstractmethod
+ async def init_controller(self):
+ """Initialize the controller."""
diff --git a/bumble/drivers/intel.py b/bumble/drivers/intel.py
new file mode 100644
index 0000000..e613c1e
--- /dev/null
+++ b/bumble/drivers/intel.py
@@ -0,0 +1,102 @@
+# Copyright 2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import logging
+
+from bumble.drivers import common
+from bumble.hci import (
+ hci_vendor_command_op_code, # type: ignore
+ HCI_Command,
+ HCI_Reset_Command,
+)
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+# -----------------------------------------------------------------------------
+# Constant
+# -----------------------------------------------------------------------------
+
+INTEL_USB_PRODUCTS = {
+ # Intel AX210
+ (0x8087, 0x0032),
+ # Intel BE200
+ (0x8087, 0x0036),
+}
+
+# -----------------------------------------------------------------------------
+# HCI Commands
+# -----------------------------------------------------------------------------
+HCI_INTEL_DDC_CONFIG_WRITE_COMMAND = hci_vendor_command_op_code(0xFC8B) # type: ignore
+HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD = [0x03, 0xE4, 0x02, 0x00]
+
+HCI_Command.register_commands(globals())
+
+
+@HCI_Command.command( # type: ignore
+ fields=[("params", "*")],
+ return_parameters_fields=[
+ ("params", "*"),
+ ],
+)
+class Hci_Intel_DDC_Config_Write_Command(HCI_Command):
+ pass
+
+
+class Driver(common.Driver):
+ def __init__(self, host):
+ self.host = host
+
+ @staticmethod
+ def check(host):
+ driver = host.hci_metadata.get("driver")
+ if driver == "intel":
+ return True
+
+ vendor_id = host.hci_metadata.get("vendor_id")
+ product_id = host.hci_metadata.get("product_id")
+
+ if vendor_id is None or product_id is None:
+ logger.debug("USB metadata not sufficient")
+ return False
+
+ if (vendor_id, product_id) not in INTEL_USB_PRODUCTS:
+ logger.debug(
+ f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
+ )
+ return False
+
+ return True
+
+ @classmethod
+ async def for_host(cls, host, force=False): # type: ignore
+ # Only instantiate this driver if explicitly selected
+ if not force and not cls.check(host):
+ return None
+
+ return cls(host)
+
+ async def init_controller(self):
+ self.host.ready = True
+ await self.host.send_command(HCI_Reset_Command(), check_result=True)
+ await self.host.send_command(
+ Hci_Intel_DDC_Config_Write_Command(
+ params=HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD
+ )
+ )
diff --git a/bumble/drivers/rtk.py b/bumble/drivers/rtk.py
index f78a14d..4a9034d 100644
--- a/bumble/drivers/rtk.py
+++ b/bumble/drivers/rtk.py
@@ -41,7 +41,7 @@ from bumble.hci import (
HCI_Reset_Command,
HCI_Read_Local_Version_Information_Command,
)
-
+from bumble.drivers import common
# -----------------------------------------------------------------------------
# Logging
@@ -285,7 +285,7 @@ class Firmware:
)
-class Driver:
+class Driver(common.Driver):
@dataclass
class DriverInfo:
rom: int
@@ -470,8 +470,12 @@ class Driver:
logger.debug("USB metadata not found")
return False
- vendor_id = host.hci_metadata.get("vendor_id", None)
- product_id = host.hci_metadata.get("product_id", None)
+ if host.hci_metadata.get('driver') == 'rtk':
+ # Forced driver
+ return True
+
+ vendor_id = host.hci_metadata.get("vendor_id")
+ product_id = host.hci_metadata.get("product_id")
if vendor_id is None or product_id is None:
logger.debug("USB metadata not sufficient")
return False
@@ -486,6 +490,9 @@ class Driver:
@classmethod
async def driver_info_for_host(cls, host):
+ await host.send_command(HCI_Reset_Command(), check_result=True)
+ host.ready = True # Needed to let the host know the controller is ready.
+
response = await host.send_command(
HCI_Read_Local_Version_Information_Command(), check_result=True
)
diff --git a/bumble/gatt.py b/bumble/gatt.py
index fe3e85c..71c01f4 100644
--- a/bumble/gatt.py
+++ b/bumble/gatt.py
@@ -23,16 +23,28 @@
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
-import asyncio
import enum
import functools
import logging
import struct
-from typing import Optional, Sequence, Iterable, List, Union
-
-from .colors import color
-from .core import UUID, get_dict_key_by_value
-from .att import Attribute
+from typing import (
+ Callable,
+ Dict,
+ Iterable,
+ List,
+ Optional,
+ Sequence,
+ Union,
+ TYPE_CHECKING,
+)
+
+from bumble.colors import color
+from bumble.core import UUID
+from bumble.att import Attribute, AttributeValue
+
+if TYPE_CHECKING:
+ from bumble.gatt_client import AttributeProxy
+ from bumble.device import Connection
# -----------------------------------------------------------------------------
@@ -93,20 +105,35 @@ GATT_RECONNECTION_CONFIGURATION_SERVICE = UUID.from_16_bits(0x1829, 'Reconne
GATT_INSULIN_DELIVERY_SERVICE = UUID.from_16_bits(0x183A, 'Insulin Delivery')
GATT_BINARY_SENSOR_SERVICE = UUID.from_16_bits(0x183B, 'Binary Sensor')
GATT_EMERGENCY_CONFIGURATION_SERVICE = UUID.from_16_bits(0x183C, 'Emergency Configuration')
+GATT_AUTHORIZATION_CONTROL_SERVICE = UUID.from_16_bits(0x183D, 'Authorization Control')
GATT_PHYSICAL_ACTIVITY_MONITOR_SERVICE = UUID.from_16_bits(0x183E, 'Physical Activity Monitor')
+GATT_ELAPSED_TIME_SERVICE = UUID.from_16_bits(0x183F, 'Elapsed Time')
+GATT_GENERIC_HEALTH_SENSOR_SERVICE = UUID.from_16_bits(0x1840, 'Generic Health Sensor')
GATT_AUDIO_INPUT_CONTROL_SERVICE = UUID.from_16_bits(0x1843, 'Audio Input Control')
GATT_VOLUME_CONTROL_SERVICE = UUID.from_16_bits(0x1844, 'Volume Control')
GATT_VOLUME_OFFSET_CONTROL_SERVICE = UUID.from_16_bits(0x1845, 'Volume Offset Control')
-GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification Service')
+GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification')
GATT_DEVICE_TIME_SERVICE = UUID.from_16_bits(0x1847, 'Device Time')
-GATT_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, 'Media Control Service')
-GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, 'Generic Media Control Service')
+GATT_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, 'Media Control')
+GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, 'Generic Media Control')
GATT_CONSTANT_TONE_EXTENSION_SERVICE = UUID.from_16_bits(0x184A, 'Constant Tone Extension')
-GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, 'Telephone Bearer Service')
-GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer Service')
+GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, 'Telephone Bearer')
+GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer')
GATT_MICROPHONE_CONTROL_SERVICE = UUID.from_16_bits(0x184D, 'Microphone Control')
-
-# Types
+GATT_AUDIO_STREAM_CONTROL_SERVICE = UUID.from_16_bits(0x184E, 'Audio Stream Control')
+GATT_BROADCAST_AUDIO_SCAN_SERVICE = UUID.from_16_bits(0x184F, 'Broadcast Audio Scan')
+GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE = UUID.from_16_bits(0x1850, 'Published Audio Capabilities')
+GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1851, 'Basic Audio Announcement')
+GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1852, 'Broadcast Audio Announcement')
+GATT_COMMON_AUDIO_SERVICE = UUID.from_16_bits(0x1853, 'Common Audio')
+GATT_HEARING_ACCESS_SERVICE = UUID.from_16_bits(0x1854, 'Hearing Access')
+GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE = UUID.from_16_bits(0x1855, 'Telephony and Media Audio')
+GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1856, 'Public Broadcast Announcement')
+GATT_ELECTRONIC_SHELF_LABEL_SERVICE = UUID.from_16_bits(0X1857, 'Electronic Shelf Label')
+GATT_GAMING_AUDIO_SERVICE = UUID.from_16_bits(0x1858, 'Gaming Audio')
+GATT_MESH_PROXY_SOLICITATION_SERVICE = UUID.from_16_bits(0x1859, 'Mesh Audio Solicitation')
+
+# Attribute Types
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2800, 'Primary Service')
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2801, 'Secondary Service')
GATT_INCLUDE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2802, 'Include')
@@ -129,6 +156,8 @@ GATT_ENVIRONMENTAL_SENSING_MEASUREMENT_DESCRIPTOR = UUID.from_16_bits(0x290C,
GATT_ENVIRONMENTAL_SENSING_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290D, 'Environmental Sensing Trigger Setting')
GATT_TIME_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290E, 'Time Trigger Setting')
GATT_COMPLETE_BR_EDR_TRANSPORT_BLOCK_DATA_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Complete BR-EDR Transport Block Data')
+GATT_OBSERVATION_SCHEDULE_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Observation Schedule')
+GATT_VALID_RANGE_AND_ACCURACY_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Valid Range And Accuracy')
# Device Information Service
GATT_SYSTEM_ID_CHARACTERISTIC = UUID.from_16_bits(0x2A23, 'System ID')
@@ -156,6 +185,96 @@ GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2A39, 'Heart
# Battery Service
GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level')
+# Telephony And Media Audio Service (TMAS)
+GATT_TMAP_ROLE_CHARACTERISTIC = UUID.from_16_bits(0x2B51, 'TMAP Role')
+
+# Audio Input Control Service (AICS)
+GATT_AUDIO_INPUT_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B77, 'Audio Input State')
+GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC = UUID.from_16_bits(0x2B78, 'Gain Settings Attribute')
+GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC = UUID.from_16_bits(0x2B79, 'Audio Input Type')
+GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC = UUID.from_16_bits(0x2B7A, 'Audio Input Status')
+GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B7B, 'Audio Input Control Point')
+GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC = UUID.from_16_bits(0x2B7C, 'Audio Input Description')
+
+# Volume Control Service (VCS)
+GATT_VOLUME_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B7D, 'Volume State')
+GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B7E, 'Volume Control Point')
+GATT_VOLUME_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2B7F, 'Volume Flags')
+
+# Volume Offset Control Service (VOCS)
+GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B80, 'Volume Offset State')
+GATT_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2B81, 'Audio Location')
+GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B82, 'Volume Offset Control Point')
+GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC = UUID.from_16_bits(0x2B83, 'Audio Output Description')
+
+# Coordinated Set Identification Service (CSIS)
+GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC = UUID.from_16_bits(0x2B84, 'Set Identity Resolving Key')
+GATT_COORDINATED_SET_SIZE_CHARACTERISTIC = UUID.from_16_bits(0x2B85, 'Coordinated Set Size')
+GATT_SET_MEMBER_LOCK_CHARACTERISTIC = UUID.from_16_bits(0x2B86, 'Set Member Lock')
+GATT_SET_MEMBER_RANK_CHARACTERISTIC = UUID.from_16_bits(0x2B87, 'Set Member Rank')
+
+# Media Control Service (MCS)
+GATT_MEDIA_PLAYER_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2B93, 'Media Player Name')
+GATT_MEDIA_PLAYER_ICON_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B94, 'Media Player Icon Object ID')
+GATT_MEDIA_PLAYER_ICON_URL_CHARACTERISTIC = UUID.from_16_bits(0x2B95, 'Media Player Icon URL')
+GATT_TRACK_CHANGED_CHARACTERISTIC = UUID.from_16_bits(0x2B96, 'Track Changed')
+GATT_TRACK_TITLE_CHARACTERISTIC = UUID.from_16_bits(0x2B97, 'Track Title')
+GATT_TRACK_DURATION_CHARACTERISTIC = UUID.from_16_bits(0x2B98, 'Track Duration')
+GATT_TRACK_POSITION_CHARACTERISTIC = UUID.from_16_bits(0x2B99, 'Track Position')
+GATT_PLAYBACK_SPEED_CHARACTERISTIC = UUID.from_16_bits(0x2B9A, 'Playback Speed')
+GATT_SEEKING_SPEED_CHARACTERISTIC = UUID.from_16_bits(0x2B9B, 'Seeking Speed')
+GATT_CURRENT_TRACK_SEGMENTS_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9C, 'Current Track Segments Object ID')
+GATT_CURRENT_TRACK_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9D, 'Current Track Object ID')
+GATT_NEXT_TRACK_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9E, 'Next Track Object ID')
+GATT_PARENT_GROUP_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9F, 'Parent Group Object ID')
+GATT_CURRENT_GROUP_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BA0, 'Current Group Object ID')
+GATT_PLAYING_ORDER_CHARACTERISTIC = UUID.from_16_bits(0x2BA1, 'Playing Order')
+GATT_PLAYING_ORDERS_SUPPORTED_CHARACTERISTIC = UUID.from_16_bits(0x2BA2, 'Playing Orders Supported')
+GATT_MEDIA_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BA3, 'Media State')
+GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BA4, 'Media Control Point')
+GATT_MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_CHARACTERISTIC = UUID.from_16_bits(0x2BA5, 'Media Control Point Opcodes Supported')
+GATT_SEARCH_RESULTS_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BA6, 'Search Results Object ID')
+GATT_SEARCH_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BA7, 'Search Control Point')
+GATT_CONTENT_CONTROL_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BBA, 'Content Control Id')
+
+# Telephone Bearer Service (TBS)
+GATT_BEARER_PROVIDER_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BB4, 'Bearer Provider Name')
+GATT_BEARER_UCI_CHARACTERISTIC = UUID.from_16_bits(0x2BB5, 'Bearer UCI')
+GATT_BEARER_TECHNOLOGY_CHARACTERISTIC = UUID.from_16_bits(0x2BB6, 'Bearer Technology')
+GATT_BEARER_URI_SCHEMES_SUPPORTED_LIST_CHARACTERISTIC = UUID.from_16_bits(0x2BB7, 'Bearer URI Schemes Supported List')
+GATT_BEARER_SIGNAL_STRENGTH_CHARACTERISTIC = UUID.from_16_bits(0x2BB8, 'Bearer Signal Strength')
+GATT_BEARER_SIGNAL_STRENGTH_REPORTING_INTERVAL_CHARACTERISTIC = UUID.from_16_bits(0x2BB9, 'Bearer Signal Strength Reporting Interval')
+GATT_BEARER_LIST_CURRENT_CALLS_CHARACTERISTIC = UUID.from_16_bits(0x2BBA, 'Bearer List Current Calls')
+GATT_CONTENT_CONTROL_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BBB, 'Content Control ID')
+GATT_STATUS_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2BBC, 'Status Flags')
+GATT_INCOMING_CALL_TARGET_BEARER_URI_CHARACTERISTIC = UUID.from_16_bits(0x2BBD, 'Incoming Call Target Bearer URI')
+GATT_CALL_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BBE, 'Call State')
+GATT_CALL_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BBF, 'Call Control Point')
+GATT_CALL_CONTROL_POINT_OPTIONAL_OPCODES_CHARACTERISTIC = UUID.from_16_bits(0x2BC0, 'Call Control Point Optional Opcodes')
+GATT_TERMINATION_REASON_CHARACTERISTIC = UUID.from_16_bits(0x2BC1, 'Termination Reason')
+GATT_INCOMING_CALL_CHARACTERISTIC = UUID.from_16_bits(0x2BC2, 'Incoming Call')
+GATT_CALL_FRIENDLY_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BC3, 'Call Friendly Name')
+
+# Microphone Control Service (MICS)
+GATT_MUTE_CHARACTERISTIC = UUID.from_16_bits(0x2BC3, 'Mute')
+
+# Audio Stream Control Service (ASCS)
+GATT_SINK_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC4, 'Sink ASE')
+GATT_SOURCE_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC5, 'Source ASE')
+GATT_ASE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BC6, 'ASE Control Point')
+
+# Broadcast Audio Scan Service (BASS)
+GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BC7, 'Broadcast Audio Scan Control Point')
+GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BC8, 'Broadcast Receive State')
+
+# Published Audio Capabilities Service (PACS)
+GATT_SINK_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BC9, 'Sink PAC')
+GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCA, 'Sink Audio Location')
+GATT_SOURCE_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BCB, 'Source PAC')
+GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCC, 'Source Audio Location')
+GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, 'Available Audio Contexts')
+GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, 'Supported Audio Contexts')
+
# ASHA Service
GATT_ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid')
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties')
@@ -177,6 +296,9 @@ GATT_BOOT_KEYBOARD_INPUT_REPORT_CHARACTERISTIC = UUID.from_16_bi
GATT_CURRENT_TIME_CHARACTERISTIC = UUID.from_16_bits(0x2A2B, 'Current Time')
GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report')
GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bits(0x2AA6, 'Central Address Resolution')
+GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B29, 'Client Supported Features')
+GATT_DATABASE_HASH_CHARACTERISTIC = UUID.from_16_bits(0x2B2A, 'Database Hash')
+GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B3A, 'Server Supported Features')
# fmt: on
# pylint: enable=line-too-long
@@ -258,9 +380,12 @@ class TemplateService(Service):
UUID: UUID
def __init__(
- self, characteristics: List[Characteristic], primary: bool = True
+ self,
+ characteristics: List[Characteristic],
+ primary: bool = True,
+ included_services: List[Service] = [],
) -> None:
- super().__init__(self.UUID, characteristics, primary)
+ super().__init__(self.UUID, characteristics, primary, included_services)
# -----------------------------------------------------------------------------
@@ -409,56 +534,43 @@ class CharacteristicDeclaration(Attribute):
# -----------------------------------------------------------------------------
-class CharacteristicValue:
- '''
- Characteristic value where reading and/or writing is delegated to functions
- passed as arguments to the constructor.
- '''
-
- def __init__(self, read=None, write=None):
- self._read = read
- self._write = write
-
- def read(self, connection):
- return self._read(connection) if self._read else b''
-
- def write(self, connection, value):
- if self._write:
- self._write(connection, value)
+class CharacteristicValue(AttributeValue):
+ """Same as AttributeValue, for backward compatibility"""
# -----------------------------------------------------------------------------
class CharacteristicAdapter:
'''
- An adapter that can adapt any object with `read_value` and `write_value`
- methods (like Characteristic and CharacteristicProxy objects) by wrapping
- those methods with ones that return/accept encoded/decoded values.
- Objects with async methods are considered proxies, so the adaptation is one
- where the return value of `read_value` is decoded and the value passed to
- `write_value` is encoded. Other objects are considered local characteristics
- so the adaptation is one where the return value of `read_value` is encoded
- and the value passed to `write_value` is decoded.
- If the characteristic has a `subscribe` method, it is wrapped with one where
- the values are decoded before being passed to the subscriber.
+ 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.
'''
- def __init__(self, characteristic):
+ read_value: Callable
+ write_value: Callable
+
+ def __init__(self, characteristic: Union[Characteristic, AttributeProxy]):
self.wrapped_characteristic = characteristic
- self.subscribers = {} # Map from subscriber to proxy subscriber
+ self.subscribers: Dict[
+ Callable, Callable
+ ] = {} # Map from subscriber to proxy subscriber
- if asyncio.iscoroutinefunction(
- characteristic.read_value
- ) and asyncio.iscoroutinefunction(characteristic.write_value):
- self.read_value = self.read_decoded_value
- self.write_value = self.write_decoded_value
- else:
+ if isinstance(characteristic, Characteristic):
self.read_value = self.read_encoded_value
self.write_value = self.write_encoded_value
-
- if hasattr(self.wrapped_characteristic, 'subscribe'):
+ else:
+ self.read_value = self.read_decoded_value
+ self.write_value = self.write_decoded_value
self.subscribe = self.wrapped_subscribe
-
- if hasattr(self.wrapped_characteristic, 'unsubscribe'):
self.unsubscribe = self.wrapped_unsubscribe
def __getattr__(self, name):
@@ -477,11 +589,13 @@ class CharacteristicAdapter:
else:
setattr(self.wrapped_characteristic, name, value)
- def read_encoded_value(self, connection):
- return self.encode_value(self.wrapped_characteristic.read_value(connection))
+ async def read_encoded_value(self, connection):
+ return self.encode_value(
+ await self.wrapped_characteristic.read_value(connection)
+ )
- def write_encoded_value(self, connection, value):
- return self.wrapped_characteristic.write_value(
+ async def write_encoded_value(self, connection, value):
+ return await self.wrapped_characteristic.write_value(
connection, self.decode_value(value)
)
@@ -616,13 +730,24 @@ class Descriptor(Attribute):
'''
def __str__(self) -> str:
+ if isinstance(self.value, bytes):
+ value_str = self.value.hex()
+ elif isinstance(self.value, CharacteristicValue):
+ value = self.value.read(None)
+ if isinstance(value, bytes):
+ value_str = value.hex()
+ else:
+ value_str = '<async>'
+ else:
+ value_str = '<...>'
return (
f'Descriptor(handle=0x{self.handle:04X}, '
f'type={self.type}, '
- f'value={self.read_value(None).hex()})'
+ f'value={value_str})'
)
+# -----------------------------------------------------------------------------
class ClientCharacteristicConfigurationBits(enum.IntFlag):
'''
See Vol 3, Part G - 3.3.3.3 - Table 3.11 Client Characteristic Configuration bit
diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py
index e3b8bb2..2079a65 100644
--- a/bumble/gatt_client.py
+++ b/bumble/gatt_client.py
@@ -38,6 +38,7 @@ from typing import (
Any,
Iterable,
Type,
+ Set,
TYPE_CHECKING,
)
@@ -128,7 +129,7 @@ class ServiceProxy(AttributeProxy):
included_services: List[ServiceProxy]
@staticmethod
- def from_client(service_class, client, service_uuid):
+ def from_client(service_class, client: Client, service_uuid: UUID):
# The service and its characteristics are considered to have already been
# discovered
services = client.get_services_by_uuid(service_uuid)
@@ -206,11 +207,11 @@ class CharacteristicProxy(AttributeProxy):
return await self.client.subscribe(self, subscriber, prefer_notify)
- async def unsubscribe(self, subscriber=None):
+ async def unsubscribe(self, subscriber=None, force=False):
if subscriber in self.subscribers:
subscriber = self.subscribers.pop(subscriber)
- return await self.client.unsubscribe(self, subscriber)
+ return await self.client.unsubscribe(self, subscriber, force)
def __str__(self) -> str:
return (
@@ -246,8 +247,12 @@ class ProfileServiceProxy:
class Client:
services: List[ServiceProxy]
cached_values: Dict[int, Tuple[datetime, bytes]]
- notification_subscribers: Dict[int, Callable[[bytes], Any]]
- indication_subscribers: Dict[int, Callable[[bytes], Any]]
+ notification_subscribers: Dict[
+ int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
+ ]
+ indication_subscribers: Dict[
+ int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
+ ]
pending_response: Optional[asyncio.futures.Future[ATT_PDU]]
pending_request: Optional[ATT_PDU]
@@ -257,10 +262,8 @@ class Client:
self.request_semaphore = asyncio.Semaphore(1)
self.pending_request = None
self.pending_response = None
- self.notification_subscribers = (
- {}
- ) # Notification subscribers, by attribute handle
- self.indication_subscribers = {} # Indication subscribers, by attribute handle
+ self.notification_subscribers = {} # Subscriber set, by attribute handle
+ self.indication_subscribers = {} # Subscriber set, by attribute handle
self.services = []
self.cached_values = {}
@@ -682,8 +685,8 @@ class Client:
async def discover_descriptors(
self,
characteristic: Optional[CharacteristicProxy] = None,
- start_handle=None,
- end_handle=None,
+ start_handle: Optional[int] = None,
+ end_handle: Optional[int] = None,
) -> List[DescriptorProxy]:
'''
See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors
@@ -789,7 +792,12 @@ class Client:
return attributes
- async def subscribe(self, characteristic, subscriber=None, prefer_notify=True):
+ async def subscribe(
+ self,
+ characteristic: CharacteristicProxy,
+ subscriber: Optional[Callable[[bytes], Any]] = None,
+ prefer_notify: bool = True,
+ ) -> None:
# If we haven't already discovered the descriptors for this characteristic,
# do it now
if not characteristic.descriptors_discovered:
@@ -826,6 +834,7 @@ class Client:
subscriber_set = subscribers.setdefault(characteristic.handle, set())
if subscriber is not None:
subscriber_set.add(subscriber)
+
# Add the characteristic as a subscriber, which will result in the
# characteristic emitting an 'update' event when a notification or indication
# is received
@@ -833,7 +842,18 @@ class Client:
await self.write_value(cccd, struct.pack('<H', bits), with_response=True)
- async def unsubscribe(self, characteristic, subscriber=None):
+ async def unsubscribe(
+ self,
+ characteristic: CharacteristicProxy,
+ subscriber: Optional[Callable[[bytes], Any]] = None,
+ force: bool = False,
+ ) -> None:
+ '''
+ Unsubscribe from a characteristic.
+
+ If `force` is True, this will write zeros to the CCCD when there are no
+ subscribers left, even if there were already no registered subscribers.
+ '''
# If we haven't already discovered the descriptors for this characteristic,
# do it now
if not characteristic.descriptors_discovered:
@@ -847,31 +867,45 @@ class Client:
logger.warning('unsubscribing from characteristic with no CCCD descriptor')
return
+ # Check if the characteristic has subscribers
+ if not (
+ characteristic.handle in self.notification_subscribers
+ or characteristic.handle in self.indication_subscribers
+ ):
+ if not force:
+ return
+
+ # Remove the subscriber(s)
if subscriber is not None:
# Remove matching subscriber from subscriber sets
for subscriber_set in (
self.notification_subscribers,
self.indication_subscribers,
):
- subscribers = subscriber_set.get(characteristic.handle, [])
- if subscriber in subscribers:
+ if (
+ subscribers := subscriber_set.get(characteristic.handle)
+ ) and subscriber in subscribers:
subscribers.remove(subscriber)
# Cleanup if we removed the last one
if not subscribers:
del subscriber_set[characteristic.handle]
else:
- # Remove all subscribers for this attribute from the sets!
+ # Remove all subscribers for this attribute from the sets
self.notification_subscribers.pop(characteristic.handle, None)
self.indication_subscribers.pop(characteristic.handle, None)
- if not self.notification_subscribers and not self.indication_subscribers:
+ # Update the CCCD
+ if not (
+ characteristic.handle in self.notification_subscribers
+ or characteristic.handle in self.indication_subscribers
+ ):
# No more subscribers left
await self.write_value(cccd, b'\x00\x00', with_response=True)
async def read_value(
self, attribute: Union[int, AttributeProxy], no_long_read: bool = False
- ) -> Any:
+ ) -> bytes:
'''
See Vol 3, Part G - 4.8.1 Read Characteristic Value
@@ -1034,7 +1068,7 @@ class Client:
logger.warning('!!! unexpected response, there is no pending request')
return
- # Sanity check: the response should match the pending request unless it is
+ # The response should match the pending request unless it is
# an error response
if att_pdu.op_code != ATT_ERROR_RESPONSE:
expected_response_name = self.pending_request.name.replace(
@@ -1067,7 +1101,7 @@ class Client:
def on_att_handle_value_notification(self, notification):
# Call all subscribers
subscribers = self.notification_subscribers.get(
- notification.attribute_handle, []
+ notification.attribute_handle, set()
)
if not subscribers:
logger.warning('!!! received notification with no subscriber')
@@ -1081,7 +1115,9 @@ class Client:
def on_att_handle_value_indication(self, indication):
# Call all subscribers
- subscribers = self.indication_subscribers.get(indication.attribute_handle, [])
+ subscribers = self.indication_subscribers.get(
+ indication.attribute_handle, set()
+ )
if not subscribers:
logger.warning('!!! received indication with no subscriber')
diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py
index cdf1b5e..3be4185 100644
--- a/bumble/gatt_server.py
+++ b/bumble/gatt_server.py
@@ -31,9 +31,9 @@ import struct
from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
from pyee import EventEmitter
-from .colors import color
-from .core import UUID
-from .att import (
+from bumble.colors import color
+from bumble.core import UUID
+from bumble.att import (
ATT_ATTRIBUTE_NOT_FOUND_ERROR,
ATT_ATTRIBUTE_NOT_LONG_ERROR,
ATT_CID,
@@ -60,7 +60,7 @@ from .att import (
ATT_Write_Response,
Attribute,
)
-from .gatt import (
+from bumble.gatt import (
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
GATT_MAX_ATTRIBUTE_VALUE_SIZE,
@@ -74,6 +74,7 @@ from .gatt import (
Descriptor,
Service,
)
+from bumble.utils import AsyncRunner
if TYPE_CHECKING:
from bumble.device import Device, Connection
@@ -327,7 +328,7 @@ class Server(EventEmitter):
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
)
- # Sanity check
+ # Check parameters
if len(value) != 2:
logger.warning('CCCD value not 2 bytes long')
return
@@ -379,7 +380,7 @@ class Server(EventEmitter):
# Get or encode the value
value = (
- attribute.read_value(connection)
+ await attribute.read_value(connection)
if value is None
else attribute.encode_value(value)
)
@@ -422,7 +423,7 @@ class Server(EventEmitter):
# Get or encode the value
value = (
- attribute.read_value(connection)
+ await attribute.read_value(connection)
if value is None
else attribute.encode_value(value)
)
@@ -650,7 +651,8 @@ class Server(EventEmitter):
self.send_response(connection, response)
- def on_att_find_by_type_value_request(self, connection, request):
+ @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
'''
@@ -658,13 +660,13 @@ class Server(EventEmitter):
# Build list of returned attributes
pdu_space_available = connection.att_mtu - 2
attributes = []
- for attribute in (
+ async for attribute in (
attribute
for attribute in self.attributes
if attribute.handle >= request.starting_handle
and attribute.handle <= request.ending_handle
and attribute.type == request.attribute_type
- and attribute.read_value(connection) == request.attribute_value
+ and (await attribute.read_value(connection)) == request.attribute_value
and pdu_space_available >= 4
):
# TODO: check permissions
@@ -702,7 +704,8 @@ class Server(EventEmitter):
self.send_response(connection, response)
- def on_att_read_by_type_request(self, connection, request):
+ @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
'''
@@ -725,7 +728,7 @@ class Server(EventEmitter):
and pdu_space_available
):
try:
- attribute_value = attribute.read_value(connection)
+ attribute_value = await attribute.read_value(connection)
except ATT_Error as error:
# If the first attribute is unreadable, return an error
# Otherwise return attributes up to this point
@@ -767,14 +770,15 @@ class Server(EventEmitter):
self.send_response(connection, response)
- def on_att_read_request(self, connection, request):
+ @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
'''
if attribute := self.get_attribute(request.attribute_handle):
try:
- value = attribute.read_value(connection)
+ value = await attribute.read_value(connection)
except ATT_Error as error:
response = ATT_Error_Response(
request_opcode_in_error=request.op_code,
@@ -792,14 +796,15 @@ class Server(EventEmitter):
)
self.send_response(connection, response)
- def on_att_read_blob_request(self, connection, request):
+ @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
'''
if attribute := self.get_attribute(request.attribute_handle):
try:
- value = attribute.read_value(connection)
+ value = await attribute.read_value(connection)
except ATT_Error as error:
response = ATT_Error_Response(
request_opcode_in_error=request.op_code,
@@ -836,7 +841,8 @@ class Server(EventEmitter):
)
self.send_response(connection, response)
- def on_att_read_by_group_type_request(self, connection, request):
+ @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
'''
@@ -864,7 +870,7 @@ class Server(EventEmitter):
):
# No need to catch permission errors here, since these attributes
# must all be world-readable
- attribute_value = attribute.read_value(connection)
+ attribute_value = await attribute.read_value(connection)
# Check the attribute value size
max_attribute_size = min(connection.att_mtu - 6, 251)
if len(attribute_value) > max_attribute_size:
@@ -903,7 +909,8 @@ class Server(EventEmitter):
self.send_response(connection, response)
- def on_att_write_request(self, connection, request):
+ @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
'''
@@ -936,12 +943,13 @@ class Server(EventEmitter):
return
# Accept the value
- attribute.write_value(connection, request.attribute_value)
+ await attribute.write_value(connection, request.attribute_value)
# Done
self.send_response(connection, ATT_Write_Response())
- def on_att_write_command(self, connection, request):
+ @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
'''
@@ -959,9 +967,9 @@ class Server(EventEmitter):
# Accept the value
try:
- attribute.write_value(connection, request.attribute_value)
+ await attribute.write_value(connection, request.attribute_value)
except Exception as error:
- logger.warning(f'!!! ignoring exception: {error}')
+ logger.exception(f'!!! ignoring exception: {error}')
def on_att_handle_value_confirmation(self, connection, _confirmation):
'''
diff --git a/bumble/hci.py b/bumble/hci.py
index 057c04a..013a2d3 100644
--- a/bumble/hci.py
+++ b/bumble/hci.py
@@ -17,11 +17,15 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import collections
+import dataclasses
+import enum
import functools
import logging
+import secrets
import struct
-from typing import Any, Dict, Callable, Optional, Type, Union
+from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union
+from bumble import crypto
from .colors import color
from .core import (
BT_BR_EDR_TRANSPORT,
@@ -148,6 +152,7 @@ HCI_COMMAND_PACKET = 0x01
HCI_ACL_DATA_PACKET = 0x02
HCI_SYNCHRONOUS_DATA_PACKET = 0x03
HCI_EVENT_PACKET = 0x04
+HCI_ISO_DATA_PACKET = 0x05
# HCI Event Codes
HCI_INQUIRY_COMPLETE_EVENT = 0x01
@@ -218,41 +223,47 @@ HCI_VENDOR_EVENT = 0xFF
# HCI Subevent Codes
-HCI_LE_CONNECTION_COMPLETE_EVENT = 0x01
-HCI_LE_ADVERTISING_REPORT_EVENT = 0x02
-HCI_LE_CONNECTION_UPDATE_COMPLETE_EVENT = 0x03
-HCI_LE_READ_REMOTE_FEATURES_COMPLETE_EVENT = 0x04
-HCI_LE_LONG_TERM_KEY_REQUEST_EVENT = 0x05
-HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_EVENT = 0x06
-HCI_LE_DATA_LENGTH_CHANGE_EVENT = 0x07
-HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMPLETE_EVENT = 0x08
-HCI_LE_GENERATE_DHKEY_COMPLETE_EVENT = 0x09
-HCI_LE_ENHANCED_CONNECTION_COMPLETE_EVENT = 0x0A
-HCI_LE_DIRECTED_ADVERTISING_REPORT_EVENT = 0x0B
-HCI_LE_PHY_UPDATE_COMPLETE_EVENT = 0x0C
-HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT = 0x0D
-HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_EVENT = 0x0E
-HCI_LE_PERIODIC_ADVERTISING_REPORT_EVENT = 0x0F
-HCI_LE_PERIODIC_ADVERTISING_SYNC_LOST_EVENT = 0x10
-HCI_LE_SCAN_TIMEOUT_EVENT = 0x11
-HCI_LE_ADVERTISING_SET_TERMINATED_EVENT = 0x12
-HCI_LE_SCAN_REQUEST_RECEIVED_EVENT = 0x13
-HCI_LE_CHANNEL_SELECTION_ALGORITHM_EVENT = 0x14
-HCI_LE_CONNECTIONLESS_IQ_REPORT_EVENT = 0X15
-HCI_LE_CONNECTION_IQ_REPORT_EVENT = 0X16
-HCI_LE_CTE_REQUEST_FAILED_EVENT = 0X17
-HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_EVENT = 0X18
-HCI_LE_CIS_ESTABLISHED_EVENT = 0X19
-HCI_LE_CIS_REQUEST_EVENT = 0X1A
-HCI_LE_CREATE_BIG_COMPLETE_EVENT = 0X1B
-HCI_LE_TERMINATE_BIG_COMPLETE_EVENT = 0X1C
-HCI_LE_BIG_SYNC_ESTABLISHED_EVENT = 0X1D
-HCI_LE_BIG_SYNC_LOST_EVENT = 0X1E
-HCI_LE_REQUEST_PEER_SCA_COMPLETE_EVENT = 0X1F
-HCI_LE_PATH_LOSS_THRESHOLD_EVENT = 0X20
-HCI_LE_TRANSMIT_POWER_REPORTING_EVENT = 0X21
-HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT = 0X22
-HCI_LE_SUBRATE_CHANGE_EVENT = 0X23
+HCI_LE_CONNECTION_COMPLETE_EVENT = 0x01
+HCI_LE_ADVERTISING_REPORT_EVENT = 0x02
+HCI_LE_CONNECTION_UPDATE_COMPLETE_EVENT = 0x03
+HCI_LE_READ_REMOTE_FEATURES_COMPLETE_EVENT = 0x04
+HCI_LE_LONG_TERM_KEY_REQUEST_EVENT = 0x05
+HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_EVENT = 0x06
+HCI_LE_DATA_LENGTH_CHANGE_EVENT = 0x07
+HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMPLETE_EVENT = 0x08
+HCI_LE_GENERATE_DHKEY_COMPLETE_EVENT = 0x09
+HCI_LE_ENHANCED_CONNECTION_COMPLETE_EVENT = 0x0A
+HCI_LE_DIRECTED_ADVERTISING_REPORT_EVENT = 0x0B
+HCI_LE_PHY_UPDATE_COMPLETE_EVENT = 0x0C
+HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT = 0x0D
+HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_EVENT = 0x0E
+HCI_LE_PERIODIC_ADVERTISING_REPORT_EVENT = 0x0F
+HCI_LE_PERIODIC_ADVERTISING_SYNC_LOST_EVENT = 0x10
+HCI_LE_SCAN_TIMEOUT_EVENT = 0x11
+HCI_LE_ADVERTISING_SET_TERMINATED_EVENT = 0x12
+HCI_LE_SCAN_REQUEST_RECEIVED_EVENT = 0x13
+HCI_LE_CHANNEL_SELECTION_ALGORITHM_EVENT = 0x14
+HCI_LE_CONNECTIONLESS_IQ_REPORT_EVENT = 0X15
+HCI_LE_CONNECTION_IQ_REPORT_EVENT = 0X16
+HCI_LE_CTE_REQUEST_FAILED_EVENT = 0X17
+HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_EVENT = 0X18
+HCI_LE_CIS_ESTABLISHED_EVENT = 0X19
+HCI_LE_CIS_REQUEST_EVENT = 0X1A
+HCI_LE_CREATE_BIG_COMPLETE_EVENT = 0X1B
+HCI_LE_TERMINATE_BIG_COMPLETE_EVENT = 0X1C
+HCI_LE_BIG_SYNC_ESTABLISHED_EVENT = 0X1D
+HCI_LE_BIG_SYNC_LOST_EVENT = 0X1E
+HCI_LE_REQUEST_PEER_SCA_COMPLETE_EVENT = 0X1F
+HCI_LE_PATH_LOSS_THRESHOLD_EVENT = 0X20
+HCI_LE_TRANSMIT_POWER_REPORTING_EVENT = 0X21
+HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT = 0X22
+HCI_LE_SUBRATE_CHANGE_EVENT = 0X23
+HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_V2_EVENT = 0X24
+HCI_LE_PERIODIC_ADVERTISING_REPORT_V2_EVENT = 0X25
+HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_V2_EVENT = 0X26
+HCI_LE_PERIODIC_ADVERTISING_SUBEVENT_DATA_REQUEST_EVENT = 0X27
+HCI_LE_PERIODIC_ADVERTISING_RESPONSE_REPORT_EVENT = 0X28
+HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT = 0X29
# HCI Command
@@ -558,6 +569,12 @@ HCI_LE_TRANSMITTER_TEST_V4_COMMAND = hci_c
HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND = hci_command_op_code(0x08, 0x007C)
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND = hci_command_op_code(0x08, 0x007D)
HCI_LE_SUBRATE_REQUEST_COMMAND = hci_command_op_code(0x08, 0x007E)
+HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND = hci_command_op_code(0x08, 0x007F)
+HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND = hci_command_op_code(0x08, 0x0082)
+HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND = hci_command_op_code(0x08, 0x0083)
+HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND = hci_command_op_code(0x08, 0x0084)
+HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND = hci_command_op_code(0x08, 0x0085)
+HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND = hci_command_op_code(0x08, 0x0086)
# HCI Error Codes
@@ -639,47 +656,6 @@ HCI_ERROR_NAMES[HCI_SUCCESS] = 'HCI_SUCCESS'
# Command Status codes
HCI_COMMAND_STATUS_PENDING = 0
-# LE Event Masks
-HCI_LE_CONNECTION_COMPLETE_EVENT_MASK = (1 << 0)
-HCI_LE_ADVERTISING_REPORT_EVENT_MASK = (1 << 1)
-HCI_LE_CONNECTION_UPDATE_COMPLETE_EVENT_MASK = (1 << 2)
-HCI_LE_READ_REMOTE_FEATURES_COMPLETE_EVENT_MASK = (1 << 3)
-HCI_LE_LONG_TERM_KEY_REQUEST_EVENT_MASK = (1 << 4)
-HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_EVENT_MASK = (1 << 5)
-HCI_LE_DATA_LENGTH_CHANGE_EVENT_MASK = (1 << 6)
-HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMPLETE_EVENT_MASK = (1 << 7)
-HCI_LE_GENERATE_DHKEY_COMPLETE_EVENT_MASK = (1 << 8)
-HCI_LE_ENHANCED_CONNECTION_COMPLETE_EVENT_MASK = (1 << 9)
-HCI_LE_DIRECTED_ADVERTISING_REPORT_EVENT_MASK = (1 << 10)
-HCI_LE_PHY_UPDATE_COMPLETE_EVENT_MASK = (1 << 11)
-HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT_MASK = (1 << 12)
-HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_EVENT_MASK = (1 << 13)
-HCI_LE_PERIODIC_ADVERTISING_REPORT_EVENT_MASK = (1 << 14)
-HCI_LE_PERIODIC_ADVERTISING_SYNC_LOST_EVENT_MASK = (1 << 15)
-HCI_LE_EXTENDED_SCAN_TIMEOUT_EVENT_MASK = (1 << 16)
-HCI_LE_EXTENDED_ADVERTISING_SET_TERMINATED_EVENT_MASK = (1 << 17)
-HCI_LE_SCAN_REQUEST_RECEIVED_EVENT_MASK = (1 << 18)
-HCI_LE_CHANNEL_SELECTION_ALGORITHM_EVENT_MASK = (1 << 19)
-HCI_LE_CONNECTIONLESS_IQ_REPORT_EVENT_MASK = (1 << 20)
-HCI_LE_CONNECTION_IQ_REPORT_EVENT_MASK = (1 << 21)
-HCI_LE_CTE_REQUEST_FAILED_EVENT_MASK = (1 << 22)
-HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_EVENT_MASK = (1 << 23)
-HCI_LE_CIS_ESTABLISHED_EVENT_MASK = (1 << 24)
-HCI_LE_CIS_REQUEST_EVENT_MASK = (1 << 25)
-HCI_LE_CREATE_BIG_COMPLETE_EVENT_MASK = (1 << 26)
-HCI_LE_TERMINATE_BIG_COMPLETE_EVENT_MASK = (1 << 27)
-HCI_LE_BIG_SYNC_ESTABLISHED_EVENT_MASK = (1 << 28)
-HCI_LE_BIG_SYNC_LOST_EVENT_MASK = (1 << 29)
-HCI_LE_REQUEST_PEER_SCA_COMPLETE_EVENT_MASK = (1 << 30)
-HCI_LE_PATH_LOSS_THRESHOLD_EVENT_MASK = (1 << 31)
-HCI_LE_TRANSMIT_POWER_REPORTING_EVENT_MASK = (1 << 32)
-HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT_MASK = (1 << 33)
-HCI_LE_SUBRATE_CHANGE_EVENT_MASK = (1 << 34)
-
-HCI_LE_EVENT_MASK_NAMES = {
- mask: mask_name for (mask_name, mask) in globals().items()
- if mask_name.startswith('HCI_LE_') and mask_name.endswith('_EVENT_MASK')
-}
# ACL
HCI_ACL_PB_FIRST_NON_FLUSHABLE = 0
@@ -719,6 +695,19 @@ HCI_LE_PHY_TYPE_TO_BIT = {
HCI_LE_CODED_PHY: 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
+
+
# Connection Parameters
HCI_CONNECTION_INTERVAL_MS_PER_UNIT = 1.25
HCI_CONNECTION_LATENCY_MS_PER_UNIT = 1.25
@@ -801,574 +790,586 @@ HCI_RANDOM_DEVICE_ADDRESS_TYPE = 0x01
HCI_PUBLIC_IDENTITY_ADDRESS_TYPE = 0x02
HCI_RANDOM_IDENTITY_ADDRESS_TYPE = 0x03
-# Supported Commands Flags
+# Supported Commands Masks
# See Bluetooth spec @ 6.27 SUPPORTED COMMANDS
-HCI_SUPPORTED_COMMANDS_FLAGS = (
- # Octet 0
- (
- HCI_INQUIRY_COMMAND,
- HCI_INQUIRY_CANCEL_COMMAND,
- HCI_PERIODIC_INQUIRY_MODE_COMMAND,
- HCI_EXIT_PERIODIC_INQUIRY_MODE_COMMAND,
- HCI_CREATE_CONNECTION_COMMAND,
- HCI_DISCONNECT_COMMAND,
- None,
- HCI_CREATE_CONNECTION_CANCEL_COMMAND
- ),
- # Octet 1
- (
- HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
- HCI_REJECT_CONNECTION_REQUEST_COMMAND,
- HCI_LINK_KEY_REQUEST_REPLY_COMMAND,
- HCI_LINK_KEY_REQUEST_NEGATIVE_REPLY_COMMAND,
- HCI_PIN_CODE_REQUEST_REPLY_COMMAND,
- HCI_PIN_CODE_REQUEST_NEGATIVE_REPLY_COMMAND,
- HCI_CHANGE_CONNECTION_PACKET_TYPE_COMMAND,
- HCI_AUTHENTICATION_REQUESTED_COMMAND
- ),
- # Octet 2
- (
- HCI_SET_CONNECTION_ENCRYPTION_COMMAND,
- HCI_CHANGE_CONNECTION_LINK_KEY_COMMAND,
- HCI_LINK_KEY_SELECTION_COMMAND,
- HCI_REMOTE_NAME_REQUEST_COMMAND,
- HCI_REMOTE_NAME_REQUEST_CANCEL_COMMAND,
- HCI_READ_REMOTE_SUPPORTED_FEATURES_COMMAND,
- HCI_READ_REMOTE_EXTENDED_FEATURES_COMMAND,
- HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND
- ),
- # Octet 3
- (
- HCI_READ_CLOCK_OFFSET_COMMAND,
- HCI_READ_LMP_HANDLE_COMMAND,
- None,
- None,
- None,
- None,
- None,
- None
- ),
- # Octet 4
- (
- None,
- HCI_HOLD_MODE_COMMAND,
- HCI_SNIFF_MODE_COMMAND,
- HCI_EXIT_SNIFF_MODE_COMMAND,
- None,
- None,
- HCI_QOS_SETUP_COMMAND,
- HCI_ROLE_DISCOVERY_COMMAND
- ),
- # Octet 5
- (
- HCI_SWITCH_ROLE_COMMAND,
- HCI_READ_LINK_POLICY_SETTINGS_COMMAND,
- HCI_WRITE_LINK_POLICY_SETTINGS_COMMAND,
- HCI_READ_DEFAULT_LINK_POLICY_SETTINGS_COMMAND,
- HCI_WRITE_DEFAULT_LINK_POLICY_SETTINGS_COMMAND,
- HCI_FLOW_SPECIFICATION_COMMAND,
- HCI_SET_EVENT_MASK_COMMAND,
- HCI_RESET_COMMAND
- ),
- # Octet 6
- (
- HCI_SET_EVENT_FILTER_COMMAND,
- HCI_FLUSH_COMMAND,
- HCI_READ_PIN_TYPE_COMMAND,
- HCI_WRITE_PIN_TYPE_COMMAND,
- None,
- HCI_READ_STORED_LINK_KEY_COMMAND,
- HCI_WRITE_STORED_LINK_KEY_COMMAND,
- HCI_DELETE_STORED_LINK_KEY_COMMAND
- ),
- # Octet 7
- (
- HCI_WRITE_LOCAL_NAME_COMMAND,
- HCI_READ_LOCAL_NAME_COMMAND,
- HCI_READ_CONNECTION_ACCEPT_TIMEOUT_COMMAND,
- HCI_WRITE_CONNECTION_ACCEPT_TIMEOUT_COMMAND,
- HCI_READ_PAGE_TIMEOUT_COMMAND,
- HCI_WRITE_PAGE_TIMEOUT_COMMAND,
- HCI_READ_SCAN_ENABLE_COMMAND,
- HCI_WRITE_SCAN_ENABLE_COMMAND
- ),
- # Octet 8
- (
- HCI_READ_PAGE_SCAN_ACTIVITY_COMMAND,
- HCI_WRITE_PAGE_SCAN_ACTIVITY_COMMAND,
- HCI_READ_INQUIRY_SCAN_ACTIVITY_COMMAND,
- HCI_WRITE_INQUIRY_SCAN_ACTIVITY_COMMAND,
- HCI_READ_AUTHENTICATION_ENABLE_COMMAND,
- HCI_WRITE_AUTHENTICATION_ENABLE_COMMAND,
- None,
- None
- ),
- # Octet 9
- (
- HCI_READ_CLASS_OF_DEVICE_COMMAND,
- HCI_WRITE_CLASS_OF_DEVICE_COMMAND,
- HCI_READ_VOICE_SETTING_COMMAND,
- HCI_WRITE_VOICE_SETTING_COMMAND,
- HCI_READ_AUTOMATIC_FLUSH_TIMEOUT_COMMAND,
- HCI_WRITE_AUTOMATIC_FLUSH_TIMEOUT_COMMAND,
- HCI_READ_NUM_BROADCAST_RETRANSMISSIONS_COMMAND,
- HCI_WRITE_NUM_BROADCAST_RETRANSMISSIONS_COMMAND
- ),
- # Octet 10
- (
- HCI_READ_HOLD_MODE_ACTIVITY_COMMAND,
- HCI_WRITE_HOLD_MODE_ACTIVITY_COMMAND,
- HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND,
- HCI_READ_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND,
- HCI_WRITE_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND,
- HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND,
- HCI_HOST_BUFFER_SIZE_COMMAND,
- HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND
- ),
- # Octet 11
- (
- HCI_READ_LINK_SUPERVISION_TIMEOUT_COMMAND,
- HCI_WRITE_LINK_SUPERVISION_TIMEOUT_COMMAND,
- HCI_READ_NUMBER_OF_SUPPORTED_IAC_COMMAND,
- HCI_READ_CURRENT_IAC_LAP_COMMAND,
- HCI_WRITE_CURRENT_IAC_LAP_COMMAND,
- None,
- None,
- None
- ),
- # Octet 12
- (
- None,
- HCI_SET_AFH_HOST_CHANNEL_CLASSIFICATION_COMMAND,
- None,
- None,
- HCI_READ_INQUIRY_SCAN_TYPE_COMMAND,
- HCI_WRITE_INQUIRY_SCAN_TYPE_COMMAND,
- HCI_READ_INQUIRY_MODE_COMMAND,
- HCI_WRITE_INQUIRY_MODE_COMMAND
- ),
- # Octet 13
- (
- HCI_READ_PAGE_SCAN_TYPE_COMMAND,
- HCI_WRITE_PAGE_SCAN_TYPE_COMMAND,
- HCI_READ_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND,
- HCI_WRITE_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND,
- None,
- None,
- None,
- None,
- ),
- # Octet 14
- (
- None,
- None,
- None,
- HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND,
- None,
- HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
- HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND,
- HCI_READ_BUFFER_SIZE_COMMAND
- ),
- # Octet 15
- (
- None,
- HCI_READ_BD_ADDR_COMMAND,
- HCI_READ_FAILED_CONTACT_COUNTER_COMMAND,
- HCI_RESET_FAILED_CONTACT_COUNTER_COMMAND,
- HCI_READ_LINK_QUALITY_COMMAND,
- HCI_READ_RSSI_COMMAND,
- HCI_READ_AFH_CHANNEL_MAP_COMMAND,
- HCI_READ_CLOCK_COMMAND
- ),
- # Octet 16
- (
- HCI_READ_LOOPBACK_MODE_COMMAND,
- HCI_WRITE_LOOPBACK_MODE_COMMAND,
- HCI_ENABLE_DEVICE_UNDER_TEST_MODE_COMMAND,
- HCI_SETUP_SYNCHRONOUS_CONNECTION_COMMAND,
- HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND,
- HCI_REJECT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND,
- None,
- None,
- ),
- # Octet 17
- (
- HCI_READ_EXTENDED_INQUIRY_RESPONSE_COMMAND,
- HCI_WRITE_EXTENDED_INQUIRY_RESPONSE_COMMAND,
- HCI_REFRESH_ENCRYPTION_KEY_COMMAND,
- None,
- HCI_SNIFF_SUBRATING_COMMAND,
- HCI_READ_SIMPLE_PAIRING_MODE_COMMAND,
- HCI_WRITE_SIMPLE_PAIRING_MODE_COMMAND,
- HCI_READ_LOCAL_OOB_DATA_COMMAND
- ),
- # Octet 18
- (
- HCI_READ_INQUIRY_RESPONSE_TRANSMIT_POWER_LEVEL_COMMAND,
- HCI_WRITE_INQUIRY_TRANSMIT_POWER_LEVEL_COMMAND,
- HCI_READ_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND,
- HCI_WRITE_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND,
- None,
- None,
- None,
- HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND
- ),
- # Octet 19
- (
- HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND,
- HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND,
- HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND,
- HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND,
- HCI_REMOTE_OOB_DATA_REQUEST_REPLY_COMMAND,
- HCI_WRITE_SIMPLE_PAIRING_DEBUG_MODE_COMMAND,
- HCI_ENHANCED_FLUSH_COMMAND,
- HCI_REMOTE_OOB_DATA_REQUEST_NEGATIVE_REPLY_COMMAND
- ),
- # Octet 20
- (
- None,
- None,
- HCI_SEND_KEYPRESS_NOTIFICATION_COMMAND,
- HCI_IO_CAPABILITY_REQUEST_NEGATIVE_REPLY_COMMAND,
- HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND,
- None,
- None,
- None,
- ),
- # Octet 21
- (
- None,
- None,
- None,
- None,
- None,
- None,
- None,
- None,
- ),
- # Octet 22
- (
- None,
- None,
- HCI_SET_EVENT_MASK_PAGE_2_COMMAND,
- None,
- None,
- None,
- None,
- None,
- ),
- # Octet 23
- (
- HCI_READ_FLOW_CONTROL_MODE_COMMAND,
- HCI_WRITE_FLOW_CONTROL_MODE_COMMAND,
- HCI_READ_DATA_BLOCK_SIZE_COMMAND,
- None,
- None,
- None,
- None,
- None,
- ),
- # Octet 24
- (
- HCI_READ_ENHANCED_TRANSMIT_POWER_LEVEL_COMMAND,
- None,
- None,
- None,
- None,
- HCI_READ_LE_HOST_SUPPORT_COMMAND,
- HCI_WRITE_LE_HOST_SUPPORT_COMMAND,
- None,
- ),
- # Octet 25
- (
- HCI_LE_SET_EVENT_MASK_COMMAND,
- HCI_LE_READ_BUFFER_SIZE_COMMAND,
- HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
- None,
- HCI_LE_SET_RANDOM_ADDRESS_COMMAND,
- HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND,
- HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND,
- HCI_LE_SET_ADVERTISING_DATA_COMMAND,
- ),
- # Octet 26
- (
- HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND,
- HCI_LE_SET_ADVERTISING_ENABLE_COMMAND,
- HCI_LE_SET_SCAN_PARAMETERS_COMMAND,
- HCI_LE_SET_SCAN_ENABLE_COMMAND,
- HCI_LE_CREATE_CONNECTION_COMMAND,
- HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND,
- HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND,
- HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND
- ),
- # Octet 27
- (
- HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND,
- HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND,
- HCI_LE_CONNECTION_UPDATE_COMMAND,
- HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND,
- HCI_LE_READ_CHANNEL_MAP_COMMAND,
- HCI_LE_READ_REMOTE_FEATURES_COMMAND,
- HCI_LE_ENCRYPT_COMMAND,
- HCI_LE_RAND_COMMAND
- ),
- # Octet 28
- (
- HCI_LE_ENABLE_ENCRYPTION_COMMAND,
- HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND,
- HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND,
- HCI_LE_READ_SUPPORTED_STATES_COMMAND,
- HCI_LE_RECEIVER_TEST_COMMAND,
- HCI_LE_TRANSMITTER_TEST_COMMAND,
- HCI_LE_TEST_END_COMMAND,
- None,
- ),
- # Octet 29
- (
- None,
- None,
- None,
- HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND,
- HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND,
- HCI_READ_LOCAL_SUPPORTED_CODECS_COMMAND,
- HCI_SET_MWS_CHANNEL_PARAMETERS_COMMAND,
- HCI_SET_EXTERNAL_FRAME_CONFIGURATION_COMMAND
- ),
- # Octet 30
- (
- HCI_SET_MWS_SIGNALING_COMMAND,
- HCI_SET_MWS_TRANSPORT_LAYER_COMMAND,
- HCI_SET_MWS_SCAN_FREQUENCY_TABLE_COMMAND,
- HCI_GET_MWS_TRANSPORT_LAYER_CONFIGURATION_COMMAND,
- HCI_SET_MWS_PATTERN_CONFIGURATION_COMMAND,
- HCI_SET_TRIGGERED_CLOCK_CAPTURE_COMMAND,
- HCI_TRUNCATED_PAGE_COMMAND,
- HCI_TRUNCATED_PAGE_CANCEL_COMMAND
- ),
- # Octet 31
- (
- HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_COMMAND,
- HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVE_COMMAND,
- HCI_START_SYNCHRONIZATION_TRAIN_COMMAND,
- HCI_RECEIVE_SYNCHRONIZATION_TRAIN_COMMAND,
- HCI_SET_RESERVED_LT_ADDR_COMMAND,
- HCI_DELETE_RESERVED_LT_ADDR_COMMAND,
- HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_DATA_COMMAND,
- HCI_READ_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND
- ),
- # Octet 32
- (
- HCI_WRITE_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND,
- HCI_REMOTE_OOB_EXTENDED_DATA_REQUEST_REPLY_COMMAND,
- HCI_READ_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND,
- HCI_WRITE_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND,
- HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND,
- HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND,
- HCI_READ_LOCAL_OOB_EXTENDED_DATA_COMMAND,
- HCI_WRITE_SECURE_CONNECTIONS_TEST_MODE_COMMAND
- ),
- # Octet 33
- (
- HCI_READ_EXTENDED_PAGE_TIMEOUT_COMMAND,
- HCI_WRITE_EXTENDED_PAGE_TIMEOUT_COMMAND,
- HCI_READ_EXTENDED_INQUIRY_LENGTH_COMMAND,
- HCI_WRITE_EXTENDED_INQUIRY_LENGTH_COMMAND,
- HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND,
- HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND,
- HCI_LE_SET_DATA_LENGTH_COMMAND,
- HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
- ),
- # Octet 34
- (
- HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
- HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMMAND,
- HCI_LE_GENERATE_DHKEY_COMMAND,
- HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND,
- HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND,
- HCI_LE_CLEAR_RESOLVING_LIST_COMMAND,
- HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND,
- HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND
- ),
- # Octet 35
- (
- HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND,
- HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND,
- HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND,
- HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
- HCI_LE_READ_PHY_COMMAND,
- HCI_LE_SET_DEFAULT_PHY_COMMAND,
- HCI_LE_SET_PHY_COMMAND,
- HCI_LE_RECEIVER_TEST_V2_COMMAND
- ),
- # Octet 36
- (
- HCI_LE_TRANSMITTER_TEST_V2_COMMAND,
- HCI_LE_SET_ADVERTISING_SET_RANDOM_ADDRESS_COMMAND,
- HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_COMMAND,
- HCI_LE_SET_EXTENDED_ADVERTISING_DATA_COMMAND,
- HCI_LE_SET_EXTENDED_SCAN_RESPONSE_DATA_COMMAND,
- HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND,
- HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
- HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
- ),
- # Octet 37
- (
- HCI_LE_REMOVE_ADVERTISING_SET_COMMAND,
- HCI_LE_CLEAR_ADVERTISING_SETS_COMMAND,
- HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_COMMAND,
- HCI_LE_SET_PERIODIC_ADVERTISING_DATA_COMMAND,
- HCI_LE_SET_PERIODIC_ADVERTISING_ENABLE_COMMAND,
- HCI_LE_SET_EXTENDED_SCAN_PARAMETERS_COMMAND,
- HCI_LE_SET_EXTENDED_SCAN_ENABLE_COMMAND,
- HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND
- ),
- # Octet 38
- (
- HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_COMMAND,
- HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_CANCEL_COMMAND,
- HCI_LE_PERIODIC_ADVERTISING_TERMINATE_SYNC_COMMAND,
- HCI_LE_ADD_DEVICE_TO_PERIODIC_ADVERTISER_LIST_COMMAND,
- HCI_LE_REMOVE_DEVICE_FROM_PERIODIC_ADVERTISER_LIST_COMMAND,
- HCI_LE_CLEAR_PERIODIC_ADVERTISER_LIST_COMMAND,
- HCI_LE_READ_PERIODIC_ADVERTISER_LIST_SIZE_COMMAND,
- HCI_LE_READ_TRANSMIT_POWER_COMMAND
- ),
- # Octet 39
- (
- HCI_LE_READ_RF_PATH_COMPENSATION_COMMAND,
- HCI_LE_WRITE_RF_PATH_COMPENSATION_COMMAND,
- HCI_LE_SET_PRIVACY_MODE_COMMAND,
- HCI_LE_RECEIVER_TEST_V3_COMMAND,
- HCI_LE_TRANSMITTER_TEST_V3_COMMAND,
- HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_PARAMETERS_COMMAND,
- HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_ENABLE_COMMAND,
- HCI_LE_SET_CONNECTIONLESS_IQ_SAMPLING_ENABLE_COMMAND,
- ),
- # Octet 40
- (
- HCI_LE_SET_CONNECTION_CTE_RECEIVE_PARAMETERS_COMMAND,
- HCI_LE_SET_CONNECTION_CTE_TRANSMIT_PARAMETERS_COMMAND,
- HCI_LE_CONNECTION_CTE_REQUEST_ENABLE_COMMAND,
- HCI_LE_CONNECTION_CTE_RESPONSE_ENABLE_COMMAND,
- HCI_LE_READ_ANTENNA_INFORMATION_COMMAND,
- HCI_LE_SET_PERIODIC_ADVERTISING_RECEIVE_ENABLE_COMMAND,
- HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_COMMAND,
- HCI_LE_PERIODIC_ADVERTISING_SET_INFO_TRANSFER_COMMAND
- ),
- # Octet 41
- (
- HCI_LE_SET_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND,
- HCI_LE_SET_DEFAULT_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND,
- HCI_LE_GENERATE_DHKEY_V2_COMMAND,
- HCI_READ_LOCAL_SIMPLE_PAIRING_OPTIONS_COMMAND,
- HCI_LE_MODIFY_SLEEP_CLOCK_ACCURACY_COMMAND,
- HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
- HCI_LE_READ_ISO_TX_SYNC_COMMAND,
- HCI_LE_SET_CIG_PARAMETERS_COMMAND
- ),
- # Octet 42
- (
- HCI_LE_SET_CIG_PARAMETERS_TEST_COMMAND,
- HCI_LE_CREATE_CIS_COMMAND,
- HCI_LE_REMOVE_CIG_COMMAND,
- HCI_LE_ACCEPT_CIS_REQUEST_COMMAND,
- HCI_LE_REJECT_CIS_REQUEST_COMMAND,
- HCI_LE_CREATE_BIG_COMMAND,
- HCI_LE_CREATE_BIG_TEST_COMMAND,
- HCI_LE_TERMINATE_BIG_COMMAND,
- ),
- # Octet 43
- (
- HCI_LE_BIG_CREATE_SYNC_COMMAND,
- HCI_LE_BIG_TERMINATE_SYNC_COMMAND,
- HCI_LE_REQUEST_PEER_SCA_COMMAND,
- HCI_LE_SETUP_ISO_DATA_PATH_COMMAND,
- HCI_LE_REMOVE_ISO_DATA_PATH_COMMAND,
- HCI_LE_ISO_TRANSMIT_TEST_COMMAND,
- HCI_LE_ISO_RECEIVE_TEST_COMMAND,
- HCI_LE_ISO_READ_TEST_COUNTERS_COMMAND
- ),
- # Octet 44
- (
- HCI_LE_ISO_TEST_END_COMMAND,
- HCI_LE_SET_HOST_FEATURE_COMMAND,
- HCI_LE_READ_ISO_LINK_QUALITY_COMMAND,
- HCI_LE_ENHANCED_READ_TRANSMIT_POWER_LEVEL_COMMAND,
- HCI_LE_READ_REMOTE_TRANSMIT_POWER_LEVEL_COMMAND,
- HCI_LE_SET_PATH_LOSS_REPORTING_PARAMETERS_COMMAND,
- HCI_LE_SET_PATH_LOSS_REPORTING_ENABLE_COMMAND,
- HCI_LE_SET_TRANSMIT_POWER_REPORTING_ENABLE_COMMAND
- ),
- # Octet 45
- (
- HCI_LE_TRANSMITTER_TEST_V4_COMMAND,
- HCI_SET_ECOSYSTEM_BASE_INTERVAL_COMMAND,
- HCI_READ_LOCAL_SUPPORTED_CODECS_V2_COMMAND,
- HCI_READ_LOCAL_SUPPORTED_CODEC_CAPABILITIES_COMMAND,
- HCI_READ_LOCAL_SUPPORTED_CONTROLLER_DELAY_COMMAND,
- HCI_CONFIGURE_DATA_PATH_COMMAND,
- HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND,
- HCI_SET_MIN_ENCRYPTION_KEY_SIZE_COMMAND
- ),
- # Octet 46
- (
- HCI_LE_SET_DEFAULT_SUBRATE_COMMAND,
- HCI_LE_SUBRATE_REQUEST_COMMAND,
- None,
- None,
- None,
- None,
- None,
- None
- )
-)
+HCI_SUPPORTED_COMMANDS_MASKS = {
+ HCI_INQUIRY_COMMAND : 1 << (0*8+0),
+ HCI_INQUIRY_CANCEL_COMMAND : 1 << (0*8+1),
+ HCI_PERIODIC_INQUIRY_MODE_COMMAND : 1 << (0*8+2),
+ HCI_EXIT_PERIODIC_INQUIRY_MODE_COMMAND : 1 << (0*8+3),
+ HCI_CREATE_CONNECTION_COMMAND : 1 << (0*8+4),
+ HCI_DISCONNECT_COMMAND : 1 << (0*8+5),
+ HCI_CREATE_CONNECTION_CANCEL_COMMAND : 1 << (0*8+7),
+ HCI_ACCEPT_CONNECTION_REQUEST_COMMAND : 1 << (1*8+0),
+ HCI_REJECT_CONNECTION_REQUEST_COMMAND : 1 << (1*8+1),
+ HCI_LINK_KEY_REQUEST_REPLY_COMMAND : 1 << (1*8+2),
+ HCI_LINK_KEY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (1*8+3),
+ HCI_PIN_CODE_REQUEST_REPLY_COMMAND : 1 << (1*8+4),
+ HCI_PIN_CODE_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (1*8+5),
+ HCI_CHANGE_CONNECTION_PACKET_TYPE_COMMAND : 1 << (1*8+6),
+ HCI_AUTHENTICATION_REQUESTED_COMMAND : 1 << (1*8+7),
+ HCI_SET_CONNECTION_ENCRYPTION_COMMAND : 1 << (2*8+0),
+ HCI_CHANGE_CONNECTION_LINK_KEY_COMMAND : 1 << (2*8+1),
+ HCI_LINK_KEY_SELECTION_COMMAND : 1 << (2*8+2),
+ HCI_REMOTE_NAME_REQUEST_COMMAND : 1 << (2*8+3),
+ HCI_REMOTE_NAME_REQUEST_CANCEL_COMMAND : 1 << (2*8+4),
+ HCI_READ_REMOTE_SUPPORTED_FEATURES_COMMAND : 1 << (2*8+5),
+ HCI_READ_REMOTE_EXTENDED_FEATURES_COMMAND : 1 << (2*8+6),
+ HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND : 1 << (2*8+7),
+ HCI_READ_CLOCK_OFFSET_COMMAND : 1 << (3*8+0),
+ HCI_READ_LMP_HANDLE_COMMAND : 1 << (3*8+1),
+ HCI_HOLD_MODE_COMMAND : 1 << (4*8+1),
+ HCI_SNIFF_MODE_COMMAND : 1 << (4*8+2),
+ HCI_EXIT_SNIFF_MODE_COMMAND : 1 << (4*8+3),
+ HCI_QOS_SETUP_COMMAND : 1 << (4*8+6),
+ HCI_ROLE_DISCOVERY_COMMAND : 1 << (4*8+7),
+ HCI_SWITCH_ROLE_COMMAND : 1 << (5*8+0),
+ HCI_READ_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+1),
+ HCI_WRITE_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+2),
+ HCI_READ_DEFAULT_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+3),
+ HCI_WRITE_DEFAULT_LINK_POLICY_SETTINGS_COMMAND : 1 << (5*8+4),
+ HCI_FLOW_SPECIFICATION_COMMAND : 1 << (5*8+5),
+ HCI_SET_EVENT_MASK_COMMAND : 1 << (5*8+6),
+ HCI_RESET_COMMAND : 1 << (5*8+7),
+ HCI_SET_EVENT_FILTER_COMMAND : 1 << (6*8+0),
+ HCI_FLUSH_COMMAND : 1 << (6*8+1),
+ HCI_READ_PIN_TYPE_COMMAND : 1 << (6*8+2),
+ HCI_WRITE_PIN_TYPE_COMMAND : 1 << (6*8+3),
+ HCI_READ_STORED_LINK_KEY_COMMAND : 1 << (6*8+5),
+ HCI_WRITE_STORED_LINK_KEY_COMMAND : 1 << (6*8+6),
+ HCI_DELETE_STORED_LINK_KEY_COMMAND : 1 << (6*8+7),
+ HCI_WRITE_LOCAL_NAME_COMMAND : 1 << (7*8+0),
+ HCI_READ_LOCAL_NAME_COMMAND : 1 << (7*8+1),
+ HCI_READ_CONNECTION_ACCEPT_TIMEOUT_COMMAND : 1 << (7*8+2),
+ HCI_WRITE_CONNECTION_ACCEPT_TIMEOUT_COMMAND : 1 << (7*8+3),
+ HCI_READ_PAGE_TIMEOUT_COMMAND : 1 << (7*8+4),
+ HCI_WRITE_PAGE_TIMEOUT_COMMAND : 1 << (7*8+5),
+ HCI_READ_SCAN_ENABLE_COMMAND : 1 << (7*8+6),
+ HCI_WRITE_SCAN_ENABLE_COMMAND : 1 << (7*8+7),
+ HCI_READ_PAGE_SCAN_ACTIVITY_COMMAND : 1 << (8*8+0),
+ HCI_WRITE_PAGE_SCAN_ACTIVITY_COMMAND : 1 << (8*8+1),
+ HCI_READ_INQUIRY_SCAN_ACTIVITY_COMMAND : 1 << (8*8+2),
+ HCI_WRITE_INQUIRY_SCAN_ACTIVITY_COMMAND : 1 << (8*8+3),
+ HCI_READ_AUTHENTICATION_ENABLE_COMMAND : 1 << (8*8+4),
+ HCI_WRITE_AUTHENTICATION_ENABLE_COMMAND : 1 << (8*8+5),
+ HCI_READ_CLASS_OF_DEVICE_COMMAND : 1 << (9*8+0),
+ HCI_WRITE_CLASS_OF_DEVICE_COMMAND : 1 << (9*8+1),
+ HCI_READ_VOICE_SETTING_COMMAND : 1 << (9*8+2),
+ HCI_WRITE_VOICE_SETTING_COMMAND : 1 << (9*8+3),
+ HCI_READ_AUTOMATIC_FLUSH_TIMEOUT_COMMAND : 1 << (9*8+4),
+ HCI_WRITE_AUTOMATIC_FLUSH_TIMEOUT_COMMAND : 1 << (9*8+5),
+ HCI_READ_NUM_BROADCAST_RETRANSMISSIONS_COMMAND : 1 << (9*8+6),
+ HCI_WRITE_NUM_BROADCAST_RETRANSMISSIONS_COMMAND : 1 << (9*8+7),
+ HCI_READ_HOLD_MODE_ACTIVITY_COMMAND : 1 << (10*8+0),
+ HCI_WRITE_HOLD_MODE_ACTIVITY_COMMAND : 1 << (10*8+1),
+ HCI_READ_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (10*8+2),
+ HCI_READ_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND : 1 << (10*8+3),
+ HCI_WRITE_SYNCHRONOUS_FLOW_CONTROL_ENABLE_COMMAND : 1 << (10*8+4),
+ HCI_SET_CONTROLLER_TO_HOST_FLOW_CONTROL_COMMAND : 1 << (10*8+5),
+ HCI_HOST_BUFFER_SIZE_COMMAND : 1 << (10*8+6),
+ HCI_HOST_NUMBER_OF_COMPLETED_PACKETS_COMMAND : 1 << (10*8+7),
+ HCI_READ_LINK_SUPERVISION_TIMEOUT_COMMAND : 1 << (11*8+0),
+ HCI_WRITE_LINK_SUPERVISION_TIMEOUT_COMMAND : 1 << (11*8+1),
+ HCI_READ_NUMBER_OF_SUPPORTED_IAC_COMMAND : 1 << (11*8+2),
+ HCI_READ_CURRENT_IAC_LAP_COMMAND : 1 << (11*8+3),
+ HCI_WRITE_CURRENT_IAC_LAP_COMMAND : 1 << (11*8+4),
+ HCI_SET_AFH_HOST_CHANNEL_CLASSIFICATION_COMMAND : 1 << (12*8+1),
+ HCI_READ_INQUIRY_SCAN_TYPE_COMMAND : 1 << (12*8+4),
+ HCI_WRITE_INQUIRY_SCAN_TYPE_COMMAND : 1 << (12*8+5),
+ HCI_READ_INQUIRY_MODE_COMMAND : 1 << (12*8+6),
+ HCI_WRITE_INQUIRY_MODE_COMMAND : 1 << (12*8+7),
+ HCI_READ_PAGE_SCAN_TYPE_COMMAND : 1 << (13*8+0),
+ HCI_WRITE_PAGE_SCAN_TYPE_COMMAND : 1 << (13*8+1),
+ HCI_READ_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND : 1 << (13*8+2),
+ HCI_WRITE_AFH_CHANNEL_ASSESSMENT_MODE_COMMAND : 1 << (13*8+3),
+ HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND : 1 << (14*8+3),
+ HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND : 1 << (14*8+5),
+ HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND : 1 << (14*8+6),
+ HCI_READ_BUFFER_SIZE_COMMAND : 1 << (14*8+7),
+ HCI_READ_BD_ADDR_COMMAND : 1 << (15*8+1),
+ HCI_READ_FAILED_CONTACT_COUNTER_COMMAND : 1 << (15*8+2),
+ HCI_RESET_FAILED_CONTACT_COUNTER_COMMAND : 1 << (15*8+3),
+ HCI_READ_LINK_QUALITY_COMMAND : 1 << (15*8+4),
+ HCI_READ_RSSI_COMMAND : 1 << (15*8+5),
+ HCI_READ_AFH_CHANNEL_MAP_COMMAND : 1 << (15*8+6),
+ HCI_READ_CLOCK_COMMAND : 1 << (15*8+7),
+ HCI_READ_LOOPBACK_MODE_COMMAND : 1 << (16*8+0),
+ HCI_WRITE_LOOPBACK_MODE_COMMAND : 1 << (16*8+1),
+ HCI_ENABLE_DEVICE_UNDER_TEST_MODE_COMMAND : 1 << (16*8+2),
+ HCI_SETUP_SYNCHRONOUS_CONNECTION_COMMAND : 1 << (16*8+3),
+ HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (16*8+4),
+ HCI_REJECT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (16*8+5),
+ HCI_READ_EXTENDED_INQUIRY_RESPONSE_COMMAND : 1 << (17*8+0),
+ HCI_WRITE_EXTENDED_INQUIRY_RESPONSE_COMMAND : 1 << (17*8+1),
+ HCI_REFRESH_ENCRYPTION_KEY_COMMAND : 1 << (17*8+2),
+ HCI_SNIFF_SUBRATING_COMMAND : 1 << (17*8+4),
+ HCI_READ_SIMPLE_PAIRING_MODE_COMMAND : 1 << (17*8+5),
+ HCI_WRITE_SIMPLE_PAIRING_MODE_COMMAND : 1 << (17*8+6),
+ HCI_READ_LOCAL_OOB_DATA_COMMAND : 1 << (17*8+7),
+ HCI_READ_INQUIRY_RESPONSE_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (18*8+0),
+ HCI_WRITE_INQUIRY_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (18*8+1),
+ HCI_READ_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND : 1 << (18*8+2),
+ HCI_WRITE_DEFAULT_ERRONEOUS_DATA_REPORTING_COMMAND : 1 << (18*8+3),
+ HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND : 1 << (18*8+7),
+ HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND : 1 << (19*8+0),
+ HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (19*8+1),
+ HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND : 1 << (19*8+2),
+ HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (19*8+3),
+ HCI_REMOTE_OOB_DATA_REQUEST_REPLY_COMMAND : 1 << (19*8+4),
+ HCI_WRITE_SIMPLE_PAIRING_DEBUG_MODE_COMMAND : 1 << (19*8+5),
+ HCI_ENHANCED_FLUSH_COMMAND : 1 << (19*8+6),
+ HCI_REMOTE_OOB_DATA_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (19*8+7),
+ HCI_SEND_KEYPRESS_NOTIFICATION_COMMAND : 1 << (20*8+2),
+ HCI_IO_CAPABILITY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (20*8+3),
+ HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND : 1 << (20*8+4),
+ HCI_SET_EVENT_MASK_PAGE_2_COMMAND : 1 << (22*8+2),
+ HCI_READ_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+0),
+ HCI_WRITE_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+1),
+ HCI_READ_DATA_BLOCK_SIZE_COMMAND : 1 << (23*8+2),
+ HCI_READ_ENHANCED_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (24*8+0),
+ HCI_READ_LE_HOST_SUPPORT_COMMAND : 1 << (24*8+5),
+ HCI_WRITE_LE_HOST_SUPPORT_COMMAND : 1 << (24*8+6),
+ HCI_LE_SET_EVENT_MASK_COMMAND : 1 << (25*8+0),
+ HCI_LE_READ_BUFFER_SIZE_COMMAND : 1 << (25*8+1),
+ HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND : 1 << (25*8+2),
+ HCI_LE_SET_RANDOM_ADDRESS_COMMAND : 1 << (25*8+4),
+ HCI_LE_SET_ADVERTISING_PARAMETERS_COMMAND : 1 << (25*8+5),
+ HCI_LE_READ_ADVERTISING_PHYSICAL_CHANNEL_TX_POWER_COMMAND : 1 << (25*8+6),
+ HCI_LE_SET_ADVERTISING_DATA_COMMAND : 1 << (25*8+7),
+ HCI_LE_SET_SCAN_RESPONSE_DATA_COMMAND : 1 << (26*8+0),
+ HCI_LE_SET_ADVERTISING_ENABLE_COMMAND : 1 << (26*8+1),
+ HCI_LE_SET_SCAN_PARAMETERS_COMMAND : 1 << (26*8+2),
+ HCI_LE_SET_SCAN_ENABLE_COMMAND : 1 << (26*8+3),
+ HCI_LE_CREATE_CONNECTION_COMMAND : 1 << (26*8+4),
+ HCI_LE_CREATE_CONNECTION_CANCEL_COMMAND : 1 << (26*8+5),
+ HCI_LE_READ_FILTER_ACCEPT_LIST_SIZE_COMMAND : 1 << (26*8+6),
+ HCI_LE_CLEAR_FILTER_ACCEPT_LIST_COMMAND : 1 << (26*8+7),
+ HCI_LE_ADD_DEVICE_TO_FILTER_ACCEPT_LIST_COMMAND : 1 << (27*8+0),
+ HCI_LE_REMOVE_DEVICE_FROM_FILTER_ACCEPT_LIST_COMMAND : 1 << (27*8+1),
+ HCI_LE_CONNECTION_UPDATE_COMMAND : 1 << (27*8+2),
+ HCI_LE_SET_HOST_CHANNEL_CLASSIFICATION_COMMAND : 1 << (27*8+3),
+ HCI_LE_READ_CHANNEL_MAP_COMMAND : 1 << (27*8+4),
+ HCI_LE_READ_REMOTE_FEATURES_COMMAND : 1 << (27*8+5),
+ HCI_LE_ENCRYPT_COMMAND : 1 << (27*8+6),
+ HCI_LE_RAND_COMMAND : 1 << (27*8+7),
+ HCI_LE_ENABLE_ENCRYPTION_COMMAND : 1 << (28*8+0),
+ HCI_LE_LONG_TERM_KEY_REQUEST_REPLY_COMMAND : 1 << (28*8+1),
+ HCI_LE_LONG_TERM_KEY_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (28*8+2),
+ HCI_LE_READ_SUPPORTED_STATES_COMMAND : 1 << (28*8+3),
+ HCI_LE_RECEIVER_TEST_COMMAND : 1 << (28*8+4),
+ HCI_LE_TRANSMITTER_TEST_COMMAND : 1 << (28*8+5),
+ HCI_LE_TEST_END_COMMAND : 1 << (28*8+6),
+ HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND : 1 << (29*8+3),
+ HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND : 1 << (29*8+4),
+ HCI_READ_LOCAL_SUPPORTED_CODECS_COMMAND : 1 << (29*8+5),
+ HCI_SET_MWS_CHANNEL_PARAMETERS_COMMAND : 1 << (29*8+6),
+ HCI_SET_EXTERNAL_FRAME_CONFIGURATION_COMMAND : 1 << (29*8+7),
+ HCI_SET_MWS_SIGNALING_COMMAND : 1 << (30*8+0),
+ HCI_SET_MWS_TRANSPORT_LAYER_COMMAND : 1 << (30*8+1),
+ HCI_SET_MWS_SCAN_FREQUENCY_TABLE_COMMAND : 1 << (30*8+2),
+ HCI_GET_MWS_TRANSPORT_LAYER_CONFIGURATION_COMMAND : 1 << (30*8+3),
+ HCI_SET_MWS_PATTERN_CONFIGURATION_COMMAND : 1 << (30*8+4),
+ HCI_SET_TRIGGERED_CLOCK_CAPTURE_COMMAND : 1 << (30*8+5),
+ HCI_TRUNCATED_PAGE_COMMAND : 1 << (30*8+6),
+ HCI_TRUNCATED_PAGE_CANCEL_COMMAND : 1 << (30*8+7),
+ HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_COMMAND : 1 << (31*8+0),
+ HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVE_COMMAND : 1 << (31*8+1),
+ HCI_START_SYNCHRONIZATION_TRAIN_COMMAND : 1 << (31*8+2),
+ HCI_RECEIVE_SYNCHRONIZATION_TRAIN_COMMAND : 1 << (31*8+3),
+ HCI_SET_RESERVED_LT_ADDR_COMMAND : 1 << (31*8+4),
+ HCI_DELETE_RESERVED_LT_ADDR_COMMAND : 1 << (31*8+5),
+ HCI_SET_CONNECTIONLESS_PERIPHERAL_BROADCAST_DATA_COMMAND : 1 << (31*8+6),
+ HCI_READ_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND : 1 << (31*8+7),
+ HCI_WRITE_SYNCHRONIZATION_TRAIN_PARAMETERS_COMMAND : 1 << (32*8+0),
+ HCI_REMOTE_OOB_EXTENDED_DATA_REQUEST_REPLY_COMMAND : 1 << (32*8+1),
+ HCI_READ_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND : 1 << (32*8+2),
+ HCI_WRITE_SECURE_CONNECTIONS_HOST_SUPPORT_COMMAND : 1 << (32*8+3),
+ HCI_READ_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND : 1 << (32*8+4),
+ HCI_WRITE_AUTHENTICATED_PAYLOAD_TIMEOUT_COMMAND : 1 << (32*8+5),
+ HCI_READ_LOCAL_OOB_EXTENDED_DATA_COMMAND : 1 << (32*8+6),
+ HCI_WRITE_SECURE_CONNECTIONS_TEST_MODE_COMMAND : 1 << (32*8+7),
+ HCI_READ_EXTENDED_PAGE_TIMEOUT_COMMAND : 1 << (33*8+0),
+ HCI_WRITE_EXTENDED_PAGE_TIMEOUT_COMMAND : 1 << (33*8+1),
+ HCI_READ_EXTENDED_INQUIRY_LENGTH_COMMAND : 1 << (33*8+2),
+ HCI_WRITE_EXTENDED_INQUIRY_LENGTH_COMMAND : 1 << (33*8+3),
+ HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_REPLY_COMMAND : 1 << (33*8+4),
+ HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_NEGATIVE_REPLY_COMMAND : 1 << (33*8+5),
+ HCI_LE_SET_DATA_LENGTH_COMMAND : 1 << (33*8+6),
+ HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND : 1 << (33*8+7),
+ HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND : 1 << (34*8+0),
+ HCI_LE_READ_LOCAL_P_256_PUBLIC_KEY_COMMAND : 1 << (34*8+1),
+ HCI_LE_GENERATE_DHKEY_COMMAND : 1 << (34*8+2),
+ HCI_LE_ADD_DEVICE_TO_RESOLVING_LIST_COMMAND : 1 << (34*8+3),
+ HCI_LE_REMOVE_DEVICE_FROM_RESOLVING_LIST_COMMAND : 1 << (34*8+4),
+ HCI_LE_CLEAR_RESOLVING_LIST_COMMAND : 1 << (34*8+5),
+ HCI_LE_READ_RESOLVING_LIST_SIZE_COMMAND : 1 << (34*8+6),
+ HCI_LE_READ_PEER_RESOLVABLE_ADDRESS_COMMAND : 1 << (34*8+7),
+ HCI_LE_READ_LOCAL_RESOLVABLE_ADDRESS_COMMAND : 1 << (35*8+0),
+ HCI_LE_SET_ADDRESS_RESOLUTION_ENABLE_COMMAND : 1 << (35*8+1),
+ HCI_LE_SET_RESOLVABLE_PRIVATE_ADDRESS_TIMEOUT_COMMAND : 1 << (35*8+2),
+ HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND : 1 << (35*8+3),
+ HCI_LE_READ_PHY_COMMAND : 1 << (35*8+4),
+ HCI_LE_SET_DEFAULT_PHY_COMMAND : 1 << (35*8+5),
+ HCI_LE_SET_PHY_COMMAND : 1 << (35*8+6),
+ HCI_LE_RECEIVER_TEST_V2_COMMAND : 1 << (35*8+7),
+ HCI_LE_TRANSMITTER_TEST_V2_COMMAND : 1 << (36*8+0),
+ HCI_LE_SET_ADVERTISING_SET_RANDOM_ADDRESS_COMMAND : 1 << (36*8+1),
+ HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_COMMAND : 1 << (36*8+2),
+ HCI_LE_SET_EXTENDED_ADVERTISING_DATA_COMMAND : 1 << (36*8+3),
+ HCI_LE_SET_EXTENDED_SCAN_RESPONSE_DATA_COMMAND : 1 << (36*8+4),
+ HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND : 1 << (36*8+5),
+ HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND : 1 << (36*8+6),
+ HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND : 1 << (36*8+7),
+ HCI_LE_REMOVE_ADVERTISING_SET_COMMAND : 1 << (37*8+0),
+ HCI_LE_CLEAR_ADVERTISING_SETS_COMMAND : 1 << (37*8+1),
+ HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_COMMAND : 1 << (37*8+2),
+ HCI_LE_SET_PERIODIC_ADVERTISING_DATA_COMMAND : 1 << (37*8+3),
+ HCI_LE_SET_PERIODIC_ADVERTISING_ENABLE_COMMAND : 1 << (37*8+4),
+ HCI_LE_SET_EXTENDED_SCAN_PARAMETERS_COMMAND : 1 << (37*8+5),
+ HCI_LE_SET_EXTENDED_SCAN_ENABLE_COMMAND : 1 << (37*8+6),
+ HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND : 1 << (37*8+7),
+ HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_COMMAND : 1 << (38*8+0),
+ HCI_LE_PERIODIC_ADVERTISING_CREATE_SYNC_CANCEL_COMMAND : 1 << (38*8+1),
+ HCI_LE_PERIODIC_ADVERTISING_TERMINATE_SYNC_COMMAND : 1 << (38*8+2),
+ HCI_LE_ADD_DEVICE_TO_PERIODIC_ADVERTISER_LIST_COMMAND : 1 << (38*8+3),
+ HCI_LE_REMOVE_DEVICE_FROM_PERIODIC_ADVERTISER_LIST_COMMAND : 1 << (38*8+4),
+ HCI_LE_CLEAR_PERIODIC_ADVERTISER_LIST_COMMAND : 1 << (38*8+5),
+ HCI_LE_READ_PERIODIC_ADVERTISER_LIST_SIZE_COMMAND : 1 << (38*8+6),
+ HCI_LE_READ_TRANSMIT_POWER_COMMAND : 1 << (38*8+7),
+ HCI_LE_READ_RF_PATH_COMPENSATION_COMMAND : 1 << (39*8+0),
+ HCI_LE_WRITE_RF_PATH_COMPENSATION_COMMAND : 1 << (39*8+1),
+ HCI_LE_SET_PRIVACY_MODE_COMMAND : 1 << (39*8+2),
+ HCI_LE_RECEIVER_TEST_V3_COMMAND : 1 << (39*8+3),
+ HCI_LE_TRANSMITTER_TEST_V3_COMMAND : 1 << (39*8+4),
+ HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_PARAMETERS_COMMAND : 1 << (39*8+5),
+ HCI_LE_SET_CONNECTIONLESS_CTE_TRANSMIT_ENABLE_COMMAND : 1 << (39*8+6),
+ HCI_LE_SET_CONNECTIONLESS_IQ_SAMPLING_ENABLE_COMMAND : 1 << (39*8+7),
+ HCI_LE_SET_CONNECTION_CTE_RECEIVE_PARAMETERS_COMMAND : 1 << (40*8+0),
+ HCI_LE_SET_CONNECTION_CTE_TRANSMIT_PARAMETERS_COMMAND : 1 << (40*8+1),
+ HCI_LE_CONNECTION_CTE_REQUEST_ENABLE_COMMAND : 1 << (40*8+2),
+ HCI_LE_CONNECTION_CTE_RESPONSE_ENABLE_COMMAND : 1 << (40*8+3),
+ HCI_LE_READ_ANTENNA_INFORMATION_COMMAND : 1 << (40*8+4),
+ HCI_LE_SET_PERIODIC_ADVERTISING_RECEIVE_ENABLE_COMMAND : 1 << (40*8+5),
+ HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_COMMAND : 1 << (40*8+6),
+ HCI_LE_PERIODIC_ADVERTISING_SET_INFO_TRANSFER_COMMAND : 1 << (40*8+7),
+ HCI_LE_SET_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND : 1 << (41*8+0),
+ HCI_LE_SET_DEFAULT_PERIODIC_ADVERTISING_SYNC_TRANSFER_PARAMETERS_COMMAND : 1 << (41*8+1),
+ HCI_LE_GENERATE_DHKEY_V2_COMMAND : 1 << (41*8+2),
+ HCI_READ_LOCAL_SIMPLE_PAIRING_OPTIONS_COMMAND : 1 << (41*8+3),
+ HCI_LE_MODIFY_SLEEP_CLOCK_ACCURACY_COMMAND : 1 << (41*8+4),
+ HCI_LE_READ_BUFFER_SIZE_V2_COMMAND : 1 << (41*8+5),
+ HCI_LE_READ_ISO_TX_SYNC_COMMAND : 1 << (41*8+6),
+ HCI_LE_SET_CIG_PARAMETERS_COMMAND : 1 << (41*8+7),
+ HCI_LE_SET_CIG_PARAMETERS_TEST_COMMAND : 1 << (42*8+0),
+ HCI_LE_CREATE_CIS_COMMAND : 1 << (42*8+1),
+ HCI_LE_REMOVE_CIG_COMMAND : 1 << (42*8+2),
+ HCI_LE_ACCEPT_CIS_REQUEST_COMMAND : 1 << (42*8+3),
+ HCI_LE_REJECT_CIS_REQUEST_COMMAND : 1 << (42*8+4),
+ HCI_LE_CREATE_BIG_COMMAND : 1 << (42*8+5),
+ HCI_LE_CREATE_BIG_TEST_COMMAND : 1 << (42*8+6),
+ HCI_LE_TERMINATE_BIG_COMMAND : 1 << (42*8+7),
+ HCI_LE_BIG_CREATE_SYNC_COMMAND : 1 << (43*8+0),
+ HCI_LE_BIG_TERMINATE_SYNC_COMMAND : 1 << (43*8+1),
+ HCI_LE_REQUEST_PEER_SCA_COMMAND : 1 << (43*8+2),
+ HCI_LE_SETUP_ISO_DATA_PATH_COMMAND : 1 << (43*8+3),
+ HCI_LE_REMOVE_ISO_DATA_PATH_COMMAND : 1 << (43*8+4),
+ HCI_LE_ISO_TRANSMIT_TEST_COMMAND : 1 << (43*8+5),
+ HCI_LE_ISO_RECEIVE_TEST_COMMAND : 1 << (43*8+6),
+ HCI_LE_ISO_READ_TEST_COUNTERS_COMMAND : 1 << (43*8+7),
+ HCI_LE_ISO_TEST_END_COMMAND : 1 << (44*8+0),
+ HCI_LE_SET_HOST_FEATURE_COMMAND : 1 << (44*8+1),
+ HCI_LE_READ_ISO_LINK_QUALITY_COMMAND : 1 << (44*8+2),
+ HCI_LE_ENHANCED_READ_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (44*8+3),
+ HCI_LE_READ_REMOTE_TRANSMIT_POWER_LEVEL_COMMAND : 1 << (44*8+4),
+ HCI_LE_SET_PATH_LOSS_REPORTING_PARAMETERS_COMMAND : 1 << (44*8+5),
+ HCI_LE_SET_PATH_LOSS_REPORTING_ENABLE_COMMAND : 1 << (44*8+6),
+ HCI_LE_SET_TRANSMIT_POWER_REPORTING_ENABLE_COMMAND : 1 << (44*8+7),
+ HCI_LE_TRANSMITTER_TEST_V4_COMMAND : 1 << (45*8+0),
+ HCI_SET_ECOSYSTEM_BASE_INTERVAL_COMMAND : 1 << (45*8+1),
+ HCI_READ_LOCAL_SUPPORTED_CODECS_V2_COMMAND : 1 << (45*8+2),
+ HCI_READ_LOCAL_SUPPORTED_CODEC_CAPABILITIES_COMMAND : 1 << (45*8+3),
+ HCI_READ_LOCAL_SUPPORTED_CONTROLLER_DELAY_COMMAND : 1 << (45*8+4),
+ HCI_CONFIGURE_DATA_PATH_COMMAND : 1 << (45*8+5),
+ HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND : 1 << (45*8+6),
+ HCI_SET_MIN_ENCRYPTION_KEY_SIZE_COMMAND : 1 << (45*8+7),
+ HCI_LE_SET_DEFAULT_SUBRATE_COMMAND : 1 << (46*8+0),
+ HCI_LE_SUBRATE_REQUEST_COMMAND : 1 << (46*8+1),
+ HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND : 1 << (46*8+2),
+ HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND : 1 << (46*8+5),
+ HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND : 1 << (46*8+6),
+ HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND : 1 << (46*8+7),
+ HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND : 1 << (47*8+0),
+ HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND : 1 << (47*8+1),
+}
# LE Supported Features
-HCI_LE_ENCRYPTION_LE_SUPPORTED_FEATURE = 0
-HCI_CONNECTION_PARAMETERS_REQUEST_PROCEDURE_LE_SUPPORTED_FEATURE = 1
-HCI_EXTENDED_REJECT_INDICATION_LE_SUPPORTED_FEATURE = 2
-HCI_PERIPHERAL_INITIATED_FEATURE_EXCHANGE_LE_SUPPORTED_FEATURE = 3
-HCI_LE_PING_LE_SUPPORTED_FEATURE = 4
-HCI_LE_DATA_PACKET_LENGTH_EXTENSION_LE_SUPPORTED_FEATURE = 5
-HCI_LL_PRIVACY_LE_SUPPORTED_FEATURE = 6
-HCI_EXTENDED_SCANNER_FILTER_POLICIES_LE_SUPPORTED_FEATURE = 7
-HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE = 8
-HCI_STABLE_MODULATION_INDEX_TRANSMITTER_LE_SUPPORTED_FEATURE = 9
-HCI_STABLE_MODULATION_INDEX_RECEIVER_LE_SUPPORTED_FEATURE = 10
-HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE = 11
-HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE = 12
-HCI_LE_PERIODIC_ADVERTISING_LE_SUPPORTED_FEATURE = 13
-HCI_CHANNEL_SELECTION_ALGORITHM_2_LE_SUPPORTED_FEATURE = 14
-HCI_LE_POWER_CLASS_1_LE_SUPPORTED_FEATURE = 15
-HCI_MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE_LE_SUPPORTED_FEATURE = 16
-HCI_CONNECTION_CTE_REQUEST_LE_SUPPORTED_FEATURE = 17
-HCI_CONNECTION_CTE_RESPONSE_LE_SUPPORTED_FEATURE = 18
-HCI_CONNECTIONLESS_CTE_TRANSMITTER_LE_SUPPORTED_FEATURE = 19
-HCI_CONNECTIONLESS_CTR_RECEIVER_LE_SUPPORTED_FEATURE = 20
-HCI_ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION_LE_SUPPORTED_FEATURE = 21
-HCI_ANTENNA_SWITCHING_DURING_CTE_RECEPTION_LE_SUPPORTED_FEATURE = 22
-HCI_RECEIVING_CONSTANT_TONE_EXTENSIONS_LE_SUPPORTED_FEATURE = 23
-HCI_PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER_LE_SUPPORTED_FEATURE = 24
-HCI_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT_LE_SUPPORTED_FEATURE = 25
-HCI_SLEEP_CLOCK_ACCURACY_UPDATES_LE_SUPPORTED_FEATURE = 26
-HCI_REMOTE_PUBLIC_KEY_VALIDATION_LE_SUPPORTED_FEATURE = 27
-HCI_CONNECTED_ISOCHRONOUS_STREAM_CENTRAL_LE_SUPPORTED_FEATURE = 28
-HCI_CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL_LE_SUPPORTED_FEATURE = 29
-HCI_ISOCHRONOUS_BROADCASTER_LE_SUPPORTED_FEATURE = 30
-HCI_SYNCHRONIZED_RECEIVER_LE_SUPPORTED_FEATURE = 31
-HCI_CONNECTED_ISOCHRONOUS_STREAM_LE_SUPPORTED_FEATURE = 32
-HCI_LE_POWER_CONTROL_REQUEST_LE_SUPPORTED_FEATURE = 33
-HCI_LE_POWER_CONTROL_REQUEST_DUP_LE_SUPPORTED_FEATURE = 34
-HCI_LE_PATH_LOSS_MONITORING_LE_SUPPORTED_FEATURE = 35
-HCI_PERIODIC_ADVERTISING_ADI_SUPPORT_LE_SUPPORTED_FEATURE = 36
-HCI_CONNECTION_SUBRATING_LE_SUPPORTED_FEATURE = 37
-HCI_CONNECTION_SUBRATING_HOST_SUPPORT_LE_SUPPORTED_FEATURE = 38
-HCI_CHANNEL_CLASSIFICATION_LE_SUPPORTED_FEATURE = 39
-
-HCI_LE_SUPPORTED_FEATURES_NAMES = {
- flag: feature_name for (feature_name, flag) in globals().items()
- if feature_name.startswith('HCI_') and feature_name.endswith('_LE_SUPPORTED_FEATURE')
-}
+# See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
+class LeFeature(enum.IntEnum):
+ LE_ENCRYPTION = 0
+ CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1
+ EXTENDED_REJECT_INDICATION = 2
+ PERIPHERAL_INITIATED_FEATURE_EXCHANGE = 3
+ LE_PING = 4
+ LE_DATA_PACKET_LENGTH_EXTENSION = 5
+ LL_PRIVACY = 6
+ EXTENDED_SCANNER_FILTER_POLICIES = 7
+ LE_2M_PHY = 8
+ STABLE_MODULATION_INDEX_TRANSMITTER = 9
+ STABLE_MODULATION_INDEX_RECEIVER = 10
+ LE_CODED_PHY = 11
+ LE_EXTENDED_ADVERTISING = 12
+ LE_PERIODIC_ADVERTISING = 13
+ CHANNEL_SELECTION_ALGORITHM_2 = 14
+ LE_POWER_CLASS_1 = 15
+ MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE = 16
+ CONNECTION_CTE_REQUEST = 17
+ CONNECTION_CTE_RESPONSE = 18
+ CONNECTIONLESS_CTE_TRANSMITTER = 19
+ CONNECTIONLESS_CTR_RECEIVER = 20
+ ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION = 21
+ ANTENNA_SWITCHING_DURING_CTE_RECEPTION = 22
+ RECEIVING_CONSTANT_TONE_EXTENSIONS = 23
+ PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER = 24
+ PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT = 25
+ SLEEP_CLOCK_ACCURACY_UPDATES = 26
+ REMOTE_PUBLIC_KEY_VALIDATION = 27
+ CONNECTED_ISOCHRONOUS_STREAM_CENTRAL = 28
+ CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL = 29
+ ISOCHRONOUS_BROADCASTER = 30
+ SYNCHRONIZED_RECEIVER = 31
+ CONNECTED_ISOCHRONOUS_STREAM = 32
+ LE_POWER_CONTROL_REQUEST = 33
+ LE_POWER_CONTROL_REQUEST_DUP = 34
+ LE_PATH_LOSS_MONITORING = 35
+ PERIODIC_ADVERTISING_ADI_SUPPORT = 36
+ CONNECTION_SUBRATING = 37
+ CONNECTION_SUBRATING_HOST_SUPPORT = 38
+ CHANNEL_CLASSIFICATION = 39
+ ADVERTISING_CODING_SELECTION = 40
+ ADVERTISING_CODING_SELECTION_HOST_SUPPORT = 41
+ PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 43
+ PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER = 44
+
+class LeFeatureMask(enum.IntFlag):
+ LE_ENCRYPTION = 1 << LeFeature.LE_ENCRYPTION
+ CONNECTION_PARAMETERS_REQUEST_PROCEDURE = 1 << LeFeature.CONNECTION_PARAMETERS_REQUEST_PROCEDURE
+ EXTENDED_REJECT_INDICATION = 1 << LeFeature.EXTENDED_REJECT_INDICATION
+ PERIPHERAL_INITIATED_FEATURE_EXCHANGE = 1 << LeFeature.PERIPHERAL_INITIATED_FEATURE_EXCHANGE
+ LE_PING = 1 << LeFeature.LE_PING
+ LE_DATA_PACKET_LENGTH_EXTENSION = 1 << LeFeature.LE_DATA_PACKET_LENGTH_EXTENSION
+ LL_PRIVACY = 1 << LeFeature.LL_PRIVACY
+ EXTENDED_SCANNER_FILTER_POLICIES = 1 << LeFeature.EXTENDED_SCANNER_FILTER_POLICIES
+ LE_2M_PHY = 1 << LeFeature.LE_2M_PHY
+ STABLE_MODULATION_INDEX_TRANSMITTER = 1 << LeFeature.STABLE_MODULATION_INDEX_TRANSMITTER
+ STABLE_MODULATION_INDEX_RECEIVER = 1 << LeFeature.STABLE_MODULATION_INDEX_RECEIVER
+ LE_CODED_PHY = 1 << LeFeature.LE_CODED_PHY
+ LE_EXTENDED_ADVERTISING = 1 << LeFeature.LE_EXTENDED_ADVERTISING
+ LE_PERIODIC_ADVERTISING = 1 << LeFeature.LE_PERIODIC_ADVERTISING
+ CHANNEL_SELECTION_ALGORITHM_2 = 1 << LeFeature.CHANNEL_SELECTION_ALGORITHM_2
+ LE_POWER_CLASS_1 = 1 << LeFeature.LE_POWER_CLASS_1
+ MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE = 1 << LeFeature.MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE
+ CONNECTION_CTE_REQUEST = 1 << LeFeature.CONNECTION_CTE_REQUEST
+ CONNECTION_CTE_RESPONSE = 1 << LeFeature.CONNECTION_CTE_RESPONSE
+ CONNECTIONLESS_CTE_TRANSMITTER = 1 << LeFeature.CONNECTIONLESS_CTE_TRANSMITTER
+ CONNECTIONLESS_CTR_RECEIVER = 1 << LeFeature.CONNECTIONLESS_CTR_RECEIVER
+ ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION = 1 << LeFeature.ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION
+ ANTENNA_SWITCHING_DURING_CTE_RECEPTION = 1 << LeFeature.ANTENNA_SWITCHING_DURING_CTE_RECEPTION
+ RECEIVING_CONSTANT_TONE_EXTENSIONS = 1 << LeFeature.RECEIVING_CONSTANT_TONE_EXTENSIONS
+ PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER = 1 << LeFeature.PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER
+ PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT = 1 << LeFeature.PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT
+ SLEEP_CLOCK_ACCURACY_UPDATES = 1 << LeFeature.SLEEP_CLOCK_ACCURACY_UPDATES
+ REMOTE_PUBLIC_KEY_VALIDATION = 1 << LeFeature.REMOTE_PUBLIC_KEY_VALIDATION
+ CONNECTED_ISOCHRONOUS_STREAM_CENTRAL = 1 << LeFeature.CONNECTED_ISOCHRONOUS_STREAM_CENTRAL
+ CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL = 1 << LeFeature.CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL
+ ISOCHRONOUS_BROADCASTER = 1 << LeFeature.ISOCHRONOUS_BROADCASTER
+ SYNCHRONIZED_RECEIVER = 1 << LeFeature.SYNCHRONIZED_RECEIVER
+ CONNECTED_ISOCHRONOUS_STREAM = 1 << LeFeature.CONNECTED_ISOCHRONOUS_STREAM
+ LE_POWER_CONTROL_REQUEST = 1 << LeFeature.LE_POWER_CONTROL_REQUEST
+ LE_POWER_CONTROL_REQUEST_DUP = 1 << LeFeature.LE_POWER_CONTROL_REQUEST_DUP
+ LE_PATH_LOSS_MONITORING = 1 << LeFeature.LE_PATH_LOSS_MONITORING
+ PERIODIC_ADVERTISING_ADI_SUPPORT = 1 << LeFeature.PERIODIC_ADVERTISING_ADI_SUPPORT
+ CONNECTION_SUBRATING = 1 << LeFeature.CONNECTION_SUBRATING
+ CONNECTION_SUBRATING_HOST_SUPPORT = 1 << LeFeature.CONNECTION_SUBRATING_HOST_SUPPORT
+ CHANNEL_CLASSIFICATION = 1 << LeFeature.CHANNEL_CLASSIFICATION
+ ADVERTISING_CODING_SELECTION = 1 << LeFeature.ADVERTISING_CODING_SELECTION
+ ADVERTISING_CODING_SELECTION_HOST_SUPPORT = 1 << LeFeature.ADVERTISING_CODING_SELECTION_HOST_SUPPORT
+ PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER
+ PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER
+
+class LmpFeature(enum.IntEnum):
+ # Page 0 (Legacy LMP features)
+ LMP_3_SLOT_PACKETS = 0
+ LMP_5_SLOT_PACKETS = 1
+ ENCRYPTION = 2
+ SLOT_OFFSET = 3
+ TIMING_ACCURACY = 4
+ ROLE_SWITCH = 5
+ HOLD_MODE = 6
+ SNIFF_MODE = 7
+ # PREVIOUSLY_USED = 8
+ POWER_CONTROL_REQUESTS = 9
+ CHANNEL_QUALITY_DRIVEN_DATA_RATE_CQDDR = 10
+ SCO_LINK = 11
+ HV2_PACKETS = 12
+ HV3_PACKETS = 13
+ U_LAW_LOG_SYNCHRONOUS_DATA = 14
+ A_LAW_LOG_SYNCHRONOUS_DATA = 15
+ CVSD_SYNCHRONOUS_DATA = 16
+ PAGING_PARAMETER_NEGOTIATION = 17
+ POWER_CONTROL = 18
+ TRANSPARENT_SYNCHRONOUS_DATA = 19
+ FLOW_CONTROL_LAG_LEAST_SIGNIFICANT_BIT = 20
+ FLOW_CONTROL_LAG_MIDDLE_BIT = 21
+ FLOW_CONTROL_LAG_MOST_SIGNIFICANT_BIT = 22
+ BROADCAST_ENCRYPTION = 23
+ # RESERVED_FOR_FUTURE_USE = 24
+ ENHANCED_DATA_RATE_ACL_2_MBPS_MODE = 25
+ ENHANCED_DATA_RATE_ACL_3_MBPS_MODE = 26
+ ENHANCED_INQUIRY_SCAN = 27
+ INTERLACED_INQUIRY_SCAN = 28
+ INTERLACED_PAGE_SCAN = 29
+ RSSI_WITH_INQUIRY_RESULTS = 30
+ EXTENDED_SCO_LINK_EV3_PACKETS = 31
+ EV4_PACKETS = 32
+ EV5_PACKETS = 33
+ # RESERVED_FOR_FUTURE_USE = 34
+ AFH_CAPABLE_PERIPHERAL = 35
+ AFH_CLASSIFICATION_PERIPHERAL = 36
+ BR_EDR_NOT_SUPPORTED = 37
+ LE_SUPPORTED_CONTROLLER = 38
+ LMP_3_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS = 39
+ LMP_5_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS = 40
+ SNIFF_SUBRATING = 41
+ PAUSE_ENCRYPTION = 42
+ AFH_CAPABLE_CENTRAL = 43
+ AFH_CLASSIFICATION_CENTRAL = 44
+ ENHANCED_DATA_RATE_ESCO_2_MBPS_MODE = 45
+ ENHANCED_DATA_RATE_ESCO_3_MBPS_MODE = 46
+ LMP_3_SLOT_ENHANCED_DATA_RATE_ESCO_PACKETS = 47
+ EXTENDED_INQUIRY_RESPONSE = 48
+ SIMULTANEOUS_LE_AND_BR_EDR_TO_SAME_DEVICE_CAPABLE_CONTROLLER = 49
+ # RESERVED_FOR_FUTURE_USE = 50
+ SECURE_SIMPLE_PAIRING_CONTROLLER_SUPPORT = 51
+ ENCAPSULATED_PDU = 52
+ ERRONEOUS_DATA_REPORTING = 53
+ NON_FLUSHABLE_PACKET_BOUNDARY_FLAG = 54
+ # RESERVED_FOR_FUTURE_USE = 55
+ HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT = 56
+ VARIABLE_INQUIRY_TX_POWER_LEVEL = 57
+ ENHANCED_POWER_CONTROL = 58
+ # RESERVED_FOR_FUTURE_USE = 59
+ # RESERVED_FOR_FUTURE_USE = 60
+ # RESERVED_FOR_FUTURE_USE = 61
+ # RESERVED_FOR_FUTURE_USE = 62
+ EXTENDED_FEATURES = 63
+
+ # Page 1
+ SECURE_SIMPLE_PAIRING_HOST_SUPPORT = 64
+ LE_SUPPORTED_HOST = 65
+ # PREVIOUSLY_USED = 66
+ SECURE_CONNECTIONS_HOST_SUPPORT = 67
+
+ # Page 2
+ CONNECTIONLESS_PERIPHERAL_BROADCAST_TRANSMITTER_OPERATION = 128
+ CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVER_OPERATION = 129
+ SYNCHRONIZATION_TRAIN = 130
+ SYNCHRONIZATION_SCAN = 131
+ HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT = 132
+ GENERALIZED_INTERLACED_SCAN = 133
+ COARSE_CLOCK_ADJUSTMENT = 134
+ RESERVED_FOR_FUTURE_USE = 135
+ SECURE_CONNECTIONS_CONTROLLER_SUPPORT = 136
+ PING = 137
+ SLOT_AVAILABILITY_MASK = 138
+ TRAIN_NUDGING = 139
+
+class LmpFeatureMask(enum.IntFlag):
+ # Page 0 (Legacy LMP features)
+ LMP_3_SLOT_PACKETS = (1 << LmpFeature.LMP_3_SLOT_PACKETS)
+ LMP_5_SLOT_PACKETS = (1 << LmpFeature.LMP_5_SLOT_PACKETS)
+ ENCRYPTION = (1 << LmpFeature.ENCRYPTION)
+ SLOT_OFFSET = (1 << LmpFeature.SLOT_OFFSET)
+ TIMING_ACCURACY = (1 << LmpFeature.TIMING_ACCURACY)
+ ROLE_SWITCH = (1 << LmpFeature.ROLE_SWITCH)
+ HOLD_MODE = (1 << LmpFeature.HOLD_MODE)
+ SNIFF_MODE = (1 << LmpFeature.SNIFF_MODE)
+ # PREVIOUSLY_USED = (1 << LmpFeature.PREVIOUSLY_USED)
+ POWER_CONTROL_REQUESTS = (1 << LmpFeature.POWER_CONTROL_REQUESTS)
+ CHANNEL_QUALITY_DRIVEN_DATA_RATE_CQDDR = (1 << LmpFeature.CHANNEL_QUALITY_DRIVEN_DATA_RATE_CQDDR)
+ SCO_LINK = (1 << LmpFeature.SCO_LINK)
+ HV2_PACKETS = (1 << LmpFeature.HV2_PACKETS)
+ HV3_PACKETS = (1 << LmpFeature.HV3_PACKETS)
+ U_LAW_LOG_SYNCHRONOUS_DATA = (1 << LmpFeature.U_LAW_LOG_SYNCHRONOUS_DATA)
+ A_LAW_LOG_SYNCHRONOUS_DATA = (1 << LmpFeature.A_LAW_LOG_SYNCHRONOUS_DATA)
+ CVSD_SYNCHRONOUS_DATA = (1 << LmpFeature.CVSD_SYNCHRONOUS_DATA)
+ PAGING_PARAMETER_NEGOTIATION = (1 << LmpFeature.PAGING_PARAMETER_NEGOTIATION)
+ POWER_CONTROL = (1 << LmpFeature.POWER_CONTROL)
+ TRANSPARENT_SYNCHRONOUS_DATA = (1 << LmpFeature.TRANSPARENT_SYNCHRONOUS_DATA)
+ FLOW_CONTROL_LAG_LEAST_SIGNIFICANT_BIT = (1 << LmpFeature.FLOW_CONTROL_LAG_LEAST_SIGNIFICANT_BIT)
+ FLOW_CONTROL_LAG_MIDDLE_BIT = (1 << LmpFeature.FLOW_CONTROL_LAG_MIDDLE_BIT)
+ FLOW_CONTROL_LAG_MOST_SIGNIFICANT_BIT = (1 << LmpFeature.FLOW_CONTROL_LAG_MOST_SIGNIFICANT_BIT)
+ BROADCAST_ENCRYPTION = (1 << LmpFeature.BROADCAST_ENCRYPTION)
+ # RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+ ENHANCED_DATA_RATE_ACL_2_MBPS_MODE = (1 << LmpFeature.ENHANCED_DATA_RATE_ACL_2_MBPS_MODE)
+ ENHANCED_DATA_RATE_ACL_3_MBPS_MODE = (1 << LmpFeature.ENHANCED_DATA_RATE_ACL_3_MBPS_MODE)
+ ENHANCED_INQUIRY_SCAN = (1 << LmpFeature.ENHANCED_INQUIRY_SCAN)
+ INTERLACED_INQUIRY_SCAN = (1 << LmpFeature.INTERLACED_INQUIRY_SCAN)
+ INTERLACED_PAGE_SCAN = (1 << LmpFeature.INTERLACED_PAGE_SCAN)
+ RSSI_WITH_INQUIRY_RESULTS = (1 << LmpFeature.RSSI_WITH_INQUIRY_RESULTS)
+ EXTENDED_SCO_LINK_EV3_PACKETS = (1 << LmpFeature.EXTENDED_SCO_LINK_EV3_PACKETS)
+ EV4_PACKETS = (1 << LmpFeature.EV4_PACKETS)
+ EV5_PACKETS = (1 << LmpFeature.EV5_PACKETS)
+ # RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+ AFH_CAPABLE_PERIPHERAL = (1 << LmpFeature.AFH_CAPABLE_PERIPHERAL)
+ AFH_CLASSIFICATION_PERIPHERAL = (1 << LmpFeature.AFH_CLASSIFICATION_PERIPHERAL)
+ BR_EDR_NOT_SUPPORTED = (1 << LmpFeature.BR_EDR_NOT_SUPPORTED)
+ LE_SUPPORTED_CONTROLLER = (1 << LmpFeature.LE_SUPPORTED_CONTROLLER)
+ LMP_3_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS = (1 << LmpFeature.LMP_3_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS)
+ LMP_5_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS = (1 << LmpFeature.LMP_5_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS)
+ SNIFF_SUBRATING = (1 << LmpFeature.SNIFF_SUBRATING)
+ PAUSE_ENCRYPTION = (1 << LmpFeature.PAUSE_ENCRYPTION)
+ AFH_CAPABLE_CENTRAL = (1 << LmpFeature.AFH_CAPABLE_CENTRAL)
+ AFH_CLASSIFICATION_CENTRAL = (1 << LmpFeature.AFH_CLASSIFICATION_CENTRAL)
+ ENHANCED_DATA_RATE_ESCO_2_MBPS_MODE = (1 << LmpFeature.ENHANCED_DATA_RATE_ESCO_2_MBPS_MODE)
+ ENHANCED_DATA_RATE_ESCO_3_MBPS_MODE = (1 << LmpFeature.ENHANCED_DATA_RATE_ESCO_3_MBPS_MODE)
+ LMP_3_SLOT_ENHANCED_DATA_RATE_ESCO_PACKETS = (1 << LmpFeature.LMP_3_SLOT_ENHANCED_DATA_RATE_ESCO_PACKETS)
+ EXTENDED_INQUIRY_RESPONSE = (1 << LmpFeature.EXTENDED_INQUIRY_RESPONSE)
+ SIMULTANEOUS_LE_AND_BR_EDR_TO_SAME_DEVICE_CAPABLE_CONTROLLER = (1 << LmpFeature.SIMULTANEOUS_LE_AND_BR_EDR_TO_SAME_DEVICE_CAPABLE_CONTROLLER)
+ # RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+ SECURE_SIMPLE_PAIRING_CONTROLLER_SUPPORT = (1 << LmpFeature.SECURE_SIMPLE_PAIRING_CONTROLLER_SUPPORT)
+ ENCAPSULATED_PDU = (1 << LmpFeature.ENCAPSULATED_PDU)
+ ERRONEOUS_DATA_REPORTING = (1 << LmpFeature.ERRONEOUS_DATA_REPORTING)
+ NON_FLUSHABLE_PACKET_BOUNDARY_FLAG = (1 << LmpFeature.NON_FLUSHABLE_PACKET_BOUNDARY_FLAG)
+ # RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+ HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT = (1 << LmpFeature.HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT)
+ VARIABLE_INQUIRY_TX_POWER_LEVEL = (1 << LmpFeature.VARIABLE_INQUIRY_TX_POWER_LEVEL)
+ ENHANCED_POWER_CONTROL = (1 << LmpFeature.ENHANCED_POWER_CONTROL)
+ # RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+ # RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+ # RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+ # RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+ EXTENDED_FEATURES = (1 << LmpFeature.EXTENDED_FEATURES)
+
+ # Page 1
+ SECURE_SIMPLE_PAIRING_HOST_SUPPORT = (1 << LmpFeature.SECURE_SIMPLE_PAIRING_HOST_SUPPORT)
+ LE_SUPPORTED_HOST = (1 << LmpFeature.LE_SUPPORTED_HOST)
+ # PREVIOUSLY_USED = (1 << LmpFeature.PREVIOUSLY_USED)
+ SECURE_CONNECTIONS_HOST_SUPPORT = (1 << LmpFeature.SECURE_CONNECTIONS_HOST_SUPPORT)
+
+ # Page 2
+ CONNECTIONLESS_PERIPHERAL_BROADCAST_TRANSMITTER_OPERATION = (1 << LmpFeature.CONNECTIONLESS_PERIPHERAL_BROADCAST_TRANSMITTER_OPERATION)
+ CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVER_OPERATION = (1 << LmpFeature.CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVER_OPERATION)
+ SYNCHRONIZATION_TRAIN = (1 << LmpFeature.SYNCHRONIZATION_TRAIN)
+ SYNCHRONIZATION_SCAN = (1 << LmpFeature.SYNCHRONIZATION_SCAN)
+ HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT = (1 << LmpFeature.HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT)
+ GENERALIZED_INTERLACED_SCAN = (1 << LmpFeature.GENERALIZED_INTERLACED_SCAN)
+ COARSE_CLOCK_ADJUSTMENT = (1 << LmpFeature.COARSE_CLOCK_ADJUSTMENT)
+ RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
+ SECURE_CONNECTIONS_CONTROLLER_SUPPORT = (1 << LmpFeature.SECURE_CONNECTIONS_CONTROLLER_SUPPORT)
+ PING = (1 << LmpFeature.PING)
+ SLOT_AVAILABILITY_MASK = (1 << LmpFeature.SLOT_AVAILABILITY_MASK)
+ TRAIN_NUDGING = (1 << LmpFeature.TRAIN_NUDGING)
+
# fmt: on
# pylint: enable=line-too-long
@@ -1379,6 +1380,45 @@ HCI_LE_SUPPORTED_FEATURES_NAMES = {
STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)}
+class CodecID(enum.IntEnum):
+ # fmt: off
+ U_LOG = 0x00
+ A_LOG = 0x01
+ CVSD = 0x02
+ TRANSPARENT = 0x03
+ LINEAR_PCM = 0x04
+ MSBC = 0x05
+ LC3 = 0x06
+ G729A = 0x07
+ VENDOR_SPECIFIC = 0xFF
+
+
+@dataclasses.dataclass(frozen=True)
+class CodingFormat:
+ codec_id: CodecID
+ company_id: int = 0
+ vendor_specific_codec_id: int = 0
+
+ @classmethod
+ def parse_from_bytes(cls, data: bytes, offset: int):
+ (codec_id, company_id, vendor_specific_codec_id) = struct.unpack_from(
+ '<BHH', data, offset
+ )
+ return offset + 5, cls(
+ codec_id=CodecID(codec_id),
+ company_id=company_id,
+ vendor_specific_codec_id=vendor_specific_codec_id,
+ )
+
+ def to_bytes(self) -> bytes:
+ return struct.pack(
+ '<BHH', self.codec_id, self.company_id, self.vendor_specific_codec_id
+ )
+
+ def __bytes__(self) -> bytes:
+ return self.to_bytes()
+
+
# -----------------------------------------------------------------------------
class HCI_Constant:
@staticmethod
@@ -1474,6 +1514,12 @@ class HCI_Object:
# The rest of the bytes
field_value = data[offset:]
return (field_value, len(field_value))
+ if field_type == 'v':
+ # Variable-length bytes field, with 1-byte length at the beginning
+ field_length = data[offset]
+ offset += 1
+ field_value = data[offset : offset + field_length]
+ return (field_value, field_length + 1)
if field_type == 1:
# 8-bit unsigned
return (data[offset], 1)
@@ -1578,6 +1624,11 @@ class HCI_Object:
raise ValueError('value too large for *-typed field')
else:
field_bytes = bytes(field_value)
+ elif field_type == 'v':
+ # Variable-length bytes field, with 1-byte length at the beginning
+ field_bytes = bytes(field_value)
+ field_length = len(field_bytes)
+ field_bytes = bytes([field_length]) + field_bytes
elif isinstance(field_value, (bytes, bytearray)) or hasattr(
field_value, 'to_bytes'
):
@@ -1792,6 +1843,43 @@ class Address:
address_type = data[offset - 1]
return Address.parse_address_with_type(data, offset, address_type)
+ @classmethod
+ def generate_static_address(cls) -> Address:
+ '''Generates Random Static Address, with the 2 most significant bits of 0b11.
+
+ See Bluetooth spec, Vol 6, Part B - Table 1.2.
+ '''
+ address_bytes = secrets.token_bytes(6)
+ address_bytes = address_bytes[:5] + bytes([address_bytes[5] | 0b11000000])
+ return Address(
+ address=address_bytes, address_type=Address.RANDOM_DEVICE_ADDRESS
+ )
+
+ @classmethod
+ def generate_private_address(cls, irk: bytes = b'') -> Address:
+ '''Generates Random Private MAC Address.
+
+ If IRK is present, a Resolvable Private Address, with the 2 most significant
+ bits of 0b01 will be generated. Otherwise, a Non-resolvable Private Address,
+ with the 2 most significant bits of 0b00 will be generated.
+
+ See Bluetooth spec, Vol 6, Part B - Table 1.2.
+
+ Args:
+ irk: Local Identity Resolving Key(IRK), in little-endian. If not set, a
+ non-resolvable address will be generated.
+ '''
+ if irk:
+ prand = crypto.generate_prand()
+ address_bytes = crypto.ah(irk, prand) + prand
+ else:
+ address_bytes = secrets.token_bytes(6)
+ address_bytes = address_bytes[:5] + bytes([address_bytes[5] & 0b00111111])
+
+ return Address(
+ address=address_bytes, address_type=Address.RANDOM_DEVICE_ADDRESS
+ )
+
def __init__(
self, address: Union[bytes, str], address_type: int = RANDOM_DEVICE_ADDRESS
):
@@ -1885,26 +1973,28 @@ Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS
Address.ANY = Address(b"\x00\x00\x00\x00\x00\x00", Address.PUBLIC_DEVICE_ADDRESS)
Address.ANY_RANDOM = Address(b"\x00\x00\x00\x00\x00\x00", Address.RANDOM_DEVICE_ADDRESS)
+
# -----------------------------------------------------------------------------
-class OwnAddressType:
+class OwnAddressType(enum.IntEnum):
PUBLIC = 0
RANDOM = 1
RESOLVABLE_OR_PUBLIC = 2
RESOLVABLE_OR_RANDOM = 3
- TYPE_NAMES = {
- PUBLIC: 'PUBLIC',
- RANDOM: 'RANDOM',
- RESOLVABLE_OR_PUBLIC: 'RESOLVABLE_OR_PUBLIC',
- RESOLVABLE_OR_RANDOM: 'RESOLVABLE_OR_RANDOM',
- }
+ @classmethod
+ def type_spec(cls):
+ return {'size': 1, 'mapper': lambda x: OwnAddressType(x).name}
- @staticmethod
- def type_name(type_id):
- return name_or_number(OwnAddressType.TYPE_NAMES, type_id)
- # pylint: disable-next=unnecessary-lambda
- TYPE_SPEC = {'size': 1, 'mapper': lambda x: OwnAddressType.type_name(x)}
+# -----------------------------------------------------------------------------
+class LoopbackMode(enum.IntEnum):
+ DISABLED = 0
+ LOCAL = 1
+ REMOTE = 2
+
+ @classmethod
+ def type_spec(cls):
+ return {'size': 1, 'mapper': lambda x: LoopbackMode(x).name}
# -----------------------------------------------------------------------------
@@ -1925,9 +2015,15 @@ class HCI_Packet:
if packet_type == HCI_ACL_DATA_PACKET:
return HCI_AclDataPacket.from_bytes(packet)
+ if packet_type == HCI_SYNCHRONOUS_DATA_PACKET:
+ return HCI_SynchronousDataPacket.from_bytes(packet)
+
if packet_type == HCI_EVENT_PACKET:
return HCI_Event.from_bytes(packet)
+ if packet_type == HCI_ISO_DATA_PACKET:
+ return HCI_IsoDataPacket.from_bytes(packet)
+
return HCI_CustomPacket(packet)
def __init__(self, name):
@@ -1960,6 +2056,7 @@ class HCI_Command(HCI_Packet):
hci_packet_type = HCI_COMMAND_PACKET
command_names: Dict[int, str] = {}
command_classes: Dict[int, Type[HCI_Command]] = {}
+ op_code: int
@staticmethod
def command(fields=(), return_parameters_fields=()):
@@ -2045,7 +2142,11 @@ class HCI_Command(HCI_Packet):
return_parameters.fields = cls.return_parameters_fields
return return_parameters
- def __init__(self, op_code, parameters=None, **kwargs):
+ def __init__(self, op_code=-1, parameters=None, **kwargs):
+ # Since the legacy implementation relies on an __init__ injector, typing always
+ # complains that positional argument op_code is not passed, so here sets a
+ # default value to allow building derived HCI_Command without op_code.
+ assert op_code != -1
super().__init__(HCI_Command.command_name(op_code))
if (fields := getattr(self, 'fields', None)) and kwargs:
HCI_Object.init_from_fields(self, fields, kwargs)
@@ -2297,6 +2398,19 @@ class HCI_Read_Clock_Offset_Command(HCI_Command):
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address),
+ ('reason', {'size': 1, 'mapper': HCI_Constant.error_name}),
+ ],
+)
+class HCI_Reject_Synchronous_Connection_Request_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.28 Reject Synchronous Connection Request Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('bd_addr', Address.parse_address),
('io_capability', {'size': 1, 'mapper': HCI_Constant.io_capability_name}),
('oob_data_present', 1),
(
@@ -2426,14 +2540,14 @@ class HCI_IO_Capability_Request_Negative_Reply_Command(HCI_Command):
('connection_handle', 2),
('transmit_bandwidth', 4),
('receive_bandwidth', 4),
- ('transmit_coding_format', 5),
- ('receive_coding_format', 5),
+ ('transmit_coding_format', CodingFormat.parse_from_bytes),
+ ('receive_coding_format', CodingFormat.parse_from_bytes),
('transmit_codec_frame_size', 2),
('receive_codec_frame_size', 2),
('input_bandwidth', 4),
('output_bandwidth', 4),
- ('input_coding_format', 5),
- ('output_coding_format', 5),
+ ('input_coding_format', CodingFormat.parse_from_bytes),
+ ('output_coding_format', CodingFormat.parse_from_bytes),
('input_coded_data_size', 2),
('output_coded_data_size', 2),
('input_pcm_data_format', 1),
@@ -2454,6 +2568,35 @@ class HCI_Enhanced_Setup_Synchronous_Connection_Command(HCI_Command):
See Bluetooth spec @ 7.1.45 Enhanced Setup Synchronous Connection Command
'''
+ class PcmDataFormat(enum.IntEnum):
+ NA = 0x00
+ ONES_COMPLEMENT = 0x01
+ TWOS_COMPLEMENT = 0x02
+ SIGN_MAGNITUDE = 0x03
+ UNSIGNED = 0x04
+
+ class DataPath(enum.IntEnum):
+ HCI = 0x00
+ PCM = 0x01
+
+ class RetransmissionEffort(enum.IntEnum):
+ NO_RETRANSMISSION = 0x00
+ OPTIMIZE_FOR_POWER = 0x01
+ OPTIMIZE_FOR_QUALITY = 0x02
+ DONT_CARE = 0xFF
+
+ class PacketType(enum.IntFlag):
+ HV1 = 0x0001
+ HV2 = 0x0002
+ HV3 = 0x0004
+ EV3 = 0x0008
+ EV4 = 0x0010
+ EV5 = 0x0020
+ NO_2_EV3 = 0x0040
+ NO_3_EV3 = 0x0080
+ NO_2_EV5 = 0x0100
+ NO_3_EV5 = 0x0200
+
# -----------------------------------------------------------------------------
@HCI_Command.command(
@@ -2461,14 +2604,14 @@ class HCI_Enhanced_Setup_Synchronous_Connection_Command(HCI_Command):
('bd_addr', Address.parse_address),
('transmit_bandwidth', 4),
('receive_bandwidth', 4),
- ('transmit_coding_format', 5),
- ('receive_coding_format', 5),
+ ('transmit_coding_format', CodingFormat.parse_from_bytes),
+ ('receive_coding_format', CodingFormat.parse_from_bytes),
('transmit_codec_frame_size', 2),
('receive_codec_frame_size', 2),
('input_bandwidth', 4),
('output_bandwidth', 4),
- ('input_coding_format', 5),
- ('output_coding_format', 5),
+ ('input_coding_format', CodingFormat.parse_from_bytes),
+ ('output_coding_format', CodingFormat.parse_from_bytes),
('input_coded_data_size', 2),
('output_coded_data_size', 2),
('input_pcm_data_format', 1),
@@ -2685,6 +2828,20 @@ class HCI_Set_Event_Mask_Command(HCI_Command):
See Bluetooth spec @ 7.3.1 Set Event Mask 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 5.4
+ # of the core specification, the bit number for each event code is equal to one
+ # 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 - 1) for event_code in event_codes).to_bytes(
+ 8, 'little'
+ )
+
# -----------------------------------------------------------------------------
@HCI_Command.command()
@@ -3094,7 +3251,12 @@ class HCI_Read_Local_Supported_Commands_Command(HCI_Command):
# -----------------------------------------------------------------------------
-@HCI_Command.command()
+@HCI_Command.command(
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('lmp_features', 8),
+ ]
+)
class HCI_Read_Local_Supported_Features_Command(HCI_Command):
'''
See Bluetooth spec @ 7.4.3 Read Local Supported Features Command
@@ -3181,12 +3343,47 @@ class HCI_Read_Encryption_Key_Size_Command(HCI_Command):
# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('loopback_mode', LoopbackMode.type_spec()),
+ ],
+)
+class HCI_Read_Loopback_Mode_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.6.1 Read Loopback Mode Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([('loopback_mode', 1)])
+class HCI_Write_Loopback_Mode_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.6.2 Write Loopback Mode Command
+ '''
+
+
+# -----------------------------------------------------------------------------
@HCI_Command.command([('le_event_mask', 8)])
class HCI_LE_Set_Event_Mask_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.1 LE Set Event Mask 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 5.4
+ # of the core specification, the bit number for each event code is equal to one
+ # 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 - 1) for event_code in event_codes).to_bytes(
+ 8, 'little'
+ )
+
# -----------------------------------------------------------------------------
@HCI_Command.command(
@@ -3244,7 +3441,7 @@ class HCI_LE_Set_Random_Address_Command(HCI_Command):
),
},
),
- ('own_address_type', OwnAddressType.TYPE_SPEC),
+ ('own_address_type', OwnAddressType.type_spec()),
('peer_address_type', Address.ADDRESS_TYPE_SPEC),
('peer_address', Address.parse_address_preceded_by_type),
('advertising_channel_map', 1),
@@ -3337,7 +3534,7 @@ class HCI_LE_Set_Advertising_Enable_Command(HCI_Command):
('le_scan_type', 1),
('le_scan_interval', 2),
('le_scan_window', 2),
- ('own_address_type', OwnAddressType.TYPE_SPEC),
+ ('own_address_type', OwnAddressType.type_spec()),
('scanning_filter_policy', 1),
]
)
@@ -3376,7 +3573,7 @@ class HCI_LE_Set_Scan_Enable_Command(HCI_Command):
('initiator_filter_policy', 1),
('peer_address_type', Address.ADDRESS_TYPE_SPEC),
('peer_address', Address.parse_address_preceded_by_type),
- ('own_address_type', OwnAddressType.TYPE_SPEC),
+ ('own_address_type', OwnAddressType.type_spec()),
('connection_interval_min', 2),
('connection_interval_max', 2),
('max_latency', 2),
@@ -3765,8 +3962,10 @@ class HCI_LE_Set_Advertising_Set_Random_Address_Command(HCI_Command):
'advertising_event_properties',
{
'size': 2,
- 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.advertising_properties_string(
- x
+ 'mapper': lambda x: str(
+ HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties(
+ x
+ )
),
},
),
@@ -3776,12 +3975,12 @@ class HCI_LE_Set_Advertising_Set_Random_Address_Command(HCI_Command):
'primary_advertising_channel_map',
{
'size': 1,
- 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.channel_map_string(
- x
+ 'mapper': lambda x: str(
+ HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap(x)
),
},
),
- ('own_address_type', OwnAddressType.TYPE_SPEC),
+ ('own_address_type', OwnAddressType.type_spec()),
('peer_address_type', Address.ADDRESS_TYPE_SPEC),
('peer_address', Address.parse_address_preceded_by_type),
('advertising_filter_policy', 1),
@@ -3792,45 +3991,43 @@ class HCI_LE_Set_Advertising_Set_Random_Address_Command(HCI_Command):
('advertising_sid', 1),
('scan_request_notification_enable', 1),
],
- return_parameters_fields=[('status', STATUS_SPEC), ('selected_tx__power', 1)],
+ return_parameters_fields=[('status', STATUS_SPEC), ('selected_tx_power', 1)],
)
class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.53 LE Set Extended Advertising Parameters Command
'''
- CONNECTABLE_ADVERTISING = 0
- SCANNABLE_ADVERTISING = 1
- DIRECTED_ADVERTISING = 2
- HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING = 3
- USE_LEGACY_ADVERTISING_PDUS = 4
- ANONYMOUS_ADVERTISING = 5
- INCLUDE_TX_POWER = 6
-
- ADVERTISING_PROPERTIES_NAMES = (
- 'CONNECTABLE_ADVERTISING',
- 'SCANNABLE_ADVERTISING',
- 'DIRECTED_ADVERTISING',
- 'HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING',
- 'USE_LEGACY_ADVERTISING_PDUS',
- 'ANONYMOUS_ADVERTISING',
- 'INCLUDE_TX_POWER',
- )
+ TX_POWER_NO_PREFERENCE = 0x7F
+ SHOULD_NOT_FRAGMENT = 0x01
- CHANNEL_37 = 0
- CHANNEL_38 = 1
- CHANNEL_39 = 2
+ class AdvertisingProperties(enum.IntFlag):
+ CONNECTABLE_ADVERTISING = 1 << 0
+ SCANNABLE_ADVERTISING = 1 << 1
+ DIRECTED_ADVERTISING = 1 << 2
+ HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING = 1 << 3
+ USE_LEGACY_ADVERTISING_PDUS = 1 << 4
+ ANONYMOUS_ADVERTISING = 1 << 5
+ INCLUDE_TX_POWER = 1 << 6
- CHANNEL_NAMES = ('37', '38', '39')
+ def __str__(self) -> str:
+ return '|'.join(
+ flag.name
+ for flag in HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties
+ if self.value & flag.value and flag.name is not None
+ )
- @classmethod
- def advertising_properties_string(cls, properties):
- # pylint: disable=line-too-long
- return f'[{",".join(bit_flags_to_strings(properties, cls.ADVERTISING_PROPERTIES_NAMES))}]'
+ class ChannelMap(enum.IntFlag):
+ CHANNEL_37 = 1 << 0
+ CHANNEL_38 = 1 << 1
+ CHANNEL_39 = 1 << 2
- @classmethod
- def channel_map_string(cls, channel_map):
- return f'[{",".join(bit_flags_to_strings(channel_map, cls.CHANNEL_NAMES))}]'
+ def __str__(self) -> str:
+ return '|'.join(
+ flag.name
+ for flag in HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
+ if self.value & flag.value and flag.name is not None
+ )
# -----------------------------------------------------------------------------
@@ -3842,9 +4039,9 @@ class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
'operation',
{
'size': 1,
- 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name(
+ 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.Operation(
x
- ),
+ ).name,
},
),
('fragment_preference', 1),
@@ -3862,23 +4059,12 @@ class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command):
See Bluetooth spec @ 7.8.54 LE Set Extended Advertising Data Command
'''
- INTERMEDIATE_FRAGMENT = 0x00
- FIRST_FRAGMENT = 0x01
- LAST_FRAGMENT = 0x02
- COMPLETE_DATA = 0x03
- UNCHANGED_DATA = 0x04
-
- OPERATION_NAMES = {
- INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
- FIRST_FRAGMENT: 'FIRST_FRAGMENT',
- LAST_FRAGMENT: 'LAST_FRAGMENT',
- COMPLETE_DATA: 'COMPLETE_DATA',
- UNCHANGED_DATA: 'UNCHANGED_DATA',
- }
-
- @classmethod
- def operation_name(cls, operation):
- return name_or_number(cls.OPERATION_NAMES, operation)
+ class Operation(enum.IntEnum):
+ INTERMEDIATE_FRAGMENT = 0x00
+ FIRST_FRAGMENT = 0x01
+ LAST_FRAGMENT = 0x02
+ COMPLETE_DATA = 0x03
+ UNCHANGED_DATA = 0x04
# -----------------------------------------------------------------------------
@@ -3890,9 +4076,9 @@ class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command):
'operation',
{
'size': 1,
- 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name(
+ 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.Operation(
x
- ),
+ ).name,
},
),
('fragment_preference', 1),
@@ -3910,22 +4096,6 @@ class HCI_LE_Set_Extended_Scan_Response_Data_Command(HCI_Command):
See Bluetooth spec @ 7.8.55 LE Set Extended Scan Response Data Command
'''
- INTERMEDIATE_FRAGMENT = 0x00
- FIRST_FRAGMENT = 0x01
- LAST_FRAGMENT = 0x02
- COMPLETE_DATA = 0x03
-
- OPERATION_NAMES = {
- INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
- FIRST_FRAGMENT: 'FIRST_FRAGMENT',
- LAST_FRAGMENT: 'LAST_FRAGMENT',
- COMPLETE_DATA: 'COMPLETE_DATA',
- }
-
- @classmethod
- def operation_name(cls, operation):
- return name_or_number(cls.OPERATION_NAMES, operation)
-
# -----------------------------------------------------------------------------
@HCI_Command.command(
@@ -4075,7 +4245,7 @@ class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command):
('scanning_filter_policy:', self.scanning_filter_policy),
('scanning_phys: ', ','.join(scanning_phys_strs)),
]
- for (i, scanning_phy_str) in enumerate(scanning_phys_strs):
+ for i, scanning_phy_str in enumerate(scanning_phys_strs):
fields.append(
(
f'{scanning_phy_str}.scan_type: ',
@@ -4209,7 +4379,7 @@ class HCI_LE_Extended_Create_Connection_Command(HCI_Command):
('initiator_filter_policy:', self.initiator_filter_policy),
(
'own_address_type: ',
- OwnAddressType.type_name(self.own_address_type),
+ OwnAddressType(self.own_address_type).name,
),
(
'peer_address_type: ',
@@ -4218,7 +4388,7 @@ class HCI_LE_Extended_Create_Connection_Command(HCI_Command):
('peer_address: ', str(self.peer_address)),
('initiating_phys: ', ','.join(initiating_phys_strs)),
]
- for (i, initiating_phys_str) in enumerate(initiating_phys_strs):
+ for i, initiating_phys_str in enumerate(initiating_phys_strs):
fields.append(
(
f'{initiating_phys_str}.scan_interval: ',
@@ -4324,6 +4494,166 @@ class HCI_LE_Set_Host_Feature_Command(HCI_Command):
# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('cig_id', 1),
+ ('sdu_interval_c_to_p', 3),
+ ('sdu_interval_p_to_c', 3),
+ ('worst_case_sca', 1),
+ ('packing', 1),
+ ('framing', 1),
+ ('max_transport_latency_c_to_p', 2),
+ ('max_transport_latency_p_to_c', 2),
+ [
+ ('cis_id', 1),
+ ('max_sdu_c_to_p', 2),
+ ('max_sdu_p_to_c', 2),
+ ('phy_c_to_p', 1),
+ ('phy_p_to_c', 1),
+ ('rtn_c_to_p', 1),
+ ('rtn_p_to_c', 1),
+ ],
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('cig_id', 1),
+ [('connection_handle', 2)],
+ ],
+)
+class HCI_LE_Set_CIG_Parameters_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.97 LE Set CIG Parameters Command
+ '''
+
+ cig_id: int
+ sdu_interval_c_to_p: int
+ sdu_interval_p_to_c: int
+ worst_case_sca: int
+ packing: int
+ framing: int
+ max_transport_latency_c_to_p: int
+ max_transport_latency_p_to_c: int
+ cis_id: List[int]
+ max_sdu_c_to_p: List[int]
+ max_sdu_p_to_c: List[int]
+ phy_c_to_p: List[int]
+ phy_p_to_c: List[int]
+ rtn_c_to_p: List[int]
+ rtn_p_to_c: List[int]
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ [
+ ('cis_connection_handle', 2),
+ ('acl_connection_handle', 2),
+ ],
+ ],
+)
+class HCI_LE_Create_CIS_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.99 LE Create CIS command
+ '''
+
+ cis_connection_handle: List[int]
+ acl_connection_handle: List[int]
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[('cig_id', 1)],
+ return_parameters_fields=[('status', STATUS_SPEC), ('cig_id', 1)],
+)
+class HCI_LE_Remove_CIG_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.100 LE Remove CIG command
+ '''
+
+ cig_id: int
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[('connection_handle', 2)],
+)
+class HCI_LE_Accept_CIS_Request_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.101 LE Accept CIS Request command
+ '''
+
+ connection_handle: int
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('connection_handle', 2),
+ ('reason', {'size': 1, 'mapper': HCI_Constant.error_name}),
+ ],
+)
+class HCI_LE_Reject_CIS_Request_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.102 LE Reject CIS Request command
+ '''
+
+ connection_handle: int
+ reason: int
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('connection_handle', 2),
+ ('data_path_direction', 1),
+ ('data_path_id', 1),
+ ('codec_id', CodingFormat.parse_from_bytes),
+ ('controller_delay', 3),
+ ('codec_configuration', 'v'),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('connection_handle', 2),
+ ],
+)
+class HCI_LE_Setup_ISO_Data_Path_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.109 LE Setup ISO Data Path command
+ '''
+
+ class Direction(enum.IntEnum):
+ HOST_TO_CONTROLLER = 0x00
+ CONTROLLER_TO_HOST = 0x01
+
+ connection_handle: int
+ data_path_direction: int
+ data_path_id: int
+ codec_id: CodingFormat
+ controller_delay: int
+ codec_configuration: bytes
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('connection_handle', 2),
+ ('data_path_direction', 1),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('connection_handle', 2),
+ ],
+)
+class HCI_LE_Remove_ISO_Data_Path_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.110 LE Remove ISO Data Path command
+ '''
+
+ connection_handle: int
+ data_path_direction: int
+
+
+# -----------------------------------------------------------------------------
# HCI Events
# -----------------------------------------------------------------------------
class HCI_Event(HCI_Packet):
@@ -4431,7 +4761,11 @@ class HCI_Event(HCI_Packet):
HCI_Object.init_from_bytes(self, parameters, 0, fields)
return self
- def __init__(self, event_code, parameters=None, **kwargs):
+ def __init__(self, event_code=-1, parameters=None, **kwargs):
+ # Since the legacy implementation relies on an __init__ injector, typing always
+ # complains that positional argument event_code is not passed, so here sets a
+ # default value to allow building derived HCI_Event without event_code.
+ assert event_code != -1
super().__init__(HCI_Event.event_name(event_code))
if (fields := getattr(self, 'fields', None)) and kwargs:
HCI_Object.init_from_fields(self, fields, kwargs)
@@ -4525,7 +4859,8 @@ class HCI_Extended_Event(HCI_Event):
HCI_Object.init_from_bytes(self, parameters, 1, fields)
return self
- def __init__(self, subevent_code, parameters, **kwargs):
+ def __init__(self, subevent_code=None, parameters=None, **kwargs):
+ assert subevent_code is not None
self.subevent_code = subevent_code
if parameters is None and (fields := getattr(self, 'fields', None)) and kwargs:
parameters = bytes([subevent_code]) + HCI_Object.dict_to_bytes(
@@ -4935,6 +5270,21 @@ HCI_LE_Meta_Event.subevent_classes[
# -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+ [
+ ('status', 1),
+ ('advertising_handle', 1),
+ ('connection_handle', 2),
+ ('num_completed_extended_advertising_events', 1),
+ ]
+)
+class HCI_LE_Advertising_Set_Terminated_Event(HCI_LE_Meta_Event):
+ '''
+ See Bluetooth spec @ 7.7.65.18 LE Advertising Set Terminated Event
+ '''
+
+
+# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event([('connection_handle', 2), ('channel_selection_algorithm', 1)])
class HCI_LE_Channel_Selection_Algorithm_Event(HCI_LE_Meta_Event):
'''
@@ -4943,6 +5293,48 @@ class HCI_LE_Channel_Selection_Algorithm_Event(HCI_LE_Meta_Event):
# -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+ [
+ ('status', STATUS_SPEC),
+ ('connection_handle', 2),
+ ('cig_sync_delay', 3),
+ ('cis_sync_delay', 3),
+ ('transport_latency_c_to_p', 3),
+ ('transport_latency_p_to_c', 3),
+ ('phy_c_to_p', 1),
+ ('phy_p_to_c', 1),
+ ('nse', 1),
+ ('bn_c_to_p', 1),
+ ('bn_p_to_c', 1),
+ ('ft_c_to_p', 1),
+ ('ft_p_to_c', 1),
+ ('max_pdu_c_to_p', 2),
+ ('max_pdu_p_to_c', 2),
+ ('iso_interval', 2),
+ ]
+)
+class HCI_LE_CIS_Established_Event(HCI_LE_Meta_Event):
+ '''
+ See Bluetooth spec @ 7.7.65.25 LE CIS Established Event
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_LE_Meta_Event.event(
+ [
+ ('acl_connection_handle', 2),
+ ('cis_connection_handle', 2),
+ ('cig_id', 1),
+ ('cis_id', 1),
+ ]
+)
+class HCI_LE_CIS_Request_Event(HCI_LE_Meta_Event):
+ '''
+ See Bluetooth spec @ 7.7.65.26 LE CIS Request Event
+ '''
+
+
+# -----------------------------------------------------------------------------
@HCI_Event.event([('status', STATUS_SPEC)])
class HCI_Inquiry_Complete_Event(HCI_Event):
'''
@@ -5068,6 +5460,10 @@ class HCI_Disconnection_Complete_Event(HCI_Event):
See Bluetooth spec @ 7.7.5 Disconnection Complete Event
'''
+ status: int
+ connection_handle: int
+ reason: int
+
# -----------------------------------------------------------------------------
@HCI_Event.event([('status', STATUS_SPEC), ('connection_handle', 2)])
@@ -5739,6 +6135,168 @@ class HCI_AclDataPacket(HCI_Packet):
# -----------------------------------------------------------------------------
+class HCI_SynchronousDataPacket(HCI_Packet):
+ '''
+ See Bluetooth spec @ 5.4.3 HCI SCO Data Packets
+ '''
+
+ hci_packet_type = HCI_SYNCHRONOUS_DATA_PACKET
+
+ @staticmethod
+ def from_bytes(packet: bytes) -> HCI_SynchronousDataPacket:
+ # Read the header
+ h, data_total_length = struct.unpack_from('<HB', packet, 1)
+ connection_handle = h & 0xFFF
+ packet_status = (h >> 12) & 0b11
+ data = packet[4:]
+ if len(data) != data_total_length:
+ raise ValueError(
+ f'invalid packet length {len(data)} != {data_total_length}'
+ )
+ return HCI_SynchronousDataPacket(
+ connection_handle, packet_status, data_total_length, data
+ )
+
+ def to_bytes(self) -> bytes:
+ h = (self.packet_status << 12) | self.connection_handle
+ return (
+ struct.pack('<BHB', HCI_SYNCHRONOUS_DATA_PACKET, h, self.data_total_length)
+ + self.data
+ )
+
+ def __init__(
+ self,
+ connection_handle: int,
+ packet_status: int,
+ data_total_length: int,
+ data: bytes,
+ ) -> None:
+ self.connection_handle = connection_handle
+ self.packet_status = packet_status
+ self.data_total_length = data_total_length
+ self.data = data
+
+ def __bytes__(self) -> bytes:
+ return self.to_bytes()
+
+ def __str__(self) -> str:
+ return (
+ f'{color("SCO", "blue")}: '
+ f'handle=0x{self.connection_handle:04x}, '
+ f'ps={self.packet_status}, '
+ f'data_total_length={self.data_total_length}, '
+ f'data={self.data.hex()}'
+ )
+
+
+# -----------------------------------------------------------------------------
+class HCI_IsoDataPacket(HCI_Packet):
+ '''
+ See Bluetooth spec @ 5.4.5 HCI ISO Data Packets
+ '''
+
+ hci_packet_type = HCI_ISO_DATA_PACKET
+
+ @staticmethod
+ def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
+ time_stamp: Optional[int] = None
+ packet_sequence_number: Optional[int] = None
+ iso_sdu_length: Optional[int] = None
+ packet_status_flag: Optional[int] = None
+
+ pos = 1
+ pdu_info, data_total_length = struct.unpack_from('<HH', packet, pos)
+ connection_handle = pdu_info & 0xFFF
+ pb_flag = (pdu_info >> 12) & 0b11
+ ts_flag = (pdu_info >> 14) & 0b01
+ pos += 4
+
+ # pb_flag in (0b00, 0b10) but faster
+ should_include_sdu_info = not (pb_flag & 0b01)
+
+ if ts_flag:
+ if not should_include_sdu_info:
+ logger.warning(f'Timestamp included when pb_flag={bin(pb_flag)}')
+ time_stamp, *_ = struct.unpack_from('<I', packet, pos)
+ pos += 4
+
+ if should_include_sdu_info:
+ packet_sequence_number, sdu_info = struct.unpack_from('<HH', packet, pos)
+ iso_sdu_length = sdu_info & 0xFFF
+ packet_status_flag = sdu_info >> 14
+ pos += 4
+
+ iso_sdu_fragment = packet[pos:]
+ return HCI_IsoDataPacket(
+ connection_handle=connection_handle,
+ pb_flag=pb_flag,
+ ts_flag=ts_flag,
+ data_total_length=data_total_length,
+ time_stamp=time_stamp,
+ packet_sequence_number=packet_sequence_number,
+ iso_sdu_length=iso_sdu_length,
+ packet_status_flag=packet_status_flag,
+ iso_sdu_fragment=iso_sdu_fragment,
+ )
+
+ def __init__(
+ self,
+ connection_handle: int,
+ pb_flag: int,
+ ts_flag: int,
+ data_total_length: int,
+ time_stamp: Optional[int],
+ packet_sequence_number: Optional[int],
+ iso_sdu_length: Optional[int],
+ packet_status_flag: Optional[int],
+ iso_sdu_fragment: bytes,
+ ) -> None:
+ self.connection_handle = connection_handle
+ self.pb_flag = pb_flag
+ self.ts_flag = ts_flag
+ self.data_total_length = data_total_length
+ self.time_stamp = time_stamp
+ self.packet_sequence_number = packet_sequence_number
+ self.iso_sdu_length = iso_sdu_length
+ self.packet_status_flag = packet_status_flag
+ self.iso_sdu_fragment = iso_sdu_fragment
+
+ def __bytes__(self) -> bytes:
+ return self.to_bytes()
+
+ def to_bytes(self) -> bytes:
+ fmt = '<BHH'
+ args = [
+ HCI_ISO_DATA_PACKET,
+ self.ts_flag << 14 | self.pb_flag << 12 | self.connection_handle,
+ self.data_total_length,
+ ]
+ if self.time_stamp is not None:
+ fmt += 'I'
+ args.append(self.time_stamp)
+ if (
+ self.packet_sequence_number is not None
+ and self.iso_sdu_length is not None
+ and self.packet_status_flag is not None
+ ):
+ fmt += 'HH'
+ args += [
+ self.packet_sequence_number,
+ self.iso_sdu_length | self.packet_status_flag << 14,
+ ]
+ return struct.pack(fmt, *args) + self.iso_sdu_fragment
+
+ def __str__(self) -> str:
+ return (
+ f'{color("ISO", "blue")}: '
+ f'handle=0x{self.connection_handle:04x}, '
+ f'ps={self.packet_status_flag}, '
+ f'data_total_length={self.data_total_length}, '
+ f'sdu={self.iso_sdu_fragment.hex()}'
+ )
+
+
+# -----------------------------------------------------------------------------
class HCI_AclDataPacketAssembler:
current_data: Optional[bytes]
@@ -5771,7 +6329,7 @@ class HCI_AclDataPacketAssembler:
self.current_data = None
self.l2cap_pdu_length = 0
else:
- # Sanity check
+ # Compliance check
if len(self.current_data) > self.l2cap_pdu_length + 4:
logger.warning('!!! ACL data exceeds L2CAP PDU')
self.current_data = None
diff --git a/bumble/helpers.py b/bumble/helpers.py
index 83c7c6d..80a376e 100644
--- a/bumble/helpers.py
+++ b/bumble/helpers.py
@@ -15,30 +15,46 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
+
+from collections.abc import Callable, MutableMapping
+import datetime
+from typing import cast, Any, Optional
import logging
-from .colors import color
-from .att import ATT_CID, ATT_PDU
-from .smp import SMP_CID, SMP_Command
-from .core import name_or_number
-from .l2cap import (
+from bumble import avc
+from bumble import avctp
+from bumble import avdtp
+from bumble import avrcp
+from bumble import crypto
+from bumble import rfcomm
+from bumble import sdp
+from bumble.colors import color
+from bumble.att import ATT_CID, ATT_PDU
+from bumble.smp import SMP_CID, SMP_Command
+from bumble.core import name_or_number
+from bumble.l2cap import (
L2CAP_PDU,
L2CAP_CONNECTION_REQUEST,
L2CAP_CONNECTION_RESPONSE,
L2CAP_SIGNALING_CID,
L2CAP_LE_SIGNALING_CID,
L2CAP_Control_Frame,
+ L2CAP_Connection_Request,
L2CAP_Connection_Response,
)
-from .hci import (
+from bumble.hci import (
+ Address,
HCI_EVENT_PACKET,
HCI_ACL_DATA_PACKET,
HCI_DISCONNECTION_COMPLETE_EVENT,
HCI_AclDataPacketAssembler,
+ HCI_Packet,
+ HCI_Event,
+ HCI_AclDataPacket,
+ HCI_Disconnection_Complete_Event,
)
-from .rfcomm import RFCOMM_Frame, RFCOMM_PSM
-from .sdp import SDP_PDU, SDP_PSM
-from .avdtp import MessageAssembler as AVDTP_MessageAssembler, AVDTP_PSM
+
# -----------------------------------------------------------------------------
# Logging
@@ -48,26 +64,36 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
PSM_NAMES = {
- RFCOMM_PSM: 'RFCOMM',
- SDP_PSM: 'SDP',
- AVDTP_PSM: 'AVDTP'
+ rfcomm.RFCOMM_PSM: 'RFCOMM',
+ sdp.SDP_PSM: 'SDP',
+ avdtp.AVDTP_PSM: 'AVDTP',
+ avctp.AVCTP_PSM: 'AVCTP',
# TODO: add more PSM values
}
+AVCTP_PID_NAMES = {avrcp.AVRCP_PID: 'AVRCP'}
+
# -----------------------------------------------------------------------------
class PacketTracer:
class AclStream:
- def __init__(self, analyzer):
+ psms: MutableMapping[int, int]
+ peer: Optional[PacketTracer.AclStream]
+ avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
+ avctp_assemblers: MutableMapping[int, avctp.MessageAssembler]
+
+ def __init__(self, analyzer: PacketTracer.Analyzer) -> None:
self.analyzer = analyzer
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
+ self.avctp_assemblers = {} # AVCTP assemblers, by source_cid
self.psms = {} # PSM, by source_cid
- self.peer = None # ACL stream in the other direction
+ self.peer = None
# pylint: disable=too-many-nested-blocks
- def on_acl_pdu(self, pdu):
+ def on_acl_pdu(self, pdu: bytes) -> None:
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
+ self.analyzer.emit(l2cap_pdu)
if l2cap_pdu.cid == ATT_CID:
att_pdu = ATT_PDU.from_bytes(l2cap_pdu.payload)
@@ -81,46 +107,59 @@ class PacketTracer:
# Check if this signals a new channel
if control_frame.code == L2CAP_CONNECTION_REQUEST:
- self.psms[control_frame.source_cid] = control_frame.psm
+ connection_request = cast(L2CAP_Connection_Request, control_frame)
+ self.psms[connection_request.source_cid] = connection_request.psm
elif control_frame.code == L2CAP_CONNECTION_RESPONSE:
+ connection_response = cast(L2CAP_Connection_Response, control_frame)
if (
- control_frame.result
+ connection_response.result
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
):
- if self.peer:
- if psm := self.peer.psms.get(control_frame.source_cid):
- # Found a pending connection
- self.psms[control_frame.destination_cid] = psm
-
- # For AVDTP connections, create a packet assembler for
- # each direction
- if psm == AVDTP_PSM:
- self.avdtp_assemblers[
- control_frame.source_cid
- ] = AVDTP_MessageAssembler(self.on_avdtp_message)
- self.peer.avdtp_assemblers[
- control_frame.destination_cid
- ] = AVDTP_MessageAssembler(
- self.peer.on_avdtp_message
- )
+ if self.peer and (
+ psm := self.peer.psms.get(connection_response.source_cid)
+ ):
+ # Found a pending connection
+ self.psms[connection_response.destination_cid] = psm
+ # For AVDTP connections, create a packet assembler for
+ # each direction
+ if psm == avdtp.AVDTP_PSM:
+ self.avdtp_assemblers[
+ connection_response.source_cid
+ ] = avdtp.MessageAssembler(self.on_avdtp_message)
+ self.peer.avdtp_assemblers[
+ connection_response.destination_cid
+ ] = avdtp.MessageAssembler(self.peer.on_avdtp_message)
+ elif psm == avctp.AVCTP_PSM:
+ self.avctp_assemblers[
+ connection_response.source_cid
+ ] = avctp.MessageAssembler(self.on_avctp_message)
+ self.peer.avctp_assemblers[
+ connection_response.destination_cid
+ ] = avctp.MessageAssembler(self.peer.on_avctp_message)
else:
# Try to find the PSM associated with this PDU
if self.peer and (psm := self.peer.psms.get(l2cap_pdu.cid)):
- if psm == SDP_PSM:
- sdp_pdu = SDP_PDU.from_bytes(l2cap_pdu.payload)
+ if psm == sdp.SDP_PSM:
+ sdp_pdu = sdp.SDP_PDU.from_bytes(l2cap_pdu.payload)
self.analyzer.emit(sdp_pdu)
- elif psm == RFCOMM_PSM:
- rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
+ elif psm == rfcomm.RFCOMM_PSM:
+ rfcomm_frame = rfcomm.RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
self.analyzer.emit(rfcomm_frame)
- elif psm == AVDTP_PSM:
+ elif psm == avdtp.AVDTP_PSM:
self.analyzer.emit(
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
)
- assembler = self.avdtp_assemblers.get(l2cap_pdu.cid)
- if assembler:
- assembler.on_pdu(l2cap_pdu.payload)
+ if avdtp_assembler := self.avdtp_assemblers.get(l2cap_pdu.cid):
+ avdtp_assembler.on_pdu(l2cap_pdu.payload)
+ elif psm == avctp.AVCTP_PSM:
+ self.analyzer.emit(
+ f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
+ f'PSM=AVCTP]: {l2cap_pdu.payload.hex()}'
+ )
+ if avctp_assembler := self.avctp_assemblers.get(l2cap_pdu.cid):
+ avctp_assembler.on_pdu(l2cap_pdu.payload)
else:
psm_string = name_or_number(PSM_NAMES, psm)
self.analyzer.emit(
@@ -130,22 +169,49 @@ class PacketTracer:
else:
self.analyzer.emit(l2cap_pdu)
- def on_avdtp_message(self, transaction_label, message):
+ def on_avdtp_message(
+ self, transaction_label: int, message: avdtp.Message
+ ) -> None:
self.analyzer.emit(
f'{color("AVDTP", "green")} [{transaction_label}] {message}'
)
- def feed_packet(self, packet):
+ def on_avctp_message(
+ self,
+ transaction_label: int,
+ is_command: bool,
+ ipid: bool,
+ pid: int,
+ payload: bytes,
+ ):
+ if pid == avrcp.AVRCP_PID:
+ avc_frame = avc.Frame.from_bytes(payload)
+ details = str(avc_frame)
+ else:
+ details = payload.hex()
+
+ c_r = 'Command' if is_command else 'Response'
+ self.analyzer.emit(
+ f'{color("AVCTP", "green")} '
+ f'{c_r}[{transaction_label}][{name_or_number(AVCTP_PID_NAMES, pid)}] '
+ f'{"#" if ipid else ""}'
+ f'{details}'
+ )
+
+ def feed_packet(self, packet: HCI_AclDataPacket) -> None:
self.packet_assembler.feed_packet(packet)
class Analyzer:
- def __init__(self, label, emit_message):
+ acl_streams: MutableMapping[int, PacketTracer.AclStream]
+ peer: PacketTracer.Analyzer
+
+ def __init__(self, label: str, emit_message: Callable[..., None]) -> None:
self.label = label
self.emit_message = emit_message
self.acl_streams = {} # ACL streams, by connection handle
- self.peer = None # Analyzer in the other direction
+ self.packet_timestamp: Optional[datetime.datetime] = None
- def start_acl_stream(self, connection_handle):
+ def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
logger.info(
f'[{self.label}] +++ Creating ACL stream for connection '
f'0x{connection_handle:04X}'
@@ -160,7 +226,7 @@ class PacketTracer:
return stream
- def end_acl_stream(self, connection_handle):
+ def end_acl_stream(self, connection_handle: int) -> None:
if connection_handle in self.acl_streams:
logger.info(
f'[{self.label}] --- Removing ACL stream for connection '
@@ -171,34 +237,52 @@ class PacketTracer:
# Let the other forwarder know so it can cleanup its stream as well
self.peer.end_acl_stream(connection_handle)
- def on_packet(self, packet):
+ def on_packet(
+ self, timestamp: Optional[datetime.datetime], packet: HCI_Packet
+ ) -> None:
+ self.packet_timestamp = timestamp
self.emit(packet)
if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
+ acl_packet = cast(HCI_AclDataPacket, packet)
# Look for an existing stream for this handle, create one if it is the
# first ACL packet for that connection handle
- if (stream := self.acl_streams.get(packet.connection_handle)) is None:
- stream = self.start_acl_stream(packet.connection_handle)
- stream.feed_packet(packet)
+ if (
+ stream := self.acl_streams.get(acl_packet.connection_handle)
+ ) is None:
+ stream = self.start_acl_stream(acl_packet.connection_handle)
+ stream.feed_packet(acl_packet)
elif packet.hci_packet_type == HCI_EVENT_PACKET:
- if packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT:
- self.end_acl_stream(packet.connection_handle)
+ event_packet = cast(HCI_Event, packet)
+ if event_packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT:
+ self.end_acl_stream(
+ cast(HCI_Disconnection_Complete_Event, packet).connection_handle
+ )
- def emit(self, message):
- self.emit_message(f'[{self.label}] {message}')
+ def emit(self, message: Any) -> None:
+ if self.packet_timestamp:
+ prefix = f"[{self.packet_timestamp.strftime('%Y-%m-%d %H:%M:%S.%f')}]"
+ else:
+ prefix = ""
+ self.emit_message(f'{prefix}[{self.label}] {message}')
- def trace(self, packet, direction=0):
+ def trace(
+ self,
+ packet: HCI_Packet,
+ direction: int = 0,
+ timestamp: Optional[datetime.datetime] = None,
+ ) -> None:
if direction == 0:
- self.host_to_controller_analyzer.on_packet(packet)
+ self.host_to_controller_analyzer.on_packet(timestamp, packet)
else:
- self.controller_to_host_analyzer.on_packet(packet)
+ self.controller_to_host_analyzer.on_packet(timestamp, packet)
def __init__(
self,
- host_to_controller_label=color('HOST->CONTROLLER', 'blue'),
- controller_to_host_label=color('CONTROLLER->HOST', 'cyan'),
- emit_message=logger.info,
- ):
+ host_to_controller_label: str = color('HOST->CONTROLLER', 'blue'),
+ controller_to_host_label: str = color('CONTROLLER->HOST', 'cyan'),
+ emit_message: Callable[..., None] = logger.info,
+ ) -> None:
self.host_to_controller_analyzer = PacketTracer.Analyzer(
host_to_controller_label, emit_message
)
@@ -207,3 +291,15 @@ class PacketTracer:
)
self.host_to_controller_analyzer.peer = self.controller_to_host_analyzer
self.controller_to_host_analyzer.peer = self.host_to_controller_analyzer
+
+
+def generate_irk() -> bytes:
+ return crypto.r()
+
+
+def verify_rpa_with_irk(rpa: Address, irk: bytes) -> bool:
+ rpa_bytes = bytes(rpa)
+ prand_given = rpa_bytes[3:]
+ hash_given = rpa_bytes[:3]
+ hash_local = crypto.ah(irk, prand_given)
+ return hash_local[:3] == hash_given
diff --git a/bumble/hfp.py b/bumble/hfp.py
index bb00920..27bb097 100644
--- a/bumble/hfp.py
+++ b/bumble/hfp.py
@@ -21,12 +21,11 @@ import asyncio
import dataclasses
import enum
import traceback
-import warnings
-from typing import Dict, List, Union, Set, TYPE_CHECKING
-
-from . import at
-from . import rfcomm
+import pyee
+from typing import Dict, List, Union, Set, Any, Optional, TYPE_CHECKING
+from bumble import at
+from bumble import rfcomm
from bumble.colors import color
from bumble.core import (
ProtocolError,
@@ -35,6 +34,11 @@ from bumble.core import (
BT_L2CAP_PROTOCOL_ID,
BT_RFCOMM_PROTOCOL_ID,
)
+from bumble.hci import (
+ HCI_Enhanced_Setup_Synchronous_Connection_Command,
+ CodingFormat,
+ CodecID,
+)
from bumble.sdp import (
DataElement,
ServiceAttribute,
@@ -65,6 +69,7 @@ class HfpProtocolError(ProtocolError):
# Protocol Support
# -----------------------------------------------------------------------------
+
# -----------------------------------------------------------------------------
class HfpProtocol:
dlc: rfcomm.DLC
@@ -73,7 +78,6 @@ class HfpProtocol:
lines_available: asyncio.Event
def __init__(self, dlc: rfcomm.DLC) -> None:
- warnings.warn("See HfProtocol", DeprecationWarning)
self.dlc = dlc
self.buffer = ''
self.lines = collections.deque()
@@ -122,10 +126,13 @@ class HfpProtocol:
# -----------------------------------------------------------------------------
-# HF supported features (AT+BRSF=) (normative).
-# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
-# and 3GPP 27.007
class HfFeature(enum.IntFlag):
+ """
+ HF supported features (AT+BRSF=) (normative).
+
+ Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+ """
+
EC_NR = 0x001 # Echo Cancel & Noise reduction
THREE_WAY_CALLING = 0x002
CLI_PRESENTATION_CAPABILITY = 0x004
@@ -140,10 +147,13 @@ class HfFeature(enum.IntFlag):
VOICE_RECOGNITION_TEST = 0x800
-# AG supported features (+BRSF:) (normative).
-# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
-# and 3GPP 27.007
class AgFeature(enum.IntFlag):
+ """
+ AG supported features (+BRSF:) (normative).
+
+ Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+ """
+
THREE_WAY_CALLING = 0x001
EC_NR = 0x002 # Echo Cancel & Noise reduction
VOICE_RECOGNITION_FUNCTION = 0x004
@@ -160,52 +170,90 @@ class AgFeature(enum.IntFlag):
VOICE_RECOGNITION_TEST = 0x2000
-# Audio Codec IDs (normative).
-# Hands-Free Profile v1.8, 10 Appendix B
class AudioCodec(enum.IntEnum):
+ """
+ Audio Codec IDs (normative).
+
+ Hands-Free Profile v1.9, 11 Appendix B
+ """
+
CVSD = 0x01 # Support for CVSD audio codec
MSBC = 0x02 # Support for mSBC audio codec
+ LC3_SWB = 0x03 # Support for LC3-SWB audio codec
-# HF Indicators (normative).
-# Bluetooth Assigned Numbers, 6.10.1 HF Indicators
class HfIndicator(enum.IntEnum):
+ """
+ HF Indicators (normative).
+
+ Bluetooth Assigned Numbers, 6.10.1 HF Indicators.
+ """
+
ENHANCED_SAFETY = 0x01 # Enhanced safety feature
BATTERY_LEVEL = 0x02 # Battery level feature
-# Call Hold supported operations (normative).
-# AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services
class CallHoldOperation(enum.IntEnum):
+ """
+ Call Hold supported operations (normative).
+
+ AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services.
+ """
+
RELEASE_ALL_HELD_CALLS = 0 # Release all held calls
RELEASE_ALL_ACTIVE_CALLS = 1 # Release all active calls, accept other
HOLD_ALL_ACTIVE_CALLS = 2 # Place all active calls on hold, accept other
ADD_HELD_CALL = 3 # Adds a held call to conversation
-# Response Hold status (normative).
-# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
-# and 3GPP 27.007
class ResponseHoldStatus(enum.IntEnum):
+ """
+ Response Hold status (normative).
+
+ Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+ """
+
INC_CALL_HELD = 0 # Put incoming call on hold
HELD_CALL_ACC = 1 # Accept a held incoming call
HELD_CALL_REJ = 2 # Reject a held incoming call
-# Values for the Call Setup AG indicator (normative).
-# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
-# and 3GPP 27.007
+class AgIndicator(enum.Enum):
+ """
+ Values for the AG indicator (normative).
+
+ Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+ """
+
+ SERVICE = 'service'
+ CALL = 'call'
+ CALL_SETUP = 'callsetup'
+ CALL_HELD = 'callheld'
+ SIGNAL = 'signal'
+ ROAM = 'roam'
+ BATTERY_CHARGE = 'battchg'
+
+
class CallSetupAgIndicator(enum.IntEnum):
+ """
+ Values for the Call Setup AG indicator (normative).
+
+ Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+ """
+
NOT_IN_CALL_SETUP = 0
INCOMING_CALL_PROCESS = 1
OUTGOING_CALL_SETUP = 2
REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
-# Values for the Call Held AG indicator (normative).
-# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
-# and 3GPP 27.007
class CallHeldAgIndicator(enum.IntEnum):
+ """
+ Values for the Call Held AG indicator (normative).
+
+ Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
+ """
+
NO_CALLS_HELD = 0
# Call is placed on hold or active/held calls swapped
# (The AG has both an active AND a held call)
@@ -213,16 +261,24 @@ class CallHeldAgIndicator(enum.IntEnum):
CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
-# Call Info direction (normative).
-# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
class CallInfoDirection(enum.IntEnum):
+ """
+ Call Info direction (normative).
+
+ AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
+ """
+
MOBILE_ORIGINATED_CALL = 0
MOBILE_TERMINATED_CALL = 1
-# Call Info status (normative).
-# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
class CallInfoStatus(enum.IntEnum):
+ """
+ Call Info status (normative).
+
+ AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
+ """
+
ACTIVE = 0
HELD = 1
DIALING = 2
@@ -231,15 +287,47 @@ class CallInfoStatus(enum.IntEnum):
WAITING = 5
-# Call Info mode (normative).
-# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
class CallInfoMode(enum.IntEnum):
+ """
+ Call Info mode (normative).
+
+ AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
+ """
+
VOICE = 0
DATA = 1
FAX = 2
UNKNOWN = 9
+class CallInfoMultiParty(enum.IntEnum):
+ """
+ Call Info Multi-Party state (normative).
+
+ AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
+ """
+
+ NOT_IN_CONFERENCE = 0
+ IN_CONFERENCE = 1
+
+
+@dataclasses.dataclass
+class CallInfo:
+ """
+ Enhanced call status.
+
+ AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
+ """
+
+ index: int
+ direction: CallInfoDirection
+ status: CallInfoStatus
+ mode: CallInfoMode
+ multi_party: CallInfoMultiParty
+ number: Optional[int] = None
+ type: Optional[int] = None
+
+
# -----------------------------------------------------------------------------
# Hands-Free Control Interoperability Requirements
# -----------------------------------------------------------------------------
@@ -320,8 +408,9 @@ class Configuration:
class AtResponseType(enum.Enum):
- """Indicate if a response is expected from an AT command, and if multiple
- responses are accepted."""
+ """
+ Indicates if a response is expected from an AT command, and if multiple responses are accepted.
+ """
NONE = 0
SINGLE = 1
@@ -355,9 +444,20 @@ class HfIndicatorState:
enabled: bool = False
-class HfProtocol:
- """Implementation for the Hands-Free side of the Hands-Free profile.
- Reference specification Hands-Free Profile v1.8"""
+class HfProtocol(pyee.EventEmitter):
+ """
+ Implementation for the Hands-Free side of the Hands-Free profile.
+
+ Reference specification Hands-Free Profile v1.8.
+
+ Emitted events:
+ codec_negotiation: When codec is renegotiated, notify the new codec.
+ Args:
+ active_codec: AudioCodec
+ ag_indicator: When AG update their indicators, notify the new state.
+ Args:
+ ag_indicator: AgIndicator
+ """
supported_hf_features: int
supported_audio_codecs: List[AudioCodec]
@@ -377,14 +477,18 @@ class HfProtocol:
response_queue: asyncio.Queue
unsolicited_queue: asyncio.Queue
read_buffer: bytearray
+ active_codec: AudioCodec
+
+ def __init__(self, dlc: rfcomm.DLC, configuration: Configuration) -> None:
+ super().__init__()
- def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
# Configure internal state.
self.dlc = dlc
self.command_lock = asyncio.Lock()
self.response_queue = asyncio.Queue()
self.unsolicited_queue = asyncio.Queue()
self.read_buffer = bytearray()
+ self.active_codec = AudioCodec.CVSD
# Build local features.
self.supported_hf_features = sum(configuration.supported_hf_features)
@@ -409,10 +513,12 @@ class HfProtocol:
def supports_ag_feature(self, feature: AgFeature) -> bool:
return (self.supported_ag_features & feature) != 0
- # Read AT messages from the RFCOMM channel.
- # Enqueue AT commands, responses, unsolicited responses to their
- # respective queues, and set the corresponding event.
def _read_at(self, data: bytes):
+ """
+ Reads AT messages from the RFCOMM channel.
+
+ Enqueues AT commands, responses, unsolicited responses to their respective queues, and set the corresponding event.
+ """
# Append to the read buffer.
self.read_buffer.extend(data)
@@ -440,17 +546,25 @@ class HfProtocol:
else:
logger.warning(f"dropping unexpected response with code '{response.code}'")
- # Send an AT command and wait for the peer response.
- # Wait for the AT responses sent by the peer, to the status code.
- # Raises asyncio.TimeoutError if the status is not received
- # after a timeout (default 1 second).
- # Raises ProtocolError if the status is not OK.
async def execute_command(
self,
cmd: str,
timeout: float = 1.0,
response_type: AtResponseType = AtResponseType.NONE,
) -> Union[None, AtResponse, List[AtResponse]]:
+ """
+ Sends an AT command and wait for the peer response.
+ Wait for the AT responses sent by the peer, to the status code.
+
+ Args:
+ cmd: the AT command in string to execute.
+ timeout: timeout in float seconds.
+ response_type: type of response.
+
+ Raises:
+ asyncio.TimeoutError: the status is not received after a timeout (default 1 second).
+ ProtocolError: the status is not OK.
+ """
async with self.command_lock:
logger.debug(f">>> {cmd}")
self.dlc.write(cmd + '\r')
@@ -473,8 +587,9 @@ class HfProtocol:
raise HfpProtocolError(result.code)
responses.append(result)
- # 4.2.1 Service Level Connection Initialization.
async def initiate_slc(self):
+ """4.2.1 Service Level Connection Initialization."""
+
# 4.2.1.1 Supported features exchange
# First, in the initialization procedure, the HF shall send the
# AT+BRSF=<HF supported features> command to the AG to both notify
@@ -614,16 +729,17 @@ class HfProtocol:
logger.info("SLC setup completed")
- # 4.11.2 Audio Connection Setup by HF
async def setup_audio_connection(self):
+ """4.11.2 Audio Connection Setup by HF."""
+
# When the HF triggers the establishment of the Codec Connection it
# shall send the AT command AT+BCC to the AG. The AG shall respond with
# OK if it will start the Codec Connection procedure, and with ERROR
# if it cannot start the Codec Connection procedure.
await self.execute_command("AT+BCC")
- # 4.11.3 Codec Connection Setup
async def setup_codec_connection(self, codec_id: int):
+ """4.11.3 Codec Connection Setup."""
# The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
# The HF shall then respond to the incoming unsolicited response with
# the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
@@ -641,27 +757,29 @@ class HfProtocol:
# Synchronous Connection with the settings that are determined by the
# 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)
logger.info("codec connection setup completed")
- # 4.13.1 Answer Incoming Call from the HF – In-Band Ringing
async def answer_incoming_call(self):
+ """4.13.1 Answer Incoming Call from the HF - In-Band Ringing."""
# The user accepts the incoming voice call by using the proper means
# provided by the HF. The HF shall then send the ATA command
# (see Section 4.34) to the AG. The AG shall then begin the procedure for
# accepting the incoming call.
await self.execute_command("ATA")
- # 4.14.1 Reject an Incoming Call from the HF
async def reject_incoming_call(self):
+ """4.14.1 Reject an Incoming Call from the HF."""
# The user rejects the incoming call by using the User Interface on the
# Hands-Free unit. The HF shall then send the AT+CHUP command
# (see Section 4.34) to the AG. This may happen at any time during the
# procedures described in Sections 4.13.1 and 4.13.2.
await self.execute_command("AT+CHUP")
- # 4.15.1 Terminate a Call Process from the HF
async def terminate_call(self):
+ """4.15.1 Terminate a Call Process from the HF."""
# The user may abort the ongoing call process using whatever means
# provided by the Hands-Free unit. The HF shall send AT+CHUP command
# (see Section 4.34) to the AG, and the AG shall then start the
@@ -670,8 +788,35 @@ class HfProtocol:
# code, with the value indicating (call=0).
await self.execute_command("AT+CHUP")
+ async def query_current_calls(self) -> List[CallInfo]:
+ """4.32.1 Query List of Current Calls in AG.
+
+ Return:
+ List of current calls in AG.
+ """
+ responses = await self.execute_command(
+ "AT+CLCC", response_type=AtResponseType.MULTIPLE
+ )
+ assert isinstance(responses, list)
+
+ calls = []
+ for response in responses:
+ call_info = CallInfo(
+ index=int(response.parameters[0]),
+ direction=CallInfoDirection(int(response.parameters[1])),
+ status=CallInfoStatus(int(response.parameters[2])),
+ mode=CallInfoMode(int(response.parameters[3])),
+ multi_party=CallInfoMultiParty(int(response.parameters[4])),
+ )
+ if len(response.parameters) >= 7:
+ call_info.number = int(response.parameters[5])
+ call_info.type = int(response.parameters[6])
+ calls.append(call_info)
+ return calls
+
async def update_ag_indicator(self, index: int, value: int):
self.ag_indicators[index].current_status = value
+ self.emit('ag_indicator', self.ag_indicators[index])
logger.info(
f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
)
@@ -689,9 +834,11 @@ class HfProtocol:
logging.info(f"unhandled unsolicited response {result.code}")
async def run(self):
- """Main rountine for the Hands-Free side of the HFP protocol.
- Initiates the service level connection then loops handling
- unsolicited AG responses."""
+ """
+ Main routine for the Hands-Free side of the HFP protocol.
+
+ Initiates the service level connection then loops handling unsolicited AG responses.
+ """
try:
await self.initiate_slc()
@@ -707,9 +854,13 @@ class HfProtocol:
# -----------------------------------------------------------------------------
-# Profile version (normative).
-# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
class ProfileVersion(enum.IntEnum):
+ """
+ Profile version (normative).
+
+ Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
+ """
+
V1_5 = 0x0105
V1_6 = 0x0106
V1_7 = 0x0107
@@ -717,9 +868,13 @@ class ProfileVersion(enum.IntEnum):
V1_9 = 0x0109
-# HF supported features (normative).
-# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
class HfSdpFeature(enum.IntFlag):
+ """
+ HF supported features (normative).
+
+ Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
+ """
+
EC_NR = 0x01 # Echo Cancel & Noise reduction
THREE_WAY_CALLING = 0x02
CLI_PRESENTATION_CAPABILITY = 0x04
@@ -730,9 +885,13 @@ class HfSdpFeature(enum.IntFlag):
VOICE_RECOGNITION_TEST = 0x80
-# AG supported features (normative).
-# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
class AgSdpFeature(enum.IntFlag):
+ """
+ AG supported features (normative).
+
+ Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
+ """
+
THREE_WAY_CALLING = 0x01
EC_NR = 0x02 # Echo Cancel & Noise reduction
VOICE_RECOGNITION_FUNCTION = 0x04
@@ -746,9 +905,12 @@ class AgSdpFeature(enum.IntFlag):
def sdp_records(
service_record_handle: int, rfcomm_channel: int, configuration: Configuration
) -> List[ServiceAttribute]:
- """Generate the SDP record for HFP Hands-Free support.
+ """
+ Generates the SDP record for HFP Hands-Free support.
+
The record exposes the features supported in the input configuration,
- and the allocated RFCOMM channel."""
+ and the allocated RFCOMM channel.
+ """
hf_supported_features = 0
@@ -819,3 +981,175 @@ def sdp_records(
DataElement.unsigned_integer_16(hf_supported_features),
),
]
+
+
+# -----------------------------------------------------------------------------
+# ESCO Codec Default Parameters
+# -----------------------------------------------------------------------------
+
+
+# Hands-Free Profile v1.8, 5.7 Codec Interoperability Requirements
+class DefaultCodecParameters(enum.IntEnum):
+ SCO_CVSD_D0 = enum.auto()
+ SCO_CVSD_D1 = enum.auto()
+ ESCO_CVSD_S1 = enum.auto()
+ ESCO_CVSD_S2 = enum.auto()
+ ESCO_CVSD_S3 = enum.auto()
+ ESCO_CVSD_S4 = enum.auto()
+ ESCO_MSBC_T1 = enum.auto()
+ ESCO_MSBC_T2 = enum.auto()
+
+
+@dataclasses.dataclass
+class EscoParameters:
+ # Codec specific
+ transmit_coding_format: CodingFormat
+ receive_coding_format: CodingFormat
+ packet_type: HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType
+ retransmission_effort: HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort
+ max_latency: int
+
+ # Common
+ input_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM)
+ output_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM)
+ input_coded_data_size: int = 16
+ output_coded_data_size: int = 16
+ input_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = (
+ HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
+ )
+ output_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = (
+ HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
+ )
+ input_pcm_sample_payload_msb_position: int = 0
+ output_pcm_sample_payload_msb_position: int = 0
+ input_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
+ HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath.HCI
+ )
+ output_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
+ HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath.HCI
+ )
+ input_transport_unit_size: int = 0
+ output_transport_unit_size: int = 0
+ input_bandwidth: int = 16000
+ output_bandwidth: int = 16000
+ transmit_bandwidth: int = 8000
+ receive_bandwidth: int = 8000
+ transmit_codec_frame_size: int = 60
+ receive_codec_frame_size: int = 60
+
+ def asdict(self) -> Dict[str, Any]:
+ # dataclasses.asdict() will recursively deep-copy the entire object,
+ # which is expensive and breaks CodingFormat object, so let it simply copy here.
+ return self.__dict__
+
+
+_ESCO_PARAMETERS_CVSD_D0 = EscoParameters(
+ transmit_coding_format=CodingFormat(CodecID.CVSD),
+ receive_coding_format=CodingFormat(CodecID.CVSD),
+ max_latency=0xFFFF,
+ packet_type=HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.HV1,
+ retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.NO_RETRANSMISSION,
+)
+
+_ESCO_PARAMETERS_CVSD_D1 = EscoParameters(
+ transmit_coding_format=CodingFormat(CodecID.CVSD),
+ receive_coding_format=CodingFormat(CodecID.CVSD),
+ max_latency=0xFFFF,
+ packet_type=HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.HV3,
+ retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.NO_RETRANSMISSION,
+)
+
+_ESCO_PARAMETERS_CVSD_S1 = EscoParameters(
+ transmit_coding_format=CodingFormat(CodecID.CVSD),
+ receive_coding_format=CodingFormat(CodecID.CVSD),
+ max_latency=0x0007,
+ packet_type=(
+ HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+ ),
+ retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
+)
+
+_ESCO_PARAMETERS_CVSD_S2 = EscoParameters(
+ transmit_coding_format=CodingFormat(CodecID.CVSD),
+ receive_coding_format=CodingFormat(CodecID.CVSD),
+ max_latency=0x0007,
+ packet_type=(
+ HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+ ),
+ retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
+)
+
+_ESCO_PARAMETERS_CVSD_S3 = EscoParameters(
+ transmit_coding_format=CodingFormat(CodecID.CVSD),
+ receive_coding_format=CodingFormat(CodecID.CVSD),
+ max_latency=0x000A,
+ packet_type=(
+ HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+ ),
+ retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
+)
+
+_ESCO_PARAMETERS_CVSD_S4 = EscoParameters(
+ transmit_coding_format=CodingFormat(CodecID.CVSD),
+ receive_coding_format=CodingFormat(CodecID.CVSD),
+ max_latency=0x000C,
+ packet_type=(
+ HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+ ),
+ retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
+)
+
+_ESCO_PARAMETERS_MSBC_T1 = EscoParameters(
+ transmit_coding_format=CodingFormat(CodecID.MSBC),
+ receive_coding_format=CodingFormat(CodecID.MSBC),
+ max_latency=0x0008,
+ packet_type=(
+ HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+ ),
+ input_bandwidth=32000,
+ output_bandwidth=32000,
+ retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
+)
+
+_ESCO_PARAMETERS_MSBC_T2 = EscoParameters(
+ transmit_coding_format=CodingFormat(CodecID.MSBC),
+ receive_coding_format=CodingFormat(CodecID.MSBC),
+ max_latency=0x000D,
+ packet_type=(
+ HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
+ | HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
+ ),
+ input_bandwidth=32000,
+ output_bandwidth=32000,
+ retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
+)
+
+ESCO_PARAMETERS = {
+ DefaultCodecParameters.SCO_CVSD_D0: _ESCO_PARAMETERS_CVSD_D0,
+ DefaultCodecParameters.SCO_CVSD_D1: _ESCO_PARAMETERS_CVSD_D1,
+ DefaultCodecParameters.ESCO_CVSD_S1: _ESCO_PARAMETERS_CVSD_S1,
+ DefaultCodecParameters.ESCO_CVSD_S2: _ESCO_PARAMETERS_CVSD_S2,
+ DefaultCodecParameters.ESCO_CVSD_S3: _ESCO_PARAMETERS_CVSD_S3,
+ DefaultCodecParameters.ESCO_CVSD_S4: _ESCO_PARAMETERS_CVSD_S4,
+ DefaultCodecParameters.ESCO_MSBC_T1: _ESCO_PARAMETERS_MSBC_T1,
+ DefaultCodecParameters.ESCO_MSBC_T2: _ESCO_PARAMETERS_MSBC_T2,
+}
diff --git a/bumble/hid.py b/bumble/hid.py
index e4d6a77..fc5c807 100644
--- a/bumble/hid.py
+++ b/bumble/hid.py
@@ -18,18 +18,18 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
-import asyncio
import enum
+import struct
+from abc import ABC, abstractmethod
from pyee import EventEmitter
-from typing import Optional, Tuple, Callable, Dict, Union, TYPE_CHECKING
+from typing import Optional, Callable, TYPE_CHECKING
+from typing_extensions import override
-from . import core, l2cap # type: ignore
-from .colors import color # type: ignore
-from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError # type: ignore
-
-if TYPE_CHECKING:
- from bumble.device import Device, Connection
+from bumble import l2cap, device
+from bumble.colors import color
+from bumble.core import InvalidStateError, ProtocolError
+from .hci import Address
# -----------------------------------------------------------------------------
@@ -61,6 +61,7 @@ class Message:
NOT_READY = 0x01
ERR_INVALID_REPORT_ID = 0x02
ERR_UNSUPPORTED_REQUEST = 0x03
+ ERR_INVALID_PARAMETER = 0x04
ERR_UNKNOWN = 0x0E
ERR_FATAL = 0x0F
@@ -102,13 +103,14 @@ class GetReportMessage(Message):
def __bytes__(self) -> bytes:
packet_bytes = bytearray()
packet_bytes.append(self.report_id)
- packet_bytes.extend(
- [(self.buffer_size & 0xFF), ((self.buffer_size >> 8) & 0xFF)]
- )
- if self.report_type == Message.ReportType.OTHER_REPORT:
+ if self.buffer_size == 0:
return self.header(self.report_type) + packet_bytes
else:
- return self.header(0x08 | self.report_type) + packet_bytes
+ return (
+ self.header(0x08 | self.report_type)
+ + packet_bytes
+ + struct.pack("<H", self.buffer_size)
+ )
@dataclass
@@ -122,6 +124,16 @@ class SetReportMessage(Message):
@dataclass
+class SendControlData(Message):
+ report_type: int
+ data: bytes
+ message_type = Message.MessageType.DATA
+
+ def __bytes__(self) -> bytes:
+ return self.header(self.report_type) + self.data
+
+
+@dataclass
class GetProtocolMessage(Message):
message_type = Message.MessageType.GET_PROTOCOL
@@ -162,31 +174,47 @@ class VirtualCableUnplug(Message):
return self.header(Message.ControlCommand.VIRTUAL_CABLE_UNPLUG)
+# Device sends input report, host sends output report.
@dataclass
class SendData(Message):
data: bytes
+ report_type: int
message_type = Message.MessageType.DATA
def __bytes__(self) -> bytes:
- return self.header(Message.ReportType.OUTPUT_REPORT) + self.data
+ return self.header(self.report_type) + self.data
+
+
+@dataclass
+class SendHandshakeMessage(Message):
+ result_code: int
+ message_type = Message.MessageType.HANDSHAKE
+
+ def __bytes__(self) -> bytes:
+ return self.header(self.result_code)
# -----------------------------------------------------------------------------
-class Host(EventEmitter):
- l2cap_ctrl_channel: Optional[l2cap.ClassicChannel]
- l2cap_intr_channel: Optional[l2cap.ClassicChannel]
+class HID(ABC, EventEmitter):
+ l2cap_ctrl_channel: Optional[l2cap.ClassicChannel] = None
+ l2cap_intr_channel: Optional[l2cap.ClassicChannel] = None
+ connection: Optional[device.Connection] = None
+
+ class Role(enum.IntEnum):
+ HOST = 0x00
+ DEVICE = 0x01
- def __init__(self, device: Device, connection: Connection) -> None:
+ def __init__(self, device: device.Device, role: Role) -> None:
super().__init__()
+ self.remote_device_bd_address: Optional[Address] = None
self.device = device
- self.connection = connection
-
- self.l2cap_ctrl_channel = None
- self.l2cap_intr_channel = None
+ self.role = role
# Register ourselves with the L2CAP channel manager
- device.register_l2cap_server(HID_CONTROL_PSM, self.on_connection)
- device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_connection)
+ 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)
async def connect_control_channel(self) -> None:
# Create a new L2CAP connection - control channel
@@ -230,9 +258,18 @@ class Host(EventEmitter):
self.l2cap_ctrl_channel = None
await channel.disconnect()
- def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
+ 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)
+
+ 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))
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
if l2cap_channel.psm == HID_CONTROL_PSM:
@@ -243,37 +280,220 @@ class Host(EventEmitter):
self.l2cap_intr_channel.sink = self.on_intr_pdu
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
+ def on_l2cap_channel_close(self, l2cap_channel: l2cap.ClassicChannel) -> None:
+ if l2cap_channel.psm == HID_CONTROL_PSM:
+ self.l2cap_ctrl_channel = None
+ else:
+ self.l2cap_intr_channel = None
+ logger.debug(f'$$$ L2CAP channel close: {l2cap_channel}')
+
+ @abstractmethod
+ def on_ctrl_pdu(self, pdu: bytes) -> None:
+ pass
+
+ def on_intr_pdu(self, pdu: bytes) -> None:
+ logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
+ self.emit("interrupt_data", pdu)
+
+ def send_pdu_on_ctrl(self, msg: bytes) -> None:
+ assert self.l2cap_ctrl_channel
+ self.l2cap_ctrl_channel.send_pdu(msg)
+
+ def send_pdu_on_intr(self, msg: bytes) -> None:
+ assert self.l2cap_intr_channel
+ self.l2cap_intr_channel.send_pdu(msg)
+
+ def send_data(self, data: bytes) -> None:
+ if self.role == HID.Role.HOST:
+ report_type = Message.ReportType.OUTPUT_REPORT
+ else:
+ report_type = Message.ReportType.INPUT_REPORT
+ msg = SendData(data, report_type)
+ hid_message = bytes(msg)
+ if self.l2cap_intr_channel is not None:
+ logger.debug(f'>>> HID INTERRUPT SEND DATA, PDU: {hid_message.hex()}')
+ self.send_pdu_on_intr(hid_message)
+
+ def virtual_cable_unplug(self) -> None:
+ msg = VirtualCableUnplug()
+ hid_message = bytes(msg)
+ logger.debug(f'>>> HID CONTROL VIRTUAL CABLE UNPLUG, PDU: {hid_message.hex()}')
+ self.send_pdu_on_ctrl(hid_message)
+
+
+# -----------------------------------------------------------------------------
+
+
+class Device(HID):
+ class GetSetReturn(enum.IntEnum):
+ FAILURE = 0x00
+ REPORT_ID_NOT_FOUND = 0x01
+ ERR_UNSUPPORTED_REQUEST = 0x02
+ ERR_UNKNOWN = 0x03
+ ERR_INVALID_PARAMETER = 0x04
+ SUCCESS = 0xFF
+
+ class GetSetStatus:
+ def __init__(self) -> None:
+ self.data = bytearray()
+ self.status = 0
+
+ def __init__(self, device: device.Device) -> None:
+ super().__init__(device, HID.Role.DEVICE)
+ get_report_cb: Optional[Callable[[int, int, int], None]] = None
+ set_report_cb: Optional[Callable[[int, int, int, bytes], None]] = None
+ get_protocol_cb: Optional[Callable[[], None]] = None
+ set_protocol_cb: Optional[Callable[[int], None]] = None
+
+ @override
def on_ctrl_pdu(self, pdu: bytes) -> None:
logger.debug(f'<<< HID CONTROL PDU: {pdu.hex()}')
- # Here we will receive all kinds of packets, parse and then call respective callbacks
- message_type = pdu[0] >> 4
param = pdu[0] & 0x0F
+ 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))
+ if message_type == Message.MessageType.GET_REPORT:
+ logger.debug('<<< HID GET REPORT')
+ self.handle_get_report(pdu)
+ elif message_type == Message.MessageType.SET_REPORT:
+ logger.debug('<<< HID SET REPORT')
+ self.handle_set_report(pdu)
+ elif message_type == Message.MessageType.GET_PROTOCOL:
+ logger.debug('<<< HID GET PROTOCOL')
+ self.handle_get_protocol(pdu)
+ elif message_type == Message.MessageType.SET_PROTOCOL:
+ logger.debug('<<< HID SET PROTOCOL')
+ self.handle_set_protocol(pdu)
elif message_type == Message.MessageType.DATA:
logger.debug('<<< HID CONTROL DATA')
- self.emit('data', pdu)
+ self.emit('control_data', pdu)
elif message_type == Message.MessageType.CONTROL:
if param == Message.ControlCommand.SUSPEND:
logger.debug('<<< HID SUSPEND')
- self.emit('suspend', pdu)
+ self.emit('suspend')
elif param == Message.ControlCommand.EXIT_SUSPEND:
logger.debug('<<< HID EXIT SUSPEND')
- self.emit('exit_suspend', pdu)
+ self.emit('exit_suspend')
elif param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG:
logger.debug('<<< HID VIRTUAL CABLE UNPLUG')
self.emit('virtual_cable_unplug')
else:
logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
else:
- logger.debug('<<< HID CONTROL DATA')
- self.emit('data', pdu)
+ logger.debug('<<< HID MESSAGE TYPE UNSUPPORTED')
+ self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
- def on_intr_pdu(self, pdu: bytes) -> None:
- logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
- self.emit("data", pdu)
+ def send_handshake_message(self, result_code: int) -> None:
+ msg = SendHandshakeMessage(result_code)
+ hid_message = bytes(msg)
+ logger.debug(f'>>> HID HANDSHAKE MESSAGE, PDU: {hid_message.hex()}')
+ self.send_pdu_on_ctrl(hid_message)
+
+ def send_control_data(self, report_type: int, data: bytes):
+ msg = SendControlData(report_type=report_type, data=data)
+ hid_message = bytes(msg)
+ logger.debug(f'>>> HID CONTROL DATA: {hid_message.hex()}')
+ self.send_pdu_on_ctrl(hid_message)
+
+ def handle_get_report(self, pdu: bytes):
+ if self.get_report_cb is None:
+ logger.debug("GetReport callback not registered !!")
+ self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+ return
+ report_type = pdu[0] & 0x03
+ buffer_flag = (pdu[0] & 0x08) >> 3
+ report_id = pdu[1]
+ logger.debug(f"buffer_flag: {buffer_flag}")
+ if buffer_flag == 1:
+ buffer_size = (pdu[3] << 8) | pdu[2]
+ else:
+ buffer_size = 0
+
+ ret = self.get_report_cb(report_id, report_type, buffer_size)
+ assert ret is not None
+ if ret.status == self.GetSetReturn.FAILURE:
+ self.send_handshake_message(Message.Handshake.ERR_UNKNOWN)
+ elif ret.status == self.GetSetReturn.SUCCESS:
+ data = bytearray()
+ data.append(report_id)
+ data.extend(ret.data)
+ if len(data) < self.l2cap_ctrl_channel.peer_mtu: # type: ignore[union-attr]
+ self.send_control_data(report_type=report_type, data=data)
+ else:
+ self.send_handshake_message(Message.Handshake.ERR_INVALID_PARAMETER)
+ elif ret.status == self.GetSetReturn.REPORT_ID_NOT_FOUND:
+ self.send_handshake_message(Message.Handshake.ERR_INVALID_REPORT_ID)
+ elif ret.status == self.GetSetReturn.ERR_INVALID_PARAMETER:
+ self.send_handshake_message(Message.Handshake.ERR_INVALID_PARAMETER)
+ elif ret.status == self.GetSetReturn.ERR_UNSUPPORTED_REQUEST:
+ self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+
+ def register_get_report_cb(self, cb: Callable[[int, int, int], None]) -> None:
+ self.get_report_cb = cb
+ logger.debug("GetReport callback registered successfully")
+
+ def handle_set_report(self, pdu: bytes):
+ if self.set_report_cb is None:
+ logger.debug("SetReport callback not registered !!")
+ self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+ return
+ report_type = pdu[0] & 0x03
+ report_id = pdu[1]
+ report_data = pdu[2:]
+ report_size = len(report_data) + 1
+ ret = self.set_report_cb(report_id, report_type, report_size, report_data)
+ assert ret is not None
+ if ret.status == self.GetSetReturn.SUCCESS:
+ self.send_handshake_message(Message.Handshake.SUCCESSFUL)
+ elif ret.status == self.GetSetReturn.ERR_INVALID_PARAMETER:
+ self.send_handshake_message(Message.Handshake.ERR_INVALID_PARAMETER)
+ elif ret.status == self.GetSetReturn.REPORT_ID_NOT_FOUND:
+ self.send_handshake_message(Message.Handshake.ERR_INVALID_REPORT_ID)
+ else:
+ self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+
+ def register_set_report_cb(
+ self, cb: Callable[[int, int, int, bytes], None]
+ ) -> None:
+ self.set_report_cb = cb
+ logger.debug("SetReport callback registered successfully")
+
+ def handle_get_protocol(self, pdu: bytes):
+ if self.get_protocol_cb is None:
+ logger.debug("GetProtocol callback not registered !!")
+ self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+ return
+ ret = self.get_protocol_cb()
+ assert ret is not None
+ if ret.status == self.GetSetReturn.SUCCESS:
+ self.send_control_data(Message.ReportType.OTHER_REPORT, ret.data)
+ else:
+ self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+
+ def register_get_protocol_cb(self, cb: Callable[[], None]) -> None:
+ self.get_protocol_cb = cb
+ logger.debug("GetProtocol callback registered successfully")
+
+ def handle_set_protocol(self, pdu: bytes):
+ if self.set_protocol_cb is None:
+ logger.debug("SetProtocol callback not registered !!")
+ self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+ return
+ ret = self.set_protocol_cb(pdu[0] & 0x01)
+ assert ret is not None
+ if ret.status == self.GetSetReturn.SUCCESS:
+ self.send_handshake_message(Message.Handshake.SUCCESSFUL)
+ else:
+ self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
+
+ def register_set_protocol_cb(self, cb: Callable[[int], None]) -> None:
+ self.set_protocol_cb = cb
+ logger.debug("SetProtocol callback registered successfully")
+
+
+# -----------------------------------------------------------------------------
+class Host(HID):
+ def __init__(self, device: device.Device) -> None:
+ super().__init__(device, HID.Role.HOST)
def get_report(self, report_type: int, report_id: int, buffer_size: int) -> None:
msg = GetReportMessage(
@@ -283,50 +503,52 @@ class Host(EventEmitter):
logger.debug(f'>>> HID CONTROL GET REPORT, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
- def set_report(self, report_type: int, data: bytes):
+ def set_report(self, report_type: int, data: bytes) -> None:
msg = SetReportMessage(report_type=report_type, data=data)
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL SET REPORT, PDU:{hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
- def get_protocol(self):
+ def get_protocol(self) -> None:
msg = GetProtocolMessage()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL GET PROTOCOL, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
- def set_protocol(self, protocol_mode: int):
+ def set_protocol(self, protocol_mode: int) -> None:
msg = SetProtocolMessage(protocol_mode=protocol_mode)
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL SET PROTOCOL, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
- def send_pdu_on_ctrl(self, msg: bytes) -> None:
- self.l2cap_ctrl_channel.send_pdu(msg) # type: ignore
-
- def send_pdu_on_intr(self, msg: bytes) -> None:
- self.l2cap_intr_channel.send_pdu(msg) # type: ignore
-
- def send_data(self, data):
- msg = SendData(data)
- hid_message = bytes(msg)
- logger.debug(f'>>> HID INTERRUPT SEND DATA, PDU: {hid_message.hex()}')
- self.send_pdu_on_intr(hid_message)
-
- def suspend(self):
+ def suspend(self) -> None:
msg = Suspend()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL SUSPEND, PDU:{hid_message.hex()}')
- self.send_pdu_on_ctrl(msg)
+ self.send_pdu_on_ctrl(hid_message)
- def exit_suspend(self):
+ def exit_suspend(self) -> None:
msg = ExitSuspend()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL EXIT SUSPEND, PDU:{hid_message.hex()}')
- self.send_pdu_on_ctrl(msg)
+ self.send_pdu_on_ctrl(hid_message)
- def virtual_cable_unplug(self):
- msg = VirtualCableUnplug()
- hid_message = bytes(msg)
- logger.debug(f'>>> HID CONTROL VIRTUAL CABLE UNPLUG, PDU: {hid_message.hex()}')
- self.send_pdu_on_ctrl(msg)
+ @override
+ def on_ctrl_pdu(self, pdu: bytes) -> None:
+ logger.debug(f'<<< HID CONTROL PDU: {pdu.hex()}')
+ param = pdu[0] & 0x0F
+ 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))
+ elif message_type == Message.MessageType.DATA:
+ logger.debug('<<< HID CONTROL DATA')
+ self.emit('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')
+ else:
+ logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
+ else:
+ logger.debug('<<< HID MESSAGE TYPE UNSUPPORTED')
diff --git a/bumble/host.py b/bumble/host.py
index 02caa46..fd0a247 100644
--- a/bumble/host.py
+++ b/bumble/host.py
@@ -18,65 +18,35 @@
from __future__ import annotations
import asyncio
import collections
+import dataclasses
import logging
import struct
-from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
+from typing import (
+ Any,
+ Awaitable,
+ Callable,
+ Deque,
+ Dict,
+ Optional,
+ Set,
+ cast,
+ TYPE_CHECKING,
+)
from bumble.colors import color
from bumble.l2cap import L2CAP_PDU
from bumble.snoop import Snooper
from bumble import drivers
-
-from .hci import (
- Address,
- HCI_ACL_DATA_PACKET,
- HCI_COMMAND_PACKET,
- HCI_COMMAND_COMPLETE_EVENT,
- HCI_EVENT_PACKET,
- HCI_LE_READ_BUFFER_SIZE_COMMAND,
- HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
- HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
- HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
- HCI_READ_BUFFER_SIZE_COMMAND,
- HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND,
- HCI_RESET_COMMAND,
- HCI_SUCCESS,
- HCI_SUPPORTED_COMMANDS_FLAGS,
- HCI_VERSION_BLUETOOTH_CORE_4_0,
- HCI_AclDataPacket,
- HCI_AclDataPacketAssembler,
- HCI_Command,
- HCI_Command_Complete_Event,
- HCI_Constant,
- HCI_Error,
- HCI_Event,
- HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
- HCI_LE_Long_Term_Key_Request_Reply_Command,
- HCI_LE_Read_Buffer_Size_Command,
- HCI_LE_Read_Local_Supported_Features_Command,
- HCI_LE_Read_Suggested_Default_Data_Length_Command,
- HCI_LE_Remote_Connection_Parameter_Request_Reply_Command,
- HCI_LE_Set_Event_Mask_Command,
- HCI_LE_Write_Suggested_Default_Data_Length_Command,
- HCI_Link_Key_Request_Negative_Reply_Command,
- HCI_Link_Key_Request_Reply_Command,
- HCI_Packet,
- HCI_Read_Buffer_Size_Command,
- HCI_Read_Local_Supported_Commands_Command,
- HCI_Read_Local_Version_Information_Command,
- HCI_Reset_Command,
- HCI_Set_Event_Mask_Command,
-)
-from .core import (
+from bumble import hci
+from bumble.core import (
BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT,
ConnectionPHY,
ConnectionParameters,
- InvalidStateError,
)
-from .utils import AbortableEventEmitter
-from .transport.common import TransportLostError
+from bumble.utils import AbortableEventEmitter
+from bumble.transport.common import TransportLostError
if TYPE_CHECKING:
from .transport.common import TransportSink, TransportSource
@@ -89,28 +59,70 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-# Constants
-# -----------------------------------------------------------------------------
-# fmt: off
+class AclPacketQueue:
+ max_packet_size: int
+
+ def __init__(
+ self,
+ max_packet_size: int,
+ max_in_flight: int,
+ send: Callable[[hci.HCI_Packet], None],
+ ) -> None:
+ self.max_packet_size = max_packet_size
+ self.max_in_flight = max_in_flight
+ self.in_flight = 0
+ self.send = send
+ self.packets: Deque[hci.HCI_AclDataPacket] = collections.deque()
-HOST_DEFAULT_HC_LE_ACL_DATA_PACKET_LENGTH = 27
-HOST_HC_TOTAL_NUM_LE_ACL_DATA_PACKETS = 1
-HOST_DEFAULT_HC_ACL_DATA_PACKET_LENGTH = 27
-HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
+ def enqueue(self, packet: hci.HCI_AclDataPacket) -> None:
+ self.packets.appendleft(packet)
+ self.check_queue()
-# fmt: on
+ if self.packets:
+ logger.debug(
+ f'{self.in_flight} ACL packets in flight, '
+ f'{len(self.packets)} in queue'
+ )
+
+ def check_queue(self) -> None:
+ while self.packets and self.in_flight < self.max_in_flight:
+ packet = self.packets.pop()
+ self.send(packet)
+ self.in_flight += 1
+
+ def on_packets_completed(self, packet_count: int) -> None:
+ if packet_count > self.in_flight:
+ logger.warning(
+ color(
+ '!!! {packet_count} completed but only '
+ f'{self.in_flight} in flight'
+ )
+ )
+ packet_count = self.in_flight
+
+ self.in_flight -= packet_count
+ self.check_queue()
# -----------------------------------------------------------------------------
class Connection:
- def __init__(self, host: Host, handle: int, peer_address: Address, transport: int):
+ def __init__(
+ self, host: Host, handle: int, peer_address: hci.Address, transport: int
+ ):
self.host = host
self.handle = handle
self.peer_address = peer_address
- self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
+ self.assembler = hci.HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.transport = transport
+ acl_packet_queue: Optional[AclPacketQueue] = (
+ host.le_acl_packet_queue
+ if transport == BT_LE_TRANSPORT
+ else host.acl_packet_queue
+ )
+ assert acl_packet_queue
+ self.acl_packet_queue = acl_packet_queue
- def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
+ def on_hci_acl_data_packet(self, packet: hci.HCI_AclDataPacket) -> None:
self.assembler.feed_packet(packet)
def on_acl_pdu(self, pdu: bytes) -> None:
@@ -119,14 +131,32 @@ class Connection:
# -----------------------------------------------------------------------------
+@dataclasses.dataclass
+class ScoLink:
+ peer_address: hci.Address
+ handle: int
+
+
+# -----------------------------------------------------------------------------
+@dataclasses.dataclass
+class CisLink:
+ peer_address: hci.Address
+ handle: int
+
+
+# -----------------------------------------------------------------------------
class Host(AbortableEventEmitter):
connections: Dict[int, Connection]
- acl_packet_queue: collections.deque[HCI_AclDataPacket]
- hci_sink: TransportSink
+ cis_links: Dict[int, CisLink]
+ sco_links: Dict[int, ScoLink]
+ acl_packet_queue: Optional[AclPacketQueue] = None
+ le_acl_packet_queue: Optional[AclPacketQueue] = None
+ hci_sink: Optional[TransportSink] = None
+ hci_metadata: Dict[str, Any]
long_term_key_provider: Optional[
Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
]
- link_key_provider: Optional[Callable[[Address], Awaitable[Optional[bytes]]]]
+ link_key_provider: Optional[Callable[[hci.Address], Awaitable[Optional[bytes]]]]
def __init__(
self,
@@ -135,21 +165,19 @@ class Host(AbortableEventEmitter):
) -> None:
super().__init__()
- self.hci_metadata = None
+ self.hci_metadata = {}
self.ready = False # True when we can accept incoming packets
- self.reset_done = False
self.connections = {} # Connections, by connection handle
+ self.cis_links = {} # CIS links, by connection handle
+ self.sco_links = {} # SCO links, by connection handle
self.pending_command = None
self.pending_response = None
- self.hc_le_acl_data_packet_length = HOST_DEFAULT_HC_LE_ACL_DATA_PACKET_LENGTH
- self.hc_total_num_le_acl_data_packets = HOST_HC_TOTAL_NUM_LE_ACL_DATA_PACKETS
- self.hc_acl_data_packet_length = HOST_DEFAULT_HC_ACL_DATA_PACKET_LENGTH
- self.hc_total_num_acl_data_packets = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS
- self.acl_packet_queue = collections.deque()
- self.acl_packets_in_flight = 0
+ self.number_of_supported_advertising_sets = 0
+ self.maximum_advertising_data_length = 31
self.local_version = None
- self.local_supported_commands = bytes(64)
+ self.local_supported_commands = 0
self.local_le_features = 0
+ self.local_lmp_features = hci.LmpFeatureMask(0) # Classic LMP features
self.suggested_max_tx_octets = 251 # Max allowed
self.suggested_max_tx_time = 2120 # Max allowed
self.command_semaphore = asyncio.Semaphore(1)
@@ -160,16 +188,13 @@ class Host(AbortableEventEmitter):
# Connect to the source and sink if specified
if controller_source:
- controller_source.set_packet_sink(self)
- self.hci_metadata = getattr(
- controller_source, 'metadata', self.hci_metadata
- )
+ self.set_packet_source(controller_source)
if controller_sink:
self.set_packet_sink(controller_sink)
def find_connection_by_bd_addr(
self,
- bd_addr: Address,
+ bd_addr: hci.Address,
transport: Optional[int] = None,
check_address_type: bool = False,
) -> Optional[Connection]:
@@ -198,105 +223,237 @@ class Host(AbortableEventEmitter):
self.ready = False
await self.flush()
- await self.send_command(HCI_Reset_Command(), check_result=True)
- self.ready = True
-
# Instantiate and init a driver for the host if needed.
# NOTE: we don't keep a reference to the driver here, because we don't
# currently have a need for the driver later on. But if the driver interface
# evolves, it may be required, then, to store a reference to the driver in
# an object property.
+ reset_needed = True
if driver_factory is not None:
if driver := await driver_factory(self):
await driver.init_controller()
+ reset_needed = False
+
+ # Send a reset command unless a driver has already done so.
+ if reset_needed:
+ await self.send_command(hci.HCI_Reset_Command(), check_result=True)
+ self.ready = True
response = await self.send_command(
- HCI_Read_Local_Supported_Commands_Command(), check_result=True
+ hci.HCI_Read_Local_Supported_Commands_Command(), check_result=True
+ )
+ self.local_supported_commands = int.from_bytes(
+ response.return_parameters.supported_commands, 'little'
)
- self.local_supported_commands = response.return_parameters.supported_commands
- if self.supports_command(HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
+ if self.supports_command(hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
response = await self.send_command(
- HCI_LE_Read_Local_Supported_Features_Command(), check_result=True
+ hci.HCI_LE_Read_Local_Supported_Features_Command(), check_result=True
)
self.local_le_features = struct.unpack(
'<Q', response.return_parameters.le_features
)[0]
- if self.supports_command(HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
+ if self.supports_command(hci.HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
response = await self.send_command(
- HCI_Read_Local_Version_Information_Command(), check_result=True
+ hci.HCI_Read_Local_Version_Information_Command(), check_result=True
)
self.local_version = response.return_parameters
+ if self.supports_command(hci.HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND):
+ max_page_number = 0
+ page_number = 0
+ lmp_features = 0
+ while page_number <= max_page_number:
+ response = await self.send_command(
+ hci.HCI_Read_Local_Extended_Features_Command(
+ page_number=page_number
+ ),
+ check_result=True,
+ )
+ lmp_features |= int.from_bytes(
+ response.return_parameters.extended_lmp_features, 'little'
+ ) << (64 * page_number)
+ max_page_number = response.return_parameters.maximum_page_number
+ page_number += 1
+ self.local_lmp_features = hci.LmpFeatureMask(lmp_features)
+
+ elif self.supports_command(hci.HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
+ response = await self.send_command(
+ hci.HCI_Read_Local_Supported_Features_Command(), check_result=True
+ )
+ self.local_lmp_features = hci.LmpFeatureMask(
+ int.from_bytes(response.return_parameters.lmp_features, 'little')
+ )
+
await self.send_command(
- HCI_Set_Event_Mask_Command(event_mask=bytes.fromhex('FFFFFFFFFFFFFF3F'))
+ hci.HCI_Set_Event_Mask_Command(
+ event_mask=hci.HCI_Set_Event_Mask_Command.mask(
+ [
+ hci.HCI_INQUIRY_COMPLETE_EVENT,
+ hci.HCI_INQUIRY_RESULT_EVENT,
+ hci.HCI_CONNECTION_COMPLETE_EVENT,
+ hci.HCI_CONNECTION_REQUEST_EVENT,
+ hci.HCI_DISCONNECTION_COMPLETE_EVENT,
+ hci.HCI_AUTHENTICATION_COMPLETE_EVENT,
+ hci.HCI_REMOTE_NAME_REQUEST_COMPLETE_EVENT,
+ hci.HCI_ENCRYPTION_CHANGE_EVENT,
+ hci.HCI_CHANGE_CONNECTION_LINK_KEY_COMPLETE_EVENT,
+ hci.HCI_LINK_KEY_TYPE_CHANGED_EVENT,
+ hci.HCI_READ_REMOTE_SUPPORTED_FEATURES_COMPLETE_EVENT,
+ hci.HCI_READ_REMOTE_VERSION_INFORMATION_COMPLETE_EVENT,
+ hci.HCI_QOS_SETUP_COMPLETE_EVENT,
+ hci.HCI_HARDWARE_ERROR_EVENT,
+ hci.HCI_FLUSH_OCCURRED_EVENT,
+ hci.HCI_ROLE_CHANGE_EVENT,
+ hci.HCI_MODE_CHANGE_EVENT,
+ hci.HCI_RETURN_LINK_KEYS_EVENT,
+ hci.HCI_PIN_CODE_REQUEST_EVENT,
+ hci.HCI_LINK_KEY_REQUEST_EVENT,
+ hci.HCI_LINK_KEY_NOTIFICATION_EVENT,
+ hci.HCI_LOOPBACK_COMMAND_EVENT,
+ hci.HCI_DATA_BUFFER_OVERFLOW_EVENT,
+ hci.HCI_MAX_SLOTS_CHANGE_EVENT,
+ hci.HCI_READ_CLOCK_OFFSET_COMPLETE_EVENT,
+ hci.HCI_CONNECTION_PACKET_TYPE_CHANGED_EVENT,
+ hci.HCI_QOS_VIOLATION_EVENT,
+ hci.HCI_PAGE_SCAN_REPETITION_MODE_CHANGE_EVENT,
+ hci.HCI_FLOW_SPECIFICATION_COMPLETE_EVENT,
+ hci.HCI_INQUIRY_RESULT_WITH_RSSI_EVENT,
+ hci.HCI_READ_REMOTE_EXTENDED_FEATURES_COMPLETE_EVENT,
+ hci.HCI_SYNCHRONOUS_CONNECTION_COMPLETE_EVENT,
+ hci.HCI_SYNCHRONOUS_CONNECTION_CHANGED_EVENT,
+ hci.HCI_SNIFF_SUBRATING_EVENT,
+ hci.HCI_EXTENDED_INQUIRY_RESULT_EVENT,
+ hci.HCI_ENCRYPTION_KEY_REFRESH_COMPLETE_EVENT,
+ hci.HCI_IO_CAPABILITY_REQUEST_EVENT,
+ hci.HCI_IO_CAPABILITY_RESPONSE_EVENT,
+ hci.HCI_USER_CONFIRMATION_REQUEST_EVENT,
+ hci.HCI_USER_PASSKEY_REQUEST_EVENT,
+ hci.HCI_REMOTE_OOB_DATA_REQUEST_EVENT,
+ hci.HCI_SIMPLE_PAIRING_COMPLETE_EVENT,
+ hci.HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT,
+ hci.HCI_ENHANCED_FLUSH_COMPLETE_EVENT,
+ hci.HCI_USER_PASSKEY_NOTIFICATION_EVENT,
+ hci.HCI_KEYPRESS_NOTIFICATION_EVENT,
+ hci.HCI_REMOTE_HOST_SUPPORTED_FEATURES_NOTIFICATION_EVENT,
+ hci.HCI_LE_META_EVENT,
+ ]
+ )
+ )
)
if (
self.local_version is not None
- and self.local_version.hci_version <= HCI_VERSION_BLUETOOTH_CORE_4_0
+ and self.local_version.hci_version <= hci.HCI_VERSION_BLUETOOTH_CORE_4_0
):
# Some older controllers don't like event masks with bits they don't
# understand
le_event_mask = bytes.fromhex('1F00000000000000')
else:
- le_event_mask = bytes.fromhex('FFFFF00000000000')
+ le_event_mask = hci.HCI_LE_Set_Event_Mask_Command.mask(
+ [
+ hci.HCI_LE_CONNECTION_COMPLETE_EVENT,
+ hci.HCI_LE_ADVERTISING_REPORT_EVENT,
+ hci.HCI_LE_CONNECTION_UPDATE_COMPLETE_EVENT,
+ hci.HCI_LE_READ_REMOTE_FEATURES_COMPLETE_EVENT,
+ hci.HCI_LE_LONG_TERM_KEY_REQUEST_EVENT,
+ hci.HCI_LE_REMOTE_CONNECTION_PARAMETER_REQUEST_EVENT,
+ hci.HCI_LE_DATA_LENGTH_CHANGE_EVENT,
+ 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_DIRECTED_ADVERTISING_REPORT_EVENT,
+ hci.HCI_LE_PHY_UPDATE_COMPLETE_EVENT,
+ hci.HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT,
+ hci.HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_EVENT,
+ hci.HCI_LE_PERIODIC_ADVERTISING_REPORT_EVENT,
+ hci.HCI_LE_PERIODIC_ADVERTISING_SYNC_LOST_EVENT,
+ hci.HCI_LE_SCAN_TIMEOUT_EVENT,
+ hci.HCI_LE_ADVERTISING_SET_TERMINATED_EVENT,
+ hci.HCI_LE_SCAN_REQUEST_RECEIVED_EVENT,
+ hci.HCI_LE_CONNECTIONLESS_IQ_REPORT_EVENT,
+ hci.HCI_LE_CONNECTION_IQ_REPORT_EVENT,
+ hci.HCI_LE_CTE_REQUEST_FAILED_EVENT,
+ hci.HCI_LE_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECEIVED_EVENT,
+ hci.HCI_LE_CIS_ESTABLISHED_EVENT,
+ hci.HCI_LE_CIS_REQUEST_EVENT,
+ hci.HCI_LE_CREATE_BIG_COMPLETE_EVENT,
+ hci.HCI_LE_TERMINATE_BIG_COMPLETE_EVENT,
+ hci.HCI_LE_BIG_SYNC_ESTABLISHED_EVENT,
+ hci.HCI_LE_BIG_SYNC_LOST_EVENT,
+ hci.HCI_LE_REQUEST_PEER_SCA_COMPLETE_EVENT,
+ hci.HCI_LE_PATH_LOSS_THRESHOLD_EVENT,
+ hci.HCI_LE_TRANSMIT_POWER_REPORTING_EVENT,
+ hci.HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT,
+ hci.HCI_LE_SUBRATE_CHANGE_EVENT,
+ ]
+ )
await self.send_command(
- HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
+ hci.HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
)
- if self.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
+ if self.supports_command(hci.HCI_READ_BUFFER_SIZE_COMMAND):
response = await self.send_command(
- HCI_Read_Buffer_Size_Command(), check_result=True
+ hci.HCI_Read_Buffer_Size_Command(), check_result=True
)
- self.hc_acl_data_packet_length = (
+ hc_acl_data_packet_length = (
response.return_parameters.hc_acl_data_packet_length
)
- self.hc_total_num_acl_data_packets = (
+ hc_total_num_acl_data_packets = (
response.return_parameters.hc_total_num_acl_data_packets
)
logger.debug(
'HCI ACL flow control: '
- f'hc_acl_data_packet_length={self.hc_acl_data_packet_length},'
- f'hc_total_num_acl_data_packets={self.hc_total_num_acl_data_packets}'
+ f'hc_acl_data_packet_length={hc_acl_data_packet_length},'
+ f'hc_total_num_acl_data_packets={hc_total_num_acl_data_packets}'
)
- if self.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
+ self.acl_packet_queue = AclPacketQueue(
+ max_packet_size=hc_acl_data_packet_length,
+ max_in_flight=hc_total_num_acl_data_packets,
+ send=self.send_hci_packet,
+ )
+
+ hc_le_acl_data_packet_length = 0
+ hc_total_num_le_acl_data_packets = 0
+ if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
response = await self.send_command(
- HCI_LE_Read_Buffer_Size_Command(), check_result=True
+ hci.HCI_LE_Read_Buffer_Size_Command(), check_result=True
)
- self.hc_le_acl_data_packet_length = (
+ hc_le_acl_data_packet_length = (
response.return_parameters.hc_le_acl_data_packet_length
)
- self.hc_total_num_le_acl_data_packets = (
+ hc_total_num_le_acl_data_packets = (
response.return_parameters.hc_total_num_le_acl_data_packets
)
logger.debug(
'HCI LE ACL flow control: '
- f'hc_le_acl_data_packet_length={self.hc_le_acl_data_packet_length},'
- 'hc_total_num_le_acl_data_packets='
- f'{self.hc_total_num_le_acl_data_packets}'
+ f'hc_le_acl_data_packet_length={hc_le_acl_data_packet_length},'
+ f'hc_total_num_le_acl_data_packets={hc_total_num_le_acl_data_packets}'
)
- if (
- response.return_parameters.hc_le_acl_data_packet_length == 0
- or response.return_parameters.hc_total_num_le_acl_data_packets == 0
- ):
- # LE and Classic share the same values
- self.hc_le_acl_data_packet_length = self.hc_acl_data_packet_length
- self.hc_total_num_le_acl_data_packets = (
- self.hc_total_num_acl_data_packets
- )
+ if hc_le_acl_data_packet_length == 0 or hc_total_num_le_acl_data_packets == 0:
+ # LE and Classic share the same queue
+ self.le_acl_packet_queue = self.acl_packet_queue
+ else:
+ # Create a separate queue for LE
+ self.le_acl_packet_queue = AclPacketQueue(
+ max_packet_size=hc_le_acl_data_packet_length,
+ max_in_flight=hc_total_num_le_acl_data_packets,
+ send=self.send_hci_packet,
+ )
if self.supports_command(
- HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
- ) and self.supports_command(HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
+ hci.HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
+ ) and self.supports_command(
+ hci.HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
+ ):
response = await self.send_command(
- HCI_LE_Read_Suggested_Default_Data_Length_Command()
+ hci.HCI_LE_Read_Suggested_Default_Data_Length_Command()
)
suggested_max_tx_octets = response.return_parameters.suggested_max_tx_octets
suggested_max_tx_time = response.return_parameters.suggested_max_tx_time
@@ -305,35 +462,59 @@ class Host(AbortableEventEmitter):
or suggested_max_tx_time != self.suggested_max_tx_time
):
await self.send_command(
- HCI_LE_Write_Suggested_Default_Data_Length_Command(
+ hci.HCI_LE_Write_Suggested_Default_Data_Length_Command(
suggested_max_tx_octets=self.suggested_max_tx_octets,
suggested_max_tx_time=self.suggested_max_tx_time,
)
)
- self.reset_done = True
+ if self.supports_command(
+ hci.HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND
+ ):
+ response = await self.send_command(
+ hci.HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command(),
+ check_result=True,
+ )
+ self.number_of_supported_advertising_sets = (
+ response.return_parameters.num_supported_advertising_sets
+ )
+
+ if self.supports_command(
+ hci.HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND
+ ):
+ response = await self.send_command(
+ hci.HCI_LE_Read_Maximum_Advertising_Data_Length_Command(),
+ check_result=True,
+ )
+ self.maximum_advertising_data_length = (
+ response.return_parameters.max_advertising_data_length
+ )
@property
- def controller(self) -> TransportSink:
+ def controller(self) -> Optional[TransportSink]:
return self.hci_sink
@controller.setter
- def controller(self, controller):
+ def controller(self, controller) -> None:
self.set_packet_sink(controller)
if controller:
- controller.set_packet_sink(self)
+ self.set_packet_source(controller)
- def set_packet_sink(self, sink: TransportSink) -> None:
+ def set_packet_sink(self, sink: Optional[TransportSink]) -> None:
self.hci_sink = sink
- def send_hci_packet(self, packet: HCI_Packet) -> None:
+ def set_packet_source(self, source: TransportSource) -> None:
+ source.set_packet_sink(self)
+ self.hci_metadata = getattr(source, 'metadata', self.hci_metadata)
+
+ def send_hci_packet(self, packet: hci.HCI_Packet) -> None:
+ logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {packet}')
if self.snooper:
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
- self.hci_sink.on_packet(bytes(packet))
+ if self.hci_sink:
+ self.hci_sink.on_packet(bytes(packet))
async def send_command(self, command, check_result=False):
- logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
-
# Wait until we can send (only one pending command at a time)
async with self.command_semaphore:
assert self.pending_command is None
@@ -357,11 +538,12 @@ class Host(AbortableEventEmitter):
else:
status = response.return_parameters.status
- if status != HCI_SUCCESS:
+ if status != hci.HCI_SUCCESS:
logger.warning(
- f'{command.name} failed ({HCI_Constant.error_name(status)})'
+ f'{command.name} failed '
+ f'({hci.HCI_Constant.error_name(status)})'
)
- raise HCI_Error(status)
+ raise hci.HCI_Error(status)
return response
except Exception as error:
@@ -374,13 +556,24 @@ class Host(AbortableEventEmitter):
self.pending_response = None
# Use this method to send a command from a task
- def send_command_sync(self, command: HCI_Command) -> None:
- async def send_command(command: HCI_Command) -> None:
+ def send_command_sync(self, command: hci.HCI_Command) -> None:
+ async def send_command(command: hci.HCI_Command) -> None:
await self.send_command(command)
asyncio.create_task(send_command(command))
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
+ if not (connection := self.connections.get(connection_handle)):
+ logger.warning(f'connection 0x{connection_handle:04X} not found')
+ return
+ packet_queue = connection.acl_packet_queue
+ if packet_queue is None:
+ logger.warning(
+ f'no ACL packet queue for connection 0x{connection_handle:04X}'
+ )
+ return
+
+ # Create a PDU
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
# Send the data to the controller via ACL packets
@@ -388,71 +581,39 @@ class Host(AbortableEventEmitter):
offset = 0
pb_flag = 0
while bytes_remaining:
- # TODO: support different LE/Classic lengths
- data_total_length = min(bytes_remaining, self.hc_le_acl_data_packet_length)
- acl_packet = HCI_AclDataPacket(
+ data_total_length = min(bytes_remaining, packet_queue.max_packet_size)
+ acl_packet = hci.HCI_AclDataPacket(
connection_handle=connection_handle,
pb_flag=pb_flag,
bc_flag=0,
data_total_length=data_total_length,
data=l2cap_pdu[offset : offset + data_total_length],
)
- logger.debug(
- f'{color("### HOST -> CONTROLLER", "blue")}: (CID={cid}) {acl_packet}'
- )
- self.queue_acl_packet(acl_packet)
+ logger.debug(f'>>> ACL packet enqueue: (CID={cid}) {acl_packet}')
+ packet_queue.enqueue(acl_packet)
pb_flag = 1
offset += data_total_length
bytes_remaining -= data_total_length
- def queue_acl_packet(self, acl_packet: HCI_AclDataPacket) -> None:
- self.acl_packet_queue.appendleft(acl_packet)
- self.check_acl_packet_queue()
-
- if len(self.acl_packet_queue):
- logger.debug(
- f'{self.acl_packets_in_flight} ACL packets in flight, '
- f'{len(self.acl_packet_queue)} in queue'
- )
-
- def check_acl_packet_queue(self) -> None:
- # Send all we can (TODO: support different LE/Classic limits)
- while (
- len(self.acl_packet_queue) > 0
- and self.acl_packets_in_flight < self.hc_total_num_le_acl_data_packets
- ):
- packet = self.acl_packet_queue.pop()
- self.send_hci_packet(packet)
- self.acl_packets_in_flight += 1
-
- def supports_command(self, command):
- # Find the support flag position for this command
- for octet, flags in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS):
- for flag_position, value in enumerate(flags):
- if value == command:
- # Check if the flag is set
- if octet < len(self.local_supported_commands) and flag_position < 8:
- return (
- self.local_supported_commands[octet] & (1 << flag_position)
- ) != 0
-
- return False
+ def supports_command(self, op_code: int) -> bool:
+ return (
+ self.local_supported_commands
+ & hci.HCI_SUPPORTED_COMMANDS_MASKS.get(op_code, 0)
+ ) != 0
@property
- def supported_commands(self):
- commands = []
- for octet, flags in enumerate(self.local_supported_commands):
- if octet < len(HCI_SUPPORTED_COMMANDS_FLAGS):
- for flag in range(8):
- if flags & (1 << flag) != 0:
- command = HCI_SUPPORTED_COMMANDS_FLAGS[octet][flag]
- if command is not None:
- commands.append(command)
+ def supported_commands(self) -> Set[int]:
+ return set(
+ op_code
+ for op_code, mask in hci.HCI_SUPPORTED_COMMANDS_MASKS.items()
+ if self.local_supported_commands & mask
+ )
- return commands
+ def supports_le_features(self, feature: hci.LeFeatureMask) -> bool:
+ return (self.local_le_features & feature) == feature
- def supports_le_feature(self, feature):
- return (self.local_le_features & (1 << feature)) != 0
+ def supports_lmp_features(self, feature: hci.LmpFeatureMask) -> bool:
+ return self.local_lmp_features & (feature) == feature
@property
def supported_le_features(self):
@@ -462,10 +623,10 @@ class Host(AbortableEventEmitter):
# Packet Sink protocol (packets coming from the controller via HCI)
def on_packet(self, packet: bytes) -> None:
- hci_packet = HCI_Packet.from_bytes(packet)
+ hci_packet = hci.HCI_Packet.from_bytes(packet)
if self.ready or (
- isinstance(hci_packet, HCI_Command_Complete_Event)
- and hci_packet.command_opcode == HCI_RESET_COMMAND
+ isinstance(hci_packet, hci.HCI_Command_Complete_Event)
+ and hci_packet.command_opcode == hci.HCI_RESET_COMMAND
):
self.on_hci_packet(hci_packet)
else:
@@ -478,35 +639,47 @@ class Host(AbortableEventEmitter):
self.emit('flush')
- def on_hci_packet(self, packet: HCI_Packet) -> None:
+ def on_hci_packet(self, packet: hci.HCI_Packet) -> None:
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
if self.snooper:
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
# If the packet is a command, invoke the handler for this packet
- if isinstance(packet, HCI_Command):
- self.on_hci_command_packet(packet)
- elif isinstance(packet, HCI_Event):
- self.on_hci_event_packet(packet)
- elif isinstance(packet, HCI_AclDataPacket):
- self.on_hci_acl_data_packet(packet)
+ if packet.hci_packet_type == hci.HCI_COMMAND_PACKET:
+ self.on_hci_command_packet(cast(hci.HCI_Command, packet))
+ elif packet.hci_packet_type == hci.HCI_EVENT_PACKET:
+ self.on_hci_event_packet(cast(hci.HCI_Event, packet))
+ elif packet.hci_packet_type == hci.HCI_ACL_DATA_PACKET:
+ self.on_hci_acl_data_packet(cast(hci.HCI_AclDataPacket, packet))
+ elif packet.hci_packet_type == hci.HCI_SYNCHRONOUS_DATA_PACKET:
+ self.on_hci_sco_data_packet(cast(hci.HCI_SynchronousDataPacket, packet))
+ elif packet.hci_packet_type == hci.HCI_ISO_DATA_PACKET:
+ self.on_hci_iso_data_packet(cast(hci.HCI_IsoDataPacket, packet))
else:
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
- def on_hci_command_packet(self, command: HCI_Command) -> None:
+ def on_hci_command_packet(self, command: hci.HCI_Command) -> None:
logger.warning(f'!!! unexpected command packet: {command}')
- def on_hci_event_packet(self, event: HCI_Event) -> None:
+ def on_hci_event_packet(self, event: hci.HCI_Event) -> None:
handler_name = f'on_{event.name.lower()}'
handler = getattr(self, handler_name, self.on_hci_event)
handler(event)
- def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
+ def on_hci_acl_data_packet(self, packet: hci.HCI_AclDataPacket) -> None:
# Look for the connection to which this data belongs
if connection := self.connections.get(packet.connection_handle):
connection.on_hci_acl_data_packet(packet)
+ def on_hci_sco_data_packet(self, packet: hci.HCI_SynchronousDataPacket) -> None:
+ # Experimental
+ self.emit('sco_packet', packet.connection_handle, packet)
+
+ def on_hci_iso_data_packet(self, packet: hci.HCI_IsoDataPacket) -> None:
+ # Experimental
+ self.emit('iso_packet', packet.connection_handle, packet)
+
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
self.emit('l2cap_pdu', connection.handle, cid, pdu)
@@ -535,7 +708,7 @@ class Host(AbortableEventEmitter):
# This is used just for the Num_HCI_Command_Packets field, not related to
# an actual command
logger.debug('no-command event')
- return None
+ return
return self.on_command_processed(event)
@@ -543,18 +716,17 @@ class Host(AbortableEventEmitter):
return self.on_command_processed(event)
def on_hci_number_of_completed_packets_event(self, event):
- total_packets = sum(event.num_completed_packets)
- if total_packets <= self.acl_packets_in_flight:
- self.acl_packets_in_flight -= total_packets
- self.check_acl_packet_queue()
- else:
- logger.warning(
- color(
- '!!! {total_packets} completed but only '
- f'{self.acl_packets_in_flight} in flight'
+ for connection_handle, num_completed_packets in zip(
+ event.connection_handles, event.num_completed_packets
+ ):
+ if not (connection := self.connections.get(connection_handle)):
+ logger.warning(
+ 'received packet completion event for unknown handle '
+ f'0x{connection_handle:04X}'
)
- )
- self.acl_packets_in_flight = 0
+ continue
+
+ connection.acl_packet_queue.on_packets_completed(num_completed_packets)
# Classic only
def on_hci_connection_request_event(self, event):
@@ -568,11 +740,11 @@ class Host(AbortableEventEmitter):
def on_hci_le_connection_complete_event(self, event):
# Check if this is a cancellation
- if event.status == HCI_SUCCESS:
+ if event.status == hci.HCI_SUCCESS:
# Create/update the connection
logger.debug(
f'### LE CONNECTION: [0x{event.connection_handle:04X}] '
- f'{event.peer_address} as {HCI_Constant.role_name(event.role)}'
+ f'{event.peer_address} as {hci.HCI_Constant.role_name(event.role)}'
)
connection = self.connections.get(event.connection_handle)
@@ -612,7 +784,7 @@ class Host(AbortableEventEmitter):
self.on_hci_le_connection_complete_event(event)
def on_hci_connection_complete_event(self, event):
- if event.status == HCI_SUCCESS:
+ if event.status == hci.HCI_SUCCESS:
# Create/update the connection
logger.debug(
f'### BR/EDR CONNECTION: [0x{event.connection_handle:04X}] '
@@ -648,25 +820,38 @@ class Host(AbortableEventEmitter):
def on_hci_disconnection_complete_event(self, event):
# Find the connection
- if (connection := self.connections.get(event.connection_handle)) is None:
+ handle = event.connection_handle
+ if (
+ connection := (
+ self.connections.get(handle)
+ or self.cis_links.get(handle)
+ or self.sco_links.get(handle)
+ )
+ ) is None:
logger.warning('!!! DISCONNECTION COMPLETE: unknown handle')
return
- if event.status == HCI_SUCCESS:
+ if event.status == hci.HCI_SUCCESS:
logger.debug(
- f'### DISCONNECTION: [0x{event.connection_handle:04X}] '
+ f'### DISCONNECTION: [0x{handle:04X}] '
f'{connection.peer_address} '
f'reason={event.reason}'
)
- del self.connections[event.connection_handle]
# Notify the listeners
- self.emit('disconnection', event.connection_handle, event.reason)
+ self.emit('disconnection', handle, event.reason)
+
+ # Remove the handle reference
+ _ = (
+ self.connections.pop(handle, 0)
+ or self.cis_links.pop(handle, 0)
+ or self.sco_links.pop(handle, 0)
+ )
else:
logger.debug(f'### DISCONNECTION FAILED: {event.status}')
# Notify the listeners
- self.emit('disconnection_failure', event.connection_handle, event.status)
+ self.emit('disconnection_failure', handle, event.status)
def on_hci_le_connection_update_complete_event(self, event):
if (connection := self.connections.get(event.connection_handle)) is None:
@@ -674,7 +859,7 @@ class Host(AbortableEventEmitter):
return
# Notify the client
- if event.status == HCI_SUCCESS:
+ if event.status == hci.HCI_SUCCESS:
connection_parameters = ConnectionParameters(
event.connection_interval,
event.peripheral_latency,
@@ -694,7 +879,7 @@ class Host(AbortableEventEmitter):
return
# Notify the client
- if event.status == HCI_SUCCESS:
+ if event.status == hci.HCI_SUCCESS:
connection_phy = ConnectionPHY(event.tx_phy, event.rx_phy)
self.emit('connection_phy_update', connection.handle, connection_phy)
else:
@@ -707,6 +892,37 @@ class Host(AbortableEventEmitter):
def on_hci_le_extended_advertising_report_event(self, event):
self.on_hci_le_advertising_report_event(event)
+ def on_hci_le_advertising_set_terminated_event(self, event):
+ self.emit(
+ 'advertising_set_termination',
+ event.status,
+ event.advertising_handle,
+ event.connection_handle,
+ event.num_completed_extended_advertising_events,
+ )
+
+ def on_hci_le_cis_request_event(self, event):
+ self.emit(
+ 'cis_request',
+ event.acl_connection_handle,
+ event.cis_connection_handle,
+ event.cig_id,
+ event.cis_id,
+ )
+
+ def on_hci_le_cis_established_event(self, event):
+ # The remaining parameters are unused for now.
+ if event.status == hci.HCI_SUCCESS:
+ self.cis_links[event.connection_handle] = CisLink(
+ handle=event.connection_handle,
+ peer_address=hci.Address.ANY,
+ )
+ self.emit('cis_establishment', event.connection_handle)
+ else:
+ self.emit(
+ 'cis_establishment_failure', event.connection_handle, event.status
+ )
+
def on_hci_le_remote_connection_parameter_request_event(self, event):
if event.connection_handle not in self.connections:
logger.warning('!!! REMOTE CONNECTION PARAMETER REQUEST: unknown handle')
@@ -715,7 +931,7 @@ class Host(AbortableEventEmitter):
# For now, just accept everything
# TODO: delegate the decision
self.send_command_sync(
- HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(
+ hci.HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(
connection_handle=event.connection_handle,
interval_min=event.interval_min,
interval_max=event.interval_max,
@@ -746,12 +962,12 @@ class Host(AbortableEventEmitter):
),
)
if long_term_key:
- response = HCI_LE_Long_Term_Key_Request_Reply_Command(
+ response = hci.HCI_LE_Long_Term_Key_Request_Reply_Command(
connection_handle=event.connection_handle,
long_term_key=long_term_key,
)
else:
- response = HCI_LE_Long_Term_Key_Request_Negative_Reply_Command(
+ response = hci.HCI_LE_Long_Term_Key_Request_Negative_Reply_Command(
connection_handle=event.connection_handle
)
@@ -760,22 +976,45 @@ class Host(AbortableEventEmitter):
asyncio.create_task(send_long_term_key())
def on_hci_synchronous_connection_complete_event(self, event):
- pass
+ if event.status == hci.HCI_SUCCESS:
+ # Create/update the connection
+ logger.debug(
+ f'### SCO CONNECTION: [0x{event.connection_handle:04X}] '
+ f'{event.bd_addr}'
+ )
+
+ self.sco_links[event.connection_handle] = ScoLink(
+ peer_address=event.bd_addr,
+ handle=event.connection_handle,
+ )
+
+ # Notify the client
+ self.emit(
+ 'sco_connection',
+ event.bd_addr,
+ event.connection_handle,
+ event.link_type,
+ )
+ else:
+ logger.debug(f'### SCO CONNECTION FAILED: {event.status}')
+
+ # Notify the client
+ self.emit('sco_connection_failure', event.bd_addr, event.status)
def on_hci_synchronous_connection_changed_event(self, event):
pass
def on_hci_role_change_event(self, event):
- if event.status == HCI_SUCCESS:
+ if event.status == hci.HCI_SUCCESS:
logger.debug(
f'role change for {event.bd_addr}: '
- f'{HCI_Constant.role_name(event.new_role)}'
+ f'{hci.HCI_Constant.role_name(event.new_role)}'
)
self.emit('role_change', event.bd_addr, event.new_role)
else:
logger.debug(
f'role change for {event.bd_addr} failed: '
- f'{HCI_Constant.error_name(event.status)}'
+ f'{hci.HCI_Constant.error_name(event.status)}'
)
self.emit('role_change_failure', event.bd_addr, event.status)
@@ -791,7 +1030,7 @@ class Host(AbortableEventEmitter):
def on_hci_authentication_complete_event(self, event):
# Notify the client
- if event.status == HCI_SUCCESS:
+ if event.status == hci.HCI_SUCCESS:
self.emit('connection_authentication', event.connection_handle)
else:
self.emit(
@@ -802,7 +1041,7 @@ class Host(AbortableEventEmitter):
def on_hci_encryption_change_event(self, event):
# Notify the client
- if event.status == HCI_SUCCESS:
+ if event.status == hci.HCI_SUCCESS:
self.emit(
'connection_encryption_change',
event.connection_handle,
@@ -815,7 +1054,7 @@ class Host(AbortableEventEmitter):
def on_hci_encryption_key_refresh_complete_event(self, event):
# Notify the client
- if event.status == HCI_SUCCESS:
+ if event.status == hci.HCI_SUCCESS:
self.emit('connection_encryption_key_refresh', event.connection_handle)
else:
self.emit(
@@ -836,16 +1075,16 @@ class Host(AbortableEventEmitter):
def on_hci_link_key_notification_event(self, event):
logger.debug(
f'link key for {event.bd_addr}: {event.link_key.hex()}, '
- f'type={HCI_Constant.link_key_type_name(event.key_type)}'
+ f'type={hci.HCI_Constant.link_key_type_name(event.key_type)}'
)
self.emit('link_key', event.bd_addr, event.link_key, event.key_type)
def on_hci_simple_pairing_complete_event(self, event):
logger.debug(
f'simple pairing complete for {event.bd_addr}: '
- f'status={HCI_Constant.status_name(event.status)}'
+ f'status={hci.HCI_Constant.status_name(event.status)}'
)
- if event.status == HCI_SUCCESS:
+ if event.status == hci.HCI_SUCCESS:
self.emit('classic_pairing', event.bd_addr)
else:
self.emit('classic_pairing_failure', event.bd_addr, event.status)
@@ -865,11 +1104,11 @@ class Host(AbortableEventEmitter):
self.link_key_provider(event.bd_addr),
)
if link_key:
- response = HCI_Link_Key_Request_Reply_Command(
+ response = hci.HCI_Link_Key_Request_Reply_Command(
bd_addr=event.bd_addr, link_key=link_key
)
else:
- response = HCI_Link_Key_Request_Negative_Reply_Command(
+ response = hci.HCI_Link_Key_Request_Negative_Reply_Command(
bd_addr=event.bd_addr
)
@@ -926,7 +1165,7 @@ class Host(AbortableEventEmitter):
)
def on_hci_remote_name_request_complete_event(self, event):
- if event.status != HCI_SUCCESS:
+ if event.status != hci.HCI_SUCCESS:
self.emit('remote_name_failure', event.bd_addr, event.status)
else:
utf8_name = event.remote_name
@@ -942,3 +1181,15 @@ class Host(AbortableEventEmitter):
event.bd_addr,
event.host_supported_features,
)
+
+ def on_hci_le_read_remote_features_complete_event(self, event):
+ if event.status != hci.HCI_SUCCESS:
+ self.emit(
+ 'le_remote_features_failure', event.connection_handle, event.status
+ )
+ else:
+ self.emit(
+ 'le_remote_features',
+ event.connection_handle,
+ int.from_bytes(event.le_features, 'little'),
+ )
diff --git a/bumble/l2cap.py b/bumble/l2cap.py
index 7a2f0ed..cec14b8 100644
--- a/bumble/l2cap.py
+++ b/bumble/l2cap.py
@@ -149,9 +149,10 @@ L2CAP_INVALID_CID_IN_REQUEST_REASON = 0x0002
L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS = 65535
L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU = 23
+L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU = 65535
L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS = 23
L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS = 65533
-L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU = 2046
+L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU = 2048
L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS = 2048
L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS = 256
@@ -172,7 +173,7 @@ L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE = 0x01
@dataclasses.dataclass
class ClassicChannelSpec:
psm: Optional[int] = None
- mtu: int = L2CAP_MIN_BR_EDR_MTU
+ mtu: int = L2CAP_DEFAULT_MTU
@dataclasses.dataclass
@@ -188,8 +189,11 @@ class LeCreditBasedChannelSpec:
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
):
raise ValueError('max credits out of range')
- if self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
- raise ValueError('MTU too small')
+ if (
+ self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU
+ or self.mtu > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU
+ ):
+ raise ValueError('MTU out of range')
if (
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
@@ -204,7 +208,7 @@ class L2CAP_PDU:
@staticmethod
def from_bytes(data: bytes) -> L2CAP_PDU:
- # Sanity check
+ # Check parameters
if len(data) < 4:
raise ValueError('not enough data for L2CAP header')
@@ -391,6 +395,9 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame):
See Bluetooth spec @ Vol 3, Part A - 4.2 CONNECTION REQUEST
'''
+ psm: int
+ source_cid: int
+
@staticmethod
def parse_psm(data: bytes, offset: int = 0) -> Tuple[int, int]:
psm_length = 2
@@ -432,6 +439,11 @@ class L2CAP_Connection_Response(L2CAP_Control_Frame):
See Bluetooth spec @ Vol 3, Part A - 4.3 CONNECTION RESPONSE
'''
+ source_cid: int
+ destination_cid: int
+ status: int
+ result: int
+
CONNECTION_SUCCESSFUL = 0x0000
CONNECTION_PENDING = 0x0001
CONNECTION_REFUSED_PSM_NOT_SUPPORTED = 0x0002
@@ -737,6 +749,8 @@ class ClassicChannel(EventEmitter):
sink: Optional[Callable[[bytes], Any]]
state: State
connection: Connection
+ mtu: int
+ peer_mtu: int
def __init__(
self,
@@ -753,6 +767,7 @@ class ClassicChannel(EventEmitter):
self.signaling_cid = signaling_cid
self.state = self.State.CLOSED
self.mtu = mtu
+ self.peer_mtu = L2CAP_MIN_BR_EDR_MTU
self.psm = psm
self.source_cid = source_cid
self.destination_cid = 0
@@ -849,7 +864,7 @@ class ClassicChannel(EventEmitter):
[
(
L2CAP_MAXIMUM_TRANSMISSION_UNIT_CONFIGURATION_OPTION_TYPE,
- struct.pack('<H', L2CAP_DEFAULT_MTU),
+ struct.pack('<H', self.mtu),
)
]
)
@@ -914,8 +929,8 @@ class ClassicChannel(EventEmitter):
options = L2CAP_Control_Frame.decode_configuration_options(request.options)
for option in options:
if option[0] == L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE:
- self.mtu = struct.unpack('<H', option[1])[0]
- logger.debug(f'MTU = {self.mtu}')
+ self.peer_mtu = struct.unpack('<H', option[1])[0]
+ logger.debug(f'peer MTU = {self.peer_mtu}')
self.send_control_frame(
L2CAP_Configure_Response(
@@ -1014,7 +1029,7 @@ class ClassicChannel(EventEmitter):
return (
f'Channel({self.source_cid}->{self.destination_cid}, '
f'PSM={self.psm}, '
- f'MTU={self.mtu}, '
+ f'MTU={self.mtu}/{self.peer_mtu}, '
f'state={self.state.name})'
)
@@ -1636,12 +1651,13 @@ class ChannelManager:
def send_pdu(self, connection, cid: int, pdu: Union[SupportsBytes, bytes]) -> None:
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
+ pdu_bytes = bytes(pdu)
logger.debug(
f'{color(">>> Sending L2CAP PDU", "blue")} '
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
- f'{connection.peer_address}: {pdu_str}'
+ f'{connection.peer_address}: {len(pdu_bytes)} bytes, {pdu_str}'
)
- self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
+ self.host.send_l2cap_pdu(connection.handle, cid, pdu_bytes)
def on_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
@@ -1918,7 +1934,7 @@ class ChannelManager:
supervision_timeout=request.timeout,
min_ce_length=0,
max_ce_length=0,
- ) # type: ignore[call-arg]
+ )
)
else:
self.send_control_frame(
diff --git a/bumble/link.py b/bumble/link.py
index 85ad96e..5ef56b7 100644
--- a/bumble/link.py
+++ b/bumble/link.py
@@ -26,9 +26,13 @@ from bumble.hci import (
HCI_SUCCESS,
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
HCI_CONNECTION_TIMEOUT_ERROR,
+ HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
HCI_PAGE_TIMEOUT_ERROR,
HCI_Connection_Complete_Event,
)
+from bumble import controller
+
+from typing import Optional, Set
# -----------------------------------------------------------------------------
# Logging
@@ -57,6 +61,8 @@ class LocalLink:
Link bus for controllers to communicate with each other
'''
+ controllers: Set[controller.Controller]
+
def __init__(self):
self.controllers = set()
self.pending_connection = None
@@ -79,7 +85,9 @@ class LocalLink:
return controller
return None
- def find_classic_controller(self, address):
+ def find_classic_controller(
+ self, address: Address
+ ) -> Optional[controller.Controller]:
for controller in self.controllers:
if controller.public_address == address:
return controller
@@ -188,6 +196,60 @@ class LocalLink:
if peripheral_controller := self.find_controller(peripheral_address):
peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk)
+ def create_cis(
+ self,
+ central_controller: controller.Controller,
+ peripheral_address: Address,
+ cig_id: int,
+ cis_id: int,
+ ) -> None:
+ logger.debug(
+ f'$$$ CIS Request {central_controller.random_address} -> {peripheral_address}'
+ )
+ if peripheral_controller := self.find_controller(peripheral_address):
+ asyncio.get_running_loop().call_soon(
+ peripheral_controller.on_link_cis_request,
+ central_controller.random_address,
+ cig_id,
+ cis_id,
+ )
+
+ def accept_cis(
+ self,
+ peripheral_controller: controller.Controller,
+ central_address: Address,
+ cig_id: int,
+ cis_id: int,
+ ) -> None:
+ logger.debug(
+ f'$$$ CIS Accept {peripheral_controller.random_address} -> {central_address}'
+ )
+ if central_controller := self.find_controller(central_address):
+ asyncio.get_running_loop().call_soon(
+ central_controller.on_link_cis_established, cig_id, cis_id
+ )
+ asyncio.get_running_loop().call_soon(
+ peripheral_controller.on_link_cis_established, cig_id, cis_id
+ )
+
+ def disconnect_cis(
+ self,
+ initiator_controller: controller.Controller,
+ peer_address: Address,
+ cig_id: int,
+ cis_id: int,
+ ) -> None:
+ logger.debug(
+ f'$$$ CIS Disconnect {initiator_controller.random_address} -> {peer_address}'
+ )
+ if peer_controller := self.find_controller(peer_address):
+ asyncio.get_running_loop().call_soon(
+ initiator_controller.on_link_cis_disconnected, cig_id, cis_id
+ )
+ asyncio.get_running_loop().call_soon(
+ peer_controller.on_link_cis_disconnected, cig_id, cis_id
+ )
+
############################################################
# Classic handlers
############################################################
@@ -271,6 +333,52 @@ class LocalLink:
initiator_controller.public_address, int(not (initiator_new_role))
)
+ def classic_sco_connect(
+ self,
+ initiator_controller: controller.Controller,
+ responder_address: Address,
+ link_type: int,
+ ):
+ logger.debug(
+ f'[Classic] {initiator_controller.public_address} connects SCO to {responder_address}'
+ )
+ responder_controller = self.find_classic_controller(responder_address)
+ # Initiator controller should handle it.
+ assert responder_controller
+
+ responder_controller.on_classic_connection_request(
+ initiator_controller.public_address,
+ link_type,
+ )
+
+ def classic_accept_sco_connection(
+ self,
+ responder_controller: controller.Controller,
+ initiator_address: Address,
+ link_type: int,
+ ):
+ logger.debug(
+ f'[Classic] {responder_controller.public_address} accepts to connect SCO {initiator_address}'
+ )
+ initiator_controller = self.find_classic_controller(initiator_address)
+ if initiator_controller is None:
+ responder_controller.on_classic_sco_connection_complete(
+ responder_controller.public_address,
+ HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
+ link_type,
+ )
+ return
+
+ async def task():
+ initiator_controller.on_classic_sco_connection_complete(
+ responder_controller.public_address, HCI_SUCCESS, link_type
+ )
+
+ asyncio.create_task(task())
+ responder_controller.on_classic_sco_connection_complete(
+ initiator_controller.public_address, HCI_SUCCESS, link_type
+ )
+
# -----------------------------------------------------------------------------
class RemoteLink:
diff --git a/bumble/pairing.py b/bumble/pairing.py
index 877b739..5614e84 100644
--- a/bumble/pairing.py
+++ b/bumble/pairing.py
@@ -15,7 +15,9 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
import enum
+from dataclasses import dataclass
from typing import Optional, Tuple
from .hci import (
@@ -35,7 +37,60 @@ from .smp import (
SMP_ID_KEY_DISTRIBUTION_FLAG,
SMP_SIGN_KEY_DISTRIBUTION_FLAG,
SMP_LINK_KEY_DISTRIBUTION_FLAG,
+ OobContext,
+ OobLegacyContext,
+ OobSharedData,
)
+from .core import AdvertisingData, LeRole
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class OobData:
+ """OOB data that can be sent from one device to another."""
+
+ address: Optional[Address] = None
+ role: Optional[LeRole] = None
+ shared_data: Optional[OobSharedData] = None
+ legacy_context: Optional[OobLegacyContext] = None
+
+ @classmethod
+ def from_ad(cls, ad: AdvertisingData) -> OobData:
+ instance = cls()
+ shared_data_c: Optional[bytes] = None
+ shared_data_r: Optional[bytes] = None
+ for ad_type, ad_data in ad.ad_structures:
+ if ad_type == AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS:
+ instance.address = Address(ad_data)
+ elif ad_type == AdvertisingData.LE_ROLE:
+ instance.role = LeRole(ad_data[0])
+ elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE:
+ shared_data_c = ad_data
+ elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_RANDOM_VALUE:
+ shared_data_r = ad_data
+ elif ad_type == AdvertisingData.SECURITY_MANAGER_TK_VALUE:
+ instance.legacy_context = OobLegacyContext(tk=ad_data)
+ if shared_data_c and shared_data_r:
+ instance.shared_data = OobSharedData(c=shared_data_c, r=shared_data_r)
+
+ return instance
+
+ def to_ad(self) -> AdvertisingData:
+ ad_structures = []
+ if self.address is not None:
+ ad_structures.append(
+ (AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS, bytes(self.address))
+ )
+ if self.role is not None:
+ ad_structures.append((AdvertisingData.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)
+ )
+
+ return AdvertisingData(ad_structures)
# -----------------------------------------------------------------------------
@@ -173,6 +228,14 @@ class PairingConfig:
PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
RANDOM = Address.RANDOM_DEVICE_ADDRESS
+ @dataclass
+ class OobConfig:
+ """Config for OOB pairing."""
+
+ our_context: Optional[OobContext]
+ peer_data: Optional[OobSharedData]
+ legacy_context: Optional[OobLegacyContext]
+
def __init__(
self,
sc: bool = True,
@@ -180,17 +243,20 @@ class PairingConfig:
bonding: bool = True,
delegate: Optional[PairingDelegate] = None,
identity_address_type: Optional[AddressType] = None,
+ oob: Optional[OobConfig] = None,
) -> None:
self.sc = sc
self.mitm = mitm
self.bonding = bonding
self.delegate = delegate or PairingDelegate()
self.identity_address_type = identity_address_type
+ self.oob = oob
def __str__(self) -> str:
return (
f'PairingConfig(sc={self.sc}, '
f'mitm={self.mitm}, bonding={self.bonding}, '
f'identity_address_type={self.identity_address_type}, '
- f'delegate[{self.delegate.io_capability}])'
+ f'delegate[{self.delegate.io_capability}]), '
+ f'oob[{self.oob}])'
)
diff --git a/bumble/pandora/config.py b/bumble/pandora/config.py
index fa448b8..e68abae 100644
--- a/bumble/pandora/config.py
+++ b/bumble/pandora/config.py
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
from bumble.pairing import PairingConfig, PairingDelegate
from dataclasses import dataclass
from typing import Any, Dict
diff --git a/bumble/pandora/device.py b/bumble/pandora/device.py
index 9173900..4b0f7f2 100644
--- a/bumble/pandora/device.py
+++ b/bumble/pandora/device.py
@@ -14,6 +14,7 @@
"""Generic & dependency free Bumble (reference) device."""
+from __future__ import annotations
from bumble import transport
from bumble.core import (
BT_GENERIC_AUDIO_SERVICE,
diff --git a/bumble/pandora/host.py b/bumble/pandora/host.py
index 9e6e4b5..e54d2d5 100644
--- a/bumble/pandora/host.py
+++ b/bumble/pandora/host.py
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
import asyncio
import bumble.device
import grpc
@@ -33,8 +34,11 @@ from bumble.device import (
DEVICE_DEFAULT_SCAN_INTERVAL,
DEVICE_DEFAULT_SCAN_WINDOW,
Advertisement,
+ AdvertisingParameters,
+ AdvertisingEventProperties,
AdvertisingType,
Device,
+ Phy,
)
from bumble.gatt import Service
from bumble.hci import (
@@ -46,9 +50,12 @@ from bumble.hci import (
from google.protobuf import any_pb2 # pytype: disable=pyi-error
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
from pandora.host_grpc_aio import HostServicer
+from pandora import host_pb2
from pandora.host_pb2 import (
NOT_CONNECTABLE,
NOT_DISCOVERABLE,
+ DISCOVERABLE_LIMITED,
+ DISCOVERABLE_GENERAL,
PRIMARY_1M,
PRIMARY_CODED,
SECONDARY_1M,
@@ -64,6 +71,7 @@ from pandora.host_pb2 import (
ConnectResponse,
DataTypes,
DisconnectRequest,
+ DiscoverabilityMode,
InquiryResponse,
PrimaryPhy,
ReadLocalAddressResponse,
@@ -93,6 +101,25 @@ SECONDARY_PHY_MAP: Dict[int, SecondaryPhy] = {
3: SECONDARY_CODED,
}
+PRIMARY_PHY_TO_BUMBLE_PHY_MAP: Dict[PrimaryPhy, Phy] = {
+ PRIMARY_1M: Phy.LE_1M,
+ PRIMARY_CODED: Phy.LE_CODED,
+}
+
+SECONDARY_PHY_TO_BUMBLE_PHY_MAP: Dict[SecondaryPhy, Phy] = {
+ SECONDARY_NONE: Phy.LE_1M,
+ SECONDARY_1M: Phy.LE_1M,
+ SECONDARY_2M: Phy.LE_2M,
+ 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,
+}
+
class HostService(HostServicer):
waited_connections: Set[int]
@@ -280,14 +307,118 @@ class HostService(HostServicer):
async def Advertise(
self, request: AdvertiseRequest, context: grpc.ServicerContext
) -> AsyncGenerator[AdvertiseResponse, None]:
- if not request.legacy:
- raise NotImplementedError(
- "TODO: add support for extended advertising in Bumble"
- )
- if request.interval:
- raise NotImplementedError("TODO: add support for `request.interval`")
- if request.interval_range:
- raise NotImplementedError("TODO: add support for `request.interval_range`")
+ try:
+ if request.legacy:
+ async for rsp in self.legacy_advertise(request, context):
+ yield rsp
+ else:
+ async for rsp in self.extended_advertise(request, context):
+ yield rsp
+ finally:
+ pass
+
+ async def extended_advertise(
+ self, request: AdvertiseRequest, context: grpc.ServicerContext
+ ) -> AsyncGenerator[AdvertiseResponse, None]:
+ advertising_data = bytes(self.unpack_data_types(request.data))
+ scan_response_data = bytes(self.unpack_data_types(request.scan_response_data))
+ scannable = len(scan_response_data) != 0
+
+ advertising_event_properties = AdvertisingEventProperties(
+ is_connectable=request.connectable,
+ is_scannable=scannable,
+ is_directed=request.target is not None,
+ is_high_duty_cycle_directed_connectable=False,
+ is_legacy=False,
+ is_anonymous=False,
+ include_tx_power=False,
+ )
+
+ peer_address = Address.ANY
+ if request.target:
+ # Need to reverse bytes order since Bumble Address is using MSB.
+ target_bytes = bytes(reversed(request.target))
+ if request.target_variant() == "public":
+ peer_address = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
+ else:
+ peer_address = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
+
+ advertising_parameters = AdvertisingParameters(
+ advertising_event_properties=advertising_event_properties,
+ own_address_type=OWN_ADDRESS_MAP[request.own_address_type],
+ peer_address=peer_address,
+ primary_advertising_phy=PRIMARY_PHY_TO_BUMBLE_PHY_MAP[request.primary_phy],
+ secondary_advertising_phy=SECONDARY_PHY_TO_BUMBLE_PHY_MAP[
+ request.secondary_phy
+ ],
+ )
+ if advertising_interval := request.interval:
+ advertising_parameters.primary_advertising_interval_min = int(
+ advertising_interval
+ )
+ advertising_parameters.primary_advertising_interval_max = int(
+ advertising_interval
+ )
+ if interval_range := request.interval_range:
+ advertising_parameters.primary_advertising_interval_max += int(
+ interval_range
+ )
+
+ advertising_set = await self.device.create_advertising_set(
+ advertising_parameters=advertising_parameters,
+ advertising_data=advertising_data,
+ scan_response_data=scan_response_data,
+ )
+
+ pending_connection: asyncio.Future[
+ bumble.device.Connection
+ ] = asyncio.get_running_loop().create_future()
+
+ if request.connectable:
+
+ def on_connection(connection: bumble.device.Connection) -> None:
+ if (
+ connection.transport == BT_LE_TRANSPORT
+ and connection.role == BT_PERIPHERAL_ROLE
+ ):
+ pending_connection.set_result(connection)
+
+ self.device.on('connection', on_connection)
+
+ try:
+ # Advertise until RPC is canceled
+ while True:
+ if not advertising_set.enabled:
+ self.log.debug('Advertise (extended)')
+ await advertising_set.start()
+
+ if not request.connectable:
+ await asyncio.sleep(1)
+ continue
+
+ connection = await pending_connection
+ pending_connection = asyncio.get_running_loop().create_future()
+
+ cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
+ yield AdvertiseResponse(connection=Connection(cookie=cookie))
+
+ await asyncio.sleep(1)
+ finally:
+ try:
+ self.log.debug('Stop Advertise (extended)')
+ await advertising_set.stop()
+ await advertising_set.remove()
+ except Exception:
+ pass
+
+ async def legacy_advertise(
+ self, request: AdvertiseRequest, context: grpc.ServicerContext
+ ) -> AsyncGenerator[AdvertiseResponse, None]:
+ if advertising_interval := request.interval:
+ self.device.config.advertising_interval_min = int(advertising_interval)
+ self.device.config.advertising_interval_max = int(advertising_interval)
+ if interval_range := request.interval_range:
+ self.device.config.advertising_interval_max += int(interval_range)
if request.primary_phy:
raise NotImplementedError("TODO: add support for `request.primary_phy`")
if request.secondary_phy:
@@ -355,14 +486,10 @@ class HostService(HostServicer):
target_bytes = bytes(reversed(request.target))
if request.target_variant() == "public":
target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
- advertising_type = (
- AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
- ) # FIXME: HIGH_DUTY ?
+ advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
else:
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
- advertising_type = (
- AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
- ) # FIXME: HIGH_DUTY ?
+ advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
if request.connectable:
@@ -420,11 +547,16 @@ class HostService(HostServicer):
self, request: ScanRequest, context: grpc.ServicerContext
) -> AsyncGenerator[ScanningResponse, None]:
# TODO: modify `start_scanning` to accept floats instead of int for ms values
- if request.phys:
- raise NotImplementedError("TODO: add support for `request.phys`")
-
self.log.debug('Scan')
+ scanning_phys = []
+ if PRIMARY_1M in request.phys:
+ scanning_phys.append(int(Phy.LE_1M))
+ if PRIMARY_CODED in request.phys:
+ scanning_phys.append(int(Phy.LE_CODED))
+ if not scanning_phys:
+ 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)
await self.device.start_scanning(
@@ -437,6 +569,7 @@ class HostService(HostServicer):
scan_window=int(request.window)
if request.window
else DEVICE_DEFAULT_SCAN_WINDOW,
+ scanning_phys=scanning_phys,
)
try:
@@ -733,6 +866,16 @@ class HostService(HostServicer):
)
)
+ flag_map = {
+ NOT_DISCOVERABLE: 0x00,
+ DISCOVERABLE_LIMITED: AdvertisingData.LE_LIMITED_DISCOVERABLE_MODE_FLAG,
+ DISCOVERABLE_GENERAL: AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG,
+ }
+
+ if dt.le_discoverability_mode:
+ flags = flag_map[dt.le_discoverability_mode]
+ ad_structures.append((AdvertisingData.FLAGS, flags.to_bytes(1, 'big')))
+
return AdvertisingData(ad_structures)
def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
diff --git a/bumble/pandora/security.py b/bumble/pandora/security.py
index 85365e6..b36fb18 100644
--- a/bumble/pandora/security.py
+++ b/bumble/pandora/security.py
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
import asyncio
import contextlib
import grpc
@@ -109,7 +110,7 @@ class PairingDelegate(BasePairingDelegate):
event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty()))
self.service.event_queue.put_nowait(event)
- answer = await anext(self.service.event_answer) # pytype: disable=name-error
+ answer = await anext(self.service.event_answer) # type: ignore
assert answer.event == event
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
return answer.confirm
@@ -124,7 +125,7 @@ class PairingDelegate(BasePairingDelegate):
event = self.add_origin(PairingEvent(numeric_comparison=number))
self.service.event_queue.put_nowait(event)
- answer = await anext(self.service.event_answer) # pytype: disable=name-error
+ answer = await anext(self.service.event_answer) # type: ignore
assert answer.event == event
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
return answer.confirm
@@ -139,7 +140,7 @@ class PairingDelegate(BasePairingDelegate):
event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty()))
self.service.event_queue.put_nowait(event)
- answer = await anext(self.service.event_answer) # pytype: disable=name-error
+ answer = await anext(self.service.event_answer) # type: ignore
assert answer.event == event
if answer.answer_variant() is None:
return None
@@ -156,7 +157,7 @@ class PairingDelegate(BasePairingDelegate):
event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty()))
self.service.event_queue.put_nowait(event)
- answer = await anext(self.service.event_answer) # pytype: disable=name-error
+ answer = await anext(self.service.event_answer) # type: ignore
assert answer.event == event
if answer.answer_variant() is None:
return None
diff --git a/bumble/pandora/utils.py b/bumble/pandora/utils.py
index c07a5bc..fba4b72 100644
--- a/bumble/pandora/utils.py
+++ b/bumble/pandora/utils.py
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
import contextlib
import functools
import grpc
diff --git a/bumble/profiles/asha_service.py b/bumble/profiles/asha_service.py
index 412b28a..acbc47e 100644
--- a/bumble/profiles/asha_service.py
+++ b/bumble/profiles/asha_service.py
@@ -18,7 +18,7 @@
# -----------------------------------------------------------------------------
import struct
import logging
-from typing import List
+from typing import List, Optional
from bumble import l2cap
from ..core import AdvertisingData
@@ -67,7 +67,7 @@ class AshaService(TemplateService):
self.emit('volume', connection, value[0])
# Handler for audio control commands
- def on_audio_control_point_write(connection: Connection, value):
+ def on_audio_control_point_write(connection: Optional[Connection], value):
logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}')
opcode = value[0]
if opcode == AshaService.OPCODE_START:
diff --git a/bumble/profiles/bap.py b/bumble/profiles/bap.py
new file mode 100644
index 0000000..dd57f01
--- /dev/null
+++ b/bumble/profiles/bap.py
@@ -0,0 +1,1247 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+
+from collections.abc import Sequence
+import dataclasses
+import enum
+import struct
+import functools
+import logging
+from typing import Optional, List, Union, Type, Dict, Any, Tuple, cast
+
+from bumble import colors
+from bumble import device
+from bumble import hci
+from bumble import gatt
+from bumble import gatt_client
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+
+
+class AudioLocation(enum.IntFlag):
+ '''Bluetooth Assigned Numbers, Section 6.12.1 - Audio Location'''
+
+ # fmt: off
+ NOT_ALLOWED = 0x00000000
+ FRONT_LEFT = 0x00000001
+ FRONT_RIGHT = 0x00000002
+ FRONT_CENTER = 0x00000004
+ LOW_FREQUENCY_EFFECTS_1 = 0x00000008
+ BACK_LEFT = 0x00000010
+ BACK_RIGHT = 0x00000020
+ FRONT_LEFT_OF_CENTER = 0x00000040
+ FRONT_RIGHT_OF_CENTER = 0x00000080
+ BACK_CENTER = 0x00000100
+ LOW_FREQUENCY_EFFECTS_2 = 0x00000200
+ SIDE_LEFT = 0x00000400
+ SIDE_RIGHT = 0x00000800
+ TOP_FRONT_LEFT = 0x00001000
+ TOP_FRONT_RIGHT = 0x00002000
+ TOP_FRONT_CENTER = 0x00004000
+ TOP_CENTER = 0x00008000
+ TOP_BACK_LEFT = 0x00010000
+ TOP_BACK_RIGHT = 0x00020000
+ TOP_SIDE_LEFT = 0x00040000
+ TOP_SIDE_RIGHT = 0x00080000
+ TOP_BACK_CENTER = 0x00100000
+ BOTTOM_FRONT_CENTER = 0x00200000
+ BOTTOM_FRONT_LEFT = 0x00400000
+ BOTTOM_FRONT_RIGHT = 0x00800000
+ FRONT_LEFT_WIDE = 0x01000000
+ FRONT_RIGHT_WIDE = 0x02000000
+ LEFT_SURROUND = 0x04000000
+ RIGHT_SURROUND = 0x08000000
+
+
+class AudioInputType(enum.IntEnum):
+ '''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type'''
+
+ # fmt: off
+ UNSPECIFIED = 0x00
+ BLUETOOTH = 0x01
+ MICROPHONE = 0x02
+ ANALOG = 0x03
+ DIGITAL = 0x04
+ RADIO = 0x05
+ STREAMING = 0x06
+ AMBIENT = 0x07
+
+
+class ContextType(enum.IntFlag):
+ '''Bluetooth Assigned Numbers, Section 6.12.3 - Context Type'''
+
+ # fmt: off
+ PROHIBITED = 0x0000
+ CONVERSATIONAL = 0x0002
+ MEDIA = 0x0004
+ GAME = 0x0008
+ INSTRUCTIONAL = 0x0010
+ VOICE_ASSISTANTS = 0x0020
+ LIVE = 0x0040
+ SOUND_EFFECTS = 0x0080
+ NOTIFICATIONS = 0x0100
+ RINGTONE = 0x0200
+ ALERTS = 0x0400
+ EMERGENCY_ALARM = 0x0800
+
+
+class SamplingFrequency(enum.IntEnum):
+ '''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency'''
+
+ # fmt: off
+ FREQ_8000 = 0x01
+ FREQ_11025 = 0x02
+ FREQ_16000 = 0x03
+ FREQ_22050 = 0x04
+ FREQ_24000 = 0x05
+ FREQ_32000 = 0x06
+ FREQ_44100 = 0x07
+ FREQ_48000 = 0x08
+ FREQ_88200 = 0x09
+ FREQ_96000 = 0x0A
+ FREQ_176400 = 0x0B
+ FREQ_192000 = 0x0C
+ FREQ_384000 = 0x0D
+ # fmt: on
+
+ @classmethod
+ def from_hz(cls, frequency: int) -> SamplingFrequency:
+ return {
+ 8000: SamplingFrequency.FREQ_8000,
+ 11025: SamplingFrequency.FREQ_11025,
+ 16000: SamplingFrequency.FREQ_16000,
+ 22050: SamplingFrequency.FREQ_22050,
+ 24000: SamplingFrequency.FREQ_24000,
+ 32000: SamplingFrequency.FREQ_32000,
+ 44100: SamplingFrequency.FREQ_44100,
+ 48000: SamplingFrequency.FREQ_48000,
+ 88200: SamplingFrequency.FREQ_88200,
+ 96000: SamplingFrequency.FREQ_96000,
+ 176400: SamplingFrequency.FREQ_176400,
+ 192000: SamplingFrequency.FREQ_192000,
+ 384000: SamplingFrequency.FREQ_384000,
+ }[frequency]
+
+ @property
+ def hz(self) -> int:
+ return {
+ SamplingFrequency.FREQ_8000: 8000,
+ SamplingFrequency.FREQ_11025: 11025,
+ SamplingFrequency.FREQ_16000: 16000,
+ SamplingFrequency.FREQ_22050: 22050,
+ SamplingFrequency.FREQ_24000: 24000,
+ SamplingFrequency.FREQ_32000: 32000,
+ SamplingFrequency.FREQ_44100: 44100,
+ SamplingFrequency.FREQ_48000: 48000,
+ SamplingFrequency.FREQ_88200: 88200,
+ SamplingFrequency.FREQ_96000: 96000,
+ SamplingFrequency.FREQ_176400: 176400,
+ SamplingFrequency.FREQ_192000: 192000,
+ SamplingFrequency.FREQ_384000: 384000,
+ }[self]
+
+
+class SupportedSamplingFrequency(enum.IntFlag):
+ '''Bluetooth Assigned Numbers, Section 6.12.4.1 - Sample Frequency'''
+
+ # fmt: off
+ FREQ_8000 = 1 << (SamplingFrequency.FREQ_8000 - 1)
+ FREQ_11025 = 1 << (SamplingFrequency.FREQ_11025 - 1)
+ FREQ_16000 = 1 << (SamplingFrequency.FREQ_16000 - 1)
+ FREQ_22050 = 1 << (SamplingFrequency.FREQ_22050 - 1)
+ FREQ_24000 = 1 << (SamplingFrequency.FREQ_24000 - 1)
+ FREQ_32000 = 1 << (SamplingFrequency.FREQ_32000 - 1)
+ FREQ_44100 = 1 << (SamplingFrequency.FREQ_44100 - 1)
+ FREQ_48000 = 1 << (SamplingFrequency.FREQ_48000 - 1)
+ FREQ_88200 = 1 << (SamplingFrequency.FREQ_88200 - 1)
+ FREQ_96000 = 1 << (SamplingFrequency.FREQ_96000 - 1)
+ FREQ_176400 = 1 << (SamplingFrequency.FREQ_176400 - 1)
+ FREQ_192000 = 1 << (SamplingFrequency.FREQ_192000 - 1)
+ FREQ_384000 = 1 << (SamplingFrequency.FREQ_384000 - 1)
+ # fmt: on
+
+ @classmethod
+ def from_hz(cls, frequencies: Sequence[int]) -> SupportedSamplingFrequency:
+ MAPPING = {
+ 8000: SupportedSamplingFrequency.FREQ_8000,
+ 11025: SupportedSamplingFrequency.FREQ_11025,
+ 16000: SupportedSamplingFrequency.FREQ_16000,
+ 22050: SupportedSamplingFrequency.FREQ_22050,
+ 24000: SupportedSamplingFrequency.FREQ_24000,
+ 32000: SupportedSamplingFrequency.FREQ_32000,
+ 44100: SupportedSamplingFrequency.FREQ_44100,
+ 48000: SupportedSamplingFrequency.FREQ_48000,
+ 88200: SupportedSamplingFrequency.FREQ_88200,
+ 96000: SupportedSamplingFrequency.FREQ_96000,
+ 176400: SupportedSamplingFrequency.FREQ_176400,
+ 192000: SupportedSamplingFrequency.FREQ_192000,
+ 384000: SupportedSamplingFrequency.FREQ_384000,
+ }
+
+ return functools.reduce(
+ lambda x, y: x | MAPPING[y],
+ frequencies,
+ cls(0),
+ )
+
+
+class FrameDuration(enum.IntEnum):
+ '''Bluetooth Assigned Numbers, Section 6.12.5.2 - Frame Duration'''
+
+ # fmt: off
+ DURATION_7500_US = 0x00
+ DURATION_10000_US = 0x01
+
+
+class SupportedFrameDuration(enum.IntFlag):
+ '''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration'''
+
+ # fmt: off
+ DURATION_7500_US_SUPPORTED = 0b0001
+ DURATION_10000_US_SUPPORTED = 0b0010
+ DURATION_7500_US_PREFERRED = 0b0001
+ DURATION_10000_US_PREFERRED = 0b0010
+
+
+# -----------------------------------------------------------------------------
+# ASE Operations
+# -----------------------------------------------------------------------------
+
+
+class ASE_Operation:
+ '''
+ See Audio Stream Control Service - 5 ASE Control operations.
+ '''
+
+ classes: Dict[int, Type[ASE_Operation]] = {}
+ op_code: int
+ name: str
+ fields: Optional[Sequence[Any]] = None
+ ase_id: List[int]
+
+ class Opcode(enum.IntEnum):
+ # fmt: off
+ CONFIG_CODEC = 0x01
+ CONFIG_QOS = 0x02
+ ENABLE = 0x03
+ RECEIVER_START_READY = 0x04
+ DISABLE = 0x05
+ RECEIVER_STOP_READY = 0x06
+ UPDATE_METADATA = 0x07
+ RELEASE = 0x08
+
+ @staticmethod
+ def from_bytes(pdu: bytes) -> ASE_Operation:
+ op_code = pdu[0]
+
+ cls = ASE_Operation.classes.get(op_code)
+ if cls is None:
+ instance = ASE_Operation(pdu)
+ instance.name = ASE_Operation.Opcode(op_code).name
+ instance.op_code = op_code
+ return instance
+ self = cls.__new__(cls)
+ ASE_Operation.__init__(self, pdu)
+ if self.fields is not None:
+ self.init_from_bytes(pdu, 1)
+ return self
+
+ @staticmethod
+ def subclass(fields):
+ def inner(cls: Type[ASE_Operation]):
+ try:
+ operation = ASE_Operation.Opcode[cls.__name__[4:].upper()]
+ cls.name = operation.name
+ cls.op_code = operation
+ except:
+ raise KeyError(f'PDU name {cls.name} not found in Ase_Operation.Opcode')
+ cls.fields = fields
+
+ # Register a factory for this class
+ ASE_Operation.classes[cls.op_code] = cls
+
+ return cls
+
+ return inner
+
+ def __init__(self, pdu: Optional[bytes] = None, **kwargs) -> None:
+ if self.fields is not None and kwargs:
+ hci.HCI_Object.init_from_fields(self, self.fields, kwargs)
+ if pdu is None:
+ pdu = bytes([self.op_code]) + hci.HCI_Object.dict_to_bytes(
+ kwargs, self.fields
+ )
+ self.pdu = pdu
+
+ def init_from_bytes(self, pdu: bytes, offset: int):
+ return hci.HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
+
+ def __bytes__(self) -> bytes:
+ return self.pdu
+
+ def __str__(self) -> str:
+ result = f'{colors.color(self.name, "yellow")} '
+ if fields := getattr(self, 'fields', None):
+ result += ':\n' + hci.HCI_Object.format_fields(self.__dict__, fields, ' ')
+ else:
+ if len(self.pdu) > 1:
+ result += f': {self.pdu.hex()}'
+ return result
+
+
+@ASE_Operation.subclass(
+ [
+ [
+ ('ase_id', 1),
+ ('target_latency', 1),
+ ('target_phy', 1),
+ ('codec_id', hci.CodingFormat.parse_from_bytes),
+ ('codec_specific_configuration', 'v'),
+ ],
+ ]
+)
+class ASE_Config_Codec(ASE_Operation):
+ '''
+ See Audio Stream Control Service 5.1 - Config Codec Operation
+ '''
+
+ target_latency: List[int]
+ target_phy: List[int]
+ codec_id: List[hci.CodingFormat]
+ codec_specific_configuration: List[bytes]
+
+
+@ASE_Operation.subclass(
+ [
+ [
+ ('ase_id', 1),
+ ('cig_id', 1),
+ ('cis_id', 1),
+ ('sdu_interval', 3),
+ ('framing', 1),
+ ('phy', 1),
+ ('max_sdu', 2),
+ ('retransmission_number', 1),
+ ('max_transport_latency', 2),
+ ('presentation_delay', 3),
+ ],
+ ]
+)
+class ASE_Config_QOS(ASE_Operation):
+ '''
+ See Audio Stream Control Service 5.2 - Config Qos Operation
+ '''
+
+ cig_id: List[int]
+ cis_id: List[int]
+ sdu_interval: List[int]
+ framing: List[int]
+ phy: List[int]
+ max_sdu: List[int]
+ retransmission_number: List[int]
+ max_transport_latency: List[int]
+ presentation_delay: List[int]
+
+
+@ASE_Operation.subclass([[('ase_id', 1), ('metadata', 'v')]])
+class ASE_Enable(ASE_Operation):
+ '''
+ See Audio Stream Control Service 5.3 - Enable Operation
+ '''
+
+ metadata: bytes
+
+
+@ASE_Operation.subclass([[('ase_id', 1)]])
+class ASE_Receiver_Start_Ready(ASE_Operation):
+ '''
+ See Audio Stream Control Service 5.4 - Receiver Start Ready Operation
+ '''
+
+
+@ASE_Operation.subclass([[('ase_id', 1)]])
+class ASE_Disable(ASE_Operation):
+ '''
+ See Audio Stream Control Service 5.5 - Disable Operation
+ '''
+
+
+@ASE_Operation.subclass([[('ase_id', 1)]])
+class ASE_Receiver_Stop_Ready(ASE_Operation):
+ '''
+ See Audio Stream Control Service 5.6 - Receiver Stop Ready Operation
+ '''
+
+
+@ASE_Operation.subclass([[('ase_id', 1), ('metadata', 'v')]])
+class ASE_Update_Metadata(ASE_Operation):
+ '''
+ See Audio Stream Control Service 5.7 - Update Metadata Operation
+ '''
+
+ metadata: List[bytes]
+
+
+@ASE_Operation.subclass([[('ase_id', 1)]])
+class ASE_Release(ASE_Operation):
+ '''
+ See Audio Stream Control Service 5.8 - Release Operation
+ '''
+
+
+class AseResponseCode(enum.IntEnum):
+ # fmt: off
+ SUCCESS = 0x00
+ UNSUPPORTED_OPCODE = 0x01
+ INVALID_LENGTH = 0x02
+ INVALID_ASE_ID = 0x03
+ INVALID_ASE_STATE_MACHINE_TRANSITION = 0x04
+ INVALID_ASE_DIRECTION = 0x05
+ UNSUPPORTED_AUDIO_CAPABILITIES = 0x06
+ UNSUPPORTED_CONFIGURATION_PARAMETER_VALUE = 0x07
+ REJECTED_CONFIGURATION_PARAMETER_VALUE = 0x08
+ INVALID_CONFIGURATION_PARAMETER_VALUE = 0x09
+ UNSUPPORTED_METADATA = 0x0A
+ REJECTED_METADATA = 0x0B
+ INVALID_METADATA = 0x0C
+ INSUFFICIENT_RESOURCES = 0x0D
+ UNSPECIFIED_ERROR = 0x0E
+
+
+class AseReasonCode(enum.IntEnum):
+ # fmt: off
+ NONE = 0x00
+ CODEC_ID = 0x01
+ CODEC_SPECIFIC_CONFIGURATION = 0x02
+ SDU_INTERVAL = 0x03
+ FRAMING = 0x04
+ PHY = 0x05
+ MAXIMUM_SDU_SIZE = 0x06
+ RETRANSMISSION_NUMBER = 0x07
+ MAX_TRANSPORT_LATENCY = 0x08
+ PRESENTATION_DELAY = 0x09
+ INVALID_ASE_CIS_MAPPING = 0x0A
+
+
+class AudioRole(enum.IntEnum):
+ SINK = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST
+ SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER
+
+
+# -----------------------------------------------------------------------------
+# Utils
+# -----------------------------------------------------------------------------
+
+
+def bits_to_channel_counts(data: int) -> List[int]:
+ pos = 0
+ counts = []
+ while data != 0:
+ # Bit 0 = count 1
+ # Bit 1 = count 2, and so on
+ pos += 1
+ if data & 1:
+ counts.append(pos)
+ data >>= 1
+ return counts
+
+
+def channel_counts_to_bits(counts: Sequence[int]) -> int:
+ return sum(set([1 << (count - 1) for count in counts]))
+
+
+# -----------------------------------------------------------------------------
+# Structures
+# -----------------------------------------------------------------------------
+
+
+@dataclasses.dataclass
+class CodecSpecificCapabilities:
+ '''See:
+ * Bluetooth Assigned Numbers, 6.12.4 - Codec Specific Capabilities LTV Structures
+ * Basic Audio Profile, 4.3.1 - Codec_Specific_Capabilities LTV requirements
+ '''
+
+ class Type(enum.IntEnum):
+ # fmt: off
+ SAMPLING_FREQUENCY = 0x01
+ FRAME_DURATION = 0x02
+ AUDIO_CHANNEL_COUNT = 0x03
+ OCTETS_PER_FRAME = 0x04
+ CODEC_FRAMES_PER_SDU = 0x05
+
+ supported_sampling_frequencies: SupportedSamplingFrequency
+ supported_frame_durations: SupportedFrameDuration
+ supported_audio_channel_counts: Sequence[int]
+ min_octets_per_codec_frame: int
+ max_octets_per_codec_frame: int
+ supported_max_codec_frames_per_sdu: int
+
+ @classmethod
+ def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities:
+ offset = 0
+ # Allowed default values.
+ supported_audio_channel_counts = [1]
+ supported_max_codec_frames_per_sdu = 1
+ while offset < len(data):
+ length, type = struct.unpack_from('BB', data, offset)
+ offset += 2
+ value = int.from_bytes(data[offset : offset + length - 1], 'little')
+ offset += length - 1
+
+ if type == CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY:
+ supported_sampling_frequencies = SupportedSamplingFrequency(value)
+ elif type == CodecSpecificCapabilities.Type.FRAME_DURATION:
+ supported_frame_durations = SupportedFrameDuration(value)
+ elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
+ supported_audio_channel_counts = bits_to_channel_counts(value)
+ elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
+ min_octets_per_sample = value & 0xFFFF
+ max_octets_per_sample = value >> 16
+ elif type == CodecSpecificCapabilities.Type.CODEC_FRAMES_PER_SDU:
+ supported_max_codec_frames_per_sdu = value
+
+ # It is expected here that if some fields are missing, an error should be raised.
+ return CodecSpecificCapabilities(
+ supported_sampling_frequencies=supported_sampling_frequencies,
+ supported_frame_durations=supported_frame_durations,
+ supported_audio_channel_counts=supported_audio_channel_counts,
+ min_octets_per_codec_frame=min_octets_per_sample,
+ max_octets_per_codec_frame=max_octets_per_sample,
+ supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu,
+ )
+
+ def __bytes__(self) -> bytes:
+ return struct.pack(
+ '<BBHBBBBBBBBHHBBB',
+ 3,
+ CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY,
+ self.supported_sampling_frequencies,
+ 2,
+ CodecSpecificCapabilities.Type.FRAME_DURATION,
+ self.supported_frame_durations,
+ 2,
+ CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT,
+ channel_counts_to_bits(self.supported_audio_channel_counts),
+ 5,
+ CodecSpecificCapabilities.Type.OCTETS_PER_FRAME,
+ self.min_octets_per_codec_frame,
+ self.max_octets_per_codec_frame,
+ 2,
+ CodecSpecificCapabilities.Type.CODEC_FRAMES_PER_SDU,
+ self.supported_max_codec_frames_per_sdu,
+ )
+
+
+@dataclasses.dataclass
+class CodecSpecificConfiguration:
+ '''See:
+ * Bluetooth Assigned Numbers, 6.12.5 - Codec Specific Configuration LTV Structures
+ * Basic Audio Profile, 4.3.2 - Codec_Specific_Capabilities LTV requirements
+ '''
+
+ class Type(enum.IntEnum):
+ # fmt: off
+ SAMPLING_FREQUENCY = 0x01
+ FRAME_DURATION = 0x02
+ AUDIO_CHANNEL_ALLOCATION = 0x03
+ OCTETS_PER_FRAME = 0x04
+ CODEC_FRAMES_PER_SDU = 0x05
+
+ sampling_frequency: SamplingFrequency
+ frame_duration: FrameDuration
+ audio_channel_allocation: AudioLocation
+ octets_per_codec_frame: int
+ codec_frames_per_sdu: int
+
+ @classmethod
+ def from_bytes(cls, data: bytes) -> CodecSpecificConfiguration:
+ offset = 0
+ # Allowed default values.
+ audio_channel_allocation = AudioLocation.NOT_ALLOWED
+ codec_frames_per_sdu = 1
+ while offset < len(data):
+ length, type = struct.unpack_from('BB', data, offset)
+ offset += 2
+ value = int.from_bytes(data[offset : offset + length - 1], 'little')
+ offset += length - 1
+
+ if type == CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY:
+ sampling_frequency = SamplingFrequency(value)
+ elif type == CodecSpecificConfiguration.Type.FRAME_DURATION:
+ frame_duration = FrameDuration(value)
+ elif type == CodecSpecificConfiguration.Type.AUDIO_CHANNEL_ALLOCATION:
+ audio_channel_allocation = AudioLocation(value)
+ elif type == CodecSpecificConfiguration.Type.OCTETS_PER_FRAME:
+ octets_per_codec_frame = value
+ elif type == CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU:
+ codec_frames_per_sdu = value
+
+ # It is expected here that if some fields are missing, an error should be raised.
+ return CodecSpecificConfiguration(
+ sampling_frequency=sampling_frequency,
+ frame_duration=frame_duration,
+ audio_channel_allocation=audio_channel_allocation,
+ octets_per_codec_frame=octets_per_codec_frame,
+ codec_frames_per_sdu=codec_frames_per_sdu,
+ )
+
+ def __bytes__(self) -> bytes:
+ return struct.pack(
+ '<BBBBBBBBIBBHBBB',
+ 2,
+ CodecSpecificConfiguration.Type.SAMPLING_FREQUENCY,
+ self.sampling_frequency,
+ 2,
+ CodecSpecificConfiguration.Type.FRAME_DURATION,
+ self.frame_duration,
+ 5,
+ CodecSpecificConfiguration.Type.AUDIO_CHANNEL_ALLOCATION,
+ self.audio_channel_allocation,
+ 3,
+ CodecSpecificConfiguration.Type.OCTETS_PER_FRAME,
+ self.octets_per_codec_frame,
+ 2,
+ CodecSpecificConfiguration.Type.CODEC_FRAMES_PER_SDU,
+ self.codec_frames_per_sdu,
+ )
+
+
+@dataclasses.dataclass
+class PacRecord:
+ coding_format: hci.CodingFormat
+ codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
+ # TODO: Parse Metadata
+ metadata: bytes = b''
+
+ @classmethod
+ def from_bytes(cls, data: bytes) -> PacRecord:
+ offset, coding_format = hci.CodingFormat.parse_from_bytes(data, 0)
+ codec_specific_capabilities_size = data[offset]
+
+ offset += 1
+ codec_specific_capabilities_bytes = data[
+ offset : offset + codec_specific_capabilities_size
+ ]
+ offset += codec_specific_capabilities_size
+ metadata_size = data[offset]
+ metadata = data[offset : offset + metadata_size]
+
+ codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
+ if coding_format.codec_id == hci.CodecID.VENDOR_SPECIFIC:
+ codec_specific_capabilities = codec_specific_capabilities_bytes
+ else:
+ codec_specific_capabilities = CodecSpecificCapabilities.from_bytes(
+ codec_specific_capabilities_bytes
+ )
+
+ return PacRecord(
+ coding_format=coding_format,
+ codec_specific_capabilities=codec_specific_capabilities,
+ metadata=metadata,
+ )
+
+ def __bytes__(self) -> bytes:
+ capabilities_bytes = bytes(self.codec_specific_capabilities)
+ return (
+ bytes(self.coding_format)
+ + bytes([len(capabilities_bytes)])
+ + capabilities_bytes
+ + bytes([len(self.metadata)])
+ + self.metadata
+ )
+
+
+# -----------------------------------------------------------------------------
+# Server
+# -----------------------------------------------------------------------------
+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
+
+ def __init__(
+ self,
+ supported_source_context: ContextType,
+ supported_sink_context: ContextType,
+ available_source_context: ContextType,
+ available_sink_context: ContextType,
+ sink_pac: Sequence[PacRecord] = [],
+ sink_audio_locations: Optional[AudioLocation] = None,
+ source_pac: Sequence[PacRecord] = [],
+ source_audio_locations: Optional[AudioLocation] = None,
+ ) -> None:
+ characteristics = []
+
+ self.supported_audio_contexts = gatt.Characteristic(
+ uuid=gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ,
+ permissions=gatt.Characteristic.Permissions.READABLE,
+ value=struct.pack('<HH', supported_sink_context, supported_source_context),
+ )
+ characteristics.append(self.supported_audio_contexts)
+
+ self.available_audio_contexts = gatt.Characteristic(
+ uuid=gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ
+ | gatt.Characteristic.Properties.NOTIFY,
+ permissions=gatt.Characteristic.Permissions.READABLE,
+ value=struct.pack('<HH', available_sink_context, available_source_context),
+ )
+ characteristics.append(self.available_audio_contexts)
+
+ if sink_pac:
+ self.sink_pac = gatt.Characteristic(
+ uuid=gatt.GATT_SINK_PAC_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ,
+ permissions=gatt.Characteristic.Permissions.READABLE,
+ value=bytes([len(sink_pac)]) + b''.join(map(bytes, sink_pac)),
+ )
+ characteristics.append(self.sink_pac)
+
+ if sink_audio_locations is not None:
+ self.sink_audio_locations = gatt.Characteristic(
+ uuid=gatt.GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ,
+ permissions=gatt.Characteristic.Permissions.READABLE,
+ value=struct.pack('<I', sink_audio_locations),
+ )
+ characteristics.append(self.sink_audio_locations)
+
+ if source_pac:
+ self.source_pac = gatt.Characteristic(
+ uuid=gatt.GATT_SOURCE_PAC_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ,
+ permissions=gatt.Characteristic.Permissions.READABLE,
+ value=bytes([len(source_pac)]) + b''.join(map(bytes, source_pac)),
+ )
+ characteristics.append(self.source_pac)
+
+ if source_audio_locations is not None:
+ self.source_audio_locations = gatt.Characteristic(
+ uuid=gatt.GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ,
+ permissions=gatt.Characteristic.Permissions.READABLE,
+ value=struct.pack('<I', source_audio_locations),
+ )
+ characteristics.append(self.source_audio_locations)
+
+ super().__init__(characteristics)
+
+
+class AseStateMachine(gatt.Characteristic):
+ class State(enum.IntEnum):
+ # fmt: off
+ IDLE = 0x00
+ CODEC_CONFIGURED = 0x01
+ QOS_CONFIGURED = 0x02
+ ENABLING = 0x03
+ STREAMING = 0x04
+ DISABLING = 0x05
+ RELEASING = 0x06
+
+ cis_link: Optional[device.CisLink] = None
+
+ # Additional parameters in CODEC_CONFIGURED State
+ preferred_framing = 0 # Unframed PDU supported
+ preferred_phy = 0
+ preferred_retransmission_number = 13
+ preferred_max_transport_latency = 100
+ supported_presentation_delay_min = 0
+ supported_presentation_delay_max = 0
+ preferred_presentation_delay_min = 0
+ preferred_presentation_delay_max = 0
+ codec_id = hci.CodingFormat(hci.CodecID.LC3)
+ codec_specific_configuration: Union[CodecSpecificConfiguration, bytes] = b''
+
+ # Additional parameters in QOS_CONFIGURED State
+ cig_id = 0
+ cis_id = 0
+ sdu_interval = 0
+ framing = 0
+ phy = 0
+ max_sdu = 0
+ retransmission_number = 0
+ max_transport_latency = 0
+ presentation_delay = 0
+
+ # Additional parameters in ENABLING, STREAMING, DISABLING State
+ # TODO: Parse this
+ metadata = b''
+
+ def __init__(
+ self,
+ role: AudioRole,
+ ase_id: int,
+ service: AudioStreamControlService,
+ ) -> None:
+ self.service = service
+ self.ase_id = ase_id
+ self._state = AseStateMachine.State.IDLE
+ self.role = role
+
+ uuid = (
+ gatt.GATT_SINK_ASE_CHARACTERISTIC
+ if role == AudioRole.SINK
+ else gatt.GATT_SOURCE_ASE_CHARACTERISTIC
+ )
+ super().__init__(
+ uuid=uuid,
+ properties=gatt.Characteristic.Properties.READ
+ | gatt.Characteristic.Properties.NOTIFY,
+ permissions=gatt.Characteristic.Permissions.READABLE,
+ 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)
+
+ def on_cis_request(
+ self,
+ acl_connection: device.Connection,
+ cis_handle: int,
+ cig_id: int,
+ cis_id: int,
+ ) -> None:
+ if cis_id == self.cis_id and self.state == self.State.ENABLING:
+ acl_connection.abort_on(
+ 'flush', self.service.device.accept_cis_request(cis_handle)
+ )
+
+ def on_cis_establishment(self, cis_link: device.CisLink) -> None:
+ if cis_link.cis_id == self.cis_id and self.state == self.State.ENABLING:
+ self.state = self.State.STREAMING
+ self.cis_link = cis_link
+
+ async def post_cis_established():
+ await self.service.device.send_command(
+ hci.HCI_LE_Setup_ISO_Data_Path_Command(
+ connection_handle=cis_link.handle,
+ data_path_direction=self.role,
+ data_path_id=0x00, # Fixed HCI
+ codec_id=hci.CodingFormat(hci.CodecID.TRANSPARENT),
+ controller_delay=0,
+ codec_configuration=b'',
+ )
+ )
+ await self.service.device.notify_subscribers(self, self.value)
+
+ cis_link.acl_connection.abort_on('flush', post_cis_established())
+
+ def on_config_codec(
+ self,
+ target_latency: int,
+ target_phy: int,
+ codec_id: hci.CodingFormat,
+ codec_specific_configuration: bytes,
+ ) -> Tuple[AseResponseCode, AseReasonCode]:
+ if self.state not in (
+ self.State.IDLE,
+ self.State.CODEC_CONFIGURED,
+ self.State.QOS_CONFIGURED,
+ ):
+ return (
+ AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+ AseReasonCode.NONE,
+ )
+
+ self.max_transport_latency = target_latency
+ self.phy = target_phy
+ self.codec_id = codec_id
+ if codec_id.codec_id == hci.CodecID.VENDOR_SPECIFIC:
+ self.codec_specific_configuration = codec_specific_configuration
+ else:
+ self.codec_specific_configuration = CodecSpecificConfiguration.from_bytes(
+ codec_specific_configuration
+ )
+
+ self.state = self.State.CODEC_CONFIGURED
+
+ return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+ def on_config_qos(
+ self,
+ cig_id: int,
+ cis_id: int,
+ sdu_interval: int,
+ framing: int,
+ phy: int,
+ max_sdu: int,
+ retransmission_number: int,
+ max_transport_latency: int,
+ presentation_delay: int,
+ ) -> Tuple[AseResponseCode, AseReasonCode]:
+ if self.state not in (
+ AseStateMachine.State.CODEC_CONFIGURED,
+ AseStateMachine.State.QOS_CONFIGURED,
+ ):
+ return (
+ AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+ AseReasonCode.NONE,
+ )
+
+ self.cig_id = cig_id
+ self.cis_id = cis_id
+ self.sdu_interval = sdu_interval
+ self.framing = framing
+ self.phy = phy
+ self.max_sdu = max_sdu
+ self.retransmission_number = retransmission_number
+ self.max_transport_latency = max_transport_latency
+ self.presentation_delay = presentation_delay
+
+ self.state = self.State.QOS_CONFIGURED
+
+ return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+ def on_enable(self, metadata: bytes) -> Tuple[AseResponseCode, AseReasonCode]:
+ if self.state != AseStateMachine.State.QOS_CONFIGURED:
+ return (
+ AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+ AseReasonCode.NONE,
+ )
+
+ self.metadata = metadata
+ self.state = self.State.ENABLING
+
+ return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+ def on_receiver_start_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
+ if self.state != AseStateMachine.State.ENABLING:
+ return (
+ AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+ AseReasonCode.NONE,
+ )
+ self.state = self.State.STREAMING
+ return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+ def on_disable(self) -> Tuple[AseResponseCode, AseReasonCode]:
+ if self.state not in (
+ AseStateMachine.State.ENABLING,
+ AseStateMachine.State.STREAMING,
+ ):
+ return (
+ AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+ AseReasonCode.NONE,
+ )
+ self.state = self.State.DISABLING
+ return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+ def on_receiver_stop_ready(self) -> Tuple[AseResponseCode, AseReasonCode]:
+ if self.state != AseStateMachine.State.DISABLING:
+ return (
+ AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+ AseReasonCode.NONE,
+ )
+ self.state = self.State.QOS_CONFIGURED
+ return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+ def on_update_metadata(
+ self, metadata: bytes
+ ) -> Tuple[AseResponseCode, AseReasonCode]:
+ if self.state not in (
+ AseStateMachine.State.ENABLING,
+ AseStateMachine.State.STREAMING,
+ ):
+ return (
+ AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+ AseReasonCode.NONE,
+ )
+ self.metadata = metadata
+ return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+ def on_release(self) -> Tuple[AseResponseCode, AseReasonCode]:
+ if self.state == AseStateMachine.State.IDLE:
+ return (
+ AseResponseCode.INVALID_ASE_STATE_MACHINE_TRANSITION,
+ AseReasonCode.NONE,
+ )
+ self.state = self.State.RELEASING
+
+ async def remove_cis_async():
+ await self.service.device.send_command(
+ hci.HCI_LE_Remove_ISO_Data_Path_Command(
+ connection_handle=self.cis_link.handle,
+ data_path_direction=self.role,
+ )
+ )
+ self.state = self.State.IDLE
+ await self.service.device.notify_subscribers(self, self.value)
+
+ self.service.device.abort_on('flush', remove_cis_async())
+ return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
+
+ @property
+ def state(self) -> State:
+ return self._state
+
+ @state.setter
+ def state(self, new_state: State) -> None:
+ logger.debug(f'{self} state change -> {colors.color(new_state.name, "cyan")}')
+ self._state = new_state
+
+ @property
+ def value(self):
+ '''Returns ASE_ID, ASE_STATE, and ASE Additional Parameters.'''
+
+ if self.state == self.State.CODEC_CONFIGURED:
+ codec_specific_configuration_bytes = bytes(
+ self.codec_specific_configuration
+ )
+ additional_parameters = (
+ struct.pack(
+ '<BBBH',
+ self.preferred_framing,
+ self.preferred_phy,
+ self.preferred_retransmission_number,
+ self.preferred_max_transport_latency,
+ )
+ + self.supported_presentation_delay_min.to_bytes(3, 'little')
+ + self.supported_presentation_delay_max.to_bytes(3, 'little')
+ + self.preferred_presentation_delay_min.to_bytes(3, 'little')
+ + self.preferred_presentation_delay_max.to_bytes(3, 'little')
+ + bytes(self.codec_id)
+ + bytes([len(codec_specific_configuration_bytes)])
+ + codec_specific_configuration_bytes
+ )
+ elif self.state == self.State.QOS_CONFIGURED:
+ additional_parameters = (
+ bytes([self.cig_id, self.cis_id])
+ + self.sdu_interval.to_bytes(3, 'little')
+ + struct.pack(
+ '<BBHBH',
+ self.framing,
+ self.phy,
+ self.max_sdu,
+ self.retransmission_number,
+ self.max_transport_latency,
+ )
+ + self.presentation_delay.to_bytes(3, 'little')
+ )
+ elif self.state in (
+ self.State.ENABLING,
+ self.State.STREAMING,
+ self.State.DISABLING,
+ ):
+ additional_parameters = (
+ bytes([self.cig_id, self.cis_id, len(self.metadata)]) + self.metadata
+ )
+ else:
+ additional_parameters = b''
+
+ return bytes([self.ase_id, self.state]) + additional_parameters
+
+ @value.setter
+ def value(self, _new_value):
+ # Readonly. Do nothing in the setter.
+ pass
+
+ def on_read(self, _: Optional[device.Connection]) -> bytes:
+ return self.value
+
+ def __str__(self) -> str:
+ return (
+ f'AseStateMachine(id={self.ase_id}, role={self.role.name} '
+ f'state={self._state.name})'
+ )
+
+
+class AudioStreamControlService(gatt.TemplateService):
+ UUID = gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE
+
+ ase_state_machines: Dict[int, AseStateMachine]
+ ase_control_point: gatt.Characteristic
+
+ def __init__(
+ self,
+ device: device.Device,
+ source_ase_id: Sequence[int] = [],
+ sink_ase_id: Sequence[int] = [],
+ ) -> None:
+ self.device = device
+ self.ase_state_machines = {
+ **{
+ id: AseStateMachine(role=AudioRole.SINK, ase_id=id, service=self)
+ for id in sink_ase_id
+ },
+ **{
+ id: AseStateMachine(role=AudioRole.SOURCE, ase_id=id, service=self)
+ for id in source_ase_id
+ },
+ } # ASE state machines, by ASE ID
+
+ self.ase_control_point = gatt.Characteristic(
+ uuid=gatt.GATT_ASE_CONTROL_POINT_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.WRITE
+ | gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE
+ | gatt.Characteristic.Properties.NOTIFY,
+ permissions=gatt.Characteristic.Permissions.WRITEABLE,
+ value=gatt.CharacteristicValue(write=self.on_write_ase_control_point),
+ )
+
+ super().__init__([self.ase_control_point, *self.ase_state_machines.values()])
+
+ def on_operation(self, opcode: ASE_Operation.Opcode, ase_id: int, args):
+ if ase := self.ase_state_machines.get(ase_id):
+ handler = getattr(ase, 'on_' + opcode.name.lower())
+ return (ase_id, *handler(*args))
+ else:
+ return (ase_id, AseResponseCode.INVALID_ASE_ID, AseReasonCode.NONE)
+
+ def on_write_ase_control_point(self, connection, data):
+ operation = ASE_Operation.from_bytes(data)
+ responses = []
+ logger.debug(f'*** ASCS Write {operation} ***')
+
+ if operation.op_code == ASE_Operation.Opcode.CONFIG_CODEC:
+ for ase_id, *args in zip(
+ operation.ase_id,
+ operation.target_latency,
+ operation.target_phy,
+ operation.codec_id,
+ operation.codec_specific_configuration,
+ ):
+ responses.append(self.on_operation(operation.op_code, ase_id, args))
+ elif operation.op_code == ASE_Operation.Opcode.CONFIG_QOS:
+ for ase_id, *args in zip(
+ operation.ase_id,
+ operation.cig_id,
+ operation.cis_id,
+ operation.sdu_interval,
+ operation.framing,
+ operation.phy,
+ operation.max_sdu,
+ operation.retransmission_number,
+ operation.max_transport_latency,
+ operation.presentation_delay,
+ ):
+ responses.append(self.on_operation(operation.op_code, ase_id, args))
+ elif operation.op_code in (
+ ASE_Operation.Opcode.ENABLE,
+ ASE_Operation.Opcode.UPDATE_METADATA,
+ ):
+ for ase_id, *args in zip(
+ operation.ase_id,
+ operation.metadata,
+ ):
+ responses.append(self.on_operation(operation.op_code, ase_id, args))
+ elif operation.op_code in (
+ ASE_Operation.Opcode.RECEIVER_START_READY,
+ ASE_Operation.Opcode.DISABLE,
+ ASE_Operation.Opcode.RECEIVER_STOP_READY,
+ ASE_Operation.Opcode.RELEASE,
+ ):
+ for ase_id in operation.ase_id:
+ responses.append(self.on_operation(operation.op_code, ase_id, []))
+
+ control_point_notification = bytes(
+ [operation.op_code, len(responses)]
+ ) + b''.join(map(bytes, responses))
+ self.device.abort_on(
+ 'flush',
+ self.device.notify_subscribers(
+ self.ase_control_point, control_point_notification
+ ),
+ )
+
+ for ase_id, *_ in responses:
+ if ase := self.ase_state_machines.get(ase_id):
+ self.device.abort_on(
+ 'flush',
+ self.device.notify_subscribers(ase, ase.value),
+ )
+
+
+# -----------------------------------------------------------------------------
+# Client
+# -----------------------------------------------------------------------------
+class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
+ SERVICE_CLASS = PublishedAudioCapabilitiesService
+
+ sink_pac: Optional[gatt_client.CharacteristicProxy] = None
+ sink_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
+ source_pac: Optional[gatt_client.CharacteristicProxy] = None
+ source_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
+ available_audio_contexts: gatt_client.CharacteristicProxy
+ supported_audio_contexts: gatt_client.CharacteristicProxy
+
+ def __init__(self, service_proxy: gatt_client.ServiceProxy):
+ self.service_proxy = service_proxy
+
+ self.available_audio_contexts = service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
+ )[0]
+ self.supported_audio_contexts = service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
+ )[0]
+
+ if characteristics := service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_SINK_PAC_CHARACTERISTIC
+ ):
+ self.sink_pac = characteristics[0]
+
+ if characteristics := service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_SOURCE_PAC_CHARACTERISTIC
+ ):
+ self.source_pac = characteristics[0]
+
+ if characteristics := service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC
+ ):
+ self.sink_audio_locations = characteristics[0]
+
+ if characteristics := service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC
+ ):
+ self.source_audio_locations = characteristics[0]
+
+
+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
+
+ def __init__(self, service_proxy: gatt_client.ServiceProxy):
+ self.service_proxy = service_proxy
+
+ self.sink_ase = service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_SINK_ASE_CHARACTERISTIC
+ )
+ self.source_ase = service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_SOURCE_ASE_CHARACTERISTIC
+ )
+ self.ase_control_point = service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_ASE_CONTROL_POINT_CHARACTERISTIC
+ )[0]
diff --git a/bumble/profiles/cap.py b/bumble/profiles/cap.py
new file mode 100644
index 0000000..476f908
--- /dev/null
+++ b/bumble/profiles/cap.py
@@ -0,0 +1,52 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+
+from bumble import gatt
+from bumble import gatt_client
+from bumble.profiles import csip
+
+
+# -----------------------------------------------------------------------------
+# Server
+# -----------------------------------------------------------------------------
+class CommonAudioServiceService(gatt.TemplateService):
+ UUID = gatt.GATT_COMMON_AUDIO_SERVICE
+
+ def __init__(
+ self,
+ coordinated_set_identification_service: csip.CoordinatedSetIdentificationService,
+ ) -> None:
+ self.coordinated_set_identification_service = (
+ coordinated_set_identification_service
+ )
+ super().__init__(
+ characteristics=[],
+ included_services=[coordinated_set_identification_service],
+ )
+
+
+# -----------------------------------------------------------------------------
+# Client
+# -----------------------------------------------------------------------------
+class CommonAudioServiceServiceProxy(gatt_client.ProfileServiceProxy):
+ SERVICE_CLASS = CommonAudioServiceService
+
+ def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
+ self.service_proxy = service_proxy
diff --git a/bumble/profiles/csip.py b/bumble/profiles/csip.py
new file mode 100644
index 0000000..03fba9c
--- /dev/null
+++ b/bumble/profiles/csip.py
@@ -0,0 +1,257 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import enum
+import struct
+from typing import Optional, Tuple
+
+from bumble import core
+from bumble import crypto
+from bumble import device
+from bumble import gatt
+from bumble import gatt_client
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+SET_IDENTITY_RESOLVING_KEY_LENGTH = 16
+
+
+class SirkType(enum.IntEnum):
+ '''Coordinated Set Identification Service - 5.1 Set Identity Resolving Key.'''
+
+ ENCRYPTED = 0x00
+ PLAINTEXT = 0x01
+
+
+class MemberLock(enum.IntEnum):
+ '''Coordinated Set Identification Service - 5.3 Set Member Lock.'''
+
+ UNLOCKED = 0x01
+ LOCKED = 0x02
+
+
+# -----------------------------------------------------------------------------
+# Crypto Toolbox
+# -----------------------------------------------------------------------------
+def s1(m: bytes) -> bytes:
+ '''
+ Coordinated Set Identification Service - 4.3 s1 SALT generation function.
+ '''
+ return crypto.aes_cmac(m[::-1], bytes(16))[::-1]
+
+
+def k1(n: bytes, salt: bytes, p: bytes) -> bytes:
+ '''
+ Coordinated Set Identification Service - 4.4 k1 derivation function.
+ '''
+ t = crypto.aes_cmac(n[::-1], salt[::-1])
+ return crypto.aes_cmac(p[::-1], t)[::-1]
+
+
+def sef(k: bytes, r: bytes) -> bytes:
+ '''
+ Coordinated Set Identification Service - 4.5 SIRK encryption function sef.
+
+ SIRK decryption function sdf shares the same algorithm. The only difference is that argument r is:
+ * Plaintext in encryption
+ * Cipher in decryption
+ '''
+ return crypto.xor(k1(k, s1(b'SIRKenc'[::-1]), b'csis'[::-1]), r)
+
+
+def sih(k: bytes, r: bytes) -> bytes:
+ '''
+ Coordinated Set Identification Service - 4.7 Resolvable Set Identifier hash function sih.
+ '''
+ return crypto.e(k, r + bytes(13))[:3]
+
+
+def generate_rsi(sirk: bytes) -> bytes:
+ '''
+ Coordinated Set Identification Service - 4.8 Resolvable Set Identifier generation operation.
+ '''
+ prand = crypto.generate_prand()
+ return sih(sirk, prand) + prand
+
+
+# -----------------------------------------------------------------------------
+# Server
+# -----------------------------------------------------------------------------
+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
+
+ def __init__(
+ self,
+ set_identity_resolving_key: bytes,
+ set_identity_resolving_key_type: SirkType,
+ coordinated_set_size: Optional[int] = None,
+ set_member_lock: Optional[MemberLock] = None,
+ set_member_rank: Optional[int] = None,
+ ) -> None:
+ if len(set_identity_resolving_key) != SET_IDENTITY_RESOLVING_KEY_LENGTH:
+ raise ValueError(
+ f'Invalid SIRK length {len(set_identity_resolving_key)}, expected {SET_IDENTITY_RESOLVING_KEY_LENGTH}'
+ )
+
+ characteristics = []
+
+ self.set_identity_resolving_key = set_identity_resolving_key
+ self.set_identity_resolving_key_type = set_identity_resolving_key_type
+ self.set_identity_resolving_key_characteristic = gatt.Characteristic(
+ uuid=gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ
+ | gatt.Characteristic.Properties.NOTIFY,
+ permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+ value=gatt.CharacteristicValue(read=self.on_sirk_read),
+ )
+ characteristics.append(self.set_identity_resolving_key_characteristic)
+
+ if coordinated_set_size is not None:
+ self.coordinated_set_size_characteristic = gatt.Characteristic(
+ uuid=gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ
+ | gatt.Characteristic.Properties.NOTIFY,
+ permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+ value=struct.pack('B', coordinated_set_size),
+ )
+ characteristics.append(self.coordinated_set_size_characteristic)
+
+ if set_member_lock is not None:
+ self.set_member_lock_characteristic = gatt.Characteristic(
+ uuid=gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ
+ | gatt.Characteristic.Properties.NOTIFY
+ | gatt.Characteristic.Properties.WRITE,
+ permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
+ | gatt.Characteristic.Permissions.WRITEABLE,
+ value=struct.pack('B', set_member_lock),
+ )
+ characteristics.append(self.set_member_lock_characteristic)
+
+ if set_member_rank is not None:
+ self.set_member_rank_characteristic = gatt.Characteristic(
+ uuid=gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ
+ | gatt.Characteristic.Properties.NOTIFY,
+ permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+ value=struct.pack('B', set_member_rank),
+ )
+ characteristics.append(self.set_member_rank_characteristic)
+
+ super().__init__(characteristics)
+
+ async def on_sirk_read(self, connection: Optional[device.Connection]) -> bytes:
+ if self.set_identity_resolving_key_type == SirkType.PLAINTEXT:
+ sirk_bytes = self.set_identity_resolving_key
+ else:
+ assert connection
+
+ if connection.transport == core.BT_LE_TRANSPORT:
+ key = await connection.device.get_long_term_key(
+ connection_handle=connection.handle, rand=b'', ediv=0
+ )
+ else:
+ key = await connection.device.get_link_key(connection.peer_address)
+
+ if not key:
+ raise RuntimeError('LTK or LinkKey is not present')
+
+ sirk_bytes = sef(key, self.set_identity_resolving_key)
+
+ return bytes([self.set_identity_resolving_key_type]) + sirk_bytes
+
+ def get_advertising_data(self) -> bytes:
+ return bytes(
+ core.AdvertisingData(
+ [
+ (
+ core.AdvertisingData.RESOLVABLE_SET_IDENTIFIER,
+ generate_rsi(self.set_identity_resolving_key),
+ ),
+ ]
+ )
+ )
+
+
+# -----------------------------------------------------------------------------
+# Client
+# -----------------------------------------------------------------------------
+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
+
+ def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
+ self.service_proxy = service_proxy
+
+ self.set_identity_resolving_key = service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC
+ )[0]
+
+ if characteristics := service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC
+ ):
+ self.coordinated_set_size = characteristics[0]
+
+ if characteristics := service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC
+ ):
+ self.set_member_lock = characteristics[0]
+
+ if characteristics := service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC
+ ):
+ self.set_member_rank = characteristics[0]
+
+ async def read_set_identity_resolving_key(self) -> Tuple[SirkType, bytes]:
+ '''Reads SIRK and decrypts if encrypted.'''
+ response = await self.set_identity_resolving_key.read_value()
+ if len(response) != SET_IDENTITY_RESOLVING_KEY_LENGTH + 1:
+ raise RuntimeError('Invalid SIRK value')
+
+ sirk_type = SirkType(response[0])
+ if sirk_type == SirkType.PLAINTEXT:
+ sirk = response[1:]
+ else:
+ connection = self.service_proxy.client.connection
+ device = connection.device
+ if connection.transport == core.BT_LE_TRANSPORT:
+ key = await device.get_long_term_key(
+ connection_handle=connection.handle, rand=b'', ediv=0
+ )
+ else:
+ key = await device.get_link_key(connection.peer_address)
+
+ if not key:
+ raise RuntimeError('LTK or LinkKey is not present')
+
+ sirk = sef(key, response[1:])
+
+ return (sirk_type, sirk)
diff --git a/bumble/profiles/heart_rate_service.py b/bumble/profiles/heart_rate_service.py
index c7d3018..fe46cb2 100644
--- a/bumble/profiles/heart_rate_service.py
+++ b/bumble/profiles/heart_rate_service.py
@@ -42,12 +42,12 @@ class HeartRateService(TemplateService):
RESET_ENERGY_EXPENDED = 0x01
class BodySensorLocation(IntEnum):
- OTHER = (0,)
- CHEST = (1,)
- WRIST = (2,)
- FINGER = (3,)
- HAND = (4,)
- EAR_LOBE = (5,)
+ OTHER = 0
+ CHEST = 1
+ WRIST = 2
+ FINGER = 3
+ HAND = 4
+ EAR_LOBE = 5
FOOT = 6
class HeartRateMeasurement:
diff --git a/bumble/profiles/vcp.py b/bumble/profiles/vcp.py
new file mode 100644
index 0000000..0788219
--- /dev/null
+++ b/bumble/profiles/vcp.py
@@ -0,0 +1,228 @@
+# Copyright 2021-2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import enum
+
+from bumble import att
+from bumble import device
+from bumble import gatt
+from bumble import gatt_client
+
+from typing import Optional
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+
+MIN_VOLUME = 0
+MAX_VOLUME = 255
+
+
+class ErrorCode(enum.IntEnum):
+ '''
+ See Volume Control Service 1.6. Application error codes.
+ '''
+
+ INVALID_CHANGE_COUNTER = 0x80
+ OPCODE_NOT_SUPPORTED = 0x81
+
+
+class VolumeFlags(enum.IntFlag):
+ '''
+ See Volume Control Service 3.3. Volume Flags.
+ '''
+
+ VOLUME_SETTING_PERSISTED = 0x01
+ # RFU
+
+
+class VolumeControlPointOpcode(enum.IntEnum):
+ '''
+ See Volume Control Service Table 3.3: Volume Control Point procedure requirements.
+ '''
+
+ # fmt: off
+ RELATIVE_VOLUME_DOWN = 0x00
+ RELATIVE_VOLUME_UP = 0x01
+ UNMUTE_RELATIVE_VOLUME_DOWN = 0x02
+ UNMUTE_RELATIVE_VOLUME_UP = 0x03
+ SET_ABSOLUTE_VOLUME = 0x04
+ UNMUTE = 0x05
+ MUTE = 0x06
+
+
+# -----------------------------------------------------------------------------
+# Server
+# -----------------------------------------------------------------------------
+class VolumeControlService(gatt.TemplateService):
+ UUID = gatt.GATT_VOLUME_CONTROL_SERVICE
+
+ volume_state: gatt.Characteristic
+ volume_control_point: gatt.Characteristic
+ volume_flags: gatt.Characteristic
+
+ volume_setting: int
+ muted: int
+ change_counter: int
+
+ def __init__(
+ self,
+ step_size: int = 16,
+ volume_setting: int = 0,
+ muted: int = 0,
+ change_counter: int = 0,
+ volume_flags: int = 0,
+ ) -> None:
+ self.step_size = step_size
+ self.volume_setting = volume_setting
+ self.muted = muted
+ self.change_counter = change_counter
+
+ self.volume_state = gatt.Characteristic(
+ uuid=gatt.GATT_VOLUME_STATE_CHARACTERISTIC,
+ properties=(
+ gatt.Characteristic.Properties.READ
+ | gatt.Characteristic.Properties.NOTIFY
+ ),
+ permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+ value=gatt.CharacteristicValue(read=self._on_read_volume_state),
+ )
+ self.volume_control_point = gatt.Characteristic(
+ uuid=gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.WRITE,
+ permissions=gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
+ value=gatt.CharacteristicValue(write=self._on_write_volume_control_point),
+ )
+ self.volume_flags = gatt.Characteristic(
+ uuid=gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC,
+ properties=gatt.Characteristic.Properties.READ,
+ permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
+ value=bytes([volume_flags]),
+ )
+
+ super().__init__(
+ [
+ self.volume_state,
+ self.volume_control_point,
+ self.volume_flags,
+ ]
+ )
+
+ @property
+ def volume_state_bytes(self) -> bytes:
+ return bytes([self.volume_setting, self.muted, self.change_counter])
+
+ @volume_state_bytes.setter
+ def volume_state_bytes(self, new_value: bytes) -> None:
+ self.volume_setting, self.muted, self.change_counter = new_value
+
+ def _on_read_volume_state(self, _connection: Optional[device.Connection]) -> bytes:
+ return self.volume_state_bytes
+
+ def _on_write_volume_control_point(
+ self, connection: Optional[device.Connection], value: bytes
+ ) -> None:
+ assert connection
+
+ opcode = VolumeControlPointOpcode(value[0])
+ change_counter = value[1]
+
+ if change_counter != self.change_counter:
+ raise att.ATT_Error(ErrorCode.INVALID_CHANGE_COUNTER)
+
+ handler = getattr(self, '_on_' + opcode.name.lower())
+ if handler(*value[2:]):
+ self.change_counter = (self.change_counter + 1) % 256
+ connection.abort_on(
+ 'disconnection',
+ connection.device.notify_subscribers(
+ attribute=self.volume_state,
+ value=self.volume_state_bytes,
+ ),
+ )
+ self.emit(
+ 'volume_state', self.volume_setting, self.muted, self.change_counter
+ )
+
+ def _on_relative_volume_down(self) -> bool:
+ old_volume = self.volume_setting
+ self.volume_setting = max(self.volume_setting - self.step_size, MIN_VOLUME)
+ return self.volume_setting != old_volume
+
+ def _on_relative_volume_up(self) -> bool:
+ old_volume = self.volume_setting
+ self.volume_setting = min(self.volume_setting + self.step_size, MAX_VOLUME)
+ return self.volume_setting != old_volume
+
+ def _on_unmute_relative_volume_down(self) -> bool:
+ old_volume, old_muted_state = self.volume_setting, self.muted
+ self.volume_setting = max(self.volume_setting - self.step_size, MIN_VOLUME)
+ self.muted = 0
+ return (self.volume_setting, self.muted) != (old_volume, old_muted_state)
+
+ def _on_unmute_relative_volume_up(self) -> bool:
+ old_volume, old_muted_state = self.volume_setting, self.muted
+ self.volume_setting = min(self.volume_setting + self.step_size, MAX_VOLUME)
+ self.muted = 0
+ return (self.volume_setting, self.muted) != (old_volume, old_muted_state)
+
+ def _on_set_absolute_volume(self, volume_setting: int) -> bool:
+ old_volume_setting = self.volume_setting
+ self.volume_setting = volume_setting
+ return old_volume_setting != self.volume_setting
+
+ def _on_unmute(self) -> bool:
+ old_muted_state = self.muted
+ self.muted = 0
+ return self.muted != old_muted_state
+
+ def _on_mute(self) -> bool:
+ old_muted_state = self.muted
+ self.muted = 1
+ return self.muted != old_muted_state
+
+
+# -----------------------------------------------------------------------------
+# Client
+# -----------------------------------------------------------------------------
+class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy):
+ SERVICE_CLASS = VolumeControlService
+
+ volume_control_point: gatt_client.CharacteristicProxy
+
+ def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
+ self.service_proxy = service_proxy
+
+ self.volume_state = gatt.PackedCharacteristicAdapter(
+ service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_VOLUME_STATE_CHARACTERISTIC
+ )[0],
+ 'BBB',
+ )
+
+ self.volume_control_point = service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC
+ )[0]
+
+ self.volume_flags = gatt.PackedCharacteristicAdapter(
+ service_proxy.get_characteristics_by_uuid(
+ gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC
+ )[0],
+ 'B',
+ )
diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py
index 53e98e0..6ca0f50 100644
--- a/bumble/rfcomm.py
+++ b/bumble/rfcomm.py
@@ -19,12 +19,16 @@ from __future__ import annotations
import logging
import asyncio
+import dataclasses
import enum
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
+from typing_extensions import Self
from pyee import EventEmitter
-from . import core, l2cap
+from bumble import core
+from bumble import l2cap
+from bumble import sdp
from .colors import color
from .core import (
UUID,
@@ -34,15 +38,6 @@ from .core import (
InvalidStateError,
ProtocolError,
)
-from .sdp import (
- SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
- SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
- SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
- SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
- SDP_PUBLIC_BROWSE_ROOT,
- DataElement,
- ServiceAttribute,
-)
if TYPE_CHECKING:
from bumble.device import Device, Connection
@@ -60,27 +55,18 @@ logger = logging.getLogger(__name__)
RFCOMM_PSM = 0x0003
+class FrameType(enum.IntEnum):
+ SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
+ UA = 0x63 # Control field [0,1,1,0,_,0,1,1] LSB-first
+ DM = 0x0F # Control field [1,1,1,1,_,0,0,0] LSB-first
+ DISC = 0x43 # Control field [0,1,0,_,0,0,1,1] LSB-first
+ UIH = 0xEF # Control field [1,1,1,_,1,1,1,1] LSB-first
+ UI = 0x03 # Control field [0,0,0,_,0,0,1,1] LSB-first
-# Frame types
-RFCOMM_SABM_FRAME = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
-RFCOMM_UA_FRAME = 0x63 # Control field [0,1,1,0,_,0,1,1] LSB-first
-RFCOMM_DM_FRAME = 0x0F # Control field [1,1,1,1,_,0,0,0] LSB-first
-RFCOMM_DISC_FRAME = 0x43 # Control field [0,1,0,_,0,0,1,1] LSB-first
-RFCOMM_UIH_FRAME = 0xEF # Control field [1,1,1,_,1,1,1,1] LSB-first
-RFCOMM_UI_FRAME = 0x03 # Control field [0,0,0,_,0,0,1,1] LSB-first
-
-RFCOMM_FRAME_TYPE_NAMES = {
- RFCOMM_SABM_FRAME: 'SABM',
- RFCOMM_UA_FRAME: 'UA',
- RFCOMM_DM_FRAME: 'DM',
- RFCOMM_DISC_FRAME: 'DISC',
- RFCOMM_UIH_FRAME: 'UIH',
- RFCOMM_UI_FRAME: 'UI'
-}
+class MccType(enum.IntEnum):
+ PN = 0x20
+ MSC = 0x38
-# MCC Types
-RFCOMM_MCC_PN_TYPE = 0x20
-RFCOMM_MCC_MSC_TYPE = 0x38
# FCS CRC
CRC_TABLE = bytes([
@@ -118,8 +104,9 @@ CRC_TABLE = bytes([
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
])
-RFCOMM_DEFAULT_INITIAL_RX_CREDITS = 7
-RFCOMM_DEFAULT_PREFERRED_MTU = 1280
+RFCOMM_DEFAULT_L2CAP_MTU = 2048
+RFCOMM_DEFAULT_WINDOW_SIZE = 7
+RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
@@ -130,29 +117,33 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
# -----------------------------------------------------------------------------
def make_service_sdp_records(
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
-) -> List[ServiceAttribute]:
+) -> List[sdp.ServiceAttribute]:
"""
Create SDP records for an RFComm service given a channel number and an
optional UUID. A Service Class Attribute is included only if the UUID is not None.
"""
records = [
- ServiceAttribute(
- SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
- DataElement.unsigned_integer_32(service_record_handle),
+ sdp.ServiceAttribute(
+ sdp.SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ sdp.DataElement.unsigned_integer_32(service_record_handle),
),
- ServiceAttribute(
- SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
- DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+ sdp.ServiceAttribute(
+ sdp.SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+ sdp.DataElement.sequence(
+ [sdp.DataElement.uuid(sdp.SDP_PUBLIC_BROWSE_ROOT)]
+ ),
),
- ServiceAttribute(
- SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
- DataElement.sequence(
+ sdp.ServiceAttribute(
+ sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ sdp.DataElement.sequence(
[
- DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
- DataElement.sequence(
+ sdp.DataElement.sequence(
+ [sdp.DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]
+ ),
+ sdp.DataElement.sequence(
[
- DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
- DataElement.unsigned_integer_8(channel),
+ sdp.DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
+ sdp.DataElement.unsigned_integer_8(channel),
]
),
]
@@ -162,9 +153,9 @@ def make_service_sdp_records(
if uuid:
records.append(
- ServiceAttribute(
- SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
- DataElement.sequence([DataElement.uuid(uuid)]),
+ sdp.ServiceAttribute(
+ sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ sdp.DataElement.sequence([sdp.DataElement.uuid(uuid)]),
)
)
@@ -172,6 +163,72 @@ def make_service_sdp_records(
# -----------------------------------------------------------------------------
+async def find_rfcomm_channels(connection: Connection) -> Dict[int, List[UUID]]:
+ """Searches all RFCOMM channels and their associated UUID from SDP service records.
+
+ Args:
+ connection: ACL connection to make SDP search.
+
+ Returns:
+ Dictionary mapping from channel number to service class UUID list.
+ """
+ results = {}
+ async with sdp.Client(connection) as sdp_client:
+ search_result = await sdp_client.search_attributes(
+ uuids=[core.BT_RFCOMM_PROTOCOL_ID],
+ attribute_ids=[
+ sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ ],
+ )
+ for attribute_lists in search_result:
+ service_classes: List[UUID] = []
+ channel: Optional[int] = None
+ for attribute in attribute_lists:
+ # The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
+ if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
+ protocol_descriptor_list = attribute.value.value
+ channel = protocol_descriptor_list[1].value[1].value
+ elif attribute.id == sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
+ service_class_id_list = attribute.value.value
+ service_classes = [
+ service_class.value for service_class in service_class_id_list
+ ]
+ if not service_classes or not channel:
+ logger.warning(f"Bad result {attribute_lists}.")
+ else:
+ results[channel] = service_classes
+ return results
+
+
+# -----------------------------------------------------------------------------
+async def find_rfcomm_channel_with_uuid(
+ connection: Connection, uuid: str | UUID
+) -> Optional[int]:
+ """Searches an RFCOMM channel associated with given UUID from service records.
+
+ Args:
+ connection: ACL connection to make SDP search.
+ uuid: UUID of service record to search for.
+
+ Returns:
+ RFCOMM channel number if found, otherwise None.
+ """
+ if isinstance(uuid, str):
+ uuid = UUID(uuid)
+ return next(
+ (
+ channel
+ for channel, class_id_list in (
+ await find_rfcomm_channels(connection)
+ ).items()
+ if uuid in class_id_list
+ ),
+ None,
+ )
+
+
+# -----------------------------------------------------------------------------
def compute_fcs(buffer: bytes) -> int:
result = 0xFF
for byte in buffer:
@@ -183,7 +240,7 @@ def compute_fcs(buffer: bytes) -> int:
class RFCOMM_Frame:
def __init__(
self,
- frame_type: int,
+ frame_type: FrameType,
c_r: int,
dlci: int,
p_f: int,
@@ -206,14 +263,11 @@ class RFCOMM_Frame:
self.length = bytes([(length << 1) | 1])
self.address = (dlci << 2) | (c_r << 1) | 1
self.control = frame_type | (p_f << 4)
- if frame_type == RFCOMM_UIH_FRAME:
+ if frame_type == FrameType.UIH:
self.fcs = compute_fcs(bytes([self.address, self.control]))
else:
self.fcs = compute_fcs(bytes([self.address, self.control]) + self.length)
- def type_name(self) -> str:
- return RFCOMM_FRAME_TYPE_NAMES[self.type]
-
@staticmethod
def parse_mcc(data) -> Tuple[int, bool, bytes]:
mcc_type = data[0] >> 2
@@ -237,24 +291,24 @@ class RFCOMM_Frame:
@staticmethod
def sabm(c_r: int, dlci: int):
- return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
+ return RFCOMM_Frame(FrameType.SABM, c_r, dlci, 1)
@staticmethod
def ua(c_r: int, dlci: int):
- return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
+ return RFCOMM_Frame(FrameType.UA, c_r, dlci, 1)
@staticmethod
def dm(c_r: int, dlci: int):
- return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
+ return RFCOMM_Frame(FrameType.DM, c_r, dlci, 1)
@staticmethod
def disc(c_r: int, dlci: int):
- return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
+ return RFCOMM_Frame(FrameType.DISC, c_r, dlci, 1)
@staticmethod
def uih(c_r: int, dlci: int, information: bytes, p_f: int = 0):
return RFCOMM_Frame(
- RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits=(p_f == 1)
+ FrameType.UIH, c_r, dlci, p_f, information, with_credits=(p_f == 1)
)
@staticmethod
@@ -262,7 +316,7 @@ class RFCOMM_Frame:
# Extract fields
dlci = (data[0] >> 2) & 0x3F
c_r = (data[0] >> 1) & 0x01
- frame_type = data[1] & 0xEF
+ frame_type = FrameType(data[1] & 0xEF)
p_f = (data[1] >> 4) & 0x01
length = data[2]
if length & 0x01:
@@ -291,7 +345,7 @@ class RFCOMM_Frame:
def __str__(self) -> str:
return (
- f'{color(self.type_name(), "yellow")}'
+ f'{color(self.type.name, "yellow")}'
f'(c/r={self.c_r},'
f'dlci={self.dlci},'
f'p/f={self.p_f},'
@@ -301,6 +355,7 @@ class RFCOMM_Frame:
# -----------------------------------------------------------------------------
+@dataclasses.dataclass
class RFCOMM_MCC_PN:
dlci: int
cl: int
@@ -310,23 +365,11 @@ class RFCOMM_MCC_PN:
max_retransmissions: int
window_size: int
- def __init__(
- self,
- dlci: int,
- cl: int,
- priority: int,
- ack_timer: int,
- max_frame_size: int,
- max_retransmissions: int,
- window_size: int,
- ) -> None:
- self.dlci = dlci
- self.cl = cl
- self.priority = priority
- self.ack_timer = ack_timer
- self.max_frame_size = max_frame_size
- self.max_retransmissions = max_retransmissions
- self.window_size = window_size
+ def __post_init__(self) -> None:
+ if self.window_size < 1 or self.window_size > 7:
+ logger.warning(
+ f'Error Recovery Window size {self.window_size} is out of range [1, 7].'
+ )
@staticmethod
def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
@@ -337,7 +380,7 @@ class RFCOMM_MCC_PN:
ack_timer=data[3],
max_frame_size=data[4] | data[5] << 8,
max_retransmissions=data[6],
- window_size=data[7],
+ window_size=data[7] & 0x07,
)
def __bytes__(self) -> bytes:
@@ -350,23 +393,14 @@ class RFCOMM_MCC_PN:
self.max_frame_size & 0xFF,
(self.max_frame_size >> 8) & 0xFF,
self.max_retransmissions & 0xFF,
- self.window_size & 0xFF,
+ # Only 3 bits are meaningful.
+ self.window_size & 0x07,
]
)
- def __str__(self) -> str:
- return (
- f'PN(dlci={self.dlci},'
- f'cl={self.cl},'
- f'priority={self.priority},'
- f'ack_timer={self.ack_timer},'
- f'max_frame_size={self.max_frame_size},'
- f'max_retransmissions={self.max_retransmissions},'
- f'window_size={self.window_size})'
- )
-
# -----------------------------------------------------------------------------
+@dataclasses.dataclass
class RFCOMM_MCC_MSC:
dlci: int
fc: int
@@ -375,16 +409,6 @@ class RFCOMM_MCC_MSC:
ic: int
dv: int
- def __init__(
- self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int
- ) -> None:
- self.dlci = dlci
- self.fc = fc
- self.rtc = rtc
- self.rtr = rtr
- self.ic = ic
- self.dv = dv
-
@staticmethod
def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
return RFCOMM_MCC_MSC(
@@ -409,16 +433,6 @@ class RFCOMM_MCC_MSC:
]
)
- def __str__(self) -> str:
- return (
- f'MSC(dlci={self.dlci},'
- f'fc={self.fc},'
- f'rtc={self.rtc},'
- f'rtr={self.rtr},'
- f'ic={self.ic},'
- f'dv={self.dv})'
- )
-
# -----------------------------------------------------------------------------
class DLC(EventEmitter):
@@ -438,25 +452,29 @@ class DLC(EventEmitter):
multiplexer: Multiplexer,
dlci: int,
max_frame_size: int,
- initial_tx_credits: int,
+ window_size: int,
) -> None:
super().__init__()
self.multiplexer = multiplexer
self.dlci = dlci
- self.rx_credits = RFCOMM_DEFAULT_INITIAL_RX_CREDITS
- self.rx_threshold = self.rx_credits // 2
- self.tx_credits = initial_tx_credits
+ self.max_frame_size = max_frame_size
+ self.window_size = window_size
+ self.rx_credits = window_size
+ self.rx_threshold = window_size // 2
+ self.tx_credits = window_size
self.tx_buffer = b''
self.state = DLC.State.INIT
self.role = multiplexer.role
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
self.sink = None
self.connection_result = None
+ self.drained = asyncio.Event()
+ self.drained.set()
# Compute the MTU
max_overhead = 4 + 1 # header with 2-byte length + fcs
self.mtu = min(
- max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
+ max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
)
def change_state(self, new_state: State) -> None:
@@ -467,7 +485,7 @@ class DLC(EventEmitter):
self.multiplexer.send_frame(frame)
def on_frame(self, frame: RFCOMM_Frame) -> None:
- handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
+ handler = getattr(self, f'on_{frame.type.name}_frame'.lower())
handler(frame)
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
@@ -481,9 +499,7 @@ class DLC(EventEmitter):
# Exchange the modem status with the peer
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
- mcc = RFCOMM_Frame.make_mcc(
- mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=1, data=bytes(msc)
- )
+ mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
@@ -499,9 +515,7 @@ class DLC(EventEmitter):
# Exchange the modem status with the peer
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
- mcc = RFCOMM_Frame.make_mcc(
- mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=1, data=bytes(msc)
- )
+ mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
@@ -534,14 +548,15 @@ class DLC(EventEmitter):
f'[{self.dlci}] {len(data)} bytes, '
f'rx_credits={self.rx_credits}: {data.hex()}'
)
- if len(data) and self.sink:
- self.sink(data) # pylint: disable=not-callable
+ if data:
+ if self.sink:
+ self.sink(data) # pylint: disable=not-callable
- # Update the credits
- if self.rx_credits > 0:
- self.rx_credits -= 1
- else:
- logger.warning(color('!!! received frame with no rx credits', 'red'))
+ # Update the credits
+ if self.rx_credits > 0:
+ self.rx_credits -= 1
+ else:
+ logger.warning(color('!!! received frame with no rx credits', 'red'))
# Check if there's anything to send (including credits)
self.process_tx()
@@ -554,9 +569,7 @@ class DLC(EventEmitter):
# Command
logger.debug(f'<<< MCC MSC Command: {msc}')
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
- mcc = RFCOMM_Frame.make_mcc(
- mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=0, data=bytes(msc)
- )
+ mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=0, data=bytes(msc))
logger.debug(f'>>> MCC MSC Response: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
else:
@@ -580,18 +593,18 @@ class DLC(EventEmitter):
cl=0xE0,
priority=7,
ack_timer=0,
- max_frame_size=RFCOMM_DEFAULT_PREFERRED_MTU,
+ max_frame_size=self.max_frame_size,
max_retransmissions=0,
- window_size=RFCOMM_DEFAULT_INITIAL_RX_CREDITS,
+ window_size=self.window_size,
)
- mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
+ mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn))
logger.debug(f'>>> PN Response: {pn}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.State.CONNECTING)
def rx_credits_needed(self) -> int:
if self.rx_credits <= self.rx_threshold:
- return RFCOMM_DEFAULT_INITIAL_RX_CREDITS - self.rx_credits
+ return self.window_size - self.rx_credits
return 0
@@ -631,6 +644,8 @@ class DLC(EventEmitter):
)
rx_credits_needed = 0
+ if not self.tx_buffer:
+ self.drained.set()
# Stream protocol
def write(self, data: Union[bytes, str]) -> None:
@@ -643,11 +658,11 @@ class DLC(EventEmitter):
raise ValueError('write only accept bytes or strings')
self.tx_buffer += data
+ self.drained.clear()
self.process_tx()
- def drain(self) -> None:
- # TODO
- pass
+ async def drain(self) -> None:
+ await self.drained.wait()
def __str__(self) -> str:
return f'DLC(dlci={self.dlci},state={self.state.name})'
@@ -704,7 +719,7 @@ class Multiplexer(EventEmitter):
if frame.dlci == 0:
self.on_frame(frame)
else:
- if frame.type == RFCOMM_DM_FRAME:
+ if frame.type == FrameType.DM:
# DM responses are for a DLCI, but since we only create the dlc when we
# receive a PN response (because we need the parameters), we handle DM
# frames at the Multiplexer level
@@ -717,7 +732,7 @@ class Multiplexer(EventEmitter):
dlc.on_frame(frame)
def on_frame(self, frame: RFCOMM_Frame) -> None:
- handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
+ handler = getattr(self, f'on_{frame.type.name}_frame'.lower())
handler(frame)
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
@@ -765,10 +780,10 @@ class Multiplexer(EventEmitter):
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
(mcc_type, c_r, value) = RFCOMM_Frame.parse_mcc(frame.information)
- if mcc_type == RFCOMM_MCC_PN_TYPE:
+ if mcc_type == MccType.PN:
pn = RFCOMM_MCC_PN.from_bytes(value)
self.on_mcc_pn(c_r, pn)
- elif mcc_type == RFCOMM_MCC_MSC_TYPE:
+ elif mcc_type == MccType.MSC:
mcs = RFCOMM_MCC_MSC.from_bytes(value)
self.on_mcc_msc(c_r, mcs)
@@ -843,7 +858,12 @@ class Multiplexer(EventEmitter):
)
await self.disconnection_result
- async def open_dlc(self, channel: int) -> DLC:
+ async def open_dlc(
+ self,
+ channel: int,
+ max_frame_size: int = RFCOMM_DEFAULT_MAX_FRAME_SIZE,
+ window_size: int = RFCOMM_DEFAULT_WINDOW_SIZE,
+ ) -> DLC:
if self.state != Multiplexer.State.CONNECTED:
if self.state == Multiplexer.State.OPENING:
raise InvalidStateError('open already in progress')
@@ -855,11 +875,11 @@ class Multiplexer(EventEmitter):
cl=0xF0,
priority=7,
ack_timer=0,
- max_frame_size=RFCOMM_DEFAULT_PREFERRED_MTU,
+ max_frame_size=max_frame_size,
max_retransmissions=0,
- window_size=RFCOMM_DEFAULT_INITIAL_RX_CREDITS,
+ window_size=window_size,
)
- mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
+ mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=1, data=bytes(pn))
logger.debug(f'>>> Sending MCC: {pn}')
self.open_result = asyncio.get_running_loop().create_future()
self.change_state(Multiplexer.State.OPENING)
@@ -889,9 +909,11 @@ class Client:
multiplexer: Optional[Multiplexer]
l2cap_channel: Optional[l2cap.ClassicChannel]
- def __init__(self, device: Device, connection: Connection) -> None:
- self.device = device
+ def __init__(
+ self, connection: Connection, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
+ ) -> None:
self.connection = connection
+ self.l2cap_mtu = l2cap_mtu
self.l2cap_channel = None
self.multiplexer = None
@@ -899,14 +921,14 @@ class Client:
# Create a new L2CAP connection
try:
self.l2cap_channel = await self.connection.create_l2cap_channel(
- spec=l2cap.ClassicChannelSpec(RFCOMM_PSM)
+ spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM, mtu=self.l2cap_mtu)
)
except ProtocolError as error:
logger.warning(f'L2CAP connection failed: {error}')
raise
assert self.l2cap_channel is not None
- # Create a mutliplexer to manage DLCs with the server
+ # Create a multiplexer to manage DLCs with the server
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
# Connect the multiplexer
@@ -922,22 +944,33 @@ class Client:
self.multiplexer = None
# Close the L2CAP channel
- # TODO
+ if self.l2cap_channel:
+ await self.l2cap_channel.disconnect()
+ self.l2cap_channel = None
+
+ async def __aenter__(self) -> Multiplexer:
+ return await self.start()
+
+ async def __aexit__(self, *args) -> None:
+ await self.shutdown()
# -----------------------------------------------------------------------------
class Server(EventEmitter):
acceptors: Dict[int, Callable[[DLC], None]]
- def __init__(self, device: Device) -> None:
+ def __init__(
+ self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
+ ) -> None:
super().__init__()
self.device = device
self.multiplexer = None
self.acceptors = {}
# Register ourselves with the L2CAP channel manager
- device.create_l2cap_server(
- spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM), handler=self.on_connection
+ self.l2cap_server = device.create_l2cap_server(
+ spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM, mtu=l2cap_mtu),
+ handler=self.on_connection,
)
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
@@ -987,3 +1020,9 @@ class Server(EventEmitter):
acceptor = self.acceptors.get(dlc.dlci >> 1)
if acceptor:
acceptor(dlc)
+
+ def __enter__(self) -> Self:
+ return self
+
+ def __exit__(self, *args) -> None:
+ self.l2cap_server.close()
diff --git a/bumble/sdp.py b/bumble/sdp.py
index bc8303c..6423790 100644
--- a/bumble/sdp.py
+++ b/bumble/sdp.py
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging
import struct
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
+from typing_extensions import Self
from . import core, l2cap
from .colors import color
@@ -97,7 +98,8 @@ SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
-# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
+
+# Profile-specific Attribute Identifiers (cf. Assigned Numbers for Service Discovery)
# used by AVRCP, HFP and A2DP
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
@@ -115,7 +117,8 @@ SDP_ATTRIBUTE_ID_NAMES = {
SDP_DOCUMENTATION_URL_ATTRIBUTE_ID: 'SDP_DOCUMENTATION_URL_ATTRIBUTE_ID',
SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID: 'SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID',
SDP_ICON_URL_ATTRIBUTE_ID: 'SDP_ICON_URL_ATTRIBUTE_ID',
- SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID: 'SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID'
+ SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID: 'SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID',
+ SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID: 'SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID',
}
SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
@@ -760,13 +763,13 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
class Client:
channel: Optional[l2cap.ClassicChannel]
- def __init__(self, device: Device) -> None:
- self.device = device
+ def __init__(self, connection: Connection) -> None:
+ self.connection = connection
self.pending_request = None
self.channel = None
- async def connect(self, connection: Connection) -> None:
- self.channel = await connection.create_l2cap_channel(
+ async def connect(self) -> None:
+ self.channel = await self.connection.create_l2cap_channel(
spec=l2cap.ClassicChannelSpec(SDP_PSM)
)
@@ -918,6 +921,13 @@ class Client:
return ServiceAttribute.list_from_data_elements(attribute_list_sequence.value)
+ async def __aenter__(self) -> Self:
+ await self.connect()
+ return self
+
+ async def __aexit__(self, *args) -> None:
+ await self.disconnect()
+
# -----------------------------------------------------------------------------
class Server:
diff --git a/bumble/smp.py b/bumble/smp.py
index f8bba40..73fd439 100644
--- a/bumble/smp.py
+++ b/bumble/smp.py
@@ -27,6 +27,7 @@ import logging
import asyncio
import enum
import secrets
+from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
@@ -53,6 +54,7 @@ from .core import (
BT_BR_EDR_TRANSPORT,
BT_CENTRAL_ROLE,
BT_LE_TRANSPORT,
+ AdvertisingData,
ProtocolError,
name_or_number,
)
@@ -185,8 +187,8 @@ SMP_KEYPRESS_AUTHREQ = 0b00010000
SMP_CT2_AUTHREQ = 0b00100000
# Crypto salt
-SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('00000000000000000000000000000000746D7031')
-SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('00000000000000000000000000000000746D7032')
+SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('000000000000000000000000746D7031')
+SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('000000000000000000000000746D7032')
# fmt: on
# pylint: enable=line-too-long
@@ -564,6 +566,54 @@ class PairingMethod(enum.IntEnum):
# -----------------------------------------------------------------------------
+class OobContext:
+ """Cryptographic context for LE SC OOB pairing."""
+
+ ecc_key: crypto.EccKey
+ r: bytes
+
+ def __init__(
+ self, ecc_key: Optional[crypto.EccKey] = None, r: Optional[bytes] = None
+ ) -> None:
+ self.ecc_key = crypto.EccKey.generate() if ecc_key is None else ecc_key
+ self.r = crypto.r() if r is None else r
+
+ def share(self) -> OobSharedData:
+ pkx = self.ecc_key.x[::-1]
+ return OobSharedData(c=crypto.f4(pkx, pkx, self.r, bytes(1)), r=self.r)
+
+
+# -----------------------------------------------------------------------------
+class OobLegacyContext:
+ """Cryptographic context for LE Legacy OOB pairing."""
+
+ tk: bytes
+
+ def __init__(self, tk: Optional[bytes] = None) -> None:
+ self.tk = crypto.r() if tk is None else tk
+
+
+# -----------------------------------------------------------------------------
+@dataclass
+class OobSharedData:
+ """Shareable data for LE SC OOB pairing."""
+
+ c: bytes
+ r: bytes
+
+ def to_ad(self) -> AdvertisingData:
+ return AdvertisingData(
+ [
+ (AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE, self.c),
+ (AdvertisingData.LE_SECURE_CONNECTIONS_RANDOM_VALUE, self.r),
+ ]
+ )
+
+ def __str__(self) -> str:
+ return f'OOB(C={self.c.hex()}, R={self.r.hex()})'
+
+
+# -----------------------------------------------------------------------------
class Session:
# I/O Capability to pairing method decision matrix
#
@@ -627,6 +677,13 @@ class Session:
},
}
+ ea: bytes
+ eb: bytes
+ ltk: bytes
+ preq: bytes
+ pres: bytes
+ tk: bytes
+
def __init__(
self,
manager: Manager,
@@ -636,17 +693,10 @@ class Session:
) -> None:
self.manager = manager
self.connection = connection
- self.preq: Optional[bytes] = None
- self.pres: Optional[bytes] = None
- self.ea = None
- self.eb = None
- self.tk = bytes(16)
- self.r = bytes(16)
self.stk = None
- self.ltk = None
self.ltk_ediv = 0
self.ltk_rand = bytes(8)
- self.link_key = None
+ self.link_key: Optional[bytes] = None
self.initiator_key_distribution: int = 0
self.responder_key_distribution: int = 0
self.peer_random_value: Optional[bytes] = None
@@ -659,7 +709,7 @@ class Session:
self.peer_bd_addr: Optional[Address] = None
self.peer_signature_key = None
self.peer_expected_distributions: List[Type[SMP_Command]] = []
- self.dh_key = None
+ self.dh_key = b''
self.confirm_value = None
self.passkey: Optional[int] = None
self.passkey_ready = asyncio.Event()
@@ -712,8 +762,8 @@ class Session:
self.io_capability = pairing_config.delegate.io_capability
self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
- # OOB (not supported yet)
- self.oob = False
+ # OOB
+ self.oob_data_flag = 0 if pairing_config.oob is None else 1
# Set up addresses
self_address = connection.self_address
@@ -729,9 +779,35 @@ class Session:
self.ia = bytes(peer_address)
self.iat = 1 if peer_address.is_random else 0
+ # Select the ECC key, TK and r initial value
+ if pairing_config.oob:
+ self.peer_oob_data = pairing_config.oob.peer_data
+ if pairing_config.sc:
+ if pairing_config.oob.our_context is None:
+ raise ValueError(
+ "oob pairing config requires a context when sc is True"
+ )
+ self.r = pairing_config.oob.our_context.r
+ self.ecc_key = pairing_config.oob.our_context.ecc_key
+ if pairing_config.oob.legacy_context is not None:
+ self.tk = pairing_config.oob.legacy_context.tk
+ else:
+ if pairing_config.oob.legacy_context is None:
+ raise ValueError(
+ "oob pairing config requires a legacy context when sc is False"
+ )
+ self.r = bytes(16)
+ self.ecc_key = manager.ecc_key
+ self.tk = pairing_config.oob.legacy_context.tk
+ else:
+ self.peer_oob_data = None
+ self.r = bytes(16)
+ self.ecc_key = manager.ecc_key
+ self.tk = bytes(16)
+
@property
def pkx(self) -> Tuple[bytes, bytes]:
- return (bytes(reversed(self.manager.ecc_key.x)), self.peer_public_key_x)
+ return (self.ecc_key.x[::-1], self.peer_public_key_x)
@property
def pka(self) -> bytes:
@@ -768,7 +844,10 @@ class Session:
return None
def decide_pairing_method(
- self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
+ self,
+ auth_req: int,
+ initiator_io_capability: int,
+ responder_io_capability: int,
) -> None:
if self.connection.transport == BT_BR_EDR_TRANSPORT:
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
@@ -909,7 +988,7 @@ class Session:
command = SMP_Pairing_Request_Command(
io_capability=self.io_capability,
- oob_data_flag=0,
+ oob_data_flag=self.oob_data_flag,
auth_req=self.auth_req,
maximum_encryption_key_size=16,
initiator_key_distribution=self.initiator_key_distribution,
@@ -921,7 +1000,7 @@ class Session:
def send_pairing_response_command(self) -> None:
response = SMP_Pairing_Response_Command(
io_capability=self.io_capability,
- oob_data_flag=0,
+ oob_data_flag=self.oob_data_flag,
auth_req=self.auth_req,
maximum_encryption_key_size=16,
initiator_key_distribution=self.initiator_key_distribution,
@@ -982,8 +1061,8 @@ class Session:
def send_public_key_command(self) -> None:
self.send_command(
SMP_Pairing_Public_Key_Command(
- public_key_x=bytes(reversed(self.manager.ecc_key.x)),
- public_key_y=bytes(reversed(self.manager.ecc_key.y)),
+ public_key_x=self.ecc_key.x[::-1],
+ public_key_y=self.ecc_key.y[::-1],
)
)
@@ -1011,7 +1090,7 @@ class Session:
# We can now encrypt the connection with the short term key, so that we can
# distribute the long term and/or other keys over an encrypted connection
self.manager.device.host.send_command_sync(
- HCI_LE_Enable_Encryption_Command( # type: ignore[call-arg]
+ HCI_LE_Enable_Encryption_Command(
connection_handle=self.connection.handle,
random_number=bytes(8),
encrypted_diversifier=0,
@@ -1019,18 +1098,56 @@ class Session:
)
)
- async def derive_ltk(self) -> None:
- link_key = await self.manager.device.get_link_key(self.connection.peer_address)
- assert link_key is not None
+ @classmethod
+ def derive_ltk(cls, link_key: bytes, ct2: bool) -> bytes:
+ '''Derives Long Term Key from Link Key.
+
+ Args:
+ link_key: BR/EDR Link Key bytes in little-endian.
+ ct2: whether ct2 is supported on both devices.
+ Returns:
+ LE Long Tern Key bytes in little-endian.
+ '''
ilk = (
crypto.h7(salt=SMP_CTKD_H7_BRLE_SALT, w=link_key)
- if self.ct2
+ if ct2
else crypto.h6(link_key, b'tmp2')
)
- self.ltk = crypto.h6(ilk, b'brle')
+ return crypto.h6(ilk, b'brle')
+
+ @classmethod
+ def derive_link_key(cls, ltk: bytes, ct2: bool) -> bytes:
+ '''Derives Link Key from Long Term Key.
+
+ Args:
+ ltk: LE Long Term Key bytes in little-endian.
+ ct2: whether ct2 is supported on both devices.
+ Returns:
+ BR/EDR Link Key bytes in little-endian.
+ '''
+ ilk = (
+ crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=ltk)
+ if ct2
+ else crypto.h6(ltk, b'tmp1')
+ )
+ return crypto.h6(ilk, b'lebr')
- def distribute_keys(self) -> None:
+ async def get_link_key_and_derive_ltk(self) -> None:
+ '''Retrieves BR/EDR Link Key from storage and derive it to LE LTK.'''
+ self.link_key = await self.manager.device.get_link_key(
+ self.connection.peer_address
+ )
+ if self.link_key is None:
+ logging.warning(
+ 'Try to derive LTK but host does not have the LK. Send a SMP_PAIRING_FAILED but the procedure will not be paused!'
+ )
+ self.send_pairing_failed(
+ SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR
+ )
+ else:
+ self.ltk = self.derive_ltk(self.link_key, self.ct2)
+ def distribute_keys(self) -> None:
# Distribute the keys as required
if self.is_initiator:
# CTKD: Derive LTK from LinkKey
@@ -1039,7 +1156,7 @@ class Session:
and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
):
self.ctkd_task = self.connection.abort_on(
- 'disconnection', self.derive_ltk()
+ 'disconnection', self.get_link_key_and_derive_ltk()
)
elif not self.sc:
# Distribute the LTK, EDIV and RAND
@@ -1069,12 +1186,7 @@ class Session:
# CTKD, calculate BR/EDR link key
if self.initiator_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
- ilk = (
- crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=self.ltk)
- if self.ct2
- else crypto.h6(self.ltk, b'tmp1')
- )
- self.link_key = crypto.h6(ilk, b'lebr')
+ self.link_key = self.derive_link_key(self.ltk, self.ct2)
else:
# CTKD: Derive LTK from LinkKey
@@ -1083,7 +1195,7 @@ class Session:
and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
):
self.ctkd_task = self.connection.abort_on(
- 'disconnection', self.derive_ltk()
+ 'disconnection', self.get_link_key_and_derive_ltk()
)
# Distribute the LTK, EDIV and RAND
elif not self.sc:
@@ -1113,12 +1225,7 @@ class Session:
# CTKD, calculate BR/EDR link key
if self.responder_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
- ilk = (
- crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=self.ltk)
- if self.ct2
- else crypto.h6(self.ltk, b'tmp1')
- )
- self.link_key = crypto.h6(ilk, b'lebr')
+ self.link_key = self.derive_link_key(self.ltk, self.ct2)
def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None:
# Set our expectations for what to wait for in the key distribution phase
@@ -1296,7 +1403,7 @@ class Session:
try:
handler(command)
except Exception as error:
- logger.warning(f'{color("!!! Exception in handler:", "red")} {error}')
+ logger.exception(f'{color("!!! Exception in handler:", "red")} {error}')
response = SMP_Pairing_Failed_Command(
reason=SMP_UNSPECIFIED_REASON_ERROR
)
@@ -1333,15 +1440,28 @@ class Session:
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
self.ct2 = self.ct2 and (command.auth_req & SMP_CT2_AUTHREQ != 0)
- # Check for OOB
- if command.oob_data_flag != 0:
- self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
- return
+ # Infer the pairing method
+ if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
+ not self.sc and (self.oob_data_flag != 0 and command.oob_data_flag != 0)
+ ):
+ # Use OOB
+ self.pairing_method = PairingMethod.OOB
+ if not self.sc and self.tk is None:
+ # For legacy OOB, TK is required.
+ logger.warning("legacy OOB without TK")
+ self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
+ return
+ if command.oob_data_flag == 0:
+ # The peer doesn't have OOB data, use r=0
+ self.r = bytes(16)
+ else:
+ # Decide which pairing method to use from the IO capability
+ self.decide_pairing_method(
+ command.auth_req,
+ command.io_capability,
+ self.io_capability,
+ )
- # Decide which pairing method to use
- self.decide_pairing_method(
- command.auth_req, command.io_capability, self.io_capability
- )
logger.debug(f'pairing method: {self.pairing_method.name}')
# Key distribution
@@ -1390,15 +1510,26 @@ class Session:
self.bonding = self.bonding and (command.auth_req & SMP_BONDING_AUTHREQ != 0)
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
- # Check for OOB
- if self.sc and command.oob_data_flag:
- self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
- return
+ # Infer the pairing method
+ if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
+ not self.sc and (self.oob_data_flag != 0 and command.oob_data_flag != 0)
+ ):
+ # Use OOB
+ self.pairing_method = PairingMethod.OOB
+ if not self.sc and self.tk is None:
+ # For legacy OOB, TK is required.
+ logger.warning("legacy OOB without TK")
+ self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
+ return
+ if command.oob_data_flag == 0:
+ # The peer doesn't have OOB data, use r=0
+ self.r = bytes(16)
+ else:
+ # Decide which pairing method to use from the IO capability
+ self.decide_pairing_method(
+ command.auth_req, self.io_capability, command.io_capability
+ )
- # Decide which pairing method to use
- self.decide_pairing_method(
- command.auth_req, self.io_capability, command.io_capability
- )
logger.debug(f'pairing method: {self.pairing_method.name}')
# Key distribution
@@ -1549,12 +1680,13 @@ class Session:
if self.passkey_step < 20:
self.send_pairing_confirm_command()
return
- else:
+ elif self.pairing_method != PairingMethod.OOB:
return
else:
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
+ PairingMethod.OOB,
):
self.send_pairing_random_command()
elif self.pairing_method == PairingMethod.PASSKEY:
@@ -1591,6 +1723,7 @@ class Session:
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
+ PairingMethod.OOB,
):
ra = bytes(16)
rb = ra
@@ -1599,7 +1732,6 @@ class Session:
ra = self.passkey.to_bytes(16, byteorder='little')
rb = ra
else:
- # OOB not implemented yet
return
assert self.preq and self.pres
@@ -1651,18 +1783,33 @@ class Session:
self.peer_public_key_y = command.public_key_y
# Compute the DH key
- self.dh_key = bytes(
- reversed(
- self.manager.ecc_key.dh(
- bytes(reversed(command.public_key_x)),
- bytes(reversed(command.public_key_y)),
- )
- )
- )
+ self.dh_key = self.ecc_key.dh(
+ command.public_key_x[::-1],
+ command.public_key_y[::-1],
+ )[::-1]
logger.debug(f'DH key: {self.dh_key.hex()}')
+ if self.pairing_method == PairingMethod.OOB:
+ # Check against shared OOB data
+ if self.peer_oob_data:
+ confirm_verifier = crypto.f4(
+ self.peer_public_key_x,
+ self.peer_public_key_x,
+ self.peer_oob_data.r,
+ bytes(1),
+ )
+ if not self.check_expected_value(
+ self.peer_oob_data.c,
+ confirm_verifier,
+ SMP_CONFIRM_VALUE_FAILED_ERROR,
+ ):
+ return
+
if self.is_initiator:
- self.send_pairing_confirm_command()
+ if self.pairing_method == PairingMethod.OOB:
+ self.send_pairing_random_command()
+ else:
+ self.send_pairing_confirm_command()
else:
if self.pairing_method == PairingMethod.PASSKEY:
self.display_or_input_passkey()
@@ -1673,6 +1820,7 @@ class Session:
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
+ PairingMethod.OOB,
):
# We can now send the confirmation value
self.send_pairing_confirm_command()
@@ -1701,7 +1849,6 @@ class Session:
else:
self.send_pairing_dhkey_check_command()
else:
- assert self.ltk
self.start_encryption(self.ltk)
def on_smp_pairing_failed_command(
@@ -1751,6 +1898,7 @@ class Manager(EventEmitter):
sessions: Dict[int, Session]
pairing_config_factory: Callable[[Connection], PairingConfig]
session_proxy: Type[Session]
+ _ecc_key: Optional[crypto.EccKey]
def __init__(
self,
@@ -1845,10 +1993,8 @@ class Manager(EventEmitter):
) -> None:
# Store the keys in the key store
if self.device.keystore and identity_address is not None:
- self.device.abort_on(
- 'flush', self.device.update_keys(str(identity_address), keys)
- )
-
+ # Make sure on_pairing emits after key update.
+ await self.device.update_keys(str(identity_address), keys)
# Notify the device
self.device.on_pairing(session.connection, identity_address, keys, session.sc)
diff --git a/bumble/transport/__init__.py b/bumble/transport/__init__.py
index bc0766b..6a9a6b5 100644
--- a/bumble/transport/__init__.py
+++ b/bumble/transport/__init__.py
@@ -18,6 +18,7 @@
from contextlib import asynccontextmanager
import logging
import os
+from typing import Optional
from .common import Transport, AsyncPipeSink, SnoopingTransport
from ..snoop import create_snooper
@@ -52,8 +53,16 @@ def _wrap_transport(transport: Transport) -> Transport:
async def open_transport(name: str) -> Transport:
"""
Open a transport by name.
- The name must be <type>:<parameters>
- Where <parameters> depend on the type (and may be empty for some types).
+ The name must be <type>:<metadata><parameters>
+ Where <parameters> depend on the type (and may be empty for some types), and
+ <metadata> is either omitted, or a ,-separated list of <key>=<value> pairs,
+ enclosed in [].
+ If there are not metadata or parameter, the : after the <type> may be omitted.
+ Examples:
+ * usb:0
+ * usb:[driver=rtk]0
+ * android-netsim
+
The supported types are:
* serial
* udp
@@ -71,87 +80,105 @@ async def open_transport(name: str) -> Transport:
* android-netsim
"""
- return _wrap_transport(await _open_transport(name))
+ scheme, *tail = name.split(':', 1)
+ spec = tail[0] if tail else None
+ metadata = None
+ if spec:
+ # Metadata may precede the spec
+ if spec.startswith('['):
+ metadata_str, *tail = spec[1:].split(']')
+ spec = tail[0] if tail else None
+ metadata = dict([entry.split('=') for entry in metadata_str.split(',')])
+
+ transport = await _open_transport(scheme, spec)
+ if metadata:
+ transport.source.metadata = { # type: ignore[attr-defined]
+ **metadata,
+ **getattr(transport.source, 'metadata', {}),
+ }
+ # pylint: disable=line-too-long
+ logger.debug(f'HCI metadata: {transport.source.metadata}') # type: ignore[attr-defined]
+
+ return _wrap_transport(transport)
# -----------------------------------------------------------------------------
-async def _open_transport(name: str) -> Transport:
+async def _open_transport(scheme: str, spec: Optional[str]) -> Transport:
# pylint: disable=import-outside-toplevel
# pylint: disable=too-many-return-statements
- scheme, *spec = name.split(':', 1)
if scheme == 'serial' and spec:
from .serial import open_serial_transport
- return await open_serial_transport(spec[0])
+ return await open_serial_transport(spec)
if scheme == 'udp' and spec:
from .udp import open_udp_transport
- return await open_udp_transport(spec[0])
+ return await open_udp_transport(spec)
if scheme == 'tcp-client' and spec:
from .tcp_client import open_tcp_client_transport
- return await open_tcp_client_transport(spec[0])
+ return await open_tcp_client_transport(spec)
if scheme == 'tcp-server' and spec:
from .tcp_server import open_tcp_server_transport
- return await open_tcp_server_transport(spec[0])
+ return await open_tcp_server_transport(spec)
if scheme == 'ws-client' and spec:
from .ws_client import open_ws_client_transport
- return await open_ws_client_transport(spec[0])
+ return await open_ws_client_transport(spec)
if scheme == 'ws-server' and spec:
from .ws_server import open_ws_server_transport
- return await open_ws_server_transport(spec[0])
+ return await open_ws_server_transport(spec)
if scheme == 'pty':
from .pty import open_pty_transport
- return await open_pty_transport(spec[0] if spec else None)
+ return await open_pty_transport(spec)
if scheme == 'file':
from .file import open_file_transport
assert spec is not None
- return await open_file_transport(spec[0])
+ return await open_file_transport(spec)
if scheme == 'vhci':
from .vhci import open_vhci_transport
- return await open_vhci_transport(spec[0] if spec else None)
+ return await open_vhci_transport(spec)
if scheme == 'hci-socket':
from .hci_socket import open_hci_socket_transport
- return await open_hci_socket_transport(spec[0] if spec else None)
+ return await open_hci_socket_transport(spec)
if scheme == 'usb':
from .usb import open_usb_transport
- assert spec is not None
- return await open_usb_transport(spec[0])
+ assert spec
+ return await open_usb_transport(spec)
if scheme == 'pyusb':
from .pyusb import open_pyusb_transport
- assert spec is not None
- return await open_pyusb_transport(spec[0])
+ assert spec
+ return await open_pyusb_transport(spec)
if scheme == 'android-emulator':
from .android_emulator import open_android_emulator_transport
- return await open_android_emulator_transport(spec[0] if spec else None)
+ return await open_android_emulator_transport(spec)
if scheme == 'android-netsim':
from .android_netsim import open_android_netsim_transport
- return await open_android_netsim_transport(spec[0] if spec else None)
+ return await open_android_netsim_transport(spec)
raise ValueError('unknown transport scheme')
@@ -170,12 +197,13 @@ 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
link = RemoteLink(name[11:])
await link.wait_until_connected()
- controller = Controller('remote', link=link)
+ controller = Controller('remote', link=link) # type:ignore[arg-type]
class LinkTransport(Transport):
async def close(self):
diff --git a/bumble/transport/android_emulator.py b/bumble/transport/android_emulator.py
index 8d19a9e..9cd7ec2 100644
--- a/bumble/transport/android_emulator.py
+++ b/bumble/transport/android_emulator.py
@@ -69,7 +69,7 @@ async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
mode = 'host'
server_host = 'localhost'
server_port = '8554'
- if spec is not None:
+ if spec:
params = spec.split(',')
for param in params:
if param.startswith('mode='):
diff --git a/bumble/transport/common.py b/bumble/transport/common.py
index 2786a75..ef35c9f 100644
--- a/bumble/transport/common.py
+++ b/bumble/transport/common.py
@@ -21,7 +21,7 @@ import struct
import asyncio
import logging
import io
-from typing import ContextManager, Tuple, Optional, Protocol, Dict
+from typing import Any, ContextManager, Tuple, Optional, Protocol, Dict
from bumble import hci
from bumble.colors import color
@@ -42,6 +42,7 @@ HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
hci.HCI_EVENT_PACKET: (1, 1, 'B'),
+ hci.HCI_ISO_DATA_PACKET: (2, 2, 'H'),
}
@@ -150,7 +151,7 @@ class PacketParser:
try:
self.sink.on_packet(bytes(self.packet))
except Exception as error:
- logger.warning(
+ logger.exception(
color(f'!!! Exception in on_packet: {error}', 'red')
)
self.reset()
@@ -167,11 +168,13 @@ class PacketReader:
def __init__(self, source: io.BufferedReader) -> None:
self.source = source
+ self.at_end = False
def next_packet(self) -> Optional[bytes]:
# Get the packet type
packet_type = self.source.read(1)
if len(packet_type) != 1:
+ self.at_end = True
return None
# Get the packet info based on its type
diff --git a/bumble/transport/hci_socket.py b/bumble/transport/hci_socket.py
index df9e885..4125043 100644
--- a/bumble/transport/hci_socket.py
+++ b/bumble/transport/hci_socket.py
@@ -59,10 +59,7 @@ async def open_hci_socket_transport(spec: Optional[str]) -> Transport:
) from error
# Compute the adapter index
- if spec is None:
- adapter_index = 0
- else:
- adapter_index = int(spec)
+ adapter_index = int(spec) if spec else 0
# Bind the socket
# NOTE: since Python doesn't support binding with the required address format (yet),
diff --git a/bumble/transport/pyusb.py b/bumble/transport/pyusb.py
index 5e686d1..61ce17e 100644
--- a/bumble/transport/pyusb.py
+++ b/bumble/transport/pyusb.py
@@ -113,9 +113,10 @@ async def open_pyusb_transport(spec: str) -> Transport:
self.loop.call_soon_threadsafe(self.stop_event.set)
class UsbPacketSource(asyncio.Protocol, ParserSource):
- def __init__(self, device, sco_enabled):
+ def __init__(self, device, metadata, sco_enabled):
super().__init__()
self.device = device
+ self.metadata = metadata
self.loop = asyncio.get_running_loop()
self.queue = asyncio.Queue()
self.dequeue_task = None
@@ -216,6 +217,15 @@ async def open_pyusb_transport(spec: str) -> Transport:
if ':' in spec:
vendor_id, product_id = spec.split(':')
device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
+ elif '-' in spec:
+
+ def device_path(device):
+ if device.port_numbers:
+ return f'{device.bus}-{".".join(map(str, device.port_numbers))}'
+ else:
+ return str(device.bus)
+
+ device = usb_find(custom_match=lambda device: device_path(device) == spec)
else:
device_index = int(spec)
devices = list(
@@ -235,6 +245,9 @@ async def open_pyusb_transport(spec: str) -> Transport:
raise ValueError('device not found')
logger.debug(f'USB Device: {device}')
+ # Collect the metadata
+ device_metadata = {'vendor_id': device.idVendor, 'product_id': device.idProduct}
+
# Detach the kernel driver if needed
if device.is_kernel_driver_active(0):
logger.debug("detaching kernel driver")
@@ -289,7 +302,7 @@ async def open_pyusb_transport(spec: str) -> Transport:
# except usb.USBError:
# logger.warning('failed to set alternate setting')
- packet_source = UsbPacketSource(device, sco_enabled)
+ packet_source = UsbPacketSource(device, device_metadata, sco_enabled)
packet_sink = UsbPacketSink(device)
packet_source.start()
packet_sink.start()
diff --git a/bumble/transport/usb.py b/bumble/transport/usb.py
index ccc82c1..6479016 100644
--- a/bumble/transport/usb.py
+++ b/bumble/transport/usb.py
@@ -24,9 +24,10 @@ import platform
import usb1
-from .common import Transport, ParserSource
-from .. import hci
-from ..colors import color
+from bumble.transport.common import Transport, ParserSource
+from bumble import hci
+from bumble.colors import color
+from bumble.utils import AsyncRunner
# -----------------------------------------------------------------------------
@@ -107,13 +108,13 @@ async def open_usb_transport(spec: str) -> Transport:
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER,
)
- READ_SIZE = 1024
+ READ_SIZE = 4096
class UsbPacketSink:
def __init__(self, device, acl_out):
self.device = device
self.acl_out = acl_out
- self.transfer = device.getTransfer()
+ self.acl_out_transfer = device.getTransfer()
self.packets = collections.deque() # Queue of packets waiting to be sent
self.loop = asyncio.get_running_loop()
self.cancel_done = self.loop.create_future()
@@ -137,21 +138,20 @@ async def open_usb_transport(spec: str) -> Transport:
# The queue was previously empty, re-prime the pump
self.process_queue()
- def on_packet_sent(self, transfer):
+ def transfer_callback(self, transfer):
status = transfer.getStatus()
- # logger.debug(f'<<< USB out transfer callback: status={status}')
# pylint: disable=no-member
if status == usb1.TRANSFER_COMPLETED:
- self.loop.call_soon_threadsafe(self.on_packet_sent_)
+ self.loop.call_soon_threadsafe(self.on_packet_sent)
elif status == usb1.TRANSFER_CANCELLED:
self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
else:
logger.warning(
- color(f'!!! out transfer not completed: status={status}', 'red')
+ color(f'!!! OUT transfer not completed: status={status}', 'red')
)
- def on_packet_sent_(self):
+ def on_packet_sent(self):
if self.packets:
self.packets.popleft()
self.process_queue()
@@ -163,22 +163,20 @@ async def open_usb_transport(spec: str) -> Transport:
packet = self.packets[0]
packet_type = packet[0]
if packet_type == hci.HCI_ACL_DATA_PACKET:
- self.transfer.setBulk(
- self.acl_out, packet[1:], callback=self.on_packet_sent
+ self.acl_out_transfer.setBulk(
+ self.acl_out, packet[1:], callback=self.transfer_callback
)
- logger.debug('submit ACL')
- self.transfer.submit()
+ self.acl_out_transfer.submit()
elif packet_type == hci.HCI_COMMAND_PACKET:
- self.transfer.setControl(
+ self.acl_out_transfer.setControl(
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
0,
0,
0,
packet[1:],
- callback=self.on_packet_sent,
+ callback=self.transfer_callback,
)
- logger.debug('submit COMMAND')
- self.transfer.submit()
+ self.acl_out_transfer.submit()
else:
logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
@@ -193,11 +191,11 @@ async def open_usb_transport(spec: str) -> Transport:
self.packets.clear()
# If we have a transfer in flight, cancel it
- if self.transfer.isSubmitted():
+ if self.acl_out_transfer.isSubmitted():
# Try to cancel the transfer, but that may fail because it may have
# already completed
try:
- self.transfer.cancel()
+ self.acl_out_transfer.cancel()
logger.debug('waiting for OUT transfer cancellation to be done...')
await self.cancel_done
@@ -206,27 +204,22 @@ async def open_usb_transport(spec: str) -> Transport:
logger.debug('OUT transfer likely already completed')
class UsbPacketSource(asyncio.Protocol, ParserSource):
- def __init__(self, context, device, metadata, acl_in, events_in):
+ def __init__(self, device, metadata, acl_in, events_in):
super().__init__()
- self.context = context
self.device = device
self.metadata = metadata
self.acl_in = acl_in
+ self.acl_in_transfer = None
self.events_in = events_in
+ self.events_in_transfer = None
self.loop = asyncio.get_running_loop()
self.queue = asyncio.Queue()
self.dequeue_task = None
- self.closed = False
- self.event_loop_done = self.loop.create_future()
self.cancel_done = {
hci.HCI_EVENT_PACKET: self.loop.create_future(),
hci.HCI_ACL_DATA_PACKET: self.loop.create_future(),
}
- self.events_in_transfer = None
- self.acl_in_transfer = None
-
- # Create a thread to process events
- self.event_thread = threading.Thread(target=self.run)
+ self.closed = False
def start(self):
# Set up transfer objects for input
@@ -234,7 +227,7 @@ async def open_usb_transport(spec: str) -> Transport:
self.events_in_transfer.setInterrupt(
self.events_in,
READ_SIZE,
- callback=self.on_packet_received,
+ callback=self.transfer_callback,
user_data=hci.HCI_EVENT_PACKET,
)
self.events_in_transfer.submit()
@@ -243,22 +236,23 @@ async def open_usb_transport(spec: str) -> Transport:
self.acl_in_transfer.setBulk(
self.acl_in,
READ_SIZE,
- callback=self.on_packet_received,
+ callback=self.transfer_callback,
user_data=hci.HCI_ACL_DATA_PACKET,
)
self.acl_in_transfer.submit()
self.dequeue_task = self.loop.create_task(self.dequeue())
- self.event_thread.start()
- def on_packet_received(self, transfer):
+ @property
+ def usb_transfer_submitted(self):
+ return (
+ self.events_in_transfer.isSubmitted()
+ or self.acl_in_transfer.isSubmitted()
+ )
+
+ def transfer_callback(self, transfer):
packet_type = transfer.getUserData()
status = transfer.getStatus()
- # logger.debug(
- # f'<<< USB IN transfer callback: status={status} '
- # f'packet_type={packet_type} '
- # f'length={transfer.getActualLength()}'
- # )
# pylint: disable=no-member
if status == usb1.TRANSFER_COMPLETED:
@@ -267,18 +261,18 @@ async def open_usb_transport(spec: str) -> Transport:
+ transfer.getBuffer()[: transfer.getActualLength()]
)
self.loop.call_soon_threadsafe(self.queue.put_nowait, packet)
+
+ # Re-submit the transfer so we can receive more data
+ transfer.submit()
elif status == usb1.TRANSFER_CANCELLED:
self.loop.call_soon_threadsafe(
self.cancel_done[packet_type].set_result, None
)
- return
else:
logger.warning(
- color(f'!!! transfer not completed: status={status}', 'red')
+ color(f'!!! IN transfer not completed: status={status}', 'red')
)
-
- # Re-submit the transfer so we can receive more data
- transfer.submit()
+ self.loop.call_soon_threadsafe(self.on_transport_lost)
async def dequeue(self):
while not self.closed:
@@ -288,21 +282,6 @@ async def open_usb_transport(spec: str) -> Transport:
return
self.parser.feed_data(packet)
- def run(self):
- logger.debug('starting USB event loop')
- while (
- self.events_in_transfer.isSubmitted()
- or self.acl_in_transfer.isSubmitted()
- ):
- # pylint: disable=no-member
- try:
- self.context.handleEvents()
- except usb1.USBErrorInterrupted:
- pass
-
- logger.debug('USB event loop done')
- self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
-
def close(self):
self.closed = True
@@ -331,15 +310,14 @@ async def open_usb_transport(spec: str) -> Transport:
f'IN[{packet_type}] transfer likely already completed'
)
- # Wait for the thread to terminate
- await self.event_loop_done
-
class UsbTransport(Transport):
def __init__(self, context, device, interface, setting, source, sink):
super().__init__(source, sink)
self.context = context
self.device = device
self.interface = interface
+ self.loop = asyncio.get_running_loop()
+ self.event_loop_done = self.loop.create_future()
# Get exclusive access
device.claimInterface(interface)
@@ -352,6 +330,22 @@ async def open_usb_transport(spec: str) -> Transport:
source.start()
sink.start()
+ # Create a thread to process events
+ self.event_thread = threading.Thread(target=self.run)
+ self.event_thread.start()
+
+ def run(self):
+ logger.debug('starting USB event loop')
+ while self.source.usb_transfer_submitted:
+ # pylint: disable=no-member
+ try:
+ self.context.handleEvents()
+ except usb1.USBErrorInterrupted:
+ pass
+
+ logger.debug('USB event loop done')
+ self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
+
async def close(self):
self.source.close()
self.sink.close()
@@ -361,6 +355,9 @@ async def open_usb_transport(spec: str) -> Transport:
self.device.close()
self.context.close()
+ # Wait for the thread to terminate
+ await self.event_loop_done
+
# Find the device according to the spec moniker
load_libusb()
context = usb1.USBContext()
@@ -399,6 +396,16 @@ async def open_usb_transport(spec: str) -> Transport:
break
device_index -= 1
device.close()
+ elif '-' in spec:
+
+ def device_path(device):
+ return f'{device.getBusNumber()}-{".".join(map(str, device.getPortNumberList()))}'
+
+ for device in context.getDeviceIterator(skip_on_error=True):
+ if device_path(device) == spec:
+ found = device
+ break
+ device.close()
else:
# Look for a compatible device by index
def device_is_bluetooth_hci(device):
@@ -540,7 +547,7 @@ async def open_usb_transport(spec: str) -> Transport:
except usb1.USBError:
logger.warning('failed to set configuration')
- source = UsbPacketSource(context, device, device_metadata, acl_in, events_in)
+ source = UsbPacketSource(device, device_metadata, acl_in, events_in)
sink = UsbPacketSink(device, acl_out)
return UsbTransport(context, device, interface, setting, source, sink)
except usb1.USBError as error:
diff --git a/bumble/utils.py b/bumble/utils.py
index a562618..e6aae4d 100644
--- a/bumble/utils.py
+++ b/bumble/utils.py
@@ -17,9 +17,10 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
-import logging
-import traceback
import collections
+import enum
+import functools
+import logging
import sys
import warnings
from typing import (
@@ -34,7 +35,7 @@ from typing import (
Union,
overload,
)
-from functools import wraps, partial
+
from pyee import EventEmitter
from .colors import color
@@ -131,13 +132,14 @@ class EventWatcher:
Args:
emitter: EventEmitter to watch
event: Event name
- handler: (Optional) Event handler. When nothing is passed, this method works as a decorator.
+ handler: (Optional) Event handler. When nothing is passed, this method
+ works as a decorator.
'''
- def wrapper(f: _Handler) -> _Handler:
- self.handlers.append((emitter, event, f))
- emitter.on(event, f)
- return f
+ def wrapper(wrapped: _Handler) -> _Handler:
+ self.handlers.append((emitter, event, wrapped))
+ emitter.on(event, wrapped)
+ return wrapped
return wrapper if handler is None else wrapper(handler)
@@ -157,13 +159,14 @@ class EventWatcher:
Args:
emitter: EventEmitter to watch
event: Event name
- handler: (Optional) Event handler. When nothing passed, this method works as a decorator.
+ handler: (Optional) Event handler. When nothing passed, this method works
+ as a decorator.
'''
- def wrapper(f: _Handler) -> _Handler:
- self.handlers.append((emitter, event, f))
- emitter.once(event, f)
- return f
+ def wrapper(wrapped: _Handler) -> _Handler:
+ self.handlers.append((emitter, event, wrapped))
+ emitter.once(event, wrapped)
+ return wrapped
return wrapper if handler is None else wrapper(handler)
@@ -223,13 +226,13 @@ class CompositeEventEmitter(AbortableEventEmitter):
if self._listener:
# Call the deregistration methods for each base class that has them
for cls in self._listener.__class__.mro():
- if hasattr(cls, '_bumble_register_composite'):
- cls._bumble_deregister_composite(listener, self)
+ if '_bumble_register_composite' in cls.__dict__:
+ cls._bumble_deregister_composite(self._listener, self)
self._listener = listener
if listener:
# Call the registration methods for each base class that has them
for cls in listener.__class__.mro():
- if hasattr(cls, '_bumble_deregister_composite'):
+ if '_bumble_deregister_composite' in cls.__dict__:
cls._bumble_register_composite(listener, self)
@@ -276,21 +279,18 @@ class AsyncRunner:
"""
def decorator(func):
- @wraps(func)
+ @functools.wraps(func)
def wrapper(*args, **kwargs):
coroutine = func(*args, **kwargs)
if queue is None:
- # Create a task to run the coroutine
+ # Spawn the coroutine as a task
async def run():
try:
await coroutine
except Exception:
- logger.warning(
- f'{color("!!! Exception in wrapper:", "red")} '
- f'{traceback.format_exc()}'
- )
+ logger.exception(color("!!! Exception in wrapper:", "red"))
- asyncio.create_task(run())
+ AsyncRunner.spawn(run())
else:
# Queue the coroutine to be awaited by the work queue
queue.enqueue(coroutine)
@@ -413,30 +413,35 @@ class FlowControlAsyncPipe:
self.check_pump()
+# -----------------------------------------------------------------------------
async def async_call(function, *args, **kwargs):
"""
- Immediately calls the function with provided args and kwargs, wrapping it in an async function.
- Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject a running loop.
+ Immediately calls the function with provided args and kwargs, wrapping it in an
+ async function.
+ Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject
+ a running loop.
result = await async_call(some_function, ...)
"""
return function(*args, **kwargs)
+# -----------------------------------------------------------------------------
def wrap_async(function):
"""
Wraps the provided function in an async function.
"""
- return partial(async_call, function)
+ return functools.partial(async_call, function)
+# -----------------------------------------------------------------------------
def deprecated(msg: str):
"""
- Throw deprecation warning before execution
+ Throw deprecation warning before execution.
"""
def wrapper(function):
- @wraps(function)
+ @functools.wraps(function)
def inner(*args, **kwargs):
warnings.warn(msg, DeprecationWarning)
return function(*args, **kwargs)
@@ -444,3 +449,39 @@ def deprecated(msg: str):
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):
+ """
+ Subclass of enum.IntEnum that can hold integer values outside the set of
+ predefined values. This is convenient for implementing protocols where some
+ integer constants may be added over time.
+ """
+
+ @classmethod
+ def _missing_(cls, value):
+ if not isinstance(value, int):
+ return None
+
+ obj = int.__new__(cls, value)
+ obj._value_ = value
+ obj._name_ = f"{cls.__name__}[{value}]"
+ return obj
diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml
index 67bdd7c..6590d12 100644
--- a/docs/mkdocs/mkdocs.yml
+++ b/docs/mkdocs/mkdocs.yml
@@ -10,7 +10,7 @@ nav:
- Contributing: development/contributing.md
- Code Style: development/code_style.md
- Use Cases:
- - Overview: use_cases/index.md
+ - use_cases/index.md
- Use Case 1: use_cases/use_case_1.md
- Use Case 2: use_cases/use_case_2.md
- Use Case 3: use_cases/use_case_3.md
@@ -23,7 +23,7 @@ nav:
- GATT: components/gatt.md
- Security Manager: components/security_manager.md
- Transports:
- - Overview: transports/index.md
+ - transports/index.md
- Serial: transports/serial.md
- USB: transports/usb.md
- PTY: transports/pty.md
@@ -37,14 +37,14 @@ nav:
- Android Emulator: transports/android_emulator.md
- File: transports/file.md
- Drivers:
- - Overview: drivers/index.md
+ - drivers/index.md
- Realtek: drivers/realtek.md
- API:
- Guide: api/guide.md
- Examples: api/examples.md
- Reference: api/reference.md
- Apps & Tools:
- - Overview: apps_and_tools/index.md
+ - apps_and_tools/index.md
- Console: apps_and_tools/console.md
- Bench: apps_and_tools/bench.md
- Speaker: apps_and_tools/speaker.md
@@ -57,19 +57,25 @@ nav:
- USB Probe: apps_and_tools/usb_probe.md
- Link Relay: apps_and_tools/link_relay.md
- Hardware:
- - Overview: hardware/index.md
+ - hardware/index.md
- Platforms:
- - Overview: platforms/index.md
+ - platforms/index.md
- macOS: platforms/macos.md
- Linux: platforms/linux.md
- Windows: platforms/windows.md
- Android: platforms/android.md
- Zephyr: platforms/zephyr.md
- Examples:
- - Overview: examples/index.md
+ - examples/index.md
- Extras:
- - Overview: extras/index.md
+ - extras/index.md
- Android Remote HCI: extras/android_remote_hci.md
+ - Android BT Bench: extras/android_bt_bench.md
+ - Hive:
+ - hive/index.md
+ - Speaker: hive/web/speaker/speaker.html
+ - Scanner: hive/web/scanner/scanner.html
+ - Heart Rate Monitor: hive/web/heart_rate_monitor/heart_rate_monitor.html
copyright: Copyright 2021-2023 Google LLC
@@ -78,6 +84,8 @@ theme:
logo: 'images/logo.png'
favicon: 'images/favicon.ico'
custom_dir: 'theme'
+ features:
+ - navigation.indexes
plugins:
- mkdocstrings:
@@ -102,6 +110,8 @@ markdown_extensions:
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
+ - pymdownx.tabbed:
+ alternate_style: true
- codehilite:
guess_lang: false
- toc:
diff --git a/docs/mkdocs/src/apps_and_tools/bench.md b/docs/mkdocs/src/apps_and_tools/bench.md
index db785d6..be68161 100644
--- a/docs/mkdocs/src/apps_and_tools/bench.md
+++ b/docs/mkdocs/src/apps_and_tools/bench.md
@@ -7,16 +7,36 @@ throughput and/or latency between two devices.
# General Usage
```
-Usage: bench.py [OPTIONS] COMMAND [ARGS]...
+Usage: bumble-bench [OPTIONS] COMMAND [ARGS]...
Options:
--device-config FILENAME Device configuration file
--role [sender|receiver|ping|pong]
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
- -s, --packet-size SIZE Packet size (server role) [8<=x<=4096]
- -c, --packet-count COUNT Packet count (server role)
- -sd, --start-delay SECONDS Start delay (server role)
+ --extended-data-length TEXT Request a data length upon connection,
+ specified as tx_octets/tx_time
+ --rfcomm-channel INTEGER RFComm channel to use
+ --rfcomm-uuid TEXT RFComm service UUID to use (ignored if
+ --rfcomm-channel is not 0)
+ --l2cap-psm INTEGER L2CAP PSM to use
+ --l2cap-mtu INTEGER L2CAP MTU to use
+ --l2cap-mps INTEGER L2CAP MPS to use
+ --l2cap-max-credits INTEGER L2CAP maximum number of credits allowed for
+ the peer
+ -s, --packet-size SIZE Packet size (client or ping role)
+ [8<=x<=4096]
+ -c, --packet-count COUNT Packet count (client or ping role)
+ -sd, --start-delay SECONDS Start delay (client or ping role)
+ --repeat N Repeat the run N times (client and ping
+ roles)(0, which is the fault, to run just
+ once)
+ --repeat-delay SECONDS Delay, in seconds, between repeats
+ --pace MILLISECONDS Wait N milliseconds between packets (0,
+ which is the fault, to send as fast as
+ possible)
+ --linger Don't exit at the end of a run (server and
+ pong roles)
--help Show this message and exit.
Commands:
@@ -35,17 +55,18 @@ Options:
--connection-interval, --ci CONNECTION_INTERVAL
Connection interval (in ms)
--phy [1m|2m|coded] PHY to use
+ --authenticate Authenticate (RFComm only)
+ --encrypt Encrypt the connection (RFComm only)
--help Show this message and exit.
```
-
-To test once device against another, one of the two devices must be running
+To test once device against another, one of the two devices must be running
the ``peripheral`` command and the other the ``central`` command. The device
running the ``peripheral`` command will accept connections from the device
running the ``central`` command.
When using Bluetooth LE (all modes except for ``rfcomm-server`` and ``rfcomm-client``utils),
-the default addresses configured in the tool should be sufficient. But when using
-Bluetooth Classic, the address of the Peripheral must be specified on the Central
+the default addresses configured in the tool should be sufficient. But when using
+Bluetooth Classic, the address of the Peripheral must be specified on the Central
using the ``--peripheral`` option. The address will be printed by the Peripheral when
it starts.
@@ -83,7 +104,7 @@ the other on `usb:1`, and two consoles/terminals. We will run a command in each.
$ bumble-bench central usb:1
```
- In this default configuration, the Central runs a Sender, as a GATT client,
+ In this default configuration, the Central runs a Sender, as a GATT client,
connecting to the Peripheral running a Receiver, as a GATT server.
!!! example "L2CAP Throughput"
diff --git a/docs/mkdocs/src/apps_and_tools/hci_bridge.md b/docs/mkdocs/src/apps_and_tools/hci_bridge.md
index d0ea1fc..9b39c94 100644
--- a/docs/mkdocs/src/apps_and_tools/hci_bridge.md
+++ b/docs/mkdocs/src/apps_and_tools/hci_bridge.md
@@ -12,12 +12,25 @@ a host that send custom HCI commands that the controller may not understand.
```
python hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list]
```
+ The command-short-circuit-list field is specified by a series of comma separated Opcode Group
+ Field (OGF) : OpCode Command Field (OCF) pairs. The OGF/OCF values are specified in the Blutooth
+ core specification.
+
+ For the commands that are listed in the short-circuit-list, the HCI bridge will always generate
+ a Command Complete Event for the specified op code. The return parameter will be HCI_SUCCESS.
+
+ This feature can only be used for commands that return Command Complete. Other events will not be
+ generated by the HCI bridge tool.
!!! example "UDP to Serial"
```
python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078
```
+ In this example, the short circuit list is specified to respond to the Vendor-specific Opcode Group
+ Field (0x3f) commands 0x70, 0x74, 0x77, 0x78 with Command Complete. The short circuit list can be
+ used where the Host uses some HCI commands that are not supported/implemented by the Controller.
+
!!! example "PTY to Link Relay"
```
python hci_bridge.py serial:emulated_uart_pty,1000000 link-relay:ws://127.0.0.1:10723/test
@@ -28,3 +41,4 @@ a host that send custom HCI commands that the controller may not understand.
(through which the communication with other virtual controllers will be mediated).
NOTE: this assumes you're running a Link Relay on port `10723`.
+
diff --git a/docs/mkdocs/src/drivers/index.md b/docs/mkdocs/src/drivers/index.md
index a904e00..aa5f0a1 100644
--- a/docs/mkdocs/src/drivers/index.md
+++ b/docs/mkdocs/src/drivers/index.md
@@ -5,6 +5,15 @@ Some Bluetooth controllers require a driver to function properly.
This may include, for instance, loading a Firmware image or patch,
loading a configuration.
+By default, drivers will be automatically probed to determine if they should be
+used with particular HCI controller.
+When the transport for an HCI controller is instantiated from a transport name,
+a driver may also be forced by specifying ``driver=<driver-name>`` in the optional
+metadata portion of the transport name. For example,
+``usb:[driver=rtk]0`` indicates that the ``rtk`` driver should be used with the
+first USB device, even if a normal probe would not have selected it based on the
+USB vendor ID and product ID.
+
Drivers included in the module are:
* [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles. \ No newline at end of file
diff --git a/docs/mkdocs/src/drivers/realtek.md b/docs/mkdocs/src/drivers/realtek.md
index acbce49..599ce04 100644
--- a/docs/mkdocs/src/drivers/realtek.md
+++ b/docs/mkdocs/src/drivers/realtek.md
@@ -1,13 +1,16 @@
REALTEK DRIVER
==============
-This driver supports loading firmware images and optional config data to
+This driver supports loading firmware images and optional config data to
USB dongles with a Realtek chipset.
A number of USB dongles are supported, but likely not all.
-When using a USB dongle, the USB product ID and manufacturer ID are used
+When using a USB dongle, the USB product ID and vendor ID are used
to find whether a matching set of firmware image and config data
is needed for that specific model. If a match exists, the driver will try
load the firmware image and, if needed, config data.
+Alternatively, the metadata property ``driver=rtk`` may be specified in a transport
+name to force that driver to be used (ex: ``usb:[driver=rtk]0`` instead of just
+``usb:0`` for the first USB device).
The driver will look for those files by name, in order, in:
* The directory specified by the environment variable `BUMBLE_RTK_FIRMWARE_DIR`
diff --git a/docs/mkdocs/src/extras/android_bt_bench.md b/docs/mkdocs/src/extras/android_bt_bench.md
new file mode 100644
index 0000000..2417e00
--- /dev/null
+++ b/docs/mkdocs/src/extras/android_bt_bench.md
@@ -0,0 +1,64 @@
+ANDROID BENCH APP
+=================
+
+This Android app that is compatible with the Bumble `bench` command line app.
+This app can be used to test the throughput and latency between two Android
+devices, or between an Android device and another device running the Bumble
+`bench` app.
+Only the RFComm Client, RFComm Server, L2CAP Client and L2CAP Server modes are
+supported.
+
+Building
+--------
+
+You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `BtBench` top level directory.
+You can also build with Android Studio: open the `BtBench` project. You can build and/or debug from there.
+
+If the build succeeds, you can find the app APKs (debug and release) at:
+
+ * [Release] ``app/build/outputs/apk/release/app-release-unsigned.apk``
+ * [Debug] ``app/build/outputs/apk/debug/app-debug.apk``
+
+
+Running
+-------
+
+### Starting the app
+You can start the app from the Android launcher, from Android Studio, or with `adb`
+
+#### Launching from the launcher
+Just tap the app icon on the launcher, check the parameters, and tap
+one of the benchmark action buttons.
+
+#### Launching with `adb`
+Using the `am` command, you can start the activity, and pass it arguments so that you can
+automatically start the benchmark test, and/or set the parameters.
+
+| Parameter Name | Parameter Type | Description
+|------------------------|----------------|------------
+| autostart | String | Benchmark to start. (rfcomm-client, rfcomm-server, l2cap-client or l2cap-server)
+| packet-count | Integer | Number of packets to send (rfcomm-client and l2cap-client only)
+| packet-size | Integer | Number of bytes per packet (rfcomm-client and l2cap-client only)
+| peer-bluetooth-address | Integer | Peer Bluetooth address to connect to (rfcomm-client and l2cap-client | only)
+
+
+!!! tip "Launching from adb with auto-start"
+ In this example, we auto-start the Rfcomm Server bench action.
+ ```bash
+ $ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-server
+ ```
+
+!!! tip "Launching from adb with auto-start and some parameters"
+ In this example, we auto-start the Rfcomm Client bench action, set the packet count to 100,
+ and the packet size to 1024, and connect to DA:4C:10:DE:17:02
+ ```bash
+ $ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-client --ei packet-count 100 --ei packet-size 1024 --es peer-bluetooth-address DA:4C:10:DE:17:02
+ ```
+
+#### Selecting a Peer Bluetooth Address
+The app's main activity has a "Peer Bluetooth Address" setting where you can change the address.
+
+!!! note "Bluetooth Address for L2CAP vs RFComm"
+ For BLE (L2CAP mode), the address of a device typically changes regularly (it is randomized for privacy), whereas the Bluetooth Classic addresses will remain the same (RFComm mode).
+ If two devices are paired and bonded, then they will each "see" a non-changing address for each other even with BLE (Resolvable Private Address)
+
diff --git a/docs/mkdocs/src/extras/android_remote_hci.md b/docs/mkdocs/src/extras/android_remote_hci.md
index 4eab132..735c31b 100644
--- a/docs/mkdocs/src/extras/android_remote_hci.md
+++ b/docs/mkdocs/src/extras/android_remote_hci.md
@@ -1,19 +1,19 @@
ANDROID REMOTE HCI APP
======================
-This application allows using an android phone's built-in Bluetooth controller with
+This application allows using an android phone's built-in Bluetooth controller with
a Bumble host stack running outside the phone (typically a development laptop or desktop).
The app runs an HCI proxy between a TCP socket on the "outside" and the Bluetooth HCI HAL
-on the "inside". (See [this page](https://source.android.com/docs/core/connect/bluetooth) for a high level
+on the "inside". (See [this page](https://source.android.com/docs/core/connect/bluetooth) for a high level
description of the Android Bluetooth HCI HAL).
-The HCI packets received on the TCP socket are forwarded to the phone's controller, and the
+The HCI packets received on the TCP socket are forwarded to the phone's controller, and the
packets coming from the controller are forwarded to the TCP socket.
Building
--------
-You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `RemoteHCI` top level directory.
+You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `extras/android/RemoteHCI` top level directory.
You can also build with Android Studio: open the `RemoteHCI` project. You can build and/or debug from there.
If the build succeeds, you can find the app APKs (debug and release) at:
@@ -25,9 +25,23 @@ If the build succeeds, you can find the app APKs (debug and release) at:
Running
-------
+!!! note
+ In the following examples, it is assumed that shell commands are executed while in the
+ app's root directory, `extras/android/RemoteHCI`. If you are in a different directory,
+ adjust the relative paths accordingly.
+
### Preconditions
-When the proxy starts (tapping the "Start" button in the app's main activity), it will try to
-bind to the Bluetooth HAL. This requires disabling SELinux temporarily, and being the only HAL client.
+When the proxy starts (tapping the "Start" button in the app's main activity, or running the proxy
+from an `adb shell` command line), it will try to bind to the Bluetooth HAL.
+This requires that there is no other HAL client, and requires certain privileges.
+For running as a regular app, this requires disabling SELinux temporarily.
+For running as a command-line executable, this just requires a root shell.
+
+#### Root Shell
+!!! tip "Restart `adb` as root"
+ ```bash
+ $ adb root
+ ```
#### Disabling SELinux
Binding to the Bluetooth HCI HAL requires certain SELinux permissions that can't simply be changed
@@ -56,8 +70,8 @@ development phone).
This state will also reset to the normal SELinux enforcement when you reboot.
#### Stopping the bluetooth process
-Since the Bluetooth HAL service can only accept one client, and that in normal conditions
-that client is the Android's bluetooth stack, it is required to first shut down the
+Since the Bluetooth HAL service can only accept one client, and that in normal conditions
+that client is the Android's bluetooth stack, it is required to first shut down the
Android bluetooth stack process.
!!! tip "Checking if the Bluetooth process is running"
@@ -79,7 +93,33 @@ Airplane Mode, then rebooting. The bluetooth process should, in theory, not rest
$ adb shell cmd bluetooth_manager disable
```
-### Starting the app
+### Running as a command line app
+
+You push the built APK to a temporary location on the phone's filesystem, then launch the command
+line executable with an `adb shell` command.
+
+!!! tip "Pushing the executable"
+ ```bash
+ $ adb push app/build/outputs/apk/release/app-release-unsigned.apk /data/local/tmp/remotehci.apk
+ ```
+ Do this every time you rebuild. Alternatively, you can push the `debug` APK instead:
+ ```bash
+ $ adb push app/build/outputs/apk/debug/app-debug.apk /data/local/tmp/remotehci.apk
+ ```
+
+!!! tip "Start the proxy from the command line"
+ ```bash
+ adb shell "CLASSPATH=/data/local/tmp/remotehci.apk app_process /system/bin com.github.google.bumble.remotehci.CommandLineInterface"
+ ```
+ This will run the proxy, listening on the default TCP port.
+ If you want a different port, pass it as a command line parameter
+
+!!! tip "Start the proxy from the command line with a specific TCP port"
+ ```bash
+ adb shell "CLASSPATH=/data/local/tmp/remotehci.apk app_process /system/bin com.github.google.bumble.remotehci.CommandLineInterface 12345"
+ ```
+
+### Running as a normal app
You can start the app from the Android launcher, from Android Studio, or with `adb`
#### Launching from the launcher
@@ -103,11 +143,11 @@ automatically start the proxy, and/or set the port number.
#### Selecting a TCP port
The RemoteHCI app's main activity has a "TCP Port" setting where you can change the port on
-which the proxy is accepting connections. If the default value isn't suitable, you can
+which the proxy is accepting connections. If the default value isn't suitable, you can
change it there (you can also use the special value 0 to let the OS assign a port number for you).
### Connecting to the proxy
-To connect the Bumble stack to the proxy, you need to be able to reach the phone's network
+To connect the Bumble stack to the proxy, you need to be able to reach the phone's network
stack. This can be done over the phone's WiFi connection, or, alternatively, using an `adb`
TCP forward (which should be faster than over WiFi).
@@ -116,7 +156,7 @@ TCP forward (which should be faster than over WiFi).
```bash
$ adb forward tcp:<outside-port> tcp:<inside-port>
```
- Where ``<outside-port>`` is the port number for a listening socket on your laptop or
+ Where ``<outside-port>`` is the port number for a listening socket on your laptop or
desktop machine, and <inside-port> is the TCP port selected in the app's user interface.
Those two ports may be the same, of course.
For example, with the default TCP port 9993:
@@ -125,7 +165,7 @@ TCP forward (which should be faster than over WiFi).
```
Once you've ensured that you can reach the proxy's TCP port on the phone, either directly or
-via an `adb` forward, you can then use it as a Bumble transport, using the transport name:
+via an `adb` forward, you can then use it as a Bumble transport, using the transport name:
``tcp-client:<host>:<port>`` syntax.
!!! example "Connecting a Bumble client"
diff --git a/docs/mkdocs/src/extras/index.md b/docs/mkdocs/src/extras/index.md
index ae906c1..59af838 100644
--- a/docs/mkdocs/src/extras/index.md
+++ b/docs/mkdocs/src/extras/index.md
@@ -8,4 +8,12 @@ Android Remote HCI
Allows using an Android phone's built-in Bluetooth controller with a Bumble
stack running on a development machine.
-See [Android Remote HCI](android_remote_hci.md) for details. \ No newline at end of file
+See [Android Remote HCI](android_remote_hci.md) for details.
+
+Android BT Bench
+----------------
+
+An Android app that is compatible with the Bumble `bench` command line app.
+This app can be used to test the throughput and latency between two Android
+devices, or between an Android device and another device running the Bumble
+`bench` app. \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/index.md b/docs/mkdocs/src/hive/index.md
new file mode 100644
index 0000000..0b6ca2c
--- /dev/null
+++ b/docs/mkdocs/src/hive/index.md
@@ -0,0 +1,59 @@
+HIVE
+====
+
+Welcome to the Bumble Hive.
+This is a collection of apps and virtual devices that can run entirely in a browser page.
+The code for the apps and devices, as well as the Bumble runtime code, runs via [Pyodide](https://pyodide.org/).
+Pyodide is a Python distribution for the browser and Node.js based on WebAssembly.
+
+The Bumble stack uses a WebSocket to exchange HCI packets with a virtual or physical
+Bluetooth controller.
+
+The apps and devices in the hive can be accessed by following the links below. Each
+page has a settings button that may be used to configure the WebSocket URL to use for
+the virtual HCI connection. This will typically be the WebSocket URL for a `netsim`
+daemon.
+There is also a [TOML index](index.toml) that can be used by tools to know at which URL to access
+each of the apps and devices, as well as their names and short descriptions.
+
+!!! tip "Using `netsim`"
+ When the `netsimd` daemon is running (for example when using the Android Emulator that
+ is included in Android Studio), the daemon listens for connections on a TCP port.
+ To find out what this TCP port is, you can read the `netsim.ini` file that `netsimd`
+ creates, it includes a line with `web.port=<tcp-port>` (for example `web.port=7681`).
+ The location of the `netsim.ini` file is platform-specific.
+
+ === "macOS"
+ On macOS, the directory where `netsim.ini` is stored is $TMPDIR
+ ```bash
+ $ cat $TMPDIR/netsim.ini
+ ```
+
+ === "Linux"
+ On Linux, the directory where `netsim.ini` is stored is $XDG_RUNTIME_DIR
+ ```bash
+ $ cat $XDG_RUNTIME_DIR/netsim.ini
+ ```
+
+
+!!! tip "Using a local radio"
+ You can connect the hive virtual apps and devices to a local Bluetooth radio, like,
+ for example, a USB dongle.
+ For that, you need to run a local HCI bridge to bridge a local HCI device to a WebSocket
+ that a web page can connect to.
+ Use the `bumble-hci-bridge` app, with the host transport set to a WebSocket server on an
+ available port (ex: `ws-server:_:7682`) and the controller transport set to the transport
+ name for the radio you want to use (ex: `usb:0` for the first USB dongle)
+
+
+Applications
+------------
+
+ * [Scanner](web/scanner/scanner.html) - Scans for BLE devices.
+
+Virtual Devices
+---------------
+
+ * [Speaker](web/speaker/speaker.html) - Virtual speaker that plays audio in a browser page.
+ * [Heart Rate Monitor](web/heart_rate_monitor/heart_rate_monitor.html) - Virtual heart rate monitor.
+
diff --git a/docs/mkdocs/src/hive/index.toml b/docs/mkdocs/src/hive/index.toml
new file mode 100644
index 0000000..5b187e3
--- /dev/null
+++ b/docs/mkdocs/src/hive/index.toml
@@ -0,0 +1,21 @@
+version = "1.0.0"
+base_url = "https://google.github.io/bumble/hive/web"
+default_hci_query_param = "hci"
+
+[[index]]
+name = "speaker"
+description = "Bumble Virtual Speaker"
+type = "Device"
+url = "speaker/speaker.html"
+
+[[index]]
+name = "scanner"
+description = "Simple Scanner Application"
+type = "Application"
+url = "scanner/scanner.html"
+
+[[index]]
+name = "heart-rate-monitor"
+description = "Virtual Heart Rate Monitor"
+type = "Device"
+url = "heart_rate_monitor/heart_rate_monitor.html"
diff --git a/docs/mkdocs/src/hive/web/bumble.js b/docs/mkdocs/src/hive/web/bumble.js
new file mode 120000
index 0000000..237a974
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/bumble.js
@@ -0,0 +1 @@
+../../../../../web/bumble.js \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html
new file mode 120000
index 0000000..ef6e18a
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.html
@@ -0,0 +1 @@
+../../../../../../web/heart_rate_monitor/heart_rate_monitor.html \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js
new file mode 120000
index 0000000..1d1dc8b
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.js
@@ -0,0 +1 @@
+../../../../../../web/heart_rate_monitor/heart_rate_monitor.js \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py
new file mode 120000
index 0000000..cb0f459
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/heart_rate_monitor/heart_rate_monitor.py
@@ -0,0 +1 @@
+../../../../../../web/heart_rate_monitor/heart_rate_monitor.py \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.css b/docs/mkdocs/src/hive/web/scanner/scanner.css
new file mode 120000
index 0000000..acc0f9e
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/scanner/scanner.css
@@ -0,0 +1 @@
+../../../../../../web/scanner/scanner.css \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.html b/docs/mkdocs/src/hive/web/scanner/scanner.html
new file mode 120000
index 0000000..fda63fc
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/scanner/scanner.html
@@ -0,0 +1 @@
+../../../../../../web/scanner/scanner.html \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.js b/docs/mkdocs/src/hive/web/scanner/scanner.js
new file mode 120000
index 0000000..4d270f4
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/scanner/scanner.js
@@ -0,0 +1 @@
+../../../../../../web/scanner/scanner.js \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/scanner/scanner.py b/docs/mkdocs/src/hive/web/scanner/scanner.py
new file mode 120000
index 0000000..4ae502a
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/scanner/scanner.py
@@ -0,0 +1 @@
+../../../../../../web/scanner/scanner.py \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/speaker/logo.svg b/docs/mkdocs/src/hive/web/speaker/logo.svg
new file mode 120000
index 0000000..0da5d27
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/speaker/logo.svg
@@ -0,0 +1 @@
+../../../../../../web/speaker/logo.svg \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.css b/docs/mkdocs/src/hive/web/speaker/speaker.css
new file mode 120000
index 0000000..046d971
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/speaker/speaker.css
@@ -0,0 +1 @@
+../../../../../../web/speaker/speaker.css \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.html b/docs/mkdocs/src/hive/web/speaker/speaker.html
new file mode 120000
index 0000000..9acaa1d
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/speaker/speaker.html
@@ -0,0 +1 @@
+../../../../../../web/speaker/speaker.html \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.js b/docs/mkdocs/src/hive/web/speaker/speaker.js
new file mode 120000
index 0000000..2ebaf50
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/speaker/speaker.js
@@ -0,0 +1 @@
+../../../../../../web/speaker/speaker.js \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/speaker/speaker.py b/docs/mkdocs/src/hive/web/speaker/speaker.py
new file mode 120000
index 0000000..1d6e95e
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/speaker/speaker.py
@@ -0,0 +1 @@
+../../../../../../web/speaker/speaker.py \ No newline at end of file
diff --git a/docs/mkdocs/src/hive/web/ui.js b/docs/mkdocs/src/hive/web/ui.js
new file mode 120000
index 0000000..71419c3
--- /dev/null
+++ b/docs/mkdocs/src/hive/web/ui.js
@@ -0,0 +1 @@
+../../../../../web/ui.js \ No newline at end of file
diff --git a/docs/mkdocs/src/index.md b/docs/mkdocs/src/index.md
index c81f7ff..aae6e54 100644
--- a/docs/mkdocs/src/index.md
+++ b/docs/mkdocs/src/index.md
@@ -152,11 +152,23 @@ Some platforms support features that not all platforms support
See the [Platforms page](platforms/index.md) for details.
+
+Hive
+----
+
+The Hive is a collection of example apps and virtual devices that are implemented using the
+Python Bumble API, running entirely in a web page. This is a convenient way to try out some
+of the examples without any Python installation, when you have some other virtual Bluetooth
+device that you can connect to or from, such as the Android Emulator.
+
+See the [Bumble Hive](hive/index.md) for details.
+
Roadmap
-------
Future features to be considered include:
+ * More profiles
* More device examples
* Add a new type of virtual link (beyond the two existing ones) to allow for link-level simulation (timing, loss, etc)
* Bindings for languages other than Python
diff --git a/docs/mkdocs/src/transports/android_emulator.md b/docs/mkdocs/src/transports/android_emulator.md
index 974ba4f..becff54 100644
--- a/docs/mkdocs/src/transports/android_emulator.md
+++ b/docs/mkdocs/src/transports/android_emulator.md
@@ -14,7 +14,7 @@ connections.
## Moniker
The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`,
-where `<options>` is a ','-separated list of `<name>=<value>` pairs`.
+where `<options>` is a comma-separated list of `<name>=<value>` pairs.
The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode).
Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process).
diff --git a/docs/mkdocs/src/transports/usb.md b/docs/mkdocs/src/transports/usb.md
index e400630..08949f0 100644
--- a/docs/mkdocs/src/transports/usb.md
+++ b/docs/mkdocs/src/transports/usb.md
@@ -10,6 +10,7 @@ The moniker for a USB transport is either:
* `usb:<vendor>:<product>`
* `usb:<vendor>:<product>/<serial-number>`
* `usb:<vendor>:<product>#<index>`
+ * `usb:<bus>-<port_numbers>`
with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one.
In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol.
@@ -17,6 +18,8 @@ In the `usb:<vendor>:<product>#<index>` form, matching devices are the ones with
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
+with `<port_numbers>` as a list of all port numbers from root separated with dots `.`
+
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
the first USB interface of the device will be used, regardless of the interface class/subclass.
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
@@ -37,6 +40,9 @@ This may be useful for some devices that use a custom class/subclass but may non
`usb:0B05:17CB!`
The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
+ `usb:3-3.4.1`
+ The BT USB dongle on bus 3 on port path 3, 4, 1.
+
## Alternative
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
diff --git a/examples/avrcp_as_sink.html b/examples/avrcp_as_sink.html
new file mode 100644
index 0000000..7c96744
--- /dev/null
+++ b/examples/avrcp_as_sink.html
@@ -0,0 +1,274 @@
+<html>
+
+<head>
+ <style>
+ * {
+ font-family: sans-serif;
+ }
+ </style>
+</head>
+<body>
+ Server Port <input id="port" type="text" value="8989"></input> <button id="connectButton" onclick="connect()">Connect</button><br>
+ <div id="socketState"></div>
+ <br>
+ <div id="buttons"></div><br>
+ <hr>
+ <button onclick="onGetPlayStatusButtonClicked()">Get Play Status</button><br>
+ <div id="getPlayStatusResponseTable"></div>
+ <hr>
+ <button onclick="onGetElementAttributesButtonClicked()">Get Element Attributes</button><br>
+ <div id="getElementAttributesResponseTable"></div>
+ <hr>
+ <table>
+ <tr>
+ <b>VOLUME</b>:
+ <button onclick="onVolumeDownButtonClicked()">-</button>
+ <button onclick="onVolumeUpButtonClicked()">+</button>&nbsp;
+ <span id="volumeText"></span><br>
+ </tr>
+ <tr>
+ <td><b>PLAYBACK STATUS</b></td><td><span id="playbackStatusText"></span></td>
+ </tr>
+ <tr>
+ <td><b>POSITION</b></td><td><span id="positionText"></span></td>
+ </tr>
+ <tr>
+ <td><b>TRACK</b></td><td><span id="trackText"></span></td>
+ </tr>
+ <tr>
+ <td><b>ADDRESSED PLAYER</b></td><td><span id="addressedPlayerText"></span></td>
+ </tr>
+ <tr>
+ <td><b>UID COUNTER</b></td><td><span id="uidCounterText"></span></td>
+ </tr>
+ <tr>
+ <td><b>SUPPORTED EVENTS</b></td><td><span id="supportedEventsText"></span></td>
+ </tr>
+ <tr>
+ <td><b>PLAYER SETTINGS</b></td><td><div id="playerSettingsTable"></div></td>
+ </tr>
+ </table>
+ <script>
+ const portInput = document.getElementById("port")
+ const connectButton = document.getElementById("connectButton")
+ const socketState = document.getElementById("socketState")
+ const volumeText = document.getElementById("volumeText")
+ const positionText = document.getElementById("positionText")
+ const trackText = document.getElementById("trackText")
+ const playbackStatusText = document.getElementById("playbackStatusText")
+ const addressedPlayerText = document.getElementById("addressedPlayerText")
+ const uidCounterText = document.getElementById("uidCounterText")
+ const supportedEventsText = document.getElementById("supportedEventsText")
+ const playerSettingsTable = document.getElementById("playerSettingsTable")
+ const getPlayStatusResponseTable = document.getElementById("getPlayStatusResponseTable")
+ const getElementAttributesResponseTable = document.getElementById("getElementAttributesResponseTable")
+ let socket
+ let volume = 0
+
+ const keyNames = [
+ "SELECT",
+ "UP",
+ "DOWN",
+ "LEFT",
+ "RIGHT",
+ "RIGHT_UP",
+ "RIGHT_DOWN",
+ "LEFT_UP",
+ "LEFT_DOWN",
+ "ROOT_MENU",
+ "SETUP_MENU",
+ "CONTENTS_MENU",
+ "FAVORITE_MENU",
+ "EXIT",
+ "NUMBER_0",
+ "NUMBER_1",
+ "NUMBER_2",
+ "NUMBER_3",
+ "NUMBER_4",
+ "NUMBER_5",
+ "NUMBER_6",
+ "NUMBER_7",
+ "NUMBER_8",
+ "NUMBER_9",
+ "DOT",
+ "ENTER",
+ "CLEAR",
+ "CHANNEL_UP",
+ "CHANNEL_DOWN",
+ "PREVIOUS_CHANNEL",
+ "SOUND_SELECT",
+ "INPUT_SELECT",
+ "DISPLAY_INFORMATION",
+ "HELP",
+ "PAGE_UP",
+ "PAGE_DOWN",
+ "POWER",
+ "VOLUME_UP",
+ "VOLUME_DOWN",
+ "MUTE",
+ "PLAY",
+ "STOP",
+ "PAUSE",
+ "RECORD",
+ "REWIND",
+ "FAST_FORWARD",
+ "EJECT",
+ "FORWARD",
+ "BACKWARD",
+ "ANGLE",
+ "SUBPICTURE",
+ "F1",
+ "F2",
+ "F3",
+ "F4",
+ "F5",
+ ]
+
+ document.addEventListener('keydown', onKeyDown)
+ document.addEventListener('keyup', onKeyUp)
+
+ const buttons = document.getElementById("buttons")
+ keyNames.forEach(name => {
+ const button = document.createElement("BUTTON")
+ button.appendChild(document.createTextNode(name))
+ button.addEventListener("mousedown", event => {
+ send({type: 'send-key-down', key: name})
+ })
+ button.addEventListener("mouseup", event => {
+ send({type: 'send-key-up', key: name})
+ })
+ buttons.appendChild(button)
+ })
+
+ updateVolume(0)
+
+ function connect() {
+ socket = new WebSocket(`ws://localhost:${portInput.value}`);
+ socket.onopen = _ => {
+ socketState.innerText = 'OPEN'
+ connectButton.disabled = true
+ }
+ socket.onclose = _ => {
+ socketState.innerText = 'CLOSED'
+ connectButton.disabled = false
+ }
+ socket.onerror = (error) => {
+ socketState.innerText = 'ERROR'
+ console.log(`ERROR: ${error}`)
+ connectButton.disabled = false
+ }
+ socket.onmessage = (message) => {
+ onMessage(JSON.parse(message.data))
+ }
+ }
+
+ function send(message) {
+ if (socket && socket.readyState == WebSocket.OPEN) {
+ socket.send(JSON.stringify(message))
+ }
+ }
+
+ function hmsText(position) {
+ const h_1 = 1000 * 60 * 60
+ const h = Math.floor(position / h_1)
+ position -= h * h_1
+ const m_1 = 1000 * 60
+ const m = Math.floor(position / m_1)
+ position -= m * m_1
+ const s_1 = 1000
+ const s = Math.floor(position / s_1)
+ position -= s * s_1
+
+ return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}:${position}`
+ }
+
+ function setTableHead(table, columns) {
+ let thead = table.createTHead()
+ let row = thead.insertRow()
+ for (let column of columns) {
+ let th = document.createElement("th")
+ let text = document.createTextNode(column)
+ th.appendChild(text)
+ row.appendChild(th)
+ }
+ }
+
+ function createTable(rows) {
+ const table = document.createElement("table")
+
+ if (rows.length != 0) {
+ columns = Object.keys(rows[0])
+ setTableHead(table, columns)
+ }
+ for (let element of rows) {
+ let row = table.insertRow()
+ for (key in element) {
+ let cell = row.insertCell()
+ let text = document.createTextNode(element[key])
+ cell.appendChild(text)
+ }
+ }
+ return table
+ }
+
+ function onMessage(message) {
+ console.log(message)
+ if (message.type == "set-volume") {
+ updateVolume(message.params.volume)
+ } else if (message.type == "supported-events") {
+ supportedEventsText.innerText = JSON.stringify(message.params.events)
+ } else if (message.type == "playback-position-changed") {
+ positionText.innerText = hmsText(message.params.position)
+ } else if (message.type == "playback-status-changed") {
+ playbackStatusText.innerText = message.params.status
+ } else if (message.type == "player-settings-changed") {
+ playerSettingsTable.replaceChildren(message.params.settings)
+ } else if (message.type == "track-changed") {
+ trackText.innerText = message.params.identifier
+ } else if (message.type == "addressed-player-changed") {
+ addressedPlayerText.innerText = JSON.stringify(message.params.player)
+ } else if (message.type == "uids-changed") {
+ uidCounterText.innerText = message.params.uid_counter
+ } else if (message.type == "get-play-status-response") {
+ getPlayStatusResponseTable.replaceChildren(message.params)
+ } else if (message.type == "get-element-attributes-response") {
+ getElementAttributesResponseTable.replaceChildren(createTable(message.params))
+ }
+ }
+
+ function updateVolume(newVolume) {
+ volume = newVolume
+ volumeText.innerText = `${volume} (${Math.round(100*volume/0x7F)}%)`
+ }
+
+ function onKeyDown(event) {
+ console.log(event)
+ send({ type: 'send-key-down', key: event.key })
+ }
+
+ function onKeyUp(event) {
+ console.log(event)
+ send({ type: 'send-key-up', key: event.key })
+ }
+
+ function onVolumeUpButtonClicked() {
+ updateVolume(Math.min(volume + 5, 0x7F))
+ send({ type: 'set-volume', volume })
+ }
+
+ function onVolumeDownButtonClicked() {
+ updateVolume(Math.max(volume - 5, 0))
+ send({ type: 'set-volume', volume })
+ }
+
+ function onGetPlayStatusButtonClicked() {
+ send({ type: 'get-play-status', volume })
+ }
+
+ function onGetElementAttributesButtonClicked() {
+ send({ type: 'get-element-attributes' })
+ }
+</script>
+</body>
+
+</html> \ No newline at end of file
diff --git a/examples/heart_rate_server.py b/examples/heart_rate_server.py
index 32f53b1..fad809f 100644
--- a/examples/heart_rate_server.py
+++ b/examples/heart_rate_server.py
@@ -29,6 +29,7 @@ from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.profiles.device_information_service import DeviceInformationService
from bumble.profiles.heart_rate_service import HeartRateService
+from bumble.utils import AsyncRunner
# -----------------------------------------------------------------------------
@@ -98,6 +99,17 @@ async def main():
)
)
+ # 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(
+ device.notify_subscriber(
+ connection,
+ heart_rate_service.heart_rate_measurement_characteristic,
+ )
+ )
+
# Go!
await device.power_on()
await device.start_advertising(auto_restart=True)
diff --git a/examples/hid_keyboard.json b/examples/hid_keyboard.json
new file mode 100644
index 0000000..b7b1409
--- /dev/null
+++ b/examples/hid_keyboard.json
@@ -0,0 +1,5 @@
+{
+ "name": "Bumble HID Keyboard",
+ "class_of_device": 9664,
+ "keystore": "JsonKeyStore"
+}
diff --git a/examples/keyboard.html b/examples/keyboard.html
index 6ad83a7..7d44a03 100644
--- a/examples/keyboard.html
+++ b/examples/keyboard.html
@@ -40,9 +40,9 @@
}
}
function onMouseMove(event) {
- //console.log(event.clientX, event.clientY)
- mouseInfo.innerText = `MOUSE: x=${event.clientX}, y=${event.clientY}`
- send({ type:'mousemove', x: event.clientX, y: event.clientY })
+ //console.log(event.movementX, event.movementY)
+ mouseInfo.innerText = `MOUSE: x=${event.movementX}, y=${event.movementY}`
+ send({ type:'mousemove', x: event.movementX, y: event.movementY })
}
function onKeyDown(event) {
diff --git a/examples/leaudio.json b/examples/leaudio.json
new file mode 100644
index 0000000..ad5f6c8
--- /dev/null
+++ b/examples/leaudio.json
@@ -0,0 +1,7 @@
+{
+ "name": "Bumble-LEA",
+ "keystore": "JsonKeyStore",
+ "address": "F0:F1:F2:F3:F4:FA",
+ "class_of_device": 2376708,
+ "advertising_interval": 100
+}
diff --git a/examples/leaudio_with_classic.json b/examples/leaudio_with_classic.json
new file mode 100644
index 0000000..8b0d593
--- /dev/null
+++ b/examples/leaudio_with_classic.json
@@ -0,0 +1,9 @@
+{
+ "name": "Bumble-LEA",
+ "keystore": "JsonKeyStore",
+ "address": "F0:F1:F2:F3:F4:FA",
+ "classic_enabled": true,
+ "cis_enabled": true,
+ "class_of_device": 2376708,
+ "advertising_interval": 100
+}
diff --git a/examples/run_a2dp_info.py b/examples/run_a2dp_info.py
index 2f21cfa..3a35695 100644
--- a/examples/run_a2dp_info.py
+++ b/examples/run_a2dp_info.py
@@ -53,10 +53,10 @@ def sdp_records():
# -----------------------------------------------------------------------------
# pylint: disable-next=too-many-nested-blocks
-async def find_a2dp_service(device, connection):
+async def find_a2dp_service(connection):
# Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
+ sdp_client = SDP_Client(connection)
+ await sdp_client.connect()
# Search for services with an Audio Sink service class
search_result = await sdp_client.search_attributes(
@@ -177,7 +177,7 @@ async def main():
print('*** Encryption on')
# Look for an A2DP service
- avdtp_version = await find_a2dp_service(device, connection)
+ avdtp_version = await find_a2dp_service(connection)
if not avdtp_version:
print(color('!!! no AVDTP service found'))
return
diff --git a/examples/run_a2dp_source.py b/examples/run_a2dp_source.py
index 69dc2d0..4645229 100644
--- a/examples/run_a2dp_source.py
+++ b/examples/run_a2dp_source.py
@@ -74,7 +74,7 @@ def codec_capabilities():
# -----------------------------------------------------------------------------
def on_avdtp_connection(read_function, protocol):
packet_source = SbcPacketSource(
- read_function, protocol.l2cap_channel.mtu, codec_capabilities()
+ read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
)
packet_pump = MediaPacketPump(packet_source.packets)
protocol.add_source(packet_source.codec_capabilities, packet_pump)
@@ -98,7 +98,7 @@ async def stream_packets(read_function, protocol):
# Stream the packets
packet_source = SbcPacketSource(
- read_function, protocol.l2cap_channel.mtu, codec_capabilities()
+ read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
)
packet_pump = MediaPacketPump(packet_source.packets)
source = protocol.add_source(packet_source.codec_capabilities, packet_pump)
@@ -165,9 +165,7 @@ async def main():
print('*** Encryption on')
# Look for an A2DP service
- avdtp_version = await find_avdtp_service_with_connection(
- device, connection
- )
+ avdtp_version = await find_avdtp_service_with_connection(connection)
if not avdtp_version:
print(color('!!! no A2DP service found'))
return
diff --git a/examples/run_advertiser.py b/examples/run_advertiser.py
index 56b1b8b..fb59426 100644
--- a/examples/run_advertiser.py
+++ b/examples/run_advertiser.py
@@ -19,9 +19,11 @@ import asyncio
import logging
import sys
import os
+import struct
+
+from bumble.core import AdvertisingData
from bumble.device import AdvertisingType, Device
from bumble.hci import Address
-
from bumble.transport import open_transport_or_link
@@ -52,6 +54,16 @@ async def main():
print('<<< connected')
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
+
+ if advertising_type.is_scannable:
+ device.scan_response_data = bytes(
+ AdvertisingData(
+ [
+ (AdvertisingData.APPEARANCE, struct.pack('<H', 0x0340)),
+ ]
+ )
+ )
+
await device.power_on()
await device.start_advertising(advertising_type=advertising_type, target=target)
await hci_source.wait_for_termination()
diff --git a/examples/run_avrcp.py b/examples/run_avrcp.py
new file mode 100644
index 0000000..4bb4143
--- /dev/null
+++ b/examples/run_avrcp.py
@@ -0,0 +1,408 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+from __future__ import annotations
+import asyncio
+import json
+import sys
+import os
+import logging
+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 import avc
+from bumble import avrcp
+from bumble import avdtp
+from bumble import a2dp
+from bumble import utils
+
+
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+def sdp_records():
+ a2dp_sink_service_record_handle = 0x00010001
+ avrcp_controller_service_record_handle = 0x00010002
+ avrcp_target_service_record_handle = 0x00010003
+ # pylint: disable=line-too-long
+ return {
+ a2dp_sink_service_record_handle: a2dp.make_audio_sink_service_sdp_records(
+ a2dp_sink_service_record_handle
+ ),
+ avrcp_controller_service_record_handle: avrcp.make_controller_service_sdp_records(
+ avrcp_controller_service_record_handle
+ ),
+ avrcp_target_service_record_handle: avrcp.make_target_service_sdp_records(
+ avrcp_controller_service_record_handle
+ ),
+ }
+
+
+# -----------------------------------------------------------------------------
+def codec_capabilities():
+ return avdtp.MediaCodecCapabilities(
+ media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
+ media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
+ media_codec_information=a2dp.SbcMediaCodecInformation.from_lists(
+ sampling_frequencies=[48000, 44100, 32000, 16000],
+ channel_modes=[
+ a2dp.SBC_MONO_CHANNEL_MODE,
+ a2dp.SBC_DUAL_CHANNEL_MODE,
+ a2dp.SBC_STEREO_CHANNEL_MODE,
+ a2dp.SBC_JOINT_STEREO_CHANNEL_MODE,
+ ],
+ block_lengths=[4, 8, 12, 16],
+ subbands=[4, 8],
+ allocation_methods=[
+ a2dp.SBC_LOUDNESS_ALLOCATION_METHOD,
+ a2dp.SBC_SNR_ALLOCATION_METHOD,
+ ],
+ minimum_bitpool_value=2,
+ maximum_bitpool_value=53,
+ ),
+ )
+
+
+# -----------------------------------------------------------------------------
+def on_avdtp_connection(server):
+ # Add a sink endpoint to the server
+ sink = server.add_sink(codec_capabilities())
+ sink.on('rtp_packet', on_rtp_packet)
+
+
+# -----------------------------------------------------------------------------
+def on_rtp_packet(packet):
+ print(f'RTP: {packet}')
+
+
+# -----------------------------------------------------------------------------
+def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketServer):
+ async def get_supported_events():
+ events = await avrcp_protocol.get_supported_events()
+ print("SUPPORTED EVENTS:", events)
+ websocket_server.send_message(
+ {
+ "type": "supported-events",
+ "params": {"events": [event.name for event in events]},
+ }
+ )
+
+ if avrcp.EventId.TRACK_CHANGED in events:
+ utils.AsyncRunner.spawn(monitor_track_changed())
+
+ if avrcp.EventId.PLAYBACK_STATUS_CHANGED in events:
+ utils.AsyncRunner.spawn(monitor_playback_status())
+
+ if avrcp.EventId.PLAYBACK_POS_CHANGED in events:
+ utils.AsyncRunner.spawn(monitor_playback_position())
+
+ if avrcp.EventId.PLAYER_APPLICATION_SETTING_CHANGED in events:
+ utils.AsyncRunner.spawn(monitor_player_application_settings())
+
+ if avrcp.EventId.AVAILABLE_PLAYERS_CHANGED in events:
+ utils.AsyncRunner.spawn(monitor_available_players())
+
+ if avrcp.EventId.ADDRESSED_PLAYER_CHANGED in events:
+ utils.AsyncRunner.spawn(monitor_addressed_player())
+
+ if avrcp.EventId.UIDS_CHANGED in events:
+ utils.AsyncRunner.spawn(monitor_uids())
+
+ if avrcp.EventId.VOLUME_CHANGED in events:
+ utils.AsyncRunner.spawn(monitor_volume())
+
+ utils.AsyncRunner.spawn(get_supported_events())
+
+ async def monitor_track_changed():
+ async for identifier in avrcp_protocol.monitor_track_changed():
+ print("TRACK CHANGED:", identifier.hex())
+ websocket_server.send_message(
+ {"type": "track-changed", "params": {"identifier": identifier.hex()}}
+ )
+
+ async def monitor_playback_status():
+ async for playback_status in avrcp_protocol.monitor_playback_status():
+ print("PLAYBACK STATUS CHANGED:", playback_status.name)
+ websocket_server.send_message(
+ {
+ "type": "playback-status-changed",
+ "params": {"status": playback_status.name},
+ }
+ )
+
+ async def monitor_playback_position():
+ async for playback_position in avrcp_protocol.monitor_playback_position(
+ playback_interval=1
+ ):
+ print("PLAYBACK POSITION CHANGED:", playback_position)
+ websocket_server.send_message(
+ {
+ "type": "playback-position-changed",
+ "params": {"position": playback_position},
+ }
+ )
+
+ async def monitor_player_application_settings():
+ async for settings in avrcp_protocol.monitor_player_application_settings():
+ print("PLAYER APPLICATION SETTINGS:", settings)
+ settings_as_dict = [
+ {"attribute": setting.attribute_id.name, "value": setting.value_id.name}
+ for setting in settings
+ ]
+ websocket_server.send_message(
+ {
+ "type": "player-settings-changed",
+ "params": {"settings": settings_as_dict},
+ }
+ )
+
+ async def monitor_available_players():
+ async for _ in avrcp_protocol.monitor_available_players():
+ print("AVAILABLE PLAYERS CHANGED")
+ websocket_server.send_message(
+ {"type": "available-players-changed", "params": {}}
+ )
+
+ async def monitor_addressed_player():
+ async for player in avrcp_protocol.monitor_addressed_player():
+ print("ADDRESSED PLAYER CHANGED")
+ websocket_server.send_message(
+ {
+ "type": "addressed-player-changed",
+ "params": {
+ "player": {
+ "player_id": player.player_id,
+ "uid_counter": player.uid_counter,
+ }
+ },
+ }
+ )
+
+ async def monitor_uids():
+ async for uid_counter in avrcp_protocol.monitor_uids():
+ print("UIDS CHANGED")
+ websocket_server.send_message(
+ {
+ "type": "uids-changed",
+ "params": {
+ "uid_counter": uid_counter,
+ },
+ }
+ )
+
+ async def monitor_volume():
+ async for volume in avrcp_protocol.monitor_volume():
+ print("VOLUME CHANGED:", volume)
+ websocket_server.send_message(
+ {"type": "volume-changed", "params": {"volume": volume}}
+ )
+
+
+# -----------------------------------------------------------------------------
+class WebSocketServer:
+ def __init__(
+ self, avrcp_protocol: avrcp.Protocol, avrcp_delegate: Delegate
+ ) -> None:
+ self.socket = None
+ self.delegate = None
+ self.avrcp_protocol = avrcp_protocol
+ self.avrcp_delegate = avrcp_delegate
+
+ async def start(self) -> None:
+ # pylint: disable-next=no-member
+ await websockets.serve(self.serve, 'localhost', 8989) # type: ignore
+
+ async def serve(self, socket, _path) -> None:
+ print('### WebSocket connected')
+ self.socket = socket
+ while True:
+ try:
+ message = await socket.recv()
+ print('Received: ', str(message))
+
+ parsed = json.loads(message)
+ message_type = parsed['type']
+ if message_type == 'send-key-down':
+ await self.on_send_key_down(parsed)
+ elif message_type == 'send-key-up':
+ await self.on_send_key_up(parsed)
+ elif message_type == 'set-volume':
+ await self.on_set_volume(parsed)
+ elif message_type == 'get-play-status':
+ await self.on_get_play_status()
+ elif message_type == 'get-element-attributes':
+ await self.on_get_element_attributes()
+ except websockets.exceptions.ConnectionClosedOK:
+ self.socket = None
+ break
+
+ async def on_send_key_down(self, message: dict) -> None:
+ key = avc.PassThroughFrame.OperationId[message["key"]]
+ await self.avrcp_protocol.send_key_event(key, True)
+
+ async def on_send_key_up(self, message: dict) -> None:
+ key = avc.PassThroughFrame.OperationId[message["key"]]
+ await self.avrcp_protocol.send_key_event(key, False)
+
+ async def on_set_volume(self, message: dict) -> None:
+ volume = message["volume"]
+ self.avrcp_delegate.volume = volume
+ self.avrcp_protocol.notify_volume_changed(volume)
+
+ async def on_get_play_status(self) -> None:
+ play_status = await self.avrcp_protocol.get_play_status()
+ self.send_message(
+ {
+ "type": "get-play-status-response",
+ "params": {
+ "song_length": play_status.song_length,
+ "song_position": play_status.song_position,
+ "play_status": play_status.play_status.name,
+ },
+ }
+ )
+
+ async def on_get_element_attributes(self) -> None:
+ attributes = await self.avrcp_protocol.get_element_attributes(
+ 0,
+ [
+ avrcp.MediaAttributeId.TITLE,
+ avrcp.MediaAttributeId.ARTIST_NAME,
+ avrcp.MediaAttributeId.ALBUM_NAME,
+ avrcp.MediaAttributeId.TRACK_NUMBER,
+ avrcp.MediaAttributeId.TOTAL_NUMBER_OF_TRACKS,
+ avrcp.MediaAttributeId.GENRE,
+ avrcp.MediaAttributeId.PLAYING_TIME,
+ avrcp.MediaAttributeId.DEFAULT_COVER_ART,
+ ],
+ )
+ self.send_message(
+ {
+ "type": "get-element-attributes-response",
+ "params": [
+ {
+ "attribute_id": attribute.attribute_id.name,
+ "attribute_value": attribute.attribute_value,
+ }
+ for attribute in attributes
+ ],
+ }
+ )
+
+ def send_message(self, message: dict) -> None:
+ if self.socket is None:
+ print("no socket, dropping message")
+ return
+ serialized = json.dumps(message)
+ utils.AsyncRunner.spawn(self.socket.send(serialized))
+
+
+# -----------------------------------------------------------------------------
+class Delegate(avrcp.Delegate):
+ def __init__(self):
+ super().__init__(
+ [avrcp.EventId.VOLUME_CHANGED, avrcp.EventId.PLAYBACK_STATUS_CHANGED]
+ )
+ self.websocket_server = None
+
+ async def set_absolute_volume(self, volume: int) -> None:
+ await super().set_absolute_volume(volume)
+ if self.websocket_server is not None:
+ self.websocket_server.send_message(
+ {"type": "set-volume", "params": {"volume": volume}}
+ )
+
+
+# -----------------------------------------------------------------------------
+async def main():
+ if len(sys.argv) < 3:
+ print(
+ 'Usage: run_avrcp_controller.py <device-config> <transport-spec> '
+ '<sbc-file> [<bt-addr>]'
+ )
+ print('example: run_avrcp_controller.py classic1.json usb:0')
+ return
+
+ print('<<< connecting to HCI...')
+ async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
+ print('<<< connected')
+
+ # Create a device
+ device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
+ device.classic_enabled = True
+
+ # Setup the SDP to expose the sink service
+ device.sdp_service_records = sdp_records()
+
+ # Start the controller
+ await device.power_on()
+
+ # Create a listener to wait for AVDTP connections
+ listener = avdtp.Listener(avdtp.Listener.create_registrar(device))
+ listener.on('connection', on_avdtp_connection)
+
+ avrcp_delegate = Delegate()
+ avrcp_protocol = avrcp.Protocol(avrcp_delegate)
+ avrcp_protocol.listen(device)
+
+ websocket_server = WebSocketServer(avrcp_protocol, avrcp_delegate)
+ avrcp_delegate.websocket_server = websocket_server
+ avrcp_protocol.on(
+ "start", lambda: on_avrcp_start(avrcp_protocol, websocket_server)
+ )
+ await websocket_server.start()
+
+ if len(sys.argv) >= 5:
+ # Connect to the peer
+ target_address = sys.argv[4]
+ print(f'=== Connecting to {target_address}...')
+ connection = await device.connect(
+ target_address, transport=BT_BR_EDR_TRANSPORT
+ )
+ print(f'=== Connected to {connection.peer_address}!')
+
+ # Request authentication
+ print('*** Authenticating...')
+ await connection.authenticate()
+ print('*** Authenticated')
+
+ # Enable encryption
+ print('*** Enabling encryption...')
+ await connection.encrypt()
+ print('*** Encryption on')
+
+ server = await avdtp.Protocol.connect(connection)
+ listener.set_server(connection, server)
+ sink = server.add_sink(codec_capabilities())
+ sink.on('rtp_packet', on_rtp_packet)
+
+ await avrcp_protocol.connect(connection)
+
+ else:
+ # Start being discoverable and connectable
+ await device.set_discoverable(True)
+ await device.set_connectable(True)
+
+ await asyncio.get_event_loop().create_future()
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_cig_setup.py b/examples/run_cig_setup.py
new file mode 100644
index 0000000..29a54ad
--- /dev/null
+++ b/examples/run_cig_setup.py
@@ -0,0 +1,103 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+from bumble.device import (
+ Device,
+ Connection,
+ AdvertisingParameters,
+ AdvertisingEventProperties,
+)
+from bumble.hci import (
+ OwnAddressType,
+)
+
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+ if len(sys.argv) < 3:
+ print(
+ 'Usage: run_cig_setup.py <config-file>'
+ '<transport-spec-for-device-1> <transport-spec-for-device-2>'
+ )
+ print(
+ 'example: run_cig_setup.py device1.json'
+ 'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
+ )
+ return
+
+ print('<<< connecting to HCI...')
+ hci_transports = await asyncio.gather(
+ open_transport_or_link(sys.argv[2]), open_transport_or_link(sys.argv[3])
+ )
+ print('<<< connected')
+
+ devices = [
+ Device.from_config_file_with_hci(
+ sys.argv[1], hci_transport.source, hci_transport.sink
+ )
+ for hci_transport in hci_transports
+ ]
+
+ devices[0].cis_enabled = True
+ devices[1].cis_enabled = True
+
+ await asyncio.gather(*[device.power_on() for device in devices])
+ advertising_set = await devices[0].create_advertising_set()
+
+ connection = await devices[1].connect(
+ devices[0].public_address, own_address_type=OwnAddressType.PUBLIC
+ )
+
+ cid_ids = [2, 3]
+ cis_handles = await devices[1].setup_cig(
+ cig_id=1,
+ cis_id=cid_ids,
+ sdu_interval=(10000, 0),
+ framing=0,
+ max_sdu=(120, 0),
+ retransmission_number=13,
+ max_transport_latency=(100, 0),
+ )
+
+ 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))
+
+ devices[0].on('cis_request', on_cis_request)
+
+ cis_links = await devices[1].create_cis(
+ [(cis, connection.handle) for cis in cis_handles]
+ )
+
+ for cis_link in cis_links:
+ await cis_link.disconnect()
+
+ await asyncio.gather(
+ *[hci_transport.source.terminated for hci_transport in hci_transports]
+ )
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_classic_connect.py b/examples/run_classic_connect.py
index 3ae6ed8..0acaedd 100644
--- a/examples/run_classic_connect.py
+++ b/examples/run_classic_connect.py
@@ -63,8 +63,8 @@ async def main():
print(f'=== Connected to {connection.peer_address}!')
# Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
+ sdp_client = SDP_Client(connection)
+ await sdp_client.connect()
# List all services in the root browse group
service_record_handles = await sdp_client.search_services(
diff --git a/examples/run_csis_servers.py b/examples/run_csis_servers.py
new file mode 100644
index 0000000..9853523
--- /dev/null
+++ b/examples/run_csis_servers.py
@@ -0,0 +1,110 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+import secrets
+
+from bumble.core import AdvertisingData
+from bumble.device import Device
+from bumble.hci import (
+ Address,
+ OwnAddressType,
+ HCI_LE_Set_Extended_Advertising_Parameters_Command,
+)
+from bumble.profiles.cap import CommonAudioServiceService
+from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
+
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+ if len(sys.argv) < 3:
+ print(
+ 'Usage: run_cig_setup.py <config-file>'
+ '<transport-spec-for-device-1> <transport-spec-for-device-2>'
+ )
+ print(
+ 'example: run_cig_setup.py device1.json'
+ 'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
+ )
+ return
+
+ print('<<< connecting to HCI...')
+ hci_transports = await asyncio.gather(
+ open_transport_or_link(sys.argv[2]), open_transport_or_link(sys.argv[3])
+ )
+ print('<<< connected')
+
+ devices = [
+ Device.from_config_file_with_hci(
+ sys.argv[1], hci_transport.source, hci_transport.sink
+ )
+ for hci_transport in hci_transports
+ ]
+
+ sirk = secrets.token_bytes(16)
+
+ for i, device in enumerate(devices):
+ device.random_address = Address(secrets.token_bytes(6))
+ await device.power_on()
+ csis = CoordinatedSetIdentificationService(
+ set_identity_resolving_key=sirk,
+ set_identity_resolving_key_type=SirkType.PLAINTEXT,
+ coordinated_set_size=2,
+ )
+ device.add_service(CommonAudioServiceService(csis))
+ advertising_data = (
+ bytes(
+ AdvertisingData(
+ [
+ (
+ AdvertisingData.COMPLETE_LOCAL_NAME,
+ bytes(f'Bumble LE Audio-{i}', 'utf-8'),
+ ),
+ (
+ AdvertisingData.FLAGS,
+ bytes(
+ [
+ AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
+ | AdvertisingData.BR_EDR_HOST_FLAG
+ | AdvertisingData.BR_EDR_CONTROLLER_FLAG
+ ]
+ ),
+ ),
+ (
+ AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+ bytes(CoordinatedSetIdentificationService.UUID),
+ ),
+ ]
+ )
+ )
+ + csis.get_advertising_data()
+ )
+ await device.create_advertising_set(advertising_data=advertising_data)
+
+ await asyncio.gather(
+ *[hci_transport.source.terminated for hci_transport in hci_transports]
+ )
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_esco_connection.py b/examples/run_esco_connection.py
new file mode 100644
index 0000000..6f3e800
--- /dev/null
+++ b/examples/run_esco_connection.py
@@ -0,0 +1,86 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import dataclasses
+import logging
+import sys
+import os
+from bumble.core import BT_BR_EDR_TRANSPORT
+from bumble.device import Device, ScoLink
+from bumble.hci import HCI_Enhanced_Setup_Synchronous_Connection_Command
+from bumble.hfp import DefaultCodecParameters, ESCO_PARAMETERS
+
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+ if len(sys.argv) < 3:
+ print(
+ 'Usage: run_esco_connection.py <config-file>'
+ '<transport-spec-for-device-1> <transport-spec-for-device-2>'
+ )
+ print(
+ 'example: run_esco_connection.py classic1.json'
+ 'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
+ )
+ return
+
+ print('<<< connecting to HCI...')
+ hci_transports = await asyncio.gather(
+ open_transport_or_link(sys.argv[2]), open_transport_or_link(sys.argv[3])
+ )
+ print('<<< connected')
+
+ devices = [
+ Device.from_config_file_with_hci(
+ sys.argv[1], hci_transport.source, hci_transport.sink
+ )
+ for hci_transport in hci_transports
+ ]
+
+ devices[0].classic_enabled = True
+ devices[1].classic_enabled = True
+
+ await asyncio.gather(*[device.power_on() for device in devices])
+
+ connections = await asyncio.gather(
+ devices[0].accept(devices[1].public_address),
+ devices[1].connect(devices[0].public_address, transport=BT_BR_EDR_TRANSPORT),
+ )
+
+ def on_sco(sco_link: ScoLink):
+ connections[0].abort_on('disconnection', sco_link.disconnect())
+
+ devices[0].once('sco_connection', on_sco)
+
+ await devices[0].send_command(
+ HCI_Enhanced_Setup_Synchronous_Connection_Command(
+ connection_handle=connections[0].handle,
+ **ESCO_PARAMETERS[DefaultCodecParameters.ESCO_CVSD_S3].asdict(),
+ )
+ )
+
+ await asyncio.gather(
+ *[hci_transport.source.terminated for hci_transport in hci_transports]
+ )
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_extended_advertiser.py b/examples/run_extended_advertiser.py
new file mode 100644
index 0000000..6605cfa
--- /dev/null
+++ b/examples/run_extended_advertiser.py
@@ -0,0 +1,73 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+from bumble.device import (
+ AdvertisingParameters,
+ AdvertisingEventProperties,
+ AdvertisingType,
+ Device,
+)
+from bumble.hci import Address
+
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+ if len(sys.argv) < 3:
+ print(
+ 'Usage: run_extended_advertiser.py <config-file> <transport-spec> [type] [address]'
+ )
+ print('example: run_extended_advertiser.py device1.json usb:0')
+ return
+
+ if len(sys.argv) >= 4:
+ advertising_properties = AdvertisingEventProperties.from_advertising_type(
+ AdvertisingType(int(sys.argv[3]))
+ )
+ else:
+ advertising_properties = AdvertisingEventProperties()
+
+ if len(sys.argv) >= 5:
+ peer_address = Address(sys.argv[4])
+ else:
+ peer_address = Address.ANY
+
+ 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()
+ await device.create_advertising_set(
+ advertising_parameters=AdvertisingParameters(
+ advertising_event_properties=advertising_properties,
+ peer_address=peer_address,
+ )
+ )
+ await hci_transport.source.terminated
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_extended_advertiser_2.py b/examples/run_extended_advertiser_2.py
new file mode 100644
index 0000000..735e1c5
--- /dev/null
+++ b/examples/run_extended_advertiser_2.py
@@ -0,0 +1,99 @@
+# Copyright 2021-2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+from bumble.device import AdvertisingParameters, AdvertisingEventProperties, Device
+from bumble.hci import Address
+from bumble.core import AdvertisingData
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+ if len(sys.argv) < 3:
+ print('Usage: run_extended_advertiser_2.py <config-file> <transport-spec>')
+ print('example: run_extended_advertiser_2.py device1.json usb:0')
+ 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()
+
+ if not device.supports_le_extended_advertising:
+ print("Device does not support extended advertising")
+ return
+
+ print("Max advertising sets:", device.host.number_of_supported_advertising_sets)
+ print(
+ "Max advertising data length:", device.host.maximum_advertising_data_length
+ )
+
+ if device.host.number_of_supported_advertising_sets >= 1:
+ advertising_data1 = AdvertisingData(
+ [(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 1".encode("utf-8"))]
+ )
+
+ set1 = await device.create_advertising_set(
+ advertising_data=bytes(advertising_data1),
+ )
+ print("Selected TX power 1:", set1.selected_tx_power)
+
+ advertising_data2 = AdvertisingData(
+ [(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 2".encode("utf-8"))]
+ )
+
+ if device.host.number_of_supported_advertising_sets >= 2:
+ set2 = await device.create_advertising_set(
+ random_address=Address("F0:F0:F0:F0:F0:F1"),
+ advertising_parameters=AdvertisingParameters(),
+ advertising_data=bytes(advertising_data2),
+ auto_start=False,
+ auto_restart=True,
+ )
+ print("Selected TX power 2:", set2.selected_tx_power)
+ await set2.start()
+
+ if device.host.number_of_supported_advertising_sets >= 3:
+ scan_response_data3 = AdvertisingData(
+ [(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 3".encode("utf-8"))]
+ )
+
+ set3 = await device.create_advertising_set(
+ random_address=Address("F0:F0:F0:F0:F0:F2"),
+ advertising_parameters=AdvertisingParameters(
+ advertising_event_properties=AdvertisingEventProperties(
+ is_connectable=False, is_scannable=True
+ )
+ ),
+ scan_response_data=bytes(scan_response_data3),
+ )
+ print("Selected TX power 3:", set2.selected_tx_power)
+
+ await hci_transport.source.terminated
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_hfp_gateway.py b/examples/run_hfp_gateway.py
index 13a2ed9..c3b392d 100644
--- a/examples/run_hfp_gateway.py
+++ b/examples/run_hfp_gateway.py
@@ -31,6 +31,7 @@ from bumble.core import (
BT_BR_EDR_TRANSPORT,
)
from bumble import rfcomm, hfp
+from bumble.hci import HCI_SynchronousDataPacket
from bumble.sdp import (
Client as SDP_Client,
DataElement,
@@ -48,8 +49,8 @@ logger = logging.getLogger(__name__)
# pylint: disable-next=too-many-nested-blocks
async def list_rfcomm_channels(device, connection):
# Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
+ sdp_client = SDP_Client(connection)
+ await sdp_client.connect()
# Search for services that support the Handsfree Profile
search_result = await sdp_client.search_attributes(
@@ -183,7 +184,7 @@ async def main():
# Create a client and start it
print('@@@ Starting to RFCOMM client...')
- rfcomm_client = rfcomm.Client(device, connection)
+ rfcomm_client = rfcomm.Client(connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
@@ -197,6 +198,13 @@ async def main():
print('@@@ Disconnected from RFCOMM server')
return
+ def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket):
+ # Reset packet and loopback
+ packet.packet_status = 0
+ device.host.send_hci_packet(packet)
+
+ device.host.on('sco_packet', on_sco)
+
# Protocol loop (just for testing at this point)
protocol = hfp.HfpProtocol(session)
while True:
diff --git a/examples/run_hfp_handsfree.py b/examples/run_hfp_handsfree.py
index 5f747fc..f4e445e 100644
--- a/examples/run_hfp_handsfree.py
+++ b/examples/run_hfp_handsfree.py
@@ -21,11 +21,13 @@ import os
import logging
import json
import websockets
+import functools
from typing import Optional
-from bumble.device import Device
+from bumble import rfcomm
+from bumble import hci
+from bumble.device import Device, Connection
from bumble.transport import open_transport_or_link
-from bumble.rfcomm import Server as RfcommServer
from bumble import hfp
from bumble.hfp import HfProtocol
@@ -57,12 +59,44 @@ class UiServer:
# -----------------------------------------------------------------------------
-def on_dlc(dlc, configuration: hfp.Configuration):
+def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
print('*** DLC connected', dlc)
protocol = HfProtocol(dlc, configuration)
UiServer.protocol = protocol
asyncio.create_task(protocol.run())
+ def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol):
+ if connection == protocol.dlc.multiplexer.l2cap_channel.connection:
+ if link_type == hci.HCI_Connection_Complete_Event.SCO_LINK_TYPE:
+ esco_parameters = hfp.ESCO_PARAMETERS[
+ hfp.DefaultCodecParameters.SCO_CVSD_D1
+ ]
+ elif protocol.active_codec == hfp.AudioCodec.MSBC:
+ esco_parameters = hfp.ESCO_PARAMETERS[
+ hfp.DefaultCodecParameters.ESCO_MSBC_T2
+ ]
+ elif protocol.active_codec == hfp.AudioCodec.CVSD:
+ esco_parameters = hfp.ESCO_PARAMETERS[
+ hfp.DefaultCodecParameters.ESCO_CVSD_S4
+ ]
+ connection.abort_on(
+ 'disconnection',
+ connection.device.send_command(
+ hci.HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(
+ bd_addr=connection.peer_address, **esco_parameters.asdict()
+ )
+ ),
+ )
+
+ handler = functools.partial(on_sco_request, protocol=protocol)
+ dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler)
+ dlc.multiplexer.l2cap_channel.once(
+ 'close',
+ lambda: dlc.multiplexer.l2cap_channel.connection.device.remove_listener(
+ 'sco_request', handler
+ ),
+ )
+
# -----------------------------------------------------------------------------
async def main():
@@ -101,7 +135,7 @@ async def main():
device.classic_enabled = True
# Create and register a server
- rfcomm_server = RfcommServer(device)
+ rfcomm_server = rfcomm.Server(device)
# Listen for incoming DLC connections
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
diff --git a/examples/run_hid_device.py b/examples/run_hid_device.py
new file mode 100644
index 0000000..9aebfc2
--- /dev/null
+++ b/examples/run_hid_device.py
@@ -0,0 +1,748 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import sys
+import os
+import logging
+import json
+import websockets
+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,
+ BT_HUMAN_INTERFACE_DEVICE_SERVICE,
+ BT_HIDP_PROTOCOL_ID,
+ UUID,
+)
+from bumble.hci import Address
+from bumble.hid import (
+ Device as HID_Device,
+ HID_CONTROL_PSM,
+ HID_INTERRUPT_PSM,
+ Message,
+)
+from bumble.sdp import (
+ Client as SDP_Client,
+ DataElement,
+ ServiceAttribute,
+ SDP_PUBLIC_BROWSE_ROOT,
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_ALL_ATTRIBUTES_RANGE,
+ SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID,
+ SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+)
+from bumble.utils import AsyncRunner
+
+# -----------------------------------------------------------------------------
+# SDP attributes for Bluetooth HID devices
+SDP_HID_SERVICE_NAME_ATTRIBUTE_ID = 0x0100
+SDP_HID_SERVICE_DESCRIPTION_ATTRIBUTE_ID = 0x0101
+SDP_HID_PROVIDER_NAME_ATTRIBUTE_ID = 0x0102
+SDP_HID_DEVICE_RELEASE_NUMBER_ATTRIBUTE_ID = 0x0200 # [DEPRECATED]
+SDP_HID_PARSER_VERSION_ATTRIBUTE_ID = 0x0201
+SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID = 0x0202
+SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID = 0x0203
+SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID = 0x0204
+SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID = 0x0205
+SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0x0206
+SDP_HID_LANGID_BASE_LIST_ATTRIBUTE_ID = 0x0207
+SDP_HID_SDP_DISABLE_ATTRIBUTE_ID = 0x0208 # [DEPRECATED]
+SDP_HID_BATTERY_POWER_ATTRIBUTE_ID = 0x0209
+SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID = 0x020A
+SDP_HID_PROFILE_VERSION_ATTRIBUTE_ID = 0x020B # DEPRECATED]
+SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID = 0x020C
+SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID = 0x020D
+SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID = 0x020E
+SDP_HID_SSR_HOST_MAX_LATENCY_ATTRIBUTE_ID = 0x020F
+SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID = 0x0210
+
+# Refer to HID profile specification v1.1.1, "5.3 Service Discovery Protocol (SDP)" for details
+# HID SDP attribute values
+LANGUAGE = 0x656E # 0x656E uint16 “en” (English)
+ENCODING = 0x6A # 0x006A uint16 UTF-8 encoding
+PRIMARY_LANGUAGE_BASE_ID = 0x100 # 0x0100 uint16 PrimaryLanguageBaseID
+VERSION_NUMBER = 0x0101 # 0x0101 uint16 version number (v1.1)
+SERVICE_NAME = b'Bumble HID'
+SERVICE_DESCRIPTION = b'Bumble'
+PROVIDER_NAME = b'Bumble'
+HID_PARSER_VERSION = 0x0111 # uint16 0x0111 (v1.1.1)
+HID_DEVICE_SUBCLASS = 0xC0 # Combo keyboard/pointing device
+HID_COUNTRY_CODE = 0x21 # 0x21 Uint8, USA
+HID_VIRTUAL_CABLE = True # Virtual cable enabled
+HID_RECONNECT_INITIATE = True # Reconnect initiate enabled
+REPORT_DESCRIPTOR_TYPE = 0x22 # 0x22 Type = Report Descriptor
+HID_LANGID_BASE_LANGUAGE = 0x0409 # 0x0409 Language = English (United States)
+HID_LANGID_BASE_BLUETOOTH_STRING_OFFSET = 0x100 # 0x0100 Default
+HID_BATTERY_POWER = True # Battery power enabled
+HID_REMOTE_WAKE = True # Remote wake enabled
+HID_SUPERVISION_TIMEOUT = 0xC80 # uint16 0xC80 (2s)
+HID_NORMALLY_CONNECTABLE = True # Normally connectable enabled
+HID_BOOT_DEVICE = True # Boot device support enabled
+HID_SSR_HOST_MAX_LATENCY = 0x640 # uint16 0x640 (1s)
+HID_SSR_HOST_MIN_TIMEOUT = 0xC80 # uint16 0xC80 (2s)
+HID_REPORT_MAP = bytes( # Text String, 50 Octet Report Descriptor
+ # pylint: disable=line-too-long
+ [
+ 0x05,
+ 0x01, # Usage Page (Generic Desktop Ctrls)
+ 0x09,
+ 0x06, # Usage (Keyboard)
+ 0xA1,
+ 0x01, # Collection (Application)
+ 0x85,
+ 0x01, # . Report ID (1)
+ 0x05,
+ 0x07, # . Usage Page (Kbrd/Keypad)
+ 0x19,
+ 0xE0, # . Usage Minimum (0xE0)
+ 0x29,
+ 0xE7, # . Usage Maximum (0xE7)
+ 0x15,
+ 0x00, # . Logical Minimum (0)
+ 0x25,
+ 0x01, # . Logical Maximum (1)
+ 0x75,
+ 0x01, # . Report Size (1)
+ 0x95,
+ 0x08, # . Report Count (8)
+ 0x81,
+ 0x02, # . Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
+ 0x95,
+ 0x01, # . Report Count (1)
+ 0x75,
+ 0x08, # . Report Size (8)
+ 0x81,
+ 0x03, # . Input (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
+ 0x95,
+ 0x05, # . Report Count (5)
+ 0x75,
+ 0x01, # . Report Size (1)
+ 0x05,
+ 0x08, # . Usage Page (LEDs)
+ 0x19,
+ 0x01, # . Usage Minimum (Num Lock)
+ 0x29,
+ 0x05, # . Usage Maximum (Kana)
+ 0x91,
+ 0x02, # . Output (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
+ 0x95,
+ 0x01, # . Report Count (1)
+ 0x75,
+ 0x03, # . Report Size (3)
+ 0x91,
+ 0x03, # . Output (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Non-volatile)
+ 0x95,
+ 0x06, # . Report Count (6)
+ 0x75,
+ 0x08, # . Report Size (8)
+ 0x15,
+ 0x00, # . Logical Minimum (0)
+ 0x25,
+ 0x65, # . Logical Maximum (101)
+ 0x05,
+ 0x07, # . Usage Page (Kbrd/Keypad)
+ 0x19,
+ 0x00, # . Usage Minimum (0x00)
+ 0x29,
+ 0x65, # . Usage Maximum (0x65)
+ 0x81,
+ 0x00, # . Input (Data,Array,Abs,No Wrap,Linear,Preferred State,No Null Position)
+ 0xC0, # End Collection
+ 0x05,
+ 0x01, # Usage Page (Generic Desktop Ctrls)
+ 0x09,
+ 0x02, # Usage (Mouse)
+ 0xA1,
+ 0x01, # Collection (Application)
+ 0x85,
+ 0x02, # . Report ID (2)
+ 0x09,
+ 0x01, # . Usage (Pointer)
+ 0xA1,
+ 0x00, # . Collection (Physical)
+ 0x05,
+ 0x09, # . Usage Page (Button)
+ 0x19,
+ 0x01, # . Usage Minimum (0x01)
+ 0x29,
+ 0x03, # . Usage Maximum (0x03)
+ 0x15,
+ 0x00, # . Logical Minimum (0)
+ 0x25,
+ 0x01, # . Logical Maximum (1)
+ 0x95,
+ 0x03, # . Report Count (3)
+ 0x75,
+ 0x01, # . Report Size (1)
+ 0x81,
+ 0x02, # . Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
+ 0x95,
+ 0x01, # . Report Count (1)
+ 0x75,
+ 0x05, # . Report Size (5)
+ 0x81,
+ 0x03, # . Input (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
+ 0x05,
+ 0x01, # . Usage Page (Generic Desktop Ctrls)
+ 0x09,
+ 0x30, # . Usage (X)
+ 0x09,
+ 0x31, # . Usage (Y)
+ 0x15,
+ 0x81, # . Logical Minimum (-127)
+ 0x25,
+ 0x7F, # . Logical Maximum (127)
+ 0x75,
+ 0x08, # . Report Size (8)
+ 0x95,
+ 0x02, # . Report Count (2)
+ 0x81,
+ 0x06, # . Input (Data,Var,Rel,No Wrap,Linear,Preferred State,No Null Position)
+ 0xC0, # . End Collection
+ 0xC0, # End Collection
+ ]
+)
+
+
+# Default protocol mode set to report protocol
+protocol_mode = Message.ProtocolMode.REPORT_PROTOCOL
+
+# -----------------------------------------------------------------------------
+def sdp_records():
+ service_record_handle = 0x00010002
+ return {
+ service_record_handle: [
+ ServiceAttribute(
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_32(service_record_handle),
+ ),
+ ServiceAttribute(
+ SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+ DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+ ),
+ ServiceAttribute(
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [DataElement.uuid(BT_HUMAN_INTERFACE_DEVICE_SERVICE)]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
+ DataElement.unsigned_integer_16(HID_CONTROL_PSM),
+ ]
+ ),
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_HIDP_PROTOCOL_ID),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.unsigned_integer_16(LANGUAGE),
+ DataElement.unsigned_integer_16(ENCODING),
+ DataElement.unsigned_integer_16(PRIMARY_LANGUAGE_BASE_ID),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_HUMAN_INTERFACE_DEVICE_SERVICE),
+ DataElement.unsigned_integer_16(VERSION_NUMBER),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
+ DataElement.unsigned_integer_16(
+ HID_INTERRUPT_PSM
+ ),
+ ]
+ ),
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_HIDP_PROTOCOL_ID),
+ ]
+ ),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_HID_SERVICE_NAME_ATTRIBUTE_ID,
+ DataElement(DataElement.TEXT_STRING, SERVICE_NAME),
+ ),
+ ServiceAttribute(
+ SDP_HID_SERVICE_DESCRIPTION_ATTRIBUTE_ID,
+ DataElement(DataElement.TEXT_STRING, SERVICE_DESCRIPTION),
+ ),
+ ServiceAttribute(
+ SDP_HID_PROVIDER_NAME_ATTRIBUTE_ID,
+ DataElement(DataElement.TEXT_STRING, PROVIDER_NAME),
+ ),
+ ServiceAttribute(
+ SDP_HID_PARSER_VERSION_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_32(HID_PARSER_VERSION),
+ ),
+ ServiceAttribute(
+ SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_32(HID_DEVICE_SUBCLASS),
+ ),
+ ServiceAttribute(
+ SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_32(HID_COUNTRY_CODE),
+ ),
+ ServiceAttribute(
+ SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID,
+ DataElement.boolean(HID_VIRTUAL_CABLE),
+ ),
+ ServiceAttribute(
+ SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID,
+ DataElement.boolean(HID_RECONNECT_INITIATE),
+ ),
+ ServiceAttribute(
+ SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.unsigned_integer_16(REPORT_DESCRIPTOR_TYPE),
+ DataElement(DataElement.TEXT_STRING, HID_REPORT_MAP),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_HID_LANGID_BASE_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.unsigned_integer_16(
+ HID_LANGID_BASE_LANGUAGE
+ ),
+ DataElement.unsigned_integer_16(
+ HID_LANGID_BASE_BLUETOOTH_STRING_OFFSET
+ ),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_HID_BATTERY_POWER_ATTRIBUTE_ID,
+ DataElement.boolean(HID_BATTERY_POWER),
+ ),
+ ServiceAttribute(
+ SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID,
+ DataElement.boolean(HID_REMOTE_WAKE),
+ ),
+ ServiceAttribute(
+ SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_16(HID_SUPERVISION_TIMEOUT),
+ ),
+ ServiceAttribute(
+ SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID,
+ DataElement.boolean(HID_NORMALLY_CONNECTABLE),
+ ),
+ ServiceAttribute(
+ SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID,
+ DataElement.boolean(HID_BOOT_DEVICE),
+ ),
+ ServiceAttribute(
+ SDP_HID_SSR_HOST_MAX_LATENCY_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_16(HID_SSR_HOST_MAX_LATENCY),
+ ),
+ ServiceAttribute(
+ SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_16(HID_SSR_HOST_MIN_TIMEOUT),
+ ),
+ ]
+ }
+
+
+# -----------------------------------------------------------------------------
+async def get_stream_reader(pipe) -> asyncio.StreamReader:
+ loop = asyncio.get_event_loop()
+ reader = asyncio.StreamReader(loop=loop)
+ protocol = asyncio.StreamReaderProtocol(reader)
+ await loop.connect_read_pipe(lambda: protocol, pipe)
+ return reader
+
+
+class DeviceData:
+ def __init__(self) -> None:
+ self.keyboardData = bytearray(
+ [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
+ )
+ self.mouseData = bytearray([0x02, 0x00, 0x00, 0x00])
+
+
+# Device's live data - Mouse and Keyboard will be stored in this
+deviceData = DeviceData()
+
+# -----------------------------------------------------------------------------
+async def keyboard_device(hid_device):
+
+ # Start a Websocket server to receive events from a web page
+ async def serve(websocket, _path):
+ global deviceData
+ while True:
+ try:
+ message = await websocket.recv()
+ print('Received: ', str(message))
+ parsed = json.loads(message)
+ message_type = parsed['type']
+ if message_type == 'keydown':
+ # Only deal with keys a to z for now
+ key = parsed['key']
+ if len(key) == 1:
+ code = ord(key)
+ if ord('a') <= code <= ord('z'):
+ hid_code = 0x04 + code - ord('a')
+ deviceData.keyboardData = bytearray(
+ [
+ 0x01,
+ 0x00,
+ 0x00,
+ hid_code,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ ]
+ )
+ hid_device.send_data(deviceData.keyboardData)
+ elif message_type == 'keyup':
+ deviceData.keyboardData = bytearray(
+ [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
+ )
+ hid_device.send_data(deviceData.keyboardData)
+ elif message_type == "mousemove":
+ # logical min and max values
+ log_min = -127
+ log_max = 127
+ x = parsed['x']
+ y = parsed['y']
+ # limiting x and y values within logical max and min range
+ x = max(log_min, min(log_max, x))
+ y = max(log_min, min(log_max, y))
+ x_cord = x.to_bytes(signed=True)
+ y_cord = y.to_bytes(signed=True)
+ deviceData.mouseData = bytearray([0x02, 0x00]) + x_cord + y_cord
+ hid_device.send_data(deviceData.mouseData)
+ except websockets.exceptions.ConnectionClosedOK:
+ pass
+
+ # pylint: disable-next=no-member
+ await websockets.serve(serve, 'localhost', 8989)
+ await asyncio.get_event_loop().create_future()
+
+
+# -----------------------------------------------------------------------------
+async def main():
+ if len(sys.argv) < 3:
+ print(
+ 'Usage: python run_hid_device.py <device-config> <transport-spec> <command>'
+ ' where <command> is one of:\n'
+ ' test-mode (run with menu enabled for testing)\n'
+ ' web (run a keyboard with keypress input from a web page, '
+ 'see keyboard.html'
+ )
+ print('example: python run_hid_device.py hid_keyboard.json usb:0 web')
+ print('example: python run_hid_device.py hid_keyboard.json usb:0 test-mode')
+
+ return
+
+ async def handle_virtual_cable_unplug():
+ hid_host_bd_addr = str(hid_device.remote_device_bd_address)
+ await hid_device.disconnect_interrupt_channel()
+ await hid_device.disconnect_control_channel()
+ await device.keystore.delete(hid_host_bd_addr) # type: ignore
+ connection = hid_device.connection
+ if connection is not None:
+ await connection.disconnect()
+
+ def on_hid_data_cb(pdu: bytes):
+ print(f'Received Data, PDU: {pdu.hex()}')
+
+ def on_get_report_cb(report_id: int, report_type: int, buffer_size: int):
+ retValue = hid_device.GetSetStatus()
+ print(
+ "GET_REPORT report_id: "
+ + str(report_id)
+ + "report_type: "
+ + str(report_type)
+ + "buffer_size:"
+ + str(buffer_size)
+ )
+ if report_type == Message.ReportType.INPUT_REPORT:
+ if report_id == 1:
+ retValue.data = deviceData.keyboardData[1:]
+ retValue.status = hid_device.GetSetReturn.SUCCESS
+ elif report_id == 2:
+ retValue.data = deviceData.mouseData[1:]
+ retValue.status = hid_device.GetSetReturn.SUCCESS
+ else:
+ retValue.status = hid_device.GetSetReturn.REPORT_ID_NOT_FOUND
+
+ if buffer_size:
+ data_len = buffer_size - 1
+ retValue.data = retValue.data[:data_len]
+ elif report_type == Message.ReportType.OUTPUT_REPORT:
+ # This sample app has nothing to do with the report received, to enable PTS
+ # testing, we will return single byte random data.
+ retValue.data = bytearray([0x11])
+ retValue.status = hid_device.GetSetReturn.SUCCESS
+ elif report_type == Message.ReportType.FEATURE_REPORT:
+ retValue.status = hid_device.GetSetReturn.ERR_INVALID_PARAMETER
+ elif report_type == Message.ReportType.OTHER_REPORT:
+ if report_id == 3:
+ retValue.status = hid_device.GetSetReturn.REPORT_ID_NOT_FOUND
+ else:
+ retValue.status = hid_device.GetSetReturn.FAILURE
+
+ return retValue
+
+ def on_set_report_cb(
+ report_id: int, report_type: int, report_size: int, data: bytes
+ ):
+ retValue = hid_device.GetSetStatus()
+ print(
+ "SET_REPORT report_id: "
+ + str(report_id)
+ + "report_type: "
+ + str(report_type)
+ + "report_size "
+ + str(report_size)
+ + "data:"
+ + str(data)
+ )
+ if report_type == Message.ReportType.FEATURE_REPORT:
+ retValue.status = hid_device.GetSetReturn.ERR_INVALID_PARAMETER
+ elif report_type == Message.ReportType.INPUT_REPORT:
+ if report_id == 1 and report_size != len(deviceData.keyboardData):
+ retValue.status = hid_device.GetSetReturn.ERR_INVALID_PARAMETER
+ elif report_id == 2 and report_size != len(deviceData.mouseData):
+ retValue.status = hid_device.GetSetReturn.ERR_INVALID_PARAMETER
+ elif report_id == 3:
+ retValue.status = hid_device.GetSetReturn.REPORT_ID_NOT_FOUND
+ else:
+ retValue.status = hid_device.GetSetReturn.SUCCESS
+ else:
+ retValue.status = hid_device.GetSetReturn.SUCCESS
+
+ return retValue
+
+ def on_get_protocol_cb():
+ retValue = hid_device.GetSetStatus()
+ retValue.data = protocol_mode.to_bytes()
+ retValue.status = hid_device.GetSetReturn.SUCCESS
+ return retValue
+
+ def on_set_protocol_cb(protocol: int):
+ retValue = hid_device.GetSetStatus()
+ # We do not support SET_PROTOCOL.
+ print(f"SET_PROTOCOL report_id: {protocol}")
+ retValue.status = hid_device.GetSetReturn.ERR_UNSUPPORTED_REQUEST
+ return retValue
+
+ def on_virtual_cable_unplug_cb():
+ print('Received Virtual Cable Unplug')
+ asyncio.create_task(handle_virtual_cable_unplug())
+
+ print('<<< connecting to HCI...')
+ async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
+ print('<<< connected')
+
+ # Create a device
+ device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
+ device.classic_enabled = True
+
+ # Create and register HID device
+ hid_device = HID_Device(device)
+
+ # Register for call backs
+ hid_device.on('interrupt_data', on_hid_data_cb)
+
+ hid_device.register_get_report_cb(on_get_report_cb)
+ hid_device.register_set_report_cb(on_set_report_cb)
+ hid_device.register_get_protocol_cb(on_get_protocol_cb)
+ hid_device.register_set_protocol_cb(on_set_protocol_cb)
+
+ # Register for virtual cable unplug call back
+ hid_device.on('virtual_cable_unplug', on_virtual_cable_unplug_cb)
+
+ # Setup the SDP to advertise HID Device service
+ device.sdp_service_records = sdp_records()
+
+ # Start the controller
+ await device.power_on()
+
+ # Start being discoverable and connectable
+ await device.set_discoverable(True)
+ await device.set_connectable(True)
+
+ async def menu():
+ reader = await get_stream_reader(sys.stdin)
+ while True:
+ print(
+ "\n************************ HID Device Menu *****************************\n"
+ )
+ print(" 1. Connect Control Channel")
+ print(" 2. Connect Interrupt Channel")
+ print(" 3. Disconnect Control Channel")
+ print(" 4. Disconnect Interrupt Channel")
+ print(" 5. Send Report on Interrupt Channel")
+ print(" 6. Virtual Cable Unplug")
+ print(" 7. Disconnect device")
+ print(" 8. Delete Bonding")
+ print(" 9. Re-connect to device")
+ print("10. Exit ")
+ print("\nEnter your choice : \n")
+
+ choice = await reader.readline()
+ choice = choice.decode('utf-8').strip()
+
+ if choice == '1':
+ await hid_device.connect_control_channel()
+
+ elif choice == '2':
+ await hid_device.connect_interrupt_channel()
+
+ elif choice == '3':
+ await hid_device.disconnect_control_channel()
+
+ elif choice == '4':
+ await hid_device.disconnect_interrupt_channel()
+
+ elif choice == '5':
+ print(" 1. Report ID 0x01")
+ print(" 2. Report ID 0x02")
+ print(" 3. Invalid Report ID")
+
+ choice1 = await reader.readline()
+ choice1 = choice1.decode('utf-8').strip()
+
+ if choice1 == '1':
+ data = bytearray(
+ [0x01, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]
+ )
+ hid_device.send_data(data)
+ data = bytearray(
+ [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
+ )
+ hid_device.send_data(data)
+
+ elif choice1 == '2':
+ data = bytearray([0x02, 0x00, 0x00, 0xF6])
+ hid_device.send_data(data)
+ data = bytearray([0x02, 0x00, 0x00, 0x00])
+ hid_device.send_data(data)
+
+ elif choice1 == '3':
+ data = bytearray([0x00, 0x00, 0x00, 0x00])
+ hid_device.send_data(data)
+ data = bytearray([0x00, 0x00, 0x00, 0x00])
+ hid_device.send_data(data)
+
+ else:
+ print('Incorrect option selected')
+
+ elif choice == '6':
+ hid_device.virtual_cable_unplug()
+ try:
+ hid_host_bd_addr = str(hid_device.remote_device_bd_address)
+ await device.keystore.delete(hid_host_bd_addr)
+ except KeyError:
+ print('Device not found or Device already unpaired.')
+
+ elif choice == '7':
+ connection = hid_device.connection
+ if connection is not None:
+ await connection.disconnect()
+ else:
+ print("Already disconnected from device")
+
+ elif choice == '8':
+ try:
+ hid_host_bd_addr = str(hid_device.remote_device_bd_address)
+ await device.keystore.delete(hid_host_bd_addr)
+ except KeyError:
+ print('Device NOT found or Device already unpaired.')
+
+ elif choice == '9':
+ hid_host_bd_addr = str(hid_device.remote_device_bd_address)
+ connection = await device.connect(
+ hid_host_bd_addr, transport=BT_BR_EDR_TRANSPORT
+ )
+ await connection.authenticate()
+ await connection.encrypt()
+
+ elif choice == '10':
+ sys.exit("Exit successful")
+
+ else:
+ print("Invalid option selected.")
+
+ if (len(sys.argv) > 3) and (sys.argv[3] == 'test-mode'):
+ # Test mode for PTS/Unit testing
+ await menu()
+ else:
+ # default option is using keyboard.html (web)
+ print("Executing in Web mode")
+ await keyboard_device(hid_device)
+
+ await hci_source.wait_for_termination()
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_hid_host.py b/examples/run_hid_host.py
index a174444..7519b4e 100644
--- a/examples/run_hid_host.py
+++ b/examples/run_hid_host.py
@@ -22,12 +22,9 @@ import logging
from bumble.colors import color
-import bumble.core
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import (
- BT_L2CAP_PROTOCOL_ID,
- BT_HIDP_PROTOCOL_ID,
BT_HUMAN_INTERFACE_DEVICE_SERVICE,
BT_BR_EDR_TRANSPORT,
)
@@ -35,8 +32,6 @@ from bumble.hci import Address
from bumble.hid import Host, Message
from bumble.sdp import (
Client as SDP_Client,
- DataElement,
- ServiceAttribute,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
@@ -75,11 +70,11 @@ SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID = 0x0210
# -----------------------------------------------------------------------------
-async def get_hid_device_sdp_record(device, connection):
+async def get_hid_device_sdp_record(connection):
# Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
+ sdp_client = SDP_Client(connection)
+ await sdp_client.connect()
if sdp_client:
print(color('Connected to SDP Server', 'blue'))
else:
@@ -290,7 +285,10 @@ async def main():
print('example: run_hid_host.py classic1.json usb:0 E1:CA:72:48:C4:E8/P')
return
- def on_hid_data_cb(pdu):
+ def on_hid_control_data_cb(pdu: bytes):
+ print(f'Received Control Data, PDU: {pdu.hex()}')
+
+ def on_hid_interrupt_data_cb(pdu: bytes):
report_type = pdu[0] & 0x0F
if len(pdu) == 1:
print(color(f'Warning: No report received', 'yellow'))
@@ -310,7 +308,7 @@ async def main():
if (report_length <= 1) or (report_id == 0):
return
-
+ # Parse report over interrupt channel
if report_type == Message.ReportType.INPUT_REPORT:
ReportParser.parse_input_report(pdu[1:]) # type: ignore
@@ -318,7 +316,9 @@ async def main():
await hid_host.disconnect_interrupt_channel()
await hid_host.disconnect_control_channel()
await device.keystore.delete(target_address) # type: ignore
- await connection.disconnect()
+ connection = hid_host.connection
+ if connection is not None:
+ await connection.disconnect()
def on_hid_virtual_cable_unplug_cb():
asyncio.create_task(handle_virtual_cable_unplug())
@@ -330,6 +330,18 @@ async def main():
# Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
+
+ # Create HID host and start it
+ print('@@@ Starting HID Host...')
+ hid_host = Host(device)
+
+ # Register for HID data call back
+ hid_host.on('interrupt_data', on_hid_interrupt_data_cb)
+ hid_host.on('control_data', on_hid_control_data_cb)
+
+ # Register for virtual cable unplug call back
+ hid_host.on('virtual_cable_unplug', on_hid_virtual_cable_unplug_cb)
+
await device.power_on()
# Connect to a peer
@@ -348,17 +360,7 @@ async def main():
await connection.encrypt()
print('*** Encryption on')
- await get_hid_device_sdp_record(device, connection)
-
- # Create HID host and start it
- print('@@@ Starting HID Host...')
- hid_host = Host(device, connection)
-
- # Register for HID data call back
- hid_host.on('data', on_hid_data_cb)
-
- # Register for virtual cable unplug call back
- hid_host.on('virtual_cable_unplug', on_hid_virtual_cable_unplug_cb)
+ await get_hid_device_sdp_record(connection)
async def menu():
reader = await get_stream_reader(sys.stdin)
@@ -374,13 +376,14 @@ async def main():
print(" 6. Set Report")
print(" 7. Set Protocol Mode")
print(" 8. Get Protocol Mode")
- print(" 9. Send Report")
+ print(" 9. Send Report on Interrupt Channel")
print("10. Suspend")
print("11. Exit Suspend")
print("12. Virtual Cable Unplug")
print("13. Disconnect device")
print("14. Delete Bonding")
print("15. Re-connect to device")
+ print("16. Exit")
print("\nEnter your choice : \n")
choice = await reader.readline()
@@ -399,21 +402,40 @@ async def main():
await hid_host.disconnect_interrupt_channel()
elif choice == '5':
- print(" 1. Report ID 0x02")
- print(" 2. Report ID 0x03")
- print(" 3. Report ID 0x05")
+ print(" 1. Input Report with ID 0x01")
+ print(" 2. Input Report with ID 0x02")
+ print(" 3. Input Report with ID 0x0F - Invalid ReportId")
+ print(" 4. Output Report with ID 0x02")
+ print(" 5. Feature Report with ID 0x05 - Unsupported Request")
+ print(" 6. Input Report with ID 0x02, BufferSize 3")
+ print(" 7. Output Report with ID 0x03, BufferSize 2")
+ print(" 8. Feature Report with ID 0x05, BufferSize 3")
choice1 = await reader.readline()
choice1 = choice1.decode('utf-8').strip()
if choice1 == '1':
- hid_host.get_report(1, 2, 3)
+ hid_host.get_report(1, 1, 0)
elif choice1 == '2':
- hid_host.get_report(2, 3, 2)
+ hid_host.get_report(1, 2, 0)
elif choice1 == '3':
- hid_host.get_report(3, 5, 3)
+ hid_host.get_report(1, 5, 0)
+
+ elif choice1 == '4':
+ hid_host.get_report(2, 2, 0)
+ elif choice1 == '5':
+ hid_host.get_report(3, 15, 0)
+
+ elif choice1 == '6':
+ hid_host.get_report(1, 2, 3)
+
+ elif choice1 == '7':
+ hid_host.get_report(2, 3, 2)
+
+ elif choice1 == '8':
+ hid_host.get_report(3, 5, 3)
else:
print('Incorrect option selected')
@@ -489,6 +511,7 @@ async def main():
hid_host.virtual_cable_unplug()
try:
await device.keystore.delete(target_address)
+ print("Unpair successful")
except KeyError:
print('Device not found or Device already unpaired.')
@@ -518,6 +541,9 @@ async def main():
await connection.authenticate()
await connection.encrypt()
+ elif choice == '16':
+ sys.exit("Exit successful")
+
else:
print("Invalid option selected.")
diff --git a/examples/run_rfcomm_client.py b/examples/run_rfcomm_client.py
index 9a94278..39ee776 100644
--- a/examples/run_rfcomm_client.py
+++ b/examples/run_rfcomm_client.py
@@ -42,10 +42,10 @@ from bumble.sdp import (
# -----------------------------------------------------------------------------
-async def list_rfcomm_channels(device, connection):
+async def list_rfcomm_channels(connection):
# Connect to the SDP Server
- sdp_client = SDP_Client(device)
- await sdp_client.connect(connection)
+ sdp_client = SDP_Client(connection)
+ await sdp_client.connect()
# Search for services with an L2CAP service attribute
search_result = await sdp_client.search_attributes(
@@ -194,7 +194,7 @@ async def main():
channel = sys.argv[4]
if channel == 'discover':
- await list_rfcomm_channels(device, connection)
+ await list_rfcomm_channels(connection)
return
# Request authentication
@@ -209,7 +209,7 @@ async def main():
# Create a client and start it
print('@@@ Starting RFCOMM client...')
- rfcomm_client = Client(device, connection)
+ rfcomm_client = Client(connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
diff --git a/examples/run_unicast_server.py b/examples/run_unicast_server.py
new file mode 100644
index 0000000..4fac1d6
--- /dev/null
+++ b/examples/run_unicast_server.py
@@ -0,0 +1,190 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+import struct
+import secrets
+from bumble.core import AdvertisingData
+from bumble.device import Device, CisLink, AdvertisingParameters
+from bumble.hci import (
+ CodecID,
+ CodingFormat,
+ OwnAddressType,
+ HCI_IsoDataPacket,
+)
+from bumble.profiles.bap import (
+ CodecSpecificCapabilities,
+ ContextType,
+ AudioLocation,
+ SupportedSamplingFrequency,
+ SupportedFrameDuration,
+ PacRecord,
+ PublishedAudioCapabilitiesService,
+ AudioStreamControlService,
+)
+from bumble.profiles.cap import CommonAudioServiceService
+from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
+
+from bumble.transport import open_transport_or_link
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+ if len(sys.argv) < 3:
+ print('Usage: run_cig_setup.py <config-file>' '<transport-spec-for-device>')
+ 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
+ )
+ device.cis_enabled = True
+
+ await device.power_on()
+
+ csis = CoordinatedSetIdentificationService(
+ set_identity_resolving_key=secrets.token_bytes(16),
+ set_identity_resolving_key_type=SirkType.PLAINTEXT,
+ )
+ device.add_service(CommonAudioServiceService(csis))
+ device.add_service(
+ PublishedAudioCapabilitiesService(
+ supported_source_context=ContextType.PROHIBITED,
+ available_source_context=ContextType.PROHIBITED,
+ supported_sink_context=ContextType.MEDIA,
+ available_sink_context=ContextType.MEDIA,
+ sink_audio_locations=(
+ AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
+ ),
+ sink_pac=[
+ # Codec Capability Setting 16_2
+ PacRecord(
+ coding_format=CodingFormat(CodecID.LC3),
+ codec_specific_capabilities=CodecSpecificCapabilities(
+ supported_sampling_frequencies=(
+ SupportedSamplingFrequency.FREQ_16000
+ ),
+ supported_frame_durations=(
+ SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+ ),
+ supported_audio_channel_counts=[1],
+ min_octets_per_codec_frame=40,
+ max_octets_per_codec_frame=40,
+ supported_max_codec_frames_per_sdu=1,
+ ),
+ ),
+ # Codec Capability Setting 24_2
+ PacRecord(
+ coding_format=CodingFormat(CodecID.LC3),
+ codec_specific_capabilities=CodecSpecificCapabilities(
+ supported_sampling_frequencies=(
+ SupportedSamplingFrequency.FREQ_48000
+ ),
+ supported_frame_durations=(
+ SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+ ),
+ supported_audio_channel_counts=[1],
+ min_octets_per_codec_frame=120,
+ max_octets_per_codec_frame=120,
+ supported_max_codec_frames_per_sdu=1,
+ ),
+ ),
+ ],
+ )
+ )
+
+ device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2]))
+
+ advertising_data = (
+ bytes(
+ AdvertisingData(
+ [
+ (
+ AdvertisingData.COMPLETE_LOCAL_NAME,
+ bytes('Bumble LE Audio', 'utf-8'),
+ ),
+ (
+ AdvertisingData.FLAGS,
+ bytes(
+ [
+ AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
+ | AdvertisingData.BR_EDR_HOST_FLAG
+ | AdvertisingData.BR_EDR_CONTROLLER_FLAG
+ ]
+ ),
+ ),
+ (
+ AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+ bytes(PublishedAudioCapabilitiesService.UUID),
+ ),
+ ]
+ )
+ )
+ + csis.get_advertising_data()
+ )
+ subprocess = await asyncio.create_subprocess_shell(
+ f'dlc3 | ffplay pipe:0',
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+
+ stdin = subprocess.stdin
+ assert stdin
+
+ # Write a fake LC3 header to dlc3.
+ stdin.write(
+ bytes([0x1C, 0xCC]) # Header.
+ + struct.pack(
+ '<HHHHHHI',
+ 18, # Header length.
+ 48000 // 100, # Sampling Rate(/100Hz).
+ 0, # Bitrate(unused).
+ 1, # Channels.
+ 10000 // 10, # Frame duration(/10us).
+ 0, # RFU.
+ 0x0FFFFFFF, # Frame counts.
+ )
+ )
+
+ def on_pdu(pdu: HCI_IsoDataPacket):
+ # LC3 format: |frame_length(2)| + |frame(length)|.
+ if pdu.iso_sdu_length:
+ stdin.write(struct.pack('<H', pdu.iso_sdu_length))
+ stdin.write(pdu.iso_sdu_fragment)
+
+ def on_cis(cis_link: CisLink):
+ cis_link.on('pdu', on_pdu)
+
+ device.once('cis_establishment', on_cis)
+
+ advertising_set = await device.create_advertising_set(
+ advertising_data=advertising_data,
+ )
+
+ await hci_transport.source.terminated
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_vcp_renderer.py b/examples/run_vcp_renderer.py
new file mode 100644
index 0000000..b695956
--- /dev/null
+++ b/examples/run_vcp_renderer.py
@@ -0,0 +1,191 @@
+# Copyright 2021-2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import sys
+import os
+import secrets
+import websockets
+import json
+
+from bumble.core import AdvertisingData
+from bumble.device import Device, AdvertisingParameters, AdvertisingEventProperties
+from bumble.hci import (
+ CodecID,
+ CodingFormat,
+ OwnAddressType,
+)
+from bumble.profiles.bap import (
+ CodecSpecificCapabilities,
+ ContextType,
+ AudioLocation,
+ SupportedSamplingFrequency,
+ SupportedFrameDuration,
+ PacRecord,
+ PublishedAudioCapabilitiesService,
+ AudioStreamControlService,
+)
+from bumble.profiles.cap import CommonAudioServiceService
+from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
+from bumble.profiles.vcp import VolumeControlService
+
+from bumble.transport import open_transport_or_link
+
+from typing import Optional
+
+
+def dumps_volume_state(volume_setting: int, muted: int, change_counter: int) -> str:
+ return json.dumps(
+ {
+ 'volume_setting': volume_setting,
+ 'muted': muted,
+ 'change_counter': change_counter,
+ }
+ )
+
+
+# -----------------------------------------------------------------------------
+async def main() -> None:
+ if len(sys.argv) < 3:
+ print('Usage: run_vcp_renderer.py <config-file>' '<transport-spec-for-device>')
+ 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()
+
+ # Add "placeholder" services to enable Android LEA features.
+ csis = CoordinatedSetIdentificationService(
+ set_identity_resolving_key=secrets.token_bytes(16),
+ set_identity_resolving_key_type=SirkType.PLAINTEXT,
+ )
+ device.add_service(CommonAudioServiceService(csis))
+ device.add_service(
+ PublishedAudioCapabilitiesService(
+ supported_source_context=ContextType.PROHIBITED,
+ available_source_context=ContextType.PROHIBITED,
+ supported_sink_context=ContextType.MEDIA,
+ available_sink_context=ContextType.MEDIA,
+ sink_audio_locations=(
+ AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
+ ),
+ sink_pac=[
+ # Codec Capability Setting 48_4
+ PacRecord(
+ coding_format=CodingFormat(CodecID.LC3),
+ codec_specific_capabilities=CodecSpecificCapabilities(
+ supported_sampling_frequencies=(
+ SupportedSamplingFrequency.FREQ_48000
+ ),
+ supported_frame_durations=(
+ SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+ ),
+ supported_audio_channel_counts=[1],
+ min_octets_per_codec_frame=120,
+ max_octets_per_codec_frame=120,
+ supported_max_codec_frames_per_sdu=1,
+ ),
+ ),
+ ],
+ )
+ )
+ device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2]))
+
+ vcs = VolumeControlService()
+ device.add_service(vcs)
+
+ ws: Optional[websockets.WebSocketServerProtocol] = None
+
+ def on_volume_state(volume_setting: int, muted: int, change_counter: int):
+ if ws:
+ asyncio.create_task(
+ ws.send(dumps_volume_state(volume_setting, muted, change_counter))
+ )
+
+ vcs.on('volume_state', on_volume_state)
+
+ advertising_data = (
+ bytes(
+ AdvertisingData(
+ [
+ (
+ AdvertisingData.COMPLETE_LOCAL_NAME,
+ bytes('Bumble LE Audio', 'utf-8'),
+ ),
+ (
+ AdvertisingData.FLAGS,
+ bytes(
+ [
+ AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
+ | AdvertisingData.BR_EDR_HOST_FLAG
+ | AdvertisingData.BR_EDR_CONTROLLER_FLAG
+ ]
+ ),
+ ),
+ (
+ AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+ bytes(PublishedAudioCapabilitiesService.UUID),
+ ),
+ ]
+ )
+ )
+ + csis.get_advertising_data()
+ )
+
+ await device.create_advertising_set(
+ advertising_parameters=AdvertisingParameters(
+ advertising_event_properties=AdvertisingEventProperties(),
+ own_address_type=OwnAddressType.PUBLIC,
+ ),
+ advertising_data=advertising_data,
+ )
+
+ async def serve(websocket: websockets.WebSocketServerProtocol, _path):
+ nonlocal ws
+ await websocket.send(
+ dumps_volume_state(vcs.volume_setting, vcs.muted, vcs.change_counter)
+ )
+ ws = websocket
+ async for message in websocket:
+ volume_state = json.loads(message)
+ vcs.volume_state_bytes = bytes(
+ [
+ volume_state['volume_setting'],
+ volume_state['muted'],
+ volume_state['change_counter'],
+ ]
+ )
+ await device.notify_subscribers(
+ vcs.volume_state, vcs.volume_state_bytes
+ )
+ ws = None
+
+ await websockets.serve(serve, 'localhost', 8989)
+
+ await hci_transport.source.terminated
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/vcp_renderer.html b/examples/vcp_renderer.html
new file mode 100644
index 0000000..c438950
--- /dev/null
+++ b/examples/vcp_renderer.html
@@ -0,0 +1,103 @@
+<html data-bs-theme="dark">
+
+<head>
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
+ integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
+
+</head>
+
+<body>
+
+ <div class="container">
+
+ <label for="server-port" class="form-label">Server Port</label>
+ <div class="input-group mb-3">
+ <input type="text" class="form-control" aria-label="Port Number" value="8989" id="port">
+ <button class="btn btn-primary" type="button" onclick="connect()">Connect</button>
+ </div>
+
+ <div class="row">
+ <div class="col">
+ <label for="volume_setting" class="form-label">Volume Setting</label>
+ <input type="range" class="form-range" min="0" max="255" id="volume_setting">
+ </div>
+ <div class="col">
+ <label for="change_counter" class="form-label">Change Counter</label>
+ <input type="range" class="form-range" min="0" max="255" id="change_counter">
+ </div>
+ <div class="col">
+ <div class="form-check form-switch">
+ <input class="form-check-input" type="checkbox" role="switch" id="muted">
+ <label class="form-check-label" for="muted">Muted</label>
+ </div>
+ </div>
+ </div>
+
+ <button class="btn btn-primary" type="button" onclick="update_state()">Notify New Volume State</button>
+
+
+ <hr>
+ <div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
+ <h3>Log</h3>
+ <code id="socketState">
+ </code>
+ </div>
+ </div>
+
+ <script>
+ let portInput = document.getElementById("port")
+ let volumeSetting = document.getElementById("volume_setting")
+ let muted = document.getElementById("muted")
+ let changeCounter = document.getElementById("change_counter")
+ let socket = null
+
+ function connect() {
+ if (socket != null) {
+ return
+ }
+ socket = new WebSocket(`ws://localhost:${portInput.value}`);
+ socket.onopen = _ => {
+ socketState.innerText += 'OPEN\n'
+ }
+ socket.onclose = _ => {
+ socketState.innerText += 'CLOSED\n'
+ socket = null
+ }
+ socket.onerror = (error) => {
+ socketState.innerText += 'ERROR\n'
+ console.log(`ERROR: ${error}`)
+ }
+ socket.onmessage = (event) => {
+ socketState.innerText += `<- ${event.data}\n`
+ let volume_state = JSON.parse(event.data)
+ volumeSetting.value = volume_state.volume_setting
+ changeCounter.value = volume_state.change_counter
+ muted.checked = volume_state.muted ? true : false
+ }
+ }
+
+ function send(message) {
+ if (socket && socket.readyState == WebSocket.OPEN) {
+ let jsonMessage = JSON.stringify(message)
+ socketState.innerText += `-> ${jsonMessage}\n`
+ socket.send(jsonMessage)
+ } else {
+ socketState.innerText += 'NOT CONNECTED\n'
+ }
+ }
+
+ function update_state() {
+ send({
+ volume_setting: parseInt(volumeSetting.value),
+ change_counter: parseInt(changeCounter.value),
+ muted: muted.checked ? 1 : 0
+ })
+ }
+ </script>
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
+ integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
+ crossorigin="anonymous"></script>
+
+</body>
+
+</html> \ No newline at end of file
diff --git a/extras/android/BtBench/.gitignore b/extras/android/BtBench/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/extras/android/BtBench/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/extras/android/BtBench/app/.gitignore b/extras/android/BtBench/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/extras/android/BtBench/app/.gitignore
@@ -0,0 +1 @@
+/build \ No newline at end of file
diff --git a/extras/android/BtBench/app/build.gradle.kts b/extras/android/BtBench/app/build.gradle.kts
new file mode 100644
index 0000000..ffde197
--- /dev/null
+++ b/extras/android/BtBench/app/build.gradle.kts
@@ -0,0 +1,70 @@
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.kotlinAndroid)
+}
+
+android {
+ namespace = "com.github.google.bumble.btbench"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.github.google.bumble.btbench"
+ minSdk = 30
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.1"
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+
+ implementation(libs.core.ktx)
+ implementation(libs.lifecycle.runtime.ktx)
+ implementation(libs.activity.compose)
+ implementation(platform(libs.compose.bom))
+ implementation(libs.ui)
+ implementation(libs.ui.graphics)
+ implementation(libs.ui.tooling.preview)
+ implementation(libs.material3)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.test.ext.junit)
+ androidTestImplementation(libs.espresso.core)
+ androidTestImplementation(platform(libs.compose.bom))
+ androidTestImplementation(libs.ui.test.junit4)
+ debugImplementation(libs.ui.tooling)
+ debugImplementation(libs.ui.test.manifest)
+} \ No newline at end of file
diff --git a/extras/android/BtBench/app/proguard-rules.pro b/extras/android/BtBench/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/extras/android/BtBench/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/AndroidManifest.xml b/extras/android/BtBench/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c77eb1a
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/AndroidManifest.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.github.google.bumble.btbench">
+ <uses-sdk android:minSdkVersion="30" android:targetSdkVersion="34" />
+ <!-- Request legacy Bluetooth permissions on older devices. -->
+ <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
+ <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
+
+ <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
+ <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
+ <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+
+ <uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
+ <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
+
+ <application
+ android:allowBackup="true"
+ android:dataExtractionRules="@xml/data_extraction_rules"
+ android:fullBackupContent="@xml/backup_rules"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:roundIcon="@mipmap/ic_launcher_round"
+ android:supportsRtl="true"
+ android:theme="@style/Theme.BTBench"
+ >
+ <activity
+ android:name=".MainActivity"
+ android:exported="true"
+ android:theme="@style/Theme.BTBench">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+<!-- <profileable android:shell="true"/>-->
+ </application>
+
+</manifest> \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/ic_launcher-playstore.png b/extras/android/BtBench/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..d27fdd2
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/ic_launcher-playstore.png
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt
new file mode 100644
index 0000000..95cdae6
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capClient.kt
@@ -0,0 +1,101 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothProfile
+import android.content.Context
+import android.os.Build
+import java.util.logging.Logger
+
+private val Log = Logger.getLogger("btbench.l2cap-client")
+
+class L2capClient(
+ private val viewModel: AppViewModel,
+ private val bluetoothAdapter: BluetoothAdapter,
+ private val context: Context
+) {
+ @SuppressLint("MissingPermission")
+ fun run() {
+ viewModel.running = true
+ val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
+ val address = viewModel.peerBluetoothAddress.take(17)
+ val remoteDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ bluetoothAdapter.getRemoteLeDevice(
+ address,
+ if (addressIsPublic) {
+ BluetoothDevice.ADDRESS_TYPE_PUBLIC
+ } else {
+ BluetoothDevice.ADDRESS_TYPE_RANDOM
+ }
+ )
+ } else {
+ bluetoothAdapter.getRemoteDevice(address)
+ }
+
+ val gatt = remoteDevice.connectGatt(
+ context,
+ false,
+ object : BluetoothGattCallback() {
+ override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
+ Log.info("MTU update: mtu=$mtu status=$status")
+ viewModel.mtu = mtu
+ }
+
+ override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
+ Log.info("PHY update: tx=$txPhy, rx=$rxPhy, status=$status")
+ viewModel.txPhy = txPhy
+ viewModel.rxPhy = rxPhy
+ }
+
+ override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
+ Log.info("PHY: tx=$txPhy, rx=$rxPhy, status=$status")
+ viewModel.txPhy = txPhy
+ viewModel.rxPhy = rxPhy
+ }
+
+ override fun onConnectionStateChange(
+ gatt: BluetoothGatt?, status: Int, newState: Int
+ ) {
+ if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
+ if (viewModel.use2mPhy) {
+ gatt.setPreferredPhy(
+ BluetoothDevice.PHY_LE_2M_MASK,
+ BluetoothDevice.PHY_LE_2M_MASK,
+ BluetoothDevice.PHY_OPTION_NO_PREFERRED
+ )
+ }
+ gatt.readPhy()
+
+ // Request an MTU update, even though we don't use GATT, because Android
+ // won't request a larger link layer maximum data length otherwise.
+ gatt.requestMtu(517)
+ }
+ }
+ },
+ BluetoothDevice.TRANSPORT_LE,
+ if (viewModel.use2mPhy) BluetoothDevice.PHY_LE_2M_MASK else BluetoothDevice.PHY_LE_1M_MASK
+ )
+
+ val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
+
+ val client = SocketClient(viewModel, socket)
+ client.run()
+ }
+} \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt
new file mode 100644
index 0000000..76c297b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/L2capServer.kt
@@ -0,0 +1,61 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.le.AdvertiseCallback
+import android.bluetooth.le.AdvertiseData
+import android.bluetooth.le.AdvertiseSettings
+import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
+import android.os.Build
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.l2cap-server")
+
+class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
+ @SuppressLint("MissingPermission")
+ fun run() {
+ // Advertise so that the peer can find us and connect.
+ val callback = object: AdvertiseCallback() {
+ override fun onStartFailure(errorCode: Int) {
+ Log.warning("failed to start advertising: $errorCode")
+ }
+
+ override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
+ Log.info("advertising started: $settingsInEffect")
+ }
+ }
+ val advertiseSettingsBuilder = AdvertiseSettings.Builder()
+ .setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
+ .setConnectable(true)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ advertiseSettingsBuilder.setDiscoverable(true)
+ }
+ val advertiseSettings = advertiseSettingsBuilder.build()
+ val advertiseData = AdvertiseData.Builder().build()
+ val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
+ val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
+
+ val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel()
+ viewModel.l2capPsm = serverSocket.psm
+ Log.info("psm = $serverSocket.psm")
+
+ val server = SocketServer(viewModel, serverSocket)
+ server.run({ advertiser.stopAdvertising(callback) }, { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) })
+ }
+} \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt
new file mode 100644
index 0000000..6081837
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/MainActivity.kt
@@ -0,0 +1,347 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Divider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.content.ContextCompat
+import com.github.google.bumble.btbench.ui.theme.BTBenchTheme
+import java.util.logging.Logger
+
+private val Log = Logger.getLogger("bumble.main-activity")
+
+const val PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
+const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
+const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
+
+class MainActivity : ComponentActivity() {
+ private val appViewModel = AppViewModel()
+ private var bluetoothAdapter: BluetoothAdapter? = null
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ appViewModel.loadPreferences(getPreferences(Context.MODE_PRIVATE))
+ checkPermissions()
+ }
+
+ private fun checkPermissions() {
+ val neededPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ arrayOf(
+ Manifest.permission.BLUETOOTH_ADVERTISE,
+ Manifest.permission.BLUETOOTH_SCAN,
+ Manifest.permission.BLUETOOTH_CONNECT
+ )
+ } else {
+ arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
+ }
+ val missingPermissions = neededPermissions.filter {
+ ContextCompat.checkSelfPermission(baseContext, it) != PackageManager.PERMISSION_GRANTED
+ }
+
+ if (missingPermissions.isEmpty()) {
+ start()
+ return
+ }
+
+ val requestPermissionsLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ permissions.entries.forEach {
+ Log.info("permission: ${it.key} = ${it.value}")
+ }
+ val grantCount = permissions.count { it.value }
+ if (grantCount == neededPermissions.size) {
+ // We have all the permissions we need.
+ start()
+ } else {
+ Log.warning("not all permissions granted")
+ }
+ }
+
+ requestPermissionsLauncher.launch(missingPermissions.toTypedArray())
+ return
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun initBluetooth() {
+ val bluetoothManager = ContextCompat.getSystemService(this, BluetoothManager::class.java)
+ bluetoothAdapter = bluetoothManager?.adapter
+
+ if (bluetoothAdapter == null) {
+ Log.warning("no bluetooth adapter")
+ return
+ }
+
+ if (!bluetoothAdapter!!.isEnabled) {
+ Log.warning("bluetooth not enabled")
+ return
+ }
+ }
+
+ private fun start() {
+ initBluetooth()
+ setContent {
+ MainView(
+ appViewModel,
+ ::becomeDiscoverable,
+ ::runRfcommClient,
+ ::runRfcommServer,
+ ::runL2capClient,
+ ::runL2capServer
+ )
+ }
+
+ // Process intent parameters, if any.
+ intent.getStringExtra("peer-bluetooth-address")?.let {
+ appViewModel.peerBluetoothAddress = it
+ }
+ val packetCount = intent.getIntExtra("packet-count", 0)
+ if (packetCount > 0) {
+ appViewModel.senderPacketCount = packetCount
+ }
+ appViewModel.updateSenderPacketCountSlider()
+ val packetSize = intent.getIntExtra("packet-size", 0)
+ if (packetSize > 0) {
+ appViewModel.senderPacketSize = packetSize
+ }
+ appViewModel.updateSenderPacketSizeSlider()
+ intent.getStringExtra("autostart")?.let {
+ when (it) {
+ "rfcomm-client" -> runRfcommClient()
+ "rfcomm-server" -> runRfcommServer()
+ "l2cap-client" -> runL2capClient()
+ "l2cap-server" -> runL2capServer()
+ }
+ }
+ }
+
+ private fun runRfcommClient() {
+ val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
+ rfcommClient?.run()
+ }
+
+ private fun runRfcommServer() {
+ val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
+ rfcommServer?.run()
+ }
+
+ private fun runL2capClient() {
+ val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it, baseContext) }
+ l2capClient?.run()
+ }
+
+ private fun runL2capServer() {
+ val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
+ l2capServer?.run()
+ }
+
+ @SuppressLint("MissingPermission")
+ fun becomeDiscoverable() {
+ val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
+ discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300)
+ startActivity(discoverableIntent)
+ }
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun MainView(
+ appViewModel: AppViewModel,
+ becomeDiscoverable: () -> Unit,
+ runRfcommClient: () -> Unit,
+ runRfcommServer: () -> Unit,
+ runL2capClient: () -> Unit,
+ runL2capServer: () -> Unit
+) {
+ BTBenchTheme {
+ val scrollState = rememberScrollState()
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ Text(
+ text = "Bumble Bench",
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ Divider()
+ val keyboardController = LocalSoftwareKeyboardController.current
+ val focusRequester = remember { FocusRequester() }
+ val focusManager = LocalFocusManager.current
+ TextField(
+ label = {
+ Text(text = "Peer Bluetooth Address")
+ },
+ value = appViewModel.peerBluetoothAddress,
+ modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
+ ),
+ onValueChange = {
+ appViewModel.updatePeerBluetoothAddress(it)
+ },
+ keyboardActions = KeyboardActions(onDone = {
+ keyboardController?.hide()
+ focusManager.clearFocus()
+ })
+ )
+ Divider()
+ TextField(label = {
+ Text(text = "L2CAP PSM")
+ },
+ value = appViewModel.l2capPsm.toString(),
+ modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
+ ),
+ onValueChange = {
+ if (it.isNotEmpty()) {
+ val psm = it.toIntOrNull()
+ if (psm != null) {
+ appViewModel.l2capPsm = psm
+ }
+ }
+ },
+ keyboardActions = KeyboardActions(onDone = {
+ keyboardController?.hide()
+ focusManager.clearFocus()
+ })
+ )
+ Divider()
+ Slider(
+ value = appViewModel.senderPacketCountSlider, onValueChange = {
+ appViewModel.senderPacketCountSlider = it
+ appViewModel.updateSenderPacketCount()
+ }, steps = 4
+ )
+ Text(text = "Packet Count: " + appViewModel.senderPacketCount.toString())
+ Divider()
+ Slider(
+ value = appViewModel.senderPacketSizeSlider, onValueChange = {
+ appViewModel.senderPacketSizeSlider = it
+ appViewModel.updateSenderPacketSize()
+ }, steps = 4
+ )
+ Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
+ Divider()
+ ActionButton(
+ text = "Become Discoverable", onClick = becomeDiscoverable, true
+ )
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(text = "2M PHY")
+ Spacer(modifier = Modifier.padding(start = 8.dp))
+ Switch(
+ checked = appViewModel.use2mPhy,
+ onCheckedChange = { appViewModel.use2mPhy = it }
+ )
+
+ }
+ Row {
+ ActionButton(
+ text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
+ )
+ ActionButton(
+ text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
+ )
+ }
+ Row {
+ ActionButton(
+ text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
+ )
+ ActionButton(
+ text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
+ )
+ }
+ Divider()
+ Text(
+ text = if (appViewModel.mtu != 0) "MTU: ${appViewModel.mtu}" else ""
+ )
+ Text(
+ text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else ""
+ )
+ Text(
+ text = "Packets Sent: ${appViewModel.packetsSent}"
+ )
+ Text(
+ text = "Packets Received: ${appViewModel.packetsReceived}"
+ )
+ Text(
+ text = "Throughput: ${appViewModel.throughput}"
+ )
+ Divider()
+ ActionButton(
+ text = "Abort", onClick = appViewModel::abort, appViewModel.running
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
+ Button(onClick = onClick, enabled = enabled) {
+ Text(text = text)
+ }
+} \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt
new file mode 100644
index 0000000..1a8cd6d
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Model.kt
@@ -0,0 +1,169 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.content.SharedPreferences
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import java.util.UUID
+
+val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
+const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
+const val DEFAULT_SENDER_PACKET_COUNT = 100
+const val DEFAULT_SENDER_PACKET_SIZE = 1024
+const val DEFAULT_PSM = 128
+
+class AppViewModel : ViewModel() {
+ private var preferences: SharedPreferences? = null
+ var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
+ var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
+ var use2mPhy by mutableStateOf(true)
+ var mtu by mutableIntStateOf(0)
+ var rxPhy by mutableIntStateOf(0)
+ var txPhy by mutableIntStateOf(0)
+ var senderPacketCountSlider by mutableFloatStateOf(0.0F)
+ var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
+ var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
+ var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
+ var packetsSent by mutableIntStateOf(0)
+ var packetsReceived by mutableIntStateOf(0)
+ var throughput by mutableIntStateOf(0)
+ var running by mutableStateOf(false)
+ var aborter: (() -> Unit)? = null
+
+ fun loadPreferences(preferences: SharedPreferences) {
+ this.preferences = preferences
+
+ val savedPeerBluetoothAddress = preferences.getString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, null)
+ if (savedPeerBluetoothAddress != null) {
+ peerBluetoothAddress = savedPeerBluetoothAddress
+ }
+
+ val savedSenderPacketCount = preferences.getInt(SENDER_PACKET_COUNT_PREF_KEY, 0)
+ if (savedSenderPacketCount != 0) {
+ senderPacketCount = savedSenderPacketCount
+ }
+ updateSenderPacketCountSlider()
+
+ val savedSenderPacketSize = preferences.getInt(SENDER_PACKET_SIZE_PREF_KEY, 0)
+ if (savedSenderPacketSize != 0) {
+ senderPacketSize = savedSenderPacketSize
+ }
+ updateSenderPacketSizeSlider()
+ }
+
+ fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
+ val address = peerBluetoothAddress.uppercase()
+ this.peerBluetoothAddress = address
+
+ // Save the address to the preferences
+ with(preferences!!.edit()) {
+ putString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, address)
+ apply()
+ }
+ }
+
+ fun updateSenderPacketCountSlider() {
+ senderPacketCountSlider = if (senderPacketCount <= 10) {
+ 0.0F
+ } else if (senderPacketCount <= 50) {
+ 0.2F
+ } else if (senderPacketCount <= 100) {
+ 0.4F
+ } else if (senderPacketCount <= 500) {
+ 0.6F
+ } else if (senderPacketCount <= 1000) {
+ 0.8F
+ } else {
+ 1.0F
+ }
+
+ with(preferences!!.edit()) {
+ putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
+ apply()
+ }
+ }
+
+ fun updateSenderPacketCount() {
+ senderPacketCount = if (senderPacketCountSlider < 0.1F) {
+ 10
+ } else if (senderPacketCountSlider < 0.3F) {
+ 50
+ } else if (senderPacketCountSlider < 0.5F) {
+ 100
+ } else if (senderPacketCountSlider < 0.7F) {
+ 500
+ } else if (senderPacketCountSlider < 0.9F) {
+ 1000
+ } else {
+ 10000
+ }
+
+ with(preferences!!.edit()) {
+ putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
+ apply()
+ }
+ }
+
+ fun updateSenderPacketSizeSlider() {
+ senderPacketSizeSlider = if (senderPacketSize <= 16) {
+ 0.0F
+ } else if (senderPacketSize <= 256) {
+ 0.02F
+ } else if (senderPacketSize <= 512) {
+ 0.4F
+ } else if (senderPacketSize <= 1024) {
+ 0.6F
+ } else if (senderPacketSize <= 2048) {
+ 0.8F
+ } else {
+ 1.0F
+ }
+
+ with(preferences!!.edit()) {
+ putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
+ apply()
+ }
+ }
+
+ fun updateSenderPacketSize() {
+ senderPacketSize = if (senderPacketSizeSlider < 0.1F) {
+ 16
+ } else if (senderPacketSizeSlider < 0.3F) {
+ 256
+ } else if (senderPacketSizeSlider < 0.5F) {
+ 512
+ } else if (senderPacketSizeSlider < 0.7F) {
+ 1024
+ } else if (senderPacketSizeSlider < 0.9F) {
+ 2048
+ } else {
+ 4096
+ }
+
+ with(preferences!!.edit()) {
+ putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
+ apply()
+ }
+ }
+
+ fun abort() {
+ aborter?.let { it() }
+ }
+}
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt
new file mode 100644
index 0000000..0fa8500
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Packet.kt
@@ -0,0 +1,178 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.bluetooth.BluetoothSocket
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.util.logging.Logger
+import kotlin.math.min
+
+private val Log = Logger.getLogger("btbench.packet")
+
+fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
+
+abstract class Packet(val type: Int, val payload: ByteArray = ByteArray(0)) {
+ companion object {
+ const val RESET = 0
+ const val SEQUENCE = 1
+ const val ACK = 2
+
+ const val LAST_FLAG = 1
+
+ fun from(data: ByteArray): Packet {
+ return when (data[0].toInt()) {
+ RESET -> ResetPacket()
+ SEQUENCE -> SequencePacket(
+ data[1].toInt(),
+ ByteBuffer.wrap(data, 2, 4).getInt(),
+ data.sliceArray(6..<data.size)
+ )
+
+ ACK -> AckPacket(data[1].toInt(), ByteBuffer.wrap(data, 2, 4).getInt())
+ else -> GenericPacket(data[0].toInt(), data.sliceArray(1..<data.size))
+ }
+ }
+ }
+
+ open fun toBytes(): ByteArray {
+ return ByteBuffer.allocate(1 + payload.size).put(type.toByte()).put(payload).array()
+ }
+}
+
+class GenericPacket(type: Int, payload: ByteArray) : Packet(type, payload)
+class ResetPacket : Packet(RESET)
+
+class AckPacket(val flags: Int, val sequenceNumber: Int) : Packet(ACK) {
+ override fun toBytes(): ByteArray {
+ return ByteBuffer.allocate(1 + 1 + 4).put(type.toByte()).put(flags.toByte())
+ .putInt(sequenceNumber).array()
+ }
+}
+
+class SequencePacket(val flags: Int, val sequenceNumber: Int, payload: ByteArray) :
+ Packet(SEQUENCE, payload) {
+ override fun toBytes(): ByteArray {
+ return ByteBuffer.allocate(1 + 1 + 4 + payload.size).put(type.toByte()).put(flags.toByte())
+ .putInt(sequenceNumber).put(payload).array()
+ }
+}
+
+abstract class PacketSink {
+ fun onPacket(packet: Packet) {
+ when (packet) {
+ is ResetPacket -> onResetPacket()
+ is AckPacket -> onAckPacket()
+ is SequencePacket -> onSequencePacket(packet)
+ }
+ }
+
+ abstract fun onResetPacket()
+ abstract fun onAckPacket()
+ abstract fun onSequencePacket(packet: SequencePacket)
+}
+
+interface DataSink {
+ fun onData(data: ByteArray)
+}
+
+interface PacketIO {
+ var packetSink: PacketSink?
+ fun sendPacket(packet: Packet)
+}
+
+class StreamedPacketIO(private val dataSink: DataSink) : PacketIO {
+ private var bytesNeeded: Int = 0
+ private var rxPacket: ByteBuffer? = null
+ private var rxHeader = ByteBuffer.allocate(2)
+
+ override var packetSink: PacketSink? = null
+
+ fun onData(data: ByteArray) {
+ var current = data
+ while (current.isNotEmpty()) {
+ if (bytesNeeded > 0) {
+ val chunk = current.sliceArray(0..<min(bytesNeeded, current.size))
+ rxPacket!!.put(chunk)
+ current = current.sliceArray(chunk.size..<current.size)
+ bytesNeeded -= chunk.size
+ if (bytesNeeded == 0) {
+ // Packet completed.
+ //Log.fine("packet complete: ${current.toHex()}")
+ packetSink?.onPacket(Packet.from(rxPacket!!.array()))
+
+ // Reset.
+ reset()
+ }
+ } else {
+ val headerBytesNeeded = 2 - rxHeader.position()
+ val headerBytes = current.sliceArray(0..<min(headerBytesNeeded, current.size))
+ current = current.sliceArray(headerBytes.size..<current.size)
+ rxHeader.put(headerBytes)
+ if (rxHeader.position() != 2) {
+ return
+ }
+ bytesNeeded = rxHeader.getShort(0).toInt()
+ if (bytesNeeded == 0) {
+ Log.warning("found 0 size packet!")
+ reset()
+ return
+ }
+ rxPacket = ByteBuffer.allocate(bytesNeeded)
+ }
+ }
+ }
+
+ private fun reset() {
+ rxPacket = null
+ rxHeader.position(0)
+ }
+
+ override fun sendPacket(packet: Packet) {
+ val packetBytes = packet.toBytes()
+ val packetData =
+ ByteBuffer.allocate(2 + packetBytes.size).putShort(packetBytes.size.toShort())
+ .put(packetBytes).array()
+ dataSink.onData(packetData)
+ }
+}
+
+class SocketDataSink(private val socket: BluetoothSocket) : DataSink {
+ override fun onData(data: ByteArray) {
+ socket.outputStream.write(data)
+ }
+}
+
+class SocketDataSource(
+ private val socket: BluetoothSocket,
+ private val onData: (data: ByteArray) -> Unit
+) {
+ fun receive() {
+ val buffer = ByteArray(4096)
+ do {
+ try {
+ val bytesRead = socket.inputStream.read(buffer)
+ if (bytesRead <= 0) {
+ break
+ }
+ onData(buffer.sliceArray(0..<bytesRead))
+ } catch (error: IOException) {
+ Log.warning("IO Exception: $error")
+ break
+ }
+ } while (true)
+ Log.info("end of stream")
+ }
+} \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt
new file mode 100644
index 0000000..c3844b8
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Receiver.kt
@@ -0,0 +1,60 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import java.util.logging.Logger
+import kotlin.time.DurationUnit
+import kotlin.time.TimeSource
+
+private val Log = Logger.getLogger("btbench.receiver")
+
+class Receiver(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
+ private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
+ private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
+ private var bytesReceived = 0
+
+ init {
+ packetIO.packetSink = this
+ }
+
+ override fun onResetPacket() {
+ startTime = TimeSource.Monotonic.markNow()
+ lastPacketTime = startTime
+ bytesReceived = 0
+ viewModel.throughput = 0
+ viewModel.packetsSent = 0
+ viewModel.packetsReceived = 0
+ }
+
+ override fun onAckPacket() {
+
+ }
+
+ override fun onSequencePacket(packet: SequencePacket) {
+ val received = packet.payload.size + 6
+ bytesReceived += received
+ val now = TimeSource.Monotonic.markNow()
+ lastPacketTime = now
+ viewModel.packetsReceived += 1
+ if (packet.flags and Packet.LAST_FLAG != 0) {
+ Log.info("received last packet")
+ val elapsed = now - startTime
+ val throughput = (bytesReceived / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
+ Log.info("throughput: $throughput")
+ viewModel.throughput = throughput
+ packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
+ }
+ }
+}
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt
new file mode 100644
index 0000000..e976c42
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommClient.kt
@@ -0,0 +1,37 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.rfcomm-client")
+
+class RfcommClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
+ @SuppressLint("MissingPermission")
+ fun run() {
+ val address = viewModel.peerBluetoothAddress.take(17)
+ val remoteDevice = bluetoothAdapter.getRemoteDevice(address)
+ val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
+ DEFAULT_RFCOMM_UUID
+ )
+
+ val client = SocketClient(viewModel, socket)
+ client.run()
+ }
+}
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt
new file mode 100644
index 0000000..69612c5
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/RfcommServer.kt
@@ -0,0 +1,35 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.rfcomm-server")
+
+class RfcommServer(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
+ @SuppressLint("MissingPermission")
+ fun run() {
+ val serverSocket = bluetoothAdapter.listenUsingInsecureRfcommWithServiceRecord(
+ "BumbleBench", DEFAULT_RFCOMM_UUID
+ )
+
+ val server = SocketServer(viewModel, serverSocket)
+ server.run({}, {})
+ }
+} \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt
new file mode 100644
index 0000000..293ac9a
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/Sender.kt
@@ -0,0 +1,84 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import java.util.concurrent.Semaphore
+import java.util.logging.Logger
+import kotlin.time.DurationUnit
+import kotlin.time.TimeSource
+
+private val Log = Logger.getLogger("btbench.sender")
+
+class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : PacketSink() {
+ private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
+ private var bytesSent = 0
+ private val done = Semaphore(0)
+
+ init {
+ packetIO.packetSink = this
+ }
+
+ fun run() {
+ viewModel.packetsSent = 0
+ viewModel.packetsReceived = 0
+ viewModel.throughput = 0
+
+ Log.info("sending reset")
+ packetIO.sendPacket(ResetPacket())
+
+ startTime = TimeSource.Monotonic.markNow()
+
+ val packetCount = viewModel.senderPacketCount
+ val packetSize = viewModel.senderPacketSize
+ for (i in 0..<packetCount - 1) {
+ packetIO.sendPacket(SequencePacket(0, i, ByteArray(packetSize - 6)))
+ bytesSent += packetSize
+ viewModel.packetsSent = i + 1
+ }
+ packetIO.sendPacket(
+ SequencePacket(
+ Packet.LAST_FLAG,
+ packetCount - 1,
+ ByteArray(packetSize - 6)
+ )
+ )
+ bytesSent += packetSize
+ viewModel.packetsSent = packetCount
+
+ // Wait for the ACK
+ Log.info("waiting for ACK")
+ done.acquire()
+ Log.info("got ACK")
+ }
+
+ fun abort() {
+ done.release()
+ }
+
+ override fun onResetPacket() {
+ }
+
+ override fun onAckPacket() {
+ Log.info("received ACK")
+ val elapsed = TimeSource.Monotonic.markNow() - startTime
+ val throughput = (bytesSent / elapsed.toDouble(DurationUnit.SECONDS)).toInt()
+ Log.info("throughput: $throughput")
+ viewModel.throughput = throughput
+ done.release()
+ }
+
+ override fun onSequencePacket(packet: SequencePacket) {
+ }
+} \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt
new file mode 100644
index 0000000..bd5b7f4
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketClient.kt
@@ -0,0 +1,69 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothSocket
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.socket-client")
+
+private const val DEFAULT_STARTUP_DELAY = 3000
+
+class SocketClient(private val viewModel: AppViewModel, private val socket: BluetoothSocket) {
+ @SuppressLint("MissingPermission")
+ fun run() {
+ viewModel.running = true
+ val socketDataSink = SocketDataSink(socket)
+ val streamIO = StreamedPacketIO(socketDataSink)
+ val socketDataSource = SocketDataSource(socket, streamIO::onData)
+ val sender = Sender(viewModel, streamIO)
+
+ fun cleanup() {
+ socket.close()
+ viewModel.aborter = {}
+ viewModel.running = false
+ }
+
+ thread(name = "SocketClient") {
+ viewModel.aborter = {
+ sender.abort()
+ socket.close()
+ }
+ Log.info("connecting to remote")
+ try {
+ socket.connect()
+ } catch (error: IOException) {
+ Log.warning("connection failed")
+ cleanup()
+ return@thread
+ }
+ Log.info("connected")
+
+ thread {
+ socketDataSource.receive()
+ }
+
+ Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
+ Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
+ Log.info("Starting to send")
+
+ sender.run()
+ cleanup()
+ }
+ }
+} \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt
new file mode 100644
index 0000000..e83a47f
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/SocketServer.kt
@@ -0,0 +1,67 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.google.bumble.btbench
+
+import android.bluetooth.BluetoothServerSocket
+import java.io.IOException
+import java.util.logging.Logger
+import kotlin.concurrent.thread
+
+private val Log = Logger.getLogger("btbench.socket-server")
+
+class SocketServer(private val viewModel: AppViewModel, private val serverSocket: BluetoothServerSocket) {
+ fun run(onConnected: () -> Unit, onDisconnected: () -> Unit) {
+ var aborted = false
+ viewModel.running = true
+
+ fun cleanup() {
+ serverSocket.close()
+ viewModel.running = false
+ }
+
+ thread(name = "SocketServer") {
+ while (!aborted) {
+ viewModel.aborter = {
+ serverSocket.close()
+ }
+ Log.info("waiting for connection...")
+ onDisconnected()
+ val socket = try {
+ serverSocket.accept()
+ } catch (error: IOException) {
+ Log.warning("server socket closed")
+ cleanup()
+ return@thread
+ }
+ Log.info("got connection from ${socket.remoteDevice.address}")
+ onConnected()
+
+ viewModel.aborter = {
+ aborted = true
+ socket.close()
+ }
+ viewModel.peerBluetoothAddress = socket.remoteDevice.address
+
+ val socketDataSink = SocketDataSink(socket)
+ val streamIO = StreamedPacketIO(socketDataSink)
+ val socketDataSource = SocketDataSource(socket, streamIO::onData)
+ val receiver = Receiver(viewModel, streamIO)
+ socketDataSource.receive()
+ socket.close()
+ }
+ cleanup()
+ }
+ }
+} \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Color.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Color.kt
new file mode 100644
index 0000000..2b538c8
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.github.google.bumble.btbench.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260) \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Theme.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Theme.kt
new file mode 100644
index 0000000..1751579
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Theme.kt
@@ -0,0 +1,63 @@
+package com.github.google.bumble.btbench.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun BTBenchTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true, content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = colorScheme.primary.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme, typography = Typography, content = content
+ )
+} \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Type.kt b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Type.kt
new file mode 100644
index 0000000..029f898
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/java/com/github/google/bumble/btbench/ui/theme/Type.kt
@@ -0,0 +1,33 @@
+package com.github.google.bumble.btbench.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )/* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+) \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_background.xml b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..ca3826a
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector
+ android:height="108dp"
+ android:width="108dp"
+ android:viewportHeight="108"
+ android:viewportWidth="108"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="#3DDC84"
+ android:pathData="M0,0h108v108h-108z"/>
+ <path android:fillColor="#00000000" android:pathData="M9,0L9,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,0L19,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M29,0L29,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M39,0L39,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M49,0L49,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M59,0L59,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M69,0L69,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M79,0L79,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M89,0L89,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M99,0L99,108"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,9L108,9"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,19L108,19"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,29L108,29"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,39L108,39"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,49L108,49"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,59L108,59"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,69L108,69"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,79L108,79"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,89L108,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M0,99L108,99"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,29L89,29"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,39L89,39"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,49L89,49"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,59L89,59"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,69L89,69"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M19,79L89,79"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M29,19L29,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M39,19L39,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M49,19L49,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M59,19L59,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M69,19L69,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+ <path android:fillColor="#00000000" android:pathData="M79,19L79,89"
+ android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
+</vector>
diff --git a/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_foreground.xml b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..2b068d1
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:aapt="http://schemas.android.com/aapt"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
+ <aapt:attr name="android:fillColor">
+ <gradient
+ android:endX="85.84757"
+ android:endY="92.4963"
+ android:startX="42.9492"
+ android:startY="49.59793"
+ android:type="linear">
+ <item
+ android:color="#44000000"
+ android:offset="0.0" />
+ <item
+ android:color="#00000000"
+ android:offset="1.0" />
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path
+ android:fillColor="#FFFFFF"
+ android:fillType="nonZero"
+ android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
+ android:strokeWidth="1"
+ android:strokeColor="#00000000" />
+</vector> \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..036d09b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/ic_launcher_background"/>
+ <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+</adaptive-icon> \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..036d09b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/ic_launcher_background"/>
+ <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+</adaptive-icon> \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..7dc4135
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..37c0b56
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..e8f5332
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..ac1ae9b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..5e12fc6
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..19ac4bf
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..30516ad
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..7a39c13
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..a2b1c8b
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..2bbc83f
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Binary files differ
diff --git a/extras/android/BtBench/app/src/main/res/values/colors.xml b/extras/android/BtBench/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="purple_200">#FFBB86FC</color>
+ <color name="purple_500">#FF6200EE</color>
+ <color name="purple_700">#FF3700B3</color>
+ <color name="teal_200">#FF03DAC5</color>
+ <color name="teal_700">#FF018786</color>
+ <color name="black">#FF000000</color>
+ <color name="white">#FFFFFFFF</color>
+</resources> \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/values/ic_launcher_background.xml b/extras/android/BtBench/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..c5d5899
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="ic_launcher_background">#FFFFFF</color>
+</resources> \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/values/strings.xml b/extras/android/BtBench/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..018c3f9
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+<resources>
+ <string name="app_name">BT Bench</string>
+</resources> \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/values/themes.xml b/extras/android/BtBench/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..f0d08db
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <style name="Theme.BTBench" parent="android:Theme.Material.Light.NoActionBar" />
+</resources> \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/xml/backup_rules.xml b/extras/android/BtBench/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..fa0f996
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Sample backup rules file; uncomment and customize as necessary.
+ See https://developer.android.com/guide/topics/data/autobackup
+ for details.
+ Note: This file is ignored for devices older that API 31
+ See https://developer.android.com/about/versions/12/backup-restore
+-->
+<full-backup-content>
+ <!--
+ <include domain="sharedpref" path="."/>
+ <exclude domain="sharedpref" path="device.xml"/>
+-->
+</full-backup-content> \ No newline at end of file
diff --git a/extras/android/BtBench/app/src/main/res/xml/data_extraction_rules.xml b/extras/android/BtBench/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/extras/android/BtBench/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Sample data extraction rules file; uncomment and customize as necessary.
+ See https://developer.android.com/about/versions/12/backup-restore#xml-changes
+ for details.
+-->
+<data-extraction-rules>
+ <cloud-backup>
+ <!-- TODO: Use <include> and <exclude> to control what is backed up.
+ <include .../>
+ <exclude .../>
+ -->
+ </cloud-backup>
+ <!--
+ <device-transfer>
+ <include .../>
+ <exclude .../>
+ </device-transfer>
+ -->
+</data-extraction-rules> \ No newline at end of file
diff --git a/extras/android/BtBench/build.gradle.kts b/extras/android/BtBench/build.gradle.kts
new file mode 100644
index 0000000..20d87a7
--- /dev/null
+++ b/extras/android/BtBench/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+plugins {
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.kotlinAndroid) apply false
+}
+true // Needed to make the Suppress annotation work for the plugins block \ No newline at end of file
diff --git a/extras/android/BtBench/gradle.properties b/extras/android/BtBench/gradle.properties
new file mode 100644
index 0000000..3c5031e
--- /dev/null
+++ b/extras/android/BtBench/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true \ No newline at end of file
diff --git a/extras/android/BtBench/gradle/libs.versions.toml b/extras/android/BtBench/gradle/libs.versions.toml
new file mode 100644
index 0000000..26945a9
--- /dev/null
+++ b/extras/android/BtBench/gradle/libs.versions.toml
@@ -0,0 +1,31 @@
+[versions]
+agp = "8.2.0"
+kotlin = "1.9.0"
+core-ktx = "1.12.0"
+junit = "4.13.2"
+androidx-test-ext-junit = "1.1.5"
+espresso-core = "3.5.1"
+lifecycle-runtime-ktx = "2.6.2"
+activity-compose = "1.7.2"
+compose-bom = "2023.08.00"
+
+[libraries]
+core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
+espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
+lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" }
+activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
+ui = { group = "androidx.compose.ui", name = "ui" }
+ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+material3 = { group = "androidx.compose.material3", name = "material3" }
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+
diff --git a/extras/android/BtBench/gradle/wrapper/gradle-wrapper.jar b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
--- /dev/null
+++ b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..8ef5972
--- /dev/null
+++ b/extras/android/BtBench/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Wed Oct 25 07:40:52 PDT 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/extras/android/BtBench/gradlew b/extras/android/BtBench/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/extras/android/BtBench/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# 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.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/extras/android/BtBench/gradlew.bat b/extras/android/BtBench/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/extras/android/BtBench/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/extras/android/BtBench/settings.gradle.kts b/extras/android/BtBench/settings.gradle.kts
new file mode 100644
index 0000000..9bdd1ab
--- /dev/null
+++ b/extras/android/BtBench/settings.gradle.kts
@@ -0,0 +1,24 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "BT Bench"
+include(":app")
+ \ No newline at end of file
diff --git a/extras/android/RemoteHCI/app/build.gradle.kts b/extras/android/RemoteHCI/app/build.gradle.kts
index 2e2df38..0e68a2f 100644
--- a/extras/android/RemoteHCI/app/build.gradle.kts
+++ b/extras/android/RemoteHCI/app/build.gradle.kts
@@ -10,7 +10,7 @@ android {
defaultConfig {
applicationId = "com.github.google.bumble.remotehci"
- minSdk = 26
+ minSdk = 29
targetSdk = 33
versionCode = 1
versionName = "1.0"
diff --git a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/CommandLineInterface.kt b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/CommandLineInterface.kt
new file mode 100644
index 0000000..2f1b59e
--- /dev/null
+++ b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/CommandLineInterface.kt
@@ -0,0 +1,57 @@
+package com.github.google.bumble.remotehci
+
+import java.io.IOException
+
+class CommandLineInterface {
+ companion object {
+ fun printUsage() {
+ System.out.println("usage: <launch-command> [-h|--help] [<tcp-port>]")
+ }
+
+ @JvmStatic fun main(args: Array<String>) {
+ System.out.println("Starting proxy")
+
+ var tcpPort = DEFAULT_TCP_PORT
+ if (args.isNotEmpty()) {
+ if (args[0] == "-h" || args[0] == "--help") {
+ printUsage()
+ return
+ }
+ try {
+ tcpPort = args[0].toInt()
+ } catch (error: NumberFormatException) {
+ System.out.println("ERROR: invalid TCP port argument")
+ printUsage()
+ return
+ }
+ }
+
+ try {
+ val hciProxy = HciProxy(tcpPort, object : HciProxy.Listener {
+ override fun onHostConnectionState(connected: Boolean) {
+ }
+
+ override fun onHciPacketCountChange(
+ commandPacketsReceived: Int,
+ aclPacketsReceived: Int,
+ scoPacketsReceived: Int,
+ eventPacketsSent: Int,
+ aclPacketsSent: Int,
+ scoPacketsSent: Int
+ ) {
+ }
+
+ override fun onMessage(message: String?) {
+ System.out.println(message)
+ }
+
+ })
+ hciProxy.run()
+ } catch (error: IOException) {
+ System.err.println("Exception while running HCI Server: $error")
+ } catch (error: HciProxy.HalException) {
+ System.err.println("HAL exception: ${error.message}")
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciHal.java b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciHal.java
index fd81921..a1bd8eb 100644
--- a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciHal.java
+++ b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciHal.java
@@ -4,6 +4,7 @@ import android.hardware.bluetooth.V1_0.Status;
import android.os.IBinder;
import android.os.RemoteException;
import android.os.ServiceManager;
+import android.os.Trace;
import android.util.Log;
import java.util.ArrayList;
@@ -53,6 +54,7 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
private final android.hardware.bluetooth.V1_0.IBluetoothHci mHciService;
private final HciHalCallback mHciCallbacks;
private int mInitializationStatus = -1;
+ private final boolean mTracingEnabled = Trace.isEnabled();
public static HciHidlHal create(HciHalCallback hciCallbacks) {
@@ -89,6 +91,7 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
}
// Map the status code.
+ Log.d(TAG, "Initialization status = " + mInitializationStatus);
switch (mInitializationStatus) {
case android.hardware.bluetooth.V1_0.Status.SUCCESS:
return Status.SUCCESS;
@@ -108,6 +111,10 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
public void sendPacket(HciPacket.Type type, byte[] packet) {
ArrayList<Byte> data = HciPacket.byteArrayToList(packet);
+ if (mTracingEnabled) {
+ Trace.beginAsyncSection("SEND_PACKET_TO_HAL", 1);
+ }
+
try {
switch (type) {
case COMMAND:
@@ -125,6 +132,10 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
} catch (RemoteException error) {
Log.w(TAG, "failed to forward packet: " + error);
}
+
+ if (mTracingEnabled) {
+ Trace.endAsyncSection("SEND_PACKET_TO_HAL", 1);
+ }
}
@Override
@@ -157,6 +168,7 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
private final android.hardware.bluetooth.IBluetoothHci mHciService;
private final HciHalCallback mHciCallbacks;
private int mInitializationStatus = android.hardware.bluetooth.Status.SUCCESS;
+ private final boolean mTracingEnabled = Trace.isEnabled();
public static HciAidlHal create(HciHalCallback hciCallbacks) {
IBinder binder = ServiceManager.getService("android.hardware.bluetooth.IBluetoothHci/default");
@@ -187,6 +199,7 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
}
// Map the status code.
+ Log.d(TAG, "Initialization status = " + mInitializationStatus);
switch (mInitializationStatus) {
case android.hardware.bluetooth.Status.SUCCESS:
return Status.SUCCESS;
@@ -208,6 +221,10 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
// HciHal methods.
@Override
public void sendPacket(HciPacket.Type type, byte[] packet) {
+ if (mTracingEnabled) {
+ Trace.beginAsyncSection("SEND_PACKET_TO_HAL", 1);
+ }
+
try {
switch (type) {
case COMMAND:
@@ -229,6 +246,10 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
} catch (RemoteException error) {
Log.w(TAG, "failed to forward packet: " + error);
}
+
+ if (mTracingEnabled) {
+ Trace.endAsyncSection("SEND_PACKET_TO_HAL", 1);
+ }
}
// IBluetoothHciCallbacks methods.
diff --git a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciServer.java b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciServer.java
index a78a86a..9332305 100644
--- a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciServer.java
+++ b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/HciServer.java
@@ -1,5 +1,6 @@
package com.github.google.bumble.remotehci;
+import android.os.Trace;
import android.util.Log;
import java.io.IOException;
@@ -15,6 +16,7 @@ public class HciServer {
private final int mPort;
private final Listener mListener;
private OutputStream mOutputStream;
+ private final boolean mTracingEnabled = Trace.isEnabled();
public interface Listener extends HciParser.Sink {
void onHostConnectionState(boolean connected);
@@ -27,6 +29,8 @@ public class HciServer {
}
public void run() throws IOException {
+ Log.i(TAG, "Tracing enabled: " + mTracingEnabled);
+
for (;;) {
try {
loop();
@@ -42,6 +46,7 @@ public class HciServer {
try (ServerSocket serverSocket = new ServerSocket(mPort)) {
mListener.onMessage("Waiting for connection on port " + serverSocket.getLocalPort());
try (Socket clientSocket = serverSocket.accept()) {
+ clientSocket.setTcpNoDelay(true);
mListener.onHostConnectionState(true);
mListener.onMessage("Connected");
HciParser parser = new HciParser(mListener);
@@ -72,6 +77,10 @@ public class HciServer {
}
public void sendPacket(HciPacket.Type type, byte[] packet) {
+ if (mTracingEnabled) {
+ Trace.beginAsyncSection("SEND_PACKET_FROM_HAL", 2);
+ }
+
// Create a combined data buffer so we can write it out in a single call.
byte[] data = new byte[packet.length + 1];
data[0] = type.value;
@@ -88,5 +97,9 @@ public class HciServer {
Log.d(TAG, "no client, dropping packet");
}
}
+
+ if (mTracingEnabled) {
+ Trace.endAsyncSection("SEND_PACKET_FROM_HAL", 2);
+ }
}
}
diff --git a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/MainActivity.kt b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/MainActivity.kt
index 3a2630a..493b7e5 100644
--- a/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/MainActivity.kt
+++ b/extras/android/RemoteHCI/app/src/main/java/com/github/google/bumble/remotehci/MainActivity.kt
@@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -71,7 +73,7 @@ class AppViewModel : ViewModel(), HciProxy.Listener {
this.tcpPort = tcpPort
// Save the port to the preferences
- with (preferences!!.edit()) {
+ with(preferences!!.edit()) {
putString(TCP_PORT_PREF_KEY, tcpPort.toString())
apply()
}
@@ -113,7 +115,7 @@ class MainActivity : ComponentActivity() {
val tcpPort = intent.getIntExtra("port", -1)
if (tcpPort >= 0) {
- appViewModel.tcpPort = tcpPport
+ appViewModel.tcpPort = tcpPort
}
setContent {
@@ -138,7 +140,8 @@ class MainActivity : ComponentActivity() {
log.warning("Exception while running HCI Server: $error")
} catch (error: HalException) {
log.warning("HAL exception: ${error.message}")
- appViewModel.message = "Cannot bind to HAL (${error.message}). You may need to use the command 'setenforce 0' in a root adb shell."
+ appViewModel.message =
+ "Cannot bind to HAL (${error.message}). You may need to use the command 'setenforce 0' in a root adb shell."
}
log.info("HCI Proxy thread ended")
appViewModel.canStart = true
@@ -157,9 +160,12 @@ fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
@Composable
fun MainView(appViewModel: AppViewModel, startProxy: () -> Unit) {
RemoteHCITheme {
- // A surface container using the 'background' color from the theme
+ val scrollState = rememberScrollState()
Surface(
- modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scrollState),
+ color = MaterialTheme.colorScheme.background
) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(
@@ -174,13 +180,15 @@ fun MainView(appViewModel: AppViewModel, startProxy: () -> Unit) {
)
Divider()
val keyboardController = LocalSoftwareKeyboardController.current
- TextField(
- label = {
- Text(text = "TCP Port")
- },
+ TextField(label = {
+ Text(text = "TCP Port")
+ },
value = appViewModel.tcpPort.toString(),
modifier = Modifier.fillMaxWidth(),
- keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done
+ ),
onValueChange = {
if (it.isNotEmpty()) {
val tcpPort = it.toIntOrNull()
@@ -189,10 +197,7 @@ fun MainView(appViewModel: AppViewModel, startProxy: () -> Unit) {
}
}
},
- keyboardActions = KeyboardActions(
- onDone = {keyboardController?.hide()}
- )
- )
+ keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }))
Divider()
val connectState = if (appViewModel.hostConnected) "CONNECTED" else "DISCONNECTED"
Text(
diff --git a/extras/android/RemoteHCI/gradle/libs.versions.toml b/extras/android/RemoteHCI/gradle/libs.versions.toml
index cc1b0f5..8bdfdc7 100644
--- a/extras/android/RemoteHCI/gradle/libs.versions.toml
+++ b/extras/android/RemoteHCI/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-agp = "8.3.0-alpha05"
+agp = "8.2.0"
kotlin = "1.8.10"
core-ktx = "1.9.0"
junit = "4.13.2"
diff --git a/extras/android/RemoteHCI/gradle/wrapper/gradle-wrapper.properties b/extras/android/RemoteHCI/gradle/wrapper/gradle-wrapper.properties
index d58714b..0821cc9 100644
--- a/extras/android/RemoteHCI/gradle/wrapper/gradle-wrapper.properties
+++ b/extras/android/RemoteHCI/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Sun Aug 06 12:53:26 PDT 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-rc-2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index c443561..3339339 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -1073,9 +1073,9 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "openssl"
-version = "0.10.57"
+version = "0.10.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c"
+checksum = "79a4c6c3a2b158f7f8f2a2fc5a969fa3a068df6fc9dbb4a43845436e3af7c800"
dependencies = [
"bitflags 2.4.0",
"cfg-if",
@@ -1105,9 +1105,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
-version = "0.9.92"
+version = "0.9.96"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db7e971c2c2bba161b2d2fdf37080177eff520b3bc044787c7f1f5f9e78d869b"
+checksum = "3812c071ba60da8b5677cc12bcb1d42989a65553772897a7e0355545a819838f"
dependencies = [
"cc",
"libc",
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 35a0f4c..8106114 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -10,7 +10,14 @@ documentation = "https://docs.rs/crate/bumble"
authors = ["Marshall Pierce <marshallpierce@google.com>"]
keywords = ["bluetooth", "ble"]
categories = ["api-bindings", "network-programming"]
-rust-version = "1.70.0"
+rust-version = "1.76.0"
+
+# https://github.com/frewsxcv/cargo-all-features#options
+[package.metadata.cargo-all-features]
+# We are interested in testing subset combinations of this feature, so this is redundant
+denylist = ["unstable"]
+# To exercise combinations of any of these features, remove from `always_include_features`
+always_include_features = ["anyhow", "pyo3-asyncio-attributes", "dev-tools", "bumble-tools"]
[dependencies]
pyo3 = { version = "0.18.3", features = ["macros"] }
@@ -26,6 +33,7 @@ thiserror = "1.0.41"
bytes = "1.5.0"
pdl-derive = "0.2.0"
pdl-runtime = "0.2.0"
+futures = "0.3.28"
# Dev tools
file-header = { version = "0.1.2", optional = true }
@@ -36,7 +44,6 @@ anyhow = { version = "1.0.71", optional = true }
clap = { version = "4.3.3", features = ["derive"], optional = true }
directories = { version = "5.0.1", optional = true }
env_logger = { version = "0.10.0", optional = true }
-futures = { version = "0.3.28", optional = true }
log = { version = "0.4.19", optional = true }
owo-colors = { version = "3.5.0", optional = true }
reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
@@ -74,6 +81,11 @@ name = "bumble"
path = "src/main.rs"
required-features = ["bumble-tools"]
+[[example]]
+name = "broadcast"
+path = "examples/broadcast.rs"
+required-features = ["unstable_extended_adv"]
+
# test entry point that uses pyo3_asyncio's test harness
[[test]]
name = "pytests"
@@ -85,5 +97,10 @@ anyhow = ["pyo3/anyhow"]
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
dev-tools = ["dep:anyhow", "dep:clap", "dep:file-header", "dep:globset"]
# separate feature for CLI so that dependencies don't spend time building these
-bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"]
+bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger"]
+
+# all the unstable features
+unstable = ["unstable_extended_adv"]
+unstable_extended_adv = []
+
default = []
diff --git a/rust/examples/battery_client.rs b/rust/examples/battery_client.rs
index 007ccb6..613d9e8 100644
--- a/rust/examples/battery_client.rs
+++ b/rust/examples/battery_client.rs
@@ -33,6 +33,7 @@
use bumble::wrapper::{
device::{Device, Peer},
+ hci::{packets::AddressType, Address},
profile::BatteryServiceProxy,
transport::Transport,
PyObjectExt,
@@ -52,12 +53,8 @@ async fn main() -> PyResult<()> {
let transport = Transport::open(cli.transport).await?;
- let device = Device::with_hci(
- "Bumble",
- "F0:F1:F2:F3:F4:F5",
- transport.source()?,
- transport.sink()?,
- )?;
+ let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?;
+ let device = Device::with_hci("Bumble", address, transport.source()?, transport.sink()?)?;
device.power_on().await?;
diff --git a/rust/examples/broadcast.rs b/rust/examples/broadcast.rs
index f87b644..affe21e 100644
--- a/rust/examples/broadcast.rs
+++ b/rust/examples/broadcast.rs
@@ -63,17 +63,28 @@ async fn main() -> PyResult<()> {
)
.map_err(|e| anyhow!(e))?;
- device.set_advertising_data(adv_data)?;
device.power_on().await?;
- println!("Advertising...");
- device.start_advertising(true).await?;
+ if cli.extended {
+ println!("Starting extended advertisement...");
+ device.start_advertising_extended(adv_data).await?;
+ } else {
+ device.set_advertising_data(adv_data)?;
+
+ println!("Starting legacy advertisement...");
+ device.start_advertising(true).await?;
+ }
// wait until user kills the process
tokio::signal::ctrl_c().await?;
- println!("Stopping...");
- device.stop_advertising().await?;
+ if cli.extended {
+ println!("Stopping extended advertisement...");
+ device.stop_advertising_extended().await?;
+ } else {
+ println!("Stopping legacy advertisement...");
+ device.stop_advertising().await?;
+ }
Ok(())
}
@@ -86,12 +97,17 @@ struct Cli {
/// See, for instance, `examples/device1.json` in the Python project.
#[arg(long)]
device_config: path::PathBuf,
+
/// Bumble transport spec.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
+ /// Whether to perform an extended (BT 5.0) advertisement
+ #[arg(long)]
+ extended: bool,
+
/// Log HCI commands
#[arg(long)]
log_hci: bool,
diff --git a/rust/examples/scanner.rs b/rust/examples/scanner.rs
index 3c328ed..0880e25 100644
--- a/rust/examples/scanner.rs
+++ b/rust/examples/scanner.rs
@@ -20,7 +20,9 @@
use bumble::{
adv::CommonDataType,
wrapper::{
- core::AdvertisementDataUnit, device::Device, hci::packets::AddressType,
+ core::AdvertisementDataUnit,
+ device::Device,
+ hci::{packets::AddressType, Address},
transport::Transport,
},
};
@@ -44,12 +46,8 @@ async fn main() -> PyResult<()> {
let transport = Transport::open(cli.transport).await?;
- let mut device = Device::with_hci(
- "Bumble",
- "F0:F1:F2:F3:F4:F5",
- transport.source()?,
- transport.sink()?,
- )?;
+ let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?;
+ let mut device = Device::with_hci("Bumble", address, transport.source()?, transport.sink()?)?;
// in practice, devices can send multiple advertisements from the same address, so we keep
// track of a timestamp for each set of data
diff --git a/rust/pytests/wrapper.rs b/rust/pytests/wrapper.rs
deleted file mode 100644
index 9fd65e7..0000000
--- a/rust/pytests/wrapper.rs
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright 2023 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// 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.
-
-use bumble::wrapper::{
- controller::Controller,
- device::Device,
- drivers::rtk::DriverInfo,
- hci::{
- packets::{
- AddressType, ErrorCode, ReadLocalVersionInformationBuilder,
- ReadLocalVersionInformationComplete,
- },
- Address, Error,
- },
- host::Host,
- link::Link,
- transport::Transport,
-};
-use nix::sys::stat::Mode;
-use pyo3::{
- exceptions::PyException,
- {PyErr, PyResult},
-};
-
-#[pyo3_asyncio::tokio::test]
-async fn fifo_transport_can_open() -> PyResult<()> {
- let dir = tempfile::tempdir().unwrap();
- let mut fifo = dir.path().to_path_buf();
- fifo.push("bumble-transport-fifo");
- nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap();
-
- let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?;
-
- t.close().await?;
-
- Ok(())
-}
-
-#[pyo3_asyncio::tokio::test]
-async fn realtek_driver_info_all_drivers() -> PyResult<()> {
- assert_eq!(12, DriverInfo::all_drivers()?.len());
- Ok(())
-}
-
-#[pyo3_asyncio::tokio::test]
-async fn hci_command_wrapper_has_correct_methods() -> PyResult<()> {
- let address = Address::new("F0:F1:F2:F3:F4:F5", &AddressType::RandomDeviceAddress)?;
- let link = Link::new_local_link()?;
- let controller = Controller::new("C1", None, None, Some(link), Some(address.clone())).await?;
- let host = Host::new(controller.clone().into(), controller.into()).await?;
- let device = Device::new(None, Some(address), None, Some(host), None)?;
-
- device.power_on().await?;
-
- // Send some simple command. A successful response means [HciCommandWrapper] has the minimum
- // required interface for the Python code to think its an [HCI_Command] object.
- let command = ReadLocalVersionInformationBuilder {};
- let event: ReadLocalVersionInformationComplete = device
- .send_command(&command.into(), true)
- .await?
- .try_into()
- .map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
-
- assert_eq!(ErrorCode::Success, event.get_status());
- Ok(())
-}
diff --git a/rust/pytests/wrapper/drivers.rs b/rust/pytests/wrapper/drivers.rs
new file mode 100644
index 0000000..d2517eb
--- /dev/null
+++ b/rust/pytests/wrapper/drivers.rs
@@ -0,0 +1,22 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 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.
+
+use bumble::wrapper::drivers::rtk::DriverInfo;
+use pyo3::PyResult;
+
+#[pyo3_asyncio::tokio::test]
+async fn realtek_driver_info_all_drivers() -> PyResult<()> {
+ assert_eq!(12, DriverInfo::all_drivers()?.len());
+ Ok(())
+}
diff --git a/rust/pytests/wrapper/hci.rs b/rust/pytests/wrapper/hci.rs
new file mode 100644
index 0000000..c4ce20d
--- /dev/null
+++ b/rust/pytests/wrapper/hci.rs
@@ -0,0 +1,86 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 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.
+
+use bumble::wrapper::{
+ controller::Controller,
+ device::Device,
+ hci::{
+ packets::{
+ AddressType, Enable, ErrorCode, LeScanType, LeScanningFilterPolicy,
+ LeSetScanEnableBuilder, LeSetScanEnableComplete, LeSetScanParametersBuilder,
+ LeSetScanParametersComplete, OwnAddressType,
+ },
+ Address, Error,
+ },
+ host::Host,
+ link::Link,
+};
+use pyo3::{
+ exceptions::PyException,
+ {PyErr, PyResult},
+};
+
+#[pyo3_asyncio::tokio::test]
+async fn test_hci_roundtrip_success_and_failure() -> PyResult<()> {
+ let address = Address::new("F0:F1:F2:F3:F4:F5", AddressType::RandomDeviceAddress)?;
+ let device = create_local_device(address).await?;
+
+ device.power_on().await?;
+
+ // BLE Spec Core v5.3
+ // 7.8.9 LE Set Scan Parameters command
+ // ...
+ // The Host shall not issue this command when scanning is enabled in the
+ // Controller; if it is the Command Disallowed error code shall be used.
+ // ...
+
+ let command = LeSetScanEnableBuilder {
+ filter_duplicates: Enable::Disabled,
+ // will cause failure later
+ le_scan_enable: Enable::Enabled,
+ };
+
+ let event: LeSetScanEnableComplete = device
+ .send_command(command.into(), false)
+ .await?
+ .try_into()
+ .map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
+
+ assert_eq!(ErrorCode::Success, event.get_status());
+
+ let command = LeSetScanParametersBuilder {
+ le_scan_type: LeScanType::Passive,
+ le_scan_interval: 0,
+ le_scan_window: 0,
+ own_address_type: OwnAddressType::RandomDeviceAddress,
+ scanning_filter_policy: LeScanningFilterPolicy::AcceptAll,
+ };
+
+ let event: LeSetScanParametersComplete = device
+ .send_command(command.into(), false)
+ .await?
+ .try_into()
+ .map_err(|e: Error| PyErr::new::<PyException, _>(e.to_string()))?;
+
+ assert_eq!(ErrorCode::CommandDisallowed, event.get_status());
+
+ Ok(())
+}
+
+async fn create_local_device(address: Address) -> PyResult<Device> {
+ let link = Link::new_local_link()?;
+ let controller = Controller::new("C1", None, None, Some(link), Some(address.clone())).await?;
+ let host = Host::new(controller.clone().into(), controller.into()).await?;
+ Device::new(None, Some(address), None, Some(host), None)
+}
diff --git a/rust/pytests/wrapper/mod.rs b/rust/pytests/wrapper/mod.rs
new file mode 100644
index 0000000..3bc9127
--- /dev/null
+++ b/rust/pytests/wrapper/mod.rs
@@ -0,0 +1,17 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 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.
+
+mod drivers;
+mod hci;
+mod transport;
diff --git a/rust/pytests/wrapper/transport.rs b/rust/pytests/wrapper/transport.rs
new file mode 100644
index 0000000..333005b
--- /dev/null
+++ b/rust/pytests/wrapper/transport.rs
@@ -0,0 +1,31 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 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.
+
+use bumble::wrapper::transport::Transport;
+use nix::sys::stat::Mode;
+use pyo3::PyResult;
+
+#[pyo3_asyncio::tokio::test]
+async fn fifo_transport_can_open() -> PyResult<()> {
+ let dir = tempfile::tempdir().unwrap();
+ let mut fifo = dir.path().to_path_buf();
+ fifo.push("bumble-transport-fifo");
+ nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap();
+
+ let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?;
+
+ t.close().await?;
+
+ Ok(())
+}
diff --git a/rust/src/internal/hci/mod.rs b/rust/src/internal/hci/mod.rs
index 232c49f..7830e31 100644
--- a/rust/src/internal/hci/mod.rs
+++ b/rust/src/internal/hci/mod.rs
@@ -94,7 +94,7 @@ impl From<Error> for PacketTypeParseError {
impl WithPacketType<Self> for Command {
fn to_vec_with_packet_type(self) -> Vec<u8> {
- prepend_packet_type(PacketType::Command, self.to_vec())
+ prepend_packet_type(PacketType::Command, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -104,7 +104,7 @@ impl WithPacketType<Self> for Command {
impl WithPacketType<Self> for Acl {
fn to_vec_with_packet_type(self) -> Vec<u8> {
- prepend_packet_type(PacketType::Acl, self.to_vec())
+ prepend_packet_type(PacketType::Acl, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -114,7 +114,7 @@ impl WithPacketType<Self> for Acl {
impl WithPacketType<Self> for Sco {
fn to_vec_with_packet_type(self) -> Vec<u8> {
- prepend_packet_type(PacketType::Sco, self.to_vec())
+ prepend_packet_type(PacketType::Sco, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -124,7 +124,7 @@ impl WithPacketType<Self> for Sco {
impl WithPacketType<Self> for Event {
fn to_vec_with_packet_type(self) -> Vec<u8> {
- prepend_packet_type(PacketType::Event, self.to_vec())
+ prepend_packet_type(PacketType::Event, self)
}
fn parse_with_packet_type(bytes: &[u8]) -> Result<Self, PacketTypeParseError> {
@@ -132,7 +132,9 @@ impl WithPacketType<Self> for Event {
}
}
-fn prepend_packet_type(packet_type: PacketType, mut packet_bytes: Vec<u8>) -> Vec<u8> {
+fn prepend_packet_type<T: Packet>(packet_type: PacketType, packet: T) -> Vec<u8> {
+ // TODO: refactor if `pdl` crate adds API for writing into buffer (github.com/google/pdl/issues/74)
+ let mut packet_bytes = packet.to_vec();
packet_bytes.insert(0, packet_type.into());
packet_bytes
}
diff --git a/rust/src/internal/hci/tests.rs b/rust/src/internal/hci/tests.rs
index 7962c88..ff9e72b 100644
--- a/rust/src/internal/hci/tests.rs
+++ b/rust/src/internal/hci/tests.rs
@@ -22,9 +22,8 @@ use bytes::Bytes;
#[test]
fn prepends_packet_type() {
let packet_type = PacketType::Event;
- let packet_bytes = vec![0x00, 0x00, 0x00, 0x00];
- let actual = prepend_packet_type(packet_type, packet_bytes);
- assert_eq!(vec![0x04, 0x00, 0x00, 0x00, 0x00], actual);
+ let actual = prepend_packet_type(packet_type, FakePacket { bytes: vec![0xFF] });
+ assert_eq!(vec![0x04, 0xFF], actual);
}
#[test]
@@ -75,11 +74,15 @@ fn test_packet_roundtrip_with_type() {
}
#[derive(Debug, PartialEq)]
-struct FakePacket;
+struct FakePacket {
+ bytes: Vec<u8>,
+}
impl FakePacket {
- fn parse(_bytes: &[u8]) -> Result<Self, Error> {
- Ok(Self)
+ fn parse(bytes: &[u8]) -> Result<Self, Error> {
+ Ok(Self {
+ bytes: bytes.to_vec(),
+ })
}
}
@@ -89,6 +92,6 @@ impl Packet for FakePacket {
}
fn to_vec(self) -> Vec<u8> {
- Vec::new()
+ self.bytes
}
}
diff --git a/rust/src/wrapper/device.rs b/rust/src/wrapper/device/mod.rs
index 6bf958a..82a274a 100644
--- a/rust/src/wrapper/device.rs
+++ b/rust/src/wrapper/device/mod.rs
@@ -14,7 +14,17 @@
//! Devices and connections to them
-use crate::internal::hci::WithPacketType;
+#[cfg(feature = "unstable_extended_adv")]
+use crate::wrapper::{
+ hci::packets::{
+ self, AdvertisingEventProperties, AdvertisingFilterPolicy, Enable, EnabledSet,
+ FragmentPreference, LeSetAdvertisingSetRandomAddressBuilder,
+ LeSetExtendedAdvertisingDataBuilder, LeSetExtendedAdvertisingEnableBuilder,
+ LeSetExtendedAdvertisingParametersBuilder, Operation, OwnAddressType, PeerAddressType,
+ PrimaryPhyType, SecondaryPhyType,
+ },
+ ConversionError,
+};
use crate::{
adv::AdvertisementDataBuilder,
wrapper::{
@@ -22,7 +32,7 @@ use crate::{
gatt_client::{ProfileServiceProxy, ServiceProxy},
hci::{
packets::{Command, ErrorCode, Event},
- Address, HciCommandWrapper,
+ Address, HciCommand, WithPacketType,
},
host::Host,
l2cap::LeConnectionOrientedChannel,
@@ -39,6 +49,9 @@ use pyo3::{
use pyo3_asyncio::tokio::into_future;
use std::path;
+#[cfg(test)]
+mod tests;
+
/// Represents the various properties of some device
pub struct DeviceConfiguration(PyObject);
@@ -69,11 +82,24 @@ impl ToPyObject for DeviceConfiguration {
}
}
+/// Used for tracking what advertising state a device might be in
+#[derive(PartialEq)]
+enum AdvertisingStatus {
+ AdvertisingLegacy,
+ AdvertisingExtended,
+ NotAdvertising,
+}
+
/// A device that can send/receive HCI frames.
-#[derive(Clone)]
-pub struct Device(PyObject);
+pub struct Device {
+ obj: PyObject,
+ advertising_status: AdvertisingStatus,
+}
impl Device {
+ #[cfg(feature = "unstable_extended_adv")]
+ const ADVERTISING_HANDLE_EXTENDED: u8 = 0x00;
+
/// Creates a Device. When optional arguments are not specified, the Python object specifies the
/// defaults.
pub fn new(
@@ -94,7 +120,10 @@ impl Device {
PyModule::import(py, intern!(py, "bumble.device"))?
.getattr(intern!(py, "Device"))?
.call((), Some(kwargs))
- .map(|any| Self(any.into()))
+ .map(|any| Self {
+ obj: any.into(),
+ advertising_status: AdvertisingStatus::NotAdvertising,
+ })
})
}
@@ -111,28 +140,38 @@ impl Device {
intern!(py, "from_config_file_with_hci"),
(device_config, source.0, sink.0),
)
- .map(|any| Self(any.into()))
+ .map(|any| Self {
+ obj: any.into(),
+ advertising_status: AdvertisingStatus::NotAdvertising,
+ })
})
}
/// Create a Device configured to communicate with a controller through an HCI source/sink
- pub fn with_hci(name: &str, address: &str, source: Source, sink: Sink) -> PyResult<Self> {
+ pub fn with_hci(name: &str, address: Address, source: Source, sink: Sink) -> PyResult<Self> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.device"))?
.getattr(intern!(py, "Device"))?
- .call_method1(intern!(py, "with_hci"), (name, address, source.0, sink.0))
- .map(|any| Self(any.into()))
+ .call_method1(intern!(py, "with_hci"), (name, address.0, source.0, sink.0))
+ .map(|any| Self {
+ obj: any.into(),
+ advertising_status: AdvertisingStatus::NotAdvertising,
+ })
})
}
/// Sends an HCI command on this Device, returning the command's event result.
- pub async fn send_command(&self, command: &Command, check_result: bool) -> PyResult<Event> {
+ ///
+ /// When `check_result` is `true`, then an `Err` will be returned if the controller's response
+ /// did not have an event code of "success".
+ pub async fn send_command(&self, command: Command, check_result: bool) -> PyResult<Event> {
+ let bumble_hci_command = HciCommand::try_from(command)?;
Python::with_gil(|py| {
- self.0
+ self.obj
.call_method1(
py,
intern!(py, "send_command"),
- (HciCommandWrapper(command.clone()), check_result),
+ (bumble_hci_command, check_result),
)
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
@@ -151,7 +190,7 @@ impl Device {
/// Turn the device on
pub async fn power_on(&self) -> PyResult<()> {
Python::with_gil(|py| {
- self.0
+ self.obj
.call_method0(py, intern!(py, "power_on"))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
@@ -162,7 +201,7 @@ impl Device {
/// Connect to a peer
pub async fn connect(&self, peer_addr: &str) -> PyResult<Connection> {
Python::with_gil(|py| {
- self.0
+ self.obj
.call_method1(py, intern!(py, "connect"), (peer_addr,))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
@@ -180,7 +219,7 @@ impl Device {
});
Python::with_gil(|py| {
- self.0
+ self.obj
.call_method1(py, intern!(py, "add_listener"), ("connection", boxed))
})
.map(|_| ())
@@ -191,7 +230,7 @@ impl Device {
Python::with_gil(|py| {
let kwargs = PyDict::new(py);
kwargs.set_item("filter_duplicates", filter_duplicates)?;
- self.0
+ self.obj
.call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
@@ -209,7 +248,7 @@ impl Device {
});
Python::with_gil(|py| {
- self.0
+ self.obj
.call_method1(py, intern!(py, "add_listener"), ("advertisement", boxed))
})
.map(|_| ())
@@ -218,7 +257,7 @@ impl Device {
/// Set the advertisement data to be used when [Device::start_advertising] is called.
pub fn set_advertising_data(&mut self, adv_data: AdvertisementDataBuilder) -> PyResult<()> {
Python::with_gil(|py| {
- self.0.setattr(
+ self.obj.setattr(
py,
intern!(py, "advertising_data"),
adv_data.into_bytes().as_slice(),
@@ -230,35 +269,162 @@ impl Device {
/// Returns the host used by the device, if any
pub fn host(&mut self) -> PyResult<Option<Host>> {
Python::with_gil(|py| {
- self.0
+ self.obj
.getattr(py, intern!(py, "host"))
.map(|obj| obj.into_option(Host::from))
})
}
/// Start advertising the data set with [Device.set_advertisement].
+ ///
+ /// When `auto_restart` is set to `true`, then the device will automatically restart advertising
+ /// when a connected device is disconnected.
pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
+ if self.advertising_status == AdvertisingStatus::AdvertisingExtended {
+ return Err(PyErr::new::<PyException, _>("Already advertising in extended mode. Stop the existing extended advertisement to start a legacy advertisement."));
+ }
+ // Bumble allows (and currently ignores) calling `start_advertising` when already
+ // advertising. Because that behavior may change in the future, we continue to delegate the
+ // handling to bumble.
+
Python::with_gil(|py| {
let kwargs = PyDict::new(py);
kwargs.set_item("auto_restart", auto_restart)?;
- self.0
+ self.obj
.call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
- .map(|_| ())
+ .map(|_| ())?;
+
+ self.advertising_status = AdvertisingStatus::AdvertisingLegacy;
+ Ok(())
+ }
+
+ /// Start advertising the data set in extended mode, replacing any existing extended adv. The
+ /// advertisement will be non-connectable.
+ ///
+ /// Fails if the device is already advertising in legacy mode.
+ #[cfg(feature = "unstable_extended_adv")]
+ pub async fn start_advertising_extended(
+ &mut self,
+ adv_data: AdvertisementDataBuilder,
+ ) -> PyResult<()> {
+ // TODO: add tests when local controller object supports extended advertisement commands (github.com/google/bumble/pull/238)
+ match self.advertising_status {
+ AdvertisingStatus::AdvertisingLegacy => return Err(PyErr::new::<PyException, _>("Already advertising in legacy mode. Stop the existing legacy advertisement to start an extended advertisement.")),
+ // Stop the current extended advertisement before advertising with new data.
+ // We could just issue an LeSetExtendedAdvertisingData command, but this approach
+ // allows better future flexibility if `start_advertising_extended` were to change.
+ AdvertisingStatus::AdvertisingExtended => self.stop_advertising_extended().await?,
+ _ => {}
+ }
+
+ // set extended params
+ let properties = AdvertisingEventProperties {
+ connectable: 0,
+ scannable: 0,
+ directed: 0,
+ high_duty_cycle: 0,
+ legacy: 0,
+ anonymous: 0,
+ tx_power: 0,
+ };
+ let extended_advertising_params_cmd = LeSetExtendedAdvertisingParametersBuilder {
+ advertising_event_properties: properties,
+ advertising_filter_policy: AdvertisingFilterPolicy::AllDevices,
+ advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
+ advertising_sid: 0,
+ advertising_tx_power: 0,
+ own_address_type: OwnAddressType::RandomDeviceAddress,
+ peer_address: default_ignored_peer_address(),
+ peer_address_type: PeerAddressType::PublicDeviceOrIdentityAddress,
+ primary_advertising_channel_map: 7,
+ primary_advertising_interval_max: 200,
+ primary_advertising_interval_min: 100,
+ primary_advertising_phy: PrimaryPhyType::Le1m,
+ scan_request_notification_enable: Enable::Disabled,
+ secondary_advertising_max_skip: 0,
+ secondary_advertising_phy: SecondaryPhyType::Le1m,
+ };
+ self.send_command(extended_advertising_params_cmd.into(), true)
+ .await?;
+
+ // set random address
+ let random_address: packets::Address =
+ self.random_address()?.try_into().map_err(|e| match e {
+ ConversionError::Python(pyerr) => pyerr,
+ ConversionError::Native(e) => PyErr::new::<PyException, _>(format!("{e:?}")),
+ })?;
+ let random_address_cmd = LeSetAdvertisingSetRandomAddressBuilder {
+ advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
+ random_address,
+ };
+ self.send_command(random_address_cmd.into(), true).await?;
+
+ // set adv data
+ let advertising_data_cmd = LeSetExtendedAdvertisingDataBuilder {
+ advertising_data: adv_data.into_bytes(),
+ advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
+ fragment_preference: FragmentPreference::ControllerMayFragment,
+ operation: Operation::CompleteAdvertisement,
+ };
+ self.send_command(advertising_data_cmd.into(), true).await?;
+
+ // enable adv
+ let extended_advertising_enable_cmd = LeSetExtendedAdvertisingEnableBuilder {
+ enable: Enable::Enabled,
+ enabled_sets: vec![EnabledSet {
+ advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
+ duration: 0,
+ max_extended_advertising_events: 0,
+ }],
+ };
+ self.send_command(extended_advertising_enable_cmd.into(), true)
+ .await?;
+
+ self.advertising_status = AdvertisingStatus::AdvertisingExtended;
+ Ok(())
}
/// Stop advertising.
pub async fn stop_advertising(&mut self) -> PyResult<()> {
Python::with_gil(|py| {
- self.0
+ self.obj
.call_method0(py, intern!(py, "stop_advertising"))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
- .map(|_| ())
+ .map(|_| ())?;
+
+ if self.advertising_status == AdvertisingStatus::AdvertisingLegacy {
+ self.advertising_status = AdvertisingStatus::NotAdvertising;
+ }
+ Ok(())
+ }
+
+ /// Stop advertising extended.
+ #[cfg(feature = "unstable_extended_adv")]
+ pub async fn stop_advertising_extended(&mut self) -> PyResult<()> {
+ if AdvertisingStatus::AdvertisingExtended != self.advertising_status {
+ return Ok(());
+ }
+
+ // disable adv
+ let extended_advertising_enable_cmd = LeSetExtendedAdvertisingEnableBuilder {
+ enable: Enable::Disabled,
+ enabled_sets: vec![EnabledSet {
+ advertising_handle: Self::ADVERTISING_HANDLE_EXTENDED,
+ duration: 0,
+ max_extended_advertising_events: 0,
+ }],
+ };
+ self.send_command(extended_advertising_enable_cmd.into(), true)
+ .await?;
+
+ self.advertising_status = AdvertisingStatus::NotAdvertising;
+ Ok(())
}
/// Registers an L2CAP connection oriented channel server. When a client connects to the server,
@@ -286,7 +452,7 @@ impl Device {
kwargs.set_opt_item("max_credits", max_credits)?;
kwargs.set_opt_item("mtu", mtu)?;
kwargs.set_opt_item("mps", mps)?;
- self.0.call_method(
+ self.obj.call_method(
py,
intern!(py, "register_l2cap_channel_server"),
(),
@@ -295,6 +461,15 @@ impl Device {
})?;
Ok(())
}
+
+ /// Gets the Device's `random_address` property
+ pub fn random_address(&self) -> PyResult<Address> {
+ Python::with_gil(|py| {
+ self.obj
+ .getattr(py, intern!(py, "random_address"))
+ .map(Address)
+ })
+ }
}
/// A connection to a remote device.
@@ -451,3 +626,13 @@ impl Advertisement {
Python::with_gil(|py| self.0.getattr(py, intern!(py, "data")).map(AdvertisingData))
}
}
+
+/// Use this address when sending an HCI command that requires providing a peer address, but the
+/// command is such that the peer address will be ignored.
+///
+/// Internal to bumble, this address might mean "any", but a packets::Address typically gets sent
+/// directly to a controller, so we don't have to worry about it.
+#[cfg(feature = "unstable_extended_adv")]
+fn default_ignored_peer_address() -> packets::Address {
+ packets::Address::try_from(0x0000_0000_0000_u64).unwrap()
+}
diff --git a/rust/src/wrapper/device/tests.rs b/rust/src/wrapper/device/tests.rs
new file mode 100644
index 0000000..648b919
--- /dev/null
+++ b/rust/src/wrapper/device/tests.rs
@@ -0,0 +1,23 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 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.
+
+#[cfg(feature = "unstable_extended_adv")]
+use crate::wrapper::device::default_ignored_peer_address;
+
+#[test]
+#[cfg(feature = "unstable_extended_adv")]
+fn default_peer_address_does_not_panic() {
+ let result = std::panic::catch_unwind(default_ignored_peer_address);
+ assert!(result.is_ok())
+}
diff --git a/rust/src/wrapper/hci.rs b/rust/src/wrapper/hci.rs
index b029a65..533fe21 100644
--- a/rust/src/wrapper/hci.rs
+++ b/rust/src/wrapper/hci.rs
@@ -14,18 +14,19 @@
//! HCI
+// re-export here, and internal usages of these imports should refer to this mod, not the internal
+// mod
+pub(crate) use crate::internal::hci::WithPacketType;
pub use crate::internal::hci::{packets, Error, Packet};
-use crate::{
- internal::hci::WithPacketType,
- wrapper::hci::packets::{AddressType, Command, ErrorCode},
+use crate::wrapper::{
+ hci::packets::{AddressType, Command, ErrorCode},
+ ConversionError,
};
use itertools::Itertools as _;
use pyo3::{
- exceptions::PyException,
- intern, pyclass, pymethods,
- types::{PyBytes, PyModule},
- FromPyObject, IntoPy, PyAny, PyErr, PyObject, PyResult, Python, ToPyObject,
+ exceptions::PyException, intern, types::PyModule, FromPyObject, IntoPy, PyAny, PyErr, PyObject,
+ PyResult, Python, ToPyObject,
};
/// Provides helpers for interacting with HCI
@@ -43,17 +44,45 @@ impl HciConstant {
}
}
+/// Bumble's representation of an HCI command.
+pub(crate) struct HciCommand(pub(crate) PyObject);
+
+impl HciCommand {
+ fn from_bytes(bytes: &[u8]) -> PyResult<Self> {
+ Python::with_gil(|py| {
+ PyModule::import(py, intern!(py, "bumble.hci"))?
+ .getattr(intern!(py, "HCI_Command"))?
+ .call_method1(intern!(py, "from_bytes"), (bytes,))
+ .map(|obj| Self(obj.to_object(py)))
+ })
+ }
+}
+
+impl TryFrom<Command> for HciCommand {
+ type Error = PyErr;
+
+ fn try_from(value: Command) -> Result<Self, Self::Error> {
+ HciCommand::from_bytes(&value.to_vec_with_packet_type())
+ }
+}
+
+impl IntoPy<PyObject> for HciCommand {
+ fn into_py(self, _py: Python<'_>) -> PyObject {
+ self.0
+ }
+}
+
/// A Bluetooth address
#[derive(Clone)]
pub struct Address(pub(crate) PyObject);
impl Address {
- /// Creates a new [Address] object
- pub fn new(address: &str, address_type: &AddressType) -> PyResult<Self> {
+ /// Creates a new [Address] object.
+ pub fn new(address: &str, address_type: AddressType) -> PyResult<Self> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.device"))?
.getattr(intern!(py, "Address"))?
- .call1((address, address_type.to_object(py)))
+ .call1((address, address_type))
.map(|any| Self(any.into()))
})
}
@@ -118,27 +147,31 @@ impl ToPyObject for Address {
}
}
-/// Implements minimum necessary interface to be treated as bumble's [HCI_Command].
-/// While pyo3's macros do not support generics, this could probably be refactored to allow multiple
-/// implementations of the HCI_Command methods in the future, if needed.
-#[pyclass]
-pub(crate) struct HciCommandWrapper(pub(crate) Command);
-
-#[pymethods]
-impl HciCommandWrapper {
- fn __bytes__(&self, py: Python) -> PyResult<PyObject> {
- let bytes = PyBytes::new(py, &self.0.clone().to_vec_with_packet_type());
- Ok(bytes.into_py(py))
- }
+/// An error meaning that the u64 value did not represent a valid BT address.
+#[derive(Debug)]
+pub struct InvalidAddress(u64);
- #[getter]
- fn op_code(&self) -> u16 {
- self.0.get_op_code().into()
+impl TryInto<packets::Address> for Address {
+ type Error = ConversionError<InvalidAddress>;
+
+ fn try_into(self) -> Result<packets::Address, Self::Error> {
+ let addr_le_bytes = self.as_le_bytes().map_err(ConversionError::Python)?;
+
+ // packets::Address only supports converting from a u64 (TODO: update if/when it supports converting from [u8; 6] -- https://github.com/google/pdl/issues/75)
+ // So first we take the python `Address` little-endian bytes (6 bytes), copy them into a
+ // [u8; 8] in little-endian format, and finally convert it into a u64.
+ let mut buf = [0_u8; 8];
+ buf[0..6].copy_from_slice(&addr_le_bytes);
+ let address_u64 = u64::from_le_bytes(buf);
+
+ packets::Address::try_from(address_u64)
+ .map_err(InvalidAddress)
+ .map_err(ConversionError::Native)
}
}
-impl ToPyObject for AddressType {
- fn to_object(&self, py: Python<'_>) -> PyObject {
+impl IntoPy<PyObject> for AddressType {
+ fn into_py(self, py: Python<'_>) -> PyObject {
u8::from(self).to_object(py)
}
}
diff --git a/rust/src/wrapper/mod.rs b/rust/src/wrapper/mod.rs
index 27b86d9..afe437d 100644
--- a/rust/src/wrapper/mod.rs
+++ b/rust/src/wrapper/mod.rs
@@ -132,3 +132,12 @@ pub(crate) fn wrap_python_async<'a>(py: Python<'a>, function: &'a PyAny) -> PyRe
.getattr(intern!(py, "wrap_async"))?
.call1((function,))
}
+
+/// Represents the two major kinds of errors that can occur when converting between Rust and Python.
+pub enum ConversionError<T> {
+ /// Occurs across the Python/native boundary.
+ Python(PyErr),
+ /// Occurs within the native ecosystem, such as when performing more transformations before
+ /// finally converting to the native type.
+ Native(T),
+}
diff --git a/rust/src/wrapper/transport.rs b/rust/src/wrapper/transport.rs
index a7ec9e9..8c62687 100644
--- a/rust/src/wrapper/transport.rs
+++ b/rust/src/wrapper/transport.rs
@@ -15,6 +15,7 @@
//! HCI packet transport
use crate::wrapper::controller::Controller;
+use futures::executor::block_on;
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python};
/// A source/sink pair for HCI packet I/O.
@@ -58,9 +59,9 @@ impl Transport {
impl Drop for Transport {
fn drop(&mut self) {
- // can't await in a Drop impl, but we can at least spawn a task to do it
- let obj = self.0.clone();
- tokio::spawn(async move { Self(obj).close().await });
+ // don't spawn a thread to handle closing, as it may get dropped at program termination,
+ // resulting in `RuntimeWarning: coroutine ... was never awaited` from Python
+ let _ = block_on(self.close());
}
}
diff --git a/setup.cfg b/setup.cfg
index 5cdf35a..e29288b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,4 +1,4 @@
-# Copyright 2021-2022 Google LLC
+# Copyright 2021-2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -24,7 +24,7 @@ url = https://github.com/google/bumble
[options]
python_requires = >=3.8
-packages = bumble, bumble.transport, bumble.drivers, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora, bumble.tools
+packages = bumble, bumble.transport, bumble.transport.grpc_protobuf, bumble.drivers, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora, bumble.tools
package_dir =
bumble = bumble
bumble.apps = apps
@@ -52,12 +52,14 @@ install_requires =
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
pyserial >= 3.5; platform_system!='Emscripten'
pyusb >= 1.2; platform_system!='Emscripten'
- websockets >= 8.1; platform_system!='Emscripten'
+ websockets >= 12.0; platform_system!='Emscripten'
[options.entry_points]
console_scripts =
+ bumble-ble-rpa-tool = bumble.apps.ble_rpa_tool:main
bumble-console = bumble.apps.console:main
bumble-controller-info = bumble.apps.controller_info:main
+ bumble-controller-loopback = bumble.apps.controller_loopback:main
bumble-gatt-dump = bumble.apps.gatt_dump:main
bumble-hci-bridge = bumble.apps.hci_bridge:main
bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
@@ -80,15 +82,15 @@ console_scripts =
build =
build >= 0.7
test =
- pytest >= 6.2
- pytest-asyncio >= 0.17
+ pytest >= 8.0
+ pytest-asyncio == 0.21.1
pytest-html >= 3.2.0
coverage >= 6.4
development =
black == 22.10
grpcio-tools >= 1.57.0
invoke >= 1.7.3
- mypy == 1.5.0
+ mypy == 1.8.0
nox >= 2022
pylint == 2.15.8
pyyaml >= 6.0
@@ -96,8 +98,8 @@ development =
types-invoke >= 1.7.3
types-protobuf >= 4.21.0
avatar =
- pandora-avatar == 0.0.5
- rootcanal == 1.3.0 ; python_version>='3.10'
+ pandora-avatar == 0.0.8
+ rootcanal == 1.9.0 ; python_version>='3.10'
documentation =
mkdocs >= 1.4.0
mkdocs-material >= 8.5.6
diff --git a/tasks.py b/tasks.py
index 6df5a8b..fab7cf1 100644
--- a/tasks.py
+++ b/tasks.py
@@ -125,7 +125,7 @@ def lint(ctx, disable='C,R', errors_only=False):
print(f">>> Running the linter{qualifier}...")
try:
ctx.run(f"pylint {' '.join(options)} bumble apps examples tasks.py")
- print("The linter is happy. ✅ 😊 🐝'")
+ print("The linter is happy. ✅ 😊 🐝")
except UnexpectedExit as exc:
print("Please check your code against the linter messages. ❌")
raise Exit(code=1) from exc
diff --git a/tests/avrcp_test.py b/tests/avrcp_test.py
new file mode 100644
index 0000000..103f360
--- /dev/null
+++ b/tests/avrcp_test.py
@@ -0,0 +1,246 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import struct
+
+import pytest
+
+from bumble import core
+from bumble import device
+from bumble import host
+from bumble import controller
+from bumble import link
+from bumble import avc
+from bumble import avrcp
+from bumble import avctp
+from bumble.transport import common
+
+
+# -----------------------------------------------------------------------------
+class TwoDevices:
+ def __init__(self):
+ self.connections = [None, None]
+
+ addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
+ self.link = link.LocalLink()
+ self.controllers = [
+ controller.Controller('C1', link=self.link, public_address=addresses[0]),
+ controller.Controller('C2', link=self.link, public_address=addresses[1]),
+ ]
+ self.devices = [
+ device.Device(
+ address=addresses[0],
+ host=host.Host(
+ self.controllers[0], common.AsyncPipeSink(self.controllers[0])
+ ),
+ ),
+ device.Device(
+ address=addresses[1],
+ host=host.Host(
+ self.controllers[1], common.AsyncPipeSink(self.controllers[1])
+ ),
+ ),
+ ]
+ self.devices[0].classic_enabled = True
+ self.devices[1].classic_enabled = True
+ self.connections = [None, None]
+ self.protocols = [None, None]
+
+ def on_connection(self, which, connection):
+ self.connections[which] = connection
+
+ async def setup_connections(self):
+ await self.devices[0].power_on()
+ await self.devices[1].power_on()
+
+ self.connections = await asyncio.gather(
+ self.devices[0].connect(
+ self.devices[1].public_address, core.BT_BR_EDR_TRANSPORT
+ ),
+ self.devices[1].accept(self.devices[0].public_address),
+ )
+
+ self.protocols = [avrcp.Protocol(), avrcp.Protocol()]
+ self.protocols[0].listen(self.devices[1])
+ await self.protocols[1].connect(self.connections[0])
+
+
+# -----------------------------------------------------------------------------
+def test_frame_parser():
+ with pytest.raises(ValueError) as error:
+ avc.Frame.from_bytes(bytes.fromhex("11480000"))
+
+ x = bytes.fromhex("014D0208")
+ frame = avc.Frame.from_bytes(x)
+ assert frame.subunit_type == avc.Frame.SubunitType.PANEL
+ assert frame.subunit_id == 7
+ assert frame.opcode == 8
+
+ x = bytes.fromhex("014DFF0108")
+ frame = avc.Frame.from_bytes(x)
+ assert frame.subunit_type == avc.Frame.SubunitType.PANEL
+ assert frame.subunit_id == 260
+ assert frame.opcode == 8
+
+ x = bytes.fromhex("0148000019581000000103")
+
+ frame = avc.Frame.from_bytes(x)
+
+ assert isinstance(frame, avc.CommandFrame)
+ assert frame.ctype == avc.CommandFrame.CommandType.STATUS
+ assert frame.subunit_type == avc.Frame.SubunitType.PANEL
+ assert frame.subunit_id == 0
+ assert frame.opcode == 0
+
+
+# -----------------------------------------------------------------------------
+def test_vendor_dependent_command():
+ x = bytes.fromhex("0148000019581000000103")
+ frame = avc.Frame.from_bytes(x)
+ assert isinstance(frame, avc.VendorDependentCommandFrame)
+ assert frame.company_id == 0x1958
+ assert frame.vendor_dependent_data == bytes.fromhex("1000000103")
+
+ frame = avc.VendorDependentCommandFrame(
+ avc.CommandFrame.CommandType.STATUS,
+ avc.Frame.SubunitType.PANEL,
+ 0,
+ 0x1958,
+ bytes.fromhex("1000000103"),
+ )
+ assert bytes(frame) == x
+
+
+# -----------------------------------------------------------------------------
+def test_avctp_message_assembler():
+ received_message = []
+
+ def on_message(transaction_label, is_response, ipid, pid, payload):
+ received_message.append((transaction_label, is_response, ipid, pid, payload))
+
+ assembler = avctp.MessageAssembler(on_message)
+
+ payload = bytes.fromhex("01")
+ assembler.on_pdu(bytes([1 << 4 | 0b00 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
+ assert received_message
+ assert received_message[0] == (1, False, False, 0x1122, payload)
+
+ received_message = []
+ payload = bytes.fromhex("010203")
+ assembler.on_pdu(bytes([1 << 4 | 0b01 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
+ assert len(received_message) == 0
+ assembler.on_pdu(bytes([1 << 4 | 0b00 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
+ assert received_message
+ assert received_message[0] == (1, False, False, 0x1122, payload)
+
+ received_message = []
+ payload = bytes.fromhex("010203")
+ assembler.on_pdu(
+ bytes([1 << 4 | 0b01 << 2 | 1 << 1 | 0, 3, 0x11, 0x22]) + payload[0:1]
+ )
+ assembler.on_pdu(
+ bytes([1 << 4 | 0b10 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload[1:2]
+ )
+ assembler.on_pdu(
+ bytes([1 << 4 | 0b11 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload[2:3]
+ )
+ assert received_message
+ assert received_message[0] == (1, False, False, 0x1122, payload)
+
+ # received_message = []
+ # parameter = bytes.fromhex("010203")
+ # assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, len(parameter)) + parameter)
+ # assert len(received_message) == 0
+
+
+# -----------------------------------------------------------------------------
+def test_avrcp_pdu_assembler():
+ received_pdus = []
+
+ def on_pdu(pdu_id, parameter):
+ received_pdus.append((pdu_id, parameter))
+
+ assembler = avrcp.PduAssembler(on_pdu)
+
+ parameter = bytes.fromhex("01")
+ assembler.on_pdu(struct.pack(">BBH", 0x10, 0b00, len(parameter)) + parameter)
+ assert received_pdus
+ assert received_pdus[0] == (0x10, parameter)
+
+ received_pdus = []
+ parameter = bytes.fromhex("010203")
+ assembler.on_pdu(struct.pack(">BBH", 0x10, 0b01, len(parameter)) + parameter)
+ assert len(received_pdus) == 0
+ assembler.on_pdu(struct.pack(">BBH", 0x10, 0b00, len(parameter)) + parameter)
+ assert received_pdus
+ assert received_pdus[0] == (0x10, parameter)
+
+ received_pdus = []
+ parameter = bytes.fromhex("010203")
+ assembler.on_pdu(struct.pack(">BBH", 0x10, 0b01, 1) + parameter[0:1])
+ assembler.on_pdu(struct.pack(">BBH", 0x10, 0b10, 1) + parameter[1:2])
+ assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, 1) + parameter[2:3])
+ assert received_pdus
+ assert received_pdus[0] == (0x10, parameter)
+
+ received_pdus = []
+ parameter = bytes.fromhex("010203")
+ assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, len(parameter)) + parameter)
+ assert len(received_pdus) == 0
+
+
+def test_passthrough_commands():
+ play_pressed = avc.PassThroughCommandFrame(
+ avc.CommandFrame.CommandType.CONTROL,
+ avc.CommandFrame.SubunitType.PANEL,
+ 0,
+ avc.PassThroughCommandFrame.StateFlag.PRESSED,
+ avc.PassThroughCommandFrame.OperationId.PLAY,
+ b'',
+ )
+
+ play_pressed_bytes = bytes(play_pressed)
+ parsed = avc.Frame.from_bytes(play_pressed_bytes)
+ assert isinstance(parsed, avc.PassThroughCommandFrame)
+ assert parsed.operation_id == avc.PassThroughCommandFrame.OperationId.PLAY
+ assert bytes(parsed) == play_pressed_bytes
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_get_supported_events():
+ two_devices = TwoDevices()
+ await two_devices.setup_connections()
+
+ supported_events = await two_devices.protocols[0].get_supported_events()
+ assert supported_events == []
+
+ delegate1 = avrcp.Delegate([avrcp.EventId.VOLUME_CHANGED])
+ two_devices.protocols[0].delegate = delegate1
+ supported_events = await two_devices.protocols[1].get_supported_events()
+ assert supported_events == [avrcp.EventId.VOLUME_CHANGED]
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ test_frame_parser()
+ test_vendor_dependent_command()
+ test_avctp_message_assembler()
+ test_avrcp_pdu_assembler()
+ test_passthrough_commands()
+ test_get_supported_events()
diff --git a/tests/bap_test.py b/tests/bap_test.py
new file mode 100644
index 0000000..bc223c1
--- /dev/null
+++ b/tests/bap_test.py
@@ -0,0 +1,403 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import os
+import functools
+import pytest
+import logging
+
+from bumble import device
+from bumble.hci import CodecID, CodingFormat
+from bumble.profiles.bap import (
+ AudioLocation,
+ AseStateMachine,
+ ASE_Operation,
+ ASE_Config_Codec,
+ ASE_Config_QOS,
+ ASE_Disable,
+ ASE_Enable,
+ ASE_Receiver_Start_Ready,
+ ASE_Receiver_Stop_Ready,
+ ASE_Release,
+ ASE_Update_Metadata,
+ SupportedFrameDuration,
+ SupportedSamplingFrequency,
+ SamplingFrequency,
+ FrameDuration,
+ CodecSpecificCapabilities,
+ CodecSpecificConfiguration,
+ ContextType,
+ PacRecord,
+ AudioStreamControlService,
+ AudioStreamControlServiceProxy,
+ PublishedAudioCapabilitiesService,
+ PublishedAudioCapabilitiesServiceProxy,
+)
+from tests.test_utils import TwoDevices
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+def basic_check(operation: ASE_Operation):
+ serialized = bytes(operation)
+ parsed = ASE_Operation.from_bytes(serialized)
+ assert bytes(parsed) == serialized
+
+
+# -----------------------------------------------------------------------------
+def test_codec_specific_capabilities() -> None:
+ SAMPLE_FREQUENCY = SupportedSamplingFrequency.FREQ_16000
+ FRAME_SURATION = SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+ AUDIO_CHANNEL_COUNTS = [1]
+ cap = CodecSpecificCapabilities(
+ supported_sampling_frequencies=SAMPLE_FREQUENCY,
+ supported_frame_durations=FRAME_SURATION,
+ supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS,
+ min_octets_per_codec_frame=40,
+ max_octets_per_codec_frame=40,
+ supported_max_codec_frames_per_sdu=1,
+ )
+ assert CodecSpecificCapabilities.from_bytes(bytes(cap)) == cap
+
+
+# -----------------------------------------------------------------------------
+def test_pac_record() -> None:
+ SAMPLE_FREQUENCY = SupportedSamplingFrequency.FREQ_16000
+ FRAME_SURATION = SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+ AUDIO_CHANNEL_COUNTS = [1]
+ cap = CodecSpecificCapabilities(
+ supported_sampling_frequencies=SAMPLE_FREQUENCY,
+ supported_frame_durations=FRAME_SURATION,
+ supported_audio_channel_counts=AUDIO_CHANNEL_COUNTS,
+ min_octets_per_codec_frame=40,
+ max_octets_per_codec_frame=40,
+ supported_max_codec_frames_per_sdu=1,
+ )
+
+ pac_record = PacRecord(
+ coding_format=CodingFormat(CodecID.LC3),
+ codec_specific_capabilities=cap,
+ metadata=b'',
+ )
+ assert PacRecord.from_bytes(bytes(pac_record)) == pac_record
+
+
+# -----------------------------------------------------------------------------
+def test_vendor_specific_pac_record() -> None:
+ # Vendor-Specific codec, Google, ID=0xFFFF. No capabilities and metadata.
+ RAW_DATA = bytes.fromhex('ffe000ffff0000')
+ assert bytes(PacRecord.from_bytes(RAW_DATA)) == RAW_DATA
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Config_Codec() -> None:
+ operation = ASE_Config_Codec(
+ ase_id=[1, 2],
+ target_latency=[3, 4],
+ target_phy=[5, 6],
+ codec_id=[CodingFormat(CodecID.LC3), CodingFormat(CodecID.LC3)],
+ codec_specific_configuration=[b'foo', b'bar'],
+ )
+ basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Config_QOS() -> None:
+ operation = ASE_Config_QOS(
+ ase_id=[1, 2],
+ cig_id=[1, 2],
+ cis_id=[3, 4],
+ sdu_interval=[5, 6],
+ framing=[0, 1],
+ phy=[2, 3],
+ max_sdu=[4, 5],
+ retransmission_number=[6, 7],
+ max_transport_latency=[8, 9],
+ presentation_delay=[10, 11],
+ )
+ basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Enable() -> None:
+ operation = ASE_Enable(
+ ase_id=[1, 2],
+ metadata=[b'foo', b'bar'],
+ )
+ basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Update_Metadata() -> None:
+ operation = ASE_Update_Metadata(
+ ase_id=[1, 2],
+ metadata=[b'foo', b'bar'],
+ )
+ basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Disable() -> None:
+ operation = ASE_Disable(ase_id=[1, 2])
+ basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Release() -> None:
+ operation = ASE_Release(ase_id=[1, 2])
+ basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Receiver_Start_Ready() -> None:
+ operation = ASE_Receiver_Start_Ready(ase_id=[1, 2])
+ basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_ASE_Receiver_Stop_Ready() -> None:
+ operation = ASE_Receiver_Stop_Ready(ase_id=[1, 2])
+ basic_check(operation)
+
+
+# -----------------------------------------------------------------------------
+def test_codec_specific_configuration() -> None:
+ SAMPLE_FREQUENCY = SamplingFrequency.FREQ_16000
+ FRAME_SURATION = FrameDuration.DURATION_10000_US
+ AUDIO_LOCATION = AudioLocation.FRONT_LEFT
+ config = CodecSpecificConfiguration(
+ sampling_frequency=SAMPLE_FREQUENCY,
+ frame_duration=FRAME_SURATION,
+ audio_channel_allocation=AUDIO_LOCATION,
+ octets_per_codec_frame=60,
+ codec_frames_per_sdu=1,
+ )
+ assert CodecSpecificConfiguration.from_bytes(bytes(config)) == config
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_pacs():
+ devices = TwoDevices()
+ devices[0].add_service(
+ PublishedAudioCapabilitiesService(
+ supported_sink_context=ContextType.MEDIA,
+ available_sink_context=ContextType.MEDIA,
+ supported_source_context=0,
+ available_source_context=0,
+ sink_pac=[
+ # Codec Capability Setting 16_2
+ PacRecord(
+ coding_format=CodingFormat(CodecID.LC3),
+ codec_specific_capabilities=CodecSpecificCapabilities(
+ supported_sampling_frequencies=(
+ SupportedSamplingFrequency.FREQ_16000
+ ),
+ supported_frame_durations=(
+ SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+ ),
+ supported_audio_channel_counts=[1],
+ min_octets_per_codec_frame=40,
+ max_octets_per_codec_frame=40,
+ supported_max_codec_frames_per_sdu=1,
+ ),
+ ),
+ # Codec Capability Setting 24_2
+ PacRecord(
+ coding_format=CodingFormat(CodecID.LC3),
+ codec_specific_capabilities=CodecSpecificCapabilities(
+ supported_sampling_frequencies=(
+ SupportedSamplingFrequency.FREQ_24000
+ ),
+ supported_frame_durations=(
+ SupportedFrameDuration.DURATION_10000_US_SUPPORTED
+ ),
+ supported_audio_channel_counts=[1],
+ min_octets_per_codec_frame=60,
+ max_octets_per_codec_frame=60,
+ supported_max_codec_frames_per_sdu=1,
+ ),
+ ),
+ ],
+ sink_audio_locations=AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT,
+ )
+ )
+
+ await devices.setup_connection()
+ peer = device.Peer(devices.connections[1])
+ pacs_client = await peer.discover_service_and_create_proxy(
+ PublishedAudioCapabilitiesServiceProxy
+ )
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_ascs():
+ devices = TwoDevices()
+ devices[0].add_service(
+ AudioStreamControlService(device=devices[0], sink_ase_id=[1, 2])
+ )
+
+ await devices.setup_connection()
+ peer = device.Peer(devices.connections[1])
+ ascs_client = await peer.discover_service_and_create_proxy(
+ AudioStreamControlServiceProxy
+ )
+
+ notifications = {1: asyncio.Queue(), 2: asyncio.Queue()}
+
+ def on_notification(data: bytes, ase_id: int):
+ notifications[ase_id].put_nowait(data)
+
+ # Should be idle
+ assert await ascs_client.sink_ase[0].read_value() == bytes(
+ [1, AseStateMachine.State.IDLE]
+ )
+ assert await ascs_client.sink_ase[1].read_value() == bytes(
+ [2, AseStateMachine.State.IDLE]
+ )
+
+ # Subscribe
+ await ascs_client.sink_ase[0].subscribe(
+ functools.partial(on_notification, ase_id=1)
+ )
+ await ascs_client.sink_ase[1].subscribe(
+ functools.partial(on_notification, ase_id=2)
+ )
+
+ # Config Codec
+ config = CodecSpecificConfiguration(
+ sampling_frequency=SamplingFrequency.FREQ_48000,
+ frame_duration=FrameDuration.DURATION_10000_US,
+ audio_channel_allocation=AudioLocation.FRONT_LEFT,
+ octets_per_codec_frame=120,
+ codec_frames_per_sdu=1,
+ )
+ await ascs_client.ase_control_point.write_value(
+ ASE_Config_Codec(
+ ase_id=[1, 2],
+ target_latency=[3, 4],
+ target_phy=[5, 6],
+ codec_id=[CodingFormat(CodecID.LC3), CodingFormat(CodecID.LC3)],
+ codec_specific_configuration=[config, config],
+ )
+ )
+ assert (await notifications[1].get())[:2] == bytes(
+ [1, AseStateMachine.State.CODEC_CONFIGURED]
+ )
+ assert (await notifications[2].get())[:2] == bytes(
+ [2, AseStateMachine.State.CODEC_CONFIGURED]
+ )
+
+ # Config QOS
+ await ascs_client.ase_control_point.write_value(
+ ASE_Config_QOS(
+ ase_id=[1, 2],
+ cig_id=[1, 2],
+ cis_id=[3, 4],
+ sdu_interval=[5, 6],
+ framing=[0, 1],
+ phy=[2, 3],
+ max_sdu=[4, 5],
+ retransmission_number=[6, 7],
+ max_transport_latency=[8, 9],
+ presentation_delay=[10, 11],
+ )
+ )
+ assert (await notifications[1].get())[:2] == bytes(
+ [1, AseStateMachine.State.QOS_CONFIGURED]
+ )
+ assert (await notifications[2].get())[:2] == bytes(
+ [2, AseStateMachine.State.QOS_CONFIGURED]
+ )
+
+ # Enable
+ await ascs_client.ase_control_point.write_value(
+ ASE_Enable(
+ ase_id=[1, 2],
+ metadata=[b'foo', b'bar'],
+ )
+ )
+ assert (await notifications[1].get())[:2] == bytes(
+ [1, AseStateMachine.State.ENABLING]
+ )
+ assert (await notifications[2].get())[:2] == bytes(
+ [2, AseStateMachine.State.ENABLING]
+ )
+
+ # CIS establishment
+ devices[0].emit(
+ 'cis_establishment',
+ device.CisLink(
+ device=devices[0],
+ acl_connection=devices.connections[0],
+ handle=5,
+ cis_id=3,
+ cig_id=1,
+ ),
+ )
+ devices[0].emit(
+ 'cis_establishment',
+ device.CisLink(
+ device=devices[0],
+ acl_connection=devices.connections[0],
+ handle=6,
+ cis_id=4,
+ cig_id=2,
+ ),
+ )
+ assert (await notifications[1].get())[:2] == bytes(
+ [1, AseStateMachine.State.STREAMING]
+ )
+ assert (await notifications[2].get())[:2] == bytes(
+ [2, AseStateMachine.State.STREAMING]
+ )
+
+ # Release
+ await ascs_client.ase_control_point.write_value(
+ ASE_Release(
+ ase_id=[1, 2],
+ metadata=[b'foo', b'bar'],
+ )
+ )
+ assert (await notifications[1].get())[:2] == bytes(
+ [1, AseStateMachine.State.RELEASING]
+ )
+ assert (await notifications[2].get())[:2] == bytes(
+ [2, AseStateMachine.State.RELEASING]
+ )
+ assert (await notifications[1].get())[:2] == bytes([1, AseStateMachine.State.IDLE])
+ assert (await notifications[2].get())[:2] == bytes([2, AseStateMachine.State.IDLE])
+
+ await asyncio.sleep(0.001)
+
+
+# -----------------------------------------------------------------------------
+async def run():
+ await test_pacs()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+ asyncio.run(run())
diff --git a/tests/cap_test.py b/tests/cap_test.py
new file mode 100644
index 0000000..ab5ab81
--- /dev/null
+++ b/tests/cap_test.py
@@ -0,0 +1,71 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import os
+import pytest
+import logging
+
+from bumble import device
+from bumble import gatt
+from bumble.profiles import cap
+from bumble.profiles import csip
+from .test_utils import TwoDevices
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_cas():
+ SIRK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa')
+
+ devices = TwoDevices()
+ devices[0].add_service(
+ cap.CommonAudioServiceService(
+ csip.CoordinatedSetIdentificationService(
+ set_identity_resolving_key=SIRK,
+ set_identity_resolving_key_type=csip.SirkType.PLAINTEXT,
+ )
+ )
+ )
+
+ await devices.setup_connection()
+ peer = device.Peer(devices.connections[1])
+ cas_client = await peer.discover_service_and_create_proxy(
+ cap.CommonAudioServiceServiceProxy
+ )
+
+ included_services = await peer.discover_included_services(cas_client.service_proxy)
+ assert any(
+ service.uuid == gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE
+ for service in included_services
+ )
+
+
+# -----------------------------------------------------------------------------
+async def run():
+ await test_cas()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+ asyncio.run(run())
diff --git a/tests/csip_test.py b/tests/csip_test.py
new file mode 100644
index 0000000..b34c426
--- /dev/null
+++ b/tests/csip_test.py
@@ -0,0 +1,120 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import os
+import pytest
+import struct
+import logging
+from unittest import mock
+
+from bumble import device
+from bumble.profiles import csip
+from .test_utils import TwoDevices
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+def test_s1():
+ assert (
+ csip.s1(b'SIRKenc'[::-1])
+ == bytes.fromhex('6901983f 18149e82 3c7d133a 7d774572')[::-1]
+ )
+
+
+# -----------------------------------------------------------------------------
+def test_k1():
+ K = bytes.fromhex('676e1b9b d448696f 061ec622 3ce5ced9')[::-1]
+ SALT = csip.s1(b'SIRKenc'[::-1])
+ P = b'csis'[::-1]
+ assert (
+ csip.k1(K, SALT, P)
+ == bytes.fromhex('5277453c c094d982 b0e8ee53 2f2d1f8b')[::-1]
+ )
+
+
+# -----------------------------------------------------------------------------
+def test_sih():
+ SIRK = bytes.fromhex('457d7d09 21a1fd22 cecd8c86 dd72cccd')[::-1]
+ PRAND = bytes.fromhex('69f563')[::-1]
+ assert csip.sih(SIRK, PRAND) == bytes.fromhex('1948da')[::-1]
+
+
+# -----------------------------------------------------------------------------
+def test_sef():
+ SIRK = bytes.fromhex('457d7d09 21a1fd22 cecd8c86 dd72cccd')[::-1]
+ K = bytes.fromhex('676e1b9b d448696f 061ec622 3ce5ced9')[::-1]
+ assert (
+ csip.sef(K, SIRK) == bytes.fromhex('170a3835 e13524a0 7e2562d5 f25fd346')[::-1]
+ )
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ 'sirk_type,', [(csip.SirkType.ENCRYPTED), (csip.SirkType.PLAINTEXT)]
+)
+async def test_csis(sirk_type):
+ SIRK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa')
+ LTK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa')
+
+ devices = TwoDevices()
+ devices[0].add_service(
+ csip.CoordinatedSetIdentificationService(
+ set_identity_resolving_key=SIRK,
+ set_identity_resolving_key_type=sirk_type,
+ coordinated_set_size=2,
+ set_member_lock=csip.MemberLock.UNLOCKED,
+ set_member_rank=0,
+ )
+ )
+
+ await devices.setup_connection()
+
+ # Mock encryption.
+ devices.connections[0].encryption = 1
+ devices.connections[1].encryption = 1
+ devices[0].get_long_term_key = mock.AsyncMock(return_value=LTK)
+ devices[1].get_long_term_key = mock.AsyncMock(return_value=LTK)
+
+ peer = device.Peer(devices.connections[1])
+ csis_client = await peer.discover_service_and_create_proxy(
+ csip.CoordinatedSetIdentificationProxy
+ )
+
+ assert await csis_client.read_set_identity_resolving_key() == (sirk_type, SIRK)
+ assert await csis_client.coordinated_set_size.read_value() == struct.pack('B', 2)
+ assert await csis_client.set_member_lock.read_value() == struct.pack(
+ 'B', csip.MemberLock.UNLOCKED
+ )
+ assert await csis_client.set_member_rank.read_value() == struct.pack('B', 0)
+
+
+# -----------------------------------------------------------------------------
+async def run():
+ test_sih()
+ await test_csis()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+ asyncio.run(run())
diff --git a/tests/device_test.py b/tests/device_test.py
index 1bcd0d0..5d87282 100644
--- a/tests/device_test.py
+++ b/tests/device_test.py
@@ -20,16 +20,23 @@ import logging
import os
from types import LambdaType
import pytest
+from unittest import mock
-from bumble.core import BT_BR_EDR_TRANSPORT
-from bumble.device import Connection, Device
-from bumble.host import Host
+from bumble.core import (
+ BT_BR_EDR_TRANSPORT,
+ BT_LE_TRANSPORT,
+ BT_PERIPHERAL_ROLE,
+ ConnectionParameters,
+)
+from bumble.device import AdvertisingParameters, Connection, Device
+from bumble.host import AclPacketQueue, Host
from bumble.hci import (
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
HCI_COMMAND_STATUS_PENDING,
HCI_CREATE_CONNECTION_COMMAND,
HCI_SUCCESS,
Address,
+ OwnAddressType,
HCI_Command_Complete_Event,
HCI_Command_Status_Event,
HCI_Connection_Complete_Event,
@@ -43,6 +50,9 @@ from bumble.gatt import (
GATT_APPEARANCE_CHARACTERISTIC,
)
+from .test_utils import TwoDevices, async_barrier
+
+
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -66,6 +76,13 @@ async def test_device_connect_parallel():
d1 = Device(host=Host(None, None))
d2 = Device(host=Host(None, None))
+ def _send(packet):
+ pass
+
+ d0.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
+ d1.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
+ d2.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
+
# enable classic
d0.classic_enabled = True
d1.classic_enabled = True
@@ -233,6 +250,190 @@ async def test_flush():
# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_legacy_advertising():
+ device = Device(host=mock.AsyncMock(Host))
+
+ # Start advertising
+ await device.start_advertising()
+ assert device.is_advertising
+
+ # Stop advertising
+ await device.stop_advertising()
+ assert not device.is_advertising
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+ 'own_address_type,',
+ (OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
+)
+@pytest.mark.asyncio
+async def test_legacy_advertising_connection(own_address_type):
+ device = Device(host=mock.AsyncMock(Host))
+ peer_address = Address('F0:F1:F2:F3:F4:F5')
+
+ # Start advertising
+ await device.start_advertising()
+ device.on_connection(
+ 0x0001,
+ BT_LE_TRANSPORT,
+ peer_address,
+ BT_PERIPHERAL_ROLE,
+ ConnectionParameters(0, 0, 0),
+ )
+
+ if own_address_type == OwnAddressType.PUBLIC:
+ assert device.lookup_connection(0x0001).self_address == device.public_address
+ else:
+ assert device.lookup_connection(0x0001).self_address == device.random_address
+
+ # For unknown reason, read_phy() in on_connection() would be killed at the end of
+ # test, so we force scheduling here to avoid an warning.
+ await asyncio.sleep(0.0001)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+ 'auto_restart,',
+ (True, False),
+)
+@pytest.mark.asyncio
+async def test_legacy_advertising_disconnection(auto_restart):
+ device = Device(host=mock.AsyncMock(spec=Host))
+ peer_address = Address('F0:F1:F2:F3:F4:F5')
+ await device.start_advertising(auto_restart=auto_restart)
+ device.on_connection(
+ 0x0001,
+ BT_LE_TRANSPORT,
+ peer_address,
+ BT_PERIPHERAL_ROLE,
+ ConnectionParameters(0, 0, 0),
+ )
+
+ device.on_advertising_set_termination(
+ HCI_SUCCESS, device.legacy_advertising_set.advertising_handle, 0x0001, 0
+ )
+
+ device.on_disconnection(0x0001, 0)
+ await async_barrier()
+ await async_barrier()
+
+ if auto_restart:
+ assert device.is_advertising
+ else:
+ assert not device.is_advertising
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_extended_advertising():
+ device = Device(host=mock.AsyncMock(Host))
+
+ # Start advertising
+ advertising_set = await device.create_advertising_set()
+ assert device.extended_advertising_sets
+ assert advertising_set.enabled
+
+ # Stop advertising
+ await advertising_set.stop()
+ assert not advertising_set.enabled
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+ 'own_address_type,',
+ (OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
+)
+@pytest.mark.asyncio
+async def test_extended_advertising_connection(own_address_type):
+ device = Device(host=mock.AsyncMock(spec=Host))
+ peer_address = Address('F0:F1:F2:F3:F4:F5')
+ advertising_set = await device.create_advertising_set(
+ advertising_parameters=AdvertisingParameters(own_address_type=own_address_type)
+ )
+ device.on_connection(
+ 0x0001,
+ BT_LE_TRANSPORT,
+ peer_address,
+ BT_PERIPHERAL_ROLE,
+ ConnectionParameters(0, 0, 0),
+ )
+ device.on_advertising_set_termination(
+ HCI_SUCCESS,
+ advertising_set.advertising_handle,
+ 0x0001,
+ 0,
+ )
+
+ if own_address_type == OwnAddressType.PUBLIC:
+ assert device.lookup_connection(0x0001).self_address == device.public_address
+ else:
+ assert device.lookup_connection(0x0001).self_address == device.random_address
+
+ # For unknown reason, read_phy() in on_connection() would be killed at the end of
+ # test, so we force scheduling here to avoid an warning.
+ await asyncio.sleep(0.0001)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_get_remote_le_features():
+ devices = TwoDevices()
+ await devices.setup_connection()
+
+ assert (await devices.connections[0].get_remote_le_features()) is not None
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_cis():
+ devices = TwoDevices()
+ await devices.setup_connection()
+
+ peripheral_cis_futures = {}
+
+ def on_cis_request(
+ acl_connection: Connection,
+ cis_handle: int,
+ _cig_id: int,
+ _cis_id: int,
+ ):
+ acl_connection.abort_on(
+ 'disconnection', devices[1].accept_cis_request(cis_handle)
+ )
+ peripheral_cis_futures[cis_handle] = asyncio.get_running_loop().create_future()
+
+ devices[1].on('cis_request', on_cis_request)
+ devices[1].on(
+ 'cis_establishment',
+ lambda cis_link: peripheral_cis_futures[cis_link.handle].set_result(None),
+ )
+
+ cis_handles = await devices[0].setup_cig(
+ cig_id=1,
+ cis_id=[2, 3],
+ sdu_interval=(0, 0),
+ framing=0,
+ max_sdu=(0, 0),
+ retransmission_number=0,
+ max_transport_latency=(0, 0),
+ )
+ assert len(cis_handles) == 2
+ cis_links = await devices[0].create_cis(
+ [
+ (cis_handles[0], devices.connections[0].handle),
+ (cis_handles[1], devices.connections[0].handle),
+ ]
+ )
+ await asyncio.gather(*peripheral_cis_futures.values())
+ assert len(cis_links) == 2
+
+ await cis_links[0].disconnect()
+ await cis_links[1].disconnect()
+
+
+# -----------------------------------------------------------------------------
def test_gatt_services_with_gas():
device = Device(host=Host(None, None))
diff --git a/tests/gatt_test.py b/tests/gatt_test.py
index d9f6d60..e3c9209 100644
--- a/tests/gatt_test.py
+++ b/tests/gatt_test.py
@@ -20,10 +20,10 @@ import logging
import os
import struct
import pytest
+from unittest.mock import AsyncMock, Mock, ANY
from bumble.controller import Controller
from bumble.gatt_client import CharacteristicProxy
-from bumble.gatt_server import Server
from bumble.link import LocalLink
from bumble.device import Device, Peer
from bumble.host import Host
@@ -50,6 +50,7 @@ from bumble.att import (
ATT_Error_Response,
ATT_Read_By_Group_Type_Request,
)
+from .test_utils import async_barrier
# -----------------------------------------------------------------------------
@@ -119,9 +120,9 @@ async def test_characteristic_encoding():
Characteristic.READABLE,
123,
)
- x = c.read_value(None)
+ x = await c.read_value(None)
assert x == bytes([123])
- c.write_value(None, bytes([122]))
+ await c.write_value(None, bytes([122]))
assert c.value == 122
class FooProxy(CharacteristicProxy):
@@ -151,7 +152,22 @@ async def test_characteristic_encoding():
bytes([123]),
)
- service = Service('3A657F47-D34F-46B3-B1EC-698E29B6B829', [characteristic])
+ async def async_read(connection):
+ return 0x05060708
+
+ async_characteristic = PackedCharacteristicAdapter(
+ Characteristic(
+ '2AB7E91B-43E8-4F73-AC3B-80C1683B47F9',
+ Characteristic.Properties.READ,
+ Characteristic.READABLE,
+ CharacteristicValue(read=async_read),
+ ),
+ '>I',
+ )
+
+ service = Service(
+ '3A657F47-D34F-46B3-B1EC-698E29B6B829', [characteristic, async_characteristic]
+ )
server.add_service(service)
await client.power_on()
@@ -183,6 +199,13 @@ async def test_characteristic_encoding():
await async_barrier()
assert characteristic.value == bytes([50])
+ c2 = peer.get_characteristics_by_uuid(async_characteristic.uuid)
+ assert len(c2) == 1
+ c2 = c2[0]
+ cd2 = PackedCharacteristicAdapter(c2, ">I")
+ cd2v = await cd2.read_value()
+ assert cd2v == 0x05060708
+
last_change = None
def on_change(value):
@@ -284,7 +307,8 @@ async def test_attribute_getters():
# -----------------------------------------------------------------------------
-def test_CharacteristicAdapter():
+@pytest.mark.asyncio
+async def test_CharacteristicAdapter():
# Check that the CharacteristicAdapter base class is transparent
v = bytes([1, 2, 3])
c = Characteristic(
@@ -295,11 +319,11 @@ def test_CharacteristicAdapter():
)
a = CharacteristicAdapter(c)
- value = a.read_value(None)
+ value = await a.read_value(None)
assert value == v
v = bytes([3, 4, 5])
- a.write_value(None, v)
+ await a.write_value(None, v)
assert c.value == v
# Simple delegated adapter
@@ -307,11 +331,11 @@ def test_CharacteristicAdapter():
c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x))
)
- value = a.read_value(None)
+ value = await a.read_value(None)
assert value == bytes(reversed(v))
v = bytes([3, 4, 5])
- a.write_value(None, v)
+ await a.write_value(None, v)
assert a.value == bytes(reversed(v))
# Packed adapter with single element format
@@ -320,10 +344,10 @@ def test_CharacteristicAdapter():
c.value = v
a = PackedCharacteristicAdapter(c, '>H')
- value = a.read_value(None)
+ value = await a.read_value(None)
assert value == pv
c.value = None
- a.write_value(None, pv)
+ await a.write_value(None, pv)
assert a.value == v
# Packed adapter with multi-element format
@@ -333,10 +357,10 @@ def test_CharacteristicAdapter():
c.value = (v1, v2)
a = PackedCharacteristicAdapter(c, '>HH')
- value = a.read_value(None)
+ value = await a.read_value(None)
assert value == pv
c.value = None
- a.write_value(None, pv)
+ await a.write_value(None, pv)
assert a.value == (v1, v2)
# Mapped adapter
@@ -347,10 +371,10 @@ def test_CharacteristicAdapter():
c.value = mapped
a = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2'))
- value = a.read_value(None)
+ value = await a.read_value(None)
assert value == pv
c.value = None
- a.write_value(None, pv)
+ await a.write_value(None, pv)
assert a.value == mapped
# UTF-8 adapter
@@ -359,27 +383,49 @@ def test_CharacteristicAdapter():
c.value = v
a = UTF8CharacteristicAdapter(c)
- value = a.read_value(None)
+ value = await a.read_value(None)
assert value == ev
c.value = None
- a.write_value(None, ev)
+ await a.write_value(None, ev)
assert a.value == v
# -----------------------------------------------------------------------------
-def test_CharacteristicValue():
+@pytest.mark.asyncio
+async def test_CharacteristicValue():
b = bytes([1, 2, 3])
- c = CharacteristicValue(read=lambda _: b)
- x = c.read(None)
+
+ async def read_value(connection):
+ return b
+
+ c = CharacteristicValue(read=read_value)
+ x = await c.read(None)
assert x == b
- result = []
- c = CharacteristicValue(
- write=lambda connection, value: result.append((connection, value))
- )
+ m = Mock()
+ c = CharacteristicValue(write=m)
z = object()
c.write(z, b)
- assert result == [(z, b)]
+ m.assert_called_once_with(z, b)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_CharacteristicValue_async():
+ b = bytes([1, 2, 3])
+
+ async def read_value(connection):
+ return b
+
+ c = CharacteristicValue(read=read_value)
+ x = await c.read(None)
+ assert x == b
+
+ m = AsyncMock()
+ c = CharacteristicValue(write=m)
+ z = object()
+ await c.write(z, b)
+ m.assert_called_once_with(z, b)
# -----------------------------------------------------------------------------
@@ -412,13 +458,6 @@ class LinkedDevices:
# -----------------------------------------------------------------------------
-async def async_barrier():
- ready = asyncio.get_running_loop().create_future()
- asyncio.get_running_loop().call_soon(ready.set_result, None)
- await ready
-
-
-# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_read_write():
[client, server] = LinkedDevices().devices[:2]
@@ -765,6 +804,83 @@ async def test_subscribe_notify():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
+async def test_unsubscribe():
+ [client, server] = LinkedDevices().devices[:2]
+
+ characteristic1 = Characteristic(
+ 'FDB159DB-036C-49E3-B3DB-6325AC750806',
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
+ Characteristic.READABLE,
+ bytes([1, 2, 3]),
+ )
+ characteristic2 = Characteristic(
+ '3234C4F4-3F34-4616-8935-45A50EE05DEB',
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
+ Characteristic.READABLE,
+ bytes([1, 2, 3]),
+ )
+
+ service1 = Service(
+ '3A657F47-D34F-46B3-B1EC-698E29B6B829',
+ [characteristic1, characteristic2],
+ )
+ server.add_services([service1])
+
+ mock1 = Mock()
+ characteristic1.on('subscription', mock1)
+ mock2 = Mock()
+ characteristic2.on('subscription', mock2)
+
+ await client.power_on()
+ await server.power_on()
+ connection = await client.connect(server.random_address)
+ peer = Peer(connection)
+
+ await peer.discover_services()
+ await peer.discover_characteristics()
+ c = peer.get_characteristics_by_uuid(characteristic1.uuid)
+ assert len(c) == 1
+ c1 = c[0]
+ c = peer.get_characteristics_by_uuid(characteristic2.uuid)
+ assert len(c) == 1
+ c2 = c[0]
+
+ await c1.subscribe()
+ await async_barrier()
+ mock1.assert_called_once_with(ANY, True, False)
+
+ await c2.subscribe()
+ await async_barrier()
+ mock2.assert_called_once_with(ANY, True, False)
+
+ mock1.reset_mock()
+ await c1.unsubscribe()
+ await async_barrier()
+ mock1.assert_called_once_with(ANY, False, False)
+
+ mock2.reset_mock()
+ await c2.unsubscribe()
+ await async_barrier()
+ mock2.assert_called_once_with(ANY, False, False)
+
+ mock1.reset_mock()
+ await c1.unsubscribe()
+ await async_barrier()
+ mock1.assert_not_called()
+
+ mock2.reset_mock()
+ await c2.unsubscribe()
+ await async_barrier()
+ mock2.assert_not_called()
+
+ mock1.reset_mock()
+ await c1.unsubscribe(force=True)
+ await async_barrier()
+ mock1.assert_called_once_with(ANY, False, False)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
async def test_mtu_exchange():
[d1, d2, d3] = LinkedDevices().devices[:3]
@@ -883,11 +999,18 @@ Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration
# -----------------------------------------------------------------------------
async def async_main():
+ test_UUID()
+ test_ATT_Error_Response()
+ test_ATT_Read_By_Group_Type_Request()
await test_read_write()
await test_read_write2()
await test_subscribe_notify()
+ await test_unsubscribe()
await test_characteristic_encoding()
await test_mtu_exchange()
+ await test_CharacteristicValue()
+ await test_CharacteristicValue_async()
+ await test_CharacteristicAdapter()
# -----------------------------------------------------------------------------
@@ -1026,9 +1149,4 @@ def test_get_attribute_group():
# -----------------------------------------------------------------------------
if __name__ == '__main__':
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
- test_UUID()
- test_ATT_Error_Response()
- test_ATT_Read_By_Group_Type_Request()
- test_CharacteristicValue()
- test_CharacteristicAdapter()
asyncio.run(async_main())
diff --git a/tests/hci_test.py b/tests/hci_test.py
index c648592..72f4022 100644
--- a/tests/hci_test.py
+++ b/tests/hci_test.py
@@ -23,13 +23,18 @@ from bumble.hci import (
HCI_LE_READ_BUFFER_SIZE_COMMAND,
HCI_RESET_COMMAND,
HCI_SUCCESS,
+ HCI_LE_CONNECTION_COMPLETE_EVENT,
+ HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT,
Address,
+ CodingFormat,
+ CodecID,
HCI_Command,
HCI_Command_Complete_Event,
HCI_Command_Status_Event,
HCI_CustomPacket,
HCI_Disconnect_Command,
HCI_Event,
+ HCI_IsoDataPacket,
HCI_LE_Add_Device_To_Filter_Accept_List_Command,
HCI_LE_Advertising_Report_Event,
HCI_LE_Channel_Selection_Algorithm_Event,
@@ -51,6 +56,7 @@ from bumble.hci import (
HCI_LE_Set_Random_Address_Command,
HCI_LE_Set_Scan_Enable_Command,
HCI_LE_Set_Scan_Parameters_Command,
+ HCI_LE_Setup_ISO_Data_Path_Command,
HCI_Number_Of_Completed_Packets_Event,
HCI_Packet,
HCI_PIN_Code_Request_Reply_Command,
@@ -270,8 +276,14 @@ def test_HCI_Set_Event_Mask_Command():
# -----------------------------------------------------------------------------
def test_HCI_LE_Set_Event_Mask_Command():
command = HCI_LE_Set_Event_Mask_Command(
- le_event_mask=bytes.fromhex('0011223344556677')
+ le_event_mask=HCI_LE_Set_Event_Mask_Command.mask(
+ [
+ HCI_LE_CONNECTION_COMPLETE_EVENT,
+ HCI_LE_ENHANCED_CONNECTION_COMPLETE_V2_EVENT,
+ ]
+ )
)
+ assert command.le_event_mask == bytes.fromhex('0100000000010000')
basic_check(command)
@@ -443,6 +455,28 @@ def test_HCI_LE_Set_Extended_Advertising_Enable_Command():
# -----------------------------------------------------------------------------
+def test_HCI_LE_Setup_ISO_Data_Path_Command():
+ command = HCI_Packet.from_bytes(bytes.fromhex('016e200d60000001030000000000000000'))
+
+ assert command.connection_handle == 0x0060
+ assert command.data_path_direction == 0x00
+ assert command.data_path_id == 0x01
+ assert command.codec_id == CodingFormat(CodecID.TRANSPARENT)
+ assert command.controller_delay == 0
+ assert command.codec_configuration == b''
+
+ command = HCI_LE_Setup_ISO_Data_Path_Command(
+ connection_handle=0x0060,
+ data_path_direction=0x00,
+ data_path_id=0x01,
+ codec_id=CodingFormat(CodecID.TRANSPARENT),
+ controller_delay=0x00,
+ codec_configuration=b'',
+ )
+ basic_check(command)
+
+
+# -----------------------------------------------------------------------------
def test_address():
a = Address('C4:F2:17:1A:1D:BB')
assert not a.is_public
@@ -462,6 +496,29 @@ def test_custom():
# -----------------------------------------------------------------------------
+def test_iso_data_packet():
+ data = bytes.fromhex(
+ '05616044002ac9f0a193003c00e83b477b00eba8d41dc018bf1a980f0290afe1e7c37652096697'
+ '52b6a535a8df61e22931ef5a36281bc77ed6a3206d984bcdabee6be831c699cb50e2'
+ )
+ packet = HCI_IsoDataPacket.from_bytes(data)
+ assert packet.connection_handle == 0x0061
+ assert packet.packet_status_flag == 0
+ assert packet.pb_flag == 0x02
+ assert packet.ts_flag == 0x01
+ assert packet.data_total_length == 68
+ assert packet.time_stamp == 2716911914
+ assert packet.packet_sequence_number == 147
+ assert packet.iso_sdu_length == 60
+ assert packet.iso_sdu_fragment == bytes.fromhex(
+ 'e83b477b00eba8d41dc018bf1a980f0290afe1e7c3765209669752b6a535a8df61e22931ef5a3'
+ '6281bc77ed6a3206d984bcdabee6be831c699cb50e2'
+ )
+
+ assert packet.to_bytes() == data
+
+
+# -----------------------------------------------------------------------------
def run_test_events():
test_HCI_Event()
test_HCI_LE_Connection_Complete_Event()
@@ -499,6 +556,7 @@ def run_test_commands():
test_HCI_LE_Set_Default_PHY_Command()
test_HCI_LE_Set_Extended_Scan_Parameters_Command()
test_HCI_LE_Set_Extended_Advertising_Enable_Command()
+ test_HCI_LE_Setup_ISO_Data_Path_Command()
# -----------------------------------------------------------------------------
@@ -507,3 +565,4 @@ if __name__ == '__main__':
run_test_commands()
test_address()
test_custom()
+ test_iso_data_packet()
diff --git a/tests/hfp_test.py b/tests/hfp_test.py
index 481d0b7..dc28180 100644
--- a/tests/hfp_test.py
+++ b/tests/hfp_test.py
@@ -23,8 +23,10 @@ import pytest
from typing import Tuple
from .test_utils import TwoDevices
+from bumble import core
from bumble import hfp
from bumble import rfcomm
+from bumble import hci
# -----------------------------------------------------------------------------
@@ -43,12 +45,10 @@ async def make_hfp_connections(
# Setup RFCOMM channel
wait_dlc = asyncio.get_running_loop().create_future()
- rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(
- lambda dlc: wait_dlc.set_result(dlc)
- )
+ rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(wait_dlc.set_result)
assert devices.connections[0]
assert devices.connections[1]
- client_mux = await rfcomm.Client(devices.devices[1], devices.connections[1]).start()
+ client_mux = await rfcomm.Client(devices.connections[1]).start()
client_dlc = await client_mux.open_dlc(rfcomm_channel)
server_dlc = await wait_dlc
@@ -90,6 +90,68 @@ async def test_slc():
# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_sco_setup():
+ devices = TwoDevices()
+
+ # Enable Classic connections
+ devices[0].classic_enabled = True
+ devices[1].classic_enabled = True
+
+ # Start
+ await devices[0].power_on()
+ await devices[1].power_on()
+
+ connections = await asyncio.gather(
+ devices[0].connect(
+ devices[1].public_address, transport=core.BT_BR_EDR_TRANSPORT
+ ),
+ devices[1].accept(devices[0].public_address),
+ )
+
+ def on_sco_request(_connection, _link_type: int):
+ connections[1].abort_on(
+ 'disconnection',
+ devices[1].send_command(
+ hci.HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(
+ bd_addr=connections[1].peer_address,
+ **hfp.ESCO_PARAMETERS[
+ hfp.DefaultCodecParameters.ESCO_CVSD_S1
+ ].asdict(),
+ )
+ ),
+ )
+
+ devices[1].on('sco_request', on_sco_request)
+
+ sco_connection_futures = [
+ asyncio.get_running_loop().create_future(),
+ asyncio.get_running_loop().create_future(),
+ ]
+
+ for device, future in zip(devices, sco_connection_futures):
+ device.on('sco_connection', future.set_result)
+
+ await devices[0].send_command(
+ hci.HCI_Enhanced_Setup_Synchronous_Connection_Command(
+ connection_handle=connections[0].handle,
+ **hfp.ESCO_PARAMETERS[hfp.DefaultCodecParameters.ESCO_CVSD_S1].asdict(),
+ )
+ )
+ sco_connections = await asyncio.gather(*sco_connection_futures)
+
+ sco_disconnection_futures = [
+ asyncio.get_running_loop().create_future(),
+ asyncio.get_running_loop().create_future(),
+ ]
+ for future, sco_connection in zip(sco_disconnection_futures, sco_connections):
+ sco_connection.on('disconnection', future.set_result)
+
+ await sco_connections[0].disconnect()
+ await asyncio.gather(*sco_disconnection_futures)
+
+
+# -----------------------------------------------------------------------------
async def run():
await test_slc()
diff --git a/tests/host_test.py b/tests/host_test.py
new file mode 100644
index 0000000..5170497
--- /dev/null
+++ b/tests/host_test.py
@@ -0,0 +1,62 @@
+# Copyright 2021-2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import logging
+import pytest
+
+from bumble.controller import Controller
+from bumble.host import Host
+from bumble.transport import AsyncPipeSink
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ 'supported_commands, lmp_features',
+ [
+ (
+ # Default commands
+ '2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
+ '30f0f9ff01008004000000000000000000000000000000000000000000000000',
+ # Only LE LMP feature
+ '0000000060000000',
+ ),
+ (
+ # All commands
+ 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
+ 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
+ # 3 pages of LMP features
+ '000102030405060708090A0B0C0D0E0F011112131415161718191A1B1C1D1E1F',
+ ),
+ ],
+)
+async def test_reset(supported_commands: str, lmp_features: str):
+ controller = Controller('C')
+ controller.supported_commands = bytes.fromhex(supported_commands)
+ controller.lmp_features = bytes.fromhex(lmp_features)
+ host = Host(controller, AsyncPipeSink(controller))
+
+ await host.reset()
+
+ assert host.local_lmp_features == int.from_bytes(
+ bytes.fromhex(lmp_features), 'little'
+ )
diff --git a/tests/l2cap_test.py b/tests/l2cap_test.py
index 5cb285c..6323ddf 100644
--- a/tests/l2cap_test.py
+++ b/tests/l2cap_test.py
@@ -228,11 +228,33 @@ async def test_bidirectional_transfer():
# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_mtu():
+ devices = TwoDevices()
+ await devices.setup_connection()
+
+ def on_channel_open(channel):
+ assert channel.peer_mtu == 456
+
+ def on_channel(channel):
+ channel.on('open', lambda: on_channel_open(channel))
+
+ server = devices.devices[1].create_l2cap_server(
+ spec=ClassicChannelSpec(mtu=345), handler=on_channel
+ )
+ client_channel = await devices.connections[0].create_l2cap_channel(
+ spec=ClassicChannelSpec(server.psm, mtu=456)
+ )
+ assert client_channel.peer_mtu == 345
+
+
+# -----------------------------------------------------------------------------
async def run():
test_helpers()
await test_basic_connection()
await test_transfer()
await test_bidirectional_transfer()
+ await test_mtu()
# -----------------------------------------------------------------------------
diff --git a/tests/rfcomm_test.py b/tests/rfcomm_test.py
index 9465462..4ce4d11 100644
--- a/tests/rfcomm_test.py
+++ b/tests/rfcomm_test.py
@@ -15,7 +15,22 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
-from bumble.rfcomm import RFCOMM_Frame
+import asyncio
+import pytest
+from typing import List
+
+from . import test_utils
+from bumble import core
+from bumble.rfcomm import (
+ RFCOMM_Frame,
+ Server,
+ Client,
+ DLC,
+ make_service_sdp_records,
+ find_rfcomm_channels,
+ find_rfcomm_channel_with_uuid,
+ RFCOMM_PSM,
+)
# -----------------------------------------------------------------------------
@@ -44,5 +59,68 @@ def test_frames():
# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_basic_connection() -> None:
+ devices = test_utils.TwoDevices()
+ await devices.setup_connection()
+
+ accept_future: asyncio.Future[DLC] = asyncio.get_running_loop().create_future()
+ channel = Server(devices[0]).listen(acceptor=accept_future.set_result)
+
+ assert devices.connections[1]
+ multiplexer = await Client(devices.connections[1]).start()
+ dlcs = await asyncio.gather(accept_future, multiplexer.open_dlc(channel))
+
+ queues: List[asyncio.Queue] = [asyncio.Queue(), asyncio.Queue()]
+ for dlc, queue in zip(dlcs, queues):
+ dlc.sink = queue.put_nowait
+
+ dlcs[0].write(b'The quick brown fox jumps over the lazy dog')
+ assert await queues[1].get() == b'The quick brown fox jumps over the lazy dog'
+
+ dlcs[1].write(b'Lorem ipsum dolor sit amet')
+ assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_service_record():
+ HANDLE = 2
+ CHANNEL = 1
+ SERVICE_UUID = core.UUID('00000000-0000-0000-0000-000000000001')
+
+ devices = test_utils.TwoDevices()
+ await devices.setup_connection()
+
+ devices[0].sdp_service_records[HANDLE] = make_service_sdp_records(
+ HANDLE, CHANNEL, SERVICE_UUID
+ )
+
+ assert SERVICE_UUID in (await find_rfcomm_channels(devices.connections[1]))[CHANNEL]
+ assert (
+ await find_rfcomm_channel_with_uuid(devices.connections[1], SERVICE_UUID)
+ == CHANNEL
+ )
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_context():
+ devices = test_utils.TwoDevices()
+ await devices.setup_connection()
+
+ server = Server(devices[0])
+ with server:
+ assert server.l2cap_server is not None
+
+ client = Client(devices.connections[1])
+ async with client:
+ assert client.l2cap_channel is not None
+
+ assert client.l2cap_channel is None
+ assert RFCOMM_PSM not in devices[0].l2cap_channel_manager.servers
+
+
+# -----------------------------------------------------------------------------
if __name__ == '__main__':
test_frames()
diff --git a/tests/sdp_test.py b/tests/sdp_test.py
index 29db875..91835e7 100644
--- a/tests/sdp_test.py
+++ b/tests/sdp_test.py
@@ -38,6 +38,7 @@ from .test_utils import TwoDevices
# pylint: disable=invalid-name
# -----------------------------------------------------------------------------
+
# -----------------------------------------------------------------------------
def basic_check(x: DataElement) -> None:
serialized = bytes(x)
@@ -215,8 +216,8 @@ async def test_service_search():
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
- client = Client(devices.devices[1])
- await client.connect(devices.connections[1])
+ client = Client(devices.connections[1])
+ await client.connect()
services = await client.search_services(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
)
@@ -236,8 +237,8 @@ async def test_service_attribute():
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
- client = Client(devices.devices[1])
- await client.connect(devices.connections[1])
+ client = Client(devices.connections[1])
+ await client.connect()
attributes = await client.get_attributes(
0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
)
@@ -257,8 +258,8 @@ async def test_service_search_attribute():
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
- client = Client(devices.devices[1])
- await client.connect(devices.connections[1])
+ client = Client(devices.connections[1])
+ await client.connect()
attributes = await client.search_attributes(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0x0000FFFF, 8)]
)
@@ -270,6 +271,20 @@ async def test_service_search_attribute():
# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_client_async_context():
+ devices = TwoDevices()
+ await devices.setup_connection()
+
+ client = Client(devices.connections[1])
+
+ async with client:
+ assert client.channel is not None
+
+ assert client.channel is None
+
+
+# -----------------------------------------------------------------------------
async def run():
test_data_elements()
await test_service_attribute()
diff --git a/tests/self_test.py b/tests/self_test.py
index 98ce5e8..259de02 100644
--- a/tests/self_test.py
+++ b/tests/self_test.py
@@ -21,7 +21,7 @@ import logging
import os
import pytest
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
from bumble.controller import Controller
from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE
@@ -34,9 +34,10 @@ from bumble.pairing import PairingConfig, PairingDelegate
from bumble.smp import (
SMP_PAIRING_NOT_SUPPORTED_ERROR,
SMP_CONFIRM_VALUE_FAILED_ERROR,
+ OobContext,
+ OobLegacyContext,
)
from bumble.core import ProtocolError
-from bumble.hci import HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE
from bumble.keys import PairingKeys
@@ -517,16 +518,8 @@ async def test_self_smp_over_classic():
# Mock connection
# TODO: Implement Classic SSP and encryption in link relayer
LINK_KEY = bytes.fromhex('287ad379dca402530a39f1f43047b835')
- two_devices.devices[0].on_link_key(
- two_devices.devices[1].public_address,
- LINK_KEY,
- HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
- )
- two_devices.devices[1].on_link_key(
- two_devices.devices[0].public_address,
- LINK_KEY,
- HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
- )
+ two_devices.devices[0].get_link_key = AsyncMock(return_value=LINK_KEY)
+ two_devices.devices[1].get_link_key = AsyncMock(return_value=LINK_KEY)
two_devices.connections[0].encryption = 1
two_devices.connections[1].encryption = 1
@@ -554,6 +547,13 @@ async def test_self_smp_over_classic():
MockSmpSession.send_public_key_command.assert_not_called()
MockSmpSession.send_pairing_random_command.assert_not_called()
+ for i in range(2):
+ assert (
+ await two_devices.devices[i].keystore.get(
+ str(two_devices.connections[i].peer_address)
+ )
+ ).link_key
+
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@@ -576,6 +576,77 @@ async def test_self_smp_public_address():
# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_self_smp_oob_sc():
+ oob_context_1 = OobContext()
+ oob_context_2 = OobContext()
+
+ pairing_config_1 = PairingConfig(
+ mitm=True,
+ sc=True,
+ bonding=True,
+ oob=PairingConfig.OobConfig(oob_context_1, oob_context_2.share(), None),
+ )
+
+ pairing_config_2 = PairingConfig(
+ mitm=True,
+ sc=True,
+ bonding=True,
+ oob=PairingConfig.OobConfig(oob_context_2, oob_context_1.share(), None),
+ )
+
+ await _test_self_smp_with_configs(pairing_config_1, pairing_config_2)
+
+ pairing_config_3 = PairingConfig(
+ mitm=True,
+ sc=True,
+ bonding=True,
+ oob=PairingConfig.OobConfig(oob_context_2, None, None),
+ )
+
+ await _test_self_smp_with_configs(pairing_config_1, pairing_config_3)
+ await _test_self_smp_with_configs(pairing_config_3, pairing_config_1)
+
+ pairing_config_4 = PairingConfig(
+ mitm=True,
+ sc=True,
+ bonding=True,
+ oob=PairingConfig.OobConfig(oob_context_2, oob_context_2.share(), None),
+ )
+
+ with pytest.raises(ProtocolError) as error:
+ await _test_self_smp_with_configs(pairing_config_1, pairing_config_4)
+ assert error.value.error_code == SMP_CONFIRM_VALUE_FAILED_ERROR
+
+ with pytest.raises(ProtocolError):
+ await _test_self_smp_with_configs(pairing_config_4, pairing_config_1)
+ assert error.value.error_code == SMP_CONFIRM_VALUE_FAILED_ERROR
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_self_smp_oob_legacy():
+ legacy_context = OobLegacyContext()
+
+ pairing_config_1 = PairingConfig(
+ mitm=True,
+ sc=False,
+ bonding=True,
+ oob=PairingConfig.OobConfig(None, None, legacy_context),
+ )
+
+ pairing_config_2 = PairingConfig(
+ mitm=True,
+ sc=True,
+ bonding=True,
+ oob=PairingConfig.OobConfig(OobContext(), None, legacy_context),
+ )
+
+ await _test_self_smp_with_configs(pairing_config_1, pairing_config_2)
+ await _test_self_smp_with_configs(pairing_config_2, pairing_config_1)
+
+
+# -----------------------------------------------------------------------------
async def run_test_self():
await test_self_connection()
await test_self_gatt()
@@ -585,6 +656,8 @@ async def run_test_self():
await test_self_smp_wrong_pin()
await test_self_smp_over_classic()
await test_self_smp_public_address()
+ await test_self_smp_oob_sc()
+ await test_self_smp_oob_legacy()
# -----------------------------------------------------------------------------
diff --git a/tests/smp_test.py b/tests/smp_test.py
index bdfa021..7a32b23 100644
--- a/tests/smp_test.py
+++ b/tests/smp_test.py
@@ -16,15 +16,23 @@
# Imports
# -----------------------------------------------------------------------------
+import pytest
+
+from bumble import smp
from bumble.crypto import EccKey, aes_cmac, ah, c1, f4, f5, f6, g2, h6, h7, s1
+from bumble.pairing import OobData, OobSharedData, LeRole
+from bumble.hci import Address
+from bumble.core import AdvertisingData
+
# -----------------------------------------------------------------------------
# pylint: disable=invalid-name
# -----------------------------------------------------------------------------
+
# -----------------------------------------------------------------------------
-def reversed_hex(hex_str):
- return bytes(reversed(bytes.fromhex(hex_str)))
+def reversed_hex(hex_str: str) -> bytes:
+ return bytes.fromhex(hex_str)[::-1]
# -----------------------------------------------------------------------------
@@ -124,116 +132,126 @@ def test_aes_cmac():
# -----------------------------------------------------------------------------
def test_f4():
- u = bytes(
- reversed(
- bytes.fromhex(
- '20b003d2 f297be2c 5e2c83a7 e9f9a5b9'
- + 'eff49111 acf4fddb cc030148 0e359de6'
- )
- )
- )
- v = bytes(
- reversed(
- bytes.fromhex(
- '55188b3d 32f6bb9a 900afcfb eed4e72a'
- + '59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
- )
- )
- )
- x = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
- z = bytes([0])
- value = f4(u, v, x, z)
- assert bytes(reversed(value)) == bytes.fromhex(
- 'f2c916f1 07a9bd1c f1eda1be a974872d'
+ u = reversed_hex(
+ '20b003d2 f297be2c 5e2c83a7 e9f9a5b9 eff49111 acf4fddb cc030148 0e359de6'
+ )
+ v = reversed_hex(
+ '55188b3d 32f6bb9a 900afcfb eed4e72a 59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
)
+ x = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
+ z = b'\0'
+ value = f4(u, v, x, z)
+ assert value == reversed_hex('f2c916f1 07a9bd1c f1eda1be a974872d')
# -----------------------------------------------------------------------------
def test_f5():
- w = bytes(
- reversed(
- bytes.fromhex(
- 'ec0234a3 57c8ad05 341010a6 0a397d9b'
- + '99796b13 b4f866f1 868d34f3 73bfa698'
- )
- )
- )
- n1 = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
- n2 = bytes(reversed(bytes.fromhex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')))
- a1 = bytes(reversed(bytes.fromhex('00561237 37bfce')))
- a2 = bytes(reversed(bytes.fromhex('00a71370 2dcfc1')))
- value = f5(w, n1, n2, a1, a2)
- assert bytes(reversed(value[0])) == bytes.fromhex(
- '2965f176 a1084a02 fd3f6a20 ce636e20'
- )
- assert bytes(reversed(value[1])) == bytes.fromhex(
- '69867911 69d7cd23 980522b5 94750a38'
+ w = reversed_hex(
+ 'ec0234a3 57c8ad05 341010a6 0a397d9b 99796b13 b4f866f1 868d34f3 73bfa698'
)
+ n1 = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
+ n2 = reversed_hex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')
+ a1 = reversed_hex('00561237 37bfce')
+ a2 = reversed_hex('00a71370 2dcfc1')
+ value = f5(w, n1, n2, a1, a2)
+ assert value[0] == reversed_hex('2965f176 a1084a02 fd3f6a20 ce636e20')
+ assert value[1] == reversed_hex('69867911 69d7cd23 980522b5 94750a38')
# -----------------------------------------------------------------------------
def test_f6():
- n1 = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
- n2 = bytes(reversed(bytes.fromhex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')))
- mac_key = bytes(reversed(bytes.fromhex('2965f176 a1084a02 fd3f6a20 ce636e20')))
- r = bytes(reversed(bytes.fromhex('12a3343b b453bb54 08da42d2 0c2d0fc8')))
- io_cap = bytes(reversed(bytes.fromhex('010102')))
- a1 = bytes(reversed(bytes.fromhex('00561237 37bfce')))
- a2 = bytes(reversed(bytes.fromhex('00a71370 2dcfc1')))
+ n1 = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
+ n2 = reversed_hex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')
+ mac_key = reversed_hex('2965f176 a1084a02 fd3f6a20 ce636e20')
+ r = reversed_hex('12a3343b b453bb54 08da42d2 0c2d0fc8')
+ io_cap = reversed_hex('010102')
+ a1 = reversed_hex('00561237 37bfce')
+ a2 = reversed_hex('00a71370 2dcfc1')
value = f6(mac_key, n1, n2, r, io_cap, a1, a2)
- assert bytes(reversed(value)) == bytes.fromhex(
- 'e3c47398 9cd0e8c5 d26c0b09 da958f61'
- )
+ assert value == reversed_hex('e3c47398 9cd0e8c5 d26c0b09 da958f61')
# -----------------------------------------------------------------------------
def test_g2():
- u = bytes(
- reversed(
- bytes.fromhex(
- '20b003d2 f297be2c 5e2c83a7 e9f9a5b9'
- + 'eff49111 acf4fddb cc030148 0e359de6'
- )
- )
- )
- v = bytes(
- reversed(
- bytes.fromhex(
- '55188b3d 32f6bb9a 900afcfb eed4e72a'
- + '59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
- )
- )
- )
- x = bytes(reversed(bytes.fromhex('d5cb8454 d177733e ffffb2ec 712baeab')))
- y = bytes(reversed(bytes.fromhex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')))
+ u = reversed_hex(
+ '20b003d2 f297be2c 5e2c83a7 e9f9a5b9 eff49111 acf4fddb cc030148 0e359de6'
+ )
+ v = reversed_hex(
+ '55188b3d 32f6bb9a 900afcfb eed4e72a 59cb9ac2 f19d7cfb 6b4fdd49 f47fc5fd'
+ )
+ x = reversed_hex('d5cb8454 d177733e ffffb2ec 712baeab')
+ y = reversed_hex('a6e8e7cc 25a75f6e 216583f7 ff3dc4cf')
value = g2(u, v, x, y)
assert value == 0x2F9ED5BA
# -----------------------------------------------------------------------------
def test_h6():
- KEY = bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')
+ KEY = reversed_hex('ec0234a3 57c8ad05 341010a6 0a397d9b')
KEY_ID = bytes.fromhex('6c656272')
- assert h6(KEY, KEY_ID) == bytes.fromhex('2d9ae102 e76dc91c e8d3a9e2 80b16399')
+ assert h6(KEY, KEY_ID) == reversed_hex('2d9ae102 e76dc91c e8d3a9e2 80b16399')
# -----------------------------------------------------------------------------
def test_h7():
- KEY = bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')
+ KEY = reversed_hex('ec0234a3 57c8ad05 341010a6 0a397d9b')
SALT = bytes.fromhex('00000000 00000000 00000000 746D7031')
- assert h7(SALT, KEY) == bytes.fromhex('fb173597 c6a3c0ec d2998c2a 75a57011')
+ assert h7(SALT, KEY) == reversed_hex('fb173597 c6a3c0ec d2998c2a 75a57011')
# -----------------------------------------------------------------------------
def test_ah():
- irk = bytes(reversed(bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')))
- prand = bytes(reversed(bytes.fromhex('708194')))
+ irk = reversed_hex('ec0234a3 57c8ad05 341010a6 0a397d9b')
+ prand = reversed_hex('708194')
value = ah(irk, prand)
- expected = bytes(reversed(bytes.fromhex('0dfbaa')))
+ expected = reversed_hex('0dfbaa')
assert value == expected
# -----------------------------------------------------------------------------
+def test_oob_data():
+ oob_data = OobData(
+ address=Address("F0:F1:F2:F3:F4:F5"),
+ role=LeRole.BOTH_PERIPHERAL_PREFERRED,
+ shared_data=OobSharedData(c=b'12', r=b'34'),
+ )
+ oob_data_ad = oob_data.to_ad()
+ oob_data_bytes = bytes(oob_data_ad)
+ oob_data_ad_parsed = AdvertisingData.from_bytes(oob_data_bytes)
+ oob_data_parsed = OobData.from_ad(oob_data_ad_parsed)
+ assert oob_data_parsed.address == oob_data.address
+ assert oob_data_parsed.role == oob_data.role
+ assert oob_data_parsed.shared_data.c == oob_data.shared_data.c
+ assert oob_data_parsed.shared_data.r == oob_data.shared_data.r
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+ 'ct2, expected',
+ [
+ (False, 'bc1ca4ef 633fc1bd 0d8230af ee388fb0'),
+ (True, '287ad379 dca40253 0a39f1f4 3047b835'),
+ ],
+)
+def test_ltk_to_link_key(ct2: bool, expected: str):
+ LTK = reversed_hex('368df9bc e3264b58 bd066c33 334fbf64')
+ assert smp.Session.derive_link_key(LTK, ct2) == reversed_hex(expected)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.parametrize(
+ 'ct2, expected',
+ [
+ (False, 'a813fb72 f1a3dfa1 8a2c9a43 f10d0a30'),
+ (True, 'e85e09eb 5eccb3e2 69418a13 3211bc79'),
+ ],
+)
+def test_link_key_to_ltk(ct2: bool, expected: str):
+ LINK_KEY = reversed_hex('05040302 01000908 07060504 03020100')
+ assert smp.Session.derive_ltk(LINK_KEY, ct2) == reversed_hex(expected)
+
+
+# -----------------------------------------------------------------------------
if __name__ == '__main__':
test_ecc()
test_c1()
@@ -246,3 +264,4 @@ if __name__ == '__main__':
test_h6()
test_h7()
test_ah()
+ test_oob_data()
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f19f18c..d193d6e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -12,6 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
from typing import List, Optional
from bumble.controller import Controller
@@ -22,6 +26,7 @@ from bumble.transport import AsyncPipeSink
from bumble.hci import Address
+# -----------------------------------------------------------------------------
class TwoDevices:
connections: List[Optional[Connection]]
@@ -29,17 +34,18 @@ class TwoDevices:
self.connections = [None, None]
self.link = LocalLink()
+ addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
self.controllers = [
- Controller('C1', link=self.link),
- Controller('C2', link=self.link),
+ Controller('C1', link=self.link, public_address=addresses[0]),
+ Controller('C2', link=self.link, public_address=addresses[1]),
]
self.devices = [
Device(
- address=Address('F0:F1:F2:F3:F4:F5'),
+ address=Address(addresses[0]),
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
),
Device(
- address=Address('F5:F4:F3:F2:F1:F0'),
+ address=Address(addresses[1]),
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
),
]
@@ -71,3 +77,13 @@ class TwoDevices:
# Check the post conditions
assert self.connections[0] is not None
assert self.connections[1] is not None
+
+ def __getitem__(self, index: int) -> Device:
+ return self.devices[index]
+
+
+# -----------------------------------------------------------------------------
+async def async_barrier():
+ ready = asyncio.get_running_loop().create_future()
+ asyncio.get_running_loop().call_soon(ready.set_result, None)
+ await ready
diff --git a/tests/utils_test.py b/tests/utils_test.py
index d6f5780..6266f9e 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -12,15 +12,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
import contextlib
import logging
import os
+from unittest.mock import MagicMock
-from bumble import utils
from pyee import EventEmitter
-from unittest.mock import MagicMock
+
+from bumble import utils
+# -----------------------------------------------------------------------------
def test_on() -> None:
emitter = EventEmitter()
with contextlib.closing(utils.EventWatcher()) as context:
@@ -33,6 +38,7 @@ def test_on() -> None:
assert mock.call_count == 1
+# -----------------------------------------------------------------------------
def test_on_decorator() -> None:
emitter = EventEmitter()
with contextlib.closing(utils.EventWatcher()) as context:
@@ -48,6 +54,7 @@ def test_on_decorator() -> None:
assert mock.call_count == 1
+# -----------------------------------------------------------------------------
def test_multiple_handlers() -> None:
emitter = EventEmitter()
with contextlib.closing(utils.EventWatcher()) as context:
@@ -65,6 +72,30 @@ def test_multiple_handlers() -> None:
# -----------------------------------------------------------------------------
+def test_open_int_enums():
+ class Foo(utils.OpenIntEnum):
+ FOO = 1
+ BAR = 2
+ BLA = 3
+
+ x = Foo(1)
+ assert x.name == "FOO"
+ assert x.value == 1
+ assert int(x) == 1
+ assert x == 1
+ assert x + 1 == 2
+
+ x = Foo(4)
+ assert x.name == "Foo[4]"
+ assert x.value == 4
+ assert int(x) == 4
+ assert x == 4
+ assert x + 1 == 5
+
+ print(list(Foo))
+
+
+# -----------------------------------------------------------------------------
def run_tests():
test_on()
test_on_decorator()
@@ -75,3 +106,4 @@ def run_tests():
if __name__ == '__main__':
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
run_tests()
+ test_open_int_enums()
diff --git a/tests/vcp_test.py b/tests/vcp_test.py
new file mode 100644
index 0000000..d45a5f5
--- /dev/null
+++ b/tests/vcp_test.py
@@ -0,0 +1,120 @@
+# Copyright 2021-2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import pytest
+import pytest_asyncio
+import logging
+
+from bumble import device
+from bumble.profiles import vcp
+from .test_utils import TwoDevices
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+@pytest_asyncio.fixture
+async def vcp_client():
+ devices = TwoDevices()
+ devices[0].add_service(
+ vcp.VolumeControlService(volume_setting=32, muted=1, volume_flags=1)
+ )
+
+ await devices.setup_connection()
+
+ # Mock encryption.
+ devices.connections[0].encryption = 1
+ devices.connections[1].encryption = 1
+
+ peer = device.Peer(devices.connections[1])
+ vcp_client = await peer.discover_service_and_create_proxy(
+ vcp.VolumeControlServiceProxy
+ )
+ yield vcp_client
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_init_service(vcp_client: vcp.VolumeControlServiceProxy):
+ assert (await vcp_client.volume_flags.read_value()) == 1
+ assert (await vcp_client.volume_state.read_value()) == (32, 1, 0)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy):
+ await vcp_client.volume_control_point.write_value(
+ bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_DOWN, 0])
+ )
+ assert (await vcp_client.volume_state.read_value()) == (16, 1, 1)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy):
+ await vcp_client.volume_control_point.write_value(
+ bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_UP, 0])
+ )
+ assert (await vcp_client.volume_state.read_value()) == (48, 1, 1)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_unmute_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy):
+ await vcp_client.volume_control_point.write_value(
+ bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_DOWN, 0])
+ )
+ assert (await vcp_client.volume_state.read_value()) == (16, 0, 1)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_unmute_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy):
+ await vcp_client.volume_control_point.write_value(
+ bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_UP, 0])
+ )
+ assert (await vcp_client.volume_state.read_value()) == (48, 0, 1)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_set_absolute_volume(vcp_client: vcp.VolumeControlServiceProxy):
+ await vcp_client.volume_control_point.write_value(
+ bytes([vcp.VolumeControlPointOpcode.SET_ABSOLUTE_VOLUME, 0, 255])
+ )
+ assert (await vcp_client.volume_state.read_value()) == (255, 1, 1)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_mute(vcp_client: vcp.VolumeControlServiceProxy):
+ await vcp_client.volume_control_point.write_value(
+ bytes([vcp.VolumeControlPointOpcode.MUTE, 0])
+ )
+ assert (await vcp_client.volume_state.read_value()) == (32, 1, 0)
+
+
+# -----------------------------------------------------------------------------
+@pytest.mark.asyncio
+async def test_unmute(vcp_client: vcp.VolumeControlServiceProxy):
+ await vcp_client.volume_control_point.write_value(
+ bytes([vcp.VolumeControlPointOpcode.UNMUTE, 0])
+ )
+ assert (await vcp_client.volume_state.read_value()) == (32, 0, 1)
diff --git a/web/bumble.js b/web/bumble.js
index b1243a5..cb807eb 100644
--- a/web/bumble.js
+++ b/web/bumble.js
@@ -5,11 +5,11 @@ function bufferToHex(buffer) {
class PacketSource {
constructor(pyodide) {
this.parser = pyodide.runPython(`
- from bumble.transport.common import PacketParser
- class ProxiedPacketParser(PacketParser):
- def feed_data(self, js_data):
- super().feed_data(bytes(js_data.to_py()))
- ProxiedPacketParser()
+ from bumble.transport.common import PacketParser
+ class ProxiedPacketParser(PacketParser):
+ def feed_data(self, js_data):
+ super().feed_data(bytes(js_data.to_py()))
+ ProxiedPacketParser()
`);
}
@@ -18,74 +18,171 @@ class PacketSource {
}
data_received(data) {
- console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
+ //console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
this.parser.feed_data(data);
}
}
class PacketSink {
- constructor(writer) {
- this.writer = writer;
- }
-
on_packet(packet) {
+ if (!this.writer) {
+ return;
+ }
const buffer = packet.toJs({create_proxies : false});
packet.destroy();
- console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
+ //console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
// TODO: create an async queue here instead of blindly calling write without awaiting
this.writer(buffer);
}
}
-export async function connectWebSocketTransport(pyodide, hciWsUrl) {
- return new Promise((resolve, reject) => {
- let resolved = false;
+class LogEvent extends Event {
+ constructor(message) {
+ super('log');
+ this.message = message;
+ }
+}
- let ws = new WebSocket(hciWsUrl);
- ws.binaryType = "arraybuffer";
+export class Bumble extends EventTarget {
+ constructor(pyodide) {
+ super();
+ this.pyodide = pyodide;
+ }
- ws.onopen = () => {
- console.log("WebSocket open");
- resolve({
- packet_source,
- packet_sink
- });
- resolved = true;
+ async loadRuntime(bumblePackage) {
+ // Load pyodide if it isn't provided.
+ if (this.pyodide === undefined) {
+ this.log('Loading Pyodide');
+ this.pyodide = await loadPyodide();
}
- ws.onclose = () => {
- console.log("WebSocket close");
- if (!resolved) {
- reject(`Failed to connect to ${hciWsUrl}`)
+ // Load the Bumble module
+ bumblePackage ||= 'bumble';
+ console.log('Installing micropip');
+ this.log(`Installing ${bumblePackage}`)
+ await this.pyodide.loadPackage('micropip');
+ await this.pyodide.runPythonAsync(`
+ import micropip
+ await micropip.install('${bumblePackage}')
+ package_list = micropip.list()
+ print(package_list)
+ `)
+
+ // Mount a filesystem so that we can persist data like the Key Store
+ let mountDir = '/bumble';
+ this.pyodide.FS.mkdir(mountDir);
+ this.pyodide.FS.mount(this.pyodide.FS.filesystems.IDBFS, { root: '.' }, mountDir);
+
+ // Sync previously persisted filesystem data into memory
+ await new Promise(resolve => {
+ this.pyodide.FS.syncfs(true, () => {
+ console.log('FS synced in');
+ resolve();
+ });
+ })
+
+ // Setup the HCI source and sink
+ this.packetSource = new PacketSource(this.pyodide);
+ this.packetSink = new PacketSink();
+ }
+
+ log(message) {
+ this.dispatchEvent(new LogEvent(message));
+ }
+
+ async connectWebSocketTransport(hciWsUrl) {
+ return new Promise((resolve, reject) => {
+ let resolved = false;
+
+ let ws = new WebSocket(hciWsUrl);
+ ws.binaryType = 'arraybuffer';
+
+ ws.onopen = () => {
+ this.log('WebSocket open');
+ resolve();
+ resolved = true;
}
- }
- ws.onmessage = (event) => {
- packet_source.data_received(event.data);
+ ws.onclose = () => {
+ this.log('WebSocket close');
+ if (!resolved) {
+ reject(`Failed to connect to ${hciWsUrl}`);
+ }
+ }
+
+ ws.onmessage = (event) => {
+ this.packetSource.data_received(event.data);
+ }
+
+ this.packetSink.writer = (packet) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(packet);
+ }
+ }
+ this.closeTransport = async () => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.close();
+ }
+ }
+ })
+ }
+
+ async loadApp(appUrl) {
+ this.log('Loading app');
+ const script = await (await fetch(appUrl)).text();
+ await this.pyodide.runPythonAsync(script);
+ const pythonMain = this.pyodide.globals.get('main');
+ const app = await pythonMain(this.packetSource, this.packetSink);
+ if (app.on) {
+ app.on('key_store_update', this.onKeystoreUpdate.bind(this));
}
+ this.log('App is ready!');
+ return app;
+ }
- const packet_source = new PacketSource(pyodide);
- const packet_sink = new PacketSink((packet) => ws.send(packet));
- })
+ onKeystoreUpdate() {
+ // Sync the FS
+ this.pyodide.FS.syncfs(() => {
+ console.log('FS synced out');
+ });
+ }
}
-export async function loadBumble(pyodide, bumblePackage) {
- // Load the Bumble module
- await pyodide.loadPackage("micropip");
- await pyodide.runPythonAsync(`
- import micropip
- await micropip.install("${bumblePackage}")
- package_list = micropip.list()
- print(package_list)
- `)
-
- // Mount a filesystem so that we can persist data like the Key Store
- let mountDir = "/bumble";
- pyodide.FS.mkdir(mountDir);
- pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, { root: "." }, mountDir);
-
- // Sync previously persisted filesystem data into memory
- pyodide.FS.syncfs(true, () => {
- console.log("FS synced in")
- });
+export async function setupSimpleApp(appUrl, bumbleControls, log) {
+ // Load Bumble
+ log('Loading Bumble');
+ const bumble = new Bumble();
+ bumble.addEventListener('log', (event) => {
+ log(event.message);
+ })
+ const params = (new URL(document.location)).searchParams;
+ await bumble.loadRuntime(params.get('package'));
+
+ log('Bumble is ready!')
+ const app = await bumble.loadApp(appUrl);
+
+ bumbleControls.connector = async (hciWsUrl) => {
+ try {
+ // Connect the WebSocket HCI transport
+ await bumble.connectWebSocketTransport(hciWsUrl);
+
+ // Start the app
+ await app.start();
+
+ return true;
+ } catch (err) {
+ log(err);
+ return false;
+ }
+ }
+ bumbleControls.stopper = async () => {
+ // Stop the app
+ await app.stop();
+
+ // Close the HCI transport
+ await bumble.closeTransport();
+ }
+ bumbleControls.onBumbleLoaded();
+
+ return app;
} \ No newline at end of file
diff --git a/web/heart_rate_monitor/heart_rate_monitor.html b/web/heart_rate_monitor/heart_rate_monitor.html
new file mode 100644
index 0000000..f44470f
--- /dev/null
+++ b/web/heart_rate_monitor/heart_rate_monitor.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
+ <script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
+ <script type="module" src="../ui.js"></script>
+ <script type="module" src="heart_rate_monitor.js"></script>
+ <style>
+ #hr-value {
+ font-family: sans-serif;
+ font-size: xx-large;
+ }
+
+ </style>
+</head>
+<body>
+ <bumble-controls id="bumble-controls"></bumble-controls><hr>
+ <span class="material-symbols-outlined">
+ cardiology
+ </span>
+ <span id="hr-value">60</span>
+ <br>
+ <button id="hr-up-button" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>arrow_upward</button>
+ <button id="hr-down-button" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>arrow_downward</button>
+ <hr>
+ <textarea id="log-output" style="width: 100%;" rows="10" disabled></textarea><hr>
+</body>
+</html>
diff --git a/web/heart_rate_monitor/heart_rate_monitor.js b/web/heart_rate_monitor/heart_rate_monitor.js
new file mode 100644
index 0000000..468e728
--- /dev/null
+++ b/web/heart_rate_monitor/heart_rate_monitor.js
@@ -0,0 +1,30 @@
+import {setupSimpleApp} from '../bumble.js';
+
+const logOutput = document.querySelector('#log-output');
+function logToOutput(message) {
+ console.log(message);
+ logOutput.value += message + '\n';
+}
+
+let heartRate = 60;
+const heartRateText = document.querySelector('#hr-value')
+
+function setHeartRate(newHeartRate) {
+ heartRate = newHeartRate;
+ heartRateText.innerHTML = heartRate;
+ app.set_heart_rate(heartRate);
+}
+
+// Setup the UI
+const bumbleControls = document.querySelector('#bumble-controls');
+document.querySelector('#hr-up-button').addEventListener('click', () => {
+ setHeartRate(heartRate + 1);
+})
+document.querySelector('#hr-down-button').addEventListener('click', () => {
+ setHeartRate(heartRate - 1);
+})
+
+// Setup the app
+const app = await setupSimpleApp('heart_rate_monitor.py', bumbleControls, logToOutput);
+logToOutput('Click the Bluetooth button to start');
+
diff --git a/web/heart_rate_monitor/heart_rate_monitor.py b/web/heart_rate_monitor/heart_rate_monitor.py
new file mode 100644
index 0000000..4a843b4
--- /dev/null
+++ b/web/heart_rate_monitor/heart_rate_monitor.py
@@ -0,0 +1,119 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import struct
+
+from bumble.core import AdvertisingData
+from bumble.device import Device
+from bumble.hci import HCI_Reset_Command
+from bumble.profiles.device_information_service import DeviceInformationService
+from bumble.profiles.heart_rate_service import HeartRateService
+from bumble.utils import AsyncRunner
+
+
+# -----------------------------------------------------------------------------
+class HeartRateMonitor:
+ def __init__(self, hci_source, hci_sink):
+ self.heart_rate = 60
+
+ self.device = Device.with_hci(
+ 'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
+ )
+
+ device_information_service = DeviceInformationService(
+ manufacturer_name='ACME',
+ model_number='HR-102',
+ serial_number='7654321',
+ hardware_revision='1.1.3',
+ software_revision='2.5.6',
+ system_id=(0x123456, 0x8877665544),
+ )
+
+ self.heart_rate_service = HeartRateService(
+ read_heart_rate_measurement=lambda _: HeartRateService.HeartRateMeasurement(
+ heart_rate=self.heart_rate,
+ sensor_contact_detected=True,
+ ),
+ body_sensor_location=HeartRateService.BodySensorLocation.WRIST,
+ reset_energy_expended=self.reset_energy_expended,
+ )
+
+ # Notify subscribers of the current value as soon as they subscribe
+ @self.heart_rate_service.heart_rate_measurement_characteristic.on(
+ 'subscription'
+ )
+ def on_subscription(_, notify_enabled, indicate_enabled):
+ if notify_enabled or indicate_enabled:
+ self.notify_heart_rate()
+
+ self.device.add_services([device_information_service, self.heart_rate_service])
+
+ self.device.advertising_data = bytes(
+ AdvertisingData(
+ [
+ (
+ AdvertisingData.FLAGS,
+ bytes(
+ [
+ AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
+ | AdvertisingData.BR_EDR_NOT_SUPPORTED_FLAG
+ ]
+ ),
+ ),
+ (
+ AdvertisingData.COMPLETE_LOCAL_NAME,
+ bytes('Bumble Heart', 'utf-8'),
+ ),
+ (
+ AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+ bytes(self.heart_rate_service.uuid),
+ ),
+ (AdvertisingData.APPEARANCE, struct.pack('<H', 0x0340)),
+ ]
+ )
+ )
+
+ async def start(self):
+ print('### Starting Monitor')
+ await self.device.power_on()
+ await self.device.start_advertising(auto_restart=True)
+ print('### Monitor started')
+
+ async def stop(self):
+ # TODO: replace this once a proper reset is implemented in the lib.
+ await self.device.host.send_command(HCI_Reset_Command())
+ await self.device.power_off()
+ print('### Monitor stopped')
+
+ def notify_heart_rate(self):
+ AsyncRunner.spawn(
+ self.device.notify_subscribers(
+ self.heart_rate_service.heart_rate_measurement_characteristic
+ )
+ )
+
+ def set_heart_rate(self, heart_rate):
+ self.heart_rate = heart_rate
+ self.notify_heart_rate()
+
+ def reset_energy_expended(self, _):
+ print('<<< Reset Energy Expended')
+
+
+# -----------------------------------------------------------------------------
+def main(hci_source, hci_sink):
+ return HeartRateMonitor(hci_source, hci_sink)
diff --git a/web/scanner/scanner.css b/web/scanner/scanner.css
new file mode 100644
index 0000000..99380ad
--- /dev/null
+++ b/web/scanner/scanner.css
@@ -0,0 +1,3 @@
+body {
+ font-family: monospace;
+}
diff --git a/web/scanner/scanner.html b/web/scanner/scanner.html
index 12c65dd..f698b01 100644
--- a/web/scanner/scanner.html
+++ b/web/scanner/scanner.html
@@ -1,129 +1,21 @@
+<!DOCTYPE html>
<html>
-
<head>
- <script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
- <style>
- body {
- font-family: monospace;
- }
-
- table, th, td {
- padding: 2px;
- white-space: pre;
- border: 1px solid black;
- border-collapse: collapse;
- }
+ <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
+ <link rel="stylesheet" href="scanner.css">
+ <script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
+ <script type="module" src="../ui.js"></script>
+ <script type="module" src="scanner.js"></script>
+</style>
</style>
</head>
-
<body>
- <button id="connectButton" disabled>Connect</button>
- <br />
- <br />
- <div>Log Output</div><br>
- <textarea id="output" style="width: 100%;" rows="10" disabled></textarea>
- <div id="scanTableContainer"><table></table></div>
-
- <script type="module">
- import { loadBumble, connectWebSocketTransport } from "../bumble.js"
- let pyodide;
- let output;
-
- function logToOutput(s) {
- output.value += s + "\n";
- console.log(s);
- }
-
- async function run() {
- const params = (new URL(document.location)).searchParams;
- const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
-
- try {
- // Create a WebSocket HCI transport
- let transport
- try {
- transport = await connectWebSocketTransport(pyodide, hciWsUrl);
- } catch (error) {
- logToOutput(error);
- return;
- }
-
- // Run the scanner example
- const script = await (await fetch("scanner.py")).text();
- await pyodide.runPythonAsync(script);
- const pythonMain = pyodide.globals.get("main");
- logToOutput("Starting scanner...");
- await pythonMain(transport.packet_source, transport.packet_sink, onScanUpdate);
- logToOutput("Scanner running");
- } catch (err) {
- logToOutput(err);
- }
- }
-
- function onScanUpdate(scanEntries) {
- scanEntries = scanEntries.toJs();
-
- const scanTable = document.createElement("table");
-
- const tableHeader = document.createElement("tr");
- for (const name of ["Address", "Address Type", "RSSI", "Data"]) {
- const header = document.createElement("th");
- header.appendChild(document.createTextNode(name));
- tableHeader.appendChild(header);
- }
- scanTable.appendChild(tableHeader);
-
- scanEntries.forEach(entry => {
- const row = document.createElement("tr");
-
- const addressCell = document.createElement("td");
- addressCell.appendChild(document.createTextNode(entry.address));
- row.appendChild(addressCell);
+ <script type="module">
+ import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
+ </script>
- const addressTypeCell = document.createElement("td");
- addressTypeCell.appendChild(document.createTextNode(entry.address_type));
- row.appendChild(addressTypeCell);
-
- const rssiCell = document.createElement("td");
- rssiCell.appendChild(document.createTextNode(entry.rssi));
- row.appendChild(rssiCell);
-
- const dataCell = document.createElement("td");
- dataCell.appendChild(document.createTextNode(entry.data));
- row.appendChild(dataCell);
-
- scanTable.appendChild(row);
- });
-
- const scanTableContainer = document.getElementById("scanTableContainer");
- scanTableContainer.replaceChild(scanTable, scanTableContainer.firstChild);
-
- return true;
- }
-
- async function main() {
- output = document.getElementById("output");
-
- // Load pyodide
- logToOutput("Loading Pyodide");
- pyodide = await loadPyodide();
-
- // Load Bumble
- logToOutput("Loading Bumble");
- const params = (new URL(document.location)).searchParams;
- const bumblePackage = params.get("package") || "bumble";
- await loadBumble(pyodide, bumblePackage);
-
- logToOutput("Ready!")
-
- // Enable the Connect button
- const connectButton = document.getElementById("connectButton");
- connectButton.disabled = false
- connectButton.addEventListener("click", run)
- }
-
- main();
- </script>
+ <bumble-controls id="bumble-controls"></bumble-controls><hr>
+ <textarea id="log-output" style="width: 100%;" rows="10" disabled></textarea><hr>
+ <scan-list id="scan-list"></scan-list>
</body>
-
-</html> \ No newline at end of file
+</html>
diff --git a/web/scanner/scanner.js b/web/scanner/scanner.js
new file mode 100644
index 0000000..34d5784
--- /dev/null
+++ b/web/scanner/scanner.js
@@ -0,0 +1,68 @@
+import {LitElement, html, css} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
+import {setupSimpleApp} from '../bumble.js';
+
+ class ScanList extends LitElement {
+ static properties = {
+ listItems: {state: true},
+ };
+
+ static styles = css`
+ table, th, td {
+ padding: 2px;
+ white-space: pre;
+ border: 1px solid black;
+ border-collapse: collapse;
+ }
+ `;
+
+ constructor() {
+ super();
+ this.listItems = [];
+ }
+
+ render() {
+ if (this.listItems.length === 0) {
+ return '';
+ }
+ return html`
+ <table>
+ <thead>
+ <tr>
+ ${Object.keys(this.listItems[0]).map(i => html`<th>${i}</th>`)}
+ </tr>
+ </thead>
+ <tbody>
+ ${this.listItems.map(i => html`
+ <tr>
+ ${Object.keys(i).map(key => html`<td>${i[key]}</td>`)}
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `;
+ }
+}
+customElements.define('scan-list', ScanList);
+
+const logOutput = document.querySelector('#log-output');
+function logToOutput(message) {
+ console.log(message);
+ logOutput.value += message + '\n';
+}
+
+function onUpdate(scanResults) {
+ const items = scanResults.toJs({create_proxies : false}).map(entry => (
+ { address: entry.address, address_type: entry.address_type, rssi: entry.rssi, data: entry.data }
+ ));
+ scanResults.destroy();
+ scanList.listItems = items;
+}
+
+// Setup the UI
+const scanList = document.querySelector('#scan-list');
+const bumbleControls = document.querySelector('#bumble-controls');
+
+// Setup the app
+const app = await setupSimpleApp('scanner.py', bumbleControls, logToOutput);
+app.on('update', onUpdate);
+logToOutput('Click the Bluetooth button to start');
diff --git a/web/scanner/scanner.py b/web/scanner/scanner.py
index c0fc456..9ff6aba 100644
--- a/web/scanner/scanner.py
+++ b/web/scanner/scanner.py
@@ -15,39 +15,59 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
-import time
-
from bumble.device import Device
+from bumble.hci import HCI_Reset_Command
# -----------------------------------------------------------------------------
-class ScanEntry:
- def __init__(self, advertisement):
- self.address = advertisement.address.to_string(False)
- self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[
- advertisement.address.address_type
- ]
- self.rssi = advertisement.rssi
- self.data = advertisement.data.to_string("\n")
+class Scanner:
+ class ScanEntry:
+ def __init__(self, advertisement):
+ self.address = advertisement.address.to_string(False)
+ self.address_type = (
+ 'Public',
+ 'Random',
+ 'Public Identity',
+ 'Random Identity',
+ )[advertisement.address.address_type]
+ self.rssi = advertisement.rssi
+ self.data = advertisement.data.to_string('\n')
+ def __init__(self, hci_source, hci_sink):
+ super().__init__()
+ self.device = Device.with_hci(
+ 'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
+ )
+ self.scan_entries = {}
+ self.listeners = {}
+ self.device.on('advertisement', self.on_advertisement)
-# -----------------------------------------------------------------------------
-class ScannerListener(Device.Listener):
- def __init__(self, callback):
- self.callback = callback
- self.entries = {}
+ async def start(self):
+ print('### Starting Scanner')
+ self.scan_entries = {}
+ self.emit_update()
+ await self.device.power_on()
+ await self.device.start_scanning()
+ print('### Scanner started')
+
+ async def stop(self):
+ # TODO: replace this once a proper reset is implemented in the lib.
+ await self.device.host.send_command(HCI_Reset_Command())
+ await self.device.power_off()
+ print('### Scanner stopped')
+
+ def emit_update(self):
+ if listener := self.listeners.get('update'):
+ listener(list(self.scan_entries.values()))
+
+ def on(self, event_name, listener):
+ self.listeners[event_name] = listener
def on_advertisement(self, advertisement):
- self.entries[advertisement.address] = ScanEntry(advertisement)
- self.callback(list(self.entries.values()))
+ self.scan_entries[advertisement.address] = self.ScanEntry(advertisement)
+ self.emit_update()
# -----------------------------------------------------------------------------
-async def main(hci_source, hci_sink, callback):
- print('### Starting Scanner')
- device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
- device.listener = ScannerListener(callback)
- await device.power_on()
- await device.start_scanning()
-
- print('### Scanner started')
+def main(hci_source, hci_sink):
+ return Scanner(hci_source, hci_sink)
diff --git a/web/speaker/speaker.css b/web/speaker/speaker.css
index 988392a..9586054 100644
--- a/web/speaker/speaker.css
+++ b/web/speaker/speaker.css
@@ -11,7 +11,16 @@ body, h1, h2, h3, h4, h5, h6 {
border: none;
border-radius: 4px;
padding: 8px;
- display: inline-block;
+ display: none;
+ margin: 4px;
+}
+
+#progressText {
+ background-color: rgb(179, 208, 146);
+ border: none;
+ border-radius: 4px;
+ padding: 8px;
+ display: none;
margin: 4px;
}
diff --git a/web/speaker/speaker.html b/web/speaker/speaker.html
index a20f084..1a9183d 100644
--- a/web/speaker/speaker.html
+++ b/web/speaker/speaker.html
@@ -2,13 +2,14 @@
<html>
<head>
<title>Bumble Speaker</title>
- <script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
- <script type="module" src="speaker.js"></script>
+ <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="speaker.css">
+ <script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
+ <script type="module" src="speaker.js"></script>
+ <script type="module" src="../ui.js"></script>
</head>
<body>
<h1><img src="logo.svg" width=100 height=100 style="vertical-align:middle" alt=""/>Bumble Virtual Speaker</h1>
- <div id="errorText"></div>
<div id="speaker">
<table><tr>
<td>
@@ -25,7 +26,8 @@
<span id="streamStateText">IDLE</span>
<span id="connectionStateText">NOT CONNECTED</span>
<div id="controlsDiv">
- <button id="audioOnButton">Audio On</button>
+ <bumble-controls id="bumble-controls"></bumble-controls>
+ <button id="audioOnButton" class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>volume_up</button>
</div>
<canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
<audio id="audio"></audio>
diff --git a/web/speaker/speaker.js b/web/speaker/speaker.js
index b94180f..12189a4 100644
--- a/web/speaker/speaker.js
+++ b/web/speaker/speaker.js
@@ -1,4 +1,4 @@
-import { loadBumble, connectWebSocketTransport } from "../bumble.js";
+import {setupSimpleApp} from '../bumble.js';
(function () {
'use strict';
@@ -8,7 +8,6 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
let bytesReceivedText;
let streamStateText;
let connectionStateText;
- let errorText;
let audioOnButton;
let mediaSource;
let sourceBuffer;
@@ -19,15 +18,14 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
let audioFrequencyData;
let packetsReceived = 0;
let bytesReceived = 0;
- let audioState = "stopped";
- let streamState = "IDLE";
+ let audioState = 'stopped';
+ let streamState = 'IDLE';
let fftCanvas;
let fftCanvasContext;
let bandwidthCanvas;
let bandwidthCanvasContext;
let bandwidthBinCount;
let bandwidthBins = [];
- let pyodide;
const FFT_WIDTH = 800;
const FFT_HEIGHT = 256;
@@ -44,18 +42,16 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
}
function initUI() {
- audioOnButton = document.getElementById("audioOnButton");
- codecText = document.getElementById("codecText");
- packetsReceivedText = document.getElementById("packetsReceivedText");
- bytesReceivedText = document.getElementById("bytesReceivedText");
- streamStateText = document.getElementById("streamStateText");
- errorText = document.getElementById("errorText");
- connectionStateText = document.getElementById("connectionStateText");
+ audioOnButton = document.getElementById('audioOnButton');
+ codecText = document.getElementById('codecText');
+ packetsReceivedText = document.getElementById('packetsReceivedText');
+ bytesReceivedText = document.getElementById('bytesReceivedText');
+ streamStateText = document.getElementById('streamStateText');
+ connectionStateText = document.getElementById('connectionStateText');
- audioOnButton.onclick = () => startAudio();
+ audioOnButton.onclick = startAudio;
- codecText.innerText = "AAC";
- setErrorText("");
+ codecText.innerText = 'AAC';
requestAnimationFrame(onAnimationFrame);
}
@@ -68,62 +64,36 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
}
function initAudioElement() {
- audioElement = document.getElementById("audio");
+ audioElement = document.getElementById('audio');
audioElement.src = URL.createObjectURL(mediaSource);
// audioElement.controls = true;
}
function initAnalyzer() {
- fftCanvas = document.getElementById("fftCanvas");
+ fftCanvas = document.getElementById('fftCanvas');
fftCanvas.width = FFT_WIDTH
fftCanvas.height = FFT_HEIGHT
fftCanvasContext = fftCanvas.getContext('2d');
- fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
+ fftCanvasContext.fillStyle = 'rgb(0, 0, 0)';
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
- bandwidthCanvas = document.getElementById("bandwidthCanvas");
+ bandwidthCanvas = document.getElementById('bandwidthCanvas');
bandwidthCanvas.width = BANDWIDTH_WIDTH
bandwidthCanvas.height = BANDWIDTH_HEIGHT
bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
- bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
+ bandwidthCanvasContext.fillStyle = 'rgb(255, 255, 255)';
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
}
async function initBumble() {
- // Load pyodide
- console.log("Loading Pyodide");
- pyodide = await loadPyodide();
-
- // Load Bumble
- console.log("Loading Bumble");
- const params = (new URL(document.location)).searchParams;
- const bumblePackage = params.get("package") || "bumble";
- await loadBumble(pyodide, bumblePackage);
-
- console.log("Ready!")
-
- const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
- try {
- // Create a WebSocket HCI transport
- let transport
- try {
- transport = await connectWebSocketTransport(pyodide, hciWsUrl);
- } catch (error) {
- console.error(error);
- setErrorText(error);
- return;
- }
-
- // Run the scanner example
- const script = await (await fetch("speaker.py")).text();
- await pyodide.runPythonAsync(script);
- const pythonMain = pyodide.globals.get("main");
- console.log("Starting speaker...");
- await pythonMain(transport.packet_source, transport.packet_sink, onEvent);
- console.log("Speaker running");
- } catch (err) {
- console.log(err);
- }
+ const bumbleControls = document.querySelector('#bumble-controls');
+ const app = await setupSimpleApp('speaker.py', bumbleControls, console.log);
+ app.on('start', onStart);
+ app.on('stop', onStop);
+ app.on('suspend', onSuspend);
+ app.on('connection', onConnection);
+ app.on('disconnection', onDisconnection);
+ app.on('audio', onAudio);
}
function startAnalyzer() {
@@ -144,15 +114,6 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
bandwidthBins = [];
}
- function setErrorText(message) {
- errorText.innerText = message;
- if (message.length == 0) {
- errorText.style.display = "none";
- } else {
- errorText.style.display = "inline-block";
- }
- }
-
function setStreamState(state) {
streamState = state;
streamStateText.innerText = streamState;
@@ -162,7 +123,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
// FFT
if (audioAnalyzer !== undefined) {
audioAnalyzer.getByteFrequencyData(audioFrequencyData);
- fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
+ fftCanvasContext.fillStyle = 'rgb(0, 0, 0)';
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
const barCount = audioFrequencyBinCount;
const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
@@ -174,7 +135,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
}
// Bandwidth
- bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
+ bandwidthCanvasContext.fillStyle = 'rgb(255, 255, 255)';
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
for (let t = 0; t < bandwidthBins.length; t++) {
@@ -188,7 +149,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
function onMediaSourceOpen() {
console.log(this.readyState);
- sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
+ sourceBuffer = mediaSource.addSourceBuffer('audio/aac');
}
function onMediaSourceClose() {
@@ -201,41 +162,30 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
async function startAudio() {
try {
- console.log("starting audio...");
+ console.log('starting audio...');
audioOnButton.disabled = true;
- audioState = "starting";
+ audioState = 'starting';
await audioElement.play();
- console.log("audio started");
- audioState = "playing";
+ console.log('audio started');
+ audioState = 'playing';
startAnalyzer();
} catch (error) {
console.error(`play failed: ${error}`);
- audioState = "stopped";
+ audioState = 'stopped';
audioOnButton.disabled = false;
}
}
- async function onEvent(name, params) {
- // Dispatch the message.
- const handlerName = `on${name.charAt(0).toUpperCase()}${name.slice(1)}`
- const handler = eventHandlers[handlerName];
- if (handler !== undefined) {
- handler(params);
- } else {
- console.warn(`unhandled event: ${name}`)
- }
- }
-
function onStart() {
- setStreamState("STARTED");
+ setStreamState('STARTED');
}
function onStop() {
- setStreamState("STOPPED");
+ setStreamState('STOPPED');
}
function onSuspend() {
- setStreamState("SUSPENDED");
+ setStreamState('SUSPENDED');
}
function onConnection(params) {
@@ -243,13 +193,13 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
}
function onDisconnection(params) {
- connectionStateText.innerText = "DISCONNECTED";
+ connectionStateText.innerText = 'DISCONNECTED';
}
function onAudio(python_packet) {
const packet = python_packet.toJs({create_proxies : false});
python_packet.destroy();
- if (audioState != "stopped") {
+ if (audioState != 'stopped') {
// Queue the audio packet.
sourceBuffer.appendBuffer(packet);
}
@@ -265,25 +215,7 @@ import { loadBumble, connectWebSocketTransport } from "../bumble.js";
}
}
- function onKeystoreupdate() {
- // Sync the FS
- pyodide.FS.syncfs(() => {
- console.log("FS synced out")
- });
- }
-
- const eventHandlers = {
- onStart,
- onStop,
- onSuspend,
- onConnection,
- onDisconnection,
- onAudio,
- onKeystoreupdate
- }
-
window.onload = (event) => {
init();
}
-
}()); \ No newline at end of file
diff --git a/web/speaker/speaker.py b/web/speaker/speaker.py
index d9488ce..2b8ce00 100644
--- a/web/speaker/speaker.py
+++ b/web/speaker/speaker.py
@@ -47,6 +47,7 @@ from bumble.a2dp import (
)
from bumble.utils import AsyncRunner
from bumble.codecs import AacAudioRtpPacket
+from bumble.hci import HCI_Reset_Command
# -----------------------------------------------------------------------------
@@ -95,15 +96,14 @@ class Speaker:
STARTED = 2
SUSPENDED = 3
- def __init__(self, hci_source, hci_sink, emit_event, codec, discover):
+ def __init__(self, hci_source, hci_sink, codec):
self.hci_source = hci_source
self.hci_sink = hci_sink
- self.emit_event = emit_event
+ self.js_listeners = {}
self.codec = codec
- self.discover = discover
self.device = None
self.connection = None
- self.listener = None
+ self.avdtp_listener = None
self.packets_received = 0
self.bytes_received = 0
self.stream_state = Speaker.StreamState.IDLE
@@ -164,7 +164,7 @@ class Speaker:
def on_key_store_update(self):
print("Key Store updated")
- self.emit_event('keystoreupdate', None)
+ self.emit('key_store_update')
def on_bluetooth_connection(self, connection):
print(f'Connection: {connection}')
@@ -172,15 +172,12 @@ class Speaker:
connection.on('disconnection', self.on_bluetooth_disconnection)
peer_name = '' if connection.peer_name is None else connection.peer_name
peer_address = connection.peer_address.to_string(False)
- self.emit_event(
- 'connection', {'peer_name': peer_name, 'peer_address': peer_address}
- )
+ self.emit('connection', {'peer_name': peer_name, 'peer_address': peer_address})
def on_bluetooth_disconnection(self, reason):
print(f'Disconnection ({reason})')
self.connection = None
- AsyncRunner.spawn(self.advertise())
- self.emit_event('disconnection', None)
+ self.emit('disconnection', None)
def on_avdtp_connection(self, protocol):
print('Audio Stream Open')
@@ -198,27 +195,23 @@ class Speaker:
# Listen for close events
protocol.on('close', self.on_avdtp_close)
- # Discover all endpoints on the remote device is requested
- if self.discover:
- AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
-
def on_avdtp_close(self):
print("Audio Stream Closed")
def on_sink_start(self):
print("Sink Started")
self.stream_state = self.StreamState.STARTED
- self.emit_event('start', None)
+ self.emit('start', None)
def on_sink_stop(self):
print("Sink Stopped")
self.stream_state = self.StreamState.STOPPED
- self.emit_event('stop', None)
+ self.emit('stop', None)
def on_sink_suspend(self):
print("Sink Suspended")
self.stream_state = self.StreamState.SUSPENDED
- self.emit_event('suspend', None)
+ self.emit('suspend', None)
def on_sink_configuration(self, config):
print("Sink Configuration:")
@@ -234,11 +227,7 @@ class Speaker:
def on_rtp_packet(self, packet):
self.packets_received += 1
self.bytes_received += len(packet.payload)
- self.emit_event("audio", self.audio_extractor.extract_audio(packet))
-
- async def advertise(self):
- await self.device.set_discoverable(True)
- await self.device.set_connectable(True)
+ self.emit("audio", self.audio_extractor.extract_audio(packet))
async def connect(self, address):
# Connect to the source
@@ -257,7 +246,7 @@ class Speaker:
print('*** Encryption on')
protocol = await Protocol.connect(connection)
- self.listener.set_server(connection, protocol)
+ self.avdtp_listener.set_server(connection, protocol)
self.on_avdtp_connection(protocol)
async def discover_remote_endpoints(self, protocol):
@@ -266,6 +255,13 @@ class Speaker:
for endpoint in endpoints:
print('@@@', endpoint)
+ def on(self, event_name, listener):
+ self.js_listeners[event_name] = listener
+
+ def emit(self, event_name, event=None):
+ if listener := self.js_listeners.get(event_name):
+ listener(event)
+
async def run(self, connect_address):
# Create a device
device_config = DeviceConfiguration()
@@ -296,8 +292,8 @@ class Speaker:
self.device.on('key_store_update', self.on_key_store_update)
# Create a listener to wait for AVDTP connections
- self.listener = Listener.for_device(self.device)
- self.listener.on('connection', self.on_avdtp_connection)
+ self.avdtp_listener = Listener.for_device(self.device)
+ self.avdtp_listener.on('connection', self.on_avdtp_connection)
print(f'Speaker ready to play, codec={self.codec}')
@@ -309,13 +305,19 @@ class Speaker:
print("Connection timed out")
return
else:
- # Start being discoverable and connectable
+ # We'll wait for a connection
print("Waiting for connection...")
- await self.advertise()
+
+ async def start(self):
+ await self.run(None)
+
+ async def stop(self):
+ # TODO: replace this once a proper reset is implemented in the lib.
+ await self.device.host.send_command(HCI_Reset_Command())
+ await self.device.power_off()
+ print('Speaker stopped')
# -----------------------------------------------------------------------------
-async def main(hci_source, hci_sink, emit_event):
- # logging.basicConfig(level='DEBUG')
- speaker = Speaker(hci_source, hci_sink, emit_event, "aac", False)
- await speaker.run(None)
+def main(hci_source, hci_sink):
+ return Speaker(hci_source, hci_sink, "aac")
diff --git a/web/ui.js b/web/ui.js
new file mode 100644
index 0000000..6e8d877
--- /dev/null
+++ b/web/ui.js
@@ -0,0 +1,102 @@
+import {LitElement, html} from 'https://cdn.jsdelivr.net/gh/lit/dist@2/core/lit-core.min.js';
+
+class BumbleControls extends LitElement {
+ constructor() {
+ super();
+ this.bumbleLoaded = false;
+ this.connected = false;
+ }
+
+ render() {
+ return html`
+ <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
+ <dialog id="settings-dialog" @close=${this.onSettingsDialogClose} style="font-family:sans-serif">
+ <p>WebSocket URL for HCI transport</p>
+ <form>
+ <input id="settings-hci-url-input" type="text" size="50"></input>
+ <button value="cancel" formmethod="dialog">Cancel</button>
+ <button @click=${this.saveSettings}>Save</button>
+ </form>
+ </dialog>
+ <button @click=${this.openSettingsDialog} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>settings</button>
+ <button @click=${this.connectBluetooth} ?disabled=${!this.canConnect()} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>bluetooth</button>
+ <button @click=${this.stop} ?disabled=${!this.connected} class="mdc-icon-button material-icons"><div class="mdc-icon-button__ripple"></div>stop</button>
+ `
+ }
+
+ get settingsHciUrlInput() {
+ return this.renderRoot.querySelector('#settings-hci-url-input');
+ }
+
+ get settingsDialog() {
+ return this.renderRoot.querySelector('#settings-dialog');
+ }
+
+ canConnect() {
+ return this.bumbleLoaded && !this.connected && this.getHciUrl();
+ }
+
+ getHciUrl() {
+ // Look for a URL parameter setting first.
+ const params = (new URL(document.location)).searchParams;
+ let hciWsUrl = params.get("hci");
+ if (hciWsUrl) {
+ return hciWsUrl;
+ }
+
+ // Try to load the setting from storage.
+ hciWsUrl = localStorage.getItem("hciWsUrl");
+ if (hciWsUrl) {
+ return hciWsUrl;
+ }
+
+ // Finally, default to nothing.
+ return null;
+ }
+
+ openSettingsDialog() {
+ const hciUrl = this.getHciUrl();
+ if (hciUrl) {
+ this.settingsHciUrlInput.value = hciUrl;
+ } else {
+ // Start with default, assuming port 7681.
+ this.settingsHciUrlInput.value = "ws://localhost:7681/v1/websocket/bt"
+ }
+ this.settingsDialog.showModal();
+ }
+
+ onSettingsDialogClose() {
+ if (this.settingsDialog.returnValue === "cancel") {
+ return;
+ }
+ if (this.settingsHciUrlInput.value) {
+ localStorage.setItem("hciWsUrl", this.settingsHciUrlInput.value);
+ } else {
+ localStorage.removeItem("hciWsUrl");
+ }
+
+ this.requestUpdate();
+ }
+
+ saveSettings(event) {
+ event.preventDefault();
+ this.settingsDialog.close(this.settingsHciUrlInput.value);
+ }
+
+ async connectBluetooth() {
+ this.connected = await this.connector(this.getHciUrl());
+ this.requestUpdate();
+ }
+
+ async stop() {
+ await this.stopper();
+ this.connected = false;
+ this.requestUpdate();
+ }
+
+ onBumbleLoaded() {
+ this.bumbleLoaded = true;
+ this.requestUpdate();
+ }
+}
+customElements.define('bumble-controls', BumbleControls);