forked from auracaster/bumble_mirror
Compare commits
195 Commits
gbg/androi
...
rust-v0.2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c6643bb8 | ||
|
|
b8214bf948 | ||
|
|
a9c62c44b3 | ||
|
|
7d0b4ef4e0 | ||
|
|
313340f1c6 | ||
|
|
e8ed69fb09 | ||
|
|
16d5cf6770 | ||
|
|
a2caf1deb2 | ||
|
|
01bfdd2c98 | ||
|
|
4a60df108a | ||
|
|
ad48109748 | ||
|
|
44c51c13ac | ||
|
|
7507be1eab | ||
|
|
cbe9446dcf | ||
|
|
174930399a | ||
|
|
1f3aee5566 | ||
|
|
256044a789 | ||
|
|
e554bd1033 | ||
|
|
38981cefa1 | ||
|
|
f2d601f411 | ||
|
|
6e7c64c1de | ||
|
|
565d51f4db | ||
|
|
de8f3d9c1e | ||
|
|
cde6d48690 | ||
|
|
02180088b3 | ||
|
|
90f49267d1 | ||
|
|
0e6d69cd7b | ||
|
|
9eccc583d5 | ||
|
|
f4aeaa6eb3 | ||
|
|
d7489a644a | ||
|
|
a877283360 | ||
|
|
6d91e7e79b | ||
|
|
567146b143 | ||
|
|
1a3272d7ca | ||
|
|
1ee1ff0b62 | ||
|
|
729fd97748 | ||
|
|
e308051885 | ||
|
|
10e53553d7 | ||
|
|
ef0b30d059 | ||
|
|
e7e9f9509a | ||
|
|
c6cfd101df | ||
|
|
d2dcf063ee | ||
|
|
d15bc7d664 | ||
|
|
e4364d18a7 | ||
|
|
6a34c9f224 | ||
|
|
2a764fd6bb | ||
|
|
3e8ce38eba | ||
|
|
8d2f37aa7a | ||
|
|
b7b70ebcbb | ||
|
|
8ba91f4986 | ||
|
|
79a5e953bc | ||
|
|
20de5ea250 | ||
|
|
bad9ce272c | ||
|
|
d3273ffa8c | ||
|
|
071fc2723a | ||
|
|
ef4ea86f58 | ||
|
|
dfdaa149d0 | ||
|
|
986343a807 | ||
|
|
5211d7ba96 | ||
|
|
a167342778 | ||
|
|
1efb8cdbee | ||
|
|
80d83e6a70 | ||
|
|
31ec1c41ce | ||
|
|
aba1ac0cea | ||
|
|
c40824e51c | ||
|
|
2920f05dae | ||
|
|
bc911d6da0 | ||
|
|
4f87f587e4 | ||
|
|
3e38ab3638 | ||
|
|
21bb911fea | ||
|
|
744dfa33a2 | ||
|
|
ec5f8535a8 | ||
|
|
5a83734a00 | ||
|
|
b4ae8af3a7 | ||
|
|
da60386385 | ||
|
|
45c4c4f4c5 | ||
|
|
9187c75d68 | ||
|
|
abeec22546 | ||
|
|
a6bab755cf | ||
|
|
acd9d994c3 | ||
|
|
37afda3ed3 | ||
|
|
54f2981267 | ||
|
|
bb025514e7 | ||
|
|
e228597269 | ||
|
|
95b0d6c6f2 | ||
|
|
fa4df6e3a2 | ||
|
|
46ceea7ecd | ||
|
|
30f89d5739 | ||
|
|
481cf40831 | ||
|
|
eff05afb7a | ||
|
|
d8e6700611 | ||
|
|
56eb5a933b | ||
|
|
caacc0c133 | ||
|
|
5f377c024b | ||
|
|
00cd8fbdd0 | ||
|
|
aeeff18428 | ||
|
|
c48e3f5e9c | ||
|
|
d6bbc1145a | ||
|
|
e2fec67bd9 | ||
|
|
88cb3b2a4d | ||
|
|
9ebb03be46 | ||
|
|
80d84af76c | ||
|
|
8f4721758f | ||
|
|
8864af4acd | ||
|
|
8980fb8cc7 | ||
|
|
2c5f3472a9 | ||
|
|
f18277ac78 | ||
|
|
8d46bc04d2 | ||
|
|
09e5ea5dec | ||
|
|
d43281c57e | ||
|
|
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 | ||
|
|
6d22ed80ec | ||
|
|
843466c822 | ||
|
|
3adcc8be09 | ||
|
|
c853d56302 | ||
|
|
dc97be5b35 | ||
|
|
73dbdfff9f | ||
|
|
dff14e1258 | ||
|
|
10a3833893 | ||
|
|
ffb3eca68b | ||
|
|
7eb493990f | ||
|
|
403a13e4c6 | ||
|
|
ad0f035df5 | ||
|
|
07f71fc895 | ||
|
|
f47b9178ad | ||
|
|
4f399249bd | ||
|
|
9324237828 | ||
|
|
d1033c018a | ||
|
|
0f29052ade | ||
|
|
0578e84586 | ||
|
|
6ab41c466f | ||
|
|
98a1093ebf | ||
|
|
caf04373f3 | ||
|
|
d4e8526766 | ||
|
|
515b83a8c7 | ||
|
|
dc18595c8a | ||
|
|
488bcfe9c6 | ||
|
|
d6cefdff8e | ||
|
|
dc410b14c4 | ||
|
|
4c49ef9403 | ||
|
|
ba85dcbda5 |
2
.github/workflows/code-check.yml
vendored
2
.github/workflows/code-check.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v3
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
|
|||||||
2
.github/workflows/python-build-test.yml
vendored
2
.github/workflows/python-build-test.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
|
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
|
||||||
rust-version: [ "1.70.0", "stable" ]
|
rust-version: [ "1.76.0", "stable" ]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,4 +9,9 @@ __pycache__
|
|||||||
# generated by setuptools_scm
|
# generated by setuptools_scm
|
||||||
bumble/_version.py
|
bumble/_version.py
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
|
.vscode/settings.json
|
||||||
/.idea
|
/.idea
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
# snoop logs
|
||||||
|
out/
|
||||||
|
|||||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -12,7 +12,9 @@
|
|||||||
"ASHA",
|
"ASHA",
|
||||||
"asyncio",
|
"asyncio",
|
||||||
"ATRAC",
|
"ATRAC",
|
||||||
|
"avctp",
|
||||||
"avdtp",
|
"avdtp",
|
||||||
|
"avrcp",
|
||||||
"bitpool",
|
"bitpool",
|
||||||
"bitstruct",
|
"bitstruct",
|
||||||
"BSCP",
|
"BSCP",
|
||||||
@@ -22,6 +24,7 @@
|
|||||||
"cmac",
|
"cmac",
|
||||||
"CONNECTIONLESS",
|
"CONNECTIONLESS",
|
||||||
"csip",
|
"csip",
|
||||||
|
"csis",
|
||||||
"csrcs",
|
"csrcs",
|
||||||
"CVSD",
|
"CVSD",
|
||||||
"datagram",
|
"datagram",
|
||||||
@@ -32,6 +35,7 @@
|
|||||||
"dhkey",
|
"dhkey",
|
||||||
"diversifier",
|
"diversifier",
|
||||||
"endianness",
|
"endianness",
|
||||||
|
"ESCO",
|
||||||
"Fitbit",
|
"Fitbit",
|
||||||
"GATTLINK",
|
"GATTLINK",
|
||||||
"HANDSFREE",
|
"HANDSFREE",
|
||||||
@@ -70,6 +74,8 @@
|
|||||||
"substates",
|
"substates",
|
||||||
"tobytes",
|
"tobytes",
|
||||||
"tsep",
|
"tsep",
|
||||||
|
"UNMUTE",
|
||||||
|
"unmuted",
|
||||||
"usbmodem",
|
"usbmodem",
|
||||||
"vhci",
|
"vhci",
|
||||||
"websockets",
|
"websockets",
|
||||||
|
|||||||
587
apps/bench.py
587
apps/bench.py
File diff suppressed because it is too large
Load Diff
63
apps/ble_rpa_tool.py
Normal file
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:
|
if not service:
|
||||||
continue
|
continue
|
||||||
values = [
|
values = [
|
||||||
attribute.read_value(connection)
|
await attribute.read_value(connection)
|
||||||
for connection in self.device.connections.values()
|
for connection in self.device.connections.values()
|
||||||
]
|
]
|
||||||
if not values:
|
if not values:
|
||||||
@@ -796,11 +796,11 @@ class ConsoleApp:
|
|||||||
if not characteristic:
|
if not characteristic:
|
||||||
continue
|
continue
|
||||||
values = [
|
values = [
|
||||||
attribute.read_value(connection)
|
await attribute.read_value(connection)
|
||||||
for connection in self.device.connections.values()
|
for connection in self.device.connections.values()
|
||||||
]
|
]
|
||||||
if not 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
|
# TODO: future optimization: convert CCCD value to human readable string
|
||||||
|
|
||||||
@@ -944,7 +944,7 @@ class ConsoleApp:
|
|||||||
|
|
||||||
# send data to any subscribers
|
# send data to any subscribers
|
||||||
if isinstance(attribute, Characteristic):
|
if isinstance(attribute, Characteristic):
|
||||||
attribute.write_value(None, value)
|
await attribute.write_value(None, value)
|
||||||
if attribute.has_properties(Characteristic.NOTIFY):
|
if attribute.has_properties(Characteristic.NOTIFY):
|
||||||
await self.device.gatt_server.notify_subscribers(attribute)
|
await self.device.gatt_server.notify_subscribers(attribute)
|
||||||
if attribute.has_properties(Characteristic.INDICATE):
|
if attribute.has_properties(Characteristic.INDICATE):
|
||||||
|
|||||||
@@ -18,24 +18,30 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import click
|
import time
|
||||||
from bumble.company_ids import COMPANY_IDENTIFIERS
|
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from bumble.company_ids import COMPANY_IDENTIFIERS
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import name_or_number
|
from bumble.core import name_or_number
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
map_null_terminated_utf8_string,
|
map_null_terminated_utf8_string,
|
||||||
|
LeFeatureMask,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_LE_SUPPORTED_FEATURES_NAMES,
|
|
||||||
HCI_VERSION_NAMES,
|
HCI_VERSION_NAMES,
|
||||||
LMP_VERSION_NAMES,
|
LMP_VERSION_NAMES,
|
||||||
HCI_Command,
|
HCI_Command,
|
||||||
HCI_Command_Complete_Event,
|
HCI_Command_Complete_Event,
|
||||||
HCI_Command_Status_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_BD_ADDR_Command,
|
HCI_Read_BD_ADDR_Command,
|
||||||
HCI_READ_LOCAL_NAME_COMMAND,
|
HCI_READ_LOCAL_NAME_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_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,
|
||||||
@@ -44,6 +50,7 @@ from bumble.hci import (
|
|||||||
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
||||||
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
||||||
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
||||||
|
HCI_Read_Local_Version_Information_Command,
|
||||||
)
|
)
|
||||||
from bumble.host import Host
|
from bumble.host import Host
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
@@ -59,7 +66,7 @@ def command_succeeded(response):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_classic_info(host):
|
async def get_classic_info(host: Host) -> None:
|
||||||
if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
|
if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
|
||||||
response = await host.send_command(HCI_Read_BD_ADDR_Command())
|
response = await host.send_command(HCI_Read_BD_ADDR_Command())
|
||||||
if command_succeeded(response):
|
if command_succeeded(response):
|
||||||
@@ -80,7 +87,7 @@ async def get_classic_info(host):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_le_info(host):
|
async def get_le_info(host: Host) -> None:
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND):
|
if host.supports_command(HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND):
|
||||||
@@ -133,11 +140,36 @@ async def get_le_info(host):
|
|||||||
|
|
||||||
print(color('LE Features:', 'yellow'))
|
print(color('LE Features:', 'yellow'))
|
||||||
for feature in host.supported_le_features:
|
for feature in host.supported_le_features:
|
||||||
print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))
|
print(LeFeatureMask(feature).name)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def async_main(transport):
|
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(latency_probes, transport):
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
|
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
|
||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
@@ -145,6 +177,23 @@ async def async_main(transport):
|
|||||||
host = Host(hci_source, hci_sink)
|
host = Host(hci_source, hci_sink)
|
||||||
await host.reset()
|
await host.reset()
|
||||||
|
|
||||||
|
# Measure the latency if requested
|
||||||
|
latencies = []
|
||||||
|
if latency_probes:
|
||||||
|
for _ in range(latency_probes):
|
||||||
|
start = time.time()
|
||||||
|
await host.send_command(HCI_Read_Local_Version_Information_Command())
|
||||||
|
latencies.append(1000 * (time.time() - start))
|
||||||
|
print(
|
||||||
|
color('HCI Command Latency:', 'yellow'),
|
||||||
|
(
|
||||||
|
f'min={min(latencies):.2f}, '
|
||||||
|
f'max={max(latencies):.2f}, '
|
||||||
|
f'average={sum(latencies)/len(latencies):.2f}'
|
||||||
|
),
|
||||||
|
'\n',
|
||||||
|
)
|
||||||
|
|
||||||
# Print version
|
# Print version
|
||||||
print(color('Version:', 'yellow'))
|
print(color('Version:', 'yellow'))
|
||||||
print(
|
print(
|
||||||
@@ -168,6 +217,9 @@ async def async_main(transport):
|
|||||||
# Get the LE info
|
# Get the LE info
|
||||||
await get_le_info(host)
|
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 the list of commands supported by the controller
|
||||||
print()
|
print()
|
||||||
print(color('Supported Commands:', 'yellow'))
|
print(color('Supported Commands:', 'yellow'))
|
||||||
@@ -177,10 +229,16 @@ async def async_main(transport):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@click.command()
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
'--latency-probes',
|
||||||
|
metavar='N',
|
||||||
|
type=int,
|
||||||
|
help='Send N commands to measure HCI transport latency statistics',
|
||||||
|
)
|
||||||
@click.argument('transport')
|
@click.argument('transport')
|
||||||
def main(transport):
|
def main(latency_probes, transport):
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||||
asyncio.run(async_main(transport))
|
asyncio.run(async_main(latency_probes, transport))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
205
apps/controller_loopback.py
Normal file
205
apps/controller_loopback.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# Copyright 2024 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble.hci import (
|
||||||
|
HCI_READ_LOOPBACK_MODE_COMMAND,
|
||||||
|
HCI_Read_Loopback_Mode_Command,
|
||||||
|
HCI_WRITE_LOOPBACK_MODE_COMMAND,
|
||||||
|
HCI_Write_Loopback_Mode_Command,
|
||||||
|
LoopbackMode,
|
||||||
|
)
|
||||||
|
from bumble.host import Host
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
class Loopback:
|
||||||
|
"""Send and receive ACL data packets in local loopback mode"""
|
||||||
|
|
||||||
|
def __init__(self, packet_size: int, packet_count: int, transport: str):
|
||||||
|
self.transport = transport
|
||||||
|
self.packet_size = packet_size
|
||||||
|
self.packet_count = packet_count
|
||||||
|
self.connection_handle: Optional[int] = None
|
||||||
|
self.connection_event = asyncio.Event()
|
||||||
|
self.done = asyncio.Event()
|
||||||
|
self.expected_cid = 0
|
||||||
|
self.bytes_received = 0
|
||||||
|
self.start_timestamp = 0.0
|
||||||
|
self.last_timestamp = 0.0
|
||||||
|
|
||||||
|
def on_connection(self, connection_handle: int, *args):
|
||||||
|
"""Retrieve connection handle from new connection event"""
|
||||||
|
if not self.connection_event.is_set():
|
||||||
|
# save first connection handle for ACL
|
||||||
|
# subsequent connections are SCO
|
||||||
|
self.connection_handle = connection_handle
|
||||||
|
self.connection_event.set()
|
||||||
|
|
||||||
|
def on_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes):
|
||||||
|
"""Calculate packet receive speed"""
|
||||||
|
now = time.time()
|
||||||
|
print(f'<<< Received packet {cid}: {len(pdu)} bytes')
|
||||||
|
assert connection_handle == self.connection_handle
|
||||||
|
assert cid == self.expected_cid
|
||||||
|
self.expected_cid += 1
|
||||||
|
if cid == 0:
|
||||||
|
self.start_timestamp = now
|
||||||
|
else:
|
||||||
|
elapsed_since_start = now - self.start_timestamp
|
||||||
|
elapsed_since_last = now - self.last_timestamp
|
||||||
|
self.bytes_received += len(pdu)
|
||||||
|
instant_rx_speed = len(pdu) / elapsed_since_last
|
||||||
|
average_rx_speed = self.bytes_received / elapsed_since_start
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f'@@@ RX speed: instant={instant_rx_speed:.4f},'
|
||||||
|
f' average={average_rx_speed:.4f}',
|
||||||
|
'cyan',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.last_timestamp = now
|
||||||
|
|
||||||
|
if self.expected_cid == self.packet_count:
|
||||||
|
print(color('@@@ Received last packet', 'green'))
|
||||||
|
self.done.set()
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Run a loopback throughput test"""
|
||||||
|
print(color('>>> Connecting to HCI...', 'green'))
|
||||||
|
async with await open_transport_or_link(self.transport) as (
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
|
):
|
||||||
|
print(color('>>> Connected', 'green'))
|
||||||
|
|
||||||
|
host = Host(hci_source, hci_sink)
|
||||||
|
await host.reset()
|
||||||
|
|
||||||
|
# make sure data can fit in one l2cap pdu
|
||||||
|
l2cap_header_size = 4
|
||||||
|
|
||||||
|
max_packet_size = (
|
||||||
|
host.acl_packet_queue
|
||||||
|
if host.acl_packet_queue
|
||||||
|
else host.le_acl_packet_queue
|
||||||
|
).max_packet_size - l2cap_header_size
|
||||||
|
if self.packet_size > max_packet_size:
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f'!!! Packet size ({self.packet_size}) larger than max supported'
|
||||||
|
f' size ({max_packet_size})',
|
||||||
|
'red',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not host.supports_command(
|
||||||
|
HCI_WRITE_LOOPBACK_MODE_COMMAND
|
||||||
|
) or not host.supports_command(HCI_READ_LOOPBACK_MODE_COMMAND):
|
||||||
|
print(color('!!! Loopback mode not supported', 'red'))
|
||||||
|
return
|
||||||
|
|
||||||
|
# set event callbacks
|
||||||
|
host.on('connection', self.on_connection)
|
||||||
|
host.on('l2cap_pdu', self.on_l2cap_pdu)
|
||||||
|
|
||||||
|
loopback_mode = LoopbackMode.LOCAL
|
||||||
|
|
||||||
|
print(color('### Setting loopback mode', 'blue'))
|
||||||
|
await host.send_command(
|
||||||
|
HCI_Write_Loopback_Mode_Command(loopback_mode=LoopbackMode.LOCAL),
|
||||||
|
check_result=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(color('### Checking loopback mode', 'blue'))
|
||||||
|
response = await host.send_command(
|
||||||
|
HCI_Read_Loopback_Mode_Command(), check_result=True
|
||||||
|
)
|
||||||
|
if response.return_parameters.loopback_mode != loopback_mode:
|
||||||
|
print(color('!!! Loopback mode mismatch', 'red'))
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.connection_event.wait()
|
||||||
|
print(color('### Connected', 'cyan'))
|
||||||
|
|
||||||
|
print(color('=== Start sending', 'magenta'))
|
||||||
|
start_time = time.time()
|
||||||
|
bytes_sent = 0
|
||||||
|
for cid in range(0, self.packet_count):
|
||||||
|
# using the cid as an incremental index
|
||||||
|
host.send_l2cap_pdu(
|
||||||
|
self.connection_handle, cid, bytes(self.packet_size)
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f'>>> Sending packet {cid}: {self.packet_size} bytes', 'yellow'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bytes_sent += self.packet_size # don't count L2CAP or HCI header sizes
|
||||||
|
await asyncio.sleep(0) # yield to allow packet receive
|
||||||
|
|
||||||
|
await self.done.wait()
|
||||||
|
print(color('=== Done!', 'magenta'))
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
average_tx_speed = bytes_sent / elapsed
|
||||||
|
print(
|
||||||
|
color(
|
||||||
|
f'@@@ TX speed: average={average_tx_speed:.4f} ({bytes_sent} bytes'
|
||||||
|
f' in {elapsed:.2f} seconds)',
|
||||||
|
'green',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
'--packet-size',
|
||||||
|
'-s',
|
||||||
|
metavar='SIZE',
|
||||||
|
type=click.IntRange(8, 4096),
|
||||||
|
default=500,
|
||||||
|
help='Packet size',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--packet-count',
|
||||||
|
'-c',
|
||||||
|
metavar='COUNT',
|
||||||
|
type=click.IntRange(1, 65535),
|
||||||
|
default=10,
|
||||||
|
help='Packet count',
|
||||||
|
)
|
||||||
|
@click.argument('transport')
|
||||||
|
def main(packet_size, packet_count, transport):
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||||
|
|
||||||
|
loopback = Loopback(packet_size, packet_count, transport)
|
||||||
|
asyncio.run(loopback.run())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -49,14 +49,16 @@ class ServerBridge:
|
|||||||
self.tcp_port = tcp_port
|
self.tcp_port = tcp_port
|
||||||
|
|
||||||
async def start(self, device: Device) -> None:
|
async def start(self, device: Device) -> None:
|
||||||
# Listen for incoming L2CAP CoC connections
|
# Listen for incoming L2CAP channel connections
|
||||||
device.create_l2cap_server(
|
device.create_l2cap_server(
|
||||||
spec=l2cap.LeCreditBasedChannelSpec(
|
spec=l2cap.LeCreditBasedChannelSpec(
|
||||||
psm=self.psm, mtu=self.mtu, mps=self.mps, max_credits=self.max_credits
|
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_connection(connection):
|
||||||
def on_ble_disconnection(reason):
|
def on_ble_disconnection(reason):
|
||||||
@@ -73,7 +75,7 @@ class ServerBridge:
|
|||||||
await device.start_advertising(auto_restart=True)
|
await device.start_advertising(auto_restart=True)
|
||||||
|
|
||||||
# Called when a new L2CAP connection is established
|
# 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)
|
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
|
||||||
|
|
||||||
class Pipe:
|
class Pipe:
|
||||||
@@ -83,7 +85,7 @@ class ServerBridge:
|
|||||||
self.l2cap_channel = l2cap_channel
|
self.l2cap_channel = l2cap_channel
|
||||||
|
|
||||||
l2cap_channel.on('close', self.on_l2cap_close)
|
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):
|
async def connect_to_tcp(self):
|
||||||
# Connect to the TCP server
|
# Connect to the TCP server
|
||||||
@@ -128,7 +130,7 @@ class ServerBridge:
|
|||||||
if self.tcp_transport is not None:
|
if self.tcp_transport is not None:
|
||||||
self.tcp_transport.close()
|
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'))
|
print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
|
||||||
if self.tcp_transport is None:
|
if self.tcp_transport is None:
|
||||||
print(color('!!! TCP socket not open, dropping', 'red'))
|
print(color('!!! TCP socket not open, dropping', 'red'))
|
||||||
@@ -183,7 +185,7 @@ class ClientBridge:
|
|||||||
peer_name = writer.get_extra_info('peer_name')
|
peer_name = writer.get_extra_info('peer_name')
|
||||||
print(color(f'<<< TCP connection from {peer_name}', 'magenta'))
|
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'))
|
print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
|
||||||
l2cap_to_tcp_pipe.write(sdu)
|
l2cap_to_tcp_pipe.write(sdu)
|
||||||
|
|
||||||
@@ -209,7 +211,7 @@ class ClientBridge:
|
|||||||
writer.close()
|
writer.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
l2cap_channel.sink = on_coc_sdu
|
l2cap_channel.sink = on_channel_sdu
|
||||||
l2cap_channel.on('close', on_l2cap_close)
|
l2cap_channel.on('close', on_l2cap_close)
|
||||||
|
|
||||||
# Start a flow control pipe from L2CAP to TCP
|
# 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.pass_context
|
||||||
@click.option('--device-config', help='Device configuration file', required=True)
|
@click.option('--device-config', help='Device configuration file', required=True)
|
||||||
@click.option('--hci-transport', help='HCI transport', 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(
|
@click.option(
|
||||||
'--l2cap-coc-max-credits',
|
'--l2cap-max-credits',
|
||||||
help='Maximum L2CAP CoC Credits',
|
help='Maximum L2CAP Credits',
|
||||||
type=click.IntRange(1, 65535),
|
type=click.IntRange(1, 65535),
|
||||||
default=128,
|
default=128,
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--l2cap-coc-mtu',
|
'--l2cap-mtu',
|
||||||
help='L2CAP CoC MTU',
|
help='L2CAP MTU',
|
||||||
type=click.IntRange(23, 65535),
|
type=click.IntRange(
|
||||||
default=1022,
|
l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU,
|
||||||
|
l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MTU,
|
||||||
|
),
|
||||||
|
default=1024,
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--l2cap-coc-mps',
|
'--l2cap-mps',
|
||||||
help='L2CAP CoC MPS',
|
help='L2CAP MPS',
|
||||||
type=click.IntRange(23, 65533),
|
type=click.IntRange(
|
||||||
|
l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS,
|
||||||
|
l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS,
|
||||||
|
),
|
||||||
default=1024,
|
default=1024,
|
||||||
)
|
)
|
||||||
def cli(
|
def cli(
|
||||||
@@ -298,17 +306,17 @@ def cli(
|
|||||||
device_config,
|
device_config,
|
||||||
hci_transport,
|
hci_transport,
|
||||||
psm,
|
psm,
|
||||||
l2cap_coc_max_credits,
|
l2cap_max_credits,
|
||||||
l2cap_coc_mtu,
|
l2cap_mtu,
|
||||||
l2cap_coc_mps,
|
l2cap_mps,
|
||||||
):
|
):
|
||||||
context.ensure_object(dict)
|
context.ensure_object(dict)
|
||||||
context.obj['device_config'] = device_config
|
context.obj['device_config'] = device_config
|
||||||
context.obj['hci_transport'] = hci_transport
|
context.obj['hci_transport'] = hci_transport
|
||||||
context.obj['psm'] = psm
|
context.obj['psm'] = psm
|
||||||
context.obj['max_credits'] = l2cap_coc_max_credits
|
context.obj['max_credits'] = l2cap_max_credits
|
||||||
context.obj['mtu'] = l2cap_coc_mtu
|
context.obj['mtu'] = l2cap_mtu
|
||||||
context.obj['mps'] = l2cap_coc_mps
|
context.obj['mps'] = l2cap_mps
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
12
apps/pair.py
12
apps/pair.py
@@ -52,10 +52,12 @@ from bumble.att import (
|
|||||||
class Waiter:
|
class Waiter:
|
||||||
instance = None
|
instance = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, linger=False):
|
||||||
self.done = asyncio.get_running_loop().create_future()
|
self.done = asyncio.get_running_loop().create_future()
|
||||||
|
self.linger = linger
|
||||||
|
|
||||||
def terminate(self):
|
def terminate(self):
|
||||||
|
if not self.linger:
|
||||||
self.done.set_result(None)
|
self.done.set_result(None)
|
||||||
|
|
||||||
async def wait_until_terminated(self):
|
async def wait_until_terminated(self):
|
||||||
@@ -302,7 +304,7 @@ async def pair(
|
|||||||
hci_transport,
|
hci_transport,
|
||||||
address_or_name,
|
address_or_name,
|
||||||
):
|
):
|
||||||
Waiter.instance = Waiter()
|
Waiter.instance = Waiter(linger=linger)
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
|
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
|
||||||
@@ -396,7 +398,6 @@ async def pair(
|
|||||||
address_or_name,
|
address_or_name,
|
||||||
transport=BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT,
|
transport=BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT,
|
||||||
)
|
)
|
||||||
pairing_failure = False
|
|
||||||
|
|
||||||
if not request:
|
if not request:
|
||||||
try:
|
try:
|
||||||
@@ -405,11 +406,8 @@ async def pair(
|
|||||||
else:
|
else:
|
||||||
await connection.authenticate()
|
await connection.authenticate()
|
||||||
except ProtocolError as error:
|
except ProtocolError as error:
|
||||||
pairing_failure = True
|
|
||||||
print(color(f'Pairing failed: {error}', 'red'))
|
print(color(f'Pairing failed: {error}', 'red'))
|
||||||
|
|
||||||
if not linger or pairing_failure:
|
|
||||||
return
|
|
||||||
else:
|
else:
|
||||||
if mode == 'le':
|
if mode == 'le':
|
||||||
# Advertise so that peers can find us and connect
|
# Advertise so that peers can find us and connect
|
||||||
@@ -459,7 +457,7 @@ class LogHandler(logging.Handler):
|
|||||||
help='Enable CTKD',
|
help='Enable CTKD',
|
||||||
show_default=True,
|
show_default=True,
|
||||||
)
|
)
|
||||||
@click.option('--linger', default=True, is_flag=True, help='Linger after pairing')
|
@click.option('--linger', default=False, is_flag=True, help='Linger after pairing')
|
||||||
@click.option(
|
@click.option(
|
||||||
'--io',
|
'--io',
|
||||||
type=click.Choice(
|
type=click.Choice(
|
||||||
|
|||||||
50
apps/scan.py
50
apps/scan.py
@@ -26,7 +26,7 @@ from bumble.transport import open_transport_or_link
|
|||||||
from bumble.keys import JsonKeyStore
|
from bumble.keys import JsonKeyStore
|
||||||
from bumble.smp import AddressResolver
|
from bumble.smp import AddressResolver
|
||||||
from bumble.device import Advertisement
|
from bumble.device import Advertisement
|
||||||
from bumble.hci import HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
|
from bumble.hci import Address, HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -66,10 +66,15 @@ class AdvertisementPrinter:
|
|||||||
address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[
|
address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[
|
||||||
address.address_type
|
address.address_type
|
||||||
]
|
]
|
||||||
|
if address.address_type in (
|
||||||
|
Address.RANDOM_IDENTITY_ADDRESS,
|
||||||
|
Address.PUBLIC_IDENTITY_ADDRESS,
|
||||||
|
):
|
||||||
|
type_color = 'yellow'
|
||||||
|
else:
|
||||||
if address.is_public:
|
if address.is_public:
|
||||||
type_color = 'cyan'
|
type_color = 'cyan'
|
||||||
else:
|
elif address.is_static:
|
||||||
if address.is_static:
|
|
||||||
type_color = 'green'
|
type_color = 'green'
|
||||||
address_qualifier = '(static)'
|
address_qualifier = '(static)'
|
||||||
elif address.is_resolvable:
|
elif address.is_resolvable:
|
||||||
@@ -116,6 +121,7 @@ async def scan(
|
|||||||
phy,
|
phy,
|
||||||
filter_duplicates,
|
filter_duplicates,
|
||||||
raw,
|
raw,
|
||||||
|
irks,
|
||||||
keystore_file,
|
keystore_file,
|
||||||
device_config,
|
device_config,
|
||||||
transport,
|
transport,
|
||||||
@@ -140,9 +146,21 @@ async def scan(
|
|||||||
|
|
||||||
if device.keystore:
|
if device.keystore:
|
||||||
resolving_keys = await device.keystore.get_resolving_keys()
|
resolving_keys = await device.keystore.get_resolving_keys()
|
||||||
resolver = AddressResolver(resolving_keys)
|
|
||||||
else:
|
else:
|
||||||
resolver = None
|
resolving_keys = []
|
||||||
|
|
||||||
|
for irk_and_address in irks:
|
||||||
|
if ':' not in irk_and_address:
|
||||||
|
raise ValueError('invalid IRK:ADDRESS value')
|
||||||
|
irk_hex, address_str = irk_and_address.split(':', 1)
|
||||||
|
resolving_keys.append(
|
||||||
|
(
|
||||||
|
bytes.fromhex(irk_hex),
|
||||||
|
Address(address_str, Address.RANDOM_DEVICE_ADDRESS),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resolver = AddressResolver(resolving_keys) if resolving_keys else None
|
||||||
|
|
||||||
printer = AdvertisementPrinter(min_rssi, resolver)
|
printer = AdvertisementPrinter(min_rssi, resolver)
|
||||||
if raw:
|
if raw:
|
||||||
@@ -187,8 +205,24 @@ async def scan(
|
|||||||
default=False,
|
default=False,
|
||||||
help='Listen for raw advertising reports instead of processed ones',
|
help='Listen for raw advertising reports instead of processed ones',
|
||||||
)
|
)
|
||||||
@click.option('--keystore-file', help='Keystore file to use when resolving addresses')
|
@click.option(
|
||||||
@click.option('--device-config', help='Device config file for the scanning device')
|
'--irk',
|
||||||
|
metavar='<IRK_HEX>:<ADDRESS>',
|
||||||
|
help=(
|
||||||
|
'Use this IRK for resolving private addresses ' '(may be used more than once)'
|
||||||
|
),
|
||||||
|
multiple=True,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--keystore-file',
|
||||||
|
metavar='FILE_PATH',
|
||||||
|
help='Keystore file to use when resolving addresses',
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
'--device-config',
|
||||||
|
metavar='FILE_PATH',
|
||||||
|
help='Device config file for the scanning device',
|
||||||
|
)
|
||||||
@click.argument('transport')
|
@click.argument('transport')
|
||||||
def main(
|
def main(
|
||||||
min_rssi,
|
min_rssi,
|
||||||
@@ -198,6 +232,7 @@ def main(
|
|||||||
phy,
|
phy,
|
||||||
filter_duplicates,
|
filter_duplicates,
|
||||||
raw,
|
raw,
|
||||||
|
irk,
|
||||||
keystore_file,
|
keystore_file,
|
||||||
device_config,
|
device_config,
|
||||||
transport,
|
transport,
|
||||||
@@ -212,6 +247,7 @@ def main(
|
|||||||
phy,
|
phy,
|
||||||
filter_duplicates,
|
filter_duplicates,
|
||||||
raw,
|
raw,
|
||||||
|
irk,
|
||||||
keystore_file,
|
keystore_file,
|
||||||
device_config,
|
device_config,
|
||||||
transport,
|
transport,
|
||||||
|
|||||||
75
apps/show.py
75
apps/show.py
@@ -15,7 +15,11 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
@@ -24,6 +28,14 @@ from bumble.transport.common import PacketReader
|
|||||||
from bumble.helpers import PacketTracer
|
from bumble.helpers import PacketTracer
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Classes
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class SnoopPacketReader:
|
class SnoopPacketReader:
|
||||||
'''
|
'''
|
||||||
@@ -36,12 +48,18 @@ class SnoopPacketReader:
|
|||||||
DATALINK_BSCP = 1003
|
DATALINK_BSCP = 1003
|
||||||
DATALINK_H5 = 1004
|
DATALINK_H5 = 1004
|
||||||
|
|
||||||
|
IDENTIFICATION_PATTERN = b'btsnoop\0'
|
||||||
|
TIMESTAMP_ANCHOR = datetime.datetime(2000, 1, 1)
|
||||||
|
TIMESTAMP_DELTA = 0x00E03AB44A676000
|
||||||
|
ONE_MICROSECOND = datetime.timedelta(microseconds=1)
|
||||||
|
|
||||||
def __init__(self, source):
|
def __init__(self, source):
|
||||||
self.source = source
|
self.source = source
|
||||||
|
self.at_end = False
|
||||||
|
|
||||||
# Read the header
|
# Read the header
|
||||||
identification_pattern = source.read(8)
|
identification_pattern = source.read(8)
|
||||||
if identification_pattern.hex().lower() != '6274736e6f6f7000':
|
if identification_pattern != self.IDENTIFICATION_PATTERN:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'not a valid snoop file, unexpected identification pattern'
|
'not a valid snoop file, unexpected identification pattern'
|
||||||
)
|
)
|
||||||
@@ -55,19 +73,32 @@ class SnoopPacketReader:
|
|||||||
# Read the record header
|
# Read the record header
|
||||||
header = self.source.read(24)
|
header = self.source.read(24)
|
||||||
if len(header) < 24:
|
if len(header) < 24:
|
||||||
return (0, None)
|
self.at_end = True
|
||||||
|
return (None, 0, None)
|
||||||
|
|
||||||
|
# Parse the header
|
||||||
(
|
(
|
||||||
original_length,
|
original_length,
|
||||||
included_length,
|
included_length,
|
||||||
packet_flags,
|
packet_flags,
|
||||||
_cumulative_drops,
|
_cumulative_drops,
|
||||||
_timestamp_seconds,
|
timestamp,
|
||||||
_timestamp_microsecond,
|
) = struct.unpack('>IIIIQ', header)
|
||||||
) = struct.unpack('>IIIIII', header)
|
|
||||||
|
|
||||||
# Abort on truncated packets
|
# Skip truncated packets
|
||||||
if original_length != included_length:
|
if original_length != included_length:
|
||||||
return (0, None)
|
print(
|
||||||
|
color(
|
||||||
|
f"!!! truncated packet ({included_length}/{original_length})", "red"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.source.read(included_length)
|
||||||
|
return (None, 0, None)
|
||||||
|
|
||||||
|
# Convert the timestamp to a datetime object.
|
||||||
|
ts_dt = self.TIMESTAMP_ANCHOR + datetime.timedelta(
|
||||||
|
microseconds=timestamp - self.TIMESTAMP_DELTA
|
||||||
|
)
|
||||||
|
|
||||||
if self.data_link_type == self.DATALINK_H1:
|
if self.data_link_type == self.DATALINK_H1:
|
||||||
# The packet is un-encapsulated, look at the flags to figure out its type
|
# The packet is un-encapsulated, look at the flags to figure out its type
|
||||||
@@ -89,7 +120,17 @@ class SnoopPacketReader:
|
|||||||
bytes([packet_type]) + self.source.read(included_length),
|
bytes([packet_type]) + self.source.read(included_length),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (packet_flags & 1, self.source.read(included_length))
|
return (ts_dt, packet_flags & 1, self.source.read(included_length))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Printer:
|
||||||
|
def __init__(self):
|
||||||
|
self.index = 0
|
||||||
|
|
||||||
|
def print(self, message: str) -> None:
|
||||||
|
self.index += 1
|
||||||
|
print(f"[{self.index:8}]{message}")
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -122,24 +163,28 @@ def main(format, vendors, filename):
|
|||||||
packet_reader = PacketReader(input)
|
packet_reader = PacketReader(input)
|
||||||
|
|
||||||
def read_next_packet():
|
def read_next_packet():
|
||||||
return (0, packet_reader.next_packet())
|
return (None, 0, packet_reader.next_packet())
|
||||||
|
|
||||||
else:
|
else:
|
||||||
packet_reader = SnoopPacketReader(input)
|
packet_reader = SnoopPacketReader(input)
|
||||||
read_next_packet = packet_reader.next_packet
|
read_next_packet = packet_reader.next_packet
|
||||||
|
|
||||||
tracer = PacketTracer(emit_message=print)
|
printer = Printer()
|
||||||
|
tracer = PacketTracer(emit_message=printer.print)
|
||||||
|
|
||||||
while True:
|
while not packet_reader.at_end:
|
||||||
try:
|
try:
|
||||||
(direction, packet) = read_next_packet()
|
(timestamp, direction, packet) = read_next_packet()
|
||||||
if packet is None:
|
if packet:
|
||||||
break
|
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction, timestamp)
|
||||||
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
|
else:
|
||||||
|
printer.print(color("[TRUNCATED]", "red"))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
logger.exception()
|
||||||
print(color(f'!!! {error}', 'red'))
|
print(color(f'!!! {error}', 'red'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||||
main() # pylint: disable=no-value-for-parameter
|
main() # pylint: disable=no-value-for-parameter
|
||||||
|
|||||||
@@ -182,11 +182,15 @@ def make_audio_source_service_sdp_records(service_record_handle, version=(1, 3))
|
|||||||
),
|
),
|
||||||
ServiceAttribute(
|
ServiceAttribute(
|
||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
DataElement.sequence(
|
DataElement.sequence(
|
||||||
[
|
[
|
||||||
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
||||||
DataElement.unsigned_integer_16(version_int),
|
DataElement.unsigned_integer_16(version_int),
|
||||||
]
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -232,11 +236,15 @@ def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
|
|||||||
),
|
),
|
||||||
ServiceAttribute(
|
ServiceAttribute(
|
||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
DataElement.sequence(
|
||||||
|
[
|
||||||
DataElement.sequence(
|
DataElement.sequence(
|
||||||
[
|
[
|
||||||
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
|
||||||
DataElement.unsigned_integer_16(version_int),
|
DataElement.unsigned_integer_16(version_int),
|
||||||
]
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,9 +25,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import enum
|
import enum
|
||||||
import functools
|
import functools
|
||||||
|
import inspect
|
||||||
import struct
|
import struct
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Awaitable,
|
||||||
|
Callable,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Type,
|
||||||
|
Union,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
from pyee import EventEmitter
|
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.core import UUID, name_or_number, ProtocolError
|
||||||
from bumble.hci import HCI_Object, key_with_value
|
from bumble.hci import HCI_Object, key_with_value
|
||||||
@@ -722,12 +734,38 @@ class ATT_Handle_Value_Confirmation(ATT_PDU):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class ConnectionValue(Protocol):
|
class AttributeValue:
|
||||||
def read(self, connection) -> bytes:
|
'''
|
||||||
...
|
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
|
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
|
||||||
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
|
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
|
||||||
|
|
||||||
value: Union[str, bytes, ConnectionValue]
|
value: Union[bytes, AttributeValue]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
attribute_type: Union[str, bytes, UUID],
|
attribute_type: Union[str, bytes, UUID],
|
||||||
permissions: Union[str, Attribute.Permissions],
|
permissions: Union[str, Attribute.Permissions],
|
||||||
value: Union[str, bytes, ConnectionValue] = b'',
|
value: Union[str, bytes, AttributeValue] = b'',
|
||||||
) -> None:
|
) -> None:
|
||||||
EventEmitter.__init__(self)
|
EventEmitter.__init__(self)
|
||||||
self.handle = 0
|
self.handle = 0
|
||||||
@@ -806,7 +844,7 @@ class Attribute(EventEmitter):
|
|||||||
def decode_value(self, value_bytes: bytes) -> Any:
|
def decode_value(self, value_bytes: bytes) -> Any:
|
||||||
return value_bytes
|
return value_bytes
|
||||||
|
|
||||||
def read_value(self, connection: Optional[Connection]) -> bytes:
|
async def read_value(self, connection: Optional[Connection]) -> bytes:
|
||||||
if (
|
if (
|
||||||
(self.permissions & self.READ_REQUIRES_ENCRYPTION)
|
(self.permissions & self.READ_REQUIRES_ENCRYPTION)
|
||||||
and connection is not None
|
and connection is not None
|
||||||
@@ -832,6 +870,8 @@ class Attribute(EventEmitter):
|
|||||||
if hasattr(self.value, 'read'):
|
if hasattr(self.value, 'read'):
|
||||||
try:
|
try:
|
||||||
value = self.value.read(connection)
|
value = self.value.read(connection)
|
||||||
|
if inspect.isawaitable(value):
|
||||||
|
value = await value
|
||||||
except ATT_Error as error:
|
except ATT_Error as error:
|
||||||
raise ATT_Error(
|
raise ATT_Error(
|
||||||
error_code=error.error_code, att_handle=self.handle
|
error_code=error.error_code, att_handle=self.handle
|
||||||
@@ -841,7 +881,7 @@ class Attribute(EventEmitter):
|
|||||||
|
|
||||||
return self.encode_value(value)
|
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 (
|
if (
|
||||||
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
|
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
|
||||||
) and not connection.encryption:
|
) and not connection.encryption:
|
||||||
@@ -864,7 +904,9 @@ class Attribute(EventEmitter):
|
|||||||
|
|
||||||
if hasattr(self.value, 'write'):
|
if hasattr(self.value, 'write'):
|
||||||
try:
|
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:
|
except ATT_Error as error:
|
||||||
raise ATT_Error(
|
raise ATT_Error(
|
||||||
error_code=error.error_code, att_handle=self.handle
|
error_code=error.error_code, att_handle=self.handle
|
||||||
|
|||||||
520
bumble/avc.py
Normal file
520
bumble/avc.py
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
import enum
|
||||||
|
import struct
|
||||||
|
from typing import Dict, Type, Union, Tuple
|
||||||
|
|
||||||
|
from bumble.utils import OpenIntEnum
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Frame:
|
||||||
|
class SubunitType(enum.IntEnum):
|
||||||
|
# AV/C Digital Interface Command Set General Specification Version 4.1
|
||||||
|
# Table 7.4
|
||||||
|
MONITOR = 0x00
|
||||||
|
AUDIO = 0x01
|
||||||
|
PRINTER = 0x02
|
||||||
|
DISC = 0x03
|
||||||
|
TAPE_RECORDER_OR_PLAYER = 0x04
|
||||||
|
TUNER = 0x05
|
||||||
|
CA = 0x06
|
||||||
|
CAMERA = 0x07
|
||||||
|
PANEL = 0x09
|
||||||
|
BULLETIN_BOARD = 0x0A
|
||||||
|
VENDOR_UNIQUE = 0x1C
|
||||||
|
EXTENDED = 0x1E
|
||||||
|
UNIT = 0x1F
|
||||||
|
|
||||||
|
class OperationCode(OpenIntEnum):
|
||||||
|
# 0x00 - 0x0F: Unit and subunit commands
|
||||||
|
VENDOR_DEPENDENT = 0x00
|
||||||
|
RESERVE = 0x01
|
||||||
|
PLUG_INFO = 0x02
|
||||||
|
|
||||||
|
# 0x10 - 0x3F: Unit commands
|
||||||
|
DIGITAL_OUTPUT = 0x10
|
||||||
|
DIGITAL_INPUT = 0x11
|
||||||
|
CHANNEL_USAGE = 0x12
|
||||||
|
OUTPUT_PLUG_SIGNAL_FORMAT = 0x18
|
||||||
|
INPUT_PLUG_SIGNAL_FORMAT = 0x19
|
||||||
|
GENERAL_BUS_SETUP = 0x1F
|
||||||
|
CONNECT_AV = 0x20
|
||||||
|
DISCONNECT_AV = 0x21
|
||||||
|
CONNECTIONS = 0x22
|
||||||
|
CONNECT = 0x24
|
||||||
|
DISCONNECT = 0x25
|
||||||
|
UNIT_INFO = 0x30
|
||||||
|
SUBUNIT_INFO = 0x31
|
||||||
|
|
||||||
|
# 0x40 - 0x7F: Subunit commands
|
||||||
|
PASS_THROUGH = 0x7C
|
||||||
|
GUI_UPDATE = 0x7D
|
||||||
|
PUSH_GUI_DATA = 0x7E
|
||||||
|
USER_ACTION = 0x7F
|
||||||
|
|
||||||
|
# 0xA0 - 0xBF: Unit and subunit commands
|
||||||
|
VERSION = 0xB0
|
||||||
|
POWER = 0xB2
|
||||||
|
|
||||||
|
subunit_type: SubunitType
|
||||||
|
subunit_id: int
|
||||||
|
opcode: OperationCode
|
||||||
|
operands: bytes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def subclass(subclass):
|
||||||
|
# Infer the opcode from the class name
|
||||||
|
if subclass.__name__.endswith("CommandFrame"):
|
||||||
|
short_name = subclass.__name__.replace("CommandFrame", "")
|
||||||
|
category_class = CommandFrame
|
||||||
|
elif subclass.__name__.endswith("ResponseFrame"):
|
||||||
|
short_name = subclass.__name__.replace("ResponseFrame", "")
|
||||||
|
category_class = ResponseFrame
|
||||||
|
else:
|
||||||
|
raise ValueError(f"invalid subclass name {subclass.__name__}")
|
||||||
|
|
||||||
|
uppercase_indexes = [
|
||||||
|
i for i in range(len(short_name)) if short_name[i].isupper()
|
||||||
|
]
|
||||||
|
uppercase_indexes.append(len(short_name))
|
||||||
|
words = [
|
||||||
|
short_name[uppercase_indexes[i] : uppercase_indexes[i + 1]].upper()
|
||||||
|
for i in range(len(uppercase_indexes) - 1)
|
||||||
|
]
|
||||||
|
opcode_name = "_".join(words)
|
||||||
|
opcode = Frame.OperationCode[opcode_name]
|
||||||
|
category_class.subclasses[opcode] = subclass
|
||||||
|
return subclass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_bytes(data: bytes) -> Frame:
|
||||||
|
if data[0] >> 4 != 0:
|
||||||
|
raise ValueError("first 4 bits must be 0s")
|
||||||
|
|
||||||
|
ctype_or_response = data[0] & 0xF
|
||||||
|
subunit_type = Frame.SubunitType(data[1] >> 3)
|
||||||
|
subunit_id = data[1] & 7
|
||||||
|
|
||||||
|
if subunit_type == Frame.SubunitType.EXTENDED:
|
||||||
|
# Not supported
|
||||||
|
raise NotImplementedError("extended subunit types not supported")
|
||||||
|
|
||||||
|
if subunit_id < 5:
|
||||||
|
opcode_offset = 2
|
||||||
|
elif subunit_id == 5:
|
||||||
|
# Extended to the next byte
|
||||||
|
extension = data[2]
|
||||||
|
if extension == 0:
|
||||||
|
raise ValueError("extended subunit ID value reserved")
|
||||||
|
if extension == 0xFF:
|
||||||
|
subunit_id = 5 + 254 + data[3]
|
||||||
|
opcode_offset = 4
|
||||||
|
else:
|
||||||
|
subunit_id = 5 + extension
|
||||||
|
opcode_offset = 3
|
||||||
|
|
||||||
|
elif subunit_id == 6:
|
||||||
|
raise ValueError("reserved subunit ID")
|
||||||
|
|
||||||
|
opcode = Frame.OperationCode(data[opcode_offset])
|
||||||
|
operands = data[opcode_offset + 1 :]
|
||||||
|
|
||||||
|
# Look for a registered subclass
|
||||||
|
if ctype_or_response < 8:
|
||||||
|
# Command
|
||||||
|
ctype = CommandFrame.CommandType(ctype_or_response)
|
||||||
|
if c_subclass := CommandFrame.subclasses.get(opcode):
|
||||||
|
return c_subclass(
|
||||||
|
ctype,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
*c_subclass.parse_operands(operands),
|
||||||
|
)
|
||||||
|
return CommandFrame(ctype, subunit_type, subunit_id, opcode, operands)
|
||||||
|
else:
|
||||||
|
# Response
|
||||||
|
response = ResponseFrame.ResponseCode(ctype_or_response)
|
||||||
|
if r_subclass := ResponseFrame.subclasses.get(opcode):
|
||||||
|
return r_subclass(
|
||||||
|
response,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
*r_subclass.parse_operands(operands),
|
||||||
|
)
|
||||||
|
return ResponseFrame(response, subunit_type, subunit_id, opcode, operands)
|
||||||
|
|
||||||
|
def to_bytes(
|
||||||
|
self,
|
||||||
|
ctype_or_response: Union[CommandFrame.CommandType, ResponseFrame.ResponseCode],
|
||||||
|
) -> bytes:
|
||||||
|
# TODO: support extended subunit types and ids.
|
||||||
|
return (
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
ctype_or_response,
|
||||||
|
self.subunit_type << 3 | self.subunit_id,
|
||||||
|
self.opcode,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ self.operands
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_string(self, extra: str) -> str:
|
||||||
|
return (
|
||||||
|
f"{self.__class__.__name__}({extra}"
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"opcode={self.opcode.name}, "
|
||||||
|
f"operands={self.operands.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
subunit_type: SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
opcode: OperationCode,
|
||||||
|
operands: bytes,
|
||||||
|
) -> None:
|
||||||
|
self.subunit_type = subunit_type
|
||||||
|
self.subunit_id = subunit_id
|
||||||
|
self.opcode = opcode
|
||||||
|
self.operands = operands
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class CommandFrame(Frame):
|
||||||
|
class CommandType(OpenIntEnum):
|
||||||
|
# AV/C Digital Interface Command Set General Specification Version 4.1
|
||||||
|
# Table 7.1
|
||||||
|
CONTROL = 0x00
|
||||||
|
STATUS = 0x01
|
||||||
|
SPECIFIC_INQUIRY = 0x02
|
||||||
|
NOTIFY = 0x03
|
||||||
|
GENERAL_INQUIRY = 0x04
|
||||||
|
|
||||||
|
subclasses: Dict[Frame.OperationCode, Type[CommandFrame]] = {}
|
||||||
|
ctype: CommandType
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ctype: CommandType,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
opcode: Frame.OperationCode,
|
||||||
|
operands: bytes,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(subunit_type, subunit_id, opcode, operands)
|
||||||
|
self.ctype = ctype
|
||||||
|
|
||||||
|
def __bytes__(self):
|
||||||
|
return self.to_bytes(self.ctype)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.to_string(f"ctype={self.ctype.name}, ")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ResponseFrame(Frame):
|
||||||
|
class ResponseCode(OpenIntEnum):
|
||||||
|
# AV/C Digital Interface Command Set General Specification Version 4.1
|
||||||
|
# Table 7.2
|
||||||
|
NOT_IMPLEMENTED = 0x08
|
||||||
|
ACCEPTED = 0x09
|
||||||
|
REJECTED = 0x0A
|
||||||
|
IN_TRANSITION = 0x0B
|
||||||
|
IMPLEMENTED_OR_STABLE = 0x0C
|
||||||
|
CHANGED = 0x0D
|
||||||
|
INTERIM = 0x0F
|
||||||
|
|
||||||
|
subclasses: Dict[Frame.OperationCode, Type[ResponseFrame]] = {}
|
||||||
|
response: ResponseCode
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
response: ResponseCode,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
opcode: Frame.OperationCode,
|
||||||
|
operands: bytes,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(subunit_type, subunit_id, opcode, operands)
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
def __bytes__(self):
|
||||||
|
return self.to_bytes(self.response)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.to_string(f"response={self.response.name}, ")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class VendorDependentFrame:
|
||||||
|
company_id: int
|
||||||
|
vendor_dependent_data: bytes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
return (
|
||||||
|
struct.unpack(">I", b"\x00" + operands[:3])[0],
|
||||||
|
operands[3:],
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_operands(self) -> bytes:
|
||||||
|
return struct.pack(">I", self.company_id)[1:] + self.vendor_dependent_data
|
||||||
|
|
||||||
|
def __init__(self, company_id: int, vendor_dependent_data: bytes):
|
||||||
|
self.company_id = company_id
|
||||||
|
self.vendor_dependent_data = vendor_dependent_data
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class VendorDependentCommandFrame(VendorDependentFrame, CommandFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ctype: CommandFrame.CommandType,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
company_id: int,
|
||||||
|
vendor_dependent_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
VendorDependentFrame.__init__(self, company_id, vendor_dependent_data)
|
||||||
|
CommandFrame.__init__(
|
||||||
|
self,
|
||||||
|
ctype,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.VENDOR_DEPENDENT,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"VendorDependentCommandFrame(ctype={self.ctype.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"company_id=0x{self.company_id:06X}, "
|
||||||
|
f"vendor_dependent_data={self.vendor_dependent_data.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class VendorDependentResponseFrame(VendorDependentFrame, ResponseFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
response: ResponseFrame.ResponseCode,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
company_id: int,
|
||||||
|
vendor_dependent_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
VendorDependentFrame.__init__(self, company_id, vendor_dependent_data)
|
||||||
|
ResponseFrame.__init__(
|
||||||
|
self,
|
||||||
|
response,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.VENDOR_DEPENDENT,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"VendorDependentResponseFrame(response={self.response.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"company_id=0x{self.company_id:06X}, "
|
||||||
|
f"vendor_dependent_data={self.vendor_dependent_data.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class PassThroughFrame:
|
||||||
|
"""
|
||||||
|
See AV/C Panel Subunit Specification 1.1 - 9.4 PASS THROUGH control command
|
||||||
|
"""
|
||||||
|
|
||||||
|
class StateFlag(enum.IntEnum):
|
||||||
|
PRESSED = 0
|
||||||
|
RELEASED = 1
|
||||||
|
|
||||||
|
class OperationId(OpenIntEnum):
|
||||||
|
SELECT = 0x00
|
||||||
|
UP = 0x01
|
||||||
|
DOWN = 0x01
|
||||||
|
LEFT = 0x03
|
||||||
|
RIGHT = 0x04
|
||||||
|
RIGHT_UP = 0x05
|
||||||
|
RIGHT_DOWN = 0x06
|
||||||
|
LEFT_UP = 0x07
|
||||||
|
LEFT_DOWN = 0x08
|
||||||
|
ROOT_MENU = 0x09
|
||||||
|
SETUP_MENU = 0x0A
|
||||||
|
CONTENTS_MENU = 0x0B
|
||||||
|
FAVORITE_MENU = 0x0C
|
||||||
|
EXIT = 0x0D
|
||||||
|
NUMBER_0 = 0x20
|
||||||
|
NUMBER_1 = 0x21
|
||||||
|
NUMBER_2 = 0x22
|
||||||
|
NUMBER_3 = 0x23
|
||||||
|
NUMBER_4 = 0x24
|
||||||
|
NUMBER_5 = 0x25
|
||||||
|
NUMBER_6 = 0x26
|
||||||
|
NUMBER_7 = 0x27
|
||||||
|
NUMBER_8 = 0x28
|
||||||
|
NUMBER_9 = 0x29
|
||||||
|
DOT = 0x2A
|
||||||
|
ENTER = 0x2B
|
||||||
|
CLEAR = 0x2C
|
||||||
|
CHANNEL_UP = 0x30
|
||||||
|
CHANNEL_DOWN = 0x31
|
||||||
|
PREVIOUS_CHANNEL = 0x32
|
||||||
|
SOUND_SELECT = 0x33
|
||||||
|
INPUT_SELECT = 0x34
|
||||||
|
DISPLAY_INFORMATION = 0x35
|
||||||
|
HELP = 0x36
|
||||||
|
PAGE_UP = 0x37
|
||||||
|
PAGE_DOWN = 0x38
|
||||||
|
POWER = 0x40
|
||||||
|
VOLUME_UP = 0x41
|
||||||
|
VOLUME_DOWN = 0x42
|
||||||
|
MUTE = 0x43
|
||||||
|
PLAY = 0x44
|
||||||
|
STOP = 0x45
|
||||||
|
PAUSE = 0x46
|
||||||
|
RECORD = 0x47
|
||||||
|
REWIND = 0x48
|
||||||
|
FAST_FORWARD = 0x49
|
||||||
|
EJECT = 0x4A
|
||||||
|
FORWARD = 0x4B
|
||||||
|
BACKWARD = 0x4C
|
||||||
|
ANGLE = 0x50
|
||||||
|
SUBPICTURE = 0x51
|
||||||
|
F1 = 0x71
|
||||||
|
F2 = 0x72
|
||||||
|
F3 = 0x73
|
||||||
|
F4 = 0x74
|
||||||
|
F5 = 0x75
|
||||||
|
VENDOR_UNIQUE = 0x7E
|
||||||
|
|
||||||
|
state_flag: StateFlag
|
||||||
|
operation_id: OperationId
|
||||||
|
operation_data: bytes
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_operands(operands: bytes) -> Tuple:
|
||||||
|
return (
|
||||||
|
PassThroughFrame.StateFlag(operands[0] >> 7),
|
||||||
|
PassThroughFrame.OperationId(operands[0] & 0x7F),
|
||||||
|
operands[1 : 1 + operands[1]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_operands(self):
|
||||||
|
return (
|
||||||
|
bytes([self.state_flag << 7 | self.operation_id, len(self.operation_data)])
|
||||||
|
+ self.operation_data
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
state_flag: StateFlag,
|
||||||
|
operation_id: OperationId,
|
||||||
|
operation_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
if len(operation_data) > 255:
|
||||||
|
raise ValueError("operation data must be <= 255 bytes")
|
||||||
|
self.state_flag = state_flag
|
||||||
|
self.operation_id = operation_id
|
||||||
|
self.operation_data = operation_data
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class PassThroughCommandFrame(PassThroughFrame, CommandFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ctype: CommandFrame.CommandType,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
state_flag: PassThroughFrame.StateFlag,
|
||||||
|
operation_id: PassThroughFrame.OperationId,
|
||||||
|
operation_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
PassThroughFrame.__init__(self, state_flag, operation_id, operation_data)
|
||||||
|
CommandFrame.__init__(
|
||||||
|
self,
|
||||||
|
ctype,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.PASS_THROUGH,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"PassThroughCommandFrame(ctype={self.ctype.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"state_flag={self.state_flag.name}, "
|
||||||
|
f"operation_id={self.operation_id.name}, "
|
||||||
|
f"operation_data={self.operation_data.hex()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@Frame.subclass
|
||||||
|
class PassThroughResponseFrame(PassThroughFrame, ResponseFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
response: ResponseFrame.ResponseCode,
|
||||||
|
subunit_type: Frame.SubunitType,
|
||||||
|
subunit_id: int,
|
||||||
|
state_flag: PassThroughFrame.StateFlag,
|
||||||
|
operation_id: PassThroughFrame.OperationId,
|
||||||
|
operation_data: bytes,
|
||||||
|
) -> None:
|
||||||
|
PassThroughFrame.__init__(self, state_flag, operation_id, operation_data)
|
||||||
|
ResponseFrame.__init__(
|
||||||
|
self,
|
||||||
|
response,
|
||||||
|
subunit_type,
|
||||||
|
subunit_id,
|
||||||
|
Frame.OperationCode.PASS_THROUGH,
|
||||||
|
self.make_operands(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"PassThroughResponseFrame(response={self.response.name}, "
|
||||||
|
f"subunit_type={self.subunit_type.name}, "
|
||||||
|
f"subunit_id=0x{self.subunit_id:02X}, "
|
||||||
|
f"state_flag={self.state_flag.name}, "
|
||||||
|
f"operation_id={self.operation_id.name}, "
|
||||||
|
f"operation_data={self.operation_data.hex()})"
|
||||||
|
)
|
||||||
291
bumble/avctp.py
Normal file
291
bumble/avctp.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
from enum import IntEnum
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
from typing import Callable, cast, Dict, Optional
|
||||||
|
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import l2cap
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
AVCTP_PSM = 0x0017
|
||||||
|
AVCTP_BROWSING_PSM = 0x001B
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class MessageAssembler:
|
||||||
|
Callback = Callable[[int, bool, bool, int, bytes], None]
|
||||||
|
|
||||||
|
transaction_label: int
|
||||||
|
pid: int
|
||||||
|
c_r: int
|
||||||
|
ipid: int
|
||||||
|
payload: bytes
|
||||||
|
number_of_packets: int
|
||||||
|
packets_received: int
|
||||||
|
|
||||||
|
def __init__(self, callback: Callback) -> None:
|
||||||
|
self.callback = callback
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.packets_received = 0
|
||||||
|
self.transaction_label = -1
|
||||||
|
self.pid = -1
|
||||||
|
self.c_r = -1
|
||||||
|
self.ipid = -1
|
||||||
|
self.payload = b''
|
||||||
|
self.number_of_packets = 0
|
||||||
|
self.packet_count = 0
|
||||||
|
|
||||||
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
|
self.packets_received += 1
|
||||||
|
|
||||||
|
transaction_label = pdu[0] >> 4
|
||||||
|
packet_type = Protocol.PacketType((pdu[0] >> 2) & 3)
|
||||||
|
c_r = (pdu[0] >> 1) & 1
|
||||||
|
ipid = pdu[0] & 1
|
||||||
|
|
||||||
|
if c_r == 0 and ipid != 0:
|
||||||
|
logger.warning("invalid IPID in command frame")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
pid_offset = 1
|
||||||
|
if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.START):
|
||||||
|
if self.transaction_label >= 0:
|
||||||
|
# We are already in a transaction
|
||||||
|
logger.warning("received START or SINGLE fragment while in transaction")
|
||||||
|
self.reset()
|
||||||
|
self.packets_received = 1
|
||||||
|
|
||||||
|
if packet_type == Protocol.PacketType.START:
|
||||||
|
self.number_of_packets = pdu[1]
|
||||||
|
pid_offset = 2
|
||||||
|
|
||||||
|
pid = struct.unpack_from(">H", pdu, pid_offset)[0]
|
||||||
|
self.payload += pdu[pid_offset + 2 :]
|
||||||
|
|
||||||
|
if packet_type in (Protocol.PacketType.CONTINUE, Protocol.PacketType.END):
|
||||||
|
if transaction_label != self.transaction_label:
|
||||||
|
logger.warning("transaction label does not match")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if pid != self.pid:
|
||||||
|
logger.warning("PID does not match")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if c_r != self.c_r:
|
||||||
|
logger.warning("C/R does not match")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.packets_received > self.number_of_packets:
|
||||||
|
logger.warning("too many fragments in transaction")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
|
||||||
|
if packet_type == Protocol.PacketType.END:
|
||||||
|
if self.packets_received != self.number_of_packets:
|
||||||
|
logger.warning("premature END")
|
||||||
|
self.reset()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.transaction_label = transaction_label
|
||||||
|
self.c_r = c_r
|
||||||
|
self.ipid = ipid
|
||||||
|
self.pid = pid
|
||||||
|
|
||||||
|
if packet_type in (Protocol.PacketType.SINGLE, Protocol.PacketType.END):
|
||||||
|
self.on_message_complete()
|
||||||
|
|
||||||
|
def on_message_complete(self):
|
||||||
|
try:
|
||||||
|
self.callback(
|
||||||
|
self.transaction_label,
|
||||||
|
self.c_r == 0,
|
||||||
|
self.ipid != 0,
|
||||||
|
self.pid,
|
||||||
|
self.payload,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.exception(color(f"!!! exception in callback: {error}", "red"))
|
||||||
|
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Protocol:
|
||||||
|
CommandHandler = Callable[[int, avc.CommandFrame], None]
|
||||||
|
command_handlers: Dict[int, CommandHandler] # Command handlers, by PID
|
||||||
|
ResponseHandler = Callable[[int, Optional[avc.ResponseFrame]], None]
|
||||||
|
response_handlers: Dict[int, ResponseHandler] # Response handlers, by PID
|
||||||
|
next_transaction_label: int
|
||||||
|
message_assembler: MessageAssembler
|
||||||
|
|
||||||
|
class PacketType(IntEnum):
|
||||||
|
SINGLE = 0b00
|
||||||
|
START = 0b01
|
||||||
|
CONTINUE = 0b10
|
||||||
|
END = 0b11
|
||||||
|
|
||||||
|
def __init__(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
||||||
|
self.command_handlers = {}
|
||||||
|
self.response_handlers = {}
|
||||||
|
self.l2cap_channel = l2cap_channel
|
||||||
|
self.message_assembler = MessageAssembler(self.on_message)
|
||||||
|
|
||||||
|
# Register to receive PDUs from the channel
|
||||||
|
l2cap_channel.sink = self.on_pdu
|
||||||
|
l2cap_channel.on("open", self.on_l2cap_channel_open)
|
||||||
|
l2cap_channel.on("close", self.on_l2cap_channel_close)
|
||||||
|
|
||||||
|
def on_l2cap_channel_open(self):
|
||||||
|
logger.debug(color("<<< AVCTP channel open", "magenta"))
|
||||||
|
|
||||||
|
def on_l2cap_channel_close(self):
|
||||||
|
logger.debug(color("<<< AVCTP channel closed", "magenta"))
|
||||||
|
|
||||||
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
|
self.message_assembler.on_pdu(pdu)
|
||||||
|
|
||||||
|
def on_message(
|
||||||
|
self,
|
||||||
|
transaction_label: int,
|
||||||
|
is_command: bool,
|
||||||
|
ipid: bool,
|
||||||
|
pid: int,
|
||||||
|
payload: bytes,
|
||||||
|
) -> None:
|
||||||
|
logger.debug(
|
||||||
|
f"<<< AVCTP Message: pid={pid}, "
|
||||||
|
f"transaction_label={transaction_label}, "
|
||||||
|
f"is_command={is_command}, "
|
||||||
|
f"ipid={ipid}, "
|
||||||
|
f"payload={payload.hex()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for invalid PID responses.
|
||||||
|
if ipid:
|
||||||
|
logger.debug(f"received IPID for PID={pid}")
|
||||||
|
|
||||||
|
# Find the appropriate handler.
|
||||||
|
if is_command:
|
||||||
|
if pid not in self.command_handlers:
|
||||||
|
logger.warning(f"no command handler for PID {pid}")
|
||||||
|
self.send_ipid(transaction_label, pid)
|
||||||
|
return
|
||||||
|
|
||||||
|
command_frame = cast(avc.CommandFrame, avc.Frame.from_bytes(payload))
|
||||||
|
self.command_handlers[pid](transaction_label, command_frame)
|
||||||
|
else:
|
||||||
|
if pid not in self.response_handlers:
|
||||||
|
logger.warning(f"no response handler for PID {pid}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# By convention, for an ipid, send a None payload to the response handler.
|
||||||
|
if ipid:
|
||||||
|
response_frame = None
|
||||||
|
else:
|
||||||
|
response_frame = cast(avc.ResponseFrame, avc.Frame.from_bytes(payload))
|
||||||
|
|
||||||
|
self.response_handlers[pid](transaction_label, response_frame)
|
||||||
|
|
||||||
|
def send_message(
|
||||||
|
self,
|
||||||
|
transaction_label: int,
|
||||||
|
is_command: bool,
|
||||||
|
ipid: bool,
|
||||||
|
pid: int,
|
||||||
|
payload: bytes,
|
||||||
|
):
|
||||||
|
# TODO: fragment large messages
|
||||||
|
packet_type = Protocol.PacketType.SINGLE
|
||||||
|
pdu = (
|
||||||
|
struct.pack(
|
||||||
|
">BH",
|
||||||
|
transaction_label << 4
|
||||||
|
| packet_type << 2
|
||||||
|
| (0 if is_command else 1) << 1
|
||||||
|
| (1 if ipid else 0),
|
||||||
|
pid,
|
||||||
|
)
|
||||||
|
+ payload
|
||||||
|
)
|
||||||
|
self.l2cap_channel.send_pdu(pdu)
|
||||||
|
|
||||||
|
def send_command(self, transaction_label: int, pid: int, payload: bytes) -> None:
|
||||||
|
logger.debug(
|
||||||
|
">>> AVCTP command: "
|
||||||
|
f"transaction_label={transaction_label}, "
|
||||||
|
f"pid={pid}, "
|
||||||
|
f"payload={payload.hex()}"
|
||||||
|
)
|
||||||
|
self.send_message(transaction_label, True, False, pid, payload)
|
||||||
|
|
||||||
|
def send_response(self, transaction_label: int, pid: int, payload: bytes):
|
||||||
|
logger.debug(
|
||||||
|
">>> AVCTP response: "
|
||||||
|
f"transaction_label={transaction_label}, "
|
||||||
|
f"pid={pid}, "
|
||||||
|
f"payload={payload.hex()}"
|
||||||
|
)
|
||||||
|
self.send_message(transaction_label, False, False, pid, payload)
|
||||||
|
|
||||||
|
def send_ipid(self, transaction_label: int, pid: int) -> None:
|
||||||
|
logger.debug(
|
||||||
|
">>> AVCTP ipid: " f"transaction_label={transaction_label}, " f"pid={pid}"
|
||||||
|
)
|
||||||
|
self.send_message(transaction_label, False, True, pid, b'')
|
||||||
|
|
||||||
|
def register_command_handler(
|
||||||
|
self, pid: int, handler: Protocol.CommandHandler
|
||||||
|
) -> None:
|
||||||
|
self.command_handlers[pid] = handler
|
||||||
|
|
||||||
|
def unregister_command_handler(
|
||||||
|
self, pid: int, handler: Protocol.CommandHandler
|
||||||
|
) -> None:
|
||||||
|
if pid not in self.command_handlers or self.command_handlers[pid] != handler:
|
||||||
|
raise ValueError("command handler not registered")
|
||||||
|
del self.command_handlers[pid]
|
||||||
|
|
||||||
|
def register_response_handler(
|
||||||
|
self, pid: int, handler: Protocol.ResponseHandler
|
||||||
|
) -> None:
|
||||||
|
self.response_handlers[pid] = handler
|
||||||
|
|
||||||
|
def unregister_response_handler(
|
||||||
|
self, pid: int, handler: Protocol.ResponseHandler
|
||||||
|
) -> None:
|
||||||
|
if pid not in self.response_handlers or self.response_handlers[pid] != handler:
|
||||||
|
raise ValueError("response handler not registered")
|
||||||
|
del self.response_handlers[pid]
|
||||||
@@ -241,7 +241,10 @@ async def find_avdtp_service_with_sdp_client(
|
|||||||
)
|
)
|
||||||
if profile_descriptor_list:
|
if profile_descriptor_list:
|
||||||
for profile_descriptor in profile_descriptor_list.value:
|
for profile_descriptor in profile_descriptor_list.value:
|
||||||
if len(profile_descriptor.value) >= 2:
|
if (
|
||||||
|
profile_descriptor.type == sdp.DataElement.SEQUENCE
|
||||||
|
and len(profile_descriptor.value) >= 2
|
||||||
|
):
|
||||||
avdtp_version_major = profile_descriptor.value[1].value >> 8
|
avdtp_version_major = profile_descriptor.value[1].value >> 8
|
||||||
avdtp_version_minor = profile_descriptor.value[1].value & 0xFF
|
avdtp_version_minor = profile_descriptor.value[1].value & 0xFF
|
||||||
return (avdtp_version_major, avdtp_version_minor)
|
return (avdtp_version_major, avdtp_version_minor)
|
||||||
@@ -511,7 +514,8 @@ class MessageAssembler:
|
|||||||
try:
|
try:
|
||||||
self.callback(self.transaction_label, message)
|
self.callback(self.transaction_label, message)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.warning(color(f'!!! exception in callback: {error}'))
|
logger.exception(color(f'!!! exception in callback: {error}', 'red'))
|
||||||
|
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
|
|
||||||
@@ -1466,10 +1470,10 @@ class Protocol(EventEmitter):
|
|||||||
f'[{transaction_label}] {message}'
|
f'[{transaction_label}] {message}'
|
||||||
)
|
)
|
||||||
max_fragment_size = (
|
max_fragment_size = (
|
||||||
self.l2cap_channel.mtu - 3
|
self.l2cap_channel.peer_mtu - 3
|
||||||
) # Enough space for a 3-byte start packet header
|
) # Enough space for a 3-byte start packet header
|
||||||
payload = message.payload
|
payload = message.payload
|
||||||
if len(payload) + 2 <= self.l2cap_channel.mtu:
|
if len(payload) + 2 <= self.l2cap_channel.peer_mtu:
|
||||||
# Fits in a single packet
|
# Fits in a single packet
|
||||||
packet_type = self.PacketType.SINGLE_PACKET
|
packet_type = self.PacketType.SINGLE_PACKET
|
||||||
else:
|
else:
|
||||||
|
|||||||
1916
bumble/avrcp.py
Normal file
1916
bumble/avrcp.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
import itertools
|
import itertools
|
||||||
import random
|
import random
|
||||||
import struct
|
import struct
|
||||||
@@ -42,6 +43,7 @@ from bumble.hci import (
|
|||||||
HCI_LE_1M_PHY,
|
HCI_LE_1M_PHY,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_UNKNOWN_HCI_COMMAND_ERROR,
|
HCI_UNKNOWN_HCI_COMMAND_ERROR,
|
||||||
|
HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||||
HCI_VERSION_BLUETOOTH_CORE_5_0,
|
HCI_VERSION_BLUETOOTH_CORE_5_0,
|
||||||
Address,
|
Address,
|
||||||
@@ -53,17 +55,21 @@ from bumble.hci import (
|
|||||||
HCI_Connection_Request_Event,
|
HCI_Connection_Request_Event,
|
||||||
HCI_Disconnection_Complete_Event,
|
HCI_Disconnection_Complete_Event,
|
||||||
HCI_Encryption_Change_Event,
|
HCI_Encryption_Change_Event,
|
||||||
|
HCI_Synchronous_Connection_Complete_Event,
|
||||||
HCI_LE_Advertising_Report_Event,
|
HCI_LE_Advertising_Report_Event,
|
||||||
|
HCI_LE_CIS_Established_Event,
|
||||||
|
HCI_LE_CIS_Request_Event,
|
||||||
HCI_LE_Connection_Complete_Event,
|
HCI_LE_Connection_Complete_Event,
|
||||||
HCI_LE_Read_Remote_Features_Complete_Event,
|
HCI_LE_Read_Remote_Features_Complete_Event,
|
||||||
HCI_Number_Of_Completed_Packets_Event,
|
HCI_Number_Of_Completed_Packets_Event,
|
||||||
HCI_Packet,
|
HCI_Packet,
|
||||||
HCI_Role_Change_Event,
|
HCI_Role_Change_Event,
|
||||||
)
|
)
|
||||||
from typing import Optional, Union, Dict, TYPE_CHECKING
|
from typing import Optional, Union, Dict, Any, TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.transport.common import TransportSink, TransportSource
|
from bumble.link import LocalLink
|
||||||
|
from bumble.transport.common import TransportSink
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -79,15 +85,27 @@ class DataObject:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CisLink:
|
||||||
|
handle: int
|
||||||
|
cis_id: int
|
||||||
|
cig_id: int
|
||||||
|
acl_connection: Optional[Connection] = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
class Connection:
|
class Connection:
|
||||||
def __init__(self, controller, handle, role, peer_address, link, transport):
|
controller: Controller
|
||||||
self.controller = controller
|
handle: int
|
||||||
self.handle = handle
|
role: int
|
||||||
self.role = role
|
peer_address: Address
|
||||||
self.peer_address = peer_address
|
link: Any
|
||||||
self.link = link
|
transport: int
|
||||||
|
link_type: int
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.transport = transport
|
|
||||||
|
|
||||||
def on_hci_acl_data_packet(self, packet):
|
def on_hci_acl_data_packet(self, packet):
|
||||||
self.assembler.feed_packet(packet)
|
self.assembler.feed_packet(packet)
|
||||||
@@ -106,10 +124,10 @@ class Connection:
|
|||||||
class Controller:
|
class Controller:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name,
|
name: str,
|
||||||
host_source=None,
|
host_source=None,
|
||||||
host_sink: Optional[TransportSink] = None,
|
host_sink: Optional[TransportSink] = None,
|
||||||
link=None,
|
link: Optional[LocalLink] = None,
|
||||||
public_address: Optional[Union[bytes, str, Address]] = None,
|
public_address: Optional[Union[bytes, str, Address]] = None,
|
||||||
):
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -125,6 +143,8 @@ class Controller:
|
|||||||
self.classic_connections: Dict[
|
self.classic_connections: Dict[
|
||||||
Address, Connection
|
Address, Connection
|
||||||
] = {} # Connections in BR/EDR
|
] = {} # Connections in BR/EDR
|
||||||
|
self.central_cis_links: Dict[int, CisLink] = {} # CIS links by handle
|
||||||
|
self.peripheral_cis_links: Dict[int, CisLink] = {} # CIS links by handle
|
||||||
|
|
||||||
self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
|
self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
|
||||||
self.hci_revision = 0
|
self.hci_revision = 0
|
||||||
@@ -134,12 +154,14 @@ class Controller:
|
|||||||
'0000000060000000'
|
'0000000060000000'
|
||||||
) # BR/EDR Not Supported, LE Supported (Controller)
|
) # BR/EDR Not Supported, LE Supported (Controller)
|
||||||
self.manufacturer_name = 0xFFFF
|
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_le_data_packet_length = 27
|
||||||
self.hc_total_num_le_data_packets = 64
|
self.hc_total_num_le_data_packets = 64
|
||||||
self.event_mask = 0
|
self.event_mask = 0
|
||||||
self.event_mask_page_2 = 0
|
self.event_mask_page_2 = 0
|
||||||
self.supported_commands = bytes.fromhex(
|
self.supported_commands = bytes.fromhex(
|
||||||
'2000800000c000000000e40000002822000000000000040000f7ffff7f000000'
|
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
|
||||||
'30f0f9ff01008004000000000000000000000000000000000000000000000000'
|
'30f0f9ff01008004000000000000000000000000000000000000000000000000'
|
||||||
)
|
)
|
||||||
self.le_event_mask = 0
|
self.le_event_mask = 0
|
||||||
@@ -301,7 +323,7 @@ class Controller:
|
|||||||
############################################################
|
############################################################
|
||||||
# Link connections
|
# Link connections
|
||||||
############################################################
|
############################################################
|
||||||
def allocate_connection_handle(self):
|
def allocate_connection_handle(self) -> int:
|
||||||
handle = 0
|
handle = 0
|
||||||
max_handle = 0
|
max_handle = 0
|
||||||
for connection in itertools.chain(
|
for connection in itertools.chain(
|
||||||
@@ -313,6 +335,13 @@ class Controller:
|
|||||||
if connection.handle == handle:
|
if connection.handle == handle:
|
||||||
# Already used, continue searching after the current max
|
# Already used, continue searching after the current max
|
||||||
handle = max_handle + 1
|
handle = max_handle + 1
|
||||||
|
for cis_handle in itertools.chain(
|
||||||
|
self.central_cis_links.keys(), self.peripheral_cis_links.keys()
|
||||||
|
):
|
||||||
|
max_handle = max(max_handle, cis_handle)
|
||||||
|
if cis_handle == handle:
|
||||||
|
# Already used, continue searching after the current max
|
||||||
|
handle = max_handle + 1
|
||||||
return handle
|
return handle
|
||||||
|
|
||||||
def find_le_connection_by_address(self, address):
|
def find_le_connection_by_address(self, address):
|
||||||
@@ -357,12 +386,13 @@ class Controller:
|
|||||||
if connection is None:
|
if connection is None:
|
||||||
connection_handle = self.allocate_connection_handle()
|
connection_handle = self.allocate_connection_handle()
|
||||||
connection = Connection(
|
connection = Connection(
|
||||||
self,
|
controller=self,
|
||||||
connection_handle,
|
handle=connection_handle,
|
||||||
BT_PERIPHERAL_ROLE,
|
role=BT_PERIPHERAL_ROLE,
|
||||||
peer_address,
|
peer_address=peer_address,
|
||||||
self.link,
|
link=self.link,
|
||||||
BT_LE_TRANSPORT,
|
transport=BT_LE_TRANSPORT,
|
||||||
|
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||||
)
|
)
|
||||||
self.peripheral_connections[peer_address] = connection
|
self.peripheral_connections[peer_address] = connection
|
||||||
logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}')
|
logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}')
|
||||||
@@ -416,12 +446,13 @@ class Controller:
|
|||||||
if connection is None:
|
if connection is None:
|
||||||
connection_handle = self.allocate_connection_handle()
|
connection_handle = self.allocate_connection_handle()
|
||||||
connection = Connection(
|
connection = Connection(
|
||||||
self,
|
controller=self,
|
||||||
connection_handle,
|
handle=connection_handle,
|
||||||
BT_CENTRAL_ROLE,
|
role=BT_CENTRAL_ROLE,
|
||||||
peer_address,
|
peer_address=peer_address,
|
||||||
self.link,
|
link=self.link,
|
||||||
BT_LE_TRANSPORT,
|
transport=BT_LE_TRANSPORT,
|
||||||
|
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||||
)
|
)
|
||||||
self.central_connections[peer_address] = connection
|
self.central_connections[peer_address] = connection
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -538,6 +569,104 @@ class Controller:
|
|||||||
)
|
)
|
||||||
self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
|
self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
|
||||||
|
|
||||||
|
def on_link_cis_request(
|
||||||
|
self, central_address: Address, cig_id: int, cis_id: int
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Called when an incoming CIS request occurs from a central on the link
|
||||||
|
'''
|
||||||
|
|
||||||
|
connection = self.peripheral_connections.get(central_address)
|
||||||
|
assert connection
|
||||||
|
|
||||||
|
pending_cis_link = CisLink(
|
||||||
|
handle=self.allocate_connection_handle(),
|
||||||
|
cis_id=cis_id,
|
||||||
|
cig_id=cig_id,
|
||||||
|
acl_connection=connection,
|
||||||
|
)
|
||||||
|
self.peripheral_cis_links[pending_cis_link.handle] = pending_cis_link
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_LE_CIS_Request_Event(
|
||||||
|
acl_connection_handle=connection.handle,
|
||||||
|
cis_connection_handle=pending_cis_link.handle,
|
||||||
|
cig_id=cig_id,
|
||||||
|
cis_id=cis_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_link_cis_established(self, cig_id: int, cis_id: int) -> None:
|
||||||
|
'''
|
||||||
|
Called when an incoming CIS established.
|
||||||
|
'''
|
||||||
|
|
||||||
|
cis_link = next(
|
||||||
|
cis_link
|
||||||
|
for cis_link in itertools.chain(
|
||||||
|
self.central_cis_links.values(), self.peripheral_cis_links.values()
|
||||||
|
)
|
||||||
|
if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
|
||||||
|
)
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_LE_CIS_Established_Event(
|
||||||
|
status=HCI_SUCCESS,
|
||||||
|
connection_handle=cis_link.handle,
|
||||||
|
# CIS parameters are ignored.
|
||||||
|
cig_sync_delay=0,
|
||||||
|
cis_sync_delay=0,
|
||||||
|
transport_latency_c_to_p=0,
|
||||||
|
transport_latency_p_to_c=0,
|
||||||
|
phy_c_to_p=0,
|
||||||
|
phy_p_to_c=0,
|
||||||
|
nse=0,
|
||||||
|
bn_c_to_p=0,
|
||||||
|
bn_p_to_c=0,
|
||||||
|
ft_c_to_p=0,
|
||||||
|
ft_p_to_c=0,
|
||||||
|
max_pdu_c_to_p=0,
|
||||||
|
max_pdu_p_to_c=0,
|
||||||
|
iso_interval=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_link_cis_disconnected(self, cig_id: int, cis_id: int) -> None:
|
||||||
|
'''
|
||||||
|
Called when a CIS disconnected.
|
||||||
|
'''
|
||||||
|
|
||||||
|
if cis_link := next(
|
||||||
|
(
|
||||||
|
cis_link
|
||||||
|
for cis_link in self.peripheral_cis_links.values()
|
||||||
|
if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
):
|
||||||
|
# Remove peripheral CIS on disconnection.
|
||||||
|
self.peripheral_cis_links.pop(cis_link.handle)
|
||||||
|
elif cis_link := next(
|
||||||
|
(
|
||||||
|
cis_link
|
||||||
|
for cis_link in self.central_cis_links.values()
|
||||||
|
if cis_link.cis_id == cis_id and cis_link.cig_id == cig_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
):
|
||||||
|
# Keep central CIS on disconnection. They should be removed by HCI_LE_Remove_CIG_Command.
|
||||||
|
cis_link.acl_connection = None
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Disconnection_Complete_Event(
|
||||||
|
status=HCI_SUCCESS,
|
||||||
|
connection_handle=cis_link.handle,
|
||||||
|
reason=HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Classic link connections
|
# Classic link connections
|
||||||
############################################################
|
############################################################
|
||||||
@@ -566,6 +695,7 @@ class Controller:
|
|||||||
peer_address=peer_address,
|
peer_address=peer_address,
|
||||||
link=self.link,
|
link=self.link,
|
||||||
transport=BT_BR_EDR_TRANSPORT,
|
transport=BT_BR_EDR_TRANSPORT,
|
||||||
|
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||||
)
|
)
|
||||||
self.classic_connections[peer_address] = connection
|
self.classic_connections[peer_address] = connection
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -619,6 +749,42 @@ class Controller:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_classic_sco_connection_complete(
|
||||||
|
self, peer_address: Address, status: int, link_type: int
|
||||||
|
):
|
||||||
|
if status == HCI_SUCCESS:
|
||||||
|
# Allocate (or reuse) a connection handle
|
||||||
|
connection_handle = self.allocate_connection_handle()
|
||||||
|
connection = Connection(
|
||||||
|
controller=self,
|
||||||
|
handle=connection_handle,
|
||||||
|
# Role doesn't matter in SCO.
|
||||||
|
role=BT_CENTRAL_ROLE,
|
||||||
|
peer_address=peer_address,
|
||||||
|
link=self.link,
|
||||||
|
transport=BT_BR_EDR_TRANSPORT,
|
||||||
|
link_type=link_type,
|
||||||
|
)
|
||||||
|
self.classic_connections[peer_address] = connection
|
||||||
|
logger.debug(f'New SCO connection handle: 0x{connection_handle:04X}')
|
||||||
|
else:
|
||||||
|
connection_handle = 0
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Synchronous_Connection_Complete_Event(
|
||||||
|
status=status,
|
||||||
|
connection_handle=connection_handle,
|
||||||
|
bd_addr=peer_address,
|
||||||
|
link_type=link_type,
|
||||||
|
# TODO: Provide SCO connection parameters.
|
||||||
|
transmission_interval=0,
|
||||||
|
retransmission_window=0,
|
||||||
|
rx_packet_length=0,
|
||||||
|
tx_packet_length=0,
|
||||||
|
air_mode=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Advertising support
|
# Advertising support
|
||||||
############################################################
|
############################################################
|
||||||
@@ -721,6 +887,17 @@ class Controller:
|
|||||||
else:
|
else:
|
||||||
# Remove the connection
|
# Remove the connection
|
||||||
del self.classic_connections[connection.peer_address]
|
del self.classic_connections[connection.peer_address]
|
||||||
|
elif cis_link := (
|
||||||
|
self.central_cis_links.get(handle) or self.peripheral_cis_links.get(handle)
|
||||||
|
):
|
||||||
|
if self.link:
|
||||||
|
self.link.disconnect_cis(
|
||||||
|
initiator_controller=self,
|
||||||
|
peer_address=cis_link.acl_connection.peer_address,
|
||||||
|
cig_id=cis_link.cig_id,
|
||||||
|
cis_id=cis_link.cis_id,
|
||||||
|
)
|
||||||
|
# Spec requires handle to be kept after disconnection.
|
||||||
|
|
||||||
def on_hci_accept_connection_request_command(self, command):
|
def on_hci_accept_connection_request_command(self, command):
|
||||||
'''
|
'''
|
||||||
@@ -738,6 +915,68 @@ class Controller:
|
|||||||
)
|
)
|
||||||
self.link.classic_accept_connection(self, command.bd_addr, command.role)
|
self.link.classic_accept_connection(self, command.bd_addr, command.role)
|
||||||
|
|
||||||
|
def on_hci_enhanced_setup_synchronous_connection_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.1.45 Enhanced Setup Synchronous Connection command
|
||||||
|
'''
|
||||||
|
|
||||||
|
if self.link is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not (
|
||||||
|
connection := self.find_classic_connection_by_handle(
|
||||||
|
command.connection_handle
|
||||||
|
)
|
||||||
|
):
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_SUCCESS,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.link.classic_sco_connect(
|
||||||
|
self, connection.peer_address, HCI_Connection_Complete_Event.ESCO_LINK_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_hci_enhanced_accept_synchronous_connection_request_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.1.46 Enhanced Accept Synchronous Connection Request command
|
||||||
|
'''
|
||||||
|
|
||||||
|
if self.link is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not (connection := self.find_classic_connection_by_address(command.bd_addr)):
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_SUCCESS,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.link.classic_accept_sco_connection(
|
||||||
|
self, connection.peer_address, HCI_Connection_Complete_Event.ESCO_LINK_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_switch_role_command(self, command):
|
def on_hci_switch_role_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
|
See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
|
||||||
@@ -912,7 +1151,41 @@ class Controller:
|
|||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
|
See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + self.lmp_features
|
return bytes([HCI_SUCCESS]) + self.lmp_features[:8]
|
||||||
|
|
||||||
|
def on_hci_read_local_extended_features_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.4.4 Read Local Extended Features Command
|
||||||
|
'''
|
||||||
|
if command.page_number * 8 > len(self.lmp_features):
|
||||||
|
return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
|
||||||
|
return (
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
# Status
|
||||||
|
HCI_SUCCESS,
|
||||||
|
# Page number
|
||||||
|
command.page_number,
|
||||||
|
# Max page number
|
||||||
|
len(self.lmp_features) // 8 - 1,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# Features of the current page
|
||||||
|
+ self.lmp_features[command.page_number * 8 : (command.page_number + 1) * 8]
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_hci_read_buffer_size_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.4.5 Read Buffer Size Command
|
||||||
|
'''
|
||||||
|
return struct.pack(
|
||||||
|
'<BHBHH',
|
||||||
|
HCI_SUCCESS,
|
||||||
|
self.hc_data_packet_length,
|
||||||
|
0,
|
||||||
|
self.hc_total_num_data_packets,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_read_bd_addr_command(self, _command):
|
def on_hci_read_bd_addr_command(self, _command):
|
||||||
'''
|
'''
|
||||||
@@ -1089,6 +1362,18 @@ class Controller:
|
|||||||
See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
|
See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
handle = command.connection_handle
|
||||||
|
|
||||||
|
if not self.find_connection_by_handle(handle):
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# First, say that the command is pending
|
# First, say that the command is pending
|
||||||
self.send_hci_packet(
|
self.send_hci_packet(
|
||||||
HCI_Command_Status_Event(
|
HCI_Command_Status_Event(
|
||||||
@@ -1102,7 +1387,7 @@ class Controller:
|
|||||||
self.send_hci_packet(
|
self.send_hci_packet(
|
||||||
HCI_LE_Read_Remote_Features_Complete_Event(
|
HCI_LE_Read_Remote_Features_Complete_Event(
|
||||||
status=HCI_SUCCESS,
|
status=HCI_SUCCESS,
|
||||||
connection_handle=0,
|
connection_handle=handle,
|
||||||
le_features=bytes.fromhex('dd40000000000000'),
|
le_features=bytes.fromhex('dd40000000000000'),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1258,8 +1543,135 @@ class Controller:
|
|||||||
}
|
}
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
|
def on_hci_le_read_maximum_advertising_data_length_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.57 LE Read Maximum Advertising Data
|
||||||
|
Length Command
|
||||||
|
'''
|
||||||
|
return struct.pack('<BH', HCI_SUCCESS, 0x0672)
|
||||||
|
|
||||||
|
def on_hci_le_read_number_of_supported_advertising_sets_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.58 LE Read Number of Supported
|
||||||
|
Advertising Set Command
|
||||||
|
'''
|
||||||
|
return struct.pack('<BB', HCI_SUCCESS, 0xF0)
|
||||||
|
|
||||||
def on_hci_le_read_transmit_power_command(self, _command):
|
def on_hci_le_read_transmit_power_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
|
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
|
||||||
'''
|
'''
|
||||||
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
|
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
|
||||||
|
|
||||||
|
def on_hci_le_set_cig_parameters_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.97 LE Set CIG Parameter Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
# Remove old CIG implicitly.
|
||||||
|
for handle, cis_link in self.central_cis_links.items():
|
||||||
|
if cis_link.cig_id == command.cig_id:
|
||||||
|
self.central_cis_links.pop(handle)
|
||||||
|
|
||||||
|
handles = []
|
||||||
|
for cis_id in command.cis_id:
|
||||||
|
handle = self.allocate_connection_handle()
|
||||||
|
handles.append(handle)
|
||||||
|
self.central_cis_links[handle] = CisLink(
|
||||||
|
cis_id=cis_id,
|
||||||
|
cig_id=command.cig_id,
|
||||||
|
handle=handle,
|
||||||
|
)
|
||||||
|
return struct.pack(
|
||||||
|
'<BBB', HCI_SUCCESS, command.cig_id, len(handles)
|
||||||
|
) + b''.join([struct.pack('<H', handle) for handle in handles])
|
||||||
|
|
||||||
|
def on_hci_le_create_cis_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.99 LE Create CIS Command
|
||||||
|
'''
|
||||||
|
if not self.link:
|
||||||
|
return
|
||||||
|
|
||||||
|
for cis_handle, acl_handle in zip(
|
||||||
|
command.cis_connection_handle, command.acl_connection_handle
|
||||||
|
):
|
||||||
|
if not (connection := self.find_connection_by_handle(acl_handle)):
|
||||||
|
logger.error(f'Cannot find connection with handle={acl_handle}')
|
||||||
|
return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
|
||||||
|
|
||||||
|
if not (cis_link := self.central_cis_links.get(cis_handle)):
|
||||||
|
logger.error(f'Cannot find CIS with handle={cis_handle}')
|
||||||
|
return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
|
||||||
|
|
||||||
|
cis_link.acl_connection = connection
|
||||||
|
|
||||||
|
self.link.create_cis(
|
||||||
|
self,
|
||||||
|
peripheral_address=connection.peer_address,
|
||||||
|
cig_id=cis_link.cig_id,
|
||||||
|
cis_id=cis_link.cis_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_COMMAND_STATUS_PENDING,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_hci_le_remove_cig_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.100 LE Remove CIG Command
|
||||||
|
'''
|
||||||
|
|
||||||
|
status = HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR
|
||||||
|
|
||||||
|
for cis_handle, cis_link in self.central_cis_links.items():
|
||||||
|
if cis_link.cig_id == command.cig_id:
|
||||||
|
self.central_cis_links.pop(cis_handle)
|
||||||
|
status = HCI_SUCCESS
|
||||||
|
|
||||||
|
return struct.pack('<BH', status, command.cig_id)
|
||||||
|
|
||||||
|
def on_hci_le_accept_cis_request_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.101 LE Accept CIS Request Command
|
||||||
|
'''
|
||||||
|
if not self.link:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not (
|
||||||
|
pending_cis_link := self.peripheral_cis_links.get(command.connection_handle)
|
||||||
|
):
|
||||||
|
logger.error(f'Cannot find CIS with handle={command.connection_handle}')
|
||||||
|
return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
|
||||||
|
|
||||||
|
assert pending_cis_link.acl_connection
|
||||||
|
self.link.accept_cis(
|
||||||
|
peripheral_controller=self,
|
||||||
|
central_address=pending_cis_link.acl_connection.peer_address,
|
||||||
|
cig_id=pending_cis_link.cig_id,
|
||||||
|
cis_id=pending_cis_link.cis_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.send_hci_packet(
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_COMMAND_STATUS_PENDING,
|
||||||
|
num_hci_command_packets=1,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_hci_le_setup_iso_data_path_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.109 LE Setup ISO Data Path Command
|
||||||
|
'''
|
||||||
|
return struct.pack('<BH', HCI_SUCCESS, command.connection_handle)
|
||||||
|
|
||||||
|
def on_hci_le_remove_iso_data_path_command(self, command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.8.110 LE Remove ISO Data Path Command
|
||||||
|
'''
|
||||||
|
return struct.pack('<BH', HCI_SUCCESS, command.connection_handle)
|
||||||
|
|||||||
@@ -97,12 +97,16 @@ class BaseError(Exception):
|
|||||||
namespace = f'{self.error_namespace}/'
|
namespace = f'{self.error_namespace}/'
|
||||||
else:
|
else:
|
||||||
namespace = ''
|
namespace = ''
|
||||||
error_text = {
|
have_name = self.error_name != ''
|
||||||
(True, True): f'{self.error_name} [0x{self.error_code:X}]',
|
have_code = self.error_code is not None
|
||||||
(True, False): self.error_name,
|
if have_name and have_code:
|
||||||
(False, True): f'0x{self.error_code:X}',
|
error_text = f'{self.error_name} [0x{self.error_code:X}]'
|
||||||
(False, False): '',
|
elif have_name and not have_code:
|
||||||
}[(self.error_name != '', self.error_code is not None)]
|
error_text = self.error_name
|
||||||
|
elif not have_name and have_code:
|
||||||
|
error_text = f'0x{self.error_code:X}'
|
||||||
|
else:
|
||||||
|
error_text = '<unspecified>'
|
||||||
|
|
||||||
return f'{type(self).__name__}({namespace}{error_text})'
|
return f'{type(self).__name__}({namespace}{error_text})'
|
||||||
|
|
||||||
@@ -319,7 +323,7 @@ BT_HIDP_PROTOCOL_ID = UUID.from_16_bits(0x0011, 'HIDP')
|
|||||||
BT_HARDCOPY_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0012, 'HardcopyControlChannel')
|
BT_HARDCOPY_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0012, 'HardcopyControlChannel')
|
||||||
BT_HARDCOPY_DATA_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0014, 'HardcopyDataChannel')
|
BT_HARDCOPY_DATA_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0014, 'HardcopyDataChannel')
|
||||||
BT_HARDCOPY_NOTIFICATION_PROTOCOL_ID = UUID.from_16_bits(0x0016, 'HardcopyNotification')
|
BT_HARDCOPY_NOTIFICATION_PROTOCOL_ID = UUID.from_16_bits(0x0016, 'HardcopyNotification')
|
||||||
BT_AVTCP_PROTOCOL_ID = UUID.from_16_bits(0x0017, 'AVCTP')
|
BT_AVCTP_PROTOCOL_ID = UUID.from_16_bits(0x0017, 'AVCTP')
|
||||||
BT_AVDTP_PROTOCOL_ID = UUID.from_16_bits(0x0019, 'AVDTP')
|
BT_AVDTP_PROTOCOL_ID = UUID.from_16_bits(0x0019, 'AVDTP')
|
||||||
BT_CMTP_PROTOCOL_ID = UUID.from_16_bits(0x001B, 'CMTP')
|
BT_CMTP_PROTOCOL_ID = UUID.from_16_bits(0x001B, 'CMTP')
|
||||||
BT_MCAP_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x001E, 'MCAPControlChannel')
|
BT_MCAP_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x001E, 'MCAPControlChannel')
|
||||||
@@ -821,8 +825,8 @@ class AdvertisingData:
|
|||||||
ad_structures = []
|
ad_structures = []
|
||||||
self.ad_structures = ad_structures[:]
|
self.ad_structures = ad_structures[:]
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def from_bytes(data):
|
def from_bytes(cls, data: bytes) -> AdvertisingData:
|
||||||
instance = AdvertisingData()
|
instance = AdvertisingData()
|
||||||
instance.append(data)
|
instance.append(data)
|
||||||
return instance
|
return instance
|
||||||
@@ -978,7 +982,7 @@ class AdvertisingData:
|
|||||||
|
|
||||||
return ad_data
|
return ad_data
|
||||||
|
|
||||||
def append(self, data):
|
def append(self, data: bytes) -> None:
|
||||||
offset = 0
|
offset = 0
|
||||||
while offset + 1 < len(data):
|
while offset + 1 < len(data):
|
||||||
length = data[offset]
|
length = data[offset]
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ class EccKey:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
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:
|
def xor(x: bytes, y: bytes) -> bytes:
|
||||||
assert len(x) == len(y)
|
assert len(x) == len(y)
|
||||||
|
|||||||
1278
bumble/device.py
1278
bumble/device.py
File diff suppressed because it is too large
Load Diff
@@ -19,12 +19,17 @@ like loading firmware after a cold start.
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import abc
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import platform
|
import platform
|
||||||
from . import rtk
|
from typing import Dict, Iterable, Optional, Type, TYPE_CHECKING
|
||||||
|
|
||||||
|
from . import rtk, intel
|
||||||
|
from .common import Driver
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.host import Host
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -32,40 +37,31 @@ from . import rtk
|
|||||||
logger = logging.getLogger(__name__)
|
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
|
# Functions
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_driver_for_host(host):
|
async def get_driver_for_host(host: Host) -> Optional[Driver]:
|
||||||
"""Probe all known diver classes until one returns a valid instance for a host,
|
"""Probe diver classes until one returns a valid instance for a host, or none is
|
||||||
or none is found.
|
found.
|
||||||
|
If a "driver" HCI metadata entry is present, only that driver class will be probed.
|
||||||
"""
|
"""
|
||||||
if driver := await rtk.Driver.for_host(host):
|
driver_classes: Dict[str, Type[Driver]] = {"rtk": rtk.Driver, "intel": intel.Driver}
|
||||||
logger.debug("Instantiated 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
|
return driver
|
||||||
|
else:
|
||||||
|
logger.debug(f"Skipping unknown driver class: {driver_name}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
45
bumble/drivers/common.py
Normal file
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."""
|
||||||
102
bumble/drivers/intel.py
Normal file
102
bumble/drivers/intel.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Copyright 2024 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from bumble.drivers import common
|
||||||
|
from bumble.hci import (
|
||||||
|
hci_vendor_command_op_code, # type: ignore
|
||||||
|
HCI_Command,
|
||||||
|
HCI_Reset_Command,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constant
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INTEL_USB_PRODUCTS = {
|
||||||
|
# Intel AX210
|
||||||
|
(0x8087, 0x0032),
|
||||||
|
# Intel BE200
|
||||||
|
(0x8087, 0x0036),
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# HCI Commands
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
HCI_INTEL_DDC_CONFIG_WRITE_COMMAND = hci_vendor_command_op_code(0xFC8B) # type: ignore
|
||||||
|
HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD = [0x03, 0xE4, 0x02, 0x00]
|
||||||
|
|
||||||
|
HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
|
@HCI_Command.command( # type: ignore
|
||||||
|
fields=[("params", "*")],
|
||||||
|
return_parameters_fields=[
|
||||||
|
("params", "*"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class Hci_Intel_DDC_Config_Write_Command(HCI_Command):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Driver(common.Driver):
|
||||||
|
def __init__(self, host):
|
||||||
|
self.host = host
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check(host):
|
||||||
|
driver = host.hci_metadata.get("driver")
|
||||||
|
if driver == "intel":
|
||||||
|
return True
|
||||||
|
|
||||||
|
vendor_id = host.hci_metadata.get("vendor_id")
|
||||||
|
product_id = host.hci_metadata.get("product_id")
|
||||||
|
|
||||||
|
if vendor_id is None or product_id is None:
|
||||||
|
logger.debug("USB metadata not sufficient")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if (vendor_id, product_id) not in INTEL_USB_PRODUCTS:
|
||||||
|
logger.debug(
|
||||||
|
f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def for_host(cls, host, force=False): # type: ignore
|
||||||
|
# Only instantiate this driver if explicitly selected
|
||||||
|
if not force and not cls.check(host):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cls(host)
|
||||||
|
|
||||||
|
async def init_controller(self):
|
||||||
|
self.host.ready = True
|
||||||
|
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
||||||
|
await self.host.send_command(
|
||||||
|
Hci_Intel_DDC_Config_Write_Command(
|
||||||
|
params=HCI_INTEL_DDC_CONFIG_WRITE_PAYLOAD
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -41,7 +41,7 @@ from bumble.hci import (
|
|||||||
HCI_Reset_Command,
|
HCI_Reset_Command,
|
||||||
HCI_Read_Local_Version_Information_Command,
|
HCI_Read_Local_Version_Information_Command,
|
||||||
)
|
)
|
||||||
|
from bumble.drivers import common
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -285,7 +285,7 @@ class Firmware:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Driver:
|
class Driver(common.Driver):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DriverInfo:
|
class DriverInfo:
|
||||||
rom: int
|
rom: int
|
||||||
@@ -470,8 +470,12 @@ class Driver:
|
|||||||
logger.debug("USB metadata not found")
|
logger.debug("USB metadata not found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
vendor_id = host.hci_metadata.get("vendor_id", None)
|
if host.hci_metadata.get('driver') == 'rtk':
|
||||||
product_id = host.hci_metadata.get("product_id", None)
|
# 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:
|
if vendor_id is None or product_id is None:
|
||||||
logger.debug("USB metadata not sufficient")
|
logger.debug("USB metadata not sufficient")
|
||||||
return False
|
return False
|
||||||
@@ -486,6 +490,9 @@ class Driver:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def driver_info_for_host(cls, host):
|
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(
|
response = await host.send_command(
|
||||||
HCI_Read_Local_Version_Information_Command(), check_result=True
|
HCI_Read_Local_Version_Information_Command(), check_result=True
|
||||||
)
|
)
|
||||||
|
|||||||
117
bumble/gatt.py
117
bumble/gatt.py
@@ -23,16 +23,28 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
|
||||||
import enum
|
import enum
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import struct
|
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 bumble.colors import color
|
||||||
from .core import UUID, get_dict_key_by_value
|
from bumble.core import UUID
|
||||||
from .att import Attribute
|
from bumble.att import Attribute, AttributeValue
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.gatt_client import AttributeProxy
|
||||||
|
from bumble.device import Connection
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -368,9 +380,12 @@ class TemplateService(Service):
|
|||||||
UUID: UUID
|
UUID: UUID
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, characteristics: List[Characteristic], primary: bool = True
|
self,
|
||||||
|
characteristics: List[Characteristic],
|
||||||
|
primary: bool = True,
|
||||||
|
included_services: List[Service] = [],
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(self.UUID, characteristics, primary)
|
super().__init__(self.UUID, characteristics, primary, included_services)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -519,56 +534,43 @@ class CharacteristicDeclaration(Attribute):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class CharacteristicValue:
|
class CharacteristicValue(AttributeValue):
|
||||||
'''
|
"""Same as AttributeValue, for backward compatibility"""
|
||||||
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 CharacteristicAdapter:
|
class CharacteristicAdapter:
|
||||||
'''
|
'''
|
||||||
An adapter that can adapt any object with `read_value` and `write_value`
|
An adapter that can adapt Characteristic and AttributeProxy objects
|
||||||
methods (like Characteristic and CharacteristicProxy objects) by wrapping
|
by wrapping their `read_value()` and `write_value()` methods with ones that
|
||||||
those methods with ones that return/accept encoded/decoded values.
|
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
|
For proxies (i.e used by a GATT client), the adaptation is one where the return
|
||||||
`write_value` is encoded. Other objects are considered local characteristics
|
value of `read_value()` is decoded and the value passed to `write_value()` is
|
||||||
so the adaptation is one where the return value of `read_value` is encoded
|
encoded. The `subscribe()` method, is wrapped with one where the values are decoded
|
||||||
and the value passed to `write_value` is decoded.
|
before being passed to the subscriber.
|
||||||
If the characteristic has a `subscribe` method, it is wrapped with one where
|
|
||||||
the values are decoded before being passed to the subscriber.
|
For local values (i.e hosted by a GATT server) the adaptation is one where the
|
||||||
|
return value of `read_value()` is encoded and the value passed to `write_value()`
|
||||||
|
is decoded.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def __init__(self, characteristic):
|
read_value: Callable
|
||||||
self.wrapped_characteristic = characteristic
|
write_value: Callable
|
||||||
self.subscribers = {} # Map from subscriber to proxy subscriber
|
|
||||||
|
|
||||||
if asyncio.iscoroutinefunction(
|
def __init__(self, characteristic: Union[Characteristic, AttributeProxy]):
|
||||||
characteristic.read_value
|
self.wrapped_characteristic = characteristic
|
||||||
) and asyncio.iscoroutinefunction(characteristic.write_value):
|
self.subscribers: Dict[
|
||||||
self.read_value = self.read_decoded_value
|
Callable, Callable
|
||||||
self.write_value = self.write_decoded_value
|
] = {} # Map from subscriber to proxy subscriber
|
||||||
else:
|
|
||||||
|
if isinstance(characteristic, Characteristic):
|
||||||
self.read_value = self.read_encoded_value
|
self.read_value = self.read_encoded_value
|
||||||
self.write_value = self.write_encoded_value
|
self.write_value = self.write_encoded_value
|
||||||
|
else:
|
||||||
if hasattr(self.wrapped_characteristic, 'subscribe'):
|
self.read_value = self.read_decoded_value
|
||||||
|
self.write_value = self.write_decoded_value
|
||||||
self.subscribe = self.wrapped_subscribe
|
self.subscribe = self.wrapped_subscribe
|
||||||
|
|
||||||
if hasattr(self.wrapped_characteristic, 'unsubscribe'):
|
|
||||||
self.unsubscribe = self.wrapped_unsubscribe
|
self.unsubscribe = self.wrapped_unsubscribe
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def __getattr__(self, name):
|
||||||
@@ -587,11 +589,13 @@ class CharacteristicAdapter:
|
|||||||
else:
|
else:
|
||||||
setattr(self.wrapped_characteristic, name, value)
|
setattr(self.wrapped_characteristic, name, value)
|
||||||
|
|
||||||
def read_encoded_value(self, connection):
|
async def read_encoded_value(self, connection):
|
||||||
return self.encode_value(self.wrapped_characteristic.read_value(connection))
|
return self.encode_value(
|
||||||
|
await self.wrapped_characteristic.read_value(connection)
|
||||||
|
)
|
||||||
|
|
||||||
def write_encoded_value(self, connection, value):
|
async def write_encoded_value(self, connection, value):
|
||||||
return self.wrapped_characteristic.write_value(
|
return await self.wrapped_characteristic.write_value(
|
||||||
connection, self.decode_value(value)
|
connection, self.decode_value(value)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -726,13 +730,24 @@ class Descriptor(Attribute):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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 (
|
return (
|
||||||
f'Descriptor(handle=0x{self.handle:04X}, '
|
f'Descriptor(handle=0x{self.handle:04X}, '
|
||||||
f'type={self.type}, '
|
f'type={self.type}, '
|
||||||
f'value={self.read_value(None).hex()})'
|
f'value={value_str})'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
class ClientCharacteristicConfigurationBits(enum.IntFlag):
|
class ClientCharacteristicConfigurationBits(enum.IntFlag):
|
||||||
'''
|
'''
|
||||||
See Vol 3, Part G - 3.3.3.3 - Table 3.11 Client Characteristic Configuration bit
|
See Vol 3, Part G - 3.3.3.3 - Table 3.11 Client Characteristic Configuration bit
|
||||||
|
|||||||
@@ -1068,7 +1068,7 @@ class Client:
|
|||||||
logger.warning('!!! unexpected response, there is no pending request')
|
logger.warning('!!! unexpected response, there is no pending request')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Sanity check: the response should match the pending request unless it is
|
# The response should match the pending request unless it is
|
||||||
# an error response
|
# an error response
|
||||||
if att_pdu.op_code != ATT_ERROR_RESPONSE:
|
if att_pdu.op_code != ATT_ERROR_RESPONSE:
|
||||||
expected_response_name = self.pending_request.name.replace(
|
expected_response_name = self.pending_request.name.replace(
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ import struct
|
|||||||
from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
|
from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from .colors import color
|
from bumble.colors import color
|
||||||
from .core import UUID
|
from bumble.core import UUID
|
||||||
from .att import (
|
from bumble.att import (
|
||||||
ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
ATT_ATTRIBUTE_NOT_LONG_ERROR,
|
ATT_ATTRIBUTE_NOT_LONG_ERROR,
|
||||||
ATT_CID,
|
ATT_CID,
|
||||||
@@ -60,7 +60,7 @@ from .att import (
|
|||||||
ATT_Write_Response,
|
ATT_Write_Response,
|
||||||
Attribute,
|
Attribute,
|
||||||
)
|
)
|
||||||
from .gatt import (
|
from bumble.gatt import (
|
||||||
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
||||||
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||||
GATT_MAX_ATTRIBUTE_VALUE_SIZE,
|
GATT_MAX_ATTRIBUTE_VALUE_SIZE,
|
||||||
@@ -74,6 +74,7 @@ from .gatt import (
|
|||||||
Descriptor,
|
Descriptor,
|
||||||
Service,
|
Service,
|
||||||
)
|
)
|
||||||
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.device import Device, Connection
|
from bumble.device import Device, Connection
|
||||||
@@ -327,7 +328,7 @@ class Server(EventEmitter):
|
|||||||
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
|
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sanity check
|
# Check parameters
|
||||||
if len(value) != 2:
|
if len(value) != 2:
|
||||||
logger.warning('CCCD value not 2 bytes long')
|
logger.warning('CCCD value not 2 bytes long')
|
||||||
return
|
return
|
||||||
@@ -379,7 +380,7 @@ class Server(EventEmitter):
|
|||||||
|
|
||||||
# Get or encode the value
|
# Get or encode the value
|
||||||
value = (
|
value = (
|
||||||
attribute.read_value(connection)
|
await attribute.read_value(connection)
|
||||||
if value is None
|
if value is None
|
||||||
else attribute.encode_value(value)
|
else attribute.encode_value(value)
|
||||||
)
|
)
|
||||||
@@ -422,7 +423,7 @@ class Server(EventEmitter):
|
|||||||
|
|
||||||
# Get or encode the value
|
# Get or encode the value
|
||||||
value = (
|
value = (
|
||||||
attribute.read_value(connection)
|
await attribute.read_value(connection)
|
||||||
if value is None
|
if value is None
|
||||||
else attribute.encode_value(value)
|
else attribute.encode_value(value)
|
||||||
)
|
)
|
||||||
@@ -650,7 +651,8 @@ class Server(EventEmitter):
|
|||||||
|
|
||||||
self.send_response(connection, response)
|
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
|
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
|
# Build list of returned attributes
|
||||||
pdu_space_available = connection.att_mtu - 2
|
pdu_space_available = connection.att_mtu - 2
|
||||||
attributes = []
|
attributes = []
|
||||||
for attribute in (
|
async for attribute in (
|
||||||
attribute
|
attribute
|
||||||
for attribute in self.attributes
|
for attribute in self.attributes
|
||||||
if attribute.handle >= request.starting_handle
|
if attribute.handle >= request.starting_handle
|
||||||
and attribute.handle <= request.ending_handle
|
and attribute.handle <= request.ending_handle
|
||||||
and attribute.type == request.attribute_type
|
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
|
and pdu_space_available >= 4
|
||||||
):
|
):
|
||||||
# TODO: check permissions
|
# TODO: check permissions
|
||||||
@@ -702,7 +704,8 @@ class Server(EventEmitter):
|
|||||||
|
|
||||||
self.send_response(connection, response)
|
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
|
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
|
and pdu_space_available
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
attribute_value = attribute.read_value(connection)
|
attribute_value = await attribute.read_value(connection)
|
||||||
except ATT_Error as error:
|
except ATT_Error as error:
|
||||||
# If the first attribute is unreadable, return an error
|
# If the first attribute is unreadable, return an error
|
||||||
# Otherwise return attributes up to this point
|
# Otherwise return attributes up to this point
|
||||||
@@ -767,14 +770,15 @@ class Server(EventEmitter):
|
|||||||
|
|
||||||
self.send_response(connection, response)
|
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
|
See Bluetooth spec Vol 3, Part F - 3.4.4.3 Read Request
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if attribute := self.get_attribute(request.attribute_handle):
|
if attribute := self.get_attribute(request.attribute_handle):
|
||||||
try:
|
try:
|
||||||
value = attribute.read_value(connection)
|
value = await attribute.read_value(connection)
|
||||||
except ATT_Error as error:
|
except ATT_Error as error:
|
||||||
response = ATT_Error_Response(
|
response = ATT_Error_Response(
|
||||||
request_opcode_in_error=request.op_code,
|
request_opcode_in_error=request.op_code,
|
||||||
@@ -792,14 +796,15 @@ class Server(EventEmitter):
|
|||||||
)
|
)
|
||||||
self.send_response(connection, response)
|
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
|
See Bluetooth spec Vol 3, Part F - 3.4.4.5 Read Blob Request
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if attribute := self.get_attribute(request.attribute_handle):
|
if attribute := self.get_attribute(request.attribute_handle):
|
||||||
try:
|
try:
|
||||||
value = attribute.read_value(connection)
|
value = await attribute.read_value(connection)
|
||||||
except ATT_Error as error:
|
except ATT_Error as error:
|
||||||
response = ATT_Error_Response(
|
response = ATT_Error_Response(
|
||||||
request_opcode_in_error=request.op_code,
|
request_opcode_in_error=request.op_code,
|
||||||
@@ -836,7 +841,8 @@ class Server(EventEmitter):
|
|||||||
)
|
)
|
||||||
self.send_response(connection, response)
|
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
|
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
|
# No need to catch permission errors here, since these attributes
|
||||||
# must all be world-readable
|
# must all be world-readable
|
||||||
attribute_value = attribute.read_value(connection)
|
attribute_value = await attribute.read_value(connection)
|
||||||
# Check the attribute value size
|
# Check the attribute value size
|
||||||
max_attribute_size = min(connection.att_mtu - 6, 251)
|
max_attribute_size = min(connection.att_mtu - 6, 251)
|
||||||
if len(attribute_value) > max_attribute_size:
|
if len(attribute_value) > max_attribute_size:
|
||||||
@@ -903,7 +909,8 @@ class Server(EventEmitter):
|
|||||||
|
|
||||||
self.send_response(connection, response)
|
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
|
See Bluetooth spec Vol 3, Part F - 3.4.5.1 Write Request
|
||||||
'''
|
'''
|
||||||
@@ -936,12 +943,13 @@ class Server(EventEmitter):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Accept the value
|
# Accept the value
|
||||||
attribute.write_value(connection, request.attribute_value)
|
await attribute.write_value(connection, request.attribute_value)
|
||||||
|
|
||||||
# Done
|
# Done
|
||||||
self.send_response(connection, ATT_Write_Response())
|
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
|
See Bluetooth spec Vol 3, Part F - 3.4.5.3 Write Command
|
||||||
'''
|
'''
|
||||||
@@ -959,9 +967,9 @@ class Server(EventEmitter):
|
|||||||
|
|
||||||
# Accept the value
|
# Accept the value
|
||||||
try:
|
try:
|
||||||
attribute.write_value(connection, request.attribute_value)
|
await attribute.write_value(connection, request.attribute_value)
|
||||||
except Exception as error:
|
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):
|
def on_att_handle_value_confirmation(self, connection, _confirmation):
|
||||||
'''
|
'''
|
||||||
|
|||||||
1394
bumble/hci.py
1394
bumble/hci.py
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, MutableMapping
|
from collections.abc import Callable, MutableMapping
|
||||||
from typing import cast, Any
|
import datetime
|
||||||
|
from typing import cast, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import avctp
|
||||||
from bumble import avdtp
|
from bumble import avdtp
|
||||||
|
from bumble import avrcp
|
||||||
|
from bumble import crypto
|
||||||
|
from bumble import rfcomm
|
||||||
|
from bumble import sdp
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.att import ATT_CID, ATT_PDU
|
from bumble.att import ATT_CID, ATT_PDU
|
||||||
from bumble.smp import SMP_CID, SMP_Command
|
from bumble.smp import SMP_CID, SMP_Command
|
||||||
@@ -37,6 +44,7 @@ from bumble.l2cap import (
|
|||||||
L2CAP_Connection_Response,
|
L2CAP_Connection_Response,
|
||||||
)
|
)
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
|
Address,
|
||||||
HCI_EVENT_PACKET,
|
HCI_EVENT_PACKET,
|
||||||
HCI_ACL_DATA_PACKET,
|
HCI_ACL_DATA_PACKET,
|
||||||
HCI_DISCONNECTION_COMPLETE_EVENT,
|
HCI_DISCONNECTION_COMPLETE_EVENT,
|
||||||
@@ -46,8 +54,7 @@ from bumble.hci import (
|
|||||||
HCI_AclDataPacket,
|
HCI_AclDataPacket,
|
||||||
HCI_Disconnection_Complete_Event,
|
HCI_Disconnection_Complete_Event,
|
||||||
)
|
)
|
||||||
from bumble.rfcomm import RFCOMM_Frame, RFCOMM_PSM
|
|
||||||
from bumble.sdp import SDP_PDU, SDP_PSM
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -57,28 +64,36 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
PSM_NAMES = {
|
PSM_NAMES = {
|
||||||
RFCOMM_PSM: 'RFCOMM',
|
rfcomm.RFCOMM_PSM: 'RFCOMM',
|
||||||
SDP_PSM: 'SDP',
|
sdp.SDP_PSM: 'SDP',
|
||||||
avdtp.AVDTP_PSM: 'AVDTP',
|
avdtp.AVDTP_PSM: 'AVDTP',
|
||||||
|
avctp.AVCTP_PSM: 'AVCTP',
|
||||||
|
# TODO: add more PSM values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AVCTP_PID_NAMES = {avrcp.AVRCP_PID: 'AVRCP'}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PacketTracer:
|
class PacketTracer:
|
||||||
class AclStream:
|
class AclStream:
|
||||||
psms: MutableMapping[int, int]
|
psms: MutableMapping[int, int]
|
||||||
peer: PacketTracer.AclStream
|
peer: Optional[PacketTracer.AclStream]
|
||||||
avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
|
avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
|
||||||
|
avctp_assemblers: MutableMapping[int, avctp.MessageAssembler]
|
||||||
|
|
||||||
def __init__(self, analyzer: PacketTracer.Analyzer) -> None:
|
def __init__(self, analyzer: PacketTracer.Analyzer) -> None:
|
||||||
self.analyzer = analyzer
|
self.analyzer = analyzer
|
||||||
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
|
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
|
||||||
|
self.avctp_assemblers = {} # AVCTP assemblers, by source_cid
|
||||||
self.psms = {} # PSM, by source_cid
|
self.psms = {} # PSM, by source_cid
|
||||||
|
self.peer = None
|
||||||
|
|
||||||
# pylint: disable=too-many-nested-blocks
|
# pylint: disable=too-many-nested-blocks
|
||||||
def on_acl_pdu(self, pdu: bytes) -> None:
|
def on_acl_pdu(self, pdu: bytes) -> None:
|
||||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||||
|
self.analyzer.emit(l2cap_pdu)
|
||||||
|
|
||||||
if l2cap_pdu.cid == ATT_CID:
|
if l2cap_pdu.cid == ATT_CID:
|
||||||
att_pdu = ATT_PDU.from_bytes(l2cap_pdu.payload)
|
att_pdu = ATT_PDU.from_bytes(l2cap_pdu.payload)
|
||||||
@@ -100,9 +115,8 @@ class PacketTracer:
|
|||||||
connection_response.result
|
connection_response.result
|
||||||
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
|
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
|
||||||
):
|
):
|
||||||
if self.peer:
|
if self.peer and (
|
||||||
if psm := self.peer.psms.get(
|
psm := self.peer.psms.get(connection_response.source_cid)
|
||||||
connection_response.source_cid
|
|
||||||
):
|
):
|
||||||
# Found a pending connection
|
# Found a pending connection
|
||||||
self.psms[connection_response.destination_cid] = psm
|
self.psms[connection_response.destination_cid] = psm
|
||||||
@@ -115,27 +129,37 @@ class PacketTracer:
|
|||||||
] = avdtp.MessageAssembler(self.on_avdtp_message)
|
] = avdtp.MessageAssembler(self.on_avdtp_message)
|
||||||
self.peer.avdtp_assemblers[
|
self.peer.avdtp_assemblers[
|
||||||
connection_response.destination_cid
|
connection_response.destination_cid
|
||||||
] = avdtp.MessageAssembler(
|
] = avdtp.MessageAssembler(self.peer.on_avdtp_message)
|
||||||
self.peer.on_avdtp_message
|
elif psm == avctp.AVCTP_PSM:
|
||||||
)
|
self.avctp_assemblers[
|
||||||
|
connection_response.source_cid
|
||||||
|
] = avctp.MessageAssembler(self.on_avctp_message)
|
||||||
|
self.peer.avctp_assemblers[
|
||||||
|
connection_response.destination_cid
|
||||||
|
] = avctp.MessageAssembler(self.peer.on_avctp_message)
|
||||||
else:
|
else:
|
||||||
# Try to find the PSM associated with this PDU
|
# Try to find the PSM associated with this PDU
|
||||||
if self.peer and (psm := self.peer.psms.get(l2cap_pdu.cid)):
|
if self.peer and (psm := self.peer.psms.get(l2cap_pdu.cid)):
|
||||||
if psm == SDP_PSM:
|
if psm == sdp.SDP_PSM:
|
||||||
sdp_pdu = SDP_PDU.from_bytes(l2cap_pdu.payload)
|
sdp_pdu = sdp.SDP_PDU.from_bytes(l2cap_pdu.payload)
|
||||||
self.analyzer.emit(sdp_pdu)
|
self.analyzer.emit(sdp_pdu)
|
||||||
elif psm == RFCOMM_PSM:
|
elif psm == rfcomm.RFCOMM_PSM:
|
||||||
rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
|
rfcomm_frame = rfcomm.RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
|
||||||
self.analyzer.emit(rfcomm_frame)
|
self.analyzer.emit(rfcomm_frame)
|
||||||
elif psm == avdtp.AVDTP_PSM:
|
elif psm == avdtp.AVDTP_PSM:
|
||||||
self.analyzer.emit(
|
self.analyzer.emit(
|
||||||
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
||||||
f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
|
f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
|
||||||
)
|
)
|
||||||
assembler = self.avdtp_assemblers.get(l2cap_pdu.cid)
|
if avdtp_assembler := self.avdtp_assemblers.get(l2cap_pdu.cid):
|
||||||
if assembler:
|
avdtp_assembler.on_pdu(l2cap_pdu.payload)
|
||||||
assembler.on_pdu(l2cap_pdu.payload)
|
elif psm == avctp.AVCTP_PSM:
|
||||||
|
self.analyzer.emit(
|
||||||
|
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
|
||||||
|
f'PSM=AVCTP]: {l2cap_pdu.payload.hex()}'
|
||||||
|
)
|
||||||
|
if avctp_assembler := self.avctp_assemblers.get(l2cap_pdu.cid):
|
||||||
|
avctp_assembler.on_pdu(l2cap_pdu.payload)
|
||||||
else:
|
else:
|
||||||
psm_string = name_or_number(PSM_NAMES, psm)
|
psm_string = name_or_number(PSM_NAMES, psm)
|
||||||
self.analyzer.emit(
|
self.analyzer.emit(
|
||||||
@@ -152,6 +176,28 @@ class PacketTracer:
|
|||||||
f'{color("AVDTP", "green")} [{transaction_label}] {message}'
|
f'{color("AVDTP", "green")} [{transaction_label}] {message}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_avctp_message(
|
||||||
|
self,
|
||||||
|
transaction_label: int,
|
||||||
|
is_command: bool,
|
||||||
|
ipid: bool,
|
||||||
|
pid: int,
|
||||||
|
payload: bytes,
|
||||||
|
):
|
||||||
|
if pid == avrcp.AVRCP_PID:
|
||||||
|
avc_frame = avc.Frame.from_bytes(payload)
|
||||||
|
details = str(avc_frame)
|
||||||
|
else:
|
||||||
|
details = payload.hex()
|
||||||
|
|
||||||
|
c_r = 'Command' if is_command else 'Response'
|
||||||
|
self.analyzer.emit(
|
||||||
|
f'{color("AVCTP", "green")} '
|
||||||
|
f'{c_r}[{transaction_label}][{name_or_number(AVCTP_PID_NAMES, pid)}] '
|
||||||
|
f'{"#" if ipid else ""}'
|
||||||
|
f'{details}'
|
||||||
|
)
|
||||||
|
|
||||||
def feed_packet(self, packet: HCI_AclDataPacket) -> None:
|
def feed_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||||
self.packet_assembler.feed_packet(packet)
|
self.packet_assembler.feed_packet(packet)
|
||||||
|
|
||||||
@@ -163,6 +209,7 @@ class PacketTracer:
|
|||||||
self.label = label
|
self.label = label
|
||||||
self.emit_message = emit_message
|
self.emit_message = emit_message
|
||||||
self.acl_streams = {} # ACL streams, by connection handle
|
self.acl_streams = {} # ACL streams, by connection handle
|
||||||
|
self.packet_timestamp: Optional[datetime.datetime] = None
|
||||||
|
|
||||||
def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
|
def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -190,7 +237,10 @@ class PacketTracer:
|
|||||||
# Let the other forwarder know so it can cleanup its stream as well
|
# Let the other forwarder know so it can cleanup its stream as well
|
||||||
self.peer.end_acl_stream(connection_handle)
|
self.peer.end_acl_stream(connection_handle)
|
||||||
|
|
||||||
def on_packet(self, packet: HCI_Packet) -> None:
|
def on_packet(
|
||||||
|
self, timestamp: Optional[datetime.datetime], packet: HCI_Packet
|
||||||
|
) -> None:
|
||||||
|
self.packet_timestamp = timestamp
|
||||||
self.emit(packet)
|
self.emit(packet)
|
||||||
|
|
||||||
if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
||||||
@@ -210,13 +260,22 @@ class PacketTracer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def emit(self, message: Any) -> None:
|
def emit(self, message: Any) -> None:
|
||||||
self.emit_message(f'[{self.label}] {message}')
|
if self.packet_timestamp:
|
||||||
|
prefix = f"[{self.packet_timestamp.strftime('%Y-%m-%d %H:%M:%S.%f')}]"
|
||||||
def trace(self, packet: HCI_Packet, direction: int = 0) -> None:
|
|
||||||
if direction == 0:
|
|
||||||
self.host_to_controller_analyzer.on_packet(packet)
|
|
||||||
else:
|
else:
|
||||||
self.controller_to_host_analyzer.on_packet(packet)
|
prefix = ""
|
||||||
|
self.emit_message(f'{prefix}[{self.label}] {message}')
|
||||||
|
|
||||||
|
def trace(
|
||||||
|
self,
|
||||||
|
packet: HCI_Packet,
|
||||||
|
direction: int = 0,
|
||||||
|
timestamp: Optional[datetime.datetime] = None,
|
||||||
|
) -> None:
|
||||||
|
if direction == 0:
|
||||||
|
self.host_to_controller_analyzer.on_packet(timestamp, packet)
|
||||||
|
else:
|
||||||
|
self.controller_to_host_analyzer.on_packet(timestamp, packet)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -232,3 +291,15 @@ class PacketTracer:
|
|||||||
)
|
)
|
||||||
self.host_to_controller_analyzer.peer = self.controller_to_host_analyzer
|
self.host_to_controller_analyzer.peer = self.controller_to_host_analyzer
|
||||||
self.controller_to_host_analyzer.peer = self.host_to_controller_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
|
||||||
|
|||||||
284
bumble/hfp.py
284
bumble/hfp.py
@@ -21,12 +21,11 @@ import asyncio
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import traceback
|
import traceback
|
||||||
import warnings
|
import pyee
|
||||||
from typing import Dict, List, Union, Set, Any, TYPE_CHECKING
|
from typing import Dict, List, Union, Set, Any, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from . import at
|
|
||||||
from . import rfcomm
|
|
||||||
|
|
||||||
|
from bumble import at
|
||||||
|
from bumble import rfcomm
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
@@ -79,7 +78,6 @@ class HfpProtocol:
|
|||||||
lines_available: asyncio.Event
|
lines_available: asyncio.Event
|
||||||
|
|
||||||
def __init__(self, dlc: rfcomm.DLC) -> None:
|
def __init__(self, dlc: rfcomm.DLC) -> None:
|
||||||
warnings.warn("See HfProtocol", DeprecationWarning)
|
|
||||||
self.dlc = dlc
|
self.dlc = dlc
|
||||||
self.buffer = ''
|
self.buffer = ''
|
||||||
self.lines = collections.deque()
|
self.lines = collections.deque()
|
||||||
@@ -128,10 +126,13 @@ class HfpProtocol:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# HF supported features (AT+BRSF=) (normative).
|
|
||||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
|
||||||
# and 3GPP 27.007
|
|
||||||
class HfFeature(enum.IntFlag):
|
class HfFeature(enum.IntFlag):
|
||||||
|
"""
|
||||||
|
HF supported features (AT+BRSF=) (normative).
|
||||||
|
|
||||||
|
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||||
|
"""
|
||||||
|
|
||||||
EC_NR = 0x001 # Echo Cancel & Noise reduction
|
EC_NR = 0x001 # Echo Cancel & Noise reduction
|
||||||
THREE_WAY_CALLING = 0x002
|
THREE_WAY_CALLING = 0x002
|
||||||
CLI_PRESENTATION_CAPABILITY = 0x004
|
CLI_PRESENTATION_CAPABILITY = 0x004
|
||||||
@@ -146,10 +147,13 @@ class HfFeature(enum.IntFlag):
|
|||||||
VOICE_RECOGNITION_TEST = 0x800
|
VOICE_RECOGNITION_TEST = 0x800
|
||||||
|
|
||||||
|
|
||||||
# AG supported features (+BRSF:) (normative).
|
|
||||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
|
||||||
# and 3GPP 27.007
|
|
||||||
class AgFeature(enum.IntFlag):
|
class AgFeature(enum.IntFlag):
|
||||||
|
"""
|
||||||
|
AG supported features (+BRSF:) (normative).
|
||||||
|
|
||||||
|
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||||
|
"""
|
||||||
|
|
||||||
THREE_WAY_CALLING = 0x001
|
THREE_WAY_CALLING = 0x001
|
||||||
EC_NR = 0x002 # Echo Cancel & Noise reduction
|
EC_NR = 0x002 # Echo Cancel & Noise reduction
|
||||||
VOICE_RECOGNITION_FUNCTION = 0x004
|
VOICE_RECOGNITION_FUNCTION = 0x004
|
||||||
@@ -166,52 +170,90 @@ class AgFeature(enum.IntFlag):
|
|||||||
VOICE_RECOGNITION_TEST = 0x2000
|
VOICE_RECOGNITION_TEST = 0x2000
|
||||||
|
|
||||||
|
|
||||||
# Audio Codec IDs (normative).
|
|
||||||
# Hands-Free Profile v1.8, 10 Appendix B
|
|
||||||
class AudioCodec(enum.IntEnum):
|
class AudioCodec(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Audio Codec IDs (normative).
|
||||||
|
|
||||||
|
Hands-Free Profile v1.9, 11 Appendix B
|
||||||
|
"""
|
||||||
|
|
||||||
CVSD = 0x01 # Support for CVSD audio codec
|
CVSD = 0x01 # Support for CVSD audio codec
|
||||||
MSBC = 0x02 # Support for mSBC audio codec
|
MSBC = 0x02 # Support for mSBC audio codec
|
||||||
|
LC3_SWB = 0x03 # Support for LC3-SWB audio codec
|
||||||
|
|
||||||
|
|
||||||
# HF Indicators (normative).
|
|
||||||
# Bluetooth Assigned Numbers, 6.10.1 HF Indicators
|
|
||||||
class HfIndicator(enum.IntEnum):
|
class HfIndicator(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
HF Indicators (normative).
|
||||||
|
|
||||||
|
Bluetooth Assigned Numbers, 6.10.1 HF Indicators.
|
||||||
|
"""
|
||||||
|
|
||||||
ENHANCED_SAFETY = 0x01 # Enhanced safety feature
|
ENHANCED_SAFETY = 0x01 # Enhanced safety feature
|
||||||
BATTERY_LEVEL = 0x02 # Battery level feature
|
BATTERY_LEVEL = 0x02 # Battery level feature
|
||||||
|
|
||||||
|
|
||||||
# Call Hold supported operations (normative).
|
|
||||||
# AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services
|
|
||||||
class CallHoldOperation(enum.IntEnum):
|
class CallHoldOperation(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Call Hold supported operations (normative).
|
||||||
|
|
||||||
|
AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services.
|
||||||
|
"""
|
||||||
|
|
||||||
RELEASE_ALL_HELD_CALLS = 0 # Release all held calls
|
RELEASE_ALL_HELD_CALLS = 0 # Release all held calls
|
||||||
RELEASE_ALL_ACTIVE_CALLS = 1 # Release all active calls, accept other
|
RELEASE_ALL_ACTIVE_CALLS = 1 # Release all active calls, accept other
|
||||||
HOLD_ALL_ACTIVE_CALLS = 2 # Place all active calls on hold, accept other
|
HOLD_ALL_ACTIVE_CALLS = 2 # Place all active calls on hold, accept other
|
||||||
ADD_HELD_CALL = 3 # Adds a held call to conversation
|
ADD_HELD_CALL = 3 # Adds a held call to conversation
|
||||||
|
|
||||||
|
|
||||||
# Response Hold status (normative).
|
|
||||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
|
||||||
# and 3GPP 27.007
|
|
||||||
class ResponseHoldStatus(enum.IntEnum):
|
class ResponseHoldStatus(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Response Hold status (normative).
|
||||||
|
|
||||||
|
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||||
|
"""
|
||||||
|
|
||||||
INC_CALL_HELD = 0 # Put incoming call on hold
|
INC_CALL_HELD = 0 # Put incoming call on hold
|
||||||
HELD_CALL_ACC = 1 # Accept a held incoming call
|
HELD_CALL_ACC = 1 # Accept a held incoming call
|
||||||
HELD_CALL_REJ = 2 # Reject a held incoming call
|
HELD_CALL_REJ = 2 # Reject a held incoming call
|
||||||
|
|
||||||
|
|
||||||
# Values for the Call Setup AG indicator (normative).
|
class AgIndicator(enum.Enum):
|
||||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
"""
|
||||||
# and 3GPP 27.007
|
Values for the AG indicator (normative).
|
||||||
|
|
||||||
|
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SERVICE = 'service'
|
||||||
|
CALL = 'call'
|
||||||
|
CALL_SETUP = 'callsetup'
|
||||||
|
CALL_HELD = 'callheld'
|
||||||
|
SIGNAL = 'signal'
|
||||||
|
ROAM = 'roam'
|
||||||
|
BATTERY_CHARGE = 'battchg'
|
||||||
|
|
||||||
|
|
||||||
class CallSetupAgIndicator(enum.IntEnum):
|
class CallSetupAgIndicator(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Values for the Call Setup AG indicator (normative).
|
||||||
|
|
||||||
|
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||||
|
"""
|
||||||
|
|
||||||
NOT_IN_CALL_SETUP = 0
|
NOT_IN_CALL_SETUP = 0
|
||||||
INCOMING_CALL_PROCESS = 1
|
INCOMING_CALL_PROCESS = 1
|
||||||
OUTGOING_CALL_SETUP = 2
|
OUTGOING_CALL_SETUP = 2
|
||||||
REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
|
REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
|
||||||
|
|
||||||
|
|
||||||
# Values for the Call Held AG indicator (normative).
|
|
||||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
|
||||||
# and 3GPP 27.007
|
|
||||||
class CallHeldAgIndicator(enum.IntEnum):
|
class CallHeldAgIndicator(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Values for the Call Held AG indicator (normative).
|
||||||
|
|
||||||
|
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||||
|
"""
|
||||||
|
|
||||||
NO_CALLS_HELD = 0
|
NO_CALLS_HELD = 0
|
||||||
# Call is placed on hold or active/held calls swapped
|
# Call is placed on hold or active/held calls swapped
|
||||||
# (The AG has both an active AND a held call)
|
# (The AG has both an active AND a held call)
|
||||||
@@ -219,16 +261,24 @@ class CallHeldAgIndicator(enum.IntEnum):
|
|||||||
CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
|
CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
|
||||||
|
|
||||||
|
|
||||||
# Call Info direction (normative).
|
|
||||||
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
|
||||||
class CallInfoDirection(enum.IntEnum):
|
class CallInfoDirection(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Call Info direction (normative).
|
||||||
|
|
||||||
|
AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
|
||||||
|
"""
|
||||||
|
|
||||||
MOBILE_ORIGINATED_CALL = 0
|
MOBILE_ORIGINATED_CALL = 0
|
||||||
MOBILE_TERMINATED_CALL = 1
|
MOBILE_TERMINATED_CALL = 1
|
||||||
|
|
||||||
|
|
||||||
# Call Info status (normative).
|
|
||||||
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
|
||||||
class CallInfoStatus(enum.IntEnum):
|
class CallInfoStatus(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Call Info status (normative).
|
||||||
|
|
||||||
|
AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
|
||||||
|
"""
|
||||||
|
|
||||||
ACTIVE = 0
|
ACTIVE = 0
|
||||||
HELD = 1
|
HELD = 1
|
||||||
DIALING = 2
|
DIALING = 2
|
||||||
@@ -237,15 +287,47 @@ class CallInfoStatus(enum.IntEnum):
|
|||||||
WAITING = 5
|
WAITING = 5
|
||||||
|
|
||||||
|
|
||||||
# Call Info mode (normative).
|
|
||||||
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
|
||||||
class CallInfoMode(enum.IntEnum):
|
class CallInfoMode(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Call Info mode (normative).
|
||||||
|
|
||||||
|
AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
|
||||||
|
"""
|
||||||
|
|
||||||
VOICE = 0
|
VOICE = 0
|
||||||
DATA = 1
|
DATA = 1
|
||||||
FAX = 2
|
FAX = 2
|
||||||
UNKNOWN = 9
|
UNKNOWN = 9
|
||||||
|
|
||||||
|
|
||||||
|
class CallInfoMultiParty(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Call Info Multi-Party state (normative).
|
||||||
|
|
||||||
|
AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NOT_IN_CONFERENCE = 0
|
||||||
|
IN_CONFERENCE = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CallInfo:
|
||||||
|
"""
|
||||||
|
Enhanced call status.
|
||||||
|
|
||||||
|
AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
direction: CallInfoDirection
|
||||||
|
status: CallInfoStatus
|
||||||
|
mode: CallInfoMode
|
||||||
|
multi_party: CallInfoMultiParty
|
||||||
|
number: Optional[int] = None
|
||||||
|
type: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Hands-Free Control Interoperability Requirements
|
# Hands-Free Control Interoperability Requirements
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -326,8 +408,9 @@ class Configuration:
|
|||||||
|
|
||||||
|
|
||||||
class AtResponseType(enum.Enum):
|
class AtResponseType(enum.Enum):
|
||||||
"""Indicate if a response is expected from an AT command, and if multiple
|
"""
|
||||||
responses are accepted."""
|
Indicates if a response is expected from an AT command, and if multiple responses are accepted.
|
||||||
|
"""
|
||||||
|
|
||||||
NONE = 0
|
NONE = 0
|
||||||
SINGLE = 1
|
SINGLE = 1
|
||||||
@@ -361,9 +444,20 @@ class HfIndicatorState:
|
|||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
class HfProtocol:
|
class HfProtocol(pyee.EventEmitter):
|
||||||
"""Implementation for the Hands-Free side of the Hands-Free profile.
|
"""
|
||||||
Reference specification Hands-Free Profile v1.8"""
|
Implementation for the Hands-Free side of the Hands-Free profile.
|
||||||
|
|
||||||
|
Reference specification Hands-Free Profile v1.8.
|
||||||
|
|
||||||
|
Emitted events:
|
||||||
|
codec_negotiation: When codec is renegotiated, notify the new codec.
|
||||||
|
Args:
|
||||||
|
active_codec: AudioCodec
|
||||||
|
ag_indicator: When AG update their indicators, notify the new state.
|
||||||
|
Args:
|
||||||
|
ag_indicator: AgIndicator
|
||||||
|
"""
|
||||||
|
|
||||||
supported_hf_features: int
|
supported_hf_features: int
|
||||||
supported_audio_codecs: List[AudioCodec]
|
supported_audio_codecs: List[AudioCodec]
|
||||||
@@ -383,14 +477,18 @@ class HfProtocol:
|
|||||||
response_queue: asyncio.Queue
|
response_queue: asyncio.Queue
|
||||||
unsolicited_queue: asyncio.Queue
|
unsolicited_queue: asyncio.Queue
|
||||||
read_buffer: bytearray
|
read_buffer: bytearray
|
||||||
|
active_codec: AudioCodec
|
||||||
|
|
||||||
|
def __init__(self, dlc: rfcomm.DLC, configuration: Configuration) -> None:
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
|
|
||||||
# Configure internal state.
|
# Configure internal state.
|
||||||
self.dlc = dlc
|
self.dlc = dlc
|
||||||
self.command_lock = asyncio.Lock()
|
self.command_lock = asyncio.Lock()
|
||||||
self.response_queue = asyncio.Queue()
|
self.response_queue = asyncio.Queue()
|
||||||
self.unsolicited_queue = asyncio.Queue()
|
self.unsolicited_queue = asyncio.Queue()
|
||||||
self.read_buffer = bytearray()
|
self.read_buffer = bytearray()
|
||||||
|
self.active_codec = AudioCodec.CVSD
|
||||||
|
|
||||||
# Build local features.
|
# Build local features.
|
||||||
self.supported_hf_features = sum(configuration.supported_hf_features)
|
self.supported_hf_features = sum(configuration.supported_hf_features)
|
||||||
@@ -415,10 +513,12 @@ class HfProtocol:
|
|||||||
def supports_ag_feature(self, feature: AgFeature) -> bool:
|
def supports_ag_feature(self, feature: AgFeature) -> bool:
|
||||||
return (self.supported_ag_features & feature) != 0
|
return (self.supported_ag_features & feature) != 0
|
||||||
|
|
||||||
# Read AT messages from the RFCOMM channel.
|
|
||||||
# Enqueue AT commands, responses, unsolicited responses to their
|
|
||||||
# respective queues, and set the corresponding event.
|
|
||||||
def _read_at(self, data: bytes):
|
def _read_at(self, data: bytes):
|
||||||
|
"""
|
||||||
|
Reads AT messages from the RFCOMM channel.
|
||||||
|
|
||||||
|
Enqueues AT commands, responses, unsolicited responses to their respective queues, and set the corresponding event.
|
||||||
|
"""
|
||||||
# Append to the read buffer.
|
# Append to the read buffer.
|
||||||
self.read_buffer.extend(data)
|
self.read_buffer.extend(data)
|
||||||
|
|
||||||
@@ -446,17 +546,25 @@ class HfProtocol:
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
||||||
|
|
||||||
# Send an AT command and wait for the peer response.
|
|
||||||
# Wait for the AT responses sent by the peer, to the status code.
|
|
||||||
# Raises asyncio.TimeoutError if the status is not received
|
|
||||||
# after a timeout (default 1 second).
|
|
||||||
# Raises ProtocolError if the status is not OK.
|
|
||||||
async def execute_command(
|
async def execute_command(
|
||||||
self,
|
self,
|
||||||
cmd: str,
|
cmd: str,
|
||||||
timeout: float = 1.0,
|
timeout: float = 1.0,
|
||||||
response_type: AtResponseType = AtResponseType.NONE,
|
response_type: AtResponseType = AtResponseType.NONE,
|
||||||
) -> Union[None, AtResponse, List[AtResponse]]:
|
) -> Union[None, AtResponse, List[AtResponse]]:
|
||||||
|
"""
|
||||||
|
Sends an AT command and wait for the peer response.
|
||||||
|
Wait for the AT responses sent by the peer, to the status code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd: the AT command in string to execute.
|
||||||
|
timeout: timeout in float seconds.
|
||||||
|
response_type: type of response.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
asyncio.TimeoutError: the status is not received after a timeout (default 1 second).
|
||||||
|
ProtocolError: the status is not OK.
|
||||||
|
"""
|
||||||
async with self.command_lock:
|
async with self.command_lock:
|
||||||
logger.debug(f">>> {cmd}")
|
logger.debug(f">>> {cmd}")
|
||||||
self.dlc.write(cmd + '\r')
|
self.dlc.write(cmd + '\r')
|
||||||
@@ -479,8 +587,9 @@ class HfProtocol:
|
|||||||
raise HfpProtocolError(result.code)
|
raise HfpProtocolError(result.code)
|
||||||
responses.append(result)
|
responses.append(result)
|
||||||
|
|
||||||
# 4.2.1 Service Level Connection Initialization.
|
|
||||||
async def initiate_slc(self):
|
async def initiate_slc(self):
|
||||||
|
"""4.2.1 Service Level Connection Initialization."""
|
||||||
|
|
||||||
# 4.2.1.1 Supported features exchange
|
# 4.2.1.1 Supported features exchange
|
||||||
# First, in the initialization procedure, the HF shall send the
|
# First, in the initialization procedure, the HF shall send the
|
||||||
# AT+BRSF=<HF supported features> command to the AG to both notify
|
# AT+BRSF=<HF supported features> command to the AG to both notify
|
||||||
@@ -620,16 +729,17 @@ class HfProtocol:
|
|||||||
|
|
||||||
logger.info("SLC setup completed")
|
logger.info("SLC setup completed")
|
||||||
|
|
||||||
# 4.11.2 Audio Connection Setup by HF
|
|
||||||
async def setup_audio_connection(self):
|
async def setup_audio_connection(self):
|
||||||
|
"""4.11.2 Audio Connection Setup by HF."""
|
||||||
|
|
||||||
# When the HF triggers the establishment of the Codec Connection it
|
# When the HF triggers the establishment of the Codec Connection it
|
||||||
# shall send the AT command AT+BCC to the AG. The AG shall respond with
|
# shall send the AT command AT+BCC to the AG. The AG shall respond with
|
||||||
# OK if it will start the Codec Connection procedure, and with ERROR
|
# OK if it will start the Codec Connection procedure, and with ERROR
|
||||||
# if it cannot start the Codec Connection procedure.
|
# if it cannot start the Codec Connection procedure.
|
||||||
await self.execute_command("AT+BCC")
|
await self.execute_command("AT+BCC")
|
||||||
|
|
||||||
# 4.11.3 Codec Connection Setup
|
|
||||||
async def setup_codec_connection(self, codec_id: int):
|
async def setup_codec_connection(self, codec_id: int):
|
||||||
|
"""4.11.3 Codec Connection Setup."""
|
||||||
# The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
|
# The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
|
||||||
# The HF shall then respond to the incoming unsolicited response with
|
# The HF shall then respond to the incoming unsolicited response with
|
||||||
# the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
|
# the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
|
||||||
@@ -647,27 +757,29 @@ class HfProtocol:
|
|||||||
# Synchronous Connection with the settings that are determined by the
|
# Synchronous Connection with the settings that are determined by the
|
||||||
# ID. The HF shall be ready to accept the synchronous connection
|
# ID. The HF shall be ready to accept the synchronous connection
|
||||||
# establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
|
# establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
|
||||||
|
self.active_codec = AudioCodec(codec_id)
|
||||||
|
self.emit('codec_negotiation', self.active_codec)
|
||||||
|
|
||||||
logger.info("codec connection setup completed")
|
logger.info("codec connection setup completed")
|
||||||
|
|
||||||
# 4.13.1 Answer Incoming Call from the HF – In-Band Ringing
|
|
||||||
async def answer_incoming_call(self):
|
async def answer_incoming_call(self):
|
||||||
|
"""4.13.1 Answer Incoming Call from the HF - In-Band Ringing."""
|
||||||
# The user accepts the incoming voice call by using the proper means
|
# The user accepts the incoming voice call by using the proper means
|
||||||
# provided by the HF. The HF shall then send the ATA command
|
# provided by the HF. The HF shall then send the ATA command
|
||||||
# (see Section 4.34) to the AG. The AG shall then begin the procedure for
|
# (see Section 4.34) to the AG. The AG shall then begin the procedure for
|
||||||
# accepting the incoming call.
|
# accepting the incoming call.
|
||||||
await self.execute_command("ATA")
|
await self.execute_command("ATA")
|
||||||
|
|
||||||
# 4.14.1 Reject an Incoming Call from the HF
|
|
||||||
async def reject_incoming_call(self):
|
async def reject_incoming_call(self):
|
||||||
|
"""4.14.1 Reject an Incoming Call from the HF."""
|
||||||
# The user rejects the incoming call by using the User Interface on the
|
# The user rejects the incoming call by using the User Interface on the
|
||||||
# Hands-Free unit. The HF shall then send the AT+CHUP command
|
# Hands-Free unit. The HF shall then send the AT+CHUP command
|
||||||
# (see Section 4.34) to the AG. This may happen at any time during the
|
# (see Section 4.34) to the AG. This may happen at any time during the
|
||||||
# procedures described in Sections 4.13.1 and 4.13.2.
|
# procedures described in Sections 4.13.1 and 4.13.2.
|
||||||
await self.execute_command("AT+CHUP")
|
await self.execute_command("AT+CHUP")
|
||||||
|
|
||||||
# 4.15.1 Terminate a Call Process from the HF
|
|
||||||
async def terminate_call(self):
|
async def terminate_call(self):
|
||||||
|
"""4.15.1 Terminate a Call Process from the HF."""
|
||||||
# The user may abort the ongoing call process using whatever means
|
# The user may abort the ongoing call process using whatever means
|
||||||
# provided by the Hands-Free unit. The HF shall send AT+CHUP command
|
# provided by the Hands-Free unit. The HF shall send AT+CHUP command
|
||||||
# (see Section 4.34) to the AG, and the AG shall then start the
|
# (see Section 4.34) to the AG, and the AG shall then start the
|
||||||
@@ -676,8 +788,35 @@ class HfProtocol:
|
|||||||
# code, with the value indicating (call=0).
|
# code, with the value indicating (call=0).
|
||||||
await self.execute_command("AT+CHUP")
|
await self.execute_command("AT+CHUP")
|
||||||
|
|
||||||
|
async def query_current_calls(self) -> List[CallInfo]:
|
||||||
|
"""4.32.1 Query List of Current Calls in AG.
|
||||||
|
|
||||||
|
Return:
|
||||||
|
List of current calls in AG.
|
||||||
|
"""
|
||||||
|
responses = await self.execute_command(
|
||||||
|
"AT+CLCC", response_type=AtResponseType.MULTIPLE
|
||||||
|
)
|
||||||
|
assert isinstance(responses, list)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
for response in responses:
|
||||||
|
call_info = CallInfo(
|
||||||
|
index=int(response.parameters[0]),
|
||||||
|
direction=CallInfoDirection(int(response.parameters[1])),
|
||||||
|
status=CallInfoStatus(int(response.parameters[2])),
|
||||||
|
mode=CallInfoMode(int(response.parameters[3])),
|
||||||
|
multi_party=CallInfoMultiParty(int(response.parameters[4])),
|
||||||
|
)
|
||||||
|
if len(response.parameters) >= 7:
|
||||||
|
call_info.number = int(response.parameters[5])
|
||||||
|
call_info.type = int(response.parameters[6])
|
||||||
|
calls.append(call_info)
|
||||||
|
return calls
|
||||||
|
|
||||||
async def update_ag_indicator(self, index: int, value: int):
|
async def update_ag_indicator(self, index: int, value: int):
|
||||||
self.ag_indicators[index].current_status = value
|
self.ag_indicators[index].current_status = value
|
||||||
|
self.emit('ag_indicator', self.ag_indicators[index])
|
||||||
logger.info(
|
logger.info(
|
||||||
f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
|
f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
|
||||||
)
|
)
|
||||||
@@ -695,9 +834,11 @@ class HfProtocol:
|
|||||||
logging.info(f"unhandled unsolicited response {result.code}")
|
logging.info(f"unhandled unsolicited response {result.code}")
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
"""Main rountine for the Hands-Free side of the HFP protocol.
|
"""
|
||||||
Initiates the service level connection then loops handling
|
Main routine for the Hands-Free side of the HFP protocol.
|
||||||
unsolicited AG responses."""
|
|
||||||
|
Initiates the service level connection then loops handling unsolicited AG responses.
|
||||||
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.initiate_slc()
|
await self.initiate_slc()
|
||||||
@@ -713,9 +854,13 @@ class HfProtocol:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# Profile version (normative).
|
|
||||||
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
|
||||||
class ProfileVersion(enum.IntEnum):
|
class ProfileVersion(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Profile version (normative).
|
||||||
|
|
||||||
|
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
||||||
|
"""
|
||||||
|
|
||||||
V1_5 = 0x0105
|
V1_5 = 0x0105
|
||||||
V1_6 = 0x0106
|
V1_6 = 0x0106
|
||||||
V1_7 = 0x0107
|
V1_7 = 0x0107
|
||||||
@@ -723,9 +868,13 @@ class ProfileVersion(enum.IntEnum):
|
|||||||
V1_9 = 0x0109
|
V1_9 = 0x0109
|
||||||
|
|
||||||
|
|
||||||
# HF supported features (normative).
|
|
||||||
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
|
||||||
class HfSdpFeature(enum.IntFlag):
|
class HfSdpFeature(enum.IntFlag):
|
||||||
|
"""
|
||||||
|
HF supported features (normative).
|
||||||
|
|
||||||
|
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
||||||
|
"""
|
||||||
|
|
||||||
EC_NR = 0x01 # Echo Cancel & Noise reduction
|
EC_NR = 0x01 # Echo Cancel & Noise reduction
|
||||||
THREE_WAY_CALLING = 0x02
|
THREE_WAY_CALLING = 0x02
|
||||||
CLI_PRESENTATION_CAPABILITY = 0x04
|
CLI_PRESENTATION_CAPABILITY = 0x04
|
||||||
@@ -736,9 +885,13 @@ class HfSdpFeature(enum.IntFlag):
|
|||||||
VOICE_RECOGNITION_TEST = 0x80
|
VOICE_RECOGNITION_TEST = 0x80
|
||||||
|
|
||||||
|
|
||||||
# AG supported features (normative).
|
|
||||||
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
|
||||||
class AgSdpFeature(enum.IntFlag):
|
class AgSdpFeature(enum.IntFlag):
|
||||||
|
"""
|
||||||
|
AG supported features (normative).
|
||||||
|
|
||||||
|
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
||||||
|
"""
|
||||||
|
|
||||||
THREE_WAY_CALLING = 0x01
|
THREE_WAY_CALLING = 0x01
|
||||||
EC_NR = 0x02 # Echo Cancel & Noise reduction
|
EC_NR = 0x02 # Echo Cancel & Noise reduction
|
||||||
VOICE_RECOGNITION_FUNCTION = 0x04
|
VOICE_RECOGNITION_FUNCTION = 0x04
|
||||||
@@ -752,9 +905,12 @@ class AgSdpFeature(enum.IntFlag):
|
|||||||
def sdp_records(
|
def sdp_records(
|
||||||
service_record_handle: int, rfcomm_channel: int, configuration: Configuration
|
service_record_handle: int, rfcomm_channel: int, configuration: Configuration
|
||||||
) -> List[ServiceAttribute]:
|
) -> List[ServiceAttribute]:
|
||||||
"""Generate the SDP record for HFP Hands-Free support.
|
"""
|
||||||
|
Generates the SDP record for HFP Hands-Free support.
|
||||||
|
|
||||||
The record exposes the features supported in the input configuration,
|
The record exposes the features supported in the input configuration,
|
||||||
and the allocated RFCOMM channel."""
|
and the allocated RFCOMM channel.
|
||||||
|
"""
|
||||||
|
|
||||||
hf_supported_features = 0
|
hf_supported_features = 0
|
||||||
|
|
||||||
|
|||||||
403
bumble/hid.py
403
bumble/hid.py
@@ -19,16 +19,17 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
import enum
|
import enum
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, Callable, TYPE_CHECKING
|
||||||
|
from typing_extensions import override
|
||||||
|
|
||||||
from bumble import l2cap
|
from bumble import l2cap, device
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import InvalidStateError, ProtocolError
|
from bumble.core import InvalidStateError, ProtocolError
|
||||||
|
from .hci import Address
|
||||||
if TYPE_CHECKING:
|
|
||||||
from bumble.device import Device, Connection
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -60,6 +61,7 @@ class Message:
|
|||||||
NOT_READY = 0x01
|
NOT_READY = 0x01
|
||||||
ERR_INVALID_REPORT_ID = 0x02
|
ERR_INVALID_REPORT_ID = 0x02
|
||||||
ERR_UNSUPPORTED_REQUEST = 0x03
|
ERR_UNSUPPORTED_REQUEST = 0x03
|
||||||
|
ERR_INVALID_PARAMETER = 0x04
|
||||||
ERR_UNKNOWN = 0x0E
|
ERR_UNKNOWN = 0x0E
|
||||||
ERR_FATAL = 0x0F
|
ERR_FATAL = 0x0F
|
||||||
|
|
||||||
@@ -101,13 +103,14 @@ class GetReportMessage(Message):
|
|||||||
def __bytes__(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
packet_bytes = bytearray()
|
packet_bytes = bytearray()
|
||||||
packet_bytes.append(self.report_id)
|
packet_bytes.append(self.report_id)
|
||||||
packet_bytes.extend(
|
if self.buffer_size == 0:
|
||||||
[(self.buffer_size & 0xFF), ((self.buffer_size >> 8) & 0xFF)]
|
|
||||||
)
|
|
||||||
if self.report_type == Message.ReportType.OTHER_REPORT:
|
|
||||||
return self.header(self.report_type) + packet_bytes
|
return self.header(self.report_type) + packet_bytes
|
||||||
else:
|
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
|
@dataclass
|
||||||
@@ -120,6 +123,16 @@ class SetReportMessage(Message):
|
|||||||
return self.header(self.report_type) + self.data
|
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
|
@dataclass
|
||||||
class GetProtocolMessage(Message):
|
class GetProtocolMessage(Message):
|
||||||
message_type = Message.MessageType.GET_PROTOCOL
|
message_type = Message.MessageType.GET_PROTOCOL
|
||||||
@@ -161,31 +174,47 @@ class VirtualCableUnplug(Message):
|
|||||||
return self.header(Message.ControlCommand.VIRTUAL_CABLE_UNPLUG)
|
return self.header(Message.ControlCommand.VIRTUAL_CABLE_UNPLUG)
|
||||||
|
|
||||||
|
|
||||||
|
# Device sends input report, host sends output report.
|
||||||
@dataclass
|
@dataclass
|
||||||
class SendData(Message):
|
class SendData(Message):
|
||||||
data: bytes
|
data: bytes
|
||||||
|
report_type: int
|
||||||
message_type = Message.MessageType.DATA
|
message_type = Message.MessageType.DATA
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
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):
|
class HID(ABC, EventEmitter):
|
||||||
l2cap_ctrl_channel: Optional[l2cap.ClassicChannel]
|
l2cap_ctrl_channel: Optional[l2cap.ClassicChannel] = None
|
||||||
l2cap_intr_channel: Optional[l2cap.ClassicChannel]
|
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__()
|
super().__init__()
|
||||||
|
self.remote_device_bd_address: Optional[Address] = None
|
||||||
self.device = device
|
self.device = device
|
||||||
self.connection = connection
|
self.role = role
|
||||||
|
|
||||||
self.l2cap_ctrl_channel = None
|
|
||||||
self.l2cap_intr_channel = None
|
|
||||||
|
|
||||||
# Register ourselves with the L2CAP channel manager
|
# Register ourselves with the L2CAP channel manager
|
||||||
device.register_l2cap_server(HID_CONTROL_PSM, self.on_connection)
|
device.register_l2cap_server(HID_CONTROL_PSM, self.on_l2cap_connection)
|
||||||
device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_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:
|
async def connect_control_channel(self) -> None:
|
||||||
# Create a new L2CAP connection - control channel
|
# Create a new L2CAP connection - control channel
|
||||||
@@ -229,9 +258,18 @@ class Host(EventEmitter):
|
|||||||
self.l2cap_ctrl_channel = None
|
self.l2cap_ctrl_channel = None
|
||||||
await channel.disconnect()
|
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}')
|
logger.debug(f'+++ New L2CAP connection: {l2cap_channel}')
|
||||||
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(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:
|
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
||||||
if l2cap_channel.psm == HID_CONTROL_PSM:
|
if l2cap_channel.psm == HID_CONTROL_PSM:
|
||||||
@@ -242,63 +280,20 @@ class Host(EventEmitter):
|
|||||||
self.l2cap_intr_channel.sink = self.on_intr_pdu
|
self.l2cap_intr_channel.sink = self.on_intr_pdu
|
||||||
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
|
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
|
||||||
|
|
||||||
def on_ctrl_pdu(self, pdu: bytes) -> None:
|
def on_l2cap_channel_close(self, l2cap_channel: l2cap.ClassicChannel) -> None:
|
||||||
logger.debug(f'<<< HID CONTROL PDU: {pdu.hex()}')
|
if l2cap_channel.psm == HID_CONTROL_PSM:
|
||||||
# Here we will receive all kinds of packets, parse and then call respective callbacks
|
self.l2cap_ctrl_channel = None
|
||||||
message_type = pdu[0] >> 4
|
else:
|
||||||
param = pdu[0] & 0x0F
|
self.l2cap_intr_channel = None
|
||||||
|
logger.debug(f'$$$ L2CAP channel close: {l2cap_channel}')
|
||||||
|
|
||||||
if message_type == Message.MessageType.HANDSHAKE:
|
@abstractmethod
|
||||||
logger.debug(f'<<< HID HANDSHAKE: {Message.Handshake(param).name}')
|
def on_ctrl_pdu(self, pdu: bytes) -> None:
|
||||||
self.emit('handshake', Message.Handshake(param))
|
pass
|
||||||
elif message_type == Message.MessageType.DATA:
|
|
||||||
logger.debug('<<< HID CONTROL DATA')
|
|
||||||
self.emit('data', pdu)
|
|
||||||
elif message_type == Message.MessageType.CONTROL:
|
|
||||||
if param == Message.ControlCommand.SUSPEND:
|
|
||||||
logger.debug('<<< HID SUSPEND')
|
|
||||||
self.emit('suspend', pdu)
|
|
||||||
elif param == Message.ControlCommand.EXIT_SUSPEND:
|
|
||||||
logger.debug('<<< HID EXIT SUSPEND')
|
|
||||||
self.emit('exit_suspend', pdu)
|
|
||||||
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)
|
|
||||||
|
|
||||||
def on_intr_pdu(self, pdu: bytes) -> None:
|
def on_intr_pdu(self, pdu: bytes) -> None:
|
||||||
logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
|
logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
|
||||||
self.emit("data", pdu)
|
self.emit("interrupt_data", pdu)
|
||||||
|
|
||||||
def get_report(self, report_type: int, report_id: int, buffer_size: int) -> None:
|
|
||||||
msg = GetReportMessage(
|
|
||||||
report_type=report_type, report_id=report_id, buffer_size=buffer_size
|
|
||||||
)
|
|
||||||
hid_message = bytes(msg)
|
|
||||||
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):
|
|
||||||
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):
|
|
||||||
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):
|
|
||||||
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:
|
def send_pdu_on_ctrl(self, msg: bytes) -> None:
|
||||||
assert self.l2cap_ctrl_channel
|
assert self.l2cap_ctrl_channel
|
||||||
@@ -308,26 +303,252 @@ class Host(EventEmitter):
|
|||||||
assert self.l2cap_intr_channel
|
assert self.l2cap_intr_channel
|
||||||
self.l2cap_intr_channel.send_pdu(msg)
|
self.l2cap_intr_channel.send_pdu(msg)
|
||||||
|
|
||||||
def send_data(self, data):
|
def send_data(self, data: bytes) -> None:
|
||||||
msg = SendData(data)
|
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)
|
hid_message = bytes(msg)
|
||||||
|
if self.l2cap_intr_channel is not None:
|
||||||
logger.debug(f'>>> HID INTERRUPT SEND DATA, PDU: {hid_message.hex()}')
|
logger.debug(f'>>> HID INTERRUPT SEND DATA, PDU: {hid_message.hex()}')
|
||||||
self.send_pdu_on_intr(hid_message)
|
self.send_pdu_on_intr(hid_message)
|
||||||
|
|
||||||
def suspend(self):
|
def virtual_cable_unplug(self) -> None:
|
||||||
msg = Suspend()
|
|
||||||
hid_message = bytes(msg)
|
|
||||||
logger.debug(f'>>> HID CONTROL SUSPEND, PDU:{hid_message.hex()}')
|
|
||||||
self.send_pdu_on_ctrl(msg)
|
|
||||||
|
|
||||||
def exit_suspend(self):
|
|
||||||
msg = ExitSuspend()
|
|
||||||
hid_message = bytes(msg)
|
|
||||||
logger.debug(f'>>> HID CONTROL EXIT SUSPEND, PDU:{hid_message.hex()}')
|
|
||||||
self.send_pdu_on_ctrl(msg)
|
|
||||||
|
|
||||||
def virtual_cable_unplug(self):
|
|
||||||
msg = VirtualCableUnplug()
|
msg = VirtualCableUnplug()
|
||||||
hid_message = bytes(msg)
|
hid_message = bytes(msg)
|
||||||
logger.debug(f'>>> HID CONTROL VIRTUAL CABLE UNPLUG, PDU: {hid_message.hex()}')
|
logger.debug(f'>>> HID CONTROL VIRTUAL CABLE UNPLUG, PDU: {hid_message.hex()}')
|
||||||
self.send_pdu_on_ctrl(msg)
|
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()}')
|
||||||
|
param = pdu[0] & 0x0F
|
||||||
|
message_type = pdu[0] >> 4
|
||||||
|
|
||||||
|
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('control_data', pdu)
|
||||||
|
elif message_type == Message.MessageType.CONTROL:
|
||||||
|
if param == Message.ControlCommand.SUSPEND:
|
||||||
|
logger.debug('<<< HID SUSPEND')
|
||||||
|
self.emit('suspend')
|
||||||
|
elif param == Message.ControlCommand.EXIT_SUSPEND:
|
||||||
|
logger.debug('<<< HID EXIT SUSPEND')
|
||||||
|
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 MESSAGE TYPE UNSUPPORTED')
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
|
||||||
|
|
||||||
|
def send_handshake_message(self, result_code: int) -> None:
|
||||||
|
msg = SendHandshakeMessage(result_code)
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID HANDSHAKE MESSAGE, PDU: {hid_message.hex()}')
|
||||||
|
self.send_pdu_on_ctrl(hid_message)
|
||||||
|
|
||||||
|
def send_control_data(self, report_type: int, data: bytes):
|
||||||
|
msg = SendControlData(report_type=report_type, data=data)
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID CONTROL DATA: {hid_message.hex()}')
|
||||||
|
self.send_pdu_on_ctrl(hid_message)
|
||||||
|
|
||||||
|
def handle_get_report(self, pdu: bytes):
|
||||||
|
if self.get_report_cb is None:
|
||||||
|
logger.debug("GetReport callback not registered !!")
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
|
||||||
|
return
|
||||||
|
report_type = pdu[0] & 0x03
|
||||||
|
buffer_flag = (pdu[0] & 0x08) >> 3
|
||||||
|
report_id = pdu[1]
|
||||||
|
logger.debug(f"buffer_flag: {buffer_flag}")
|
||||||
|
if buffer_flag == 1:
|
||||||
|
buffer_size = (pdu[3] << 8) | pdu[2]
|
||||||
|
else:
|
||||||
|
buffer_size = 0
|
||||||
|
|
||||||
|
ret = self.get_report_cb(report_id, report_type, buffer_size)
|
||||||
|
assert ret is not None
|
||||||
|
if ret.status == self.GetSetReturn.FAILURE:
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_UNKNOWN)
|
||||||
|
elif ret.status == self.GetSetReturn.SUCCESS:
|
||||||
|
data = bytearray()
|
||||||
|
data.append(report_id)
|
||||||
|
data.extend(ret.data)
|
||||||
|
if len(data) < self.l2cap_ctrl_channel.peer_mtu: # type: ignore[union-attr]
|
||||||
|
self.send_control_data(report_type=report_type, data=data)
|
||||||
|
else:
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_INVALID_PARAMETER)
|
||||||
|
elif ret.status == self.GetSetReturn.REPORT_ID_NOT_FOUND:
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_INVALID_REPORT_ID)
|
||||||
|
elif ret.status == self.GetSetReturn.ERR_INVALID_PARAMETER:
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_INVALID_PARAMETER)
|
||||||
|
elif ret.status == self.GetSetReturn.ERR_UNSUPPORTED_REQUEST:
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
|
||||||
|
|
||||||
|
def register_get_report_cb(self, cb: Callable[[int, int, int], None]) -> None:
|
||||||
|
self.get_report_cb = cb
|
||||||
|
logger.debug("GetReport callback registered successfully")
|
||||||
|
|
||||||
|
def handle_set_report(self, pdu: bytes):
|
||||||
|
if self.set_report_cb is None:
|
||||||
|
logger.debug("SetReport callback not registered !!")
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
|
||||||
|
return
|
||||||
|
report_type = pdu[0] & 0x03
|
||||||
|
report_id = pdu[1]
|
||||||
|
report_data = pdu[2:]
|
||||||
|
report_size = len(report_data) + 1
|
||||||
|
ret = self.set_report_cb(report_id, report_type, report_size, report_data)
|
||||||
|
assert ret is not None
|
||||||
|
if ret.status == self.GetSetReturn.SUCCESS:
|
||||||
|
self.send_handshake_message(Message.Handshake.SUCCESSFUL)
|
||||||
|
elif ret.status == self.GetSetReturn.ERR_INVALID_PARAMETER:
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_INVALID_PARAMETER)
|
||||||
|
elif ret.status == self.GetSetReturn.REPORT_ID_NOT_FOUND:
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_INVALID_REPORT_ID)
|
||||||
|
else:
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
|
||||||
|
|
||||||
|
def register_set_report_cb(
|
||||||
|
self, cb: Callable[[int, int, int, bytes], None]
|
||||||
|
) -> None:
|
||||||
|
self.set_report_cb = cb
|
||||||
|
logger.debug("SetReport callback registered successfully")
|
||||||
|
|
||||||
|
def handle_get_protocol(self, pdu: bytes):
|
||||||
|
if self.get_protocol_cb is None:
|
||||||
|
logger.debug("GetProtocol callback not registered !!")
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
|
||||||
|
return
|
||||||
|
ret = self.get_protocol_cb()
|
||||||
|
assert ret is not None
|
||||||
|
if ret.status == self.GetSetReturn.SUCCESS:
|
||||||
|
self.send_control_data(Message.ReportType.OTHER_REPORT, ret.data)
|
||||||
|
else:
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
|
||||||
|
|
||||||
|
def register_get_protocol_cb(self, cb: Callable[[], None]) -> None:
|
||||||
|
self.get_protocol_cb = cb
|
||||||
|
logger.debug("GetProtocol callback registered successfully")
|
||||||
|
|
||||||
|
def handle_set_protocol(self, pdu: bytes):
|
||||||
|
if self.set_protocol_cb is None:
|
||||||
|
logger.debug("SetProtocol callback not registered !!")
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
|
||||||
|
return
|
||||||
|
ret = self.set_protocol_cb(pdu[0] & 0x01)
|
||||||
|
assert ret is not None
|
||||||
|
if ret.status == self.GetSetReturn.SUCCESS:
|
||||||
|
self.send_handshake_message(Message.Handshake.SUCCESSFUL)
|
||||||
|
else:
|
||||||
|
self.send_handshake_message(Message.Handshake.ERR_UNSUPPORTED_REQUEST)
|
||||||
|
|
||||||
|
def register_set_protocol_cb(self, cb: Callable[[int], None]) -> None:
|
||||||
|
self.set_protocol_cb = cb
|
||||||
|
logger.debug("SetProtocol callback registered successfully")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Host(HID):
|
||||||
|
def __init__(self, device: device.Device) -> None:
|
||||||
|
super().__init__(device, HID.Role.HOST)
|
||||||
|
|
||||||
|
def get_report(self, report_type: int, report_id: int, buffer_size: int) -> None:
|
||||||
|
msg = GetReportMessage(
|
||||||
|
report_type=report_type, report_id=report_id, buffer_size=buffer_size
|
||||||
|
)
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
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) -> 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) -> 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) -> 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 suspend(self) -> None:
|
||||||
|
msg = Suspend()
|
||||||
|
hid_message = bytes(msg)
|
||||||
|
logger.debug(f'>>> HID CONTROL SUSPEND, PDU:{hid_message.hex()}')
|
||||||
|
self.send_pdu_on_ctrl(hid_message)
|
||||||
|
|
||||||
|
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(hid_message)
|
||||||
|
|
||||||
|
@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')
|
||||||
|
|||||||
671
bumble/host.py
671
bumble/host.py
File diff suppressed because it is too large
Load Diff
@@ -149,9 +149,10 @@ L2CAP_INVALID_CID_IN_REQUEST_REASON = 0x0002
|
|||||||
|
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS = 65535
|
L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS = 65535
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU = 23
|
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_MIN_MPS = 23
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS = 65533
|
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_MPS = 2048
|
||||||
L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS = 256
|
L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS = 256
|
||||||
|
|
||||||
@@ -172,7 +173,7 @@ L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE = 0x01
|
|||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class ClassicChannelSpec:
|
class ClassicChannelSpec:
|
||||||
psm: Optional[int] = None
|
psm: Optional[int] = None
|
||||||
mtu: int = L2CAP_MIN_BR_EDR_MTU
|
mtu: int = L2CAP_DEFAULT_MTU
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@@ -188,8 +189,11 @@ class LeCreditBasedChannelSpec:
|
|||||||
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
||||||
):
|
):
|
||||||
raise ValueError('max credits out of range')
|
raise ValueError('max credits out of range')
|
||||||
if self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
|
if (
|
||||||
raise ValueError('MTU too small')
|
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 (
|
if (
|
||||||
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
|
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
|
||||||
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
|
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
|
||||||
@@ -204,7 +208,7 @@ class L2CAP_PDU:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data: bytes) -> L2CAP_PDU:
|
def from_bytes(data: bytes) -> L2CAP_PDU:
|
||||||
# Sanity check
|
# Check parameters
|
||||||
if len(data) < 4:
|
if len(data) < 4:
|
||||||
raise ValueError('not enough data for L2CAP header')
|
raise ValueError('not enough data for L2CAP header')
|
||||||
|
|
||||||
@@ -745,6 +749,8 @@ class ClassicChannel(EventEmitter):
|
|||||||
sink: Optional[Callable[[bytes], Any]]
|
sink: Optional[Callable[[bytes], Any]]
|
||||||
state: State
|
state: State
|
||||||
connection: Connection
|
connection: Connection
|
||||||
|
mtu: int
|
||||||
|
peer_mtu: int
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -761,6 +767,7 @@ class ClassicChannel(EventEmitter):
|
|||||||
self.signaling_cid = signaling_cid
|
self.signaling_cid = signaling_cid
|
||||||
self.state = self.State.CLOSED
|
self.state = self.State.CLOSED
|
||||||
self.mtu = mtu
|
self.mtu = mtu
|
||||||
|
self.peer_mtu = L2CAP_MIN_BR_EDR_MTU
|
||||||
self.psm = psm
|
self.psm = psm
|
||||||
self.source_cid = source_cid
|
self.source_cid = source_cid
|
||||||
self.destination_cid = 0
|
self.destination_cid = 0
|
||||||
@@ -857,7 +864,7 @@ class ClassicChannel(EventEmitter):
|
|||||||
[
|
[
|
||||||
(
|
(
|
||||||
L2CAP_MAXIMUM_TRANSMISSION_UNIT_CONFIGURATION_OPTION_TYPE,
|
L2CAP_MAXIMUM_TRANSMISSION_UNIT_CONFIGURATION_OPTION_TYPE,
|
||||||
struct.pack('<H', L2CAP_DEFAULT_MTU),
|
struct.pack('<H', self.mtu),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -922,8 +929,8 @@ class ClassicChannel(EventEmitter):
|
|||||||
options = L2CAP_Control_Frame.decode_configuration_options(request.options)
|
options = L2CAP_Control_Frame.decode_configuration_options(request.options)
|
||||||
for option in options:
|
for option in options:
|
||||||
if option[0] == L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE:
|
if option[0] == L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE:
|
||||||
self.mtu = struct.unpack('<H', option[1])[0]
|
self.peer_mtu = struct.unpack('<H', option[1])[0]
|
||||||
logger.debug(f'MTU = {self.mtu}')
|
logger.debug(f'peer MTU = {self.peer_mtu}')
|
||||||
|
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
L2CAP_Configure_Response(
|
L2CAP_Configure_Response(
|
||||||
@@ -1022,7 +1029,7 @@ class ClassicChannel(EventEmitter):
|
|||||||
return (
|
return (
|
||||||
f'Channel({self.source_cid}->{self.destination_cid}, '
|
f'Channel({self.source_cid}->{self.destination_cid}, '
|
||||||
f'PSM={self.psm}, '
|
f'PSM={self.psm}, '
|
||||||
f'MTU={self.mtu}, '
|
f'MTU={self.mtu}/{self.peer_mtu}, '
|
||||||
f'state={self.state.name})'
|
f'state={self.state.name})'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1644,12 +1651,13 @@ class ChannelManager:
|
|||||||
|
|
||||||
def send_pdu(self, connection, cid: int, pdu: Union[SupportsBytes, bytes]) -> None:
|
def send_pdu(self, connection, cid: int, pdu: Union[SupportsBytes, bytes]) -> None:
|
||||||
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
|
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
|
||||||
|
pdu_bytes = bytes(pdu)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{color(">>> Sending L2CAP PDU", "blue")} '
|
f'{color(">>> Sending L2CAP PDU", "blue")} '
|
||||||
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
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:
|
def on_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
|
||||||
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
|
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
|
||||||
@@ -1926,7 +1934,7 @@ class ChannelManager:
|
|||||||
supervision_timeout=request.timeout,
|
supervision_timeout=request.timeout,
|
||||||
min_ce_length=0,
|
min_ce_length=0,
|
||||||
max_ce_length=0,
|
max_ce_length=0,
|
||||||
) # type: ignore[call-arg]
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
|
|||||||
110
bumble/link.py
110
bumble/link.py
@@ -26,9 +26,13 @@ from bumble.hci import (
|
|||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
|
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
|
||||||
HCI_CONNECTION_TIMEOUT_ERROR,
|
HCI_CONNECTION_TIMEOUT_ERROR,
|
||||||
|
HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||||
HCI_PAGE_TIMEOUT_ERROR,
|
HCI_PAGE_TIMEOUT_ERROR,
|
||||||
HCI_Connection_Complete_Event,
|
HCI_Connection_Complete_Event,
|
||||||
)
|
)
|
||||||
|
from bumble import controller
|
||||||
|
|
||||||
|
from typing import Optional, Set
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -57,6 +61,8 @@ class LocalLink:
|
|||||||
Link bus for controllers to communicate with each other
|
Link bus for controllers to communicate with each other
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
controllers: Set[controller.Controller]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.controllers = set()
|
self.controllers = set()
|
||||||
self.pending_connection = None
|
self.pending_connection = None
|
||||||
@@ -79,7 +85,9 @@ class LocalLink:
|
|||||||
return controller
|
return controller
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def find_classic_controller(self, address):
|
def find_classic_controller(
|
||||||
|
self, address: Address
|
||||||
|
) -> Optional[controller.Controller]:
|
||||||
for controller in self.controllers:
|
for controller in self.controllers:
|
||||||
if controller.public_address == address:
|
if controller.public_address == address:
|
||||||
return controller
|
return controller
|
||||||
@@ -188,6 +196,60 @@ class LocalLink:
|
|||||||
if peripheral_controller := self.find_controller(peripheral_address):
|
if peripheral_controller := self.find_controller(peripheral_address):
|
||||||
peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk)
|
peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk)
|
||||||
|
|
||||||
|
def create_cis(
|
||||||
|
self,
|
||||||
|
central_controller: controller.Controller,
|
||||||
|
peripheral_address: Address,
|
||||||
|
cig_id: int,
|
||||||
|
cis_id: int,
|
||||||
|
) -> None:
|
||||||
|
logger.debug(
|
||||||
|
f'$$$ CIS Request {central_controller.random_address} -> {peripheral_address}'
|
||||||
|
)
|
||||||
|
if peripheral_controller := self.find_controller(peripheral_address):
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
|
peripheral_controller.on_link_cis_request,
|
||||||
|
central_controller.random_address,
|
||||||
|
cig_id,
|
||||||
|
cis_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def accept_cis(
|
||||||
|
self,
|
||||||
|
peripheral_controller: controller.Controller,
|
||||||
|
central_address: Address,
|
||||||
|
cig_id: int,
|
||||||
|
cis_id: int,
|
||||||
|
) -> None:
|
||||||
|
logger.debug(
|
||||||
|
f'$$$ CIS Accept {peripheral_controller.random_address} -> {central_address}'
|
||||||
|
)
|
||||||
|
if central_controller := self.find_controller(central_address):
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
|
central_controller.on_link_cis_established, cig_id, cis_id
|
||||||
|
)
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
|
peripheral_controller.on_link_cis_established, cig_id, cis_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def disconnect_cis(
|
||||||
|
self,
|
||||||
|
initiator_controller: controller.Controller,
|
||||||
|
peer_address: Address,
|
||||||
|
cig_id: int,
|
||||||
|
cis_id: int,
|
||||||
|
) -> None:
|
||||||
|
logger.debug(
|
||||||
|
f'$$$ CIS Disconnect {initiator_controller.random_address} -> {peer_address}'
|
||||||
|
)
|
||||||
|
if peer_controller := self.find_controller(peer_address):
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
|
initiator_controller.on_link_cis_disconnected, cig_id, cis_id
|
||||||
|
)
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
|
peer_controller.on_link_cis_disconnected, cig_id, cis_id
|
||||||
|
)
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Classic handlers
|
# Classic handlers
|
||||||
############################################################
|
############################################################
|
||||||
@@ -271,6 +333,52 @@ class LocalLink:
|
|||||||
initiator_controller.public_address, int(not (initiator_new_role))
|
initiator_controller.public_address, int(not (initiator_new_role))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def classic_sco_connect(
|
||||||
|
self,
|
||||||
|
initiator_controller: controller.Controller,
|
||||||
|
responder_address: Address,
|
||||||
|
link_type: int,
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
f'[Classic] {initiator_controller.public_address} connects SCO to {responder_address}'
|
||||||
|
)
|
||||||
|
responder_controller = self.find_classic_controller(responder_address)
|
||||||
|
# Initiator controller should handle it.
|
||||||
|
assert responder_controller
|
||||||
|
|
||||||
|
responder_controller.on_classic_connection_request(
|
||||||
|
initiator_controller.public_address,
|
||||||
|
link_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
def classic_accept_sco_connection(
|
||||||
|
self,
|
||||||
|
responder_controller: controller.Controller,
|
||||||
|
initiator_address: Address,
|
||||||
|
link_type: int,
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
f'[Classic] {responder_controller.public_address} accepts to connect SCO {initiator_address}'
|
||||||
|
)
|
||||||
|
initiator_controller = self.find_classic_controller(initiator_address)
|
||||||
|
if initiator_controller is None:
|
||||||
|
responder_controller.on_classic_sco_connection_complete(
|
||||||
|
responder_controller.public_address,
|
||||||
|
HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
||||||
|
link_type,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def task():
|
||||||
|
initiator_controller.on_classic_sco_connection_complete(
|
||||||
|
responder_controller.public_address, HCI_SUCCESS, link_type
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.create_task(task())
|
||||||
|
responder_controller.on_classic_sco_connection_complete(
|
||||||
|
initiator_controller.public_address, HCI_SUCCESS, link_type
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class RemoteLink:
|
class RemoteLink:
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ from bumble.device import (
|
|||||||
DEVICE_DEFAULT_SCAN_INTERVAL,
|
DEVICE_DEFAULT_SCAN_INTERVAL,
|
||||||
DEVICE_DEFAULT_SCAN_WINDOW,
|
DEVICE_DEFAULT_SCAN_WINDOW,
|
||||||
Advertisement,
|
Advertisement,
|
||||||
|
AdvertisingParameters,
|
||||||
|
AdvertisingEventProperties,
|
||||||
AdvertisingType,
|
AdvertisingType,
|
||||||
Device,
|
Device,
|
||||||
|
Phy,
|
||||||
)
|
)
|
||||||
from bumble.gatt import Service
|
from bumble.gatt import Service
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
@@ -47,9 +50,12 @@ from bumble.hci import (
|
|||||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
||||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
||||||
from pandora.host_grpc_aio import HostServicer
|
from pandora.host_grpc_aio import HostServicer
|
||||||
|
from pandora import host_pb2
|
||||||
from pandora.host_pb2 import (
|
from pandora.host_pb2 import (
|
||||||
NOT_CONNECTABLE,
|
NOT_CONNECTABLE,
|
||||||
NOT_DISCOVERABLE,
|
NOT_DISCOVERABLE,
|
||||||
|
DISCOVERABLE_LIMITED,
|
||||||
|
DISCOVERABLE_GENERAL,
|
||||||
PRIMARY_1M,
|
PRIMARY_1M,
|
||||||
PRIMARY_CODED,
|
PRIMARY_CODED,
|
||||||
SECONDARY_1M,
|
SECONDARY_1M,
|
||||||
@@ -65,6 +71,7 @@ from pandora.host_pb2 import (
|
|||||||
ConnectResponse,
|
ConnectResponse,
|
||||||
DataTypes,
|
DataTypes,
|
||||||
DisconnectRequest,
|
DisconnectRequest,
|
||||||
|
DiscoverabilityMode,
|
||||||
InquiryResponse,
|
InquiryResponse,
|
||||||
PrimaryPhy,
|
PrimaryPhy,
|
||||||
ReadLocalAddressResponse,
|
ReadLocalAddressResponse,
|
||||||
@@ -94,6 +101,25 @@ SECONDARY_PHY_MAP: Dict[int, SecondaryPhy] = {
|
|||||||
3: SECONDARY_CODED,
|
3: SECONDARY_CODED,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PRIMARY_PHY_TO_BUMBLE_PHY_MAP: Dict[PrimaryPhy, Phy] = {
|
||||||
|
PRIMARY_1M: Phy.LE_1M,
|
||||||
|
PRIMARY_CODED: Phy.LE_CODED,
|
||||||
|
}
|
||||||
|
|
||||||
|
SECONDARY_PHY_TO_BUMBLE_PHY_MAP: Dict[SecondaryPhy, Phy] = {
|
||||||
|
SECONDARY_NONE: Phy.LE_1M,
|
||||||
|
SECONDARY_1M: Phy.LE_1M,
|
||||||
|
SECONDARY_2M: Phy.LE_2M,
|
||||||
|
SECONDARY_CODED: Phy.LE_CODED,
|
||||||
|
}
|
||||||
|
|
||||||
|
OWN_ADDRESS_MAP: Dict[host_pb2.OwnAddressType, bumble.hci.OwnAddressType] = {
|
||||||
|
host_pb2.PUBLIC: bumble.hci.OwnAddressType.PUBLIC,
|
||||||
|
host_pb2.RANDOM: bumble.hci.OwnAddressType.RANDOM,
|
||||||
|
host_pb2.RESOLVABLE_OR_PUBLIC: bumble.hci.OwnAddressType.RESOLVABLE_OR_PUBLIC,
|
||||||
|
host_pb2.RESOLVABLE_OR_RANDOM: bumble.hci.OwnAddressType.RESOLVABLE_OR_RANDOM,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class HostService(HostServicer):
|
class HostService(HostServicer):
|
||||||
waited_connections: Set[int]
|
waited_connections: Set[int]
|
||||||
@@ -281,14 +307,118 @@ class HostService(HostServicer):
|
|||||||
async def Advertise(
|
async def Advertise(
|
||||||
self, request: AdvertiseRequest, context: grpc.ServicerContext
|
self, request: AdvertiseRequest, context: grpc.ServicerContext
|
||||||
) -> AsyncGenerator[AdvertiseResponse, None]:
|
) -> AsyncGenerator[AdvertiseResponse, None]:
|
||||||
if not request.legacy:
|
try:
|
||||||
raise NotImplementedError(
|
if request.legacy:
|
||||||
"TODO: add support for extended advertising in Bumble"
|
async for rsp in self.legacy_advertise(request, context):
|
||||||
|
yield rsp
|
||||||
|
else:
|
||||||
|
async for rsp in self.extended_advertise(request, context):
|
||||||
|
yield rsp
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def extended_advertise(
|
||||||
|
self, request: AdvertiseRequest, context: grpc.ServicerContext
|
||||||
|
) -> AsyncGenerator[AdvertiseResponse, None]:
|
||||||
|
advertising_data = bytes(self.unpack_data_types(request.data))
|
||||||
|
scan_response_data = bytes(self.unpack_data_types(request.scan_response_data))
|
||||||
|
scannable = len(scan_response_data) != 0
|
||||||
|
|
||||||
|
advertising_event_properties = AdvertisingEventProperties(
|
||||||
|
is_connectable=request.connectable,
|
||||||
|
is_scannable=scannable,
|
||||||
|
is_directed=request.target is not None,
|
||||||
|
is_high_duty_cycle_directed_connectable=False,
|
||||||
|
is_legacy=False,
|
||||||
|
is_anonymous=False,
|
||||||
|
include_tx_power=False,
|
||||||
)
|
)
|
||||||
if request.interval:
|
|
||||||
raise NotImplementedError("TODO: add support for `request.interval`")
|
peer_address = Address.ANY
|
||||||
if request.interval_range:
|
if request.target:
|
||||||
raise NotImplementedError("TODO: add support for `request.interval_range`")
|
# Need to reverse bytes order since Bumble Address is using MSB.
|
||||||
|
target_bytes = bytes(reversed(request.target))
|
||||||
|
if request.target_variant() == "public":
|
||||||
|
peer_address = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
|
||||||
|
else:
|
||||||
|
peer_address = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
|
||||||
|
|
||||||
|
advertising_parameters = AdvertisingParameters(
|
||||||
|
advertising_event_properties=advertising_event_properties,
|
||||||
|
own_address_type=OWN_ADDRESS_MAP[request.own_address_type],
|
||||||
|
peer_address=peer_address,
|
||||||
|
primary_advertising_phy=PRIMARY_PHY_TO_BUMBLE_PHY_MAP[request.primary_phy],
|
||||||
|
secondary_advertising_phy=SECONDARY_PHY_TO_BUMBLE_PHY_MAP[
|
||||||
|
request.secondary_phy
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if advertising_interval := request.interval:
|
||||||
|
advertising_parameters.primary_advertising_interval_min = int(
|
||||||
|
advertising_interval
|
||||||
|
)
|
||||||
|
advertising_parameters.primary_advertising_interval_max = int(
|
||||||
|
advertising_interval
|
||||||
|
)
|
||||||
|
if interval_range := request.interval_range:
|
||||||
|
advertising_parameters.primary_advertising_interval_max += int(
|
||||||
|
interval_range
|
||||||
|
)
|
||||||
|
|
||||||
|
advertising_set = await self.device.create_advertising_set(
|
||||||
|
advertising_parameters=advertising_parameters,
|
||||||
|
advertising_data=advertising_data,
|
||||||
|
scan_response_data=scan_response_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
pending_connection: asyncio.Future[
|
||||||
|
bumble.device.Connection
|
||||||
|
] = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
if request.connectable:
|
||||||
|
|
||||||
|
def on_connection(connection: bumble.device.Connection) -> None:
|
||||||
|
if (
|
||||||
|
connection.transport == BT_LE_TRANSPORT
|
||||||
|
and connection.role == BT_PERIPHERAL_ROLE
|
||||||
|
):
|
||||||
|
pending_connection.set_result(connection)
|
||||||
|
|
||||||
|
self.device.on('connection', on_connection)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Advertise until RPC is canceled
|
||||||
|
while True:
|
||||||
|
if not advertising_set.enabled:
|
||||||
|
self.log.debug('Advertise (extended)')
|
||||||
|
await advertising_set.start()
|
||||||
|
|
||||||
|
if not request.connectable:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
connection = await pending_connection
|
||||||
|
pending_connection = asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
||||||
|
yield AdvertiseResponse(connection=Connection(cookie=cookie))
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self.log.debug('Stop Advertise (extended)')
|
||||||
|
await advertising_set.stop()
|
||||||
|
await advertising_set.remove()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def legacy_advertise(
|
||||||
|
self, request: AdvertiseRequest, context: grpc.ServicerContext
|
||||||
|
) -> AsyncGenerator[AdvertiseResponse, None]:
|
||||||
|
if advertising_interval := request.interval:
|
||||||
|
self.device.config.advertising_interval_min = int(advertising_interval)
|
||||||
|
self.device.config.advertising_interval_max = int(advertising_interval)
|
||||||
|
if interval_range := request.interval_range:
|
||||||
|
self.device.config.advertising_interval_max += int(interval_range)
|
||||||
if request.primary_phy:
|
if request.primary_phy:
|
||||||
raise NotImplementedError("TODO: add support for `request.primary_phy`")
|
raise NotImplementedError("TODO: add support for `request.primary_phy`")
|
||||||
if request.secondary_phy:
|
if request.secondary_phy:
|
||||||
@@ -356,14 +486,10 @@ class HostService(HostServicer):
|
|||||||
target_bytes = bytes(reversed(request.target))
|
target_bytes = bytes(reversed(request.target))
|
||||||
if request.target_variant() == "public":
|
if request.target_variant() == "public":
|
||||||
target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
|
target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
|
||||||
advertising_type = (
|
advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
|
||||||
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
|
|
||||||
) # FIXME: HIGH_DUTY ?
|
|
||||||
else:
|
else:
|
||||||
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
|
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
|
||||||
advertising_type = (
|
advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
|
||||||
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
|
|
||||||
) # FIXME: HIGH_DUTY ?
|
|
||||||
|
|
||||||
if request.connectable:
|
if request.connectable:
|
||||||
|
|
||||||
@@ -421,11 +547,16 @@ class HostService(HostServicer):
|
|||||||
self, request: ScanRequest, context: grpc.ServicerContext
|
self, request: ScanRequest, context: grpc.ServicerContext
|
||||||
) -> AsyncGenerator[ScanningResponse, None]:
|
) -> AsyncGenerator[ScanningResponse, None]:
|
||||||
# TODO: modify `start_scanning` to accept floats instead of int for ms values
|
# TODO: modify `start_scanning` to accept floats instead of int for ms values
|
||||||
if request.phys:
|
|
||||||
raise NotImplementedError("TODO: add support for `request.phys`")
|
|
||||||
|
|
||||||
self.log.debug('Scan')
|
self.log.debug('Scan')
|
||||||
|
|
||||||
|
scanning_phys = []
|
||||||
|
if PRIMARY_1M in request.phys:
|
||||||
|
scanning_phys.append(int(Phy.LE_1M))
|
||||||
|
if PRIMARY_CODED in request.phys:
|
||||||
|
scanning_phys.append(int(Phy.LE_CODED))
|
||||||
|
if not scanning_phys:
|
||||||
|
scanning_phys = [int(Phy.LE_1M), int(Phy.LE_CODED)]
|
||||||
|
|
||||||
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
|
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
|
||||||
handler = self.device.on('advertisement', scan_queue.put_nowait)
|
handler = self.device.on('advertisement', scan_queue.put_nowait)
|
||||||
await self.device.start_scanning(
|
await self.device.start_scanning(
|
||||||
@@ -438,6 +569,7 @@ class HostService(HostServicer):
|
|||||||
scan_window=int(request.window)
|
scan_window=int(request.window)
|
||||||
if request.window
|
if request.window
|
||||||
else DEVICE_DEFAULT_SCAN_WINDOW,
|
else DEVICE_DEFAULT_SCAN_WINDOW,
|
||||||
|
scanning_phys=scanning_phys,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -734,6 +866,16 @@ class HostService(HostServicer):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
flag_map = {
|
||||||
|
NOT_DISCOVERABLE: 0x00,
|
||||||
|
DISCOVERABLE_LIMITED: AdvertisingData.LE_LIMITED_DISCOVERABLE_MODE_FLAG,
|
||||||
|
DISCOVERABLE_GENERAL: AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dt.le_discoverability_mode:
|
||||||
|
flags = flag_map[dt.le_discoverability_mode]
|
||||||
|
ad_structures.append((AdvertisingData.FLAGS, flags.to_bytes(1, 'big')))
|
||||||
|
|
||||||
return AdvertisingData(ad_structures)
|
return AdvertisingData(ad_structures)
|
||||||
|
|
||||||
def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
|
def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty()))
|
event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty()))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
||||||
return answer.confirm
|
return answer.confirm
|
||||||
@@ -125,7 +125,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(numeric_comparison=number))
|
event = self.add_origin(PairingEvent(numeric_comparison=number))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
||||||
return answer.confirm
|
return answer.confirm
|
||||||
@@ -140,7 +140,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty()))
|
event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty()))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
if answer.answer_variant() is None:
|
if answer.answer_variant() is None:
|
||||||
return None
|
return None
|
||||||
@@ -157,7 +157,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
|
|
||||||
event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty()))
|
event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty()))
|
||||||
self.service.event_queue.put_nowait(event)
|
self.service.event_queue.put_nowait(event)
|
||||||
answer = await anext(self.service.event_answer) # pytype: disable=name-error
|
answer = await anext(self.service.event_answer) # type: ignore
|
||||||
assert answer.event == event
|
assert answer.event == event
|
||||||
if answer.answer_variant() is None:
|
if answer.answer_variant() is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import struct
|
import struct
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from bumble import l2cap
|
from bumble import l2cap
|
||||||
from ..core import AdvertisingData
|
from ..core import AdvertisingData
|
||||||
@@ -67,7 +67,7 @@ class AshaService(TemplateService):
|
|||||||
self.emit('volume', connection, value[0])
|
self.emit('volume', connection, value[0])
|
||||||
|
|
||||||
# Handler for audio control commands
|
# 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()}')
|
logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}')
|
||||||
opcode = value[0]
|
opcode = value[0]
|
||||||
if opcode == AshaService.OPCODE_START:
|
if opcode == AshaService.OPCODE_START:
|
||||||
|
|||||||
1247
bumble/profiles/bap.py
Normal file
1247
bumble/profiles/bap.py
Normal file
File diff suppressed because it is too large
Load Diff
52
bumble/profiles/cap.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
|
||||||
@@ -19,8 +19,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import enum
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
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
|
||||||
from bumble import gatt_client
|
from bumble import gatt_client
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@ from bumble import gatt_client
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
SET_IDENTITY_RESOLVING_KEY_LENGTH = 16
|
||||||
|
|
||||||
|
|
||||||
class SirkType(enum.IntEnum):
|
class SirkType(enum.IntEnum):
|
||||||
'''Coordinated Set Identification Service - 5.1 Set Identity Resolving Key.'''
|
'''Coordinated Set Identification Service - 5.1 Set Identity Resolving Key.'''
|
||||||
|
|
||||||
@@ -43,9 +49,47 @@ class MemberLock(enum.IntEnum):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Utils
|
# Crypto Toolbox
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# TODO: Implement RSI Generator
|
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
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -54,6 +98,7 @@ class MemberLock(enum.IntEnum):
|
|||||||
class CoordinatedSetIdentificationService(gatt.TemplateService):
|
class CoordinatedSetIdentificationService(gatt.TemplateService):
|
||||||
UUID = gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE
|
UUID = gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE
|
||||||
|
|
||||||
|
set_identity_resolving_key: bytes
|
||||||
set_identity_resolving_key_characteristic: gatt.Characteristic
|
set_identity_resolving_key_characteristic: gatt.Characteristic
|
||||||
coordinated_set_size_characteristic: Optional[gatt.Characteristic] = None
|
coordinated_set_size_characteristic: Optional[gatt.Characteristic] = None
|
||||||
set_member_lock_characteristic: Optional[gatt.Characteristic] = None
|
set_member_lock_characteristic: Optional[gatt.Characteristic] = None
|
||||||
@@ -62,19 +107,26 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
set_identity_resolving_key: bytes,
|
set_identity_resolving_key: bytes,
|
||||||
|
set_identity_resolving_key_type: SirkType,
|
||||||
coordinated_set_size: Optional[int] = None,
|
coordinated_set_size: Optional[int] = None,
|
||||||
set_member_lock: Optional[MemberLock] = None,
|
set_member_lock: Optional[MemberLock] = None,
|
||||||
set_member_rank: Optional[int] = None,
|
set_member_rank: Optional[int] = None,
|
||||||
) -> 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 = []
|
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(
|
self.set_identity_resolving_key_characteristic = gatt.Characteristic(
|
||||||
uuid=gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC,
|
uuid=gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC,
|
||||||
properties=gatt.Characteristic.Properties.READ
|
properties=gatt.Characteristic.Properties.READ
|
||||||
| gatt.Characteristic.Properties.NOTIFY,
|
| gatt.Characteristic.Properties.NOTIFY,
|
||||||
permissions=gatt.Characteristic.Permissions.READABLE,
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
# TODO: Implement encrypted SIRK reader.
|
value=gatt.CharacteristicValue(read=self.on_sirk_read),
|
||||||
value=struct.pack('B', SirkType.PLAINTEXT) + set_identity_resolving_key,
|
|
||||||
)
|
)
|
||||||
characteristics.append(self.set_identity_resolving_key_characteristic)
|
characteristics.append(self.set_identity_resolving_key_characteristic)
|
||||||
|
|
||||||
@@ -83,7 +135,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
uuid=gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC,
|
uuid=gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC,
|
||||||
properties=gatt.Characteristic.Properties.READ
|
properties=gatt.Characteristic.Properties.READ
|
||||||
| gatt.Characteristic.Properties.NOTIFY,
|
| gatt.Characteristic.Properties.NOTIFY,
|
||||||
permissions=gatt.Characteristic.Permissions.READABLE,
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
value=struct.pack('B', coordinated_set_size),
|
value=struct.pack('B', coordinated_set_size),
|
||||||
)
|
)
|
||||||
characteristics.append(self.coordinated_set_size_characteristic)
|
characteristics.append(self.coordinated_set_size_characteristic)
|
||||||
@@ -94,7 +146,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
properties=gatt.Characteristic.Properties.READ
|
properties=gatt.Characteristic.Properties.READ
|
||||||
| gatt.Characteristic.Properties.NOTIFY
|
| gatt.Characteristic.Properties.NOTIFY
|
||||||
| gatt.Characteristic.Properties.WRITE,
|
| gatt.Characteristic.Properties.WRITE,
|
||||||
permissions=gatt.Characteristic.Permissions.READABLE
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
|
||||||
| gatt.Characteristic.Permissions.WRITEABLE,
|
| gatt.Characteristic.Permissions.WRITEABLE,
|
||||||
value=struct.pack('B', set_member_lock),
|
value=struct.pack('B', set_member_lock),
|
||||||
)
|
)
|
||||||
@@ -105,13 +157,45 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
uuid=gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC,
|
uuid=gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC,
|
||||||
properties=gatt.Characteristic.Properties.READ
|
properties=gatt.Characteristic.Properties.READ
|
||||||
| gatt.Characteristic.Properties.NOTIFY,
|
| gatt.Characteristic.Properties.NOTIFY,
|
||||||
permissions=gatt.Characteristic.Permissions.READABLE,
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
value=struct.pack('B', set_member_rank),
|
value=struct.pack('B', set_member_rank),
|
||||||
)
|
)
|
||||||
characteristics.append(self.set_member_rank_characteristic)
|
characteristics.append(self.set_member_rank_characteristic)
|
||||||
|
|
||||||
super().__init__(characteristics)
|
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
|
# Client
|
||||||
@@ -145,3 +229,29 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
|||||||
gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC
|
gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC
|
||||||
):
|
):
|
||||||
self.set_member_rank = characteristics[0]
|
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)
|
||||||
|
|||||||
228
bumble/profiles/vcp.py
Normal file
228
bumble/profiles/vcp.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Copyright 2021-2024 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from bumble import att
|
||||||
|
from bumble import device
|
||||||
|
from bumble import gatt
|
||||||
|
from bumble import gatt_client
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MIN_VOLUME = 0
|
||||||
|
MAX_VOLUME = 255
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCode(enum.IntEnum):
|
||||||
|
'''
|
||||||
|
See Volume Control Service 1.6. Application error codes.
|
||||||
|
'''
|
||||||
|
|
||||||
|
INVALID_CHANGE_COUNTER = 0x80
|
||||||
|
OPCODE_NOT_SUPPORTED = 0x81
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeFlags(enum.IntFlag):
|
||||||
|
'''
|
||||||
|
See Volume Control Service 3.3. Volume Flags.
|
||||||
|
'''
|
||||||
|
|
||||||
|
VOLUME_SETTING_PERSISTED = 0x01
|
||||||
|
# RFU
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeControlPointOpcode(enum.IntEnum):
|
||||||
|
'''
|
||||||
|
See Volume Control Service Table 3.3: Volume Control Point procedure requirements.
|
||||||
|
'''
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
RELATIVE_VOLUME_DOWN = 0x00
|
||||||
|
RELATIVE_VOLUME_UP = 0x01
|
||||||
|
UNMUTE_RELATIVE_VOLUME_DOWN = 0x02
|
||||||
|
UNMUTE_RELATIVE_VOLUME_UP = 0x03
|
||||||
|
SET_ABSOLUTE_VOLUME = 0x04
|
||||||
|
UNMUTE = 0x05
|
||||||
|
MUTE = 0x06
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Server
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class VolumeControlService(gatt.TemplateService):
|
||||||
|
UUID = gatt.GATT_VOLUME_CONTROL_SERVICE
|
||||||
|
|
||||||
|
volume_state: gatt.Characteristic
|
||||||
|
volume_control_point: gatt.Characteristic
|
||||||
|
volume_flags: gatt.Characteristic
|
||||||
|
|
||||||
|
volume_setting: int
|
||||||
|
muted: int
|
||||||
|
change_counter: int
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
step_size: int = 16,
|
||||||
|
volume_setting: int = 0,
|
||||||
|
muted: int = 0,
|
||||||
|
change_counter: int = 0,
|
||||||
|
volume_flags: int = 0,
|
||||||
|
) -> None:
|
||||||
|
self.step_size = step_size
|
||||||
|
self.volume_setting = volume_setting
|
||||||
|
self.muted = muted
|
||||||
|
self.change_counter = change_counter
|
||||||
|
|
||||||
|
self.volume_state = gatt.Characteristic(
|
||||||
|
uuid=gatt.GATT_VOLUME_STATE_CHARACTERISTIC,
|
||||||
|
properties=(
|
||||||
|
gatt.Characteristic.Properties.READ
|
||||||
|
| gatt.Characteristic.Properties.NOTIFY
|
||||||
|
),
|
||||||
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
|
value=gatt.CharacteristicValue(read=self._on_read_volume_state),
|
||||||
|
)
|
||||||
|
self.volume_control_point = gatt.Characteristic(
|
||||||
|
uuid=gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC,
|
||||||
|
properties=gatt.Characteristic.Properties.WRITE,
|
||||||
|
permissions=gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||||
|
value=gatt.CharacteristicValue(write=self._on_write_volume_control_point),
|
||||||
|
)
|
||||||
|
self.volume_flags = gatt.Characteristic(
|
||||||
|
uuid=gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC,
|
||||||
|
properties=gatt.Characteristic.Properties.READ,
|
||||||
|
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||||
|
value=bytes([volume_flags]),
|
||||||
|
)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
[
|
||||||
|
self.volume_state,
|
||||||
|
self.volume_control_point,
|
||||||
|
self.volume_flags,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_state_bytes(self) -> bytes:
|
||||||
|
return bytes([self.volume_setting, self.muted, self.change_counter])
|
||||||
|
|
||||||
|
@volume_state_bytes.setter
|
||||||
|
def volume_state_bytes(self, new_value: bytes) -> None:
|
||||||
|
self.volume_setting, self.muted, self.change_counter = new_value
|
||||||
|
|
||||||
|
def _on_read_volume_state(self, _connection: Optional[device.Connection]) -> bytes:
|
||||||
|
return self.volume_state_bytes
|
||||||
|
|
||||||
|
def _on_write_volume_control_point(
|
||||||
|
self, connection: Optional[device.Connection], value: bytes
|
||||||
|
) -> None:
|
||||||
|
assert connection
|
||||||
|
|
||||||
|
opcode = VolumeControlPointOpcode(value[0])
|
||||||
|
change_counter = value[1]
|
||||||
|
|
||||||
|
if change_counter != self.change_counter:
|
||||||
|
raise att.ATT_Error(ErrorCode.INVALID_CHANGE_COUNTER)
|
||||||
|
|
||||||
|
handler = getattr(self, '_on_' + opcode.name.lower())
|
||||||
|
if handler(*value[2:]):
|
||||||
|
self.change_counter = (self.change_counter + 1) % 256
|
||||||
|
connection.abort_on(
|
||||||
|
'disconnection',
|
||||||
|
connection.device.notify_subscribers(
|
||||||
|
attribute=self.volume_state,
|
||||||
|
value=self.volume_state_bytes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.emit(
|
||||||
|
'volume_state', self.volume_setting, self.muted, self.change_counter
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_relative_volume_down(self) -> bool:
|
||||||
|
old_volume = self.volume_setting
|
||||||
|
self.volume_setting = max(self.volume_setting - self.step_size, MIN_VOLUME)
|
||||||
|
return self.volume_setting != old_volume
|
||||||
|
|
||||||
|
def _on_relative_volume_up(self) -> bool:
|
||||||
|
old_volume = self.volume_setting
|
||||||
|
self.volume_setting = min(self.volume_setting + self.step_size, MAX_VOLUME)
|
||||||
|
return self.volume_setting != old_volume
|
||||||
|
|
||||||
|
def _on_unmute_relative_volume_down(self) -> bool:
|
||||||
|
old_volume, old_muted_state = self.volume_setting, self.muted
|
||||||
|
self.volume_setting = max(self.volume_setting - self.step_size, MIN_VOLUME)
|
||||||
|
self.muted = 0
|
||||||
|
return (self.volume_setting, self.muted) != (old_volume, old_muted_state)
|
||||||
|
|
||||||
|
def _on_unmute_relative_volume_up(self) -> bool:
|
||||||
|
old_volume, old_muted_state = self.volume_setting, self.muted
|
||||||
|
self.volume_setting = min(self.volume_setting + self.step_size, MAX_VOLUME)
|
||||||
|
self.muted = 0
|
||||||
|
return (self.volume_setting, self.muted) != (old_volume, old_muted_state)
|
||||||
|
|
||||||
|
def _on_set_absolute_volume(self, volume_setting: int) -> bool:
|
||||||
|
old_volume_setting = self.volume_setting
|
||||||
|
self.volume_setting = volume_setting
|
||||||
|
return old_volume_setting != self.volume_setting
|
||||||
|
|
||||||
|
def _on_unmute(self) -> bool:
|
||||||
|
old_muted_state = self.muted
|
||||||
|
self.muted = 0
|
||||||
|
return self.muted != old_muted_state
|
||||||
|
|
||||||
|
def _on_mute(self) -> bool:
|
||||||
|
old_muted_state = self.muted
|
||||||
|
self.muted = 1
|
||||||
|
return self.muted != old_muted_state
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Client
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy):
|
||||||
|
SERVICE_CLASS = VolumeControlService
|
||||||
|
|
||||||
|
volume_control_point: gatt_client.CharacteristicProxy
|
||||||
|
|
||||||
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||||
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
|
self.volume_state = gatt.PackedCharacteristicAdapter(
|
||||||
|
service_proxy.get_characteristics_by_uuid(
|
||||||
|
gatt.GATT_VOLUME_STATE_CHARACTERISTIC
|
||||||
|
)[0],
|
||||||
|
'BBB',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.volume_control_point = service_proxy.get_characteristics_by_uuid(
|
||||||
|
gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.volume_flags = gatt.PackedCharacteristicAdapter(
|
||||||
|
service_proxy.get_characteristics_by_uuid(
|
||||||
|
gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC
|
||||||
|
)[0],
|
||||||
|
'B',
|
||||||
|
)
|
||||||
334
bumble/rfcomm.py
334
bumble/rfcomm.py
@@ -19,12 +19,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from . import core, l2cap
|
from bumble import core
|
||||||
|
from bumble import l2cap
|
||||||
|
from bumble import sdp
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import (
|
from .core import (
|
||||||
UUID,
|
UUID,
|
||||||
@@ -34,15 +38,6 @@ from .core import (
|
|||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
)
|
)
|
||||||
from .sdp import (
|
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
|
||||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_PUBLIC_BROWSE_ROOT,
|
|
||||||
DataElement,
|
|
||||||
ServiceAttribute,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.device import Device, Connection
|
from bumble.device import Device, Connection
|
||||||
@@ -60,27 +55,18 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
RFCOMM_PSM = 0x0003
|
RFCOMM_PSM = 0x0003
|
||||||
|
|
||||||
|
class FrameType(enum.IntEnum):
|
||||||
|
SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
|
||||||
|
UA = 0x63 # Control field [0,1,1,0,_,0,1,1] LSB-first
|
||||||
|
DM = 0x0F # Control field [1,1,1,1,_,0,0,0] LSB-first
|
||||||
|
DISC = 0x43 # Control field [0,1,0,_,0,0,1,1] LSB-first
|
||||||
|
UIH = 0xEF # Control field [1,1,1,_,1,1,1,1] LSB-first
|
||||||
|
UI = 0x03 # Control field [0,0,0,_,0,0,1,1] LSB-first
|
||||||
|
|
||||||
# Frame types
|
class MccType(enum.IntEnum):
|
||||||
RFCOMM_SABM_FRAME = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
|
PN = 0x20
|
||||||
RFCOMM_UA_FRAME = 0x63 # Control field [0,1,1,0,_,0,1,1] LSB-first
|
MSC = 0x38
|
||||||
RFCOMM_DM_FRAME = 0x0F # Control field [1,1,1,1,_,0,0,0] LSB-first
|
|
||||||
RFCOMM_DISC_FRAME = 0x43 # Control field [0,1,0,_,0,0,1,1] LSB-first
|
|
||||||
RFCOMM_UIH_FRAME = 0xEF # Control field [1,1,1,_,1,1,1,1] LSB-first
|
|
||||||
RFCOMM_UI_FRAME = 0x03 # Control field [0,0,0,_,0,0,1,1] LSB-first
|
|
||||||
|
|
||||||
RFCOMM_FRAME_TYPE_NAMES = {
|
|
||||||
RFCOMM_SABM_FRAME: 'SABM',
|
|
||||||
RFCOMM_UA_FRAME: 'UA',
|
|
||||||
RFCOMM_DM_FRAME: 'DM',
|
|
||||||
RFCOMM_DISC_FRAME: 'DISC',
|
|
||||||
RFCOMM_UIH_FRAME: 'UIH',
|
|
||||||
RFCOMM_UI_FRAME: 'UI'
|
|
||||||
}
|
|
||||||
|
|
||||||
# MCC Types
|
|
||||||
RFCOMM_MCC_PN_TYPE = 0x20
|
|
||||||
RFCOMM_MCC_MSC_TYPE = 0x38
|
|
||||||
|
|
||||||
# FCS CRC
|
# FCS CRC
|
||||||
CRC_TABLE = bytes([
|
CRC_TABLE = bytes([
|
||||||
@@ -118,8 +104,9 @@ CRC_TABLE = bytes([
|
|||||||
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
|
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
|
||||||
])
|
])
|
||||||
|
|
||||||
RFCOMM_DEFAULT_INITIAL_RX_CREDITS = 7
|
RFCOMM_DEFAULT_L2CAP_MTU = 2048
|
||||||
RFCOMM_DEFAULT_PREFERRED_MTU = 1280
|
RFCOMM_DEFAULT_WINDOW_SIZE = 7
|
||||||
|
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
|
||||||
|
|
||||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
|
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
|
||||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
||||||
@@ -130,29 +117,33 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def make_service_sdp_records(
|
def make_service_sdp_records(
|
||||||
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
|
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
|
||||||
) -> List[ServiceAttribute]:
|
) -> List[sdp.ServiceAttribute]:
|
||||||
"""
|
"""
|
||||||
Create SDP records for an RFComm service given a channel number and an
|
Create SDP records for an RFComm service given a channel number and an
|
||||||
optional UUID. A Service Class Attribute is included only if the UUID is not None.
|
optional UUID. A Service Class Attribute is included only if the UUID is not None.
|
||||||
"""
|
"""
|
||||||
records = [
|
records = [
|
||||||
ServiceAttribute(
|
sdp.ServiceAttribute(
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
sdp.SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
DataElement.unsigned_integer_32(service_record_handle),
|
sdp.DataElement.unsigned_integer_32(service_record_handle),
|
||||||
),
|
),
|
||||||
ServiceAttribute(
|
sdp.ServiceAttribute(
|
||||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
sdp.SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
|
sdp.DataElement.sequence(
|
||||||
|
[sdp.DataElement.uuid(sdp.SDP_PUBLIC_BROWSE_ROOT)]
|
||||||
),
|
),
|
||||||
ServiceAttribute(
|
),
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
sdp.ServiceAttribute(
|
||||||
DataElement.sequence(
|
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
sdp.DataElement.sequence(
|
||||||
[
|
[
|
||||||
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
sdp.DataElement.sequence(
|
||||||
DataElement.sequence(
|
[sdp.DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]
|
||||||
|
),
|
||||||
|
sdp.DataElement.sequence(
|
||||||
[
|
[
|
||||||
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
sdp.DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||||
DataElement.unsigned_integer_8(channel),
|
sdp.DataElement.unsigned_integer_8(channel),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -162,15 +153,81 @@ def make_service_sdp_records(
|
|||||||
|
|
||||||
if uuid:
|
if uuid:
|
||||||
records.append(
|
records.append(
|
||||||
ServiceAttribute(
|
sdp.ServiceAttribute(
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
DataElement.sequence([DataElement.uuid(uuid)]),
|
sdp.DataElement.sequence([sdp.DataElement.uuid(uuid)]),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def find_rfcomm_channels(connection: Connection) -> Dict[int, List[UUID]]:
|
||||||
|
"""Searches all RFCOMM channels and their associated UUID from SDP service records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connection: ACL connection to make SDP search.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping from channel number to service class UUID list.
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
async with sdp.Client(connection) as sdp_client:
|
||||||
|
search_result = await sdp_client.search_attributes(
|
||||||
|
uuids=[core.BT_RFCOMM_PROTOCOL_ID],
|
||||||
|
attribute_ids=[
|
||||||
|
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for attribute_lists in search_result:
|
||||||
|
service_classes: List[UUID] = []
|
||||||
|
channel: Optional[int] = None
|
||||||
|
for attribute in attribute_lists:
|
||||||
|
# The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
|
||||||
|
if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
||||||
|
protocol_descriptor_list = attribute.value.value
|
||||||
|
channel = protocol_descriptor_list[1].value[1].value
|
||||||
|
elif attribute.id == sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
|
||||||
|
service_class_id_list = attribute.value.value
|
||||||
|
service_classes = [
|
||||||
|
service_class.value for service_class in service_class_id_list
|
||||||
|
]
|
||||||
|
if not service_classes or not channel:
|
||||||
|
logger.warning(f"Bad result {attribute_lists}.")
|
||||||
|
else:
|
||||||
|
results[channel] = service_classes
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def find_rfcomm_channel_with_uuid(
|
||||||
|
connection: Connection, uuid: str | UUID
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Searches an RFCOMM channel associated with given UUID from service records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connection: ACL connection to make SDP search.
|
||||||
|
uuid: UUID of service record to search for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RFCOMM channel number if found, otherwise None.
|
||||||
|
"""
|
||||||
|
if isinstance(uuid, str):
|
||||||
|
uuid = UUID(uuid)
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
channel
|
||||||
|
for channel, class_id_list in (
|
||||||
|
await find_rfcomm_channels(connection)
|
||||||
|
).items()
|
||||||
|
if uuid in class_id_list
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def compute_fcs(buffer: bytes) -> int:
|
def compute_fcs(buffer: bytes) -> int:
|
||||||
result = 0xFF
|
result = 0xFF
|
||||||
@@ -183,7 +240,7 @@ def compute_fcs(buffer: bytes) -> int:
|
|||||||
class RFCOMM_Frame:
|
class RFCOMM_Frame:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
frame_type: int,
|
frame_type: FrameType,
|
||||||
c_r: int,
|
c_r: int,
|
||||||
dlci: int,
|
dlci: int,
|
||||||
p_f: int,
|
p_f: int,
|
||||||
@@ -206,14 +263,11 @@ class RFCOMM_Frame:
|
|||||||
self.length = bytes([(length << 1) | 1])
|
self.length = bytes([(length << 1) | 1])
|
||||||
self.address = (dlci << 2) | (c_r << 1) | 1
|
self.address = (dlci << 2) | (c_r << 1) | 1
|
||||||
self.control = frame_type | (p_f << 4)
|
self.control = frame_type | (p_f << 4)
|
||||||
if frame_type == RFCOMM_UIH_FRAME:
|
if frame_type == FrameType.UIH:
|
||||||
self.fcs = compute_fcs(bytes([self.address, self.control]))
|
self.fcs = compute_fcs(bytes([self.address, self.control]))
|
||||||
else:
|
else:
|
||||||
self.fcs = compute_fcs(bytes([self.address, self.control]) + self.length)
|
self.fcs = compute_fcs(bytes([self.address, self.control]) + self.length)
|
||||||
|
|
||||||
def type_name(self) -> str:
|
|
||||||
return RFCOMM_FRAME_TYPE_NAMES[self.type]
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_mcc(data) -> Tuple[int, bool, bytes]:
|
def parse_mcc(data) -> Tuple[int, bool, bytes]:
|
||||||
mcc_type = data[0] >> 2
|
mcc_type = data[0] >> 2
|
||||||
@@ -237,24 +291,24 @@ class RFCOMM_Frame:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sabm(c_r: int, dlci: int):
|
def sabm(c_r: int, dlci: int):
|
||||||
return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
|
return RFCOMM_Frame(FrameType.SABM, c_r, dlci, 1)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def ua(c_r: int, dlci: int):
|
def ua(c_r: int, dlci: int):
|
||||||
return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
|
return RFCOMM_Frame(FrameType.UA, c_r, dlci, 1)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def dm(c_r: int, dlci: int):
|
def dm(c_r: int, dlci: int):
|
||||||
return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
|
return RFCOMM_Frame(FrameType.DM, c_r, dlci, 1)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def disc(c_r: int, dlci: int):
|
def disc(c_r: int, dlci: int):
|
||||||
return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
|
return RFCOMM_Frame(FrameType.DISC, c_r, dlci, 1)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def uih(c_r: int, dlci: int, information: bytes, p_f: int = 0):
|
def uih(c_r: int, dlci: int, information: bytes, p_f: int = 0):
|
||||||
return RFCOMM_Frame(
|
return RFCOMM_Frame(
|
||||||
RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits=(p_f == 1)
|
FrameType.UIH, c_r, dlci, p_f, information, with_credits=(p_f == 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -262,7 +316,7 @@ class RFCOMM_Frame:
|
|||||||
# Extract fields
|
# Extract fields
|
||||||
dlci = (data[0] >> 2) & 0x3F
|
dlci = (data[0] >> 2) & 0x3F
|
||||||
c_r = (data[0] >> 1) & 0x01
|
c_r = (data[0] >> 1) & 0x01
|
||||||
frame_type = data[1] & 0xEF
|
frame_type = FrameType(data[1] & 0xEF)
|
||||||
p_f = (data[1] >> 4) & 0x01
|
p_f = (data[1] >> 4) & 0x01
|
||||||
length = data[2]
|
length = data[2]
|
||||||
if length & 0x01:
|
if length & 0x01:
|
||||||
@@ -291,7 +345,7 @@ class RFCOMM_Frame:
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'{color(self.type_name(), "yellow")}'
|
f'{color(self.type.name, "yellow")}'
|
||||||
f'(c/r={self.c_r},'
|
f'(c/r={self.c_r},'
|
||||||
f'dlci={self.dlci},'
|
f'dlci={self.dlci},'
|
||||||
f'p/f={self.p_f},'
|
f'p/f={self.p_f},'
|
||||||
@@ -301,6 +355,7 @@ class RFCOMM_Frame:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
class RFCOMM_MCC_PN:
|
class RFCOMM_MCC_PN:
|
||||||
dlci: int
|
dlci: int
|
||||||
cl: int
|
cl: int
|
||||||
@@ -310,23 +365,11 @@ class RFCOMM_MCC_PN:
|
|||||||
max_retransmissions: int
|
max_retransmissions: int
|
||||||
window_size: int
|
window_size: int
|
||||||
|
|
||||||
def __init__(
|
def __post_init__(self) -> None:
|
||||||
self,
|
if self.window_size < 1 or self.window_size > 7:
|
||||||
dlci: int,
|
logger.warning(
|
||||||
cl: int,
|
f'Error Recovery Window size {self.window_size} is out of range [1, 7].'
|
||||||
priority: int,
|
)
|
||||||
ack_timer: int,
|
|
||||||
max_frame_size: int,
|
|
||||||
max_retransmissions: int,
|
|
||||||
window_size: int,
|
|
||||||
) -> None:
|
|
||||||
self.dlci = dlci
|
|
||||||
self.cl = cl
|
|
||||||
self.priority = priority
|
|
||||||
self.ack_timer = ack_timer
|
|
||||||
self.max_frame_size = max_frame_size
|
|
||||||
self.max_retransmissions = max_retransmissions
|
|
||||||
self.window_size = window_size
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
|
def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
|
||||||
@@ -337,7 +380,7 @@ class RFCOMM_MCC_PN:
|
|||||||
ack_timer=data[3],
|
ack_timer=data[3],
|
||||||
max_frame_size=data[4] | data[5] << 8,
|
max_frame_size=data[4] | data[5] << 8,
|
||||||
max_retransmissions=data[6],
|
max_retransmissions=data[6],
|
||||||
window_size=data[7],
|
window_size=data[7] & 0x07,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
@@ -350,23 +393,14 @@ class RFCOMM_MCC_PN:
|
|||||||
self.max_frame_size & 0xFF,
|
self.max_frame_size & 0xFF,
|
||||||
(self.max_frame_size >> 8) & 0xFF,
|
(self.max_frame_size >> 8) & 0xFF,
|
||||||
self.max_retransmissions & 0xFF,
|
self.max_retransmissions & 0xFF,
|
||||||
self.window_size & 0xFF,
|
# Only 3 bits are meaningful.
|
||||||
|
self.window_size & 0x07,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return (
|
|
||||||
f'PN(dlci={self.dlci},'
|
|
||||||
f'cl={self.cl},'
|
|
||||||
f'priority={self.priority},'
|
|
||||||
f'ack_timer={self.ack_timer},'
|
|
||||||
f'max_frame_size={self.max_frame_size},'
|
|
||||||
f'max_retransmissions={self.max_retransmissions},'
|
|
||||||
f'window_size={self.window_size})'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
class RFCOMM_MCC_MSC:
|
class RFCOMM_MCC_MSC:
|
||||||
dlci: int
|
dlci: int
|
||||||
fc: int
|
fc: int
|
||||||
@@ -375,16 +409,6 @@ class RFCOMM_MCC_MSC:
|
|||||||
ic: int
|
ic: int
|
||||||
dv: int
|
dv: int
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int
|
|
||||||
) -> None:
|
|
||||||
self.dlci = dlci
|
|
||||||
self.fc = fc
|
|
||||||
self.rtc = rtc
|
|
||||||
self.rtr = rtr
|
|
||||||
self.ic = ic
|
|
||||||
self.dv = dv
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
|
def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
|
||||||
return RFCOMM_MCC_MSC(
|
return RFCOMM_MCC_MSC(
|
||||||
@@ -409,16 +433,6 @@ class RFCOMM_MCC_MSC:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return (
|
|
||||||
f'MSC(dlci={self.dlci},'
|
|
||||||
f'fc={self.fc},'
|
|
||||||
f'rtc={self.rtc},'
|
|
||||||
f'rtr={self.rtr},'
|
|
||||||
f'ic={self.ic},'
|
|
||||||
f'dv={self.dv})'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class DLC(EventEmitter):
|
class DLC(EventEmitter):
|
||||||
@@ -438,25 +452,29 @@ class DLC(EventEmitter):
|
|||||||
multiplexer: Multiplexer,
|
multiplexer: Multiplexer,
|
||||||
dlci: int,
|
dlci: int,
|
||||||
max_frame_size: int,
|
max_frame_size: int,
|
||||||
initial_tx_credits: int,
|
window_size: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.multiplexer = multiplexer
|
self.multiplexer = multiplexer
|
||||||
self.dlci = dlci
|
self.dlci = dlci
|
||||||
self.rx_credits = RFCOMM_DEFAULT_INITIAL_RX_CREDITS
|
self.max_frame_size = max_frame_size
|
||||||
self.rx_threshold = self.rx_credits // 2
|
self.window_size = window_size
|
||||||
self.tx_credits = initial_tx_credits
|
self.rx_credits = window_size
|
||||||
|
self.rx_threshold = window_size // 2
|
||||||
|
self.tx_credits = window_size
|
||||||
self.tx_buffer = b''
|
self.tx_buffer = b''
|
||||||
self.state = DLC.State.INIT
|
self.state = DLC.State.INIT
|
||||||
self.role = multiplexer.role
|
self.role = multiplexer.role
|
||||||
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
||||||
self.sink = None
|
self.sink = None
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
self.drained = asyncio.Event()
|
||||||
|
self.drained.set()
|
||||||
|
|
||||||
# Compute the MTU
|
# Compute the MTU
|
||||||
max_overhead = 4 + 1 # header with 2-byte length + fcs
|
max_overhead = 4 + 1 # header with 2-byte length + fcs
|
||||||
self.mtu = min(
|
self.mtu = min(
|
||||||
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
|
max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
|
||||||
)
|
)
|
||||||
|
|
||||||
def change_state(self, new_state: State) -> None:
|
def change_state(self, new_state: State) -> None:
|
||||||
@@ -467,7 +485,7 @@ class DLC(EventEmitter):
|
|||||||
self.multiplexer.send_frame(frame)
|
self.multiplexer.send_frame(frame)
|
||||||
|
|
||||||
def on_frame(self, frame: RFCOMM_Frame) -> None:
|
def on_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
handler = getattr(self, f'on_{frame.type.name}_frame'.lower())
|
||||||
handler(frame)
|
handler(frame)
|
||||||
|
|
||||||
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
@@ -481,9 +499,7 @@ class DLC(EventEmitter):
|
|||||||
|
|
||||||
# Exchange the modem status with the peer
|
# Exchange the modem status with the peer
|
||||||
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
||||||
mcc = RFCOMM_Frame.make_mcc(
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
|
||||||
mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=1, data=bytes(msc)
|
|
||||||
)
|
|
||||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
|
|
||||||
@@ -499,9 +515,7 @@ class DLC(EventEmitter):
|
|||||||
|
|
||||||
# Exchange the modem status with the peer
|
# Exchange the modem status with the peer
|
||||||
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
||||||
mcc = RFCOMM_Frame.make_mcc(
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
|
||||||
mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=1, data=bytes(msc)
|
|
||||||
)
|
|
||||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
|
|
||||||
@@ -534,7 +548,8 @@ class DLC(EventEmitter):
|
|||||||
f'[{self.dlci}] {len(data)} bytes, '
|
f'[{self.dlci}] {len(data)} bytes, '
|
||||||
f'rx_credits={self.rx_credits}: {data.hex()}'
|
f'rx_credits={self.rx_credits}: {data.hex()}'
|
||||||
)
|
)
|
||||||
if len(data) and self.sink:
|
if data:
|
||||||
|
if self.sink:
|
||||||
self.sink(data) # pylint: disable=not-callable
|
self.sink(data) # pylint: disable=not-callable
|
||||||
|
|
||||||
# Update the credits
|
# Update the credits
|
||||||
@@ -554,9 +569,7 @@ class DLC(EventEmitter):
|
|||||||
# Command
|
# Command
|
||||||
logger.debug(f'<<< MCC MSC Command: {msc}')
|
logger.debug(f'<<< MCC MSC Command: {msc}')
|
||||||
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
||||||
mcc = RFCOMM_Frame.make_mcc(
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=0, data=bytes(msc))
|
||||||
mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=0, data=bytes(msc)
|
|
||||||
)
|
|
||||||
logger.debug(f'>>> MCC MSC Response: {msc}')
|
logger.debug(f'>>> MCC MSC Response: {msc}')
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
else:
|
else:
|
||||||
@@ -580,18 +593,18 @@ class DLC(EventEmitter):
|
|||||||
cl=0xE0,
|
cl=0xE0,
|
||||||
priority=7,
|
priority=7,
|
||||||
ack_timer=0,
|
ack_timer=0,
|
||||||
max_frame_size=RFCOMM_DEFAULT_PREFERRED_MTU,
|
max_frame_size=self.max_frame_size,
|
||||||
max_retransmissions=0,
|
max_retransmissions=0,
|
||||||
window_size=RFCOMM_DEFAULT_INITIAL_RX_CREDITS,
|
window_size=self.window_size,
|
||||||
)
|
)
|
||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn))
|
||||||
logger.debug(f'>>> PN Response: {pn}')
|
logger.debug(f'>>> PN Response: {pn}')
|
||||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||||
self.change_state(DLC.State.CONNECTING)
|
self.change_state(DLC.State.CONNECTING)
|
||||||
|
|
||||||
def rx_credits_needed(self) -> int:
|
def rx_credits_needed(self) -> int:
|
||||||
if self.rx_credits <= self.rx_threshold:
|
if self.rx_credits <= self.rx_threshold:
|
||||||
return RFCOMM_DEFAULT_INITIAL_RX_CREDITS - self.rx_credits
|
return self.window_size - self.rx_credits
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -631,6 +644,8 @@ class DLC(EventEmitter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
rx_credits_needed = 0
|
rx_credits_needed = 0
|
||||||
|
if not self.tx_buffer:
|
||||||
|
self.drained.set()
|
||||||
|
|
||||||
# Stream protocol
|
# Stream protocol
|
||||||
def write(self, data: Union[bytes, str]) -> None:
|
def write(self, data: Union[bytes, str]) -> None:
|
||||||
@@ -643,11 +658,11 @@ class DLC(EventEmitter):
|
|||||||
raise ValueError('write only accept bytes or strings')
|
raise ValueError('write only accept bytes or strings')
|
||||||
|
|
||||||
self.tx_buffer += data
|
self.tx_buffer += data
|
||||||
|
self.drained.clear()
|
||||||
self.process_tx()
|
self.process_tx()
|
||||||
|
|
||||||
def drain(self) -> None:
|
async def drain(self) -> None:
|
||||||
# TODO
|
await self.drained.wait()
|
||||||
pass
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'DLC(dlci={self.dlci},state={self.state.name})'
|
return f'DLC(dlci={self.dlci},state={self.state.name})'
|
||||||
@@ -704,7 +719,7 @@ class Multiplexer(EventEmitter):
|
|||||||
if frame.dlci == 0:
|
if frame.dlci == 0:
|
||||||
self.on_frame(frame)
|
self.on_frame(frame)
|
||||||
else:
|
else:
|
||||||
if frame.type == RFCOMM_DM_FRAME:
|
if frame.type == FrameType.DM:
|
||||||
# DM responses are for a DLCI, but since we only create the dlc when we
|
# DM responses are for a DLCI, but since we only create the dlc when we
|
||||||
# receive a PN response (because we need the parameters), we handle DM
|
# receive a PN response (because we need the parameters), we handle DM
|
||||||
# frames at the Multiplexer level
|
# frames at the Multiplexer level
|
||||||
@@ -717,7 +732,7 @@ class Multiplexer(EventEmitter):
|
|||||||
dlc.on_frame(frame)
|
dlc.on_frame(frame)
|
||||||
|
|
||||||
def on_frame(self, frame: RFCOMM_Frame) -> None:
|
def on_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
handler = getattr(self, f'on_{frame.type.name}_frame'.lower())
|
||||||
handler(frame)
|
handler(frame)
|
||||||
|
|
||||||
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||||
@@ -765,10 +780,10 @@ class Multiplexer(EventEmitter):
|
|||||||
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
|
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
(mcc_type, c_r, value) = RFCOMM_Frame.parse_mcc(frame.information)
|
(mcc_type, c_r, value) = RFCOMM_Frame.parse_mcc(frame.information)
|
||||||
|
|
||||||
if mcc_type == RFCOMM_MCC_PN_TYPE:
|
if mcc_type == MccType.PN:
|
||||||
pn = RFCOMM_MCC_PN.from_bytes(value)
|
pn = RFCOMM_MCC_PN.from_bytes(value)
|
||||||
self.on_mcc_pn(c_r, pn)
|
self.on_mcc_pn(c_r, pn)
|
||||||
elif mcc_type == RFCOMM_MCC_MSC_TYPE:
|
elif mcc_type == MccType.MSC:
|
||||||
mcs = RFCOMM_MCC_MSC.from_bytes(value)
|
mcs = RFCOMM_MCC_MSC.from_bytes(value)
|
||||||
self.on_mcc_msc(c_r, mcs)
|
self.on_mcc_msc(c_r, mcs)
|
||||||
|
|
||||||
@@ -843,7 +858,12 @@ class Multiplexer(EventEmitter):
|
|||||||
)
|
)
|
||||||
await self.disconnection_result
|
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.CONNECTED:
|
||||||
if self.state == Multiplexer.State.OPENING:
|
if self.state == Multiplexer.State.OPENING:
|
||||||
raise InvalidStateError('open already in progress')
|
raise InvalidStateError('open already in progress')
|
||||||
@@ -855,11 +875,11 @@ class Multiplexer(EventEmitter):
|
|||||||
cl=0xF0,
|
cl=0xF0,
|
||||||
priority=7,
|
priority=7,
|
||||||
ack_timer=0,
|
ack_timer=0,
|
||||||
max_frame_size=RFCOMM_DEFAULT_PREFERRED_MTU,
|
max_frame_size=max_frame_size,
|
||||||
max_retransmissions=0,
|
max_retransmissions=0,
|
||||||
window_size=RFCOMM_DEFAULT_INITIAL_RX_CREDITS,
|
window_size=window_size,
|
||||||
)
|
)
|
||||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
|
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=1, data=bytes(pn))
|
||||||
logger.debug(f'>>> Sending MCC: {pn}')
|
logger.debug(f'>>> Sending MCC: {pn}')
|
||||||
self.open_result = asyncio.get_running_loop().create_future()
|
self.open_result = asyncio.get_running_loop().create_future()
|
||||||
self.change_state(Multiplexer.State.OPENING)
|
self.change_state(Multiplexer.State.OPENING)
|
||||||
@@ -889,8 +909,11 @@ class Client:
|
|||||||
multiplexer: Optional[Multiplexer]
|
multiplexer: Optional[Multiplexer]
|
||||||
l2cap_channel: Optional[l2cap.ClassicChannel]
|
l2cap_channel: Optional[l2cap.ClassicChannel]
|
||||||
|
|
||||||
def __init__(self, connection: Connection) -> None:
|
def __init__(
|
||||||
|
self, connection: Connection, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
|
||||||
|
) -> None:
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
self.l2cap_mtu = l2cap_mtu
|
||||||
self.l2cap_channel = None
|
self.l2cap_channel = None
|
||||||
self.multiplexer = None
|
self.multiplexer = None
|
||||||
|
|
||||||
@@ -898,7 +921,7 @@ class Client:
|
|||||||
# Create a new L2CAP connection
|
# Create a new L2CAP connection
|
||||||
try:
|
try:
|
||||||
self.l2cap_channel = await self.connection.create_l2cap_channel(
|
self.l2cap_channel = await self.connection.create_l2cap_channel(
|
||||||
spec=l2cap.ClassicChannelSpec(RFCOMM_PSM)
|
spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM, mtu=self.l2cap_mtu)
|
||||||
)
|
)
|
||||||
except ProtocolError as error:
|
except ProtocolError as error:
|
||||||
logger.warning(f'L2CAP connection failed: {error}')
|
logger.warning(f'L2CAP connection failed: {error}')
|
||||||
@@ -921,22 +944,33 @@ class Client:
|
|||||||
self.multiplexer = None
|
self.multiplexer = None
|
||||||
|
|
||||||
# Close the L2CAP channel
|
# Close the L2CAP channel
|
||||||
# TODO
|
if self.l2cap_channel:
|
||||||
|
await self.l2cap_channel.disconnect()
|
||||||
|
self.l2cap_channel = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> Multiplexer:
|
||||||
|
return await self.start()
|
||||||
|
|
||||||
|
async def __aexit__(self, *args) -> None:
|
||||||
|
await self.shutdown()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server(EventEmitter):
|
class Server(EventEmitter):
|
||||||
acceptors: Dict[int, Callable[[DLC], None]]
|
acceptors: Dict[int, Callable[[DLC], None]]
|
||||||
|
|
||||||
def __init__(self, device: Device) -> None:
|
def __init__(
|
||||||
|
self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.multiplexer = None
|
self.multiplexer = None
|
||||||
self.acceptors = {}
|
self.acceptors = {}
|
||||||
|
|
||||||
# Register ourselves with the L2CAP channel manager
|
# Register ourselves with the L2CAP channel manager
|
||||||
device.create_l2cap_server(
|
self.l2cap_server = device.create_l2cap_server(
|
||||||
spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM), handler=self.on_connection
|
spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM, mtu=l2cap_mtu),
|
||||||
|
handler=self.on_connection,
|
||||||
)
|
)
|
||||||
|
|
||||||
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
|
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
|
||||||
@@ -986,3 +1020,9 @@ class Server(EventEmitter):
|
|||||||
acceptor = self.acceptors.get(dlc.dlci >> 1)
|
acceptor = self.acceptors.get(dlc.dlci >> 1)
|
||||||
if acceptor:
|
if acceptor:
|
||||||
acceptor(dlc)
|
acceptor(dlc)
|
||||||
|
|
||||||
|
def __enter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args) -> None:
|
||||||
|
self.l2cap_server.close()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
|
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from . import core, l2cap
|
from . import core, l2cap
|
||||||
from .colors import color
|
from .colors import color
|
||||||
@@ -97,7 +98,8 @@ SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B
|
|||||||
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
||||||
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
||||||
|
|
||||||
# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
|
|
||||||
|
# Profile-specific Attribute Identifiers (cf. Assigned Numbers for Service Discovery)
|
||||||
# used by AVRCP, HFP and A2DP
|
# used by AVRCP, HFP and A2DP
|
||||||
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
|
||||||
|
|
||||||
@@ -115,7 +117,8 @@ SDP_ATTRIBUTE_ID_NAMES = {
|
|||||||
SDP_DOCUMENTATION_URL_ATTRIBUTE_ID: 'SDP_DOCUMENTATION_URL_ATTRIBUTE_ID',
|
SDP_DOCUMENTATION_URL_ATTRIBUTE_ID: 'SDP_DOCUMENTATION_URL_ATTRIBUTE_ID',
|
||||||
SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID: 'SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID',
|
SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID: 'SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID',
|
||||||
SDP_ICON_URL_ATTRIBUTE_ID: 'SDP_ICON_URL_ATTRIBUTE_ID',
|
SDP_ICON_URL_ATTRIBUTE_ID: 'SDP_ICON_URL_ATTRIBUTE_ID',
|
||||||
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID: 'SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID'
|
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID: 'SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID',
|
||||||
|
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID: 'SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID',
|
||||||
}
|
}
|
||||||
|
|
||||||
SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
|
SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
|
||||||
@@ -918,6 +921,13 @@ class Client:
|
|||||||
|
|
||||||
return ServiceAttribute.list_from_data_elements(attribute_list_sequence.value)
|
return ServiceAttribute.list_from_data_elements(attribute_list_sequence.value)
|
||||||
|
|
||||||
|
async def __aenter__(self) -> Self:
|
||||||
|
await self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args) -> None:
|
||||||
|
await self.disconnect()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server:
|
class Server:
|
||||||
|
|||||||
@@ -1090,7 +1090,7 @@ class Session:
|
|||||||
# We can now encrypt the connection with the short term key, so that we can
|
# 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
|
# distribute the long term and/or other keys over an encrypted connection
|
||||||
self.manager.device.host.send_command_sync(
|
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,
|
connection_handle=self.connection.handle,
|
||||||
random_number=bytes(8),
|
random_number=bytes(8),
|
||||||
encrypted_diversifier=0,
|
encrypted_diversifier=0,
|
||||||
@@ -1134,8 +1134,10 @@ class Session:
|
|||||||
|
|
||||||
async def get_link_key_and_derive_ltk(self) -> None:
|
async def get_link_key_and_derive_ltk(self) -> None:
|
||||||
'''Retrieves BR/EDR Link Key from storage and derive it to LE LTK.'''
|
'''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)
|
self.link_key = await self.manager.device.get_link_key(
|
||||||
if link_key is None:
|
self.connection.peer_address
|
||||||
|
)
|
||||||
|
if self.link_key is None:
|
||||||
logging.warning(
|
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!'
|
'Try to derive LTK but host does not have the LK. Send a SMP_PAIRING_FAILED but the procedure will not be paused!'
|
||||||
)
|
)
|
||||||
@@ -1143,7 +1145,7 @@ class Session:
|
|||||||
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR
|
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.ltk = self.derive_ltk(link_key, self.ct2)
|
self.ltk = self.derive_ltk(self.link_key, self.ct2)
|
||||||
|
|
||||||
def distribute_keys(self) -> None:
|
def distribute_keys(self) -> None:
|
||||||
# Distribute the keys as required
|
# Distribute the keys as required
|
||||||
@@ -1991,10 +1993,8 @@ class Manager(EventEmitter):
|
|||||||
) -> None:
|
) -> None:
|
||||||
# Store the keys in the key store
|
# Store the keys in the key store
|
||||||
if self.device.keystore and identity_address is not None:
|
if self.device.keystore and identity_address is not None:
|
||||||
self.device.abort_on(
|
# Make sure on_pairing emits after key update.
|
||||||
'flush', self.device.update_keys(str(identity_address), keys)
|
await self.device.update_keys(str(identity_address), keys)
|
||||||
)
|
|
||||||
|
|
||||||
# Notify the device
|
# Notify the device
|
||||||
self.device.on_pairing(session.connection, identity_address, keys, session.sc)
|
self.device.on_pairing(session.connection, identity_address, keys, session.sc)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
||||||
from ..snoop import create_snooper
|
from ..snoop import create_snooper
|
||||||
@@ -52,8 +53,16 @@ def _wrap_transport(transport: Transport) -> Transport:
|
|||||||
async def open_transport(name: str) -> Transport:
|
async def open_transport(name: str) -> Transport:
|
||||||
"""
|
"""
|
||||||
Open a transport by name.
|
Open a transport by name.
|
||||||
The name must be <type>:<parameters>
|
The name must be <type>:<metadata><parameters>
|
||||||
Where <parameters> depend on the type (and may be empty for some types).
|
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:
|
The supported types are:
|
||||||
* serial
|
* serial
|
||||||
* udp
|
* udp
|
||||||
@@ -71,87 +80,105 @@ async def open_transport(name: str) -> Transport:
|
|||||||
* android-netsim
|
* android-netsim
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return _wrap_transport(await _open_transport(name))
|
scheme, *tail = name.split(':', 1)
|
||||||
|
spec = tail[0] if tail else None
|
||||||
|
metadata = None
|
||||||
|
if spec:
|
||||||
|
# Metadata may precede the spec
|
||||||
|
if spec.startswith('['):
|
||||||
|
metadata_str, *tail = spec[1:].split(']')
|
||||||
|
spec = tail[0] if tail else None
|
||||||
|
metadata = dict([entry.split('=') for entry in metadata_str.split(',')])
|
||||||
|
|
||||||
|
transport = await _open_transport(scheme, spec)
|
||||||
|
if metadata:
|
||||||
|
transport.source.metadata = { # type: ignore[attr-defined]
|
||||||
|
**metadata,
|
||||||
|
**getattr(transport.source, 'metadata', {}),
|
||||||
|
}
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
logger.debug(f'HCI metadata: {transport.source.metadata}') # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
return _wrap_transport(transport)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def _open_transport(name: str) -> Transport:
|
async def _open_transport(scheme: str, spec: Optional[str]) -> Transport:
|
||||||
# pylint: disable=import-outside-toplevel
|
# pylint: disable=import-outside-toplevel
|
||||||
# pylint: disable=too-many-return-statements
|
# pylint: disable=too-many-return-statements
|
||||||
|
|
||||||
scheme, *spec = name.split(':', 1)
|
|
||||||
if scheme == 'serial' and spec:
|
if scheme == 'serial' and spec:
|
||||||
from .serial import open_serial_transport
|
from .serial import open_serial_transport
|
||||||
|
|
||||||
return await open_serial_transport(spec[0])
|
return await open_serial_transport(spec)
|
||||||
|
|
||||||
if scheme == 'udp' and spec:
|
if scheme == 'udp' and spec:
|
||||||
from .udp import open_udp_transport
|
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:
|
if scheme == 'tcp-client' and spec:
|
||||||
from .tcp_client import open_tcp_client_transport
|
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:
|
if scheme == 'tcp-server' and spec:
|
||||||
from .tcp_server import open_tcp_server_transport
|
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:
|
if scheme == 'ws-client' and spec:
|
||||||
from .ws_client import open_ws_client_transport
|
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:
|
if scheme == 'ws-server' and spec:
|
||||||
from .ws_server import open_ws_server_transport
|
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':
|
if scheme == 'pty':
|
||||||
from .pty import open_pty_transport
|
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':
|
if scheme == 'file':
|
||||||
from .file import open_file_transport
|
from .file import open_file_transport
|
||||||
|
|
||||||
assert spec is not None
|
assert spec is not None
|
||||||
return await open_file_transport(spec[0])
|
return await open_file_transport(spec)
|
||||||
|
|
||||||
if scheme == 'vhci':
|
if scheme == 'vhci':
|
||||||
from .vhci import open_vhci_transport
|
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':
|
if scheme == 'hci-socket':
|
||||||
from .hci_socket import open_hci_socket_transport
|
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':
|
if scheme == 'usb':
|
||||||
from .usb import open_usb_transport
|
from .usb import open_usb_transport
|
||||||
|
|
||||||
assert spec is not None
|
assert spec
|
||||||
return await open_usb_transport(spec[0])
|
return await open_usb_transport(spec)
|
||||||
|
|
||||||
if scheme == 'pyusb':
|
if scheme == 'pyusb':
|
||||||
from .pyusb import open_pyusb_transport
|
from .pyusb import open_pyusb_transport
|
||||||
|
|
||||||
assert spec is not None
|
assert spec
|
||||||
return await open_pyusb_transport(spec[0])
|
return await open_pyusb_transport(spec)
|
||||||
|
|
||||||
if scheme == 'android-emulator':
|
if scheme == 'android-emulator':
|
||||||
from .android_emulator import open_android_emulator_transport
|
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':
|
if scheme == 'android-netsim':
|
||||||
from .android_netsim import open_android_netsim_transport
|
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')
|
raise ValueError('unknown transport scheme')
|
||||||
|
|
||||||
@@ -170,12 +197,13 @@ async def open_transport_or_link(name: str) -> Transport:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
if name.startswith('link-relay:'):
|
if name.startswith('link-relay:'):
|
||||||
|
logger.warning('Link Relay has been deprecated.')
|
||||||
from ..controller import Controller
|
from ..controller import Controller
|
||||||
from ..link import RemoteLink # lazy import
|
from ..link import RemoteLink # lazy import
|
||||||
|
|
||||||
link = RemoteLink(name[11:])
|
link = RemoteLink(name[11:])
|
||||||
await link.wait_until_connected()
|
await link.wait_until_connected()
|
||||||
controller = Controller('remote', link=link)
|
controller = Controller('remote', link=link) # type:ignore[arg-type]
|
||||||
|
|
||||||
class LinkTransport(Transport):
|
class LinkTransport(Transport):
|
||||||
async def close(self):
|
async def close(self):
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
|
|||||||
mode = 'host'
|
mode = 'host'
|
||||||
server_host = 'localhost'
|
server_host = 'localhost'
|
||||||
server_port = '8554'
|
server_port = '8554'
|
||||||
if spec is not None:
|
if spec:
|
||||||
params = spec.split(',')
|
params = spec.split(',')
|
||||||
for param in params:
|
for param in params:
|
||||||
if param.startswith('mode='):
|
if param.startswith('mode='):
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import struct
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import io
|
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 import hci
|
||||||
from bumble.colors import color
|
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_ACL_DATA_PACKET: (2, 2, 'H'),
|
||||||
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
|
||||||
hci.HCI_EVENT_PACKET: (1, 1, 'B'),
|
hci.HCI_EVENT_PACKET: (1, 1, 'B'),
|
||||||
|
hci.HCI_ISO_DATA_PACKET: (2, 2, 'H'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -167,11 +168,13 @@ class PacketReader:
|
|||||||
|
|
||||||
def __init__(self, source: io.BufferedReader) -> None:
|
def __init__(self, source: io.BufferedReader) -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
|
self.at_end = False
|
||||||
|
|
||||||
def next_packet(self) -> Optional[bytes]:
|
def next_packet(self) -> Optional[bytes]:
|
||||||
# Get the packet type
|
# Get the packet type
|
||||||
packet_type = self.source.read(1)
|
packet_type = self.source.read(1)
|
||||||
if len(packet_type) != 1:
|
if len(packet_type) != 1:
|
||||||
|
self.at_end = True
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get the packet info based on its type
|
# Get the packet info based on its type
|
||||||
|
|||||||
@@ -59,10 +59,7 @@ async def open_hci_socket_transport(spec: Optional[str]) -> Transport:
|
|||||||
) from error
|
) from error
|
||||||
|
|
||||||
# Compute the adapter index
|
# Compute the adapter index
|
||||||
if spec is None:
|
adapter_index = int(spec) if spec else 0
|
||||||
adapter_index = 0
|
|
||||||
else:
|
|
||||||
adapter_index = int(spec)
|
|
||||||
|
|
||||||
# Bind the socket
|
# Bind the socket
|
||||||
# NOTE: since Python doesn't support binding with the required address format (yet),
|
# NOTE: since Python doesn't support binding with the required address format (yet),
|
||||||
|
|||||||
@@ -113,9 +113,10 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
self.loop.call_soon_threadsafe(self.stop_event.set)
|
self.loop.call_soon_threadsafe(self.stop_event.set)
|
||||||
|
|
||||||
class UsbPacketSource(asyncio.Protocol, ParserSource):
|
class UsbPacketSource(asyncio.Protocol, ParserSource):
|
||||||
def __init__(self, device, sco_enabled):
|
def __init__(self, device, metadata, sco_enabled):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
|
self.metadata = metadata
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
self.queue = asyncio.Queue()
|
self.queue = asyncio.Queue()
|
||||||
self.dequeue_task = None
|
self.dequeue_task = None
|
||||||
@@ -216,6 +217,15 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
if ':' in spec:
|
if ':' in spec:
|
||||||
vendor_id, product_id = spec.split(':')
|
vendor_id, product_id = spec.split(':')
|
||||||
device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
|
device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
|
||||||
|
elif '-' in spec:
|
||||||
|
|
||||||
|
def device_path(device):
|
||||||
|
if device.port_numbers:
|
||||||
|
return f'{device.bus}-{".".join(map(str, device.port_numbers))}'
|
||||||
|
else:
|
||||||
|
return str(device.bus)
|
||||||
|
|
||||||
|
device = usb_find(custom_match=lambda device: device_path(device) == spec)
|
||||||
else:
|
else:
|
||||||
device_index = int(spec)
|
device_index = int(spec)
|
||||||
devices = list(
|
devices = list(
|
||||||
@@ -235,6 +245,9 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
raise ValueError('device not found')
|
raise ValueError('device not found')
|
||||||
logger.debug(f'USB Device: {device}')
|
logger.debug(f'USB Device: {device}')
|
||||||
|
|
||||||
|
# Collect the metadata
|
||||||
|
device_metadata = {'vendor_id': device.idVendor, 'product_id': device.idProduct}
|
||||||
|
|
||||||
# Detach the kernel driver if needed
|
# Detach the kernel driver if needed
|
||||||
if device.is_kernel_driver_active(0):
|
if device.is_kernel_driver_active(0):
|
||||||
logger.debug("detaching kernel driver")
|
logger.debug("detaching kernel driver")
|
||||||
@@ -289,7 +302,7 @@ async def open_pyusb_transport(spec: str) -> Transport:
|
|||||||
# except usb.USBError:
|
# except usb.USBError:
|
||||||
# logger.warning('failed to set alternate setting')
|
# logger.warning('failed to set alternate setting')
|
||||||
|
|
||||||
packet_source = UsbPacketSource(device, sco_enabled)
|
packet_source = UsbPacketSource(device, device_metadata, sco_enabled)
|
||||||
packet_sink = UsbPacketSink(device)
|
packet_sink = UsbPacketSink(device)
|
||||||
packet_source.start()
|
packet_source.start()
|
||||||
packet_sink.start()
|
packet_sink.start()
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER,
|
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER,
|
||||||
)
|
)
|
||||||
|
|
||||||
READ_SIZE = 1024
|
READ_SIZE = 4096
|
||||||
|
|
||||||
class UsbPacketSink:
|
class UsbPacketSink:
|
||||||
def __init__(self, device, acl_out):
|
def __init__(self, device, acl_out):
|
||||||
@@ -396,6 +396,16 @@ async def open_usb_transport(spec: str) -> Transport:
|
|||||||
break
|
break
|
||||||
device_index -= 1
|
device_index -= 1
|
||||||
device.close()
|
device.close()
|
||||||
|
elif '-' in spec:
|
||||||
|
|
||||||
|
def device_path(device):
|
||||||
|
return f'{device.getBusNumber()}-{".".join(map(str, device.getPortNumberList()))}'
|
||||||
|
|
||||||
|
for device in context.getDeviceIterator(skip_on_error=True):
|
||||||
|
if device_path(device) == spec:
|
||||||
|
found = device
|
||||||
|
break
|
||||||
|
device.close()
|
||||||
else:
|
else:
|
||||||
# Look for a compatible device by index
|
# Look for a compatible device by index
|
||||||
def device_is_bluetooth_hci(device):
|
def device_is_bluetooth_hci(device):
|
||||||
|
|||||||
@@ -17,9 +17,10 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import traceback
|
|
||||||
import collections
|
import collections
|
||||||
|
import enum
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from typing import (
|
from typing import (
|
||||||
@@ -34,7 +35,7 @@ from typing import (
|
|||||||
Union,
|
Union,
|
||||||
overload,
|
overload,
|
||||||
)
|
)
|
||||||
from functools import wraps, partial
|
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
@@ -131,13 +132,14 @@ class EventWatcher:
|
|||||||
Args:
|
Args:
|
||||||
emitter: EventEmitter to watch
|
emitter: EventEmitter to watch
|
||||||
event: Event name
|
event: Event name
|
||||||
handler: (Optional) Event handler. When nothing is passed, this method works as a decorator.
|
handler: (Optional) Event handler. When nothing is passed, this method
|
||||||
|
works as a decorator.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def wrapper(f: _Handler) -> _Handler:
|
def wrapper(wrapped: _Handler) -> _Handler:
|
||||||
self.handlers.append((emitter, event, f))
|
self.handlers.append((emitter, event, wrapped))
|
||||||
emitter.on(event, f)
|
emitter.on(event, wrapped)
|
||||||
return f
|
return wrapped
|
||||||
|
|
||||||
return wrapper if handler is None else wrapper(handler)
|
return wrapper if handler is None else wrapper(handler)
|
||||||
|
|
||||||
@@ -157,13 +159,14 @@ class EventWatcher:
|
|||||||
Args:
|
Args:
|
||||||
emitter: EventEmitter to watch
|
emitter: EventEmitter to watch
|
||||||
event: Event name
|
event: Event name
|
||||||
handler: (Optional) Event handler. When nothing passed, this method works as a decorator.
|
handler: (Optional) Event handler. When nothing passed, this method works
|
||||||
|
as a decorator.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
def wrapper(f: _Handler) -> _Handler:
|
def wrapper(wrapped: _Handler) -> _Handler:
|
||||||
self.handlers.append((emitter, event, f))
|
self.handlers.append((emitter, event, wrapped))
|
||||||
emitter.once(event, f)
|
emitter.once(event, wrapped)
|
||||||
return f
|
return wrapped
|
||||||
|
|
||||||
return wrapper if handler is None else wrapper(handler)
|
return wrapper if handler is None else wrapper(handler)
|
||||||
|
|
||||||
@@ -223,13 +226,13 @@ class CompositeEventEmitter(AbortableEventEmitter):
|
|||||||
if self._listener:
|
if self._listener:
|
||||||
# Call the deregistration methods for each base class that has them
|
# Call the deregistration methods for each base class that has them
|
||||||
for cls in self._listener.__class__.mro():
|
for cls in self._listener.__class__.mro():
|
||||||
if hasattr(cls, '_bumble_register_composite'):
|
if '_bumble_register_composite' in cls.__dict__:
|
||||||
cls._bumble_deregister_composite(listener, self)
|
cls._bumble_deregister_composite(self._listener, self)
|
||||||
self._listener = listener
|
self._listener = listener
|
||||||
if listener:
|
if listener:
|
||||||
# Call the registration methods for each base class that has them
|
# Call the registration methods for each base class that has them
|
||||||
for cls in listener.__class__.mro():
|
for cls in listener.__class__.mro():
|
||||||
if hasattr(cls, '_bumble_deregister_composite'):
|
if '_bumble_deregister_composite' in cls.__dict__:
|
||||||
cls._bumble_register_composite(listener, self)
|
cls._bumble_register_composite(listener, self)
|
||||||
|
|
||||||
|
|
||||||
@@ -276,21 +279,18 @@ class AsyncRunner:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
@wraps(func)
|
@functools.wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
coroutine = func(*args, **kwargs)
|
coroutine = func(*args, **kwargs)
|
||||||
if queue is None:
|
if queue is None:
|
||||||
# Create a task to run the coroutine
|
# Spawn the coroutine as a task
|
||||||
async def run():
|
async def run():
|
||||||
try:
|
try:
|
||||||
await coroutine
|
await coroutine
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.exception(color("!!! Exception in wrapper:", "red"))
|
||||||
f'{color("!!! Exception in wrapper:", "red")} '
|
|
||||||
f'{traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.create_task(run())
|
AsyncRunner.spawn(run())
|
||||||
else:
|
else:
|
||||||
# Queue the coroutine to be awaited by the work queue
|
# Queue the coroutine to be awaited by the work queue
|
||||||
queue.enqueue(coroutine)
|
queue.enqueue(coroutine)
|
||||||
@@ -413,30 +413,35 @@ class FlowControlAsyncPipe:
|
|||||||
self.check_pump()
|
self.check_pump()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
async def async_call(function, *args, **kwargs):
|
async def async_call(function, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Immediately calls the function with provided args and kwargs, wrapping it in an async function.
|
Immediately calls the function with provided args and kwargs, wrapping it in an
|
||||||
Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject a running loop.
|
async function.
|
||||||
|
Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject
|
||||||
|
a running loop.
|
||||||
|
|
||||||
result = await async_call(some_function, ...)
|
result = await async_call(some_function, ...)
|
||||||
"""
|
"""
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def wrap_async(function):
|
def wrap_async(function):
|
||||||
"""
|
"""
|
||||||
Wraps the provided function in an async function.
|
Wraps the provided function in an async function.
|
||||||
"""
|
"""
|
||||||
return partial(async_call, function)
|
return functools.partial(async_call, function)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def deprecated(msg: str):
|
def deprecated(msg: str):
|
||||||
"""
|
"""
|
||||||
Throw deprecation warning before execution.
|
Throw deprecation warning before execution.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def wrapper(function):
|
def wrapper(function):
|
||||||
@wraps(function)
|
@functools.wraps(function)
|
||||||
def inner(*args, **kwargs):
|
def inner(*args, **kwargs):
|
||||||
warnings.warn(msg, DeprecationWarning)
|
warnings.warn(msg, DeprecationWarning)
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
@@ -446,13 +451,14 @@ def deprecated(msg: str):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
def experimental(msg: str):
|
def experimental(msg: str):
|
||||||
"""
|
"""
|
||||||
Throws a future warning before execution.
|
Throws a future warning before execution.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def wrapper(function):
|
def wrapper(function):
|
||||||
@wraps(function)
|
@functools.wraps(function)
|
||||||
def inner(*args, **kwargs):
|
def inner(*args, **kwargs):
|
||||||
warnings.warn(msg, FutureWarning)
|
warnings.warn(msg, FutureWarning)
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
@@ -460,3 +466,22 @@ def experimental(msg: str):
|
|||||||
return inner
|
return inner
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class OpenIntEnum(enum.IntEnum):
|
||||||
|
"""
|
||||||
|
Subclass of enum.IntEnum that can hold integer values outside the set of
|
||||||
|
predefined values. This is convenient for implementing protocols where some
|
||||||
|
integer constants may be added over time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _missing_(cls, value):
|
||||||
|
if not isinstance(value, int):
|
||||||
|
return None
|
||||||
|
|
||||||
|
obj = int.__new__(cls, value)
|
||||||
|
obj._value_ = value
|
||||||
|
obj._name_ = f"{cls.__name__}[{value}]"
|
||||||
|
return obj
|
||||||
|
|||||||
@@ -7,16 +7,36 @@ throughput and/or latency between two devices.
|
|||||||
# General Usage
|
# General Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
Usage: bench.py [OPTIONS] COMMAND [ARGS]...
|
Usage: bumble-bench [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--device-config FILENAME Device configuration file
|
--device-config FILENAME Device configuration file
|
||||||
--role [sender|receiver|ping|pong]
|
--role [sender|receiver|ping|pong]
|
||||||
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
|
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
|
||||||
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
|
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
|
||||||
-s, --packet-size SIZE Packet size (server role) [8<=x<=4096]
|
--extended-data-length TEXT Request a data length upon connection,
|
||||||
-c, --packet-count COUNT Packet count (server role)
|
specified as tx_octets/tx_time
|
||||||
-sd, --start-delay SECONDS Start delay (server role)
|
--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.
|
--help Show this message and exit.
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
@@ -35,10 +55,11 @@ Options:
|
|||||||
--connection-interval, --ci CONNECTION_INTERVAL
|
--connection-interval, --ci CONNECTION_INTERVAL
|
||||||
Connection interval (in ms)
|
Connection interval (in ms)
|
||||||
--phy [1m|2m|coded] PHY to use
|
--phy [1m|2m|coded] PHY to use
|
||||||
|
--authenticate Authenticate (RFComm only)
|
||||||
|
--encrypt Encrypt the connection (RFComm only)
|
||||||
--help Show this message and exit.
|
--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
|
the ``peripheral`` command and the other the ``central`` command. The device
|
||||||
running the ``peripheral`` command will accept connections from the device
|
running the ``peripheral`` command will accept connections from the device
|
||||||
|
|||||||
@@ -12,12 +12,25 @@ a host that send custom HCI commands that the controller may not understand.
|
|||||||
```
|
```
|
||||||
python hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list]
|
python hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list]
|
||||||
```
|
```
|
||||||
|
The command-short-circuit-list field is specified by a series of comma separated Opcode Group
|
||||||
|
Field (OGF) : OpCode Command Field (OCF) pairs. The OGF/OCF values are specified in the Blutooth
|
||||||
|
core specification.
|
||||||
|
|
||||||
|
For the commands that are listed in the short-circuit-list, the HCI bridge will always generate
|
||||||
|
a Command Complete Event for the specified op code. The return parameter will be HCI_SUCCESS.
|
||||||
|
|
||||||
|
This feature can only be used for commands that return Command Complete. Other events will not be
|
||||||
|
generated by the HCI bridge tool.
|
||||||
|
|
||||||
!!! example "UDP to Serial"
|
!!! example "UDP to Serial"
|
||||||
```
|
```
|
||||||
python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078
|
python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In this example, the short circuit list is specified to respond to the Vendor-specific Opcode Group
|
||||||
|
Field (0x3f) commands 0x70, 0x74, 0x77, 0x78 with Command Complete. The short circuit list can be
|
||||||
|
used where the Host uses some HCI commands that are not supported/implemented by the Controller.
|
||||||
|
|
||||||
!!! example "PTY to Link Relay"
|
!!! example "PTY to Link Relay"
|
||||||
```
|
```
|
||||||
python hci_bridge.py serial:emulated_uart_pty,1000000 link-relay:ws://127.0.0.1:10723/test
|
python hci_bridge.py serial:emulated_uart_pty,1000000 link-relay:ws://127.0.0.1:10723/test
|
||||||
@@ -28,3 +41,4 @@ a host that send custom HCI commands that the controller may not understand.
|
|||||||
(through which the communication with other virtual controllers will be mediated).
|
(through which the communication with other virtual controllers will be mediated).
|
||||||
|
|
||||||
NOTE: this assumes you're running a Link Relay on port `10723`.
|
NOTE: this assumes you're running a Link Relay on port `10723`.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ Some Bluetooth controllers require a driver to function properly.
|
|||||||
This may include, for instance, loading a Firmware image or patch,
|
This may include, for instance, loading a Firmware image or patch,
|
||||||
loading a configuration.
|
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:
|
Drivers included in the module are:
|
||||||
|
|
||||||
* [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles.
|
* [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles.
|
||||||
@@ -4,10 +4,13 @@ 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.
|
USB dongles with a Realtek chipset.
|
||||||
A number of USB dongles are supported, but likely not all.
|
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
|
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
|
is needed for that specific model. If a match exists, the driver will try
|
||||||
load the firmware image and, if needed, config data.
|
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 driver will look for those files by name, in order, in:
|
||||||
|
|
||||||
* The directory specified by the environment variable `BUMBLE_RTK_FIRMWARE_DIR`
|
* The directory specified by the environment variable `BUMBLE_RTK_FIRMWARE_DIR`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ The moniker for a USB transport is either:
|
|||||||
* `usb:<vendor>:<product>`
|
* `usb:<vendor>:<product>`
|
||||||
* `usb:<vendor>:<product>/<serial-number>`
|
* `usb:<vendor>:<product>/<serial-number>`
|
||||||
* `usb:<vendor>:<product>#<index>`
|
* `usb:<vendor>:<product>#<index>`
|
||||||
|
* `usb:<bus>-<port_numbers>`
|
||||||
|
|
||||||
with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one.
|
with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one.
|
||||||
In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol.
|
In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol.
|
||||||
@@ -17,6 +18,8 @@ In the `usb:<vendor>:<product>#<index>` form, matching devices are the ones with
|
|||||||
|
|
||||||
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
|
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
|
||||||
|
|
||||||
|
with `<port_numbers>` as a list of all port numbers from root separated with dots `.`
|
||||||
|
|
||||||
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
|
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
|
||||||
the first USB interface of the device will be used, regardless of the interface class/subclass.
|
the first USB interface of the device will be used, regardless of the interface class/subclass.
|
||||||
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
|
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
|
||||||
@@ -37,6 +40,9 @@ This may be useful for some devices that use a custom class/subclass but may non
|
|||||||
`usb:0B05:17CB!`
|
`usb:0B05:17CB!`
|
||||||
The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
|
The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
|
||||||
|
|
||||||
|
`usb:3-3.4.1`
|
||||||
|
The BT USB dongle on bus 3 on port path 3, 4, 1.
|
||||||
|
|
||||||
|
|
||||||
## Alternative
|
## Alternative
|
||||||
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
|
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
|
||||||
|
|||||||
274
examples/avrcp_as_sink.html
Normal file
274
examples/avrcp_as_sink.html
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
Server Port <input id="port" type="text" value="8989"></input> <button id="connectButton" onclick="connect()">Connect</button><br>
|
||||||
|
<div id="socketState"></div>
|
||||||
|
<br>
|
||||||
|
<div id="buttons"></div><br>
|
||||||
|
<hr>
|
||||||
|
<button onclick="onGetPlayStatusButtonClicked()">Get Play Status</button><br>
|
||||||
|
<div id="getPlayStatusResponseTable"></div>
|
||||||
|
<hr>
|
||||||
|
<button onclick="onGetElementAttributesButtonClicked()">Get Element Attributes</button><br>
|
||||||
|
<div id="getElementAttributesResponseTable"></div>
|
||||||
|
<hr>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<b>VOLUME</b>:
|
||||||
|
<button onclick="onVolumeDownButtonClicked()">-</button>
|
||||||
|
<button onclick="onVolumeUpButtonClicked()">+</button>
|
||||||
|
<span id="volumeText"></span><br>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>PLAYBACK STATUS</b></td><td><span id="playbackStatusText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>POSITION</b></td><td><span id="positionText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>TRACK</b></td><td><span id="trackText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>ADDRESSED PLAYER</b></td><td><span id="addressedPlayerText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>UID COUNTER</b></td><td><span id="uidCounterText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>SUPPORTED EVENTS</b></td><td><span id="supportedEventsText"></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>PLAYER SETTINGS</b></td><td><div id="playerSettingsTable"></div></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<script>
|
||||||
|
const portInput = document.getElementById("port")
|
||||||
|
const connectButton = document.getElementById("connectButton")
|
||||||
|
const socketState = document.getElementById("socketState")
|
||||||
|
const volumeText = document.getElementById("volumeText")
|
||||||
|
const positionText = document.getElementById("positionText")
|
||||||
|
const trackText = document.getElementById("trackText")
|
||||||
|
const playbackStatusText = document.getElementById("playbackStatusText")
|
||||||
|
const addressedPlayerText = document.getElementById("addressedPlayerText")
|
||||||
|
const uidCounterText = document.getElementById("uidCounterText")
|
||||||
|
const supportedEventsText = document.getElementById("supportedEventsText")
|
||||||
|
const playerSettingsTable = document.getElementById("playerSettingsTable")
|
||||||
|
const getPlayStatusResponseTable = document.getElementById("getPlayStatusResponseTable")
|
||||||
|
const getElementAttributesResponseTable = document.getElementById("getElementAttributesResponseTable")
|
||||||
|
let socket
|
||||||
|
let volume = 0
|
||||||
|
|
||||||
|
const keyNames = [
|
||||||
|
"SELECT",
|
||||||
|
"UP",
|
||||||
|
"DOWN",
|
||||||
|
"LEFT",
|
||||||
|
"RIGHT",
|
||||||
|
"RIGHT_UP",
|
||||||
|
"RIGHT_DOWN",
|
||||||
|
"LEFT_UP",
|
||||||
|
"LEFT_DOWN",
|
||||||
|
"ROOT_MENU",
|
||||||
|
"SETUP_MENU",
|
||||||
|
"CONTENTS_MENU",
|
||||||
|
"FAVORITE_MENU",
|
||||||
|
"EXIT",
|
||||||
|
"NUMBER_0",
|
||||||
|
"NUMBER_1",
|
||||||
|
"NUMBER_2",
|
||||||
|
"NUMBER_3",
|
||||||
|
"NUMBER_4",
|
||||||
|
"NUMBER_5",
|
||||||
|
"NUMBER_6",
|
||||||
|
"NUMBER_7",
|
||||||
|
"NUMBER_8",
|
||||||
|
"NUMBER_9",
|
||||||
|
"DOT",
|
||||||
|
"ENTER",
|
||||||
|
"CLEAR",
|
||||||
|
"CHANNEL_UP",
|
||||||
|
"CHANNEL_DOWN",
|
||||||
|
"PREVIOUS_CHANNEL",
|
||||||
|
"SOUND_SELECT",
|
||||||
|
"INPUT_SELECT",
|
||||||
|
"DISPLAY_INFORMATION",
|
||||||
|
"HELP",
|
||||||
|
"PAGE_UP",
|
||||||
|
"PAGE_DOWN",
|
||||||
|
"POWER",
|
||||||
|
"VOLUME_UP",
|
||||||
|
"VOLUME_DOWN",
|
||||||
|
"MUTE",
|
||||||
|
"PLAY",
|
||||||
|
"STOP",
|
||||||
|
"PAUSE",
|
||||||
|
"RECORD",
|
||||||
|
"REWIND",
|
||||||
|
"FAST_FORWARD",
|
||||||
|
"EJECT",
|
||||||
|
"FORWARD",
|
||||||
|
"BACKWARD",
|
||||||
|
"ANGLE",
|
||||||
|
"SUBPICTURE",
|
||||||
|
"F1",
|
||||||
|
"F2",
|
||||||
|
"F3",
|
||||||
|
"F4",
|
||||||
|
"F5",
|
||||||
|
]
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
document.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
|
const buttons = document.getElementById("buttons")
|
||||||
|
keyNames.forEach(name => {
|
||||||
|
const button = document.createElement("BUTTON")
|
||||||
|
button.appendChild(document.createTextNode(name))
|
||||||
|
button.addEventListener("mousedown", event => {
|
||||||
|
send({type: 'send-key-down', key: name})
|
||||||
|
})
|
||||||
|
button.addEventListener("mouseup", event => {
|
||||||
|
send({type: 'send-key-up', key: name})
|
||||||
|
})
|
||||||
|
buttons.appendChild(button)
|
||||||
|
})
|
||||||
|
|
||||||
|
updateVolume(0)
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
socket = new WebSocket(`ws://localhost:${portInput.value}`);
|
||||||
|
socket.onopen = _ => {
|
||||||
|
socketState.innerText = 'OPEN'
|
||||||
|
connectButton.disabled = true
|
||||||
|
}
|
||||||
|
socket.onclose = _ => {
|
||||||
|
socketState.innerText = 'CLOSED'
|
||||||
|
connectButton.disabled = false
|
||||||
|
}
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
socketState.innerText = 'ERROR'
|
||||||
|
console.log(`ERROR: ${error}`)
|
||||||
|
connectButton.disabled = false
|
||||||
|
}
|
||||||
|
socket.onmessage = (message) => {
|
||||||
|
onMessage(JSON.parse(message.data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(message) {
|
||||||
|
if (socket && socket.readyState == WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmsText(position) {
|
||||||
|
const h_1 = 1000 * 60 * 60
|
||||||
|
const h = Math.floor(position / h_1)
|
||||||
|
position -= h * h_1
|
||||||
|
const m_1 = 1000 * 60
|
||||||
|
const m = Math.floor(position / m_1)
|
||||||
|
position -= m * m_1
|
||||||
|
const s_1 = 1000
|
||||||
|
const s = Math.floor(position / s_1)
|
||||||
|
position -= s * s_1
|
||||||
|
|
||||||
|
return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}:${position}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTableHead(table, columns) {
|
||||||
|
let thead = table.createTHead()
|
||||||
|
let row = thead.insertRow()
|
||||||
|
for (let column of columns) {
|
||||||
|
let th = document.createElement("th")
|
||||||
|
let text = document.createTextNode(column)
|
||||||
|
th.appendChild(text)
|
||||||
|
row.appendChild(th)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTable(rows) {
|
||||||
|
const table = document.createElement("table")
|
||||||
|
|
||||||
|
if (rows.length != 0) {
|
||||||
|
columns = Object.keys(rows[0])
|
||||||
|
setTableHead(table, columns)
|
||||||
|
}
|
||||||
|
for (let element of rows) {
|
||||||
|
let row = table.insertRow()
|
||||||
|
for (key in element) {
|
||||||
|
let cell = row.insertCell()
|
||||||
|
let text = document.createTextNode(element[key])
|
||||||
|
cell.appendChild(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMessage(message) {
|
||||||
|
console.log(message)
|
||||||
|
if (message.type == "set-volume") {
|
||||||
|
updateVolume(message.params.volume)
|
||||||
|
} else if (message.type == "supported-events") {
|
||||||
|
supportedEventsText.innerText = JSON.stringify(message.params.events)
|
||||||
|
} else if (message.type == "playback-position-changed") {
|
||||||
|
positionText.innerText = hmsText(message.params.position)
|
||||||
|
} else if (message.type == "playback-status-changed") {
|
||||||
|
playbackStatusText.innerText = message.params.status
|
||||||
|
} else if (message.type == "player-settings-changed") {
|
||||||
|
playerSettingsTable.replaceChildren(message.params.settings)
|
||||||
|
} else if (message.type == "track-changed") {
|
||||||
|
trackText.innerText = message.params.identifier
|
||||||
|
} else if (message.type == "addressed-player-changed") {
|
||||||
|
addressedPlayerText.innerText = JSON.stringify(message.params.player)
|
||||||
|
} else if (message.type == "uids-changed") {
|
||||||
|
uidCounterText.innerText = message.params.uid_counter
|
||||||
|
} else if (message.type == "get-play-status-response") {
|
||||||
|
getPlayStatusResponseTable.replaceChildren(message.params)
|
||||||
|
} else if (message.type == "get-element-attributes-response") {
|
||||||
|
getElementAttributesResponseTable.replaceChildren(createTable(message.params))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVolume(newVolume) {
|
||||||
|
volume = newVolume
|
||||||
|
volumeText.innerText = `${volume} (${Math.round(100*volume/0x7F)}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event) {
|
||||||
|
console.log(event)
|
||||||
|
send({ type: 'send-key-down', key: event.key })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(event) {
|
||||||
|
console.log(event)
|
||||||
|
send({ type: 'send-key-up', key: event.key })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVolumeUpButtonClicked() {
|
||||||
|
updateVolume(Math.min(volume + 5, 0x7F))
|
||||||
|
send({ type: 'set-volume', volume })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVolumeDownButtonClicked() {
|
||||||
|
updateVolume(Math.max(volume - 5, 0))
|
||||||
|
send({ type: 'set-volume', volume })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGetPlayStatusButtonClicked() {
|
||||||
|
send({ type: 'get-play-status', volume })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGetElementAttributesButtonClicked() {
|
||||||
|
send({ type: 'get-element-attributes' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
5
examples/hid_keyboard.json
Normal file
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) {
|
function onMouseMove(event) {
|
||||||
//console.log(event.clientX, event.clientY)
|
//console.log(event.movementX, event.movementY)
|
||||||
mouseInfo.innerText = `MOUSE: x=${event.clientX}, y=${event.clientY}`
|
mouseInfo.innerText = `MOUSE: x=${event.movementX}, y=${event.movementY}`
|
||||||
send({ type:'mousemove', x: event.clientX, y: event.clientY })
|
send({ type:'mousemove', x: event.movementX, y: event.movementY })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(event) {
|
function onKeyDown(event) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Bumble-LEA",
|
"name": "Bumble-LEA",
|
||||||
"keystore": "JsonKeyStore",
|
"keystore": "JsonKeyStore",
|
||||||
|
"address": "F0:F1:F2:F3:F4:FA",
|
||||||
|
"class_of_device": 2376708,
|
||||||
"advertising_interval": 100
|
"advertising_interval": 100
|
||||||
}
|
}
|
||||||
|
|||||||
9
examples/leaudio_with_classic.json
Normal file
9
examples/leaudio_with_classic.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "Bumble-LEA",
|
||||||
|
"keystore": "JsonKeyStore",
|
||||||
|
"address": "F0:F1:F2:F3:F4:FA",
|
||||||
|
"classic_enabled": true,
|
||||||
|
"cis_enabled": true,
|
||||||
|
"class_of_device": 2376708,
|
||||||
|
"advertising_interval": 100
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ def codec_capabilities():
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_avdtp_connection(read_function, protocol):
|
def on_avdtp_connection(read_function, protocol):
|
||||||
packet_source = SbcPacketSource(
|
packet_source = SbcPacketSource(
|
||||||
read_function, protocol.l2cap_channel.mtu, codec_capabilities()
|
read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
|
||||||
)
|
)
|
||||||
packet_pump = MediaPacketPump(packet_source.packets)
|
packet_pump = MediaPacketPump(packet_source.packets)
|
||||||
protocol.add_source(packet_source.codec_capabilities, packet_pump)
|
protocol.add_source(packet_source.codec_capabilities, packet_pump)
|
||||||
@@ -98,7 +98,7 @@ async def stream_packets(read_function, protocol):
|
|||||||
|
|
||||||
# Stream the packets
|
# Stream the packets
|
||||||
packet_source = SbcPacketSource(
|
packet_source = SbcPacketSource(
|
||||||
read_function, protocol.l2cap_channel.mtu, codec_capabilities()
|
read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
|
||||||
)
|
)
|
||||||
packet_pump = MediaPacketPump(packet_source.packets)
|
packet_pump = MediaPacketPump(packet_source.packets)
|
||||||
source = protocol.add_source(packet_source.codec_capabilities, packet_pump)
|
source = protocol.add_source(packet_source.codec_capabilities, packet_pump)
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from bumble.core import AdvertisingData
|
||||||
from bumble.device import AdvertisingType, Device
|
from bumble.device import AdvertisingType, Device
|
||||||
from bumble.hci import Address
|
from bumble.hci import Address
|
||||||
|
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +54,16 @@ async def main():
|
|||||||
print('<<< connected')
|
print('<<< connected')
|
||||||
|
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||||
|
|
||||||
|
if advertising_type.is_scannable:
|
||||||
|
device.scan_response_data = bytes(
|
||||||
|
AdvertisingData(
|
||||||
|
[
|
||||||
|
(AdvertisingData.APPEARANCE, struct.pack('<H', 0x0340)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_advertising(advertising_type=advertising_type, target=target)
|
await device.start_advertising(advertising_type=advertising_type, target=target)
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.wait_for_termination()
|
||||||
|
|||||||
408
examples/run_avrcp.py
Normal file
408
examples/run_avrcp.py
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
# Copyright 2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
from bumble.device import Device
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
from bumble.core import BT_BR_EDR_TRANSPORT
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import avrcp
|
||||||
|
from bumble import avdtp
|
||||||
|
from bumble import a2dp
|
||||||
|
from bumble import utils
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def sdp_records():
|
||||||
|
a2dp_sink_service_record_handle = 0x00010001
|
||||||
|
avrcp_controller_service_record_handle = 0x00010002
|
||||||
|
avrcp_target_service_record_handle = 0x00010003
|
||||||
|
# pylint: disable=line-too-long
|
||||||
|
return {
|
||||||
|
a2dp_sink_service_record_handle: a2dp.make_audio_sink_service_sdp_records(
|
||||||
|
a2dp_sink_service_record_handle
|
||||||
|
),
|
||||||
|
avrcp_controller_service_record_handle: avrcp.make_controller_service_sdp_records(
|
||||||
|
avrcp_controller_service_record_handle
|
||||||
|
),
|
||||||
|
avrcp_target_service_record_handle: avrcp.make_target_service_sdp_records(
|
||||||
|
avrcp_controller_service_record_handle
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def codec_capabilities():
|
||||||
|
return avdtp.MediaCodecCapabilities(
|
||||||
|
media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
|
media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
|
||||||
|
media_codec_information=a2dp.SbcMediaCodecInformation.from_lists(
|
||||||
|
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||||
|
channel_modes=[
|
||||||
|
a2dp.SBC_MONO_CHANNEL_MODE,
|
||||||
|
a2dp.SBC_DUAL_CHANNEL_MODE,
|
||||||
|
a2dp.SBC_STEREO_CHANNEL_MODE,
|
||||||
|
a2dp.SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||||
|
],
|
||||||
|
block_lengths=[4, 8, 12, 16],
|
||||||
|
subbands=[4, 8],
|
||||||
|
allocation_methods=[
|
||||||
|
a2dp.SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||||
|
a2dp.SBC_SNR_ALLOCATION_METHOD,
|
||||||
|
],
|
||||||
|
minimum_bitpool_value=2,
|
||||||
|
maximum_bitpool_value=53,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_avdtp_connection(server):
|
||||||
|
# Add a sink endpoint to the server
|
||||||
|
sink = server.add_sink(codec_capabilities())
|
||||||
|
sink.on('rtp_packet', on_rtp_packet)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_rtp_packet(packet):
|
||||||
|
print(f'RTP: {packet}')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketServer):
|
||||||
|
async def get_supported_events():
|
||||||
|
events = await avrcp_protocol.get_supported_events()
|
||||||
|
print("SUPPORTED EVENTS:", events)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "supported-events",
|
||||||
|
"params": {"events": [event.name for event in events]},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if avrcp.EventId.TRACK_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_track_changed())
|
||||||
|
|
||||||
|
if avrcp.EventId.PLAYBACK_STATUS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_playback_status())
|
||||||
|
|
||||||
|
if avrcp.EventId.PLAYBACK_POS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_playback_position())
|
||||||
|
|
||||||
|
if avrcp.EventId.PLAYER_APPLICATION_SETTING_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_player_application_settings())
|
||||||
|
|
||||||
|
if avrcp.EventId.AVAILABLE_PLAYERS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_available_players())
|
||||||
|
|
||||||
|
if avrcp.EventId.ADDRESSED_PLAYER_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_addressed_player())
|
||||||
|
|
||||||
|
if avrcp.EventId.UIDS_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_uids())
|
||||||
|
|
||||||
|
if avrcp.EventId.VOLUME_CHANGED in events:
|
||||||
|
utils.AsyncRunner.spawn(monitor_volume())
|
||||||
|
|
||||||
|
utils.AsyncRunner.spawn(get_supported_events())
|
||||||
|
|
||||||
|
async def monitor_track_changed():
|
||||||
|
async for identifier in avrcp_protocol.monitor_track_changed():
|
||||||
|
print("TRACK CHANGED:", identifier.hex())
|
||||||
|
websocket_server.send_message(
|
||||||
|
{"type": "track-changed", "params": {"identifier": identifier.hex()}}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_playback_status():
|
||||||
|
async for playback_status in avrcp_protocol.monitor_playback_status():
|
||||||
|
print("PLAYBACK STATUS CHANGED:", playback_status.name)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "playback-status-changed",
|
||||||
|
"params": {"status": playback_status.name},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_playback_position():
|
||||||
|
async for playback_position in avrcp_protocol.monitor_playback_position(
|
||||||
|
playback_interval=1
|
||||||
|
):
|
||||||
|
print("PLAYBACK POSITION CHANGED:", playback_position)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "playback-position-changed",
|
||||||
|
"params": {"position": playback_position},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_player_application_settings():
|
||||||
|
async for settings in avrcp_protocol.monitor_player_application_settings():
|
||||||
|
print("PLAYER APPLICATION SETTINGS:", settings)
|
||||||
|
settings_as_dict = [
|
||||||
|
{"attribute": setting.attribute_id.name, "value": setting.value_id.name}
|
||||||
|
for setting in settings
|
||||||
|
]
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "player-settings-changed",
|
||||||
|
"params": {"settings": settings_as_dict},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_available_players():
|
||||||
|
async for _ in avrcp_protocol.monitor_available_players():
|
||||||
|
print("AVAILABLE PLAYERS CHANGED")
|
||||||
|
websocket_server.send_message(
|
||||||
|
{"type": "available-players-changed", "params": {}}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_addressed_player():
|
||||||
|
async for player in avrcp_protocol.monitor_addressed_player():
|
||||||
|
print("ADDRESSED PLAYER CHANGED")
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "addressed-player-changed",
|
||||||
|
"params": {
|
||||||
|
"player": {
|
||||||
|
"player_id": player.player_id,
|
||||||
|
"uid_counter": player.uid_counter,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_uids():
|
||||||
|
async for uid_counter in avrcp_protocol.monitor_uids():
|
||||||
|
print("UIDS CHANGED")
|
||||||
|
websocket_server.send_message(
|
||||||
|
{
|
||||||
|
"type": "uids-changed",
|
||||||
|
"params": {
|
||||||
|
"uid_counter": uid_counter,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def monitor_volume():
|
||||||
|
async for volume in avrcp_protocol.monitor_volume():
|
||||||
|
print("VOLUME CHANGED:", volume)
|
||||||
|
websocket_server.send_message(
|
||||||
|
{"type": "volume-changed", "params": {"volume": volume}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class WebSocketServer:
|
||||||
|
def __init__(
|
||||||
|
self, avrcp_protocol: avrcp.Protocol, avrcp_delegate: Delegate
|
||||||
|
) -> None:
|
||||||
|
self.socket = None
|
||||||
|
self.delegate = None
|
||||||
|
self.avrcp_protocol = avrcp_protocol
|
||||||
|
self.avrcp_delegate = avrcp_delegate
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
# pylint: disable-next=no-member
|
||||||
|
await websockets.serve(self.serve, 'localhost', 8989) # type: ignore
|
||||||
|
|
||||||
|
async def serve(self, socket, _path) -> None:
|
||||||
|
print('### WebSocket connected')
|
||||||
|
self.socket = socket
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
message = await socket.recv()
|
||||||
|
print('Received: ', str(message))
|
||||||
|
|
||||||
|
parsed = json.loads(message)
|
||||||
|
message_type = parsed['type']
|
||||||
|
if message_type == 'send-key-down':
|
||||||
|
await self.on_send_key_down(parsed)
|
||||||
|
elif message_type == 'send-key-up':
|
||||||
|
await self.on_send_key_up(parsed)
|
||||||
|
elif message_type == 'set-volume':
|
||||||
|
await self.on_set_volume(parsed)
|
||||||
|
elif message_type == 'get-play-status':
|
||||||
|
await self.on_get_play_status()
|
||||||
|
elif message_type == 'get-element-attributes':
|
||||||
|
await self.on_get_element_attributes()
|
||||||
|
except websockets.exceptions.ConnectionClosedOK:
|
||||||
|
self.socket = None
|
||||||
|
break
|
||||||
|
|
||||||
|
async def on_send_key_down(self, message: dict) -> None:
|
||||||
|
key = avc.PassThroughFrame.OperationId[message["key"]]
|
||||||
|
await self.avrcp_protocol.send_key_event(key, True)
|
||||||
|
|
||||||
|
async def on_send_key_up(self, message: dict) -> None:
|
||||||
|
key = avc.PassThroughFrame.OperationId[message["key"]]
|
||||||
|
await self.avrcp_protocol.send_key_event(key, False)
|
||||||
|
|
||||||
|
async def on_set_volume(self, message: dict) -> None:
|
||||||
|
volume = message["volume"]
|
||||||
|
self.avrcp_delegate.volume = volume
|
||||||
|
self.avrcp_protocol.notify_volume_changed(volume)
|
||||||
|
|
||||||
|
async def on_get_play_status(self) -> None:
|
||||||
|
play_status = await self.avrcp_protocol.get_play_status()
|
||||||
|
self.send_message(
|
||||||
|
{
|
||||||
|
"type": "get-play-status-response",
|
||||||
|
"params": {
|
||||||
|
"song_length": play_status.song_length,
|
||||||
|
"song_position": play_status.song_position,
|
||||||
|
"play_status": play_status.play_status.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_get_element_attributes(self) -> None:
|
||||||
|
attributes = await self.avrcp_protocol.get_element_attributes(
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
avrcp.MediaAttributeId.TITLE,
|
||||||
|
avrcp.MediaAttributeId.ARTIST_NAME,
|
||||||
|
avrcp.MediaAttributeId.ALBUM_NAME,
|
||||||
|
avrcp.MediaAttributeId.TRACK_NUMBER,
|
||||||
|
avrcp.MediaAttributeId.TOTAL_NUMBER_OF_TRACKS,
|
||||||
|
avrcp.MediaAttributeId.GENRE,
|
||||||
|
avrcp.MediaAttributeId.PLAYING_TIME,
|
||||||
|
avrcp.MediaAttributeId.DEFAULT_COVER_ART,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.send_message(
|
||||||
|
{
|
||||||
|
"type": "get-element-attributes-response",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"attribute_id": attribute.attribute_id.name,
|
||||||
|
"attribute_value": attribute.attribute_value,
|
||||||
|
}
|
||||||
|
for attribute in attributes
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_message(self, message: dict) -> None:
|
||||||
|
if self.socket is None:
|
||||||
|
print("no socket, dropping message")
|
||||||
|
return
|
||||||
|
serialized = json.dumps(message)
|
||||||
|
utils.AsyncRunner.spawn(self.socket.send(serialized))
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Delegate(avrcp.Delegate):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
[avrcp.EventId.VOLUME_CHANGED, avrcp.EventId.PLAYBACK_STATUS_CHANGED]
|
||||||
|
)
|
||||||
|
self.websocket_server = None
|
||||||
|
|
||||||
|
async def set_absolute_volume(self, volume: int) -> None:
|
||||||
|
await super().set_absolute_volume(volume)
|
||||||
|
if self.websocket_server is not None:
|
||||||
|
self.websocket_server.send_message(
|
||||||
|
{"type": "set-volume", "params": {"volume": volume}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(
|
||||||
|
'Usage: run_avrcp_controller.py <device-config> <transport-spec> '
|
||||||
|
'<sbc-file> [<bt-addr>]'
|
||||||
|
)
|
||||||
|
print('example: run_avrcp_controller.py classic1.json usb:0')
|
||||||
|
return
|
||||||
|
|
||||||
|
print('<<< connecting to HCI...')
|
||||||
|
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
||||||
|
print('<<< connected')
|
||||||
|
|
||||||
|
# Create a device
|
||||||
|
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||||
|
device.classic_enabled = True
|
||||||
|
|
||||||
|
# Setup the SDP to expose the sink service
|
||||||
|
device.sdp_service_records = sdp_records()
|
||||||
|
|
||||||
|
# Start the controller
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
# Create a listener to wait for AVDTP connections
|
||||||
|
listener = avdtp.Listener(avdtp.Listener.create_registrar(device))
|
||||||
|
listener.on('connection', on_avdtp_connection)
|
||||||
|
|
||||||
|
avrcp_delegate = Delegate()
|
||||||
|
avrcp_protocol = avrcp.Protocol(avrcp_delegate)
|
||||||
|
avrcp_protocol.listen(device)
|
||||||
|
|
||||||
|
websocket_server = WebSocketServer(avrcp_protocol, avrcp_delegate)
|
||||||
|
avrcp_delegate.websocket_server = websocket_server
|
||||||
|
avrcp_protocol.on(
|
||||||
|
"start", lambda: on_avrcp_start(avrcp_protocol, websocket_server)
|
||||||
|
)
|
||||||
|
await websocket_server.start()
|
||||||
|
|
||||||
|
if len(sys.argv) >= 5:
|
||||||
|
# Connect to the peer
|
||||||
|
target_address = sys.argv[4]
|
||||||
|
print(f'=== Connecting to {target_address}...')
|
||||||
|
connection = await device.connect(
|
||||||
|
target_address, transport=BT_BR_EDR_TRANSPORT
|
||||||
|
)
|
||||||
|
print(f'=== Connected to {connection.peer_address}!')
|
||||||
|
|
||||||
|
# Request authentication
|
||||||
|
print('*** Authenticating...')
|
||||||
|
await connection.authenticate()
|
||||||
|
print('*** Authenticated')
|
||||||
|
|
||||||
|
# Enable encryption
|
||||||
|
print('*** Enabling encryption...')
|
||||||
|
await connection.encrypt()
|
||||||
|
print('*** Encryption on')
|
||||||
|
|
||||||
|
server = await avdtp.Protocol.connect(connection)
|
||||||
|
listener.set_server(connection, server)
|
||||||
|
sink = server.add_sink(codec_capabilities())
|
||||||
|
sink.on('rtp_packet', on_rtp_packet)
|
||||||
|
|
||||||
|
await avrcp_protocol.connect(connection)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Start being discoverable and connectable
|
||||||
|
await device.set_discoverable(True)
|
||||||
|
await device.set_connectable(True)
|
||||||
|
|
||||||
|
await asyncio.get_event_loop().create_future()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||||
|
asyncio.run(main())
|
||||||
@@ -22,10 +22,11 @@ import os
|
|||||||
from bumble.device import (
|
from bumble.device import (
|
||||||
Device,
|
Device,
|
||||||
Connection,
|
Connection,
|
||||||
|
AdvertisingParameters,
|
||||||
|
AdvertisingEventProperties,
|
||||||
)
|
)
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
OwnAddressType,
|
OwnAddressType,
|
||||||
HCI_LE_Set_Extended_Advertising_Parameters_Command,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
@@ -61,12 +62,7 @@ async def main() -> None:
|
|||||||
devices[1].cis_enabled = True
|
devices[1].cis_enabled = True
|
||||||
|
|
||||||
await asyncio.gather(*[device.power_on() for device in devices])
|
await asyncio.gather(*[device.power_on() for device in devices])
|
||||||
await devices[0].start_extended_advertising(
|
advertising_set = await devices[0].create_advertising_set()
|
||||||
advertising_properties=(
|
|
||||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING
|
|
||||||
),
|
|
||||||
own_address_type=OwnAddressType.PUBLIC,
|
|
||||||
)
|
|
||||||
|
|
||||||
connection = await devices[1].connect(
|
connection = await devices[1].connect(
|
||||||
devices[0].public_address, own_address_type=OwnAddressType.PUBLIC
|
devices[0].public_address, own_address_type=OwnAddressType.PUBLIC
|
||||||
|
|||||||
110
examples/run_csis_servers.py
Normal file
110
examples/run_csis_servers.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from bumble.core import AdvertisingData
|
||||||
|
from bumble.device import Device
|
||||||
|
from bumble.hci import (
|
||||||
|
Address,
|
||||||
|
OwnAddressType,
|
||||||
|
HCI_LE_Set_Extended_Advertising_Parameters_Command,
|
||||||
|
)
|
||||||
|
from bumble.profiles.cap import CommonAudioServiceService
|
||||||
|
from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
|
||||||
|
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def main() -> None:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(
|
||||||
|
'Usage: run_cig_setup.py <config-file>'
|
||||||
|
'<transport-spec-for-device-1> <transport-spec-for-device-2>'
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
'example: run_cig_setup.py device1.json'
|
||||||
|
'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
print('<<< connecting to HCI...')
|
||||||
|
hci_transports = await asyncio.gather(
|
||||||
|
open_transport_or_link(sys.argv[2]), open_transport_or_link(sys.argv[3])
|
||||||
|
)
|
||||||
|
print('<<< connected')
|
||||||
|
|
||||||
|
devices = [
|
||||||
|
Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
for hci_transport in hci_transports
|
||||||
|
]
|
||||||
|
|
||||||
|
sirk = secrets.token_bytes(16)
|
||||||
|
|
||||||
|
for i, device in enumerate(devices):
|
||||||
|
device.random_address = Address(secrets.token_bytes(6))
|
||||||
|
await device.power_on()
|
||||||
|
csis = CoordinatedSetIdentificationService(
|
||||||
|
set_identity_resolving_key=sirk,
|
||||||
|
set_identity_resolving_key_type=SirkType.PLAINTEXT,
|
||||||
|
coordinated_set_size=2,
|
||||||
|
)
|
||||||
|
device.add_service(CommonAudioServiceService(csis))
|
||||||
|
advertising_data = (
|
||||||
|
bytes(
|
||||||
|
AdvertisingData(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||||
|
bytes(f'Bumble LE Audio-{i}', 'utf-8'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AdvertisingData.FLAGS,
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
|
||||||
|
| AdvertisingData.BR_EDR_HOST_FLAG
|
||||||
|
| AdvertisingData.BR_EDR_CONTROLLER_FLAG
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||||
|
bytes(CoordinatedSetIdentificationService.UUID),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ csis.get_advertising_data()
|
||||||
|
)
|
||||||
|
await device.create_advertising_set(advertising_data=advertising_data)
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
*[hci_transport.source.terminated for hci_transport in hci_transports]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||||
|
asyncio.run(main())
|
||||||
@@ -73,7 +73,6 @@ async def main() -> None:
|
|||||||
HCI_Enhanced_Setup_Synchronous_Connection_Command(
|
HCI_Enhanced_Setup_Synchronous_Connection_Command(
|
||||||
connection_handle=connections[0].handle,
|
connection_handle=connections[0].handle,
|
||||||
**ESCO_PARAMETERS[DefaultCodecParameters.ESCO_CVSD_S3].asdict(),
|
**ESCO_PARAMETERS[DefaultCodecParameters.ESCO_CVSD_S3].asdict(),
|
||||||
# type: ignore[call-args]
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from bumble.device import AdvertisingType, Device
|
from bumble.device import (
|
||||||
from bumble.hci import Address, HCI_LE_Set_Extended_Advertising_Parameters_Command
|
AdvertisingParameters,
|
||||||
|
AdvertisingEventProperties,
|
||||||
|
AdvertisingType,
|
||||||
|
Device,
|
||||||
|
)
|
||||||
|
from bumble.hci import Address
|
||||||
|
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
|
|
||||||
@@ -35,20 +40,16 @@ async def main() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if len(sys.argv) >= 4:
|
if len(sys.argv) >= 4:
|
||||||
advertising_properties = (
|
advertising_properties = AdvertisingEventProperties.from_advertising_type(
|
||||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties(
|
AdvertisingType(int(sys.argv[3]))
|
||||||
int(sys.argv[3])
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
advertising_properties = (
|
advertising_properties = AdvertisingEventProperties()
|
||||||
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(sys.argv) >= 5:
|
if len(sys.argv) >= 5:
|
||||||
target = Address(sys.argv[4])
|
peer_address = Address(sys.argv[4])
|
||||||
else:
|
else:
|
||||||
target = Address.ANY
|
peer_address = Address.ANY
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
print('<<< connecting to HCI...')
|
||||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
@@ -58,8 +59,11 @@ async def main() -> None:
|
|||||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
)
|
)
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_extended_advertising(
|
await device.create_advertising_set(
|
||||||
advertising_properties=advertising_properties, target=target
|
advertising_parameters=AdvertisingParameters(
|
||||||
|
advertising_event_properties=advertising_properties,
|
||||||
|
peer_address=peer_address,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await hci_transport.source.terminated
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|||||||
99
examples/run_extended_advertiser_2.py
Normal file
99
examples/run_extended_advertiser_2.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Copyright 2021-2024 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from bumble.device import AdvertisingParameters, AdvertisingEventProperties, Device
|
||||||
|
from bumble.hci import Address
|
||||||
|
from bumble.core import AdvertisingData
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def main() -> None:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print('Usage: run_extended_advertiser_2.py <config-file> <transport-spec>')
|
||||||
|
print('example: run_extended_advertiser_2.py device1.json usb:0')
|
||||||
|
return
|
||||||
|
|
||||||
|
print('<<< connecting to HCI...')
|
||||||
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
|
print('<<< connected')
|
||||||
|
|
||||||
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
if not device.supports_le_extended_advertising:
|
||||||
|
print("Device does not support extended advertising")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Max advertising sets:", device.host.number_of_supported_advertising_sets)
|
||||||
|
print(
|
||||||
|
"Max advertising data length:", device.host.maximum_advertising_data_length
|
||||||
|
)
|
||||||
|
|
||||||
|
if device.host.number_of_supported_advertising_sets >= 1:
|
||||||
|
advertising_data1 = AdvertisingData(
|
||||||
|
[(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 1".encode("utf-8"))]
|
||||||
|
)
|
||||||
|
|
||||||
|
set1 = await device.create_advertising_set(
|
||||||
|
advertising_data=bytes(advertising_data1),
|
||||||
|
)
|
||||||
|
print("Selected TX power 1:", set1.selected_tx_power)
|
||||||
|
|
||||||
|
advertising_data2 = AdvertisingData(
|
||||||
|
[(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 2".encode("utf-8"))]
|
||||||
|
)
|
||||||
|
|
||||||
|
if device.host.number_of_supported_advertising_sets >= 2:
|
||||||
|
set2 = await device.create_advertising_set(
|
||||||
|
random_address=Address("F0:F0:F0:F0:F0:F1"),
|
||||||
|
advertising_parameters=AdvertisingParameters(),
|
||||||
|
advertising_data=bytes(advertising_data2),
|
||||||
|
auto_start=False,
|
||||||
|
auto_restart=True,
|
||||||
|
)
|
||||||
|
print("Selected TX power 2:", set2.selected_tx_power)
|
||||||
|
await set2.start()
|
||||||
|
|
||||||
|
if device.host.number_of_supported_advertising_sets >= 3:
|
||||||
|
scan_response_data3 = AdvertisingData(
|
||||||
|
[(AdvertisingData.COMPLETE_LOCAL_NAME, "Bumble 3".encode("utf-8"))]
|
||||||
|
)
|
||||||
|
|
||||||
|
set3 = await device.create_advertising_set(
|
||||||
|
random_address=Address("F0:F0:F0:F0:F0:F2"),
|
||||||
|
advertising_parameters=AdvertisingParameters(
|
||||||
|
advertising_event_properties=AdvertisingEventProperties(
|
||||||
|
is_connectable=False, is_scannable=True
|
||||||
|
)
|
||||||
|
),
|
||||||
|
scan_response_data=bytes(scan_response_data3),
|
||||||
|
)
|
||||||
|
print("Selected TX power 3:", set2.selected_tx_power)
|
||||||
|
|
||||||
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||||
|
asyncio.run(main())
|
||||||
@@ -21,11 +21,13 @@ import os
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import websockets
|
import websockets
|
||||||
|
import functools
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from bumble.device import Device
|
from bumble import rfcomm
|
||||||
|
from bumble import hci
|
||||||
|
from bumble.device import Device, Connection
|
||||||
from bumble.transport import open_transport_or_link
|
from bumble.transport import open_transport_or_link
|
||||||
from bumble.rfcomm import Server as RfcommServer
|
|
||||||
from bumble import hfp
|
from bumble import hfp
|
||||||
from bumble.hfp import HfProtocol
|
from bumble.hfp import HfProtocol
|
||||||
|
|
||||||
@@ -57,12 +59,44 @@ class UiServer:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_dlc(dlc, configuration: hfp.Configuration):
|
def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
|
||||||
print('*** DLC connected', dlc)
|
print('*** DLC connected', dlc)
|
||||||
protocol = HfProtocol(dlc, configuration)
|
protocol = HfProtocol(dlc, configuration)
|
||||||
UiServer.protocol = protocol
|
UiServer.protocol = protocol
|
||||||
asyncio.create_task(protocol.run())
|
asyncio.create_task(protocol.run())
|
||||||
|
|
||||||
|
def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol):
|
||||||
|
if connection == protocol.dlc.multiplexer.l2cap_channel.connection:
|
||||||
|
if link_type == hci.HCI_Connection_Complete_Event.SCO_LINK_TYPE:
|
||||||
|
esco_parameters = hfp.ESCO_PARAMETERS[
|
||||||
|
hfp.DefaultCodecParameters.SCO_CVSD_D1
|
||||||
|
]
|
||||||
|
elif protocol.active_codec == hfp.AudioCodec.MSBC:
|
||||||
|
esco_parameters = hfp.ESCO_PARAMETERS[
|
||||||
|
hfp.DefaultCodecParameters.ESCO_MSBC_T2
|
||||||
|
]
|
||||||
|
elif protocol.active_codec == hfp.AudioCodec.CVSD:
|
||||||
|
esco_parameters = hfp.ESCO_PARAMETERS[
|
||||||
|
hfp.DefaultCodecParameters.ESCO_CVSD_S4
|
||||||
|
]
|
||||||
|
connection.abort_on(
|
||||||
|
'disconnection',
|
||||||
|
connection.device.send_command(
|
||||||
|
hci.HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(
|
||||||
|
bd_addr=connection.peer_address, **esco_parameters.asdict()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = functools.partial(on_sco_request, protocol=protocol)
|
||||||
|
dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler)
|
||||||
|
dlc.multiplexer.l2cap_channel.once(
|
||||||
|
'close',
|
||||||
|
lambda: dlc.multiplexer.l2cap_channel.connection.device.remove_listener(
|
||||||
|
'sco_request', handler
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def main():
|
async def main():
|
||||||
@@ -101,7 +135,7 @@ async def main():
|
|||||||
device.classic_enabled = True
|
device.classic_enabled = True
|
||||||
|
|
||||||
# Create and register a server
|
# Create and register a server
|
||||||
rfcomm_server = RfcommServer(device)
|
rfcomm_server = rfcomm.Server(device)
|
||||||
|
|
||||||
# Listen for incoming DLC connections
|
# Listen for incoming DLC connections
|
||||||
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
|
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
|
||||||
|
|||||||
748
examples/run_hid_device.py
Normal file
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())
|
||||||
@@ -285,7 +285,10 @@ async def main():
|
|||||||
print('example: run_hid_host.py classic1.json usb:0 E1:CA:72:48:C4:E8/P')
|
print('example: run_hid_host.py classic1.json usb:0 E1:CA:72:48:C4:E8/P')
|
||||||
return
|
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
|
report_type = pdu[0] & 0x0F
|
||||||
if len(pdu) == 1:
|
if len(pdu) == 1:
|
||||||
print(color(f'Warning: No report received', 'yellow'))
|
print(color(f'Warning: No report received', 'yellow'))
|
||||||
@@ -305,7 +308,7 @@ async def main():
|
|||||||
|
|
||||||
if (report_length <= 1) or (report_id == 0):
|
if (report_length <= 1) or (report_id == 0):
|
||||||
return
|
return
|
||||||
|
# Parse report over interrupt channel
|
||||||
if report_type == Message.ReportType.INPUT_REPORT:
|
if report_type == Message.ReportType.INPUT_REPORT:
|
||||||
ReportParser.parse_input_report(pdu[1:]) # type: ignore
|
ReportParser.parse_input_report(pdu[1:]) # type: ignore
|
||||||
|
|
||||||
@@ -313,6 +316,8 @@ async def main():
|
|||||||
await hid_host.disconnect_interrupt_channel()
|
await hid_host.disconnect_interrupt_channel()
|
||||||
await hid_host.disconnect_control_channel()
|
await hid_host.disconnect_control_channel()
|
||||||
await device.keystore.delete(target_address) # type: ignore
|
await device.keystore.delete(target_address) # type: ignore
|
||||||
|
connection = hid_host.connection
|
||||||
|
if connection is not None:
|
||||||
await connection.disconnect()
|
await connection.disconnect()
|
||||||
|
|
||||||
def on_hid_virtual_cable_unplug_cb():
|
def on_hid_virtual_cable_unplug_cb():
|
||||||
@@ -325,6 +330,18 @@ async def main():
|
|||||||
# Create a device
|
# Create a device
|
||||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||||
device.classic_enabled = True
|
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()
|
await device.power_on()
|
||||||
|
|
||||||
# Connect to a peer
|
# Connect to a peer
|
||||||
@@ -345,16 +362,6 @@ async def main():
|
|||||||
|
|
||||||
await get_hid_device_sdp_record(connection)
|
await get_hid_device_sdp_record(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)
|
|
||||||
|
|
||||||
async def menu():
|
async def menu():
|
||||||
reader = await get_stream_reader(sys.stdin)
|
reader = await get_stream_reader(sys.stdin)
|
||||||
while True:
|
while True:
|
||||||
@@ -369,13 +376,14 @@ async def main():
|
|||||||
print(" 6. Set Report")
|
print(" 6. Set Report")
|
||||||
print(" 7. Set Protocol Mode")
|
print(" 7. Set Protocol Mode")
|
||||||
print(" 8. Get Protocol Mode")
|
print(" 8. Get Protocol Mode")
|
||||||
print(" 9. Send Report")
|
print(" 9. Send Report on Interrupt Channel")
|
||||||
print("10. Suspend")
|
print("10. Suspend")
|
||||||
print("11. Exit Suspend")
|
print("11. Exit Suspend")
|
||||||
print("12. Virtual Cable Unplug")
|
print("12. Virtual Cable Unplug")
|
||||||
print("13. Disconnect device")
|
print("13. Disconnect device")
|
||||||
print("14. Delete Bonding")
|
print("14. Delete Bonding")
|
||||||
print("15. Re-connect to device")
|
print("15. Re-connect to device")
|
||||||
|
print("16. Exit")
|
||||||
print("\nEnter your choice : \n")
|
print("\nEnter your choice : \n")
|
||||||
|
|
||||||
choice = await reader.readline()
|
choice = await reader.readline()
|
||||||
@@ -394,21 +402,40 @@ async def main():
|
|||||||
await hid_host.disconnect_interrupt_channel()
|
await hid_host.disconnect_interrupt_channel()
|
||||||
|
|
||||||
elif choice == '5':
|
elif choice == '5':
|
||||||
print(" 1. Report ID 0x02")
|
print(" 1. Input Report with ID 0x01")
|
||||||
print(" 2. Report ID 0x03")
|
print(" 2. Input Report with ID 0x02")
|
||||||
print(" 3. Report ID 0x05")
|
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 = await reader.readline()
|
||||||
choice1 = choice1.decode('utf-8').strip()
|
choice1 = choice1.decode('utf-8').strip()
|
||||||
|
|
||||||
if choice1 == '1':
|
if choice1 == '1':
|
||||||
hid_host.get_report(1, 2, 3)
|
hid_host.get_report(1, 1, 0)
|
||||||
|
|
||||||
elif choice1 == '2':
|
elif choice1 == '2':
|
||||||
hid_host.get_report(2, 3, 2)
|
hid_host.get_report(1, 2, 0)
|
||||||
|
|
||||||
elif choice1 == '3':
|
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:
|
else:
|
||||||
print('Incorrect option selected')
|
print('Incorrect option selected')
|
||||||
|
|
||||||
@@ -484,6 +511,7 @@ async def main():
|
|||||||
hid_host.virtual_cable_unplug()
|
hid_host.virtual_cable_unplug()
|
||||||
try:
|
try:
|
||||||
await device.keystore.delete(target_address)
|
await device.keystore.delete(target_address)
|
||||||
|
print("Unpair successful")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
print('Device not found or Device already unpaired.')
|
print('Device not found or Device already unpaired.')
|
||||||
|
|
||||||
@@ -513,6 +541,9 @@ async def main():
|
|||||||
await connection.authenticate()
|
await connection.authenticate()
|
||||||
await connection.encrypt()
|
await connection.encrypt()
|
||||||
|
|
||||||
|
elif choice == '16':
|
||||||
|
sys.exit("Exit successful")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print("Invalid option selected.")
|
print("Invalid option selected.")
|
||||||
|
|
||||||
|
|||||||
190
examples/run_unicast_server.py
Normal file
190
examples/run_unicast_server.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# Copyright 2021-2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import secrets
|
||||||
|
from bumble.core import AdvertisingData
|
||||||
|
from bumble.device import Device, CisLink, AdvertisingParameters
|
||||||
|
from bumble.hci import (
|
||||||
|
CodecID,
|
||||||
|
CodingFormat,
|
||||||
|
OwnAddressType,
|
||||||
|
HCI_IsoDataPacket,
|
||||||
|
)
|
||||||
|
from bumble.profiles.bap import (
|
||||||
|
CodecSpecificCapabilities,
|
||||||
|
ContextType,
|
||||||
|
AudioLocation,
|
||||||
|
SupportedSamplingFrequency,
|
||||||
|
SupportedFrameDuration,
|
||||||
|
PacRecord,
|
||||||
|
PublishedAudioCapabilitiesService,
|
||||||
|
AudioStreamControlService,
|
||||||
|
)
|
||||||
|
from bumble.profiles.cap import CommonAudioServiceService
|
||||||
|
from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
|
||||||
|
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def main() -> None:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print('Usage: run_cig_setup.py <config-file>' '<transport-spec-for-device>')
|
||||||
|
return
|
||||||
|
|
||||||
|
print('<<< connecting to HCI...')
|
||||||
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
|
print('<<< connected')
|
||||||
|
|
||||||
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
device.cis_enabled = True
|
||||||
|
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
csis = CoordinatedSetIdentificationService(
|
||||||
|
set_identity_resolving_key=secrets.token_bytes(16),
|
||||||
|
set_identity_resolving_key_type=SirkType.PLAINTEXT,
|
||||||
|
)
|
||||||
|
device.add_service(CommonAudioServiceService(csis))
|
||||||
|
device.add_service(
|
||||||
|
PublishedAudioCapabilitiesService(
|
||||||
|
supported_source_context=ContextType.PROHIBITED,
|
||||||
|
available_source_context=ContextType.PROHIBITED,
|
||||||
|
supported_sink_context=ContextType.MEDIA,
|
||||||
|
available_sink_context=ContextType.MEDIA,
|
||||||
|
sink_audio_locations=(
|
||||||
|
AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
|
||||||
|
),
|
||||||
|
sink_pac=[
|
||||||
|
# Codec Capability Setting 16_2
|
||||||
|
PacRecord(
|
||||||
|
coding_format=CodingFormat(CodecID.LC3),
|
||||||
|
codec_specific_capabilities=CodecSpecificCapabilities(
|
||||||
|
supported_sampling_frequencies=(
|
||||||
|
SupportedSamplingFrequency.FREQ_16000
|
||||||
|
),
|
||||||
|
supported_frame_durations=(
|
||||||
|
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||||
|
),
|
||||||
|
supported_audio_channel_counts=[1],
|
||||||
|
min_octets_per_codec_frame=40,
|
||||||
|
max_octets_per_codec_frame=40,
|
||||||
|
supported_max_codec_frames_per_sdu=1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Codec Capability Setting 24_2
|
||||||
|
PacRecord(
|
||||||
|
coding_format=CodingFormat(CodecID.LC3),
|
||||||
|
codec_specific_capabilities=CodecSpecificCapabilities(
|
||||||
|
supported_sampling_frequencies=(
|
||||||
|
SupportedSamplingFrequency.FREQ_48000
|
||||||
|
),
|
||||||
|
supported_frame_durations=(
|
||||||
|
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||||
|
),
|
||||||
|
supported_audio_channel_counts=[1],
|
||||||
|
min_octets_per_codec_frame=120,
|
||||||
|
max_octets_per_codec_frame=120,
|
||||||
|
supported_max_codec_frames_per_sdu=1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2]))
|
||||||
|
|
||||||
|
advertising_data = (
|
||||||
|
bytes(
|
||||||
|
AdvertisingData(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||||
|
bytes('Bumble LE Audio', 'utf-8'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AdvertisingData.FLAGS,
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
|
||||||
|
| AdvertisingData.BR_EDR_HOST_FLAG
|
||||||
|
| AdvertisingData.BR_EDR_CONTROLLER_FLAG
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||||
|
bytes(PublishedAudioCapabilitiesService.UUID),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ csis.get_advertising_data()
|
||||||
|
)
|
||||||
|
subprocess = await asyncio.create_subprocess_shell(
|
||||||
|
f'dlc3 | ffplay pipe:0',
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdin = subprocess.stdin
|
||||||
|
assert stdin
|
||||||
|
|
||||||
|
# Write a fake LC3 header to dlc3.
|
||||||
|
stdin.write(
|
||||||
|
bytes([0x1C, 0xCC]) # Header.
|
||||||
|
+ struct.pack(
|
||||||
|
'<HHHHHHI',
|
||||||
|
18, # Header length.
|
||||||
|
48000 // 100, # Sampling Rate(/100Hz).
|
||||||
|
0, # Bitrate(unused).
|
||||||
|
1, # Channels.
|
||||||
|
10000 // 10, # Frame duration(/10us).
|
||||||
|
0, # RFU.
|
||||||
|
0x0FFFFFFF, # Frame counts.
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_pdu(pdu: HCI_IsoDataPacket):
|
||||||
|
# LC3 format: |frame_length(2)| + |frame(length)|.
|
||||||
|
if pdu.iso_sdu_length:
|
||||||
|
stdin.write(struct.pack('<H', pdu.iso_sdu_length))
|
||||||
|
stdin.write(pdu.iso_sdu_fragment)
|
||||||
|
|
||||||
|
def on_cis(cis_link: CisLink):
|
||||||
|
cis_link.on('pdu', on_pdu)
|
||||||
|
|
||||||
|
device.once('cis_establishment', on_cis)
|
||||||
|
|
||||||
|
advertising_set = await device.create_advertising_set(
|
||||||
|
advertising_data=advertising_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||||
|
asyncio.run(main())
|
||||||
191
examples/run_vcp_renderer.py
Normal file
191
examples/run_vcp_renderer.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Copyright 2021-2024 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import websockets
|
||||||
|
import json
|
||||||
|
|
||||||
|
from bumble.core import AdvertisingData
|
||||||
|
from bumble.device import Device, AdvertisingParameters, AdvertisingEventProperties
|
||||||
|
from bumble.hci import (
|
||||||
|
CodecID,
|
||||||
|
CodingFormat,
|
||||||
|
OwnAddressType,
|
||||||
|
)
|
||||||
|
from bumble.profiles.bap import (
|
||||||
|
CodecSpecificCapabilities,
|
||||||
|
ContextType,
|
||||||
|
AudioLocation,
|
||||||
|
SupportedSamplingFrequency,
|
||||||
|
SupportedFrameDuration,
|
||||||
|
PacRecord,
|
||||||
|
PublishedAudioCapabilitiesService,
|
||||||
|
AudioStreamControlService,
|
||||||
|
)
|
||||||
|
from bumble.profiles.cap import CommonAudioServiceService
|
||||||
|
from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
|
||||||
|
from bumble.profiles.vcp import VolumeControlService
|
||||||
|
|
||||||
|
from bumble.transport import open_transport_or_link
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def dumps_volume_state(volume_setting: int, muted: int, change_counter: int) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
'volume_setting': volume_setting,
|
||||||
|
'muted': muted,
|
||||||
|
'change_counter': change_counter,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def main() -> None:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print('Usage: run_vcp_renderer.py <config-file>' '<transport-spec-for-device>')
|
||||||
|
return
|
||||||
|
|
||||||
|
print('<<< connecting to HCI...')
|
||||||
|
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||||
|
print('<<< connected')
|
||||||
|
|
||||||
|
device = Device.from_config_file_with_hci(
|
||||||
|
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||||
|
)
|
||||||
|
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
# Add "placeholder" services to enable Android LEA features.
|
||||||
|
csis = CoordinatedSetIdentificationService(
|
||||||
|
set_identity_resolving_key=secrets.token_bytes(16),
|
||||||
|
set_identity_resolving_key_type=SirkType.PLAINTEXT,
|
||||||
|
)
|
||||||
|
device.add_service(CommonAudioServiceService(csis))
|
||||||
|
device.add_service(
|
||||||
|
PublishedAudioCapabilitiesService(
|
||||||
|
supported_source_context=ContextType.PROHIBITED,
|
||||||
|
available_source_context=ContextType.PROHIBITED,
|
||||||
|
supported_sink_context=ContextType.MEDIA,
|
||||||
|
available_sink_context=ContextType.MEDIA,
|
||||||
|
sink_audio_locations=(
|
||||||
|
AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
|
||||||
|
),
|
||||||
|
sink_pac=[
|
||||||
|
# Codec Capability Setting 48_4
|
||||||
|
PacRecord(
|
||||||
|
coding_format=CodingFormat(CodecID.LC3),
|
||||||
|
codec_specific_capabilities=CodecSpecificCapabilities(
|
||||||
|
supported_sampling_frequencies=(
|
||||||
|
SupportedSamplingFrequency.FREQ_48000
|
||||||
|
),
|
||||||
|
supported_frame_durations=(
|
||||||
|
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||||
|
),
|
||||||
|
supported_audio_channel_counts=[1],
|
||||||
|
min_octets_per_codec_frame=120,
|
||||||
|
max_octets_per_codec_frame=120,
|
||||||
|
supported_max_codec_frames_per_sdu=1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2]))
|
||||||
|
|
||||||
|
vcs = VolumeControlService()
|
||||||
|
device.add_service(vcs)
|
||||||
|
|
||||||
|
ws: Optional[websockets.WebSocketServerProtocol] = None
|
||||||
|
|
||||||
|
def on_volume_state(volume_setting: int, muted: int, change_counter: int):
|
||||||
|
if ws:
|
||||||
|
asyncio.create_task(
|
||||||
|
ws.send(dumps_volume_state(volume_setting, muted, change_counter))
|
||||||
|
)
|
||||||
|
|
||||||
|
vcs.on('volume_state', on_volume_state)
|
||||||
|
|
||||||
|
advertising_data = (
|
||||||
|
bytes(
|
||||||
|
AdvertisingData(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||||
|
bytes('Bumble LE Audio', 'utf-8'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AdvertisingData.FLAGS,
|
||||||
|
bytes(
|
||||||
|
[
|
||||||
|
AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
|
||||||
|
| AdvertisingData.BR_EDR_HOST_FLAG
|
||||||
|
| AdvertisingData.BR_EDR_CONTROLLER_FLAG
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||||
|
bytes(PublishedAudioCapabilitiesService.UUID),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ csis.get_advertising_data()
|
||||||
|
)
|
||||||
|
|
||||||
|
await device.create_advertising_set(
|
||||||
|
advertising_parameters=AdvertisingParameters(
|
||||||
|
advertising_event_properties=AdvertisingEventProperties(),
|
||||||
|
own_address_type=OwnAddressType.PUBLIC,
|
||||||
|
),
|
||||||
|
advertising_data=advertising_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def serve(websocket: websockets.WebSocketServerProtocol, _path):
|
||||||
|
nonlocal ws
|
||||||
|
await websocket.send(
|
||||||
|
dumps_volume_state(vcs.volume_setting, vcs.muted, vcs.change_counter)
|
||||||
|
)
|
||||||
|
ws = websocket
|
||||||
|
async for message in websocket:
|
||||||
|
volume_state = json.loads(message)
|
||||||
|
vcs.volume_state_bytes = bytes(
|
||||||
|
[
|
||||||
|
volume_state['volume_setting'],
|
||||||
|
volume_state['muted'],
|
||||||
|
volume_state['change_counter'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await device.notify_subscribers(
|
||||||
|
vcs.volume_state, vcs.volume_state_bytes
|
||||||
|
)
|
||||||
|
ws = None
|
||||||
|
|
||||||
|
await websockets.serve(serve, 'localhost', 8989)
|
||||||
|
|
||||||
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||||
|
asyncio.run(main())
|
||||||
103
examples/vcp_renderer.html
Normal file
103
examples/vcp_renderer.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<html data-bs-theme="dark">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
|
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<label for="server-port" class="form-label">Server Port</label>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control" aria-label="Port Number" value="8989" id="port">
|
||||||
|
<button class="btn btn-primary" type="button" onclick="connect()">Connect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<label for="volume_setting" class="form-label">Volume Setting</label>
|
||||||
|
<input type="range" class="form-range" min="0" max="255" id="volume_setting">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label for="change_counter" class="form-label">Change Counter</label>
|
||||||
|
<input type="range" class="form-range" min="0" max="255" id="change_counter">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="muted">
|
||||||
|
<label class="form-check-label" for="muted">Muted</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" type="button" onclick="update_state()">Notify New Volume State</button>
|
||||||
|
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
|
||||||
|
<h3>Log</h3>
|
||||||
|
<code id="socketState">
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let portInput = document.getElementById("port")
|
||||||
|
let volumeSetting = document.getElementById("volume_setting")
|
||||||
|
let muted = document.getElementById("muted")
|
||||||
|
let changeCounter = document.getElementById("change_counter")
|
||||||
|
let socket = null
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (socket != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
socket = new WebSocket(`ws://localhost:${portInput.value}`);
|
||||||
|
socket.onopen = _ => {
|
||||||
|
socketState.innerText += 'OPEN\n'
|
||||||
|
}
|
||||||
|
socket.onclose = _ => {
|
||||||
|
socketState.innerText += 'CLOSED\n'
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
socketState.innerText += 'ERROR\n'
|
||||||
|
console.log(`ERROR: ${error}`)
|
||||||
|
}
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
socketState.innerText += `<- ${event.data}\n`
|
||||||
|
let volume_state = JSON.parse(event.data)
|
||||||
|
volumeSetting.value = volume_state.volume_setting
|
||||||
|
changeCounter.value = volume_state.change_counter
|
||||||
|
muted.checked = volume_state.muted ? true : false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(message) {
|
||||||
|
if (socket && socket.readyState == WebSocket.OPEN) {
|
||||||
|
let jsonMessage = JSON.stringify(message)
|
||||||
|
socketState.innerText += `-> ${jsonMessage}\n`
|
||||||
|
socket.send(jsonMessage)
|
||||||
|
} else {
|
||||||
|
socketState.innerText += 'NOT CONNECTED\n'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_state() {
|
||||||
|
send({
|
||||||
|
volume_setting: parseInt(volumeSetting.value),
|
||||||
|
change_counter: parseInt(changeCounter.value),
|
||||||
|
muted: muted.checked ? 1 : 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
|
||||||
|
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
package="com.github.google.bumble.btbench">
|
||||||
|
<uses-sdk android:minSdkVersion="30" android:targetSdkVersion="34" />
|
||||||
<!-- Request legacy Bluetooth permissions on older devices. -->
|
<!-- Request legacy Bluetooth permissions on older devices. -->
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
<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_ADMIN" android:maxSdkVersion="30" />
|
||||||
@@ -22,11 +22,10 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.BTBench"
|
android:theme="@style/Theme.BTBench"
|
||||||
tools:targetApi="31">
|
>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
|
||||||
android:theme="@style/Theme.BTBench">
|
android:theme="@style/Theme.BTBench">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -16,17 +16,83 @@ package com.github.google.bumble.btbench
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothAdapter
|
import android.bluetooth.BluetoothAdapter
|
||||||
import java.io.IOException
|
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
|
import java.util.logging.Logger
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.l2cap-client")
|
private val Log = Logger.getLogger("btbench.l2cap-client")
|
||||||
|
|
||||||
class L2capClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
class L2capClient(
|
||||||
|
private val viewModel: AppViewModel,
|
||||||
|
private val bluetoothAdapter: BluetoothAdapter,
|
||||||
|
private val context: Context
|
||||||
|
) {
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
fun run() {
|
||||||
viewModel.running = true
|
viewModel.running = true
|
||||||
val remoteDevice = bluetoothAdapter.getRemoteDevice(viewModel.peerBluetoothAddress)
|
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 socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
|
||||||
|
|
||||||
val client = SocketClient(viewModel, socket)
|
val client = SocketClient(viewModel, socket)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ private val Log = Logger.getLogger("btbench.l2cap-server")
|
|||||||
class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
|
class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
fun run() {
|
||||||
// Advertise to that the peer can find us and connect.
|
// Advertise so that the peer can find us and connect.
|
||||||
val callback = object: AdvertiseCallback() {
|
val callback = object: AdvertiseCallback() {
|
||||||
override fun onStartFailure(errorCode: Int) {
|
override fun onStartFailure(errorCode: Int) {
|
||||||
Log.warning("failed to start advertising: $errorCode")
|
Log.warning("failed to start advertising: $errorCode")
|
||||||
@@ -50,13 +50,12 @@ class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdap
|
|||||||
val advertiseData = AdvertiseData.Builder().build()
|
val advertiseData = AdvertiseData.Builder().build()
|
||||||
val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
|
val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
|
||||||
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
|
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
|
||||||
advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback)
|
|
||||||
|
|
||||||
val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel()
|
val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel()
|
||||||
viewModel.l2capPsm = serverSocket.psm
|
viewModel.l2capPsm = serverSocket.psm
|
||||||
Log.info("psm = $serverSocket.psm")
|
Log.info("psm = $serverSocket.psm")
|
||||||
|
|
||||||
val server = SocketServer(viewModel, serverSocket)
|
val server = SocketServer(viewModel, serverSocket)
|
||||||
server.run({ advertiser.stopAdvertising(callback) })
|
server.run({ advertiser.stopAdvertising(callback) }, { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,23 +26,33 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
@@ -171,7 +181,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun runL2capClient() {
|
private fun runL2capClient() {
|
||||||
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it) }
|
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it, baseContext) }
|
||||||
l2capClient?.run()
|
l2capClient?.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,9 +209,12 @@ fun MainView(
|
|||||||
runL2capServer: () -> Unit
|
runL2capServer: () -> Unit
|
||||||
) {
|
) {
|
||||||
BTBenchTheme {
|
BTBenchTheme {
|
||||||
// A surface container using the 'background' color from the theme
|
val scrollState = rememberScrollState()
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -212,28 +225,33 @@ fun MainView(
|
|||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
TextField(label = {
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
TextField(
|
||||||
|
label = {
|
||||||
Text(text = "Peer Bluetooth Address")
|
Text(text = "Peer Bluetooth Address")
|
||||||
},
|
},
|
||||||
value = appViewModel.peerBluetoothAddress,
|
value = appViewModel.peerBluetoothAddress,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
|
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
|
||||||
),
|
),
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
appViewModel.updatePeerBluetoothAddress(it)
|
appViewModel.updatePeerBluetoothAddress(it)
|
||||||
},
|
},
|
||||||
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
|
keyboardController?.hide()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
})
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
TextField(label = {
|
TextField(label = {
|
||||||
Text(text = "L2CAP PSM")
|
Text(text = "L2CAP PSM")
|
||||||
},
|
},
|
||||||
value = appViewModel.l2capPsm.toString(),
|
value = appViewModel.l2capPsm.toString(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
keyboardType = KeyboardType.Number,
|
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
|
||||||
imeAction = ImeAction.Done
|
|
||||||
),
|
),
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
if (it.isNotEmpty()) {
|
if (it.isNotEmpty()) {
|
||||||
@@ -243,7 +261,11 @@ fun MainView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }))
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
|
keyboardController?.hide()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
})
|
||||||
|
)
|
||||||
Divider()
|
Divider()
|
||||||
Slider(
|
Slider(
|
||||||
value = appViewModel.senderPacketCountSlider, onValueChange = {
|
value = appViewModel.senderPacketCountSlider, onValueChange = {
|
||||||
@@ -264,7 +286,19 @@ fun MainView(
|
|||||||
ActionButton(
|
ActionButton(
|
||||||
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
||||||
)
|
)
|
||||||
Row() {
|
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(
|
ActionButton(
|
||||||
text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
|
text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
|
||||||
)
|
)
|
||||||
@@ -272,7 +306,7 @@ fun MainView(
|
|||||||
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
|
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row() {
|
Row {
|
||||||
ActionButton(
|
ActionButton(
|
||||||
text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
|
text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
|
||||||
)
|
)
|
||||||
@@ -281,6 +315,12 @@ fun MainView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Divider()
|
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(
|
||||||
text = "Packets Sent: ${appViewModel.packetsSent}"
|
text = "Packets Sent: ${appViewModel.packetsSent}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,15 +23,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
val DEFAULT_RFCOMM_UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
|
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_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||||
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
||||||
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
||||||
|
const val DEFAULT_PSM = 128
|
||||||
|
|
||||||
class AppViewModel : ViewModel() {
|
class AppViewModel : ViewModel() {
|
||||||
private var preferences: SharedPreferences? = null
|
private var preferences: SharedPreferences? = null
|
||||||
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
||||||
var l2capPsm by mutableStateOf(0)
|
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 senderPacketCountSlider by mutableFloatStateOf(0.0F)
|
||||||
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
|
||||||
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
|
||||||
@@ -64,28 +69,29 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
|
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
|
||||||
this.peerBluetoothAddress = peerBluetoothAddress
|
val address = peerBluetoothAddress.uppercase()
|
||||||
|
this.peerBluetoothAddress = address
|
||||||
|
|
||||||
// Save the address to the preferences
|
// Save the address to the preferences
|
||||||
with(preferences!!.edit()) {
|
with(preferences!!.edit()) {
|
||||||
putString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, peerBluetoothAddress)
|
putString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, address)
|
||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSenderPacketCountSlider() {
|
fun updateSenderPacketCountSlider() {
|
||||||
if (senderPacketCount <= 10) {
|
senderPacketCountSlider = if (senderPacketCount <= 10) {
|
||||||
senderPacketCountSlider = 0.0F
|
0.0F
|
||||||
} else if (senderPacketCount <= 50) {
|
} else if (senderPacketCount <= 50) {
|
||||||
senderPacketCountSlider = 0.2F
|
0.2F
|
||||||
} else if (senderPacketCount <= 100) {
|
} else if (senderPacketCount <= 100) {
|
||||||
senderPacketCountSlider = 0.4F
|
0.4F
|
||||||
} else if (senderPacketCount <= 500) {
|
} else if (senderPacketCount <= 500) {
|
||||||
senderPacketCountSlider = 0.6F
|
0.6F
|
||||||
} else if (senderPacketCount <= 1000) {
|
} else if (senderPacketCount <= 1000) {
|
||||||
senderPacketCountSlider = 0.8F
|
0.8F
|
||||||
} else {
|
} else {
|
||||||
senderPacketCountSlider = 1.0F
|
1.0F
|
||||||
}
|
}
|
||||||
|
|
||||||
with(preferences!!.edit()) {
|
with(preferences!!.edit()) {
|
||||||
@@ -95,18 +101,18 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateSenderPacketCount() {
|
fun updateSenderPacketCount() {
|
||||||
if (senderPacketCountSlider < 0.1F) {
|
senderPacketCount = if (senderPacketCountSlider < 0.1F) {
|
||||||
senderPacketCount = 10
|
10
|
||||||
} else if (senderPacketCountSlider < 0.3F) {
|
} else if (senderPacketCountSlider < 0.3F) {
|
||||||
senderPacketCount = 50
|
50
|
||||||
} else if (senderPacketCountSlider < 0.5F) {
|
} else if (senderPacketCountSlider < 0.5F) {
|
||||||
senderPacketCount = 100
|
100
|
||||||
} else if (senderPacketCountSlider < 0.7F) {
|
} else if (senderPacketCountSlider < 0.7F) {
|
||||||
senderPacketCount = 500
|
500
|
||||||
} else if (senderPacketCountSlider < 0.9F) {
|
} else if (senderPacketCountSlider < 0.9F) {
|
||||||
senderPacketCount = 1000
|
1000
|
||||||
} else {
|
} else {
|
||||||
senderPacketCount = 10000
|
10000
|
||||||
}
|
}
|
||||||
|
|
||||||
with(preferences!!.edit()) {
|
with(preferences!!.edit()) {
|
||||||
@@ -116,18 +122,18 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateSenderPacketSizeSlider() {
|
fun updateSenderPacketSizeSlider() {
|
||||||
if (senderPacketSize <= 1) {
|
senderPacketSizeSlider = if (senderPacketSize <= 16) {
|
||||||
senderPacketSizeSlider = 0.0F
|
0.0F
|
||||||
} else if (senderPacketSize <= 256) {
|
} else if (senderPacketSize <= 256) {
|
||||||
senderPacketSizeSlider = 0.02F
|
0.02F
|
||||||
} else if (senderPacketSize <= 512) {
|
} else if (senderPacketSize <= 512) {
|
||||||
senderPacketSizeSlider = 0.4F
|
0.4F
|
||||||
} else if (senderPacketSize <= 1024) {
|
} else if (senderPacketSize <= 1024) {
|
||||||
senderPacketSizeSlider = 0.6F
|
0.6F
|
||||||
} else if (senderPacketSize <= 2048) {
|
} else if (senderPacketSize <= 2048) {
|
||||||
senderPacketSizeSlider = 0.8F
|
0.8F
|
||||||
} else {
|
} else {
|
||||||
senderPacketSizeSlider = 1.0F
|
1.0F
|
||||||
}
|
}
|
||||||
|
|
||||||
with(preferences!!.edit()) {
|
with(preferences!!.edit()) {
|
||||||
@@ -137,18 +143,18 @@ class AppViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateSenderPacketSize() {
|
fun updateSenderPacketSize() {
|
||||||
if (senderPacketSizeSlider < 0.1F) {
|
senderPacketSize = if (senderPacketSizeSlider < 0.1F) {
|
||||||
senderPacketSize = 1
|
16
|
||||||
} else if (senderPacketSizeSlider < 0.3F) {
|
} else if (senderPacketSizeSlider < 0.3F) {
|
||||||
senderPacketSize = 256
|
256
|
||||||
} else if (senderPacketSizeSlider < 0.5F) {
|
} else if (senderPacketSizeSlider < 0.5F) {
|
||||||
senderPacketSize = 512
|
512
|
||||||
} else if (senderPacketSizeSlider < 0.7F) {
|
} else if (senderPacketSizeSlider < 0.7F) {
|
||||||
senderPacketSize = 1024
|
1024
|
||||||
} else if (senderPacketSizeSlider < 0.9F) {
|
} else if (senderPacketSizeSlider < 0.9F) {
|
||||||
senderPacketSize = 2048
|
2048
|
||||||
} else {
|
} else {
|
||||||
senderPacketSize = 4096
|
4096
|
||||||
}
|
}
|
||||||
|
|
||||||
with(preferences!!.edit()) {
|
with(preferences!!.edit()) {
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ private val Log = Logger.getLogger("btbench.rfcomm-client")
|
|||||||
class RfcommClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
class RfcommClient(private val viewModel: AppViewModel, val bluetoothAdapter: BluetoothAdapter) {
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
fun run() {
|
||||||
val remoteDevice = bluetoothAdapter.getRemoteDevice(viewModel.peerBluetoothAddress)
|
val address = viewModel.peerBluetoothAddress.take(17)
|
||||||
|
val remoteDevice = bluetoothAdapter.getRemoteDevice(address)
|
||||||
val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
|
val socket = remoteDevice.createInsecureRfcommSocketToServiceRecord(
|
||||||
DEFAULT_RFCOMM_UUID
|
DEFAULT_RFCOMM_UUID
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,6 @@ class RfcommServer(private val viewModel: AppViewModel, val bluetoothAdapter: Bl
|
|||||||
)
|
)
|
||||||
|
|
||||||
val server = SocketServer(viewModel, serverSocket)
|
val server = SocketServer(viewModel, serverSocket)
|
||||||
server.run({})
|
server.run({}, {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,8 @@ import kotlin.concurrent.thread
|
|||||||
|
|
||||||
private val Log = Logger.getLogger("btbench.socket-client")
|
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) {
|
class SocketClient(private val viewModel: AppViewModel, private val socket: BluetoothSocket) {
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun run() {
|
fun run() {
|
||||||
@@ -56,6 +58,10 @@ class SocketClient(private val viewModel: AppViewModel, private val socket: Blue
|
|||||||
socketDataSource.receive()
|
socketDataSource.receive()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.info("Startup delay: $DEFAULT_STARTUP_DELAY")
|
||||||
|
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||||
|
Log.info("Starting to send")
|
||||||
|
|
||||||
sender.run()
|
sender.run()
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,13 @@ import kotlin.concurrent.thread
|
|||||||
private val Log = Logger.getLogger("btbench.socket-server")
|
private val Log = Logger.getLogger("btbench.socket-server")
|
||||||
|
|
||||||
class SocketServer(private val viewModel: AppViewModel, private val serverSocket: BluetoothServerSocket) {
|
class SocketServer(private val viewModel: AppViewModel, private val serverSocket: BluetoothServerSocket) {
|
||||||
fun run(onTerminate: () -> Unit) {
|
fun run(onConnected: () -> Unit, onDisconnected: () -> Unit) {
|
||||||
var aborted = false
|
var aborted = false
|
||||||
viewModel.running = true
|
viewModel.running = true
|
||||||
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
serverSocket.close()
|
serverSocket.close()
|
||||||
viewModel.running = false
|
viewModel.running = false
|
||||||
onTerminate()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
thread(name = "SocketServer") {
|
thread(name = "SocketServer") {
|
||||||
@@ -38,6 +37,7 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
|||||||
serverSocket.close()
|
serverSocket.close()
|
||||||
}
|
}
|
||||||
Log.info("waiting for connection...")
|
Log.info("waiting for connection...")
|
||||||
|
onDisconnected()
|
||||||
val socket = try {
|
val socket = try {
|
||||||
serverSocket.accept()
|
serverSocket.accept()
|
||||||
} catch (error: IOException) {
|
} catch (error: IOException) {
|
||||||
@@ -45,7 +45,8 @@ class SocketServer(private val viewModel: AppViewModel, private val serverSocket
|
|||||||
cleanup()
|
cleanup()
|
||||||
return@thread
|
return@thread
|
||||||
}
|
}
|
||||||
Log.info("got connection")
|
Log.info("got connection from ${socket.remoteDevice.address}")
|
||||||
|
onConnected()
|
||||||
|
|
||||||
viewModel.aborter = {
|
viewModel.aborter = {
|
||||||
aborted = true
|
aborted = true
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.github.google.bumble.remotehci"
|
applicationId = "com.github.google.bumble.remotehci"
|
||||||
minSdk = 26
|
minSdk = 29
|
||||||
targetSdk = 33
|
targetSdk = 33
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.hardware.bluetooth.V1_0.Status;
|
|||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.os.RemoteException;
|
import android.os.RemoteException;
|
||||||
import android.os.ServiceManager;
|
import android.os.ServiceManager;
|
||||||
|
import android.os.Trace;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -53,6 +54,7 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
|
|||||||
private final android.hardware.bluetooth.V1_0.IBluetoothHci mHciService;
|
private final android.hardware.bluetooth.V1_0.IBluetoothHci mHciService;
|
||||||
private final HciHalCallback mHciCallbacks;
|
private final HciHalCallback mHciCallbacks;
|
||||||
private int mInitializationStatus = -1;
|
private int mInitializationStatus = -1;
|
||||||
|
private final boolean mTracingEnabled = Trace.isEnabled();
|
||||||
|
|
||||||
|
|
||||||
public static HciHidlHal create(HciHalCallback hciCallbacks) {
|
public static HciHidlHal create(HciHalCallback hciCallbacks) {
|
||||||
@@ -89,6 +91,7 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map the status code.
|
// Map the status code.
|
||||||
|
Log.d(TAG, "Initialization status = " + mInitializationStatus);
|
||||||
switch (mInitializationStatus) {
|
switch (mInitializationStatus) {
|
||||||
case android.hardware.bluetooth.V1_0.Status.SUCCESS:
|
case android.hardware.bluetooth.V1_0.Status.SUCCESS:
|
||||||
return Status.SUCCESS;
|
return Status.SUCCESS;
|
||||||
@@ -108,6 +111,10 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
|
|||||||
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
||||||
ArrayList<Byte> data = HciPacket.byteArrayToList(packet);
|
ArrayList<Byte> data = HciPacket.byteArrayToList(packet);
|
||||||
|
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.beginAsyncSection("SEND_PACKET_TO_HAL", 1);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case COMMAND:
|
case COMMAND:
|
||||||
@@ -125,6 +132,10 @@ class HciHidlHal extends android.hardware.bluetooth.V1_0.IBluetoothHciCallbacks.
|
|||||||
} catch (RemoteException error) {
|
} catch (RemoteException error) {
|
||||||
Log.w(TAG, "failed to forward packet: " + error);
|
Log.w(TAG, "failed to forward packet: " + error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.endAsyncSection("SEND_PACKET_TO_HAL", 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -157,6 +168,7 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
|
|||||||
private final android.hardware.bluetooth.IBluetoothHci mHciService;
|
private final android.hardware.bluetooth.IBluetoothHci mHciService;
|
||||||
private final HciHalCallback mHciCallbacks;
|
private final HciHalCallback mHciCallbacks;
|
||||||
private int mInitializationStatus = android.hardware.bluetooth.Status.SUCCESS;
|
private int mInitializationStatus = android.hardware.bluetooth.Status.SUCCESS;
|
||||||
|
private final boolean mTracingEnabled = Trace.isEnabled();
|
||||||
|
|
||||||
public static HciAidlHal create(HciHalCallback hciCallbacks) {
|
public static HciAidlHal create(HciHalCallback hciCallbacks) {
|
||||||
IBinder binder = ServiceManager.getService("android.hardware.bluetooth.IBluetoothHci/default");
|
IBinder binder = ServiceManager.getService("android.hardware.bluetooth.IBluetoothHci/default");
|
||||||
@@ -187,6 +199,7 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map the status code.
|
// Map the status code.
|
||||||
|
Log.d(TAG, "Initialization status = " + mInitializationStatus);
|
||||||
switch (mInitializationStatus) {
|
switch (mInitializationStatus) {
|
||||||
case android.hardware.bluetooth.Status.SUCCESS:
|
case android.hardware.bluetooth.Status.SUCCESS:
|
||||||
return Status.SUCCESS;
|
return Status.SUCCESS;
|
||||||
@@ -208,6 +221,10 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
|
|||||||
// HciHal methods.
|
// HciHal methods.
|
||||||
@Override
|
@Override
|
||||||
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.beginAsyncSection("SEND_PACKET_TO_HAL", 1);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case COMMAND:
|
case COMMAND:
|
||||||
@@ -229,6 +246,10 @@ class HciAidlHal extends android.hardware.bluetooth.IBluetoothHciCallbacks.Stub
|
|||||||
} catch (RemoteException error) {
|
} catch (RemoteException error) {
|
||||||
Log.w(TAG, "failed to forward packet: " + error);
|
Log.w(TAG, "failed to forward packet: " + error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.endAsyncSection("SEND_PACKET_TO_HAL", 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IBluetoothHciCallbacks methods.
|
// IBluetoothHciCallbacks methods.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.github.google.bumble.remotehci;
|
package com.github.google.bumble.remotehci;
|
||||||
|
|
||||||
|
import android.os.Trace;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -15,6 +16,7 @@ public class HciServer {
|
|||||||
private final int mPort;
|
private final int mPort;
|
||||||
private final Listener mListener;
|
private final Listener mListener;
|
||||||
private OutputStream mOutputStream;
|
private OutputStream mOutputStream;
|
||||||
|
private final boolean mTracingEnabled = Trace.isEnabled();
|
||||||
|
|
||||||
public interface Listener extends HciParser.Sink {
|
public interface Listener extends HciParser.Sink {
|
||||||
void onHostConnectionState(boolean connected);
|
void onHostConnectionState(boolean connected);
|
||||||
@@ -27,6 +29,8 @@ public class HciServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void run() throws IOException {
|
public void run() throws IOException {
|
||||||
|
Log.i(TAG, "Tracing enabled: " + mTracingEnabled);
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
try {
|
try {
|
||||||
loop();
|
loop();
|
||||||
@@ -42,6 +46,7 @@ public class HciServer {
|
|||||||
try (ServerSocket serverSocket = new ServerSocket(mPort)) {
|
try (ServerSocket serverSocket = new ServerSocket(mPort)) {
|
||||||
mListener.onMessage("Waiting for connection on port " + serverSocket.getLocalPort());
|
mListener.onMessage("Waiting for connection on port " + serverSocket.getLocalPort());
|
||||||
try (Socket clientSocket = serverSocket.accept()) {
|
try (Socket clientSocket = serverSocket.accept()) {
|
||||||
|
clientSocket.setTcpNoDelay(true);
|
||||||
mListener.onHostConnectionState(true);
|
mListener.onHostConnectionState(true);
|
||||||
mListener.onMessage("Connected");
|
mListener.onMessage("Connected");
|
||||||
HciParser parser = new HciParser(mListener);
|
HciParser parser = new HciParser(mListener);
|
||||||
@@ -72,6 +77,10 @@ public class HciServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
public void sendPacket(HciPacket.Type type, byte[] packet) {
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.beginAsyncSection("SEND_PACKET_FROM_HAL", 2);
|
||||||
|
}
|
||||||
|
|
||||||
// Create a combined data buffer so we can write it out in a single call.
|
// Create a combined data buffer so we can write it out in a single call.
|
||||||
byte[] data = new byte[packet.length + 1];
|
byte[] data = new byte[packet.length + 1];
|
||||||
data[0] = type.value;
|
data[0] = type.value;
|
||||||
@@ -88,5 +97,9 @@ public class HciServer {
|
|||||||
Log.d(TAG, "no client, dropping packet");
|
Log.d(TAG, "no client, dropping packet");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mTracingEnabled) {
|
||||||
|
Trace.endAsyncSection("SEND_PACKET_FROM_HAL", 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -138,7 +140,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
log.warning("Exception while running HCI Server: $error")
|
log.warning("Exception while running HCI Server: $error")
|
||||||
} catch (error: HalException) {
|
} catch (error: HalException) {
|
||||||
log.warning("HAL exception: ${error.message}")
|
log.warning("HAL exception: ${error.message}")
|
||||||
appViewModel.message = "Cannot bind to HAL (${error.message}). You may need to use the command 'setenforce 0' in a root adb shell."
|
appViewModel.message =
|
||||||
|
"Cannot bind to HAL (${error.message}). You may need to use the command 'setenforce 0' in a root adb shell."
|
||||||
}
|
}
|
||||||
log.info("HCI Proxy thread ended")
|
log.info("HCI Proxy thread ended")
|
||||||
appViewModel.canStart = true
|
appViewModel.canStart = true
|
||||||
@@ -157,9 +160,12 @@ fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainView(appViewModel: AppViewModel, startProxy: () -> Unit) {
|
fun MainView(appViewModel: AppViewModel, startProxy: () -> Unit) {
|
||||||
RemoteHCITheme {
|
RemoteHCITheme {
|
||||||
// A surface container using the 'background' color from the theme
|
val scrollState = rememberScrollState()
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -174,13 +180,15 @@ fun MainView(appViewModel: AppViewModel, startProxy: () -> Unit) {
|
|||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
TextField(
|
TextField(label = {
|
||||||
label = {
|
|
||||||
Text(text = "TCP Port")
|
Text(text = "TCP Port")
|
||||||
},
|
},
|
||||||
value = appViewModel.tcpPort.toString(),
|
value = appViewModel.tcpPort.toString(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
keyboardOptions = KeyboardOptions.Default.copy(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
if (it.isNotEmpty()) {
|
if (it.isNotEmpty()) {
|
||||||
val tcpPort = it.toIntOrNull()
|
val tcpPort = it.toIntOrNull()
|
||||||
@@ -189,10 +197,7 @@ fun MainView(appViewModel: AppViewModel, startProxy: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }))
|
||||||
onDone = {keyboardController?.hide()}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Divider()
|
Divider()
|
||||||
val connectState = if (appViewModel.hostConnected) "CONNECTED" else "DISCONNECTED"
|
val connectState = if (appViewModel.hostConnected) "CONNECTED" else "DISCONNECTED"
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# Next
|
# 0.2.0
|
||||||
|
|
||||||
- Code-gen company ID table
|
- Code-gen company ID table
|
||||||
|
- Unstable support for extended advertisements
|
||||||
|
- CLI tools for downloading Realtek firmware
|
||||||
|
- PDL-generated types for HCI commands
|
||||||
|
|
||||||
# 0.1.0
|
# 0.1.0
|
||||||
|
|
||||||
|
|||||||
10
rust/Cargo.lock
generated
10
rust/Cargo.lock
generated
@@ -182,7 +182,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumble"
|
name = "bumble"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1073,9 +1073,9 @@ checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.57"
|
version = "0.10.60"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c"
|
checksum = "79a4c6c3a2b158f7f8f2a2fc5a969fa3a068df6fc9dbb4a43845436e3af7c800"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.4.0",
|
"bitflags 2.4.0",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -1105,9 +1105,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.92"
|
version = "0.9.96"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "db7e971c2c2bba161b2d2fdf37080177eff520b3bc044787c7f1f5f9e78d869b"
|
checksum = "3812c071ba60da8b5677cc12bcb1d42989a65553772897a7e0355545a819838f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bumble"
|
name = "bumble"
|
||||||
description = "Rust API for the Bumble Bluetooth stack"
|
description = "Rust API for the Bumble Bluetooth stack"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
homepage = "https://google.github.io/bumble/index.html"
|
homepage = "https://google.github.io/bumble/index.html"
|
||||||
@@ -10,7 +10,7 @@ documentation = "https://docs.rs/crate/bumble"
|
|||||||
authors = ["Marshall Pierce <marshallpierce@google.com>"]
|
authors = ["Marshall Pierce <marshallpierce@google.com>"]
|
||||||
keywords = ["bluetooth", "ble"]
|
keywords = ["bluetooth", "ble"]
|
||||||
categories = ["api-bindings", "network-programming"]
|
categories = ["api-bindings", "network-programming"]
|
||||||
rust-version = "1.70.0"
|
rust-version = "1.76.0"
|
||||||
|
|
||||||
# https://github.com/frewsxcv/cargo-all-features#options
|
# https://github.com/frewsxcv/cargo-all-features#options
|
||||||
[package.metadata.cargo-all-features]
|
[package.metadata.cargo-all-features]
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ PYTHONPATH=..:[virtualenv site-packages] \
|
|||||||
cargo run --features bumble-tools --bin bumble -- --help
|
cargo run --features bumble-tools --bin bumble -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Notable subcommands:
|
||||||
|
|
||||||
|
- `firmware realtek download`: download Realtek firmware for various chipsets so that it can be automatically loaded when needed
|
||||||
|
- `usb probe`: show USB devices, highlighting the ones usable for Bluetooth
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
Run the tests:
|
Run the tests:
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ impl Controller {
|
|||||||
/// module specifies the defaults. Must be called from a thread with a Python event loop, which
|
/// module specifies the defaults. Must be called from a thread with a Python event loop, which
|
||||||
/// should be true on `tokio::main` and `async_std::main`.
|
/// should be true on `tokio::main` and `async_std::main`.
|
||||||
///
|
///
|
||||||
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars.
|
/// For more info, see <https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars>.
|
||||||
pub async fn new(
|
pub async fn new(
|
||||||
name: &str,
|
name: &str,
|
||||||
host_source: Option<TransportSource>,
|
host_source: Option<TransportSource>,
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ impl ToPyObject for Address {
|
|||||||
|
|
||||||
/// An error meaning that the u64 value did not represent a valid BT address.
|
/// An error meaning that the u64 value did not represent a valid BT address.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct InvalidAddress(u64);
|
pub struct InvalidAddress(#[allow(unused)] u64);
|
||||||
|
|
||||||
impl TryInto<packets::Address> for Address {
|
impl TryInto<packets::Address> for Address {
|
||||||
type Error = ConversionError<InvalidAddress>;
|
type Error = ConversionError<InvalidAddress>;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ impl LeConnectionOrientedChannel {
|
|||||||
/// Must be called from a thread with a Python event loop, which should be true on
|
/// Must be called from a thread with a Python event loop, which should be true on
|
||||||
/// `tokio::main` and `async_std::main`.
|
/// `tokio::main` and `async_std::main`.
|
||||||
///
|
///
|
||||||
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars.
|
/// For more info, see <https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars>.
|
||||||
pub async fn disconnect(&mut self) -> PyResult<()> {
|
pub async fn disconnect(&mut self) -> PyResult<()> {
|
||||||
Python::with_gil(|py| {
|
Python::with_gil(|py| {
|
||||||
self.0
|
self.0
|
||||||
|
|||||||
14
setup.cfg
14
setup.cfg
@@ -52,12 +52,14 @@ install_requires =
|
|||||||
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
|
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
|
||||||
pyserial >= 3.5; platform_system!='Emscripten'
|
pyserial >= 3.5; platform_system!='Emscripten'
|
||||||
pyusb >= 1.2; platform_system!='Emscripten'
|
pyusb >= 1.2; platform_system!='Emscripten'
|
||||||
websockets >= 8.1; platform_system!='Emscripten'
|
websockets >= 12.0; platform_system!='Emscripten'
|
||||||
|
|
||||||
[options.entry_points]
|
[options.entry_points]
|
||||||
console_scripts =
|
console_scripts =
|
||||||
|
bumble-ble-rpa-tool = bumble.apps.ble_rpa_tool:main
|
||||||
bumble-console = bumble.apps.console:main
|
bumble-console = bumble.apps.console:main
|
||||||
bumble-controller-info = bumble.apps.controller_info:main
|
bumble-controller-info = bumble.apps.controller_info:main
|
||||||
|
bumble-controller-loopback = bumble.apps.controller_loopback:main
|
||||||
bumble-gatt-dump = bumble.apps.gatt_dump:main
|
bumble-gatt-dump = bumble.apps.gatt_dump:main
|
||||||
bumble-hci-bridge = bumble.apps.hci_bridge:main
|
bumble-hci-bridge = bumble.apps.hci_bridge:main
|
||||||
bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
|
bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
|
||||||
@@ -80,15 +82,15 @@ console_scripts =
|
|||||||
build =
|
build =
|
||||||
build >= 0.7
|
build >= 0.7
|
||||||
test =
|
test =
|
||||||
pytest >= 6.2
|
pytest >= 8.0
|
||||||
pytest-asyncio >= 0.17
|
pytest-asyncio == 0.21.1
|
||||||
pytest-html >= 3.2.0
|
pytest-html >= 3.2.0
|
||||||
coverage >= 6.4
|
coverage >= 6.4
|
||||||
development =
|
development =
|
||||||
black == 22.10
|
black == 22.10
|
||||||
grpcio-tools >= 1.57.0
|
grpcio-tools >= 1.57.0
|
||||||
invoke >= 1.7.3
|
invoke >= 1.7.3
|
||||||
mypy == 1.5.0
|
mypy == 1.8.0
|
||||||
nox >= 2022
|
nox >= 2022
|
||||||
pylint == 2.15.8
|
pylint == 2.15.8
|
||||||
pyyaml >= 6.0
|
pyyaml >= 6.0
|
||||||
@@ -96,8 +98,8 @@ development =
|
|||||||
types-invoke >= 1.7.3
|
types-invoke >= 1.7.3
|
||||||
types-protobuf >= 4.21.0
|
types-protobuf >= 4.21.0
|
||||||
avatar =
|
avatar =
|
||||||
pandora-avatar == 0.0.5
|
pandora-avatar == 0.0.8
|
||||||
rootcanal == 1.3.0 ; python_version>='3.10'
|
rootcanal == 1.9.0 ; python_version>='3.10'
|
||||||
documentation =
|
documentation =
|
||||||
mkdocs >= 1.4.0
|
mkdocs >= 1.4.0
|
||||||
mkdocs-material >= 8.5.6
|
mkdocs-material >= 8.5.6
|
||||||
|
|||||||
246
tests/avrcp_test.py
Normal file
246
tests/avrcp_test.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Copyright 2023 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import asyncio
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bumble import core
|
||||||
|
from bumble import device
|
||||||
|
from bumble import host
|
||||||
|
from bumble import controller
|
||||||
|
from bumble import link
|
||||||
|
from bumble import avc
|
||||||
|
from bumble import avrcp
|
||||||
|
from bumble import avctp
|
||||||
|
from bumble.transport import common
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class TwoDevices:
|
||||||
|
def __init__(self):
|
||||||
|
self.connections = [None, None]
|
||||||
|
|
||||||
|
addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
|
||||||
|
self.link = link.LocalLink()
|
||||||
|
self.controllers = [
|
||||||
|
controller.Controller('C1', link=self.link, public_address=addresses[0]),
|
||||||
|
controller.Controller('C2', link=self.link, public_address=addresses[1]),
|
||||||
|
]
|
||||||
|
self.devices = [
|
||||||
|
device.Device(
|
||||||
|
address=addresses[0],
|
||||||
|
host=host.Host(
|
||||||
|
self.controllers[0], common.AsyncPipeSink(self.controllers[0])
|
||||||
|
),
|
||||||
|
),
|
||||||
|
device.Device(
|
||||||
|
address=addresses[1],
|
||||||
|
host=host.Host(
|
||||||
|
self.controllers[1], common.AsyncPipeSink(self.controllers[1])
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.devices[0].classic_enabled = True
|
||||||
|
self.devices[1].classic_enabled = True
|
||||||
|
self.connections = [None, None]
|
||||||
|
self.protocols = [None, None]
|
||||||
|
|
||||||
|
def on_connection(self, which, connection):
|
||||||
|
self.connections[which] = connection
|
||||||
|
|
||||||
|
async def setup_connections(self):
|
||||||
|
await self.devices[0].power_on()
|
||||||
|
await self.devices[1].power_on()
|
||||||
|
|
||||||
|
self.connections = await asyncio.gather(
|
||||||
|
self.devices[0].connect(
|
||||||
|
self.devices[1].public_address, core.BT_BR_EDR_TRANSPORT
|
||||||
|
),
|
||||||
|
self.devices[1].accept(self.devices[0].public_address),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.protocols = [avrcp.Protocol(), avrcp.Protocol()]
|
||||||
|
self.protocols[0].listen(self.devices[1])
|
||||||
|
await self.protocols[1].connect(self.connections[0])
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_frame_parser():
|
||||||
|
with pytest.raises(ValueError) as error:
|
||||||
|
avc.Frame.from_bytes(bytes.fromhex("11480000"))
|
||||||
|
|
||||||
|
x = bytes.fromhex("014D0208")
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
assert frame.subunit_type == avc.Frame.SubunitType.PANEL
|
||||||
|
assert frame.subunit_id == 7
|
||||||
|
assert frame.opcode == 8
|
||||||
|
|
||||||
|
x = bytes.fromhex("014DFF0108")
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
assert frame.subunit_type == avc.Frame.SubunitType.PANEL
|
||||||
|
assert frame.subunit_id == 260
|
||||||
|
assert frame.opcode == 8
|
||||||
|
|
||||||
|
x = bytes.fromhex("0148000019581000000103")
|
||||||
|
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
|
||||||
|
assert isinstance(frame, avc.CommandFrame)
|
||||||
|
assert frame.ctype == avc.CommandFrame.CommandType.STATUS
|
||||||
|
assert frame.subunit_type == avc.Frame.SubunitType.PANEL
|
||||||
|
assert frame.subunit_id == 0
|
||||||
|
assert frame.opcode == 0
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_vendor_dependent_command():
|
||||||
|
x = bytes.fromhex("0148000019581000000103")
|
||||||
|
frame = avc.Frame.from_bytes(x)
|
||||||
|
assert isinstance(frame, avc.VendorDependentCommandFrame)
|
||||||
|
assert frame.company_id == 0x1958
|
||||||
|
assert frame.vendor_dependent_data == bytes.fromhex("1000000103")
|
||||||
|
|
||||||
|
frame = avc.VendorDependentCommandFrame(
|
||||||
|
avc.CommandFrame.CommandType.STATUS,
|
||||||
|
avc.Frame.SubunitType.PANEL,
|
||||||
|
0,
|
||||||
|
0x1958,
|
||||||
|
bytes.fromhex("1000000103"),
|
||||||
|
)
|
||||||
|
assert bytes(frame) == x
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_avctp_message_assembler():
|
||||||
|
received_message = []
|
||||||
|
|
||||||
|
def on_message(transaction_label, is_response, ipid, pid, payload):
|
||||||
|
received_message.append((transaction_label, is_response, ipid, pid, payload))
|
||||||
|
|
||||||
|
assembler = avctp.MessageAssembler(on_message)
|
||||||
|
|
||||||
|
payload = bytes.fromhex("01")
|
||||||
|
assembler.on_pdu(bytes([1 << 4 | 0b00 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
|
||||||
|
assert received_message
|
||||||
|
assert received_message[0] == (1, False, False, 0x1122, payload)
|
||||||
|
|
||||||
|
received_message = []
|
||||||
|
payload = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(bytes([1 << 4 | 0b01 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
|
||||||
|
assert len(received_message) == 0
|
||||||
|
assembler.on_pdu(bytes([1 << 4 | 0b00 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload)
|
||||||
|
assert received_message
|
||||||
|
assert received_message[0] == (1, False, False, 0x1122, payload)
|
||||||
|
|
||||||
|
received_message = []
|
||||||
|
payload = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(
|
||||||
|
bytes([1 << 4 | 0b01 << 2 | 1 << 1 | 0, 3, 0x11, 0x22]) + payload[0:1]
|
||||||
|
)
|
||||||
|
assembler.on_pdu(
|
||||||
|
bytes([1 << 4 | 0b10 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload[1:2]
|
||||||
|
)
|
||||||
|
assembler.on_pdu(
|
||||||
|
bytes([1 << 4 | 0b11 << 2 | 1 << 1 | 0, 0x11, 0x22]) + payload[2:3]
|
||||||
|
)
|
||||||
|
assert received_message
|
||||||
|
assert received_message[0] == (1, False, False, 0x1122, payload)
|
||||||
|
|
||||||
|
# received_message = []
|
||||||
|
# parameter = bytes.fromhex("010203")
|
||||||
|
# assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, len(parameter)) + parameter)
|
||||||
|
# assert len(received_message) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_avrcp_pdu_assembler():
|
||||||
|
received_pdus = []
|
||||||
|
|
||||||
|
def on_pdu(pdu_id, parameter):
|
||||||
|
received_pdus.append((pdu_id, parameter))
|
||||||
|
|
||||||
|
assembler = avrcp.PduAssembler(on_pdu)
|
||||||
|
|
||||||
|
parameter = bytes.fromhex("01")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b00, len(parameter)) + parameter)
|
||||||
|
assert received_pdus
|
||||||
|
assert received_pdus[0] == (0x10, parameter)
|
||||||
|
|
||||||
|
received_pdus = []
|
||||||
|
parameter = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b01, len(parameter)) + parameter)
|
||||||
|
assert len(received_pdus) == 0
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b00, len(parameter)) + parameter)
|
||||||
|
assert received_pdus
|
||||||
|
assert received_pdus[0] == (0x10, parameter)
|
||||||
|
|
||||||
|
received_pdus = []
|
||||||
|
parameter = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b01, 1) + parameter[0:1])
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b10, 1) + parameter[1:2])
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, 1) + parameter[2:3])
|
||||||
|
assert received_pdus
|
||||||
|
assert received_pdus[0] == (0x10, parameter)
|
||||||
|
|
||||||
|
received_pdus = []
|
||||||
|
parameter = bytes.fromhex("010203")
|
||||||
|
assembler.on_pdu(struct.pack(">BBH", 0x10, 0b11, len(parameter)) + parameter)
|
||||||
|
assert len(received_pdus) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_passthrough_commands():
|
||||||
|
play_pressed = avc.PassThroughCommandFrame(
|
||||||
|
avc.CommandFrame.CommandType.CONTROL,
|
||||||
|
avc.CommandFrame.SubunitType.PANEL,
|
||||||
|
0,
|
||||||
|
avc.PassThroughCommandFrame.StateFlag.PRESSED,
|
||||||
|
avc.PassThroughCommandFrame.OperationId.PLAY,
|
||||||
|
b'',
|
||||||
|
)
|
||||||
|
|
||||||
|
play_pressed_bytes = bytes(play_pressed)
|
||||||
|
parsed = avc.Frame.from_bytes(play_pressed_bytes)
|
||||||
|
assert isinstance(parsed, avc.PassThroughCommandFrame)
|
||||||
|
assert parsed.operation_id == avc.PassThroughCommandFrame.OperationId.PLAY
|
||||||
|
assert bytes(parsed) == play_pressed_bytes
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_supported_events():
|
||||||
|
two_devices = TwoDevices()
|
||||||
|
await two_devices.setup_connections()
|
||||||
|
|
||||||
|
supported_events = await two_devices.protocols[0].get_supported_events()
|
||||||
|
assert supported_events == []
|
||||||
|
|
||||||
|
delegate1 = avrcp.Delegate([avrcp.EventId.VOLUME_CHANGED])
|
||||||
|
two_devices.protocols[0].delegate = delegate1
|
||||||
|
supported_events = await two_devices.protocols[1].get_supported_events()
|
||||||
|
assert supported_events == [avrcp.EventId.VOLUME_CHANGED]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == '__main__':
|
||||||
|
test_frame_parser()
|
||||||
|
test_vendor_dependent_command()
|
||||||
|
test_avctp_message_assembler()
|
||||||
|
test_avrcp_pdu_assembler()
|
||||||
|
test_passthrough_commands()
|
||||||
|
test_get_supported_events()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user