Compare commits
164 Commits
uael/pando
...
gbg/loopba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00cd8fbdd0 | ||
|
|
c48e3f5e9c | ||
|
|
d6bbc1145a | ||
|
|
e2fec67bd9 | ||
|
|
88cb3b2a4d | ||
|
|
9ebb03be46 | ||
|
|
80d84af76c | ||
|
|
8f4721758f | ||
|
|
8864af4acd | ||
|
|
8980fb8cc7 | ||
|
|
2c5f3472a9 | ||
|
|
f18277ac78 | ||
|
|
09e5ea5dec | ||
|
|
6810865670 | ||
|
|
3e9e06a02c | ||
|
|
ccd12f6591 | ||
|
|
f9a7843f7e | ||
|
|
210c334db7 | ||
|
|
f297cdfcce | ||
|
|
5b536d00ab | ||
|
|
b4af46ebd5 | ||
|
|
c08da3193e | ||
|
|
f2925ca647 | ||
|
|
fd4d68e5c0 | ||
|
|
5d83deffa4 | ||
|
|
2878cca478 | ||
|
|
53934716db | ||
|
|
d885d45824 | ||
|
|
b90d0f8710 | ||
|
|
8ccfc90fe6 | ||
|
|
92aa7e9e2a | ||
|
|
afc6d19e04 | ||
|
|
c05f073b33 | ||
|
|
2b4c2a22f4 | ||
|
|
47fe93a148 | ||
|
|
6139ca8045 | ||
|
|
87c76a4a0e | ||
|
|
f7b66db873 | ||
|
|
0b314bd7f7 | ||
|
|
9da2e32ad7 | ||
|
|
93c0875740 | ||
|
|
a286700239 | ||
|
|
98ed772e8a | ||
|
|
f0b55a4f97 | ||
|
|
b74503d345 | ||
|
|
f911163e49 | ||
|
|
b083cc99ad | ||
|
|
d35643524e | ||
|
|
62a8ced447 | ||
|
|
085f163c92 | ||
|
|
81a6b1e097 | ||
|
|
dd090c9e6b | ||
|
|
11faa48422 | ||
|
|
55596176c2 | ||
|
|
4d6822d312 | ||
|
|
985c365e6d | ||
|
|
af57762227 | ||
|
|
3575f9030e | ||
|
|
698d947d85 | ||
|
|
ff6528d2bf | ||
|
|
72ac75a98d | ||
|
|
5e3ecb74e4 | ||
|
|
c59be293c8 | ||
|
|
88b4cbdf1a | ||
|
|
d6afbc6f4e | ||
|
|
fc90de3e7b | ||
|
|
847c2ef114 | ||
|
|
a0bf0c1f4d | ||
|
|
8400ff0802 | ||
|
|
0ed6aa230b | ||
|
|
6d22ed80ec | ||
|
|
72d5360af9 | ||
|
|
ac3961e763 | ||
|
|
843466c822 | ||
|
|
8385035400 | ||
|
|
3adcc8be09 | ||
|
|
c853d56302 | ||
|
|
dc97be5b35 | ||
|
|
73dbdfff9f | ||
|
|
dff14e1258 | ||
|
|
10a3833893 | ||
|
|
247cb89332 | ||
|
|
3fc71a0266 | ||
|
|
392dcc3a05 | ||
|
|
f27015d1b7 | ||
|
|
86a19b41aa | ||
|
|
320164d476 | ||
|
|
40ae661ee5 | ||
|
|
ffb3eca68b | ||
|
|
c5def93bb8 | ||
|
|
a9c4c5833d | ||
|
|
58c9c4f590 | ||
|
|
24524d88cb | ||
|
|
b8849ab311 | ||
|
|
f3cd8f8ed0 | ||
|
|
2b26de3f3a | ||
|
|
0149c4c212 | ||
|
|
f2ed898784 | ||
|
|
464a476f9f | ||
|
|
e85d067fb5 | ||
|
|
7eb493990f | ||
|
|
04d5bf3afc | ||
|
|
403a13e4c6 | ||
|
|
ad0f035df5 | ||
|
|
a13e193d3b | ||
|
|
28a1a5ebc2 | ||
|
|
6310dc777f | ||
|
|
07f71fc895 | ||
|
|
f47b9178ad | ||
|
|
863de18877 | ||
|
|
4f399249bd | ||
|
|
f0e5cdee1a | ||
|
|
7bc7d0f5af | ||
|
|
a65a215fd7 | ||
|
|
80d34a226d | ||
|
|
a9628f73e3 | ||
|
|
9324237828 | ||
|
|
d1033c018a | ||
|
|
0f29052ade | ||
|
|
0578e84586 | ||
|
|
6ab41c466f | ||
|
|
98a1093ebf | ||
|
|
caf04373f3 | ||
|
|
d4e8526766 | ||
|
|
515b83a8c7 | ||
|
|
9bf2e03354 | ||
|
|
dc18595c8a | ||
|
|
488bcfe9c6 | ||
|
|
2900b93bb3 | ||
|
|
284cc8a321 | ||
|
|
3dc2e4036c | ||
|
|
268f6b0d51 | ||
|
|
46239b321b | ||
|
|
8a536cd522 | ||
|
|
f9f5d7ccbd | ||
|
|
d6cefdff8e | ||
|
|
dc410b14c4 | ||
|
|
4c49ef9403 | ||
|
|
ba85dcbda5 | ||
|
|
e08c84dd20 | ||
|
|
8b46136703 | ||
|
|
9c7089c8ff | ||
|
|
aac8d89cd0 | ||
|
|
24e75bfeab | ||
|
|
42868b08d3 | ||
|
|
19b61d9ac0 | ||
|
|
db2a2e2bb9 | ||
|
|
e1fdb12647 | ||
|
|
a8ec1b0949 | ||
|
|
2e30b2de77 | ||
|
|
7e407ccae1 | ||
|
|
0667e83919 | ||
|
|
1a6c9a4d04 | ||
|
|
14f5b912ad | ||
|
|
46d6242171 | ||
|
|
753b966148 | ||
|
|
5a307c19b8 | ||
|
|
2cd4f84800 | ||
|
|
4ae612090b | ||
|
|
59d7717963 | ||
|
|
1004f10384 | ||
|
|
1051648ffb | ||
|
|
45edcafb06 | ||
|
|
9f0bcc131f |
8
.github/workflows/python-build-test.yml
vendored
@@ -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
|
||||
|
||||
2
.gitignore
vendored
@@ -10,3 +10,5 @@ __pycache__
|
||||
bumble/_version.py
|
||||
.vscode/launch.json
|
||||
/.idea
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
7
.vscode/settings.json
vendored
@@ -21,7 +21,10 @@
|
||||
"cccds",
|
||||
"cmac",
|
||||
"CONNECTIONLESS",
|
||||
"csip",
|
||||
"csis",
|
||||
"csrcs",
|
||||
"CVSD",
|
||||
"datagram",
|
||||
"DATALINK",
|
||||
"delayreport",
|
||||
@@ -29,6 +32,7 @@
|
||||
"deregistration",
|
||||
"dhkey",
|
||||
"diversifier",
|
||||
"endianness",
|
||||
"Fitbit",
|
||||
"GATTLINK",
|
||||
"HANDSFREE",
|
||||
@@ -38,12 +42,14 @@
|
||||
"libc",
|
||||
"libusb",
|
||||
"MITM",
|
||||
"MSBC",
|
||||
"NDIS",
|
||||
"netsim",
|
||||
"NONBLOCK",
|
||||
"NONCONN",
|
||||
"OXIMETER",
|
||||
"popleft",
|
||||
"PRAND",
|
||||
"protobuf",
|
||||
"psms",
|
||||
"pyee",
|
||||
@@ -55,6 +61,7 @@
|
||||
"SEID",
|
||||
"seids",
|
||||
"SERV",
|
||||
"SIRK",
|
||||
"ssrc",
|
||||
"strerror",
|
||||
"subband",
|
||||
|
||||
703
apps/bench.py
63
apps/ble_rpa_tool.py
Normal file
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -32,16 +32,22 @@ from bumble.hci import (
|
||||
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,
|
||||
)
|
||||
from bumble.host import Host
|
||||
from bumble.transport import open_transport_or_link
|
||||
@@ -57,7 +63,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 +84,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,11 +123,48 @@ 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))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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):
|
||||
print('<<< connecting to HCI...')
|
||||
@@ -154,6 +197,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'))
|
||||
|
||||
200
apps/controller_loopback.py
Normal file
@@ -0,0 +1,200 @@
|
||||
# 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.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=int,
|
||||
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()
|
||||
@@ -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
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
77
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,
|
||||
|
||||
151
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 (
|
||||
@@ -239,24 +243,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 +272,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 +293,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 +314,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 +348,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 +367,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 +403,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 +416,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 +433,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 +464,7 @@ class AacMediaCodecInformation(
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
object_types = [
|
||||
'MPEG_2_AAC_LC',
|
||||
'MPEG_4_AAC_LC',
|
||||
@@ -474,26 +489,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 +521,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 +553,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 +602,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
|
||||
|
||||
@@ -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 write(self, connection, value: bytes) -> None:
|
||||
...
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -250,15 +250,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()
|
||||
|
||||
|
||||
@@ -134,12 +134,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
|
||||
@@ -914,6 +916,19 @@ class Controller:
|
||||
'''
|
||||
return bytes([HCI_SUCCESS]) + self.lmp_features
|
||||
|
||||
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):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
|
||||
@@ -1000,6 +1015,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
|
||||
@@ -1260,3 +1278,15 @@ class Controller:
|
||||
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_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)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import struct
|
||||
from typing import List, Optional, Tuple, Union, cast, Dict
|
||||
|
||||
@@ -1051,3 +1052,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
|
||||
|
||||
158
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 xor(x, y):
|
||||
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: 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))
|
||||
|
||||
899
bumble/device.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
|
||||
from .common import Driver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.host import Host
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -32,40 +37,31 @@ from . import rtk
|
||||
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}
|
||||
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
|
||||
|
||||
|
||||
45
bumble/drivers/common.py
Normal file
@@ -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."""
|
||||
@@ -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
|
||||
)
|
||||
|
||||
239
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 typing import (
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Union,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from .colors import color
|
||||
from .core import UUID, get_dict_key_by_value
|
||||
from .att import Attribute
|
||||
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')
|
||||
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')
|
||||
|
||||
# Types
|
||||
# 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):
|
||||
self.wrapped_characteristic = characteristic
|
||||
self.subscribers = {} # Map from subscriber to proxy subscriber
|
||||
read_value: Callable
|
||||
write_value: Callable
|
||||
|
||||
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:
|
||||
def __init__(self, characteristic: Union[Characteristic, AttributeProxy]):
|
||||
self.wrapped_characteristic = characteristic
|
||||
self.subscribers: Dict[
|
||||
Callable, Callable
|
||||
] = {} # Map from subscriber to proxy subscriber
|
||||
|
||||
if isinstance(characteristic, Characteristic):
|
||||
self.read_value = self.read_encoded_value
|
||||
self.write_value = self.write_encoded_value
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
'''
|
||||
|
||||
833
bumble/hci.py
@@ -15,30 +15,41 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, MutableMapping
|
||||
from typing import cast, Any
|
||||
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 avdtp
|
||||
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
|
||||
from bumble.rfcomm import RFCOMM_Frame, RFCOMM_PSM
|
||||
from bumble.sdp import SDP_PDU, SDP_PSM
|
||||
from bumble import crypto
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -50,23 +61,25 @@ logger = logging.getLogger(__name__)
|
||||
PSM_NAMES = {
|
||||
RFCOMM_PSM: 'RFCOMM',
|
||||
SDP_PSM: 'SDP',
|
||||
AVDTP_PSM: 'AVDTP'
|
||||
# TODO: add more PSM values
|
||||
avdtp.AVDTP_PSM: 'AVDTP',
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PacketTracer:
|
||||
class AclStream:
|
||||
def __init__(self, analyzer):
|
||||
psms: MutableMapping[int, int]
|
||||
peer: PacketTracer.AclStream
|
||||
avdtp_assemblers: MutableMapping[int, avdtp.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.psms = {} # PSM, by source_cid
|
||||
self.peer = None # ACL stream in the other direction
|
||||
|
||||
# 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)
|
||||
|
||||
if l2cap_pdu.cid == ATT_CID:
|
||||
@@ -81,26 +94,30 @@ 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):
|
||||
if psm := self.peer.psms.get(
|
||||
connection_response.source_cid
|
||||
):
|
||||
# Found a pending connection
|
||||
self.psms[control_frame.destination_cid] = psm
|
||||
self.psms[connection_response.destination_cid] = psm
|
||||
|
||||
# For AVDTP connections, create a packet assembler for
|
||||
# each direction
|
||||
if psm == AVDTP_PSM:
|
||||
if psm == avdtp.AVDTP_PSM:
|
||||
self.avdtp_assemblers[
|
||||
control_frame.source_cid
|
||||
] = AVDTP_MessageAssembler(self.on_avdtp_message)
|
||||
connection_response.source_cid
|
||||
] = avdtp.MessageAssembler(self.on_avdtp_message)
|
||||
self.peer.avdtp_assemblers[
|
||||
control_frame.destination_cid
|
||||
] = AVDTP_MessageAssembler(
|
||||
connection_response.destination_cid
|
||||
] = avdtp.MessageAssembler(
|
||||
self.peer.on_avdtp_message
|
||||
)
|
||||
|
||||
@@ -113,7 +130,7 @@ class PacketTracer:
|
||||
elif psm == RFCOMM_PSM:
|
||||
rfcomm_frame = 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()}'
|
||||
@@ -130,22 +147,26 @@ 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 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
|
||||
|
||||
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 +181,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,23 +192,29 @@ 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, packet: HCI_Packet) -> None:
|
||||
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):
|
||||
def emit(self, message: Any) -> None:
|
||||
self.emit_message(f'[{self.label}] {message}')
|
||||
|
||||
def trace(self, packet, direction=0):
|
||||
def trace(self, packet: HCI_Packet, direction: int = 0) -> None:
|
||||
if direction == 0:
|
||||
self.host_to_controller_analyzer.on_packet(packet)
|
||||
else:
|
||||
@@ -195,10 +222,10 @@ class PacketTracer:
|
||||
|
||||
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 +234,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
|
||||
|
||||
180
bumble/hfp.py
@@ -22,7 +22,7 @@ import dataclasses
|
||||
import enum
|
||||
import traceback
|
||||
import warnings
|
||||
from typing import Dict, List, Union, Set, TYPE_CHECKING
|
||||
from typing import Dict, List, Union, Set, Any, TYPE_CHECKING
|
||||
|
||||
from . import at
|
||||
from . import rfcomm
|
||||
@@ -35,6 +35,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 +70,7 @@ class HfpProtocolError(ProtocolError):
|
||||
# Protocol Support
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HfpProtocol:
|
||||
dlc: rfcomm.DLC
|
||||
@@ -819,3 +825,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,
|
||||
}
|
||||
|
||||
346
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
|
||||
@@ -121,6 +123,16 @@ class SetReportMessage(Message):
|
||||
return self.header(self.report_type) + self.data
|
||||
|
||||
|
||||
@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
|
||||
|
||||
def __init__(self, device: Device, connection: Connection) -> None:
|
||||
class Role(enum.IntEnum):
|
||||
HOST = 0x00
|
||||
DEVICE = 0x01
|
||||
|
||||
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.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')
|
||||
|
||||
291
bumble/host.py
@@ -21,7 +21,7 @@ import collections
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
|
||||
from typing import Any, Awaitable, Callable, Deque, Dict, Optional, cast, TYPE_CHECKING
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.l2cap import L2CAP_PDU
|
||||
@@ -32,8 +32,8 @@ from .hci import (
|
||||
Address,
|
||||
HCI_ACL_DATA_PACKET,
|
||||
HCI_COMMAND_PACKET,
|
||||
HCI_COMMAND_COMPLETE_EVENT,
|
||||
HCI_EVENT_PACKET,
|
||||
HCI_ISO_DATA_PACKET,
|
||||
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
|
||||
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
||||
@@ -43,6 +43,7 @@ from .hci import (
|
||||
HCI_RESET_COMMAND,
|
||||
HCI_SUCCESS,
|
||||
HCI_SUPPORTED_COMMANDS_FLAGS,
|
||||
HCI_SYNCHRONOUS_DATA_PACKET,
|
||||
HCI_VERSION_BLUETOOTH_CORE_4_0,
|
||||
HCI_AclDataPacket,
|
||||
HCI_AclDataPacketAssembler,
|
||||
@@ -51,6 +52,7 @@ from .hci import (
|
||||
HCI_Constant,
|
||||
HCI_Error,
|
||||
HCI_Event,
|
||||
HCI_IsoDataPacket,
|
||||
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
|
||||
HCI_LE_Long_Term_Key_Request_Reply_Command,
|
||||
HCI_LE_Read_Buffer_Size_Command,
|
||||
@@ -67,13 +69,13 @@ from .hci import (
|
||||
HCI_Read_Local_Version_Information_Command,
|
||||
HCI_Reset_Command,
|
||||
HCI_Set_Event_Mask_Command,
|
||||
HCI_SynchronousDataPacket,
|
||||
)
|
||||
from .core import (
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
BT_LE_TRANSPORT,
|
||||
ConnectionPHY,
|
||||
ConnectionParameters,
|
||||
InvalidStateError,
|
||||
)
|
||||
from .utils import AbortableEventEmitter
|
||||
from .transport.common import TransportLostError
|
||||
@@ -89,16 +91,49 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
# fmt: off
|
||||
class AclPacketQueue:
|
||||
max_packet_size: int
|
||||
|
||||
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 __init__(
|
||||
self,
|
||||
max_packet_size: int,
|
||||
max_in_flight: int,
|
||||
send: Callable[[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_AclDataPacket] = collections.deque()
|
||||
|
||||
# fmt: on
|
||||
def enqueue(self, packet: HCI_AclDataPacket) -> None:
|
||||
self.packets.appendleft(packet)
|
||||
self.check_queue()
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -109,6 +144,13 @@ class Connection:
|
||||
self.peer_address = peer_address
|
||||
self.assembler = 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:
|
||||
self.assembler.feed_packet(packet)
|
||||
@@ -121,8 +163,10 @@ class Connection:
|
||||
# -----------------------------------------------------------------------------
|
||||
class Host(AbortableEventEmitter):
|
||||
connections: Dict[int, Connection]
|
||||
acl_packet_queue: collections.deque[HCI_AclDataPacket]
|
||||
hci_sink: TransportSink
|
||||
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]]]
|
||||
]
|
||||
@@ -135,18 +179,11 @@ 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.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.local_version = None
|
||||
self.local_supported_commands = bytes(64)
|
||||
self.local_le_features = 0
|
||||
@@ -160,10 +197,7 @@ 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)
|
||||
|
||||
@@ -198,17 +232,21 @@ 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_Reset_Command(), check_result=True)
|
||||
self.ready = True
|
||||
|
||||
response = await self.send_command(
|
||||
HCI_Read_Local_Supported_Commands_Command(), check_result=True
|
||||
@@ -241,7 +279,7 @@ class Host(AbortableEventEmitter):
|
||||
# understand
|
||||
le_event_mask = bytes.fromhex('1F00000000000000')
|
||||
else:
|
||||
le_event_mask = bytes.fromhex('FFFFF00000000000')
|
||||
le_event_mask = bytes.fromhex('FFFFFFFF00000000')
|
||||
|
||||
await self.send_command(
|
||||
HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
|
||||
@@ -251,46 +289,54 @@ class Host(AbortableEventEmitter):
|
||||
response = await self.send_command(
|
||||
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}'
|
||||
)
|
||||
|
||||
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_LE_READ_BUFFER_SIZE_COMMAND):
|
||||
response = await self.send_command(
|
||||
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
|
||||
@@ -311,29 +357,31 @@ class Host(AbortableEventEmitter):
|
||||
)
|
||||
)
|
||||
|
||||
self.reset_done = True
|
||||
|
||||
@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)
|
||||
|
||||
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||
def set_packet_sink(self, sink: Optional[TransportSink]) -> None:
|
||||
self.hci_sink = sink
|
||||
|
||||
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_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
|
||||
@@ -381,6 +429,17 @@ class Host(AbortableEventEmitter):
|
||||
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,8 +447,7 @@ 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)
|
||||
data_total_length = min(bytes_remaining, packet_queue.max_packet_size)
|
||||
acl_packet = HCI_AclDataPacket(
|
||||
connection_handle=connection_handle,
|
||||
pb_flag=pb_flag,
|
||||
@@ -397,34 +455,12 @@ class Host(AbortableEventEmitter):
|
||||
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):
|
||||
@@ -485,12 +521,16 @@ class Host(AbortableEventEmitter):
|
||||
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_COMMAND_PACKET:
|
||||
self.on_hci_command_packet(cast(HCI_Command, packet))
|
||||
elif packet.hci_packet_type == HCI_EVENT_PACKET:
|
||||
self.on_hci_event_packet(cast(HCI_Event, packet))
|
||||
elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
||||
self.on_hci_acl_data_packet(cast(HCI_AclDataPacket, packet))
|
||||
elif packet.hci_packet_type == HCI_SYNCHRONOUS_DATA_PACKET:
|
||||
self.on_hci_sco_data_packet(cast(HCI_SynchronousDataPacket, packet))
|
||||
elif packet.hci_packet_type == HCI_ISO_DATA_PACKET:
|
||||
self.on_hci_iso_data_packet(cast(HCI_IsoDataPacket, packet))
|
||||
else:
|
||||
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
||||
|
||||
@@ -507,6 +547,14 @@ class Host(AbortableEventEmitter):
|
||||
if connection := self.connections.get(packet.connection_handle):
|
||||
connection.on_hci_acl_data_packet(packet)
|
||||
|
||||
def on_hci_sco_data_packet(self, packet: HCI_SynchronousDataPacket) -> None:
|
||||
# Experimental
|
||||
self.emit('sco_packet', packet.connection_handle, packet)
|
||||
|
||||
def on_hci_iso_data_packet(self, packet: 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 +583,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 +591,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):
|
||||
@@ -707,6 +754,32 @@ 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,
|
||||
)
|
||||
|
||||
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_SUCCESS:
|
||||
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')
|
||||
@@ -760,7 +833,25 @@ class Host(AbortableEventEmitter):
|
||||
asyncio.create_task(send_long_term_key())
|
||||
|
||||
def on_hci_synchronous_connection_complete_event(self, event):
|
||||
pass
|
||||
if event.status == HCI_SUCCESS:
|
||||
# Create/update the connection
|
||||
logger.debug(
|
||||
f'### SCO CONNECTION: [0x{event.connection_handle:04X}] '
|
||||
f'{event.bd_addr}'
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1636,12 +1648,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 +1931,7 @@ class ChannelManager:
|
||||
supervision_timeout=request.timeout,
|
||||
min_ce_length=0,
|
||||
max_ce_length=0,
|
||||
) # type: ignore[call-arg]
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.send_control_frame(
|
||||
|
||||
@@ -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}])'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
1247
bumble/profiles/bap.py
Normal file
52
bumble/profiles/cap.py
Normal file
@@ -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
|
||||
257
bumble/profiles/csip.py
Normal file
@@ -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)
|
||||
@@ -118,8 +118,8 @@ CRC_TABLE = bytes([
|
||||
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
|
||||
])
|
||||
|
||||
RFCOMM_DEFAULT_INITIAL_RX_CREDITS = 7
|
||||
RFCOMM_DEFAULT_PREFERRED_MTU = 1280
|
||||
RFCOMM_DEFAULT_WINDOW_SIZE = 16
|
||||
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
|
||||
|
||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
|
||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
||||
@@ -438,20 +438,24 @@ 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
|
||||
@@ -537,11 +541,11 @@ class DLC(EventEmitter):
|
||||
if len(data) and 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()
|
||||
@@ -580,9 +584,9 @@ 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))
|
||||
logger.debug(f'>>> PN Response: {pn}')
|
||||
@@ -591,7 +595,7 @@ class DLC(EventEmitter):
|
||||
|
||||
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 +635,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 +649,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})'
|
||||
@@ -843,7 +849,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,9 +866,9 @@ 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))
|
||||
logger.debug(f'>>> Sending MCC: {pn}')
|
||||
@@ -889,8 +900,7 @@ 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) -> None:
|
||||
self.connection = connection
|
||||
self.l2cap_channel = None
|
||||
self.multiplexer = None
|
||||
@@ -906,7 +916,7 @@ class Client:
|
||||
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
|
||||
|
||||
@@ -760,13 +760,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)
|
||||
)
|
||||
|
||||
|
||||
284
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
|
||||
@@ -563,6 +565,54 @@ class PairingMethod(enum.IntEnum):
|
||||
CTKD_OVER_CLASSIC = 4
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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,54 @@ 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')
|
||||
|
||||
async def get_link_key_and_derive_ltk(self) -> None:
|
||||
'''Retrieves BR/EDR Link Key from storage and derive it to LE LTK.'''
|
||||
link_key = await self.manager.device.get_link_key(self.connection.peer_address)
|
||||
if 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(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 +1154,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 +1184,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 +1193,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 +1223,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 +1401,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 +1438,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 +1508,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 +1678,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 +1721,7 @@ class Session:
|
||||
if self.pairing_method in (
|
||||
PairingMethod.JUST_WORKS,
|
||||
PairingMethod.NUMERIC_COMPARISON,
|
||||
PairingMethod.OOB,
|
||||
):
|
||||
ra = bytes(16)
|
||||
rb = ra
|
||||
@@ -1599,7 +1730,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 +1781,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 +1818,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 +1847,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 +1896,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,
|
||||
|
||||
@@ -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,106 @@ 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
|
||||
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(',')])
|
||||
else:
|
||||
metadata = None
|
||||
|
||||
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')
|
||||
|
||||
|
||||
@@ -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='):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
@@ -540,7 +537,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:
|
||||
|
||||
@@ -280,17 +280,14 @@ class AsyncRunner:
|
||||
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)
|
||||
@@ -432,7 +429,7 @@ def wrap_async(function):
|
||||
|
||||
def deprecated(msg: str):
|
||||
"""
|
||||
Throw deprecation warning before execution
|
||||
Throw deprecation warning before execution.
|
||||
"""
|
||||
|
||||
def wrapper(function):
|
||||
@@ -444,3 +441,19 @@ def deprecated(msg: str):
|
||||
return inner
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def experimental(msg: str):
|
||||
"""
|
||||
Throws a future warning before execution.
|
||||
"""
|
||||
|
||||
def wrapper(function):
|
||||
@wraps(function)
|
||||
def inner(*args, **kwargs):
|
||||
warnings.warn(msg, FutureWarning)
|
||||
return function(*args, **kwargs)
|
||||
|
||||
return inner
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -70,6 +70,7 @@ nav:
|
||||
- Extras:
|
||||
- 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
64
docs/mkdocs/src/extras/android_bt_bench.md
Normal file
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
5
examples/hid_keyboard.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Bumble HID Keyboard",
|
||||
"class_of_device": 9664,
|
||||
"keystore": "JsonKeyStore"
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
6
examples/leaudio.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Bumble-LEA",
|
||||
"keystore": "JsonKeyStore",
|
||||
"address": "F0:F1:F2:F3:F4:FA",
|
||||
"advertising_interval": 100
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
107
examples/run_cig_setup.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# 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,
|
||||
)
|
||||
from bumble.hci import (
|
||||
OwnAddressType,
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command,
|
||||
)
|
||||
|
||||
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])
|
||||
await devices[0].start_extended_advertising(
|
||||
advertising_properties=(
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING
|
||||
),
|
||||
own_address_type=OwnAddressType.PUBLIC,
|
||||
)
|
||||
|
||||
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())
|
||||
@@ -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(
|
||||
|
||||
116
examples/run_csis_servers.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# 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.start_extended_advertising(
|
||||
advertising_properties=(
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING
|
||||
),
|
||||
own_address_type=OwnAddressType.RANDOM,
|
||||
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())
|
||||
86
examples/run_esco_connection.py
Normal file
@@ -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())
|
||||
69
examples/run_extended_advertiser.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# 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 AdvertisingType, Device
|
||||
from bumble.hci import Address, HCI_LE_Set_Extended_Advertising_Parameters_Command
|
||||
|
||||
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 = (
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties(
|
||||
int(sys.argv[3])
|
||||
)
|
||||
)
|
||||
else:
|
||||
advertising_properties = (
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING
|
||||
)
|
||||
|
||||
if len(sys.argv) >= 5:
|
||||
target = Address(sys.argv[4])
|
||||
else:
|
||||
target = 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.start_extended_advertising(
|
||||
advertising_properties=advertising_properties, target=target
|
||||
)
|
||||
await hci_transport.source.terminated
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
asyncio.run(main())
|
||||
@@ -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:
|
||||
|
||||
748
examples/run_hid_device.py
Normal file
@@ -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())
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
195
examples/run_unicast_server.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# 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
|
||||
from bumble.hci import (
|
||||
CodecID,
|
||||
CodingFormat,
|
||||
OwnAddressType,
|
||||
HCI_IsoDataPacket,
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command,
|
||||
)
|
||||
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_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,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
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.
|
||||
24000 // 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)
|
||||
|
||||
await device.start_extended_advertising(
|
||||
advertising_properties=(
|
||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING
|
||||
),
|
||||
own_address_type=OwnAddressType.RANDOM,
|
||||
advertising_data=advertising_data,
|
||||
)
|
||||
|
||||
await hci_transport.source.terminated
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
asyncio.run(main())
|
||||
15
extras/android/BtBench/.gitignore
vendored
Normal file
@@ -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
|
||||
1
extras/android/BtBench/app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
70
extras/android/BtBench/app/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
21
extras/android/BtBench/app/proguard-rules.pro
vendored
Normal file
@@ -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
|
||||
40
extras/android/BtBench/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- 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"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
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>
|
||||
BIN
extras/android/BtBench/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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({}, {})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 6.1 KiB |