mirror of
https://github.com/google/bumble.git
synced 2026-05-06 03:38:01 +00:00
Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27d02ef18d | ||
|
|
c0725e2a4a | ||
|
|
bf0784dde4 | ||
|
|
444f43f6a3 | ||
|
|
2420c47cf1 | ||
|
|
0a78e7506b | ||
|
|
f7cc6f6657 | ||
|
|
f2824ee6b8 | ||
|
|
7188ef08de | ||
|
|
3ded9014d3 | ||
|
|
b6125bdfb1 | ||
|
|
dc17f4f1ca | ||
|
|
3f65380c20 | ||
|
|
25a0056ecc | ||
|
|
85f6b10983 | ||
|
|
e85f041e9d | ||
|
|
ee09e6f10d | ||
|
|
c3daf4a7e1 | ||
|
|
3af623be7e | ||
|
|
4e76d3057b | ||
|
|
eda7360222 | ||
|
|
a4c15c00de | ||
|
|
cba4df4aef | ||
|
|
ceb8b448e9 | ||
|
|
311b716d5c | ||
|
|
0ba9e5c317 | ||
|
|
3517225b62 | ||
|
|
ad4bb1578b | ||
|
|
4af65b381b | ||
|
|
a5cd3365ae | ||
|
|
2915cb8bb6 | ||
|
|
28e485b7b3 | ||
|
|
1198f2c3f5 | ||
|
|
80aaf6a2b9 | ||
|
|
eb64debb62 | ||
|
|
c158f25b1e | ||
|
|
1330e83517 | ||
|
|
d9c9bea6cb | ||
|
|
3b937631b3 | ||
|
|
f8aa309111 | ||
|
|
673281ed71 | ||
|
|
3ac7af4683 | ||
|
|
5ebfaae74e | ||
|
|
e6175f85fe | ||
|
|
f9ba527508 | ||
|
|
a407c4cabf | ||
|
|
6c2d6dddb5 | ||
|
|
797cd216d4 | ||
|
|
e2e8c90e47 | ||
|
|
3d5648cdc3 | ||
|
|
d810d93aaf | ||
|
|
81d9adb983 | ||
|
|
377fa896f7 | ||
|
|
79e5974946 | ||
|
|
657451474e | ||
|
|
9f730dce6f | ||
|
|
1a6be95a7e | ||
|
|
aea5320d71 | ||
|
|
91cb1b1df3 | ||
|
|
81bdc86e52 | ||
|
|
f23cad34e3 | ||
|
|
30fde2c00b | ||
|
|
256a1a7405 | ||
|
|
116d9b26bb | ||
|
|
aabe2ca063 | ||
|
|
2d17a5f742 | ||
|
|
3894b14467 | ||
|
|
e62f947430 | ||
|
|
dcb8a4b607 | ||
|
|
81985c47a9 | ||
|
|
7118328b07 | ||
|
|
5dc01d792a | ||
|
|
255f357975 | ||
|
|
c86920558b | ||
|
|
8e6efd0b2f | ||
|
|
2a59e19283 | ||
|
|
34f5b81c7d | ||
|
|
d34d6a5c98 | ||
|
|
aedc971653 | ||
|
|
c6815fb820 | ||
|
|
f44d013690 | ||
|
|
e63dc15ede | ||
|
|
c901e15666 | ||
|
|
022323b19c | ||
|
|
a0d24e95e7 | ||
|
|
7efbd303e0 | ||
|
|
49530d8d6d | ||
|
|
85b78b46f8 | ||
|
|
3f9ef5aac2 | ||
|
|
e488ea9783 | ||
|
|
21d937c2f1 | ||
|
|
a8396e6cce | ||
|
|
7e1b1c8f78 | ||
|
|
55719bf6de | ||
|
|
5059920696 | ||
|
|
c577f17c99 | ||
|
|
252f3e49b6 | ||
|
|
f3ecf04479 | ||
|
|
4986f55043 | ||
|
|
7e89c8a7f8 | ||
|
|
085905a7bf | ||
|
|
7523118581 | ||
|
|
c619f1f21b | ||
|
|
d4b0da9265 | ||
|
|
f1058e4d4e | ||
|
|
454d477d7e | ||
|
|
6966228d74 | ||
|
|
f4271a5646 | ||
|
|
534209f0af | ||
|
|
549b82999a | ||
|
|
551f577b2a | ||
|
|
c69c1532cc | ||
|
|
f95b2054c8 | ||
|
|
84a6453dda | ||
|
|
3fdd7ee45e | ||
|
|
591ed61686 | ||
|
|
3d3acbb374 | ||
|
|
671f306a27 | ||
|
|
f7364db992 | ||
|
|
0fb2b3bd66 | ||
|
|
9e270d4d62 | ||
|
|
cf60b5ffbb | ||
|
|
aa4c57d105 | ||
|
|
61a601e6e2 | ||
|
|
05fd4fbfc6 | ||
|
|
2cad743f8c | ||
|
|
6aa9e0bdf7 | ||
|
|
255414f315 | ||
|
|
d2df76f6f4 | ||
|
|
884b1c20e4 | ||
|
|
91a2b4f676 | ||
|
|
5831f79d62 | ||
|
|
054dc70f3f |
2
.github/workflows/python-build-test.yml
vendored
2
.github/workflows/python-build-test.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
|||||||
components: clippy,rustfmt
|
components: clippy,rustfmt
|
||||||
toolchain: ${{ matrix.rust-version }}
|
toolchain: ${{ matrix.rust-version }}
|
||||||
- name: Install Rust dependencies
|
- name: Install Rust dependencies
|
||||||
run: cargo install cargo-all-features --version 1.11.0 # allows building/testing combinations of features
|
run: cargo install cargo-all-features --version 1.11.0 --locked # allows building/testing combinations of features
|
||||||
- name: Check License Headers
|
- name: Check License Headers
|
||||||
run: cd rust && cargo run --features dev-tools --bin file-header check-all
|
run: cd rust && cargo run --features dev-tools --bin file-header check-all
|
||||||
- name: Rust Build
|
- name: Rust Build
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ venv/
|
|||||||
.venv/
|
.venv/
|
||||||
# snoop logs
|
# snoop logs
|
||||||
out/
|
out/
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
._*
|
||||||
|
|||||||
@@ -24,13 +24,18 @@ import dataclasses
|
|||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
import sys
|
||||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Sequence
|
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Sequence
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
)
|
)
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import tomli
|
|
||||||
|
if sys.version_info >= (3, 11):
|
||||||
|
import tomllib
|
||||||
|
else:
|
||||||
|
import tomli as tomllib
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import lc3 # type: ignore # pylint: disable=E0401
|
import lc3 # type: ignore # pylint: disable=E0401
|
||||||
@@ -114,7 +119,7 @@ def parse_broadcast_list(filename: str) -> Sequence[Broadcast]:
|
|||||||
broadcasts: list[Broadcast] = []
|
broadcasts: list[Broadcast] = []
|
||||||
|
|
||||||
with open(filename, "rb") as config_file:
|
with open(filename, "rb") as config_file:
|
||||||
config = tomli.load(config_file)
|
config = tomllib.load(config_file)
|
||||||
for broadcast in config.get("broadcasts", []):
|
for broadcast in config.get("broadcasts", []):
|
||||||
sources = []
|
sources = []
|
||||||
for source in broadcast.get("sources", []):
|
for source in broadcast.get("sources", []):
|
||||||
|
|||||||
@@ -27,23 +27,17 @@ from bumble.core import name_or_number
|
|||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||||
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
|
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
|
||||||
HCI_LE_READ_MAXIMUM_ADVERTISING_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_MINIMUM_SUPPORTED_CONNECTION_INTERVAL_COMMAND,
|
||||||
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
||||||
HCI_READ_BD_ADDR_COMMAND,
|
HCI_READ_BD_ADDR_COMMAND,
|
||||||
HCI_READ_BUFFER_SIZE_COMMAND,
|
HCI_READ_BUFFER_SIZE_COMMAND,
|
||||||
HCI_READ_LOCAL_NAME_COMMAND,
|
HCI_READ_LOCAL_NAME_COMMAND,
|
||||||
HCI_SUCCESS,
|
|
||||||
CodecID,
|
|
||||||
HCI_Command,
|
HCI_Command,
|
||||||
HCI_Command_Complete_Event,
|
|
||||||
HCI_Command_Status_Event,
|
|
||||||
HCI_LE_Read_Buffer_Size_Command,
|
HCI_LE_Read_Buffer_Size_Command,
|
||||||
HCI_LE_Read_Buffer_Size_V2_Command,
|
HCI_LE_Read_Buffer_Size_V2_Command,
|
||||||
HCI_LE_Read_Maximum_Advertising_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_Minimum_Supported_Connection_Interval_Command,
|
||||||
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
||||||
HCI_Read_BD_ADDR_Command,
|
HCI_Read_BD_ADDR_Command,
|
||||||
HCI_Read_Buffer_Size_Command,
|
HCI_Read_Buffer_Size_Command,
|
||||||
@@ -59,85 +53,81 @@ from bumble.host import Host
|
|||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
def command_succeeded(response):
|
|
||||||
if isinstance(response, HCI_Command_Status_Event):
|
|
||||||
return response.status == HCI_SUCCESS
|
|
||||||
if isinstance(response, HCI_Command_Complete_Event):
|
|
||||||
return response.return_parameters.status == HCI_SUCCESS
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_classic_info(host: Host) -> None:
|
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())
|
response1 = await host.send_sync_command(HCI_Read_BD_ADDR_Command())
|
||||||
if command_succeeded(response):
|
print()
|
||||||
print()
|
print(
|
||||||
print(
|
color('Public Address:', 'yellow'),
|
||||||
color('Public Address:', 'yellow'),
|
response1.bd_addr.to_string(False),
|
||||||
response.return_parameters.bd_addr.to_string(False),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
|
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
|
||||||
response = await host.send_command(HCI_Read_Local_Name_Command())
|
response2 = await host.send_sync_command(HCI_Read_Local_Name_Command())
|
||||||
if command_succeeded(response):
|
print()
|
||||||
print()
|
print(
|
||||||
print(
|
color('Local Name:', 'yellow'),
|
||||||
color('Local Name:', 'yellow'),
|
map_null_terminated_utf8_string(response2.local_name),
|
||||||
map_null_terminated_utf8_string(response.return_parameters.local_name),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_le_info(host: Host) -> None:
|
async def get_le_info(host: Host) -> None:
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND):
|
print(
|
||||||
response = await host.send_command(
|
color('LE Number Of Supported Advertising Sets:', 'yellow'),
|
||||||
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command()
|
host.number_of_supported_advertising_sets,
|
||||||
)
|
'\n',
|
||||||
if command_succeeded(response):
|
)
|
||||||
print(
|
|
||||||
color('LE Number Of Supported Advertising Sets:', 'yellow'),
|
|
||||||
response.return_parameters.num_supported_advertising_sets,
|
|
||||||
'\n',
|
|
||||||
)
|
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND):
|
print(
|
||||||
response = await host.send_command(
|
color('LE Maximum Advertising Data Length:', 'yellow'),
|
||||||
HCI_LE_Read_Maximum_Advertising_Data_Length_Command()
|
host.maximum_advertising_data_length,
|
||||||
)
|
'\n',
|
||||||
if command_succeeded(response):
|
)
|
||||||
print(
|
|
||||||
color('LE Maximum Advertising Data Length:', 'yellow'),
|
|
||||||
response.return_parameters.max_advertising_data_length,
|
|
||||||
'\n',
|
|
||||||
)
|
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND):
|
if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND):
|
||||||
response = await host.send_command(HCI_LE_Read_Maximum_Data_Length_Command())
|
response1 = await host.send_sync_command(
|
||||||
if command_succeeded(response):
|
HCI_LE_Read_Maximum_Data_Length_Command()
|
||||||
print(
|
)
|
||||||
color('Maximum Data Length:', 'yellow'),
|
print(
|
||||||
(
|
color('LE Maximum Data Length:', 'yellow'),
|
||||||
f'tx:{response.return_parameters.supported_max_tx_octets}/'
|
(
|
||||||
f'{response.return_parameters.supported_max_tx_time}, '
|
f'tx:{response1.supported_max_tx_octets}/'
|
||||||
f'rx:{response.return_parameters.supported_max_rx_octets}/'
|
f'{response1.supported_max_tx_time}, '
|
||||||
f'{response.return_parameters.supported_max_rx_time}'
|
f'rx:{response1.supported_max_rx_octets}/'
|
||||||
),
|
f'{response1.supported_max_rx_time}'
|
||||||
'\n',
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
|
if host.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
|
||||||
response = await host.send_command(
|
response2 = await host.send_sync_command(
|
||||||
HCI_LE_Read_Suggested_Default_Data_Length_Command()
|
HCI_LE_Read_Suggested_Default_Data_Length_Command()
|
||||||
)
|
)
|
||||||
if command_succeeded(response):
|
print(
|
||||||
|
color('LE Suggested Default Data Length:', 'yellow'),
|
||||||
|
f'{response2.suggested_max_tx_octets}/'
|
||||||
|
f'{response2.suggested_max_tx_time}',
|
||||||
|
'\n',
|
||||||
|
)
|
||||||
|
|
||||||
|
if host.supports_command(HCI_LE_READ_MINIMUM_SUPPORTED_CONNECTION_INTERVAL_COMMAND):
|
||||||
|
response3 = await host.send_sync_command(
|
||||||
|
HCI_LE_Read_Minimum_Supported_Connection_Interval_Command()
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color('LE Minimum Supported Connection Interval:', 'yellow'),
|
||||||
|
f'{response3.minimum_supported_connection_interval * 125} µs',
|
||||||
|
)
|
||||||
|
for group in range(len(response3.group_min)):
|
||||||
print(
|
print(
|
||||||
color('Suggested Default Data Length:', 'yellow'),
|
f' Group {group}: '
|
||||||
f'{response.return_parameters.suggested_max_tx_octets}/'
|
f'{response3.group_min[group] * 125} µs to '
|
||||||
f'{response.return_parameters.suggested_max_tx_time}',
|
f'{response3.group_max[group] * 125} µs '
|
||||||
|
'by increments of '
|
||||||
|
f'{response3.group_stride[group] * 125} µs',
|
||||||
'\n',
|
'\n',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,37 +141,31 @@ async def get_flow_control_info(host: Host) -> None:
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
|
if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
|
||||||
response = await host.send_command(
|
response1 = await host.send_sync_command(HCI_Read_Buffer_Size_Command())
|
||||||
HCI_Read_Buffer_Size_Command(), check_result=True
|
|
||||||
)
|
|
||||||
print(
|
print(
|
||||||
color('ACL Flow Control:', 'yellow'),
|
color('ACL Flow Control:', 'yellow'),
|
||||||
f'{response.return_parameters.hc_total_num_acl_data_packets} '
|
f'{response1.hc_total_num_acl_data_packets} '
|
||||||
f'packets of size {response.return_parameters.hc_acl_data_packet_length}',
|
f'packets of size {response1.hc_acl_data_packet_length}',
|
||||||
)
|
)
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
||||||
response = await host.send_command(
|
response2 = await host.send_sync_command(HCI_LE_Read_Buffer_Size_V2_Command())
|
||||||
HCI_LE_Read_Buffer_Size_V2_Command(), check_result=True
|
|
||||||
)
|
|
||||||
print(
|
print(
|
||||||
color('LE ACL Flow Control:', 'yellow'),
|
color('LE ACL Flow Control:', 'yellow'),
|
||||||
f'{response.return_parameters.total_num_le_acl_data_packets} '
|
f'{response2.total_num_le_acl_data_packets} '
|
||||||
f'packets of size {response.return_parameters.le_acl_data_packet_length}',
|
f'packets of size {response2.le_acl_data_packet_length}',
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
color('LE ISO Flow Control:', 'yellow'),
|
color('LE ISO Flow Control:', 'yellow'),
|
||||||
f'{response.return_parameters.total_num_iso_data_packets} '
|
f'{response2.total_num_iso_data_packets} '
|
||||||
f'packets of size {response.return_parameters.iso_data_packet_length}',
|
f'packets of size {response2.iso_data_packet_length}',
|
||||||
)
|
)
|
||||||
elif host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
elif host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||||
response = await host.send_command(
|
response3 = await host.send_sync_command(HCI_LE_Read_Buffer_Size_Command())
|
||||||
HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
|
||||||
)
|
|
||||||
print(
|
print(
|
||||||
color('LE ACL Flow Control:', 'yellow'),
|
color('LE ACL Flow Control:', 'yellow'),
|
||||||
f'{response.return_parameters.total_num_le_acl_data_packets} '
|
f'{response3.total_num_le_acl_data_packets} '
|
||||||
f'packets of size {response.return_parameters.le_acl_data_packet_length}',
|
f'packets of size {response3.le_acl_data_packet_length}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -190,52 +174,44 @@ async def get_codecs_info(host: Host) -> None:
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
if host.supports_command(HCI_Read_Local_Supported_Codecs_V2_Command.op_code):
|
if host.supports_command(HCI_Read_Local_Supported_Codecs_V2_Command.op_code):
|
||||||
response = await host.send_command(
|
response1 = await host.send_sync_command(
|
||||||
HCI_Read_Local_Supported_Codecs_V2_Command(), check_result=True
|
HCI_Read_Local_Supported_Codecs_V2_Command()
|
||||||
)
|
)
|
||||||
print(color('Codecs:', 'yellow'))
|
print(color('Codecs:', 'yellow'))
|
||||||
|
|
||||||
for codec_id, transport in zip(
|
for codec_id, transport in zip(
|
||||||
response.return_parameters.standard_codec_ids,
|
response1.standard_codec_ids,
|
||||||
response.return_parameters.standard_codec_transports,
|
response1.standard_codec_transports,
|
||||||
):
|
):
|
||||||
transport_name = HCI_Read_Local_Supported_Codecs_V2_Command.Transport(
|
print(f' {codec_id.name} - {transport.name}')
|
||||||
transport
|
|
||||||
).name
|
|
||||||
codec_name = CodecID(codec_id).name
|
|
||||||
print(f' {codec_name} - {transport_name}')
|
|
||||||
|
|
||||||
for codec_id, transport in zip(
|
for vendor_codec_id, vendor_transport in zip(
|
||||||
response.return_parameters.vendor_specific_codec_ids,
|
response1.vendor_specific_codec_ids,
|
||||||
response.return_parameters.vendor_specific_codec_transports,
|
response1.vendor_specific_codec_transports,
|
||||||
):
|
):
|
||||||
transport_name = HCI_Read_Local_Supported_Codecs_V2_Command.Transport(
|
company = name_or_number(COMPANY_IDENTIFIERS, vendor_codec_id >> 16)
|
||||||
transport
|
print(f' {company} / {vendor_codec_id & 0xFFFF} - {vendor_transport.name}')
|
||||||
).name
|
|
||||||
company = name_or_number(COMPANY_IDENTIFIERS, codec_id >> 16)
|
|
||||||
print(f' {company} / {codec_id & 0xFFFF} - {transport_name}')
|
|
||||||
|
|
||||||
if not response.return_parameters.standard_codec_ids:
|
if not response1.standard_codec_ids:
|
||||||
print(' No standard codecs')
|
print(' No standard codecs')
|
||||||
if not response.return_parameters.vendor_specific_codec_ids:
|
if not response1.vendor_specific_codec_ids:
|
||||||
print(' No Vendor-specific codecs')
|
print(' No Vendor-specific codecs')
|
||||||
|
|
||||||
if host.supports_command(HCI_Read_Local_Supported_Codecs_Command.op_code):
|
if host.supports_command(HCI_Read_Local_Supported_Codecs_Command.op_code):
|
||||||
response = await host.send_command(
|
response2 = await host.send_sync_command(
|
||||||
HCI_Read_Local_Supported_Codecs_Command(), check_result=True
|
HCI_Read_Local_Supported_Codecs_Command()
|
||||||
)
|
)
|
||||||
print(color('Codecs (BR/EDR):', 'yellow'))
|
print(color('Codecs (BR/EDR):', 'yellow'))
|
||||||
for codec_id in response.return_parameters.standard_codec_ids:
|
for codec_id in response2.standard_codec_ids:
|
||||||
codec_name = CodecID(codec_id).name
|
print(f' {codec_id.name}')
|
||||||
print(f' {codec_name}')
|
|
||||||
|
|
||||||
for codec_id in response.return_parameters.vendor_specific_codec_ids:
|
for vendor_codec_id in response2.vendor_specific_codec_ids:
|
||||||
company = name_or_number(COMPANY_IDENTIFIERS, codec_id >> 16)
|
company = name_or_number(COMPANY_IDENTIFIERS, vendor_codec_id >> 16)
|
||||||
print(f' {company} / {codec_id & 0xFFFF}')
|
print(f' {company} / {vendor_codec_id & 0xFFFF}')
|
||||||
|
|
||||||
if not response.return_parameters.standard_codec_ids:
|
if not response2.standard_codec_ids:
|
||||||
print(' No standard codecs')
|
print(' No standard codecs')
|
||||||
if not response.return_parameters.vendor_specific_codec_ids:
|
if not response2.vendor_specific_codec_ids:
|
||||||
print(' No Vendor-specific codecs')
|
print(' No Vendor-specific codecs')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class Loopback:
|
|||||||
print(color('@@@ Received last packet', 'green'))
|
print(color('@@@ Received last packet', 'green'))
|
||||||
self.done.set()
|
self.done.set()
|
||||||
|
|
||||||
async def run(self):
|
async def run(self) -> None:
|
||||||
"""Run a loopback throughput test"""
|
"""Run a loopback throughput test"""
|
||||||
print(color('>>> Connecting to HCI...', 'green'))
|
print(color('>>> Connecting to HCI...', 'green'))
|
||||||
async with await open_transport(self.transport) as (
|
async with await open_transport(self.transport) as (
|
||||||
@@ -100,11 +100,15 @@ class Loopback:
|
|||||||
# make sure data can fit in one l2cap pdu
|
# make sure data can fit in one l2cap pdu
|
||||||
l2cap_header_size = 4
|
l2cap_header_size = 4
|
||||||
|
|
||||||
max_packet_size = (
|
packet_queue = (
|
||||||
host.acl_packet_queue
|
host.acl_packet_queue
|
||||||
if host.acl_packet_queue
|
if host.acl_packet_queue
|
||||||
else host.le_acl_packet_queue
|
else host.le_acl_packet_queue
|
||||||
).max_packet_size - l2cap_header_size
|
)
|
||||||
|
if packet_queue is None:
|
||||||
|
print(color('!!! No packet queue', 'red'))
|
||||||
|
return
|
||||||
|
max_packet_size = packet_queue.max_packet_size - l2cap_header_size
|
||||||
if self.packet_size > max_packet_size:
|
if self.packet_size > max_packet_size:
|
||||||
print(
|
print(
|
||||||
color(
|
color(
|
||||||
@@ -128,20 +132,18 @@ class Loopback:
|
|||||||
loopback_mode = LoopbackMode.LOCAL
|
loopback_mode = LoopbackMode.LOCAL
|
||||||
|
|
||||||
print(color('### Setting loopback mode', 'blue'))
|
print(color('### Setting loopback mode', 'blue'))
|
||||||
await host.send_command(
|
await host.send_sync_command(
|
||||||
HCI_Write_Loopback_Mode_Command(loopback_mode=LoopbackMode.LOCAL),
|
HCI_Write_Loopback_Mode_Command(loopback_mode=LoopbackMode.LOCAL),
|
||||||
check_result=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
print(color('### Checking loopback mode', 'blue'))
|
print(color('### Checking loopback mode', 'blue'))
|
||||||
response = await host.send_command(
|
response = await host.send_sync_command(HCI_Read_Loopback_Mode_Command())
|
||||||
HCI_Read_Loopback_Mode_Command(), check_result=True
|
if response.loopback_mode != loopback_mode:
|
||||||
)
|
|
||||||
if response.return_parameters.loopback_mode != loopback_mode:
|
|
||||||
print(color('!!! Loopback mode mismatch', 'red'))
|
print(color('!!! Loopback mode mismatch', 'red'))
|
||||||
return
|
return
|
||||||
|
|
||||||
await self.connection_event.wait()
|
await self.connection_event.wait()
|
||||||
|
assert self.connection_handle is not None
|
||||||
print(color('### Connected', 'cyan'))
|
print(color('### Connected', 'cyan'))
|
||||||
|
|
||||||
print(color('=== Start sending', 'magenta'))
|
print(color('=== Start sending', 'magenta'))
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ async def run(
|
|||||||
await bridge.start()
|
await bridge.start()
|
||||||
|
|
||||||
# Wait until the source terminates
|
# Wait until the source terminates
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ async def async_main():
|
|||||||
response = hci.HCI_Command_Complete_Event(
|
response = hci.HCI_Command_Complete_Event(
|
||||||
num_hci_command_packets=1,
|
num_hci_command_packets=1,
|
||||||
command_opcode=hci_packet.op_code,
|
command_opcode=hci_packet.op_code,
|
||||||
return_parameters=bytes([hci.HCI_SUCCESS]),
|
return_parameters=hci.HCI_StatusReturnParameters(
|
||||||
|
status=hci.HCI_ErrorCode.SUCCESS
|
||||||
|
),
|
||||||
)
|
)
|
||||||
# Return a packet with 'respond to sender' set to True
|
# Return a packet with 'respond to sender' set to True
|
||||||
return (bytes(response), True)
|
return (bytes(response), True)
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ async def run(device_config, hci_transport, bridge):
|
|||||||
await bridge.start(device)
|
await bridge.start(device)
|
||||||
|
|
||||||
# Wait until the transport terminates
|
# Wait until the transport terminates
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
15
apps/pair.py
15
apps/pair.py
@@ -20,11 +20,12 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from prompt_toolkit.shortcuts import PromptSession
|
from prompt_toolkit.shortcuts import PromptSession
|
||||||
|
|
||||||
from bumble import data_types
|
from bumble import data_types, smp
|
||||||
from bumble.a2dp import make_audio_sink_service_sdp_records
|
from bumble.a2dp import make_audio_sink_service_sdp_records
|
||||||
from bumble.att import (
|
from bumble.att import (
|
||||||
ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
|
ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
|
||||||
@@ -40,7 +41,7 @@ from bumble.core import (
|
|||||||
PhysicalTransport,
|
PhysicalTransport,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
)
|
)
|
||||||
from bumble.device import Device, Peer
|
from bumble.device import Connection, Device, Peer
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
GATT_DEVICE_NAME_CHARACTERISTIC,
|
||||||
GATT_GENERIC_ACCESS_SERVICE,
|
GATT_GENERIC_ACCESS_SERVICE,
|
||||||
@@ -53,7 +54,6 @@ from bumble.hci import OwnAddressType
|
|||||||
from bumble.keys import JsonKeyStore
|
from bumble.keys import JsonKeyStore
|
||||||
from bumble.pairing import OobData, PairingConfig, PairingDelegate
|
from bumble.pairing import OobData, PairingConfig, PairingDelegate
|
||||||
from bumble.smp import OobContext, OobLegacyContext
|
from bumble.smp import OobContext, OobLegacyContext
|
||||||
from bumble.smp import error_name as smp_error_name
|
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
from bumble.utils import AsyncRunner
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ POST_PAIRING_DELAY = 1
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Waiter:
|
class Waiter:
|
||||||
instance: Waiter | None = None
|
instance: ClassVar[Waiter | None] = None
|
||||||
|
|
||||||
def __init__(self, linger=False):
|
def __init__(self, linger=False):
|
||||||
self.done = asyncio.get_running_loop().create_future()
|
self.done = asyncio.get_running_loop().create_future()
|
||||||
@@ -319,12 +319,13 @@ async def on_classic_pairing(connection):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@AsyncRunner.run_in_task()
|
@AsyncRunner.run_in_task()
|
||||||
async def on_pairing_failure(connection, reason):
|
async def on_pairing_failure(connection: Connection, reason: smp.ErrorCode):
|
||||||
print(color('***-----------------------------------', 'red'))
|
print(color('***-----------------------------------', 'red'))
|
||||||
print(color(f'*** Pairing failed: {smp_error_name(reason)}', 'red'))
|
print(color(f'*** Pairing failed: {reason.name}', 'red'))
|
||||||
print(color('***-----------------------------------', 'red'))
|
print(color('***-----------------------------------', 'red'))
|
||||||
await connection.disconnect()
|
await connection.disconnect()
|
||||||
Waiter.instance.terminate()
|
if Waiter.instance:
|
||||||
|
Waiter.instance.terminate()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ async def run(device_config, hci_transport, bridge):
|
|||||||
await bridge.start(device)
|
await bridge.start(device)
|
||||||
|
|
||||||
# Wait until the transport terminates
|
# Wait until the transport terminates
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
except core.ConnectionError as error:
|
except core.ConnectionError as error:
|
||||||
print(color(f"!!! Bluetooth connection failed: {error}", "red"))
|
print(color(f"!!! Bluetooth connection failed: {error}", "red"))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|||||||
14
apps/scan.py
14
apps/scan.py
@@ -22,7 +22,7 @@ import click
|
|||||||
import bumble.logging
|
import bumble.logging
|
||||||
from bumble import data_types
|
from bumble import data_types
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Advertisement, Device
|
from bumble.device import Advertisement, Device, DeviceConfiguration
|
||||||
from bumble.hci import HCI_LE_1M_PHY, HCI_LE_CODED_PHY, Address, HCI_Constant
|
from bumble.hci import HCI_LE_1M_PHY, HCI_LE_CODED_PHY, Address, HCI_Constant
|
||||||
from bumble.keys import JsonKeyStore
|
from bumble.keys import JsonKeyStore
|
||||||
from bumble.smp import AddressResolver
|
from bumble.smp import AddressResolver
|
||||||
@@ -144,8 +144,14 @@ async def scan(
|
|||||||
device_config, hci_source, hci_sink
|
device_config, hci_source, hci_sink
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
device = Device.with_hci(
|
device = Device.from_config_with_hci(
|
||||||
'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
|
DeviceConfiguration(
|
||||||
|
name='Bumble',
|
||||||
|
address=Address('F0:F1:F2:F3:F4:F5'),
|
||||||
|
keystore='JsonKeyStore',
|
||||||
|
),
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
)
|
)
|
||||||
|
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
@@ -190,7 +196,7 @@ async def scan(
|
|||||||
scanning_phys=scanning_phys,
|
scanning_phys=scanning_phys,
|
||||||
)
|
)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -726,7 +726,7 @@ class Speaker:
|
|||||||
print("Waiting for connection...")
|
print("Waiting for connection...")
|
||||||
await self.advertise()
|
await self.advertise()
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
|
|
||||||
for output in self.outputs:
|
for output in self.outputs:
|
||||||
await output.stop()
|
await output.stop()
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import usb1
|
import usb1
|
||||||
|
|
||||||
@@ -166,13 +168,16 @@ def is_bluetooth_hci(device):
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option('--verbose', is_flag=True, default=False, help='Print more details')
|
@click.option('--verbose', is_flag=True, default=False, help='Print more details')
|
||||||
def main(verbose):
|
@click.option('--hci-only', is_flag=True, default=False, help='only show HCI device')
|
||||||
|
@click.option('--manufacturer', help='filter by manufacturer')
|
||||||
|
@click.option('--product', help='filter by product')
|
||||||
|
def main(verbose: bool, manufacturer: str, product: str, hci_only: bool):
|
||||||
bumble.logging.setup_basic_logging('WARNING')
|
bumble.logging.setup_basic_logging('WARNING')
|
||||||
|
|
||||||
load_libusb()
|
load_libusb()
|
||||||
with usb1.USBContext() as context:
|
with usb1.USBContext() as context:
|
||||||
bluetooth_device_count = 0
|
bluetooth_device_count = 0
|
||||||
devices = {}
|
devices: dict[tuple[Any, Any], list[str | None]] = {}
|
||||||
|
|
||||||
for device in context.getDeviceIterator(skip_on_error=True):
|
for device in context.getDeviceIterator(skip_on_error=True):
|
||||||
device_class = device.getDeviceClass()
|
device_class = device.getDeviceClass()
|
||||||
@@ -234,6 +239,14 @@ def main(verbose):
|
|||||||
f'{basic_transport_name}/{device_serial_number}'
|
f'{basic_transport_name}/{device_serial_number}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter
|
||||||
|
if product and device_product != product:
|
||||||
|
continue
|
||||||
|
if manufacturer and device_manufacturer != manufacturer:
|
||||||
|
continue
|
||||||
|
if not is_bluetooth_hci(device) and hci_only:
|
||||||
|
continue
|
||||||
|
|
||||||
# Print the results
|
# Print the results
|
||||||
print(
|
print(
|
||||||
color(
|
color(
|
||||||
|
|||||||
@@ -88,13 +88,6 @@ SBC_DUAL_CHANNEL_MODE = 0x01
|
|||||||
SBC_STEREO_CHANNEL_MODE = 0x02
|
SBC_STEREO_CHANNEL_MODE = 0x02
|
||||||
SBC_JOINT_STEREO_CHANNEL_MODE = 0x03
|
SBC_JOINT_STEREO_CHANNEL_MODE = 0x03
|
||||||
|
|
||||||
SBC_CHANNEL_MODE_NAMES = {
|
|
||||||
SBC_MONO_CHANNEL_MODE: 'SBC_MONO_CHANNEL_MODE',
|
|
||||||
SBC_DUAL_CHANNEL_MODE: 'SBC_DUAL_CHANNEL_MODE',
|
|
||||||
SBC_STEREO_CHANNEL_MODE: 'SBC_STEREO_CHANNEL_MODE',
|
|
||||||
SBC_JOINT_STEREO_CHANNEL_MODE: 'SBC_JOINT_STEREO_CHANNEL_MODE'
|
|
||||||
}
|
|
||||||
|
|
||||||
SBC_BLOCK_LENGTHS = [4, 8, 12, 16]
|
SBC_BLOCK_LENGTHS = [4, 8, 12, 16]
|
||||||
|
|
||||||
SBC_SUBBANDS = [4, 8]
|
SBC_SUBBANDS = [4, 8]
|
||||||
@@ -102,11 +95,6 @@ SBC_SUBBANDS = [4, 8]
|
|||||||
SBC_SNR_ALLOCATION_METHOD = 0x00
|
SBC_SNR_ALLOCATION_METHOD = 0x00
|
||||||
SBC_LOUDNESS_ALLOCATION_METHOD = 0x01
|
SBC_LOUDNESS_ALLOCATION_METHOD = 0x01
|
||||||
|
|
||||||
SBC_ALLOCATION_METHOD_NAMES = {
|
|
||||||
SBC_SNR_ALLOCATION_METHOD: 'SBC_SNR_ALLOCATION_METHOD',
|
|
||||||
SBC_LOUDNESS_ALLOCATION_METHOD: 'SBC_LOUDNESS_ALLOCATION_METHOD'
|
|
||||||
}
|
|
||||||
|
|
||||||
SBC_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
SBC_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
||||||
|
|
||||||
MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [
|
MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [
|
||||||
@@ -129,13 +117,6 @@ MPEG_4_AAC_LC_OBJECT_TYPE = 0x01
|
|||||||
MPEG_4_AAC_LTP_OBJECT_TYPE = 0x02
|
MPEG_4_AAC_LTP_OBJECT_TYPE = 0x02
|
||||||
MPEG_4_AAC_SCALABLE_OBJECT_TYPE = 0x03
|
MPEG_4_AAC_SCALABLE_OBJECT_TYPE = 0x03
|
||||||
|
|
||||||
MPEG_2_4_OBJECT_TYPE_NAMES = {
|
|
||||||
MPEG_2_AAC_LC_OBJECT_TYPE: 'MPEG_2_AAC_LC_OBJECT_TYPE',
|
|
||||||
MPEG_4_AAC_LC_OBJECT_TYPE: 'MPEG_4_AAC_LC_OBJECT_TYPE',
|
|
||||||
MPEG_4_AAC_LTP_OBJECT_TYPE: 'MPEG_4_AAC_LTP_OBJECT_TYPE',
|
|
||||||
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 'MPEG_4_AAC_SCALABLE_OBJECT_TYPE'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
OPUS_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
OPUS_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
||||||
|
|
||||||
@@ -267,26 +248,27 @@ class MediaCodecInformation:
|
|||||||
def create(
|
def create(
|
||||||
cls, media_codec_type: int, data: bytes
|
cls, media_codec_type: int, data: bytes
|
||||||
) -> MediaCodecInformation | bytes:
|
) -> MediaCodecInformation | bytes:
|
||||||
if media_codec_type == CodecType.SBC:
|
match media_codec_type:
|
||||||
return SbcMediaCodecInformation.from_bytes(data)
|
case CodecType.SBC:
|
||||||
elif media_codec_type == CodecType.MPEG_2_4_AAC:
|
return SbcMediaCodecInformation.from_bytes(data)
|
||||||
return AacMediaCodecInformation.from_bytes(data)
|
case CodecType.MPEG_2_4_AAC:
|
||||||
elif media_codec_type == CodecType.NON_A2DP:
|
return AacMediaCodecInformation.from_bytes(data)
|
||||||
vendor_media_codec_information = (
|
case CodecType.NON_A2DP:
|
||||||
VendorSpecificMediaCodecInformation.from_bytes(data)
|
vendor_media_codec_information = (
|
||||||
)
|
VendorSpecificMediaCodecInformation.from_bytes(data)
|
||||||
if (
|
|
||||||
vendor_class_map := A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES.get(
|
|
||||||
vendor_media_codec_information.vendor_id
|
|
||||||
)
|
|
||||||
) and (
|
|
||||||
media_codec_information_class := vendor_class_map.get(
|
|
||||||
vendor_media_codec_information.codec_id
|
|
||||||
)
|
|
||||||
):
|
|
||||||
return media_codec_information_class.from_bytes(
|
|
||||||
vendor_media_codec_information.value
|
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
vendor_class_map := A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES.get(
|
||||||
|
vendor_media_codec_information.vendor_id
|
||||||
|
)
|
||||||
|
) and (
|
||||||
|
media_codec_information_class := vendor_class_map.get(
|
||||||
|
vendor_media_codec_information.codec_id
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return media_codec_information_class.from_bytes(
|
||||||
|
vendor_media_codec_information.value
|
||||||
|
)
|
||||||
return vendor_media_codec_information
|
return vendor_media_codec_information
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
62
bumble/at.py
62
bumble/at.py
@@ -27,7 +27,7 @@ def tokenize_parameters(buffer: bytes) -> list[bytes]:
|
|||||||
are ignored [..], unless they are embedded in numeric or string constants"
|
are ignored [..], unless they are embedded in numeric or string constants"
|
||||||
Raises AtParsingError in case of invalid input string."""
|
Raises AtParsingError in case of invalid input string."""
|
||||||
|
|
||||||
tokens = []
|
tokens: list[bytearray] = []
|
||||||
in_quotes = False
|
in_quotes = False
|
||||||
token = bytearray()
|
token = bytearray()
|
||||||
for b in buffer:
|
for b in buffer:
|
||||||
@@ -40,23 +40,24 @@ def tokenize_parameters(buffer: bytes) -> list[bytes]:
|
|||||||
tokens.append(token[1:-1])
|
tokens.append(token[1:-1])
|
||||||
token = bytearray()
|
token = bytearray()
|
||||||
else:
|
else:
|
||||||
if char == b' ':
|
match char:
|
||||||
pass
|
case b' ':
|
||||||
elif char == b',' or char == b')':
|
pass
|
||||||
tokens.append(token)
|
case b',' | b')':
|
||||||
tokens.append(char)
|
tokens.append(token)
|
||||||
token = bytearray()
|
tokens.append(char)
|
||||||
elif char == b'(':
|
token = bytearray()
|
||||||
if len(token) > 0:
|
case b'(':
|
||||||
raise AtParsingError("open_paren following regular character")
|
if len(token) > 0:
|
||||||
tokens.append(char)
|
raise AtParsingError("open_paren following regular character")
|
||||||
elif char == b'"':
|
tokens.append(char)
|
||||||
if len(token) > 0:
|
case b'"':
|
||||||
raise AtParsingError("quote following regular character")
|
if len(token) > 0:
|
||||||
in_quotes = True
|
raise AtParsingError("quote following regular character")
|
||||||
token.extend(char)
|
in_quotes = True
|
||||||
else:
|
token.extend(char)
|
||||||
token.extend(char)
|
case _:
|
||||||
|
token.extend(char)
|
||||||
|
|
||||||
tokens.append(token)
|
tokens.append(token)
|
||||||
return [bytes(token) for token in tokens if len(token) > 0]
|
return [bytes(token) for token in tokens if len(token) > 0]
|
||||||
@@ -71,18 +72,19 @@ def parse_parameters(buffer: bytes) -> list[bytes | list]:
|
|||||||
current: bytes | list = b''
|
current: bytes | list = b''
|
||||||
|
|
||||||
for token in tokens:
|
for token in tokens:
|
||||||
if token == b',':
|
match token:
|
||||||
accumulator[-1].append(current)
|
case b',':
|
||||||
current = b''
|
accumulator[-1].append(current)
|
||||||
elif token == b'(':
|
current = b''
|
||||||
accumulator.append([])
|
case b'(':
|
||||||
elif token == b')':
|
accumulator.append([])
|
||||||
if len(accumulator) < 2:
|
case b')':
|
||||||
raise AtParsingError("close_paren without matching open_paren")
|
if len(accumulator) < 2:
|
||||||
accumulator[-1].append(current)
|
raise AtParsingError("close_paren without matching open_paren")
|
||||||
current = accumulator.pop()
|
accumulator[-1].append(current)
|
||||||
else:
|
current = accumulator.pop()
|
||||||
current = token
|
case _:
|
||||||
|
current = token
|
||||||
|
|
||||||
accumulator[-1].append(current)
|
accumulator[-1].append(current)
|
||||||
if len(accumulator) > 1:
|
if len(accumulator) > 1:
|
||||||
|
|||||||
236
bumble/att.py
236
bumble/att.py
@@ -29,7 +29,7 @@ import enum
|
|||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
import struct
|
import struct
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable, Sequence
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
ClassVar,
|
ClassVar,
|
||||||
@@ -42,7 +42,7 @@ from typing_extensions import TypeIs
|
|||||||
|
|
||||||
from bumble import hci, l2cap, utils
|
from bumble import hci, l2cap, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import UUID, InvalidOperationError, ProtocolError
|
from bumble.core import UUID, InvalidOperationError, InvalidPacketError, ProtocolError
|
||||||
from bumble.hci import HCI_Object
|
from bumble.hci import HCI_Object
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -72,34 +72,36 @@ ATT_PSM = 0x001F
|
|||||||
EATT_PSM = 0x0027
|
EATT_PSM = 0x0027
|
||||||
|
|
||||||
class Opcode(hci.SpecableEnum):
|
class Opcode(hci.SpecableEnum):
|
||||||
ATT_ERROR_RESPONSE = 0x01
|
ATT_ERROR_RESPONSE = 0x01
|
||||||
ATT_EXCHANGE_MTU_REQUEST = 0x02
|
ATT_EXCHANGE_MTU_REQUEST = 0x02
|
||||||
ATT_EXCHANGE_MTU_RESPONSE = 0x03
|
ATT_EXCHANGE_MTU_RESPONSE = 0x03
|
||||||
ATT_FIND_INFORMATION_REQUEST = 0x04
|
ATT_FIND_INFORMATION_REQUEST = 0x04
|
||||||
ATT_FIND_INFORMATION_RESPONSE = 0x05
|
ATT_FIND_INFORMATION_RESPONSE = 0x05
|
||||||
ATT_FIND_BY_TYPE_VALUE_REQUEST = 0x06
|
ATT_FIND_BY_TYPE_VALUE_REQUEST = 0x06
|
||||||
ATT_FIND_BY_TYPE_VALUE_RESPONSE = 0x07
|
ATT_FIND_BY_TYPE_VALUE_RESPONSE = 0x07
|
||||||
ATT_READ_BY_TYPE_REQUEST = 0x08
|
ATT_READ_BY_TYPE_REQUEST = 0x08
|
||||||
ATT_READ_BY_TYPE_RESPONSE = 0x09
|
ATT_READ_BY_TYPE_RESPONSE = 0x09
|
||||||
ATT_READ_REQUEST = 0x0A
|
ATT_READ_REQUEST = 0x0A
|
||||||
ATT_READ_RESPONSE = 0x0B
|
ATT_READ_RESPONSE = 0x0B
|
||||||
ATT_READ_BLOB_REQUEST = 0x0C
|
ATT_READ_BLOB_REQUEST = 0x0C
|
||||||
ATT_READ_BLOB_RESPONSE = 0x0D
|
ATT_READ_BLOB_RESPONSE = 0x0D
|
||||||
ATT_READ_MULTIPLE_REQUEST = 0x0E
|
ATT_READ_MULTIPLE_REQUEST = 0x0E
|
||||||
ATT_READ_MULTIPLE_RESPONSE = 0x0F
|
ATT_READ_MULTIPLE_RESPONSE = 0x0F
|
||||||
ATT_READ_BY_GROUP_TYPE_REQUEST = 0x10
|
ATT_READ_BY_GROUP_TYPE_REQUEST = 0x10
|
||||||
ATT_READ_BY_GROUP_TYPE_RESPONSE = 0x11
|
ATT_READ_BY_GROUP_TYPE_RESPONSE = 0x11
|
||||||
ATT_WRITE_REQUEST = 0x12
|
ATT_READ_MULTIPLE_VARIABLE_REQUEST = 0x20
|
||||||
ATT_WRITE_RESPONSE = 0x13
|
ATT_READ_MULTIPLE_VARIABLE_RESPONSE = 0x21
|
||||||
ATT_WRITE_COMMAND = 0x52
|
ATT_WRITE_REQUEST = 0x12
|
||||||
ATT_SIGNED_WRITE_COMMAND = 0xD2
|
ATT_WRITE_RESPONSE = 0x13
|
||||||
ATT_PREPARE_WRITE_REQUEST = 0x16
|
ATT_WRITE_COMMAND = 0x52
|
||||||
ATT_PREPARE_WRITE_RESPONSE = 0x17
|
ATT_SIGNED_WRITE_COMMAND = 0xD2
|
||||||
ATT_EXECUTE_WRITE_REQUEST = 0x18
|
ATT_PREPARE_WRITE_REQUEST = 0x16
|
||||||
ATT_EXECUTE_WRITE_RESPONSE = 0x19
|
ATT_PREPARE_WRITE_RESPONSE = 0x17
|
||||||
ATT_HANDLE_VALUE_NOTIFICATION = 0x1B
|
ATT_EXECUTE_WRITE_REQUEST = 0x18
|
||||||
ATT_HANDLE_VALUE_INDICATION = 0x1D
|
ATT_EXECUTE_WRITE_RESPONSE = 0x19
|
||||||
ATT_HANDLE_VALUE_CONFIRMATION = 0x1E
|
ATT_HANDLE_VALUE_NOTIFICATION = 0x1B
|
||||||
|
ATT_HANDLE_VALUE_INDICATION = 0x1D
|
||||||
|
ATT_HANDLE_VALUE_CONFIRMATION = 0x1E
|
||||||
|
|
||||||
ATT_REQUESTS = [
|
ATT_REQUESTS = [
|
||||||
Opcode.ATT_EXCHANGE_MTU_REQUEST,
|
Opcode.ATT_EXCHANGE_MTU_REQUEST,
|
||||||
@@ -110,9 +112,10 @@ ATT_REQUESTS = [
|
|||||||
Opcode.ATT_READ_BLOB_REQUEST,
|
Opcode.ATT_READ_BLOB_REQUEST,
|
||||||
Opcode.ATT_READ_MULTIPLE_REQUEST,
|
Opcode.ATT_READ_MULTIPLE_REQUEST,
|
||||||
Opcode.ATT_READ_BY_GROUP_TYPE_REQUEST,
|
Opcode.ATT_READ_BY_GROUP_TYPE_REQUEST,
|
||||||
|
Opcode.ATT_READ_MULTIPLE_VARIABLE_REQUEST,
|
||||||
Opcode.ATT_WRITE_REQUEST,
|
Opcode.ATT_WRITE_REQUEST,
|
||||||
Opcode.ATT_PREPARE_WRITE_REQUEST,
|
Opcode.ATT_PREPARE_WRITE_REQUEST,
|
||||||
Opcode.ATT_EXECUTE_WRITE_REQUEST
|
Opcode.ATT_EXECUTE_WRITE_REQUEST,
|
||||||
]
|
]
|
||||||
|
|
||||||
ATT_RESPONSES = [
|
ATT_RESPONSES = [
|
||||||
@@ -125,9 +128,10 @@ ATT_RESPONSES = [
|
|||||||
Opcode.ATT_READ_BLOB_RESPONSE,
|
Opcode.ATT_READ_BLOB_RESPONSE,
|
||||||
Opcode.ATT_READ_MULTIPLE_RESPONSE,
|
Opcode.ATT_READ_MULTIPLE_RESPONSE,
|
||||||
Opcode.ATT_READ_BY_GROUP_TYPE_RESPONSE,
|
Opcode.ATT_READ_BY_GROUP_TYPE_RESPONSE,
|
||||||
|
Opcode.ATT_READ_MULTIPLE_VARIABLE_RESPONSE,
|
||||||
Opcode.ATT_WRITE_RESPONSE,
|
Opcode.ATT_WRITE_RESPONSE,
|
||||||
Opcode.ATT_PREPARE_WRITE_RESPONSE,
|
Opcode.ATT_PREPARE_WRITE_RESPONSE,
|
||||||
Opcode.ATT_EXECUTE_WRITE_RESPONSE
|
Opcode.ATT_EXECUTE_WRITE_RESPONSE,
|
||||||
]
|
]
|
||||||
|
|
||||||
class ErrorCode(hci.SpecableEnum):
|
class ErrorCode(hci.SpecableEnum):
|
||||||
@@ -185,6 +189,18 @@ ATT_INSUFFICIENT_RESOURCES_ERROR = ErrorCode.INSUFFICIENT_RESOURCES
|
|||||||
ATT_DEFAULT_MTU = 23
|
ATT_DEFAULT_MTU = 23
|
||||||
|
|
||||||
HANDLE_FIELD_SPEC = {'size': 2, 'mapper': lambda x: f'0x{x:04X}'}
|
HANDLE_FIELD_SPEC = {'size': 2, 'mapper': lambda x: f'0x{x:04X}'}
|
||||||
|
_SET_OF_HANDLES_METADATA = hci.metadata({
|
||||||
|
'parser': lambda data, offset: (
|
||||||
|
len(data),
|
||||||
|
[
|
||||||
|
struct.unpack_from('<H', data, i)[0]
|
||||||
|
for i in range(offset, len(data), 2)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
'serializer': lambda handles: b''.join(
|
||||||
|
[struct.pack('<H', handle) for handle in handles]
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
# pylint: enable=line-too-long
|
# pylint: enable=line-too-long
|
||||||
@@ -233,6 +249,8 @@ class ATT_PDU:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_bytes(cls, pdu: bytes) -> ATT_PDU:
|
def from_bytes(cls, pdu: bytes) -> ATT_PDU:
|
||||||
|
if not pdu:
|
||||||
|
raise InvalidPacketError("Empty ATT PDU")
|
||||||
op_code = pdu[0]
|
op_code = pdu[0]
|
||||||
|
|
||||||
subclass = ATT_PDU.pdu_classes.get(op_code)
|
subclass = ATT_PDU.pdu_classes.get(op_code)
|
||||||
@@ -554,7 +572,7 @@ class ATT_Read_Multiple_Request(ATT_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part F - 3.4.4.7 Read Multiple Request
|
See Bluetooth spec @ Vol 3, Part F - 3.4.4.7 Read Multiple Request
|
||||||
'''
|
'''
|
||||||
|
|
||||||
set_of_handles: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
set_of_handles: Sequence[int] = dataclasses.field(metadata=_SET_OF_HANDLES_METADATA)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -635,6 +653,55 @@ class ATT_Read_By_Group_Type_Response(ATT_PDU):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@ATT_PDU.subclass
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class ATT_Read_Multiple_Variable_Request(ATT_PDU):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ Vol 3, Part F - 3.4.4.11 Read Multiple Variable Request
|
||||||
|
'''
|
||||||
|
|
||||||
|
set_of_handles: Sequence[int] = dataclasses.field(metadata=_SET_OF_HANDLES_METADATA)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@ATT_PDU.subclass
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class ATT_Read_Multiple_Variable_Response(ATT_PDU):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ Vol 3, Part F - 3.4.4.12 Read Multiple Variable Response
|
||||||
|
'''
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_length_value_tuples(
|
||||||
|
cls, data: bytes, offset: int
|
||||||
|
) -> tuple[int, list[tuple[int, bytes]]]:
|
||||||
|
length_value_tuple_list: list[tuple[int, bytes]] = []
|
||||||
|
while offset < len(data):
|
||||||
|
length = struct.unpack_from('<H', data, offset)[0]
|
||||||
|
length_value_tuple_list.append(
|
||||||
|
(length, data[offset + 2 : offset + 2 + length])
|
||||||
|
)
|
||||||
|
offset += 2 + length
|
||||||
|
return (len(data), length_value_tuple_list)
|
||||||
|
|
||||||
|
length_value_tuple_list: Sequence[tuple[int, bytes]] = dataclasses.field(
|
||||||
|
metadata=hci.metadata(
|
||||||
|
{
|
||||||
|
'parser': lambda data, offset: ATT_Read_Multiple_Variable_Response._parse_length_value_tuples(
|
||||||
|
data, offset
|
||||||
|
),
|
||||||
|
'serializer': lambda length_value_tuple_list: b''.join(
|
||||||
|
[
|
||||||
|
struct.pack('<H', length) + value
|
||||||
|
for length, value in length_value_tuple_list
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@ATT_PDU.subclass
|
@ATT_PDU.subclass
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@@ -889,12 +956,13 @@ class Attribute(utils.EventEmitter, Generic[_T]):
|
|||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
|
|
||||||
# Convert the type to a UUID object if it isn't already
|
# Convert the type to a UUID object if it isn't already
|
||||||
if isinstance(attribute_type, str):
|
match attribute_type:
|
||||||
self.type = UUID(attribute_type)
|
case str():
|
||||||
elif isinstance(attribute_type, bytes):
|
self.type = UUID(attribute_type)
|
||||||
self.type = UUID.from_bytes(attribute_type)
|
case bytes():
|
||||||
else:
|
self.type = UUID.from_bytes(attribute_type)
|
||||||
self.type = attribute_type
|
case _:
|
||||||
|
self.type = attribute_type
|
||||||
|
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@@ -929,30 +997,31 @@ class Attribute(utils.EventEmitter, Generic[_T]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
value: _T | None
|
value: _T | None
|
||||||
if isinstance(self.value, AttributeValue):
|
match self.value:
|
||||||
try:
|
case AttributeValue():
|
||||||
read_value = self.value.read(connection)
|
try:
|
||||||
if inspect.isawaitable(read_value):
|
read_value = self.value.read(connection)
|
||||||
value = await read_value
|
if inspect.isawaitable(read_value):
|
||||||
else:
|
value = await read_value
|
||||||
value = read_value
|
else:
|
||||||
except ATT_Error as error:
|
value = read_value
|
||||||
raise ATT_Error(
|
except ATT_Error as error:
|
||||||
error_code=error.error_code, att_handle=self.handle
|
raise ATT_Error(
|
||||||
) from error
|
error_code=error.error_code, att_handle=self.handle
|
||||||
elif isinstance(self.value, AttributeValueV2):
|
) from error
|
||||||
try:
|
case AttributeValueV2():
|
||||||
read_value = self.value.read(bearer)
|
try:
|
||||||
if inspect.isawaitable(read_value):
|
read_value = self.value.read(bearer)
|
||||||
value = await read_value
|
if inspect.isawaitable(read_value):
|
||||||
else:
|
value = await read_value
|
||||||
value = read_value
|
else:
|
||||||
except ATT_Error as error:
|
value = read_value
|
||||||
raise ATT_Error(
|
except ATT_Error as error:
|
||||||
error_code=error.error_code, att_handle=self.handle
|
raise ATT_Error(
|
||||||
) from error
|
error_code=error.error_code, att_handle=self.handle
|
||||||
else:
|
) from error
|
||||||
value = self.value
|
case _:
|
||||||
|
value = self.value
|
||||||
|
|
||||||
self.emit(self.EVENT_READ, connection, b'' if value is None else value)
|
self.emit(self.EVENT_READ, connection, b'' if value is None else value)
|
||||||
|
|
||||||
@@ -984,26 +1053,27 @@ class Attribute(utils.EventEmitter, Generic[_T]):
|
|||||||
|
|
||||||
decoded_value = self.decode_value(value)
|
decoded_value = self.decode_value(value)
|
||||||
|
|
||||||
if isinstance(self.value, AttributeValue):
|
match self.value:
|
||||||
try:
|
case AttributeValue():
|
||||||
result = self.value.write(connection, decoded_value)
|
try:
|
||||||
if inspect.isawaitable(result):
|
result = self.value.write(connection, decoded_value)
|
||||||
await result
|
if inspect.isawaitable(result):
|
||||||
except ATT_Error as error:
|
await result
|
||||||
raise ATT_Error(
|
except ATT_Error as error:
|
||||||
error_code=error.error_code, att_handle=self.handle
|
raise ATT_Error(
|
||||||
) from error
|
error_code=error.error_code, att_handle=self.handle
|
||||||
elif isinstance(self.value, AttributeValueV2):
|
) from error
|
||||||
try:
|
case AttributeValueV2():
|
||||||
result = self.value.write(bearer, decoded_value)
|
try:
|
||||||
if inspect.isawaitable(result):
|
result = self.value.write(bearer, decoded_value)
|
||||||
await result
|
if inspect.isawaitable(result):
|
||||||
except ATT_Error as error:
|
await result
|
||||||
raise ATT_Error(
|
except ATT_Error as error:
|
||||||
error_code=error.error_code, att_handle=self.handle
|
raise ATT_Error(
|
||||||
) from error
|
error_code=error.error_code, att_handle=self.handle
|
||||||
else:
|
) from error
|
||||||
self.value = decoded_value
|
case _:
|
||||||
|
self.value = decoded_value
|
||||||
|
|
||||||
self.emit(self.EVENT_WRITE, connection, decoded_value)
|
self.emit(self.EVENT_WRITE, connection, decoded_value)
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ class Protocol:
|
|||||||
)
|
)
|
||||||
+ payload
|
+ payload
|
||||||
)
|
)
|
||||||
self.l2cap_channel.send_pdu(pdu)
|
self.l2cap_channel.write(pdu)
|
||||||
|
|
||||||
def send_command(self, transaction_label: int, pid: int, payload: bytes) -> None:
|
def send_command(self, transaction_label: int, pid: int, payload: bytes) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ class MediaPacketPump:
|
|||||||
await self.clock.sleep(delay)
|
await self.clock.sleep(delay)
|
||||||
|
|
||||||
# Emit
|
# Emit
|
||||||
rtp_channel.send_pdu(bytes(packet))
|
rtp_channel.write(bytes(packet))
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{color(">>> sending RTP packet:", "green")} {packet}'
|
f'{color(">>> sending RTP packet:", "green")} {packet}'
|
||||||
)
|
)
|
||||||
@@ -1519,7 +1519,7 @@ class Protocol(utils.EventEmitter):
|
|||||||
header = bytes([first_header_byte])
|
header = bytes([first_header_byte])
|
||||||
|
|
||||||
# Send one packet
|
# Send one packet
|
||||||
self.l2cap_channel.send_pdu(header + payload[:max_fragment_size])
|
self.l2cap_channel.write(header + payload[:max_fragment_size])
|
||||||
|
|
||||||
# Prepare for the next packet
|
# Prepare for the next packet
|
||||||
payload = payload[max_fragment_size:]
|
payload = payload[max_fragment_size:]
|
||||||
@@ -1829,7 +1829,7 @@ class Stream:
|
|||||||
|
|
||||||
def send_media_packet(self, packet: MediaPacket) -> None:
|
def send_media_packet(self, packet: MediaPacket) -> None:
|
||||||
assert self.rtp_channel
|
assert self.rtp_channel
|
||||||
self.rtp_channel.send_pdu(bytes(packet))
|
self.rtp_channel.write(bytes(packet))
|
||||||
|
|
||||||
async def configure(self) -> None:
|
async def configure(self) -> None:
|
||||||
if self.state != State.IDLE:
|
if self.state != State.IDLE:
|
||||||
|
|||||||
951
bumble/avrcp.py
951
bumble/avrcp.py
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,12 @@ class HCI_Bridge:
|
|||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet):
|
||||||
# Convert the packet bytes to an object
|
# Convert the packet bytes to an object
|
||||||
hci_packet = HCI_Packet.from_bytes(packet)
|
try:
|
||||||
|
hci_packet = HCI_Packet.from_bytes(packet)
|
||||||
|
except Exception:
|
||||||
|
logger.warning('forwarding unparsed packet as-is')
|
||||||
|
self.hci_sink.on_packet(packet)
|
||||||
|
return
|
||||||
|
|
||||||
# Filter the packet
|
# Filter the packet
|
||||||
if self.packet_filter is not None:
|
if self.packet_filter is not None:
|
||||||
@@ -50,7 +55,10 @@ class HCI_Bridge:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Analyze the packet
|
# Analyze the packet
|
||||||
self.trace(hci_packet)
|
try:
|
||||||
|
self.trace(hci_packet)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Exception while tracing packet')
|
||||||
|
|
||||||
# Bridge the packet
|
# Bridge the packet
|
||||||
self.hci_sink.on_packet(packet)
|
self.hci_sink.on_packet(packet)
|
||||||
|
|||||||
1288
bumble/controller.py
1288
bumble/controller.py
File diff suppressed because it is too large
Load Diff
158
bumble/core.py
158
bumble/core.py
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
|
import functools
|
||||||
import struct
|
import struct
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from typing import (
|
from typing import (
|
||||||
@@ -273,6 +274,18 @@ class UUID:
|
|||||||
def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> tuple[int, UUID]:
|
def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> tuple[int, UUID]:
|
||||||
return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2])
|
return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2])
|
||||||
|
|
||||||
|
@functools.cached_property
|
||||||
|
def uuid_128_bytes(self) -> bytes:
|
||||||
|
match len(self.uuid_bytes):
|
||||||
|
case 2:
|
||||||
|
return self.BASE_UUID + self.uuid_bytes + bytes([0, 0])
|
||||||
|
case 4:
|
||||||
|
return self.BASE_UUID + self.uuid_bytes
|
||||||
|
case 16:
|
||||||
|
return self.uuid_bytes
|
||||||
|
case _:
|
||||||
|
assert False, "unreachable"
|
||||||
|
|
||||||
def to_bytes(self, force_128: bool = False) -> bytes:
|
def to_bytes(self, force_128: bool = False) -> bytes:
|
||||||
'''
|
'''
|
||||||
Serialize UUID in little-endian byte-order
|
Serialize UUID in little-endian byte-order
|
||||||
@@ -280,14 +293,7 @@ class UUID:
|
|||||||
if not force_128:
|
if not force_128:
|
||||||
return self.uuid_bytes
|
return self.uuid_bytes
|
||||||
|
|
||||||
if len(self.uuid_bytes) == 2:
|
return self.uuid_128_bytes
|
||||||
return self.BASE_UUID + self.uuid_bytes + bytes([0, 0])
|
|
||||||
elif len(self.uuid_bytes) == 4:
|
|
||||||
return self.BASE_UUID + self.uuid_bytes
|
|
||||||
elif len(self.uuid_bytes) == 16:
|
|
||||||
return self.uuid_bytes
|
|
||||||
else:
|
|
||||||
assert False, "unreachable"
|
|
||||||
|
|
||||||
def to_pdu_bytes(self) -> bytes:
|
def to_pdu_bytes(self) -> bytes:
|
||||||
'''
|
'''
|
||||||
@@ -317,7 +323,7 @@ class UUID:
|
|||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
if isinstance(other, UUID):
|
if isinstance(other, UUID):
|
||||||
return self.to_bytes(force_128=True) == other.to_bytes(force_128=True)
|
return self.uuid_128_bytes == other.uuid_128_bytes
|
||||||
|
|
||||||
if isinstance(other, str):
|
if isinstance(other, str):
|
||||||
return UUID(other) == self
|
return UUID(other) == self
|
||||||
@@ -325,7 +331,7 @@ class UUID:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(self.uuid_bytes)
|
return hash(self.uuid_128_bytes)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
result = self.to_hex_str(separator='-')
|
result = self.to_hex_str(separator='-')
|
||||||
@@ -923,7 +929,7 @@ class DeviceClass:
|
|||||||
# pylint: enable=line-too-long
|
# pylint: enable=line-too-long
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def split_class_of_device(class_of_device):
|
def split_class_of_device(class_of_device: int) -> tuple[int, int, int]:
|
||||||
# Split the bit fields of the composite class of device value into:
|
# Split the bit fields of the composite class of device value into:
|
||||||
# (service_classes, major_device_class, minor_device_class)
|
# (service_classes, major_device_class, minor_device_class)
|
||||||
return (
|
return (
|
||||||
@@ -1769,66 +1775,71 @@ class AdvertisingData:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ad_data_to_string(cls, ad_type: int, ad_data: bytes) -> str:
|
def ad_data_to_string(cls, ad_type: int, ad_data: bytes) -> str:
|
||||||
if ad_type == AdvertisingData.FLAGS:
|
match ad_type:
|
||||||
ad_type_str = 'Flags'
|
case AdvertisingData.FLAGS:
|
||||||
ad_data_str = AdvertisingData.flags_to_string(ad_data[0], short=True)
|
ad_type_str = 'Flags'
|
||||||
elif ad_type == AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.flags_to_string(ad_data[0], short=True)
|
||||||
ad_type_str = 'Complete List of 16-bit Service Class UUIDs'
|
case AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 2)
|
ad_type_str = 'Complete List of 16-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 2)
|
||||||
ad_type_str = 'Incomplete List of 16-bit Service Class UUIDs'
|
case AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 2)
|
ad_type_str = 'Incomplete List of 16-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 2)
|
||||||
ad_type_str = 'Complete List of 32-bit Service Class UUIDs'
|
case AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 4)
|
ad_type_str = 'Complete List of 32-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 4)
|
||||||
ad_type_str = 'Incomplete List of 32-bit Service Class UUIDs'
|
case AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 4)
|
ad_type_str = 'Incomplete List of 32-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 4)
|
||||||
ad_type_str = 'Complete List of 128-bit Service Class UUIDs'
|
case AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 16)
|
ad_type_str = 'Complete List of 128-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 16)
|
||||||
ad_type_str = 'Incomplete List of 128-bit Service Class UUIDs'
|
case AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 16)
|
ad_type_str = 'Incomplete List of 128-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.SERVICE_DATA_16_BIT_UUID:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 16)
|
||||||
ad_type_str = 'Service Data'
|
case AdvertisingData.SERVICE_DATA_16_BIT_UUID:
|
||||||
uuid = UUID.from_bytes(ad_data[:2])
|
ad_type_str = 'Service Data'
|
||||||
ad_data_str = f'service={uuid}, data={ad_data[2:].hex()}'
|
uuid = UUID.from_bytes(ad_data[:2])
|
||||||
elif ad_type == AdvertisingData.SERVICE_DATA_32_BIT_UUID:
|
ad_data_str = f'service={uuid}, data={ad_data[2:].hex()}'
|
||||||
ad_type_str = 'Service Data'
|
case AdvertisingData.SERVICE_DATA_32_BIT_UUID:
|
||||||
uuid = UUID.from_bytes(ad_data[:4])
|
ad_type_str = 'Service Data'
|
||||||
ad_data_str = f'service={uuid}, data={ad_data[4:].hex()}'
|
uuid = UUID.from_bytes(ad_data[:4])
|
||||||
elif ad_type == AdvertisingData.SERVICE_DATA_128_BIT_UUID:
|
ad_data_str = f'service={uuid}, data={ad_data[4:].hex()}'
|
||||||
ad_type_str = 'Service Data'
|
case AdvertisingData.SERVICE_DATA_128_BIT_UUID:
|
||||||
uuid = UUID.from_bytes(ad_data[:16])
|
ad_type_str = 'Service Data'
|
||||||
ad_data_str = f'service={uuid}, data={ad_data[16:].hex()}'
|
uuid = UUID.from_bytes(ad_data[:16])
|
||||||
elif ad_type == AdvertisingData.SHORTENED_LOCAL_NAME:
|
ad_data_str = f'service={uuid}, data={ad_data[16:].hex()}'
|
||||||
ad_type_str = 'Shortened Local Name'
|
case AdvertisingData.SHORTENED_LOCAL_NAME:
|
||||||
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
ad_type_str = 'Shortened Local Name'
|
||||||
elif ad_type == AdvertisingData.COMPLETE_LOCAL_NAME:
|
|
||||||
ad_type_str = 'Complete Local Name'
|
|
||||||
try:
|
|
||||||
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
||||||
except UnicodeDecodeError:
|
case AdvertisingData.COMPLETE_LOCAL_NAME:
|
||||||
|
ad_type_str = 'Complete Local Name'
|
||||||
|
try:
|
||||||
|
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
ad_data_str = ad_data.hex()
|
||||||
|
case AdvertisingData.TX_POWER_LEVEL:
|
||||||
|
ad_type_str = 'TX Power Level'
|
||||||
|
ad_data_str = str(ad_data[0])
|
||||||
|
case AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
|
||||||
|
ad_type_str = 'Manufacturer Specific Data'
|
||||||
|
company_id = struct.unpack_from('<H', ad_data, 0)[0]
|
||||||
|
company_name = COMPANY_IDENTIFIERS.get(
|
||||||
|
company_id, f'0x{company_id:04X}'
|
||||||
|
)
|
||||||
|
ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
|
||||||
|
case AdvertisingData.APPEARANCE:
|
||||||
|
ad_type_str = 'Appearance'
|
||||||
|
appearance = Appearance.from_int(
|
||||||
|
struct.unpack_from('<H', ad_data, 0)[0]
|
||||||
|
)
|
||||||
|
ad_data_str = str(appearance)
|
||||||
|
case AdvertisingData.BROADCAST_NAME:
|
||||||
|
ad_type_str = 'Broadcast Name'
|
||||||
|
ad_data_str = ad_data.decode('utf-8')
|
||||||
|
case _:
|
||||||
|
ad_type_str = AdvertisingData.Type(ad_type).name
|
||||||
ad_data_str = ad_data.hex()
|
ad_data_str = ad_data.hex()
|
||||||
elif ad_type == AdvertisingData.TX_POWER_LEVEL:
|
|
||||||
ad_type_str = 'TX Power Level'
|
|
||||||
ad_data_str = str(ad_data[0])
|
|
||||||
elif ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
|
|
||||||
ad_type_str = 'Manufacturer Specific Data'
|
|
||||||
company_id = struct.unpack_from('<H', ad_data, 0)[0]
|
|
||||||
company_name = COMPANY_IDENTIFIERS.get(company_id, f'0x{company_id:04X}')
|
|
||||||
ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
|
|
||||||
elif ad_type == AdvertisingData.APPEARANCE:
|
|
||||||
ad_type_str = 'Appearance'
|
|
||||||
appearance = Appearance.from_int(struct.unpack_from('<H', ad_data, 0)[0])
|
|
||||||
ad_data_str = str(appearance)
|
|
||||||
elif ad_type == AdvertisingData.BROADCAST_NAME:
|
|
||||||
ad_type_str = 'Broadcast Name'
|
|
||||||
ad_data_str = ad_data.decode('utf-8')
|
|
||||||
else:
|
|
||||||
ad_type_str = AdvertisingData.Type(ad_type).name
|
|
||||||
ad_data_str = ad_data.hex()
|
|
||||||
|
|
||||||
return f'[{ad_type_str}]: {ad_data_str}'
|
return f'[{ad_type_str}]: {ad_data_str}'
|
||||||
|
|
||||||
@@ -2105,13 +2116,10 @@ class AdvertisingData:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Connection PHY
|
# Connection PHY
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
class ConnectionPHY:
|
class ConnectionPHY:
|
||||||
def __init__(self, tx_phy, rx_phy):
|
tx_phy: int
|
||||||
self.tx_phy = tx_phy
|
rx_phy: int
|
||||||
self.rx_phy = rx_phy
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f'ConnectionPHY(tx_phy={self.tx_phy}, rx_phy={self.rx_phy})'
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
1703
bumble/device.py
1703
bumble/device.py
File diff suppressed because it is too large
Load Diff
@@ -89,51 +89,54 @@ HCI_INTEL_WRITE_BOOT_PARAMS_COMMAND = hci.hci_vendor_command_op_code(0x000E)
|
|||||||
hci.HCI_Command.register_commands(globals())
|
hci.HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_Intel_Read_Version_Command(hci.HCI_Command):
|
class HCI_Intel_Read_Version_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
|
tlv: bytes = hci.field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_Intel_Read_Version_ReturnParameters)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_Intel_Read_Version_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_Intel_Read_Version_ReturnParameters]
|
||||||
|
):
|
||||||
param0: int = dataclasses.field(metadata=hci.metadata(1))
|
param0: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
("status", hci.STATUS_SPEC),
|
|
||||||
("tlv", "*"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(hci.HCI_StatusReturnParameters)
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class Hci_Intel_Secure_Send_Command(hci.HCI_Command):
|
class Hci_Intel_Secure_Send_Command(
|
||||||
|
hci.HCI_SyncCommand[hci.HCI_StatusReturnParameters]
|
||||||
|
):
|
||||||
data_type: int = dataclasses.field(metadata=hci.metadata(1))
|
data_type: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
data: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
data: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
("status", 1),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_Intel_Reset_Command(hci.HCI_Command):
|
class HCI_Intel_Reset_ReturnParameters(hci.HCI_ReturnParameters):
|
||||||
|
data: bytes = hci.field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_Intel_Reset_ReturnParameters)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_Intel_Reset_Command(hci.HCI_SyncCommand[HCI_Intel_Reset_ReturnParameters]):
|
||||||
reset_type: int = dataclasses.field(metadata=hci.metadata(1))
|
reset_type: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
patch_enable: int = dataclasses.field(metadata=hci.metadata(1))
|
patch_enable: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
ddc_reload: int = dataclasses.field(metadata=hci.metadata(1))
|
ddc_reload: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
boot_option: int = dataclasses.field(metadata=hci.metadata(1))
|
boot_option: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
boot_address: int = dataclasses.field(metadata=hci.metadata(4))
|
boot_address: int = dataclasses.field(metadata=hci.metadata(4))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
("data", "*"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class Hci_Intel_Write_Device_Config_Command(hci.HCI_Command):
|
class HCI_Intel_Write_Device_Config_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
data: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
params: bytes = hci.field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
("status", hci.STATUS_SPEC),
|
@hci.HCI_SyncCommand.sync_command(HCI_Intel_Write_Device_Config_ReturnParameters)
|
||||||
("params", "*"),
|
@dataclasses.dataclass
|
||||||
]
|
class HCI_Intel_Write_Device_Config_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_Intel_Write_Device_Config_ReturnParameters]
|
||||||
|
):
|
||||||
|
data: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -198,50 +201,51 @@ def _parse_tlv(data: bytes) -> list[tuple[ValueType, Any]]:
|
|||||||
value = data[2 : 2 + value_length]
|
value = data[2 : 2 + value_length]
|
||||||
typed_value: Any
|
typed_value: Any
|
||||||
|
|
||||||
if value_type == ValueType.END:
|
match value_type:
|
||||||
break
|
case ValueType.END:
|
||||||
|
break
|
||||||
|
|
||||||
if value_type in (ValueType.CNVI, ValueType.CNVR):
|
case ValueType.CNVI | ValueType.CNVR:
|
||||||
(v,) = struct.unpack("<I", value)
|
(v,) = struct.unpack("<I", value)
|
||||||
typed_value = (
|
typed_value = (
|
||||||
(((v >> 0) & 0xF) << 12)
|
(((v >> 0) & 0xF) << 12)
|
||||||
| (((v >> 4) & 0xF) << 0)
|
| (((v >> 4) & 0xF) << 0)
|
||||||
| (((v >> 8) & 0xF) << 4)
|
| (((v >> 8) & 0xF) << 4)
|
||||||
| (((v >> 24) & 0xF) << 8)
|
| (((v >> 24) & 0xF) << 8)
|
||||||
)
|
)
|
||||||
elif value_type == ValueType.HARDWARE_INFO:
|
case ValueType.HARDWARE_INFO:
|
||||||
(v,) = struct.unpack("<I", value)
|
(v,) = struct.unpack("<I", value)
|
||||||
typed_value = HardwareInfo(
|
typed_value = HardwareInfo(
|
||||||
HardwarePlatform((v >> 8) & 0xFF), HardwareVariant((v >> 16) & 0x3F)
|
HardwarePlatform((v >> 8) & 0xFF), HardwareVariant((v >> 16) & 0x3F)
|
||||||
)
|
)
|
||||||
elif value_type in (
|
case (
|
||||||
ValueType.USB_VENDOR_ID,
|
ValueType.USB_VENDOR_ID
|
||||||
ValueType.USB_PRODUCT_ID,
|
| ValueType.USB_PRODUCT_ID
|
||||||
ValueType.DEVICE_REVISION,
|
| ValueType.DEVICE_REVISION
|
||||||
):
|
):
|
||||||
(typed_value,) = struct.unpack("<H", value)
|
(typed_value,) = struct.unpack("<H", value)
|
||||||
elif value_type == ValueType.CURRENT_MODE_OF_OPERATION:
|
case ValueType.CURRENT_MODE_OF_OPERATION:
|
||||||
typed_value = ModeOfOperation(value[0])
|
typed_value = ModeOfOperation(value[0])
|
||||||
elif value_type in (
|
case (
|
||||||
ValueType.BUILD_TYPE,
|
ValueType.BUILD_TYPE
|
||||||
ValueType.BUILD_NUMBER,
|
| ValueType.BUILD_NUMBER
|
||||||
ValueType.SECURE_BOOT,
|
| ValueType.SECURE_BOOT
|
||||||
ValueType.OTP_LOCK,
|
| ValueType.OTP_LOCK
|
||||||
ValueType.API_LOCK,
|
| ValueType.API_LOCK
|
||||||
ValueType.DEBUG_LOCK,
|
| ValueType.DEBUG_LOCK
|
||||||
ValueType.SECURE_BOOT_ENGINE_TYPE,
|
| ValueType.SECURE_BOOT_ENGINE_TYPE
|
||||||
):
|
):
|
||||||
typed_value = value[0]
|
typed_value = value[0]
|
||||||
elif value_type == ValueType.TIMESTAMP:
|
case ValueType.TIMESTAMP:
|
||||||
typed_value = Timestamp(value[0], value[1])
|
typed_value = Timestamp(value[0], value[1])
|
||||||
elif value_type == ValueType.FIRMWARE_BUILD:
|
case ValueType.FIRMWARE_BUILD:
|
||||||
typed_value = FirmwareBuild(value[0], Timestamp(value[1], value[2]))
|
typed_value = FirmwareBuild(value[0], Timestamp(value[1], value[2]))
|
||||||
elif value_type == ValueType.BLUETOOTH_ADDRESS:
|
case ValueType.BLUETOOTH_ADDRESS:
|
||||||
typed_value = hci.Address(
|
typed_value = hci.Address(
|
||||||
value, address_type=hci.Address.PUBLIC_DEVICE_ADDRESS
|
value, address_type=hci.Address.PUBLIC_DEVICE_ADDRESS
|
||||||
)
|
)
|
||||||
else:
|
case _:
|
||||||
typed_value = value
|
typed_value = value
|
||||||
|
|
||||||
result.append((value_type, typed_value))
|
result.append((value_type, typed_value))
|
||||||
data = data[2 + value_length :]
|
data = data[2 + value_length :]
|
||||||
@@ -402,7 +406,7 @@ class Driver(common.Driver):
|
|||||||
self.host.on_hci_event_packet(event)
|
self.host.on_hci_event_packet(event)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not event.return_parameters == hci.HCI_SUCCESS:
|
if not event.return_parameters.status == hci.HCI_SUCCESS:
|
||||||
raise DriverError("HCI_Command_Complete_Event error")
|
raise DriverError("HCI_Command_Complete_Event error")
|
||||||
|
|
||||||
if self.max_in_flight_firmware_load_commands != event.num_hci_command_packets:
|
if self.max_in_flight_firmware_load_commands != event.num_hci_command_packets:
|
||||||
@@ -641,8 +645,8 @@ class Driver(common.Driver):
|
|||||||
while ddc_data:
|
while ddc_data:
|
||||||
ddc_len = 1 + ddc_data[0]
|
ddc_len = 1 + ddc_data[0]
|
||||||
ddc_payload = ddc_data[:ddc_len]
|
ddc_payload = ddc_data[:ddc_len]
|
||||||
await self.host.send_command(
|
await self.host.send_sync_command(
|
||||||
Hci_Intel_Write_Device_Config_Command(data=ddc_payload)
|
HCI_Intel_Write_Device_Config_Command(data=ddc_payload)
|
||||||
)
|
)
|
||||||
ddc_data = ddc_data[ddc_len:]
|
ddc_data = ddc_data[ddc_len:]
|
||||||
|
|
||||||
@@ -660,31 +664,34 @@ class Driver(common.Driver):
|
|||||||
|
|
||||||
async def read_device_info(self) -> dict[ValueType, Any]:
|
async def read_device_info(self) -> dict[ValueType, Any]:
|
||||||
self.host.ready = True
|
self.host.ready = True
|
||||||
response = await self.host.send_command(hci.HCI_Reset_Command())
|
response1 = await self.host.send_sync_command_raw(hci.HCI_Reset_Command())
|
||||||
if not (
|
if not isinstance(
|
||||||
isinstance(response, hci.HCI_Command_Complete_Event)
|
response1.return_parameters, hci.HCI_StatusReturnParameters
|
||||||
and response.return_parameters
|
) or response1.return_parameters.status not in (
|
||||||
in (hci.HCI_UNKNOWN_HCI_COMMAND_ERROR, hci.HCI_SUCCESS)
|
hci.HCI_UNKNOWN_HCI_COMMAND_ERROR,
|
||||||
|
hci.HCI_SUCCESS,
|
||||||
):
|
):
|
||||||
# When the controller is in operational mode, the response is a
|
# When the controller is in operational mode, the response is a
|
||||||
# successful response.
|
# successful response.
|
||||||
# When the controller is in bootloader mode,
|
# When the controller is in bootloader mode,
|
||||||
# HCI_UNKNOWN_HCI_COMMAND_ERROR is the expected response. Anything
|
# HCI_UNKNOWN_HCI_COMMAND_ERROR is the expected response. Anything
|
||||||
# else is a failure.
|
# else is a failure.
|
||||||
logger.warning(f"unexpected response: {response}")
|
logger.warning(f"unexpected response: {response1}")
|
||||||
raise DriverError("unexpected HCI response")
|
raise DriverError("unexpected HCI response")
|
||||||
|
|
||||||
# Read the firmware version.
|
# Read the firmware version.
|
||||||
response = await self.host.send_command(
|
response2 = await self.host.send_sync_command_raw(
|
||||||
HCI_Intel_Read_Version_Command(param0=0xFF)
|
HCI_Intel_Read_Version_Command(param0=0xFF)
|
||||||
)
|
)
|
||||||
if not isinstance(response, hci.HCI_Command_Complete_Event):
|
if (
|
||||||
raise DriverError("unexpected HCI response")
|
not isinstance(
|
||||||
|
response2.return_parameters, HCI_Intel_Read_Version_ReturnParameters
|
||||||
if response.return_parameters.status != 0: # type: ignore
|
)
|
||||||
|
or response2.return_parameters.status != 0
|
||||||
|
):
|
||||||
raise DriverError("HCI_Intel_Read_Version_Command error")
|
raise DriverError("HCI_Intel_Read_Version_Command error")
|
||||||
|
|
||||||
tlvs = _parse_tlv(response.return_parameters.tlv) # type: ignore
|
tlvs = _parse_tlv(response2.return_parameters.tlv) # type: ignore
|
||||||
|
|
||||||
# Convert the list to a dict. That's Ok here because we only expect each type
|
# Convert the list to a dict. That's Ok here because we only expect each type
|
||||||
# to appear just once.
|
# to appear just once.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Support for Realtek USB dongles.
|
|||||||
Based on various online bits of information, including the Linux kernel.
|
Based on various online bits of information, including the Linux kernel.
|
||||||
(see `drivers/bluetooth/btrtl.c`)
|
(see `drivers/bluetooth/btrtl.c`)
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import enum
|
import enum
|
||||||
@@ -31,10 +32,14 @@ import weakref
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from bumble import core, hci
|
from bumble import core, hci
|
||||||
from bumble.drivers import common
|
from bumble.drivers import common
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.host import Host
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -77,6 +82,7 @@ class RtlProjectId(enum.IntEnum):
|
|||||||
PROJECT_ID_8852A = 18
|
PROJECT_ID_8852A = 18
|
||||||
PROJECT_ID_8852B = 20
|
PROJECT_ID_8852B = 20
|
||||||
PROJECT_ID_8852C = 25
|
PROJECT_ID_8852C = 25
|
||||||
|
PROJECT_ID_8761C = 51
|
||||||
|
|
||||||
|
|
||||||
RTK_PROJECT_ID_TO_ROM = {
|
RTK_PROJECT_ID_TO_ROM = {
|
||||||
@@ -92,6 +98,7 @@ RTK_PROJECT_ID_TO_ROM = {
|
|||||||
18: RTK_ROM_LMP_8852A,
|
18: RTK_ROM_LMP_8852A,
|
||||||
20: RTK_ROM_LMP_8852A,
|
20: RTK_ROM_LMP_8852A,
|
||||||
25: RTK_ROM_LMP_8852A,
|
25: RTK_ROM_LMP_8852A,
|
||||||
|
51: RTK_ROM_LMP_8761A,
|
||||||
}
|
}
|
||||||
|
|
||||||
# List of USB (VendorID, ProductID) for Realtek-based devices.
|
# List of USB (VendorID, ProductID) for Realtek-based devices.
|
||||||
@@ -122,7 +129,12 @@ RTK_USB_PRODUCTS = {
|
|||||||
(0x2357, 0x0604),
|
(0x2357, 0x0604),
|
||||||
(0x2550, 0x8761),
|
(0x2550, 0x8761),
|
||||||
(0x2B89, 0x8761),
|
(0x2B89, 0x8761),
|
||||||
|
(0x2C0A, 0x8761),
|
||||||
(0x7392, 0xC611),
|
(0x7392, 0xC611),
|
||||||
|
# Realtek 8761CUV
|
||||||
|
(0x0B05, 0x1BF6),
|
||||||
|
(0x0BDA, 0xC761),
|
||||||
|
(0x7392, 0xF611),
|
||||||
# Realtek 8821AE
|
# Realtek 8821AE
|
||||||
(0x0B05, 0x17DC),
|
(0x0B05, 0x17DC),
|
||||||
(0x13D3, 0x3414),
|
(0x13D3, 0x3414),
|
||||||
@@ -182,23 +194,36 @@ HCI_RTK_DROP_FIRMWARE_COMMAND = hci.hci_vendor_command_op_code(0x66)
|
|||||||
hci.HCI_Command.register_commands(globals())
|
hci.HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HCI_RTK_Read_ROM_Version_Command(hci.HCI_Command):
|
class HCI_RTK_Read_ROM_Version_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
return_parameters_fields = [("status", hci.STATUS_SPEC), ("version", 1)]
|
version: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
@hci.HCI_SyncCommand.sync_command(HCI_RTK_Read_ROM_Version_ReturnParameters)
|
||||||
@dataclass
|
@dataclass
|
||||||
class HCI_RTK_Download_Command(hci.HCI_Command):
|
class HCI_RTK_Read_ROM_Version_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_RTK_Read_ROM_Version_ReturnParameters]
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HCI_RTK_Download_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
|
index: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_RTK_Download_ReturnParameters)
|
||||||
|
@dataclass
|
||||||
|
class HCI_RTK_Download_Command(hci.HCI_SyncCommand[HCI_RTK_Download_ReturnParameters]):
|
||||||
index: int = field(metadata=hci.metadata(1))
|
index: int = field(metadata=hci.metadata(1))
|
||||||
payload: bytes = field(metadata=hci.metadata(RTK_FRAGMENT_LENGTH))
|
payload: bytes = field(metadata=hci.metadata(RTK_FRAGMENT_LENGTH))
|
||||||
return_parameters_fields = [("status", hci.STATUS_SPEC), ("index", 1)]
|
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
@hci.HCI_SyncCommand.sync_command(hci.HCI_GenericReturnParameters)
|
||||||
@dataclass
|
@dataclass
|
||||||
class HCI_RTK_Drop_Firmware_Command(hci.HCI_Command):
|
class HCI_RTK_Drop_Firmware_Command(
|
||||||
|
hci.HCI_SyncCommand[hci.HCI_GenericReturnParameters]
|
||||||
|
):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -363,6 +388,15 @@ class Driver(common.Driver):
|
|||||||
fw_name="rtl8761bu_fw.bin",
|
fw_name="rtl8761bu_fw.bin",
|
||||||
config_name="rtl8761bu_config.bin",
|
config_name="rtl8761bu_config.bin",
|
||||||
),
|
),
|
||||||
|
# 8761CU
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8761A,
|
||||||
|
hci=(0x0E, 0x00),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
fw_name="rtl8761cu_fw.bin",
|
||||||
|
config_name="rtl8761cu_config.bin",
|
||||||
|
),
|
||||||
# 8822C
|
# 8822C
|
||||||
DriverInfo(
|
DriverInfo(
|
||||||
rom=RTK_ROM_LMP_8822B,
|
rom=RTK_ROM_LMP_8822B,
|
||||||
@@ -420,9 +454,17 @@ class Driver(common.Driver):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def find_driver_info(hci_version, hci_subversion, lmp_subversion):
|
def find_driver_info(hci_version, hci_subversion, lmp_subversion):
|
||||||
for driver_info in Driver.DRIVER_INFOS:
|
for driver_info in Driver.DRIVER_INFOS:
|
||||||
if driver_info.rom == lmp_subversion and driver_info.hci == (
|
if driver_info.rom == lmp_subversion and (
|
||||||
hci_subversion,
|
driver_info.hci
|
||||||
hci_version,
|
== (
|
||||||
|
hci_subversion,
|
||||||
|
hci_version,
|
||||||
|
)
|
||||||
|
or driver_info.hci
|
||||||
|
== (
|
||||||
|
hci_subversion,
|
||||||
|
0x0,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
return driver_info
|
return driver_info
|
||||||
|
|
||||||
@@ -467,7 +509,7 @@ class Driver(common.Driver):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check(host):
|
def check(host: Host) -> bool:
|
||||||
if not host.hci_metadata:
|
if not host.hci_metadata:
|
||||||
logger.debug("USB metadata not found")
|
logger.debug("USB metadata not found")
|
||||||
return False
|
return False
|
||||||
@@ -491,37 +533,44 @@ class Driver(common.Driver):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_loaded_firmware_version(host):
|
async def get_loaded_firmware_version(host: Host) -> int | None:
|
||||||
response = await host.send_command(HCI_RTK_Read_ROM_Version_Command())
|
response1 = await host.send_sync_command_raw(HCI_RTK_Read_ROM_Version_Command())
|
||||||
|
if (
|
||||||
if response.return_parameters.status != hci.HCI_SUCCESS:
|
not isinstance(
|
||||||
|
response1.return_parameters, HCI_RTK_Read_ROM_Version_ReturnParameters
|
||||||
|
)
|
||||||
|
or response1.return_parameters.status != hci.HCI_SUCCESS
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
response = await host.send_command(
|
response2 = await host.send_sync_command(
|
||||||
hci.HCI_Read_Local_Version_Information_Command(), check_result=True
|
hci.HCI_Read_Local_Version_Information_Command()
|
||||||
)
|
|
||||||
return (
|
|
||||||
response.return_parameters.hci_subversion << 16
|
|
||||||
| response.return_parameters.lmp_subversion
|
|
||||||
)
|
)
|
||||||
|
return response2.hci_subversion << 16 | response2.lmp_subversion
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def driver_info_for_host(cls, host):
|
async def driver_info_for_host(cls, host: Host) -> DriverInfo | None:
|
||||||
try:
|
try:
|
||||||
await host.send_command(
|
await host.send_sync_command(
|
||||||
hci.HCI_Reset_Command(),
|
hci.HCI_Reset_Command(),
|
||||||
check_result=True,
|
|
||||||
response_timeout=cls.POST_RESET_DELAY,
|
response_timeout=cls.POST_RESET_DELAY,
|
||||||
)
|
)
|
||||||
host.ready = True # Needed to let the host know the controller is ready.
|
host.ready = True # Needed to let the host know the controller is ready.
|
||||||
except asyncio.exceptions.TimeoutError:
|
except asyncio.exceptions.TimeoutError:
|
||||||
logger.warning("timeout waiting for hci reset, retrying")
|
logger.warning("timeout waiting for hci reset, retrying")
|
||||||
await host.send_command(hci.HCI_Reset_Command(), check_result=True)
|
await host.send_sync_command(hci.HCI_Reset_Command())
|
||||||
host.ready = True
|
host.ready = True
|
||||||
|
|
||||||
command = hci.HCI_Read_Local_Version_Information_Command()
|
response = await host.send_sync_command_raw(
|
||||||
response = await host.send_command(command, check_result=True)
|
hci.HCI_Read_Local_Version_Information_Command()
|
||||||
if response.command_opcode != command.op_code:
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(
|
||||||
|
response.return_parameters,
|
||||||
|
hci.HCI_Read_Local_Version_Information_ReturnParameters,
|
||||||
|
)
|
||||||
|
or response.return_parameters.status != hci.HCI_SUCCESS
|
||||||
|
):
|
||||||
logger.error("failed to probe local version information")
|
logger.error("failed to probe local version information")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -546,7 +595,7 @@ class Driver(common.Driver):
|
|||||||
return driver_info
|
return driver_info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def for_host(cls, host, force=False):
|
async def for_host(cls, host: Host, force: bool = False):
|
||||||
# Check that a driver is needed for this host
|
# Check that a driver is needed for this host
|
||||||
if not force and not cls.check(host):
|
if not force and not cls.check(host):
|
||||||
return None
|
return None
|
||||||
@@ -601,15 +650,21 @@ class Driver(common.Driver):
|
|||||||
|
|
||||||
# TODO: load the firmware
|
# TODO: load the firmware
|
||||||
|
|
||||||
async def download_for_rtl8723b(self):
|
async def download_for_rtl8723b(self) -> int | None:
|
||||||
if self.driver_info.has_rom_version:
|
if self.driver_info.has_rom_version:
|
||||||
response = await self.host.send_command(
|
response1 = await self.host.send_sync_command_raw(
|
||||||
HCI_RTK_Read_ROM_Version_Command(), check_result=True
|
HCI_RTK_Read_ROM_Version_Command()
|
||||||
)
|
)
|
||||||
if response.return_parameters.status != hci.HCI_SUCCESS:
|
if (
|
||||||
|
not isinstance(
|
||||||
|
response1.return_parameters,
|
||||||
|
HCI_RTK_Read_ROM_Version_ReturnParameters,
|
||||||
|
)
|
||||||
|
or response1.return_parameters.status != hci.HCI_SUCCESS
|
||||||
|
):
|
||||||
logger.warning("can't get ROM version")
|
logger.warning("can't get ROM version")
|
||||||
return None
|
return None
|
||||||
rom_version = response.return_parameters.version
|
rom_version = response1.return_parameters.version
|
||||||
logger.debug(f"ROM version before download: {rom_version:04X}")
|
logger.debug(f"ROM version before download: {rom_version:04X}")
|
||||||
else:
|
else:
|
||||||
rom_version = 0
|
rom_version = 0
|
||||||
@@ -644,21 +699,25 @@ class Driver(common.Driver):
|
|||||||
fragment_offset = fragment_index * RTK_FRAGMENT_LENGTH
|
fragment_offset = fragment_index * RTK_FRAGMENT_LENGTH
|
||||||
fragment = payload[fragment_offset : fragment_offset + RTK_FRAGMENT_LENGTH]
|
fragment = payload[fragment_offset : fragment_offset + RTK_FRAGMENT_LENGTH]
|
||||||
logger.debug(f"downloading fragment {fragment_index}")
|
logger.debug(f"downloading fragment {fragment_index}")
|
||||||
await self.host.send_command(
|
await self.host.send_sync_command(
|
||||||
HCI_RTK_Download_Command(index=download_index, payload=fragment),
|
HCI_RTK_Download_Command(index=download_index, payload=fragment)
|
||||||
check_result=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("download complete!")
|
logger.debug("download complete!")
|
||||||
|
|
||||||
# Read the version again
|
# Read the version again
|
||||||
response = await self.host.send_command(
|
response2 = await self.host.send_sync_command_raw(
|
||||||
HCI_RTK_Read_ROM_Version_Command(), check_result=True
|
HCI_RTK_Read_ROM_Version_Command()
|
||||||
)
|
)
|
||||||
if response.return_parameters.status != hci.HCI_SUCCESS:
|
if (
|
||||||
|
not isinstance(
|
||||||
|
response2.return_parameters, HCI_RTK_Read_ROM_Version_ReturnParameters
|
||||||
|
)
|
||||||
|
or response2.return_parameters.status != hci.HCI_SUCCESS
|
||||||
|
):
|
||||||
logger.warning("can't get ROM version")
|
logger.warning("can't get ROM version")
|
||||||
else:
|
else:
|
||||||
rom_version = response.return_parameters.version
|
rom_version = response2.return_parameters.version
|
||||||
logger.debug(f"ROM version after download: {rom_version:02X}")
|
logger.debug(f"ROM version after download: {rom_version:02X}")
|
||||||
|
|
||||||
return firmware.version
|
return firmware.version
|
||||||
@@ -680,7 +739,7 @@ class Driver(common.Driver):
|
|||||||
|
|
||||||
async def init_controller(self):
|
async def init_controller(self):
|
||||||
await self.download_firmware()
|
await self.download_firmware()
|
||||||
await self.host.send_command(hci.HCI_Reset_Command(), check_result=True)
|
await self.host.send_sync_command(hci.HCI_Reset_Command())
|
||||||
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# 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 logging
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from bumble.gatt import (
|
|
||||||
GATT_APPEARANCE_CHARACTERISTIC,
|
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
|
||||||
GATT_GENERIC_ACCESS_SERVICE,
|
|
||||||
Characteristic,
|
|
||||||
Service,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Logging
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Classes
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
class GenericAccessService(Service):
|
|
||||||
def __init__(self, device_name, appearance=(0, 0)):
|
|
||||||
device_name_characteristic = Characteristic(
|
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.READ,
|
|
||||||
Characteristic.READABLE,
|
|
||||||
device_name.encode('utf-8')[:248],
|
|
||||||
)
|
|
||||||
|
|
||||||
appearance_characteristic = Characteristic(
|
|
||||||
GATT_APPEARANCE_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.READ,
|
|
||||||
Characteristic.READABLE,
|
|
||||||
struct.pack('<H', (appearance[0] << 6) | appearance[1]),
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
GATT_GENERIC_ACCESS_SERVICE,
|
|
||||||
[device_name_characteristic, appearance_characteristic],
|
|
||||||
)
|
|
||||||
@@ -29,7 +29,7 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from collections.abc import Iterable, Sequence
|
from collections.abc import Iterable, Sequence
|
||||||
from typing import TypeVar
|
from typing import ClassVar, TypeVar
|
||||||
|
|
||||||
from bumble.att import Attribute, AttributeValue, AttributeValueV2
|
from bumble.att import Attribute, AttributeValue, AttributeValueV2
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
@@ -403,7 +403,7 @@ class TemplateService(Service):
|
|||||||
to expose their UUID as a class property
|
to expose their UUID as a class property
|
||||||
'''
|
'''
|
||||||
|
|
||||||
UUID: UUID
|
UUID: ClassVar[UUID]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ from datetime import datetime
|
|||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
|
ClassVar,
|
||||||
Generic,
|
Generic,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
overload,
|
overload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble import att, core, l2cap, utils
|
from bumble import att, core, l2cap, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import UUID, InvalidStateError
|
from bumble.core import UUID, InvalidStateError
|
||||||
@@ -249,10 +252,10 @@ class ProfileServiceProxy:
|
|||||||
Base class for profile-specific service proxies
|
Base class for profile-specific service proxies
|
||||||
'''
|
'''
|
||||||
|
|
||||||
SERVICE_CLASS: type[TemplateService]
|
SERVICE_CLASS: ClassVar[type[TemplateService]]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_client(cls, client: Client) -> ProfileServiceProxy | None:
|
def from_client(cls, client: Client) -> Self | None:
|
||||||
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
|
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
|
||||||
|
|
||||||
|
|
||||||
@@ -285,8 +288,6 @@ class Client:
|
|||||||
self._bearer_id = (
|
self._bearer_id = (
|
||||||
f'[0x{bearer.connection.handle:04X}|CID=0x{bearer.source_cid:04X}]'
|
f'[0x{bearer.connection.handle:04X}|CID=0x{bearer.source_cid:04X}]'
|
||||||
)
|
)
|
||||||
# Fill the mtu.
|
|
||||||
bearer.on_att_mtu_update(att.ATT_DEFAULT_MTU)
|
|
||||||
self.connection = bearer.connection
|
self.connection = bearer.connection
|
||||||
else:
|
else:
|
||||||
bearer.on(bearer.EVENT_DISCONNECTION, self.on_disconnection)
|
bearer.on(bearer.EVENT_DISCONNECTION, self.on_disconnection)
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ class Server(utils.EventEmitter):
|
|||||||
channel.connection.handle,
|
channel.connection.handle,
|
||||||
channel.source_cid,
|
channel.source_cid,
|
||||||
)
|
)
|
||||||
channel.att_mtu = att.ATT_DEFAULT_MTU
|
|
||||||
channel.sink = lambda pdu: self.on_gatt_pdu(
|
channel.sink = lambda pdu: self.on_gatt_pdu(
|
||||||
channel, att.ATT_PDU.from_bytes(pdu)
|
channel, att.ATT_PDU.from_bytes(pdu)
|
||||||
)
|
)
|
||||||
@@ -777,6 +776,18 @@ class Server(utils.EventEmitter):
|
|||||||
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.starting_handle == 0x0000
|
||||||
|
or request.starting_handle > request.ending_handle
|
||||||
|
):
|
||||||
|
response = att.ATT_Error_Response(
|
||||||
|
request_opcode_in_error=request.op_code,
|
||||||
|
attribute_handle_in_error=request.starting_handle,
|
||||||
|
error_code=att.ATT_INVALID_HANDLE_ERROR,
|
||||||
|
)
|
||||||
|
self.send_response(bearer, response)
|
||||||
|
return
|
||||||
|
|
||||||
attributes: list[tuple[int, bytes]] = []
|
attributes: list[tuple[int, bytes]] = []
|
||||||
for attribute in (
|
for attribute in (
|
||||||
attribute
|
attribute
|
||||||
@@ -977,6 +988,94 @@ class Server(utils.EventEmitter):
|
|||||||
|
|
||||||
self.send_response(bearer, response)
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
|
@utils.AsyncRunner.run_in_task()
|
||||||
|
async def on_att_read_multiple_request(
|
||||||
|
self, bearer: att.Bearer, request: att.ATT_Read_Multiple_Request
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 3, Part F - 3.4.4.7 Read Multiple Request.
|
||||||
|
'''
|
||||||
|
response: att.ATT_PDU
|
||||||
|
|
||||||
|
pdu_space_available = bearer.att_mtu - 1
|
||||||
|
values: list[bytes] = []
|
||||||
|
|
||||||
|
for handle in request.set_of_handles:
|
||||||
|
if not (attribute := self.get_attribute(handle)):
|
||||||
|
response = att.ATT_Error_Response(
|
||||||
|
request_opcode_in_error=request.op_code,
|
||||||
|
attribute_handle_in_error=handle,
|
||||||
|
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
|
)
|
||||||
|
self.send_response(bearer, response)
|
||||||
|
return
|
||||||
|
# No need to catch permission errors here, since these attributes
|
||||||
|
# must all be world-readable
|
||||||
|
attribute_value = await attribute.read_value(bearer)
|
||||||
|
# Check the attribute value size
|
||||||
|
max_attribute_size = min(bearer.att_mtu - 1, 251)
|
||||||
|
if len(attribute_value) > max_attribute_size:
|
||||||
|
# We need to truncate
|
||||||
|
attribute_value = attribute_value[:max_attribute_size]
|
||||||
|
|
||||||
|
# Check if there is enough space
|
||||||
|
entry_size = len(attribute_value)
|
||||||
|
if pdu_space_available < entry_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Add the attribute to the list
|
||||||
|
values.append(attribute_value)
|
||||||
|
pdu_space_available -= entry_size
|
||||||
|
|
||||||
|
response = att.ATT_Read_Multiple_Response(set_of_values=b''.join(values))
|
||||||
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
|
@utils.AsyncRunner.run_in_task()
|
||||||
|
async def on_att_read_multiple_variable_request(
|
||||||
|
self, bearer: att.Bearer, request: att.ATT_Read_Multiple_Variable_Request
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 3, Part F - 3.4.4.11 Read Multiple Variable Request.
|
||||||
|
'''
|
||||||
|
response: att.ATT_PDU
|
||||||
|
|
||||||
|
pdu_space_available = bearer.att_mtu - 1
|
||||||
|
length_value_tuple_list: list[tuple[int, bytes]] = []
|
||||||
|
|
||||||
|
for handle in request.set_of_handles:
|
||||||
|
if not (attribute := self.get_attribute(handle)):
|
||||||
|
response = att.ATT_Error_Response(
|
||||||
|
request_opcode_in_error=request.op_code,
|
||||||
|
attribute_handle_in_error=handle,
|
||||||
|
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
|
)
|
||||||
|
self.send_response(bearer, response)
|
||||||
|
return
|
||||||
|
# No need to catch permission errors here, since these attributes
|
||||||
|
# must all be world-readable
|
||||||
|
attribute_value = await attribute.read_value(bearer)
|
||||||
|
length = len(attribute_value)
|
||||||
|
# Check the attribute value size
|
||||||
|
max_attribute_size = min(bearer.att_mtu - 3, 251)
|
||||||
|
if len(attribute_value) > max_attribute_size:
|
||||||
|
# We need to truncate
|
||||||
|
attribute_value = attribute_value[:max_attribute_size]
|
||||||
|
|
||||||
|
# Check if there is enough space
|
||||||
|
entry_size = 2 + len(attribute_value)
|
||||||
|
|
||||||
|
# Add the attribute to the list
|
||||||
|
length_value_tuple_list.append((length, attribute_value))
|
||||||
|
pdu_space_available -= entry_size
|
||||||
|
|
||||||
|
if pdu_space_available <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
response = att.ATT_Read_Multiple_Variable_Response(
|
||||||
|
length_value_tuple_list=length_value_tuple_list
|
||||||
|
)
|
||||||
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
@utils.AsyncRunner.run_in_task()
|
@utils.AsyncRunner.run_in_task()
|
||||||
async def on_att_write_request(
|
async def on_att_write_request(
|
||||||
self, bearer: att.Bearer, request: att.ATT_Write_Request
|
self, bearer: att.Bearer, request: att.ATT_Write_Request
|
||||||
|
|||||||
2408
bumble/hci.py
2408
bumble/hci.py
File diff suppressed because it is too large
Load Diff
162
bumble/hfp.py
162
bumble/hfp.py
@@ -26,7 +26,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar
|
from typing import Any, ClassVar, Literal, overload
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
@@ -68,6 +68,8 @@ class HfpProtocolError(ProtocolError):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HfpProtocol:
|
class HfpProtocol:
|
||||||
|
MAX_BUFFER_SIZE: ClassVar[int] = 65536
|
||||||
|
|
||||||
dlc: rfcomm.DLC
|
dlc: rfcomm.DLC
|
||||||
buffer: str
|
buffer: str
|
||||||
lines: collections.deque
|
lines: collections.deque
|
||||||
@@ -84,10 +86,19 @@ class HfpProtocol:
|
|||||||
def feed(self, data: bytes | str) -> None:
|
def feed(self, data: bytes | str) -> None:
|
||||||
# Convert the data to a string if needed
|
# Convert the data to a string if needed
|
||||||
if isinstance(data, bytes):
|
if isinstance(data, bytes):
|
||||||
data = data.decode('utf-8')
|
data = data.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
logger.debug(f'<<< Data received: {data}')
|
logger.debug(f'<<< Data received: {data}')
|
||||||
|
|
||||||
|
# Drop incoming data if it would overflow the buffer; keep existing
|
||||||
|
# partial packet state intact so a future clean packet can still parse.
|
||||||
|
if len(self.buffer) + len(data) > self.MAX_BUFFER_SIZE:
|
||||||
|
logger.warning(
|
||||||
|
'HFP buffer overflow (>%d bytes), dropping incoming data',
|
||||||
|
self.MAX_BUFFER_SIZE,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Add to the buffer and look for lines
|
# Add to the buffer and look for lines
|
||||||
self.buffer += data
|
self.buffer += data
|
||||||
while (separator := self.buffer.find('\r')) >= 0:
|
while (separator := self.buffer.find('\r')) >= 0:
|
||||||
@@ -420,61 +431,6 @@ class CmeError(enum.IntEnum):
|
|||||||
# Hands-Free Control Interoperability Requirements
|
# Hands-Free Control Interoperability Requirements
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
# Response codes.
|
|
||||||
RESPONSE_CODES = {
|
|
||||||
"+APLSIRI",
|
|
||||||
"+BAC",
|
|
||||||
"+BCC",
|
|
||||||
"+BCS",
|
|
||||||
"+BIA",
|
|
||||||
"+BIEV",
|
|
||||||
"+BIND",
|
|
||||||
"+BINP",
|
|
||||||
"+BLDN",
|
|
||||||
"+BRSF",
|
|
||||||
"+BTRH",
|
|
||||||
"+BVRA",
|
|
||||||
"+CCWA",
|
|
||||||
"+CHLD",
|
|
||||||
"+CHUP",
|
|
||||||
"+CIND",
|
|
||||||
"+CLCC",
|
|
||||||
"+CLIP",
|
|
||||||
"+CMEE",
|
|
||||||
"+CMER",
|
|
||||||
"+CNUM",
|
|
||||||
"+COPS",
|
|
||||||
"+IPHONEACCEV",
|
|
||||||
"+NREC",
|
|
||||||
"+VGM",
|
|
||||||
"+VGS",
|
|
||||||
"+VTS",
|
|
||||||
"+XAPL",
|
|
||||||
"A",
|
|
||||||
"D",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Unsolicited responses and statuses.
|
|
||||||
UNSOLICITED_CODES = {
|
|
||||||
"+APLSIRI",
|
|
||||||
"+BCS",
|
|
||||||
"+BIND",
|
|
||||||
"+BSIR",
|
|
||||||
"+BTRH",
|
|
||||||
"+BVRA",
|
|
||||||
"+CCWA",
|
|
||||||
"+CIEV",
|
|
||||||
"+CLIP",
|
|
||||||
"+VGM",
|
|
||||||
"+VGS",
|
|
||||||
"BLACKLISTED",
|
|
||||||
"BUSY",
|
|
||||||
"DELAYED",
|
|
||||||
"NO ANSWER",
|
|
||||||
"NO CARRIER",
|
|
||||||
"RING",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Status codes
|
# Status codes
|
||||||
STATUS_CODES = {
|
STATUS_CODES = {
|
||||||
"+CME ERROR",
|
"+CME ERROR",
|
||||||
@@ -727,12 +683,9 @@ class HfProtocol(utils.EventEmitter):
|
|||||||
|
|
||||||
dlc: rfcomm.DLC
|
dlc: rfcomm.DLC
|
||||||
command_lock: asyncio.Lock
|
command_lock: asyncio.Lock
|
||||||
if TYPE_CHECKING:
|
pending_command: str | None = None
|
||||||
response_queue: asyncio.Queue[AtResponse]
|
response_queue: asyncio.Queue[AtResponse]
|
||||||
unsolicited_queue: asyncio.Queue[AtResponse | None]
|
unsolicited_queue: asyncio.Queue[AtResponse | None]
|
||||||
else:
|
|
||||||
response_queue: asyncio.Queue
|
|
||||||
unsolicited_queue: asyncio.Queue
|
|
||||||
read_buffer: bytearray
|
read_buffer: bytearray
|
||||||
active_codec: AudioCodec
|
active_codec: AudioCodec
|
||||||
|
|
||||||
@@ -805,16 +758,39 @@ class HfProtocol(utils.EventEmitter):
|
|||||||
self.read_buffer = self.read_buffer[trailer + 2 :]
|
self.read_buffer = self.read_buffer[trailer + 2 :]
|
||||||
|
|
||||||
# Forward the received code to the correct queue.
|
# Forward the received code to the correct queue.
|
||||||
if self.command_lock.locked() and (
|
if self.pending_command and (
|
||||||
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
response.code in STATUS_CODES or response.code in self.pending_command
|
||||||
):
|
):
|
||||||
self.response_queue.put_nowait(response)
|
self.response_queue.put_nowait(response)
|
||||||
elif response.code in UNSOLICITED_CODES:
|
|
||||||
self.unsolicited_queue.put_nowait(response)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
self.unsolicited_queue.put_nowait(response)
|
||||||
f"dropping unexpected response with code '{response.code}'"
|
|
||||||
)
|
@overload
|
||||||
|
async def execute_command(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
*,
|
||||||
|
response_type: Literal[AtResponseType.NONE] = AtResponseType.NONE,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def execute_command(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
*,
|
||||||
|
response_type: Literal[AtResponseType.SINGLE],
|
||||||
|
) -> AtResponse: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def execute_command(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
*,
|
||||||
|
response_type: Literal[AtResponseType.MULTIPLE],
|
||||||
|
) -> list[AtResponse]: ...
|
||||||
|
|
||||||
async def execute_command(
|
async def execute_command(
|
||||||
self,
|
self,
|
||||||
@@ -835,27 +811,34 @@ class HfProtocol(utils.EventEmitter):
|
|||||||
asyncio.TimeoutError: the status is not received after a timeout (default 1 second).
|
asyncio.TimeoutError: the status is not received after a timeout (default 1 second).
|
||||||
ProtocolError: the status is not OK.
|
ProtocolError: the status is not OK.
|
||||||
"""
|
"""
|
||||||
async with self.command_lock:
|
try:
|
||||||
logger.debug(f">>> {cmd}")
|
async with self.command_lock:
|
||||||
self.dlc.write(cmd + '\r')
|
self.pending_command = cmd
|
||||||
responses: list[AtResponse] = []
|
logger.debug(f">>> {cmd}")
|
||||||
|
self.dlc.write(cmd + '\r')
|
||||||
|
responses: list[AtResponse] = []
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
self.response_queue.get(), timeout=timeout
|
self.response_queue.get(), timeout=timeout
|
||||||
)
|
)
|
||||||
if result.code == 'OK':
|
if result.code == 'OK':
|
||||||
if response_type == AtResponseType.SINGLE and len(responses) != 1:
|
if (
|
||||||
raise HfpProtocolError("NO ANSWER")
|
response_type == AtResponseType.SINGLE
|
||||||
|
and len(responses) != 1
|
||||||
|
):
|
||||||
|
raise HfpProtocolError("NO ANSWER")
|
||||||
|
|
||||||
if response_type == AtResponseType.MULTIPLE:
|
if response_type == AtResponseType.MULTIPLE:
|
||||||
return responses
|
return responses
|
||||||
if response_type == AtResponseType.SINGLE:
|
if response_type == AtResponseType.SINGLE:
|
||||||
return responses[0]
|
return responses[0]
|
||||||
return None
|
return None
|
||||||
if result.code in STATUS_CODES:
|
if result.code in STATUS_CODES:
|
||||||
raise HfpProtocolError(result.code)
|
raise HfpProtocolError(result.code)
|
||||||
responses.append(result)
|
responses.append(result)
|
||||||
|
finally:
|
||||||
|
self.pending_command = None
|
||||||
|
|
||||||
async def initiate_slc(self):
|
async def initiate_slc(self):
|
||||||
"""4.2.1 Service Level Connection Initialization."""
|
"""4.2.1 Service Level Connection Initialization."""
|
||||||
@@ -1067,7 +1050,6 @@ class HfProtocol(utils.EventEmitter):
|
|||||||
responses = await self.execute_command(
|
responses = await self.execute_command(
|
||||||
"AT+CLCC", response_type=AtResponseType.MULTIPLE
|
"AT+CLCC", response_type=AtResponseType.MULTIPLE
|
||||||
)
|
)
|
||||||
assert isinstance(responses, list)
|
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
for response in responses:
|
for response in responses:
|
||||||
|
|||||||
@@ -312,11 +312,11 @@ class HID(ABC, utils.EventEmitter):
|
|||||||
|
|
||||||
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
|
||||||
self.l2cap_ctrl_channel.send_pdu(msg)
|
self.l2cap_ctrl_channel.write(msg)
|
||||||
|
|
||||||
def send_pdu_on_intr(self, msg: bytes) -> None:
|
def send_pdu_on_intr(self, msg: bytes) -> None:
|
||||||
assert self.l2cap_intr_channel
|
assert self.l2cap_intr_channel
|
||||||
self.l2cap_intr_channel.send_pdu(msg)
|
self.l2cap_intr_channel.write(msg)
|
||||||
|
|
||||||
def send_data(self, data: bytes) -> None:
|
def send_data(self, data: bytes) -> None:
|
||||||
if self.role == HID.Role.HOST:
|
if self.role == HID.Role.HOST:
|
||||||
|
|||||||
536
bumble/host.py
536
bumble/host.py
@@ -21,13 +21,16 @@ import asyncio
|
|||||||
import collections
|
import collections
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import logging
|
import logging
|
||||||
import struct
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
||||||
|
|
||||||
from bumble import drivers, hci, utils
|
from bumble import drivers, hci, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import ConnectionPHY, InvalidStateError, PhysicalTransport
|
from bumble.core import (
|
||||||
|
ConnectionPHY,
|
||||||
|
InvalidStateError,
|
||||||
|
PhysicalTransport,
|
||||||
|
)
|
||||||
from bumble.l2cap import L2CAP_PDU
|
from bumble.l2cap import L2CAP_PDU
|
||||||
from bumble.snoop import Snooper
|
from bumble.snoop import Snooper
|
||||||
from bumble.transport.common import TransportLostError
|
from bumble.transport.common import TransportLostError
|
||||||
@@ -35,7 +38,6 @@ from bumble.transport.common import TransportLostError
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.transport.common import TransportSink, TransportSource
|
from bumble.transport.common import TransportSink, TransportSource
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -236,6 +238,9 @@ class IsoLink:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
_RP = TypeVar('_RP', bound=hci.HCI_ReturnParameters)
|
||||||
|
|
||||||
|
|
||||||
class Host(utils.EventEmitter):
|
class Host(utils.EventEmitter):
|
||||||
connections: dict[int, Connection]
|
connections: dict[int, Connection]
|
||||||
cis_links: dict[int, IsoLink]
|
cis_links: dict[int, IsoLink]
|
||||||
@@ -264,13 +269,20 @@ class Host(utils.EventEmitter):
|
|||||||
self.bis_links = {} # BIS links, by connection handle
|
self.bis_links = {} # BIS links, by connection handle
|
||||||
self.sco_links = {} # SCO links, by connection handle
|
self.sco_links = {} # SCO links, by connection handle
|
||||||
self.bigs = {} # BIG Handle to BIS Handles
|
self.bigs = {} # BIG Handle to BIS Handles
|
||||||
self.pending_command = None
|
self.pending_command: hci.HCI_SyncCommand | hci.HCI_AsyncCommand | None = None
|
||||||
self.pending_response: asyncio.Future[Any] | None = None
|
self.pending_response: (
|
||||||
|
asyncio.Future[
|
||||||
|
hci.HCI_Command_Complete_Event | hci.HCI_Command_Status_Event
|
||||||
|
]
|
||||||
|
| None
|
||||||
|
) = None
|
||||||
self.number_of_supported_advertising_sets = 0
|
self.number_of_supported_advertising_sets = 0
|
||||||
self.maximum_advertising_data_length = 31
|
self.maximum_advertising_data_length = 31
|
||||||
self.local_version = None
|
self.local_version: (
|
||||||
|
hci.HCI_Read_Local_Version_Information_ReturnParameters | None
|
||||||
|
) = None
|
||||||
self.local_supported_commands = 0
|
self.local_supported_commands = 0
|
||||||
self.local_le_features = 0
|
self.local_le_features = hci.LeFeatureMask(0) # LE features
|
||||||
self.local_lmp_features = hci.LmpFeatureMask(0) # Classic LMP features
|
self.local_lmp_features = hci.LmpFeatureMask(0) # Classic LMP features
|
||||||
self.suggested_max_tx_octets = 251 # Max allowed
|
self.suggested_max_tx_octets = 251 # Max allowed
|
||||||
self.suggested_max_tx_time = 2120 # Max allowed
|
self.suggested_max_tx_time = 2120 # Max allowed
|
||||||
@@ -312,7 +324,7 @@ class Host(utils.EventEmitter):
|
|||||||
self.emit('flush')
|
self.emit('flush')
|
||||||
self.command_semaphore.release()
|
self.command_semaphore.release()
|
||||||
|
|
||||||
async def reset(self, driver_factory=drivers.get_driver_for_host):
|
async def reset(self, driver_factory=drivers.get_driver_for_host) -> None:
|
||||||
if self.ready:
|
if self.ready:
|
||||||
self.ready = False
|
self.ready = False
|
||||||
await self.flush()
|
await self.flush()
|
||||||
@@ -330,57 +342,61 @@ class Host(utils.EventEmitter):
|
|||||||
|
|
||||||
# Send a reset command unless a driver has already done so.
|
# Send a reset command unless a driver has already done so.
|
||||||
if reset_needed:
|
if reset_needed:
|
||||||
await self.send_command(hci.HCI_Reset_Command(), check_result=True)
|
await self.send_sync_command(hci.HCI_Reset_Command())
|
||||||
self.ready = True
|
self.ready = True
|
||||||
|
|
||||||
response = await self.send_command(
|
response1 = await self.send_sync_command(
|
||||||
hci.HCI_Read_Local_Supported_Commands_Command(), check_result=True
|
hci.HCI_Read_Local_Supported_Commands_Command()
|
||||||
)
|
)
|
||||||
self.local_supported_commands = int.from_bytes(
|
self.local_supported_commands = int.from_bytes(
|
||||||
response.return_parameters.supported_commands, 'little'
|
response1.supported_commands, 'little'
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.supports_command(hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
|
||||||
response = await self.send_command(
|
|
||||||
hci.HCI_LE_Read_Local_Supported_Features_Command(), check_result=True
|
|
||||||
)
|
|
||||||
self.local_le_features = struct.unpack(
|
|
||||||
'<Q', response.return_parameters.le_features
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
if self.supports_command(hci.HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
|
if self.supports_command(hci.HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
|
||||||
response = await self.send_command(
|
self.local_version = await self.send_sync_command(
|
||||||
hci.HCI_Read_Local_Version_Information_Command(), check_result=True
|
hci.HCI_Read_Local_Version_Information_Command()
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.supports_command(hci.HCI_LE_READ_ALL_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
||||||
|
response2 = await self.send_sync_command(
|
||||||
|
hci.HCI_LE_Read_All_Local_Supported_Features_Command()
|
||||||
|
)
|
||||||
|
self.local_le_features = hci.LeFeatureMask(
|
||||||
|
int.from_bytes(response2.le_features, 'little')
|
||||||
|
)
|
||||||
|
elif self.supports_command(hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
||||||
|
response3 = await self.send_sync_command(
|
||||||
|
hci.HCI_LE_Read_Local_Supported_Features_Command()
|
||||||
|
)
|
||||||
|
self.local_le_features = hci.LeFeatureMask(
|
||||||
|
int.from_bytes(response3.le_features, 'little')
|
||||||
)
|
)
|
||||||
self.local_version = response.return_parameters
|
|
||||||
|
|
||||||
if self.supports_command(hci.HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND):
|
if self.supports_command(hci.HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND):
|
||||||
max_page_number = 0
|
max_page_number = 0
|
||||||
page_number = 0
|
page_number = 0
|
||||||
lmp_features = 0
|
lmp_features = 0
|
||||||
while page_number <= max_page_number:
|
while page_number <= max_page_number:
|
||||||
response = await self.send_command(
|
response4 = await self.send_sync_command(
|
||||||
hci.HCI_Read_Local_Extended_Features_Command(
|
hci.HCI_Read_Local_Extended_Features_Command(
|
||||||
page_number=page_number
|
page_number=page_number
|
||||||
),
|
)
|
||||||
check_result=True,
|
|
||||||
)
|
)
|
||||||
lmp_features |= int.from_bytes(
|
lmp_features |= int.from_bytes(
|
||||||
response.return_parameters.extended_lmp_features, 'little'
|
response4.extended_lmp_features, 'little'
|
||||||
) << (64 * page_number)
|
) << (64 * page_number)
|
||||||
max_page_number = response.return_parameters.maximum_page_number
|
max_page_number = response4.maximum_page_number
|
||||||
page_number += 1
|
page_number += 1
|
||||||
self.local_lmp_features = hci.LmpFeatureMask(lmp_features)
|
self.local_lmp_features = hci.LmpFeatureMask(lmp_features)
|
||||||
|
|
||||||
elif self.supports_command(hci.HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
elif self.supports_command(hci.HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
||||||
response = await self.send_command(
|
response5 = await self.send_sync_command(
|
||||||
hci.HCI_Read_Local_Supported_Features_Command(), check_result=True
|
hci.HCI_Read_Local_Supported_Features_Command()
|
||||||
)
|
)
|
||||||
self.local_lmp_features = hci.LmpFeatureMask(
|
self.local_lmp_features = hci.LmpFeatureMask(
|
||||||
int.from_bytes(response.return_parameters.lmp_features, 'little')
|
int.from_bytes(response5.lmp_features, 'little')
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.send_command(
|
await self.send_sync_command(
|
||||||
hci.HCI_Set_Event_Mask_Command(
|
hci.HCI_Set_Event_Mask_Command(
|
||||||
event_mask=hci.HCI_Set_Event_Mask_Command.mask(
|
event_mask=hci.HCI_Set_Event_Mask_Command.mask(
|
||||||
[
|
[
|
||||||
@@ -437,7 +453,7 @@ class Host(utils.EventEmitter):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.supports_command(hci.HCI_SET_EVENT_MASK_PAGE_2_COMMAND):
|
if self.supports_command(hci.HCI_SET_EVENT_MASK_PAGE_2_COMMAND):
|
||||||
await self.send_command(
|
await self.send_sync_command(
|
||||||
hci.HCI_Set_Event_Mask_Page_2_Command(
|
hci.HCI_Set_Event_Mask_Page_2_Command(
|
||||||
event_mask_page_2=hci.HCI_Set_Event_Mask_Page_2_Command.mask(
|
event_mask_page_2=hci.HCI_Set_Event_Mask_Page_2_Command.mask(
|
||||||
[hci.HCI_ENCRYPTION_CHANGE_V2_EVENT]
|
[hci.HCI_ENCRYPTION_CHANGE_V2_EVENT]
|
||||||
@@ -490,29 +506,28 @@ class Host(utils.EventEmitter):
|
|||||||
hci.HCI_LE_TRANSMIT_POWER_REPORTING_EVENT,
|
hci.HCI_LE_TRANSMIT_POWER_REPORTING_EVENT,
|
||||||
hci.HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT,
|
hci.HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT,
|
||||||
hci.HCI_LE_SUBRATE_CHANGE_EVENT,
|
hci.HCI_LE_SUBRATE_CHANGE_EVENT,
|
||||||
|
hci.HCI_LE_READ_ALL_REMOTE_FEATURES_COMPLETE_EVENT,
|
||||||
hci.HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMPLETE_EVENT,
|
hci.HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMPLETE_EVENT,
|
||||||
hci.HCI_LE_CS_PROCEDURE_ENABLE_COMPLETE_EVENT,
|
hci.HCI_LE_CS_PROCEDURE_ENABLE_COMPLETE_EVENT,
|
||||||
hci.HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT,
|
hci.HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT,
|
||||||
hci.HCI_LE_CS_CONFIG_COMPLETE_EVENT,
|
hci.HCI_LE_CS_CONFIG_COMPLETE_EVENT,
|
||||||
hci.HCI_LE_CS_SUBEVENT_RESULT_EVENT,
|
hci.HCI_LE_CS_SUBEVENT_RESULT_EVENT,
|
||||||
hci.HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT,
|
hci.HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT,
|
||||||
|
hci.HCI_LE_MONITORED_ADVERTISERS_REPORT_EVENT,
|
||||||
|
hci.HCI_LE_FRAME_SPACE_UPDATE_COMPLETE_EVENT,
|
||||||
|
hci.HCI_LE_UTP_RECEIVE_EVENT,
|
||||||
|
hci.HCI_LE_CONNECTION_RATE_CHANGE_EVENT,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.send_command(
|
await self.send_sync_command(
|
||||||
hci.HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
|
hci.HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.supports_command(hci.HCI_READ_BUFFER_SIZE_COMMAND):
|
if self.supports_command(hci.HCI_READ_BUFFER_SIZE_COMMAND):
|
||||||
response = await self.send_command(
|
response6 = await self.send_sync_command(hci.HCI_Read_Buffer_Size_Command())
|
||||||
hci.HCI_Read_Buffer_Size_Command(), check_result=True
|
hc_acl_data_packet_length = response6.hc_acl_data_packet_length
|
||||||
)
|
hc_total_num_acl_data_packets = response6.hc_total_num_acl_data_packets
|
||||||
hc_acl_data_packet_length = (
|
|
||||||
response.return_parameters.hc_acl_data_packet_length
|
|
||||||
)
|
|
||||||
hc_total_num_acl_data_packets = (
|
|
||||||
response.return_parameters.hc_total_num_acl_data_packets
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'HCI ACL flow control: '
|
'HCI ACL flow control: '
|
||||||
@@ -531,19 +546,13 @@ class Host(utils.EventEmitter):
|
|||||||
iso_data_packet_length = 0
|
iso_data_packet_length = 0
|
||||||
total_num_iso_data_packets = 0
|
total_num_iso_data_packets = 0
|
||||||
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
||||||
response = await self.send_command(
|
response7 = await self.send_sync_command(
|
||||||
hci.HCI_LE_Read_Buffer_Size_V2_Command(), check_result=True
|
hci.HCI_LE_Read_Buffer_Size_V2_Command()
|
||||||
)
|
|
||||||
le_acl_data_packet_length = (
|
|
||||||
response.return_parameters.le_acl_data_packet_length
|
|
||||||
)
|
|
||||||
total_num_le_acl_data_packets = (
|
|
||||||
response.return_parameters.total_num_le_acl_data_packets
|
|
||||||
)
|
|
||||||
iso_data_packet_length = response.return_parameters.iso_data_packet_length
|
|
||||||
total_num_iso_data_packets = (
|
|
||||||
response.return_parameters.total_num_iso_data_packets
|
|
||||||
)
|
)
|
||||||
|
le_acl_data_packet_length = response7.le_acl_data_packet_length
|
||||||
|
total_num_le_acl_data_packets = response7.total_num_le_acl_data_packets
|
||||||
|
iso_data_packet_length = response7.iso_data_packet_length
|
||||||
|
total_num_iso_data_packets = response7.total_num_iso_data_packets
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'HCI LE flow control: '
|
'HCI LE flow control: '
|
||||||
@@ -553,15 +562,11 @@ class Host(utils.EventEmitter):
|
|||||||
f'total_num_iso_data_packets={total_num_iso_data_packets}'
|
f'total_num_iso_data_packets={total_num_iso_data_packets}'
|
||||||
)
|
)
|
||||||
elif self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
elif self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||||
response = await self.send_command(
|
response8 = await self.send_sync_command(
|
||||||
hci.HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
hci.HCI_LE_Read_Buffer_Size_Command()
|
||||||
)
|
|
||||||
le_acl_data_packet_length = (
|
|
||||||
response.return_parameters.le_acl_data_packet_length
|
|
||||||
)
|
|
||||||
total_num_le_acl_data_packets = (
|
|
||||||
response.return_parameters.total_num_le_acl_data_packets
|
|
||||||
)
|
)
|
||||||
|
le_acl_data_packet_length = response8.le_acl_data_packet_length
|
||||||
|
total_num_le_acl_data_packets = response8.total_num_le_acl_data_packets
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'HCI LE ACL flow control: '
|
'HCI LE ACL flow control: '
|
||||||
@@ -592,16 +597,16 @@ class Host(utils.EventEmitter):
|
|||||||
) and self.supports_command(
|
) and self.supports_command(
|
||||||
hci.HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
|
hci.HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
|
||||||
):
|
):
|
||||||
response = await self.send_command(
|
response9 = await self.send_sync_command(
|
||||||
hci.HCI_LE_Read_Suggested_Default_Data_Length_Command()
|
hci.HCI_LE_Read_Suggested_Default_Data_Length_Command()
|
||||||
)
|
)
|
||||||
suggested_max_tx_octets = response.return_parameters.suggested_max_tx_octets
|
suggested_max_tx_octets = response9.suggested_max_tx_octets
|
||||||
suggested_max_tx_time = response.return_parameters.suggested_max_tx_time
|
suggested_max_tx_time = response9.suggested_max_tx_time
|
||||||
if (
|
if (
|
||||||
suggested_max_tx_octets != self.suggested_max_tx_octets
|
suggested_max_tx_octets != self.suggested_max_tx_octets
|
||||||
or suggested_max_tx_time != self.suggested_max_tx_time
|
or suggested_max_tx_time != self.suggested_max_tx_time
|
||||||
):
|
):
|
||||||
await self.send_command(
|
await self.send_sync_command(
|
||||||
hci.HCI_LE_Write_Suggested_Default_Data_Length_Command(
|
hci.HCI_LE_Write_Suggested_Default_Data_Length_Command(
|
||||||
suggested_max_tx_octets=self.suggested_max_tx_octets,
|
suggested_max_tx_octets=self.suggested_max_tx_octets,
|
||||||
suggested_max_tx_time=self.suggested_max_tx_time,
|
suggested_max_tx_time=self.suggested_max_tx_time,
|
||||||
@@ -611,24 +616,28 @@ class Host(utils.EventEmitter):
|
|||||||
if self.supports_command(
|
if self.supports_command(
|
||||||
hci.HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND
|
hci.HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND
|
||||||
):
|
):
|
||||||
response = await self.send_command(
|
try:
|
||||||
hci.HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command(),
|
response10 = await self.send_sync_command(
|
||||||
check_result=True,
|
hci.HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command()
|
||||||
)
|
)
|
||||||
self.number_of_supported_advertising_sets = (
|
self.number_of_supported_advertising_sets = (
|
||||||
response.return_parameters.num_supported_advertising_sets
|
response10.num_supported_advertising_sets
|
||||||
)
|
)
|
||||||
|
except hci.HCI_Error:
|
||||||
|
logger.warning('Failed to read number of supported advertising sets')
|
||||||
|
|
||||||
if self.supports_command(
|
if self.supports_command(
|
||||||
hci.HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND
|
hci.HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND
|
||||||
):
|
):
|
||||||
response = await self.send_command(
|
try:
|
||||||
hci.HCI_LE_Read_Maximum_Advertising_Data_Length_Command(),
|
response11 = await self.send_sync_command(
|
||||||
check_result=True,
|
hci.HCI_LE_Read_Maximum_Advertising_Data_Length_Command()
|
||||||
)
|
)
|
||||||
self.maximum_advertising_data_length = (
|
self.maximum_advertising_data_length = (
|
||||||
response.return_parameters.max_advertising_data_length
|
response11.max_advertising_data_length
|
||||||
)
|
)
|
||||||
|
except hci.HCI_Error:
|
||||||
|
logger.warning('Failed to read maximum advertising data length')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def controller(self) -> TransportSink | None:
|
def controller(self) -> TransportSink | None:
|
||||||
@@ -654,56 +663,173 @@ class Host(utils.EventEmitter):
|
|||||||
if self.hci_sink:
|
if self.hci_sink:
|
||||||
self.hci_sink.on_packet(bytes(packet))
|
self.hci_sink.on_packet(bytes(packet))
|
||||||
|
|
||||||
async def send_command(
|
async def _send_command(
|
||||||
self, command, check_result=False, response_timeout: int | None = None
|
self,
|
||||||
):
|
command: hci.HCI_SyncCommand | hci.HCI_AsyncCommand,
|
||||||
|
response_timeout: float | None = None,
|
||||||
|
) -> hci.HCI_Command_Complete_Event | hci.HCI_Command_Status_Event:
|
||||||
# Wait until we can send (only one pending command at a time)
|
# Wait until we can send (only one pending command at a time)
|
||||||
async with self.command_semaphore:
|
await self.command_semaphore.acquire()
|
||||||
assert self.pending_command is None
|
|
||||||
assert self.pending_response is None
|
|
||||||
|
|
||||||
# Create a future value to hold the eventual response
|
# Create a future value to hold the eventual response
|
||||||
self.pending_response = asyncio.get_running_loop().create_future()
|
assert self.pending_command is None
|
||||||
self.pending_command = command
|
assert self.pending_response is None
|
||||||
|
self.pending_response = asyncio.get_running_loop().create_future()
|
||||||
|
self.pending_command = command
|
||||||
|
|
||||||
try:
|
response: (
|
||||||
self.send_hci_packet(command)
|
hci.HCI_Command_Complete_Event | hci.HCI_Command_Status_Event | None
|
||||||
await asyncio.wait_for(self.pending_response, timeout=response_timeout)
|
) = None
|
||||||
response = self.pending_response.result()
|
try:
|
||||||
|
self.send_hci_packet(command)
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
self.pending_response, timeout=response_timeout
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
except Exception:
|
||||||
|
logger.exception(color("!!! Exception while sending command:", "red"))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.pending_command = None
|
||||||
|
self.pending_response = None
|
||||||
|
if response is None or (
|
||||||
|
response.num_hci_command_packets and self.command_semaphore.locked()
|
||||||
|
):
|
||||||
|
self.command_semaphore.release()
|
||||||
|
|
||||||
# Check the return parameters if required
|
@overload
|
||||||
if check_result:
|
async def send_command(
|
||||||
if isinstance(response, hci.HCI_Command_Status_Event):
|
self,
|
||||||
status = response.status # type: ignore[attr-defined]
|
command: hci.HCI_SyncCommand[_RP],
|
||||||
elif isinstance(response.return_parameters, int):
|
check_result: bool = False,
|
||||||
status = response.return_parameters
|
response_timeout: float | None = None,
|
||||||
elif isinstance(response.return_parameters, bytes):
|
) -> hci.HCI_Command_Complete_Event[_RP]: ...
|
||||||
# return parameters first field is a one byte status code
|
|
||||||
status = response.return_parameters[0]
|
|
||||||
else:
|
|
||||||
status = response.return_parameters.status
|
|
||||||
|
|
||||||
if status != hci.HCI_SUCCESS:
|
@overload
|
||||||
logger.warning(
|
async def send_command(
|
||||||
f'{command.name} failed '
|
self,
|
||||||
f'({hci.HCI_Constant.error_name(status)})'
|
command: hci.HCI_AsyncCommand,
|
||||||
)
|
check_result: bool = False,
|
||||||
raise hci.HCI_Error(status)
|
response_timeout: float | None = None,
|
||||||
|
) -> hci.HCI_Command_Status_Event: ...
|
||||||
|
|
||||||
return response
|
async def send_command(
|
||||||
except Exception:
|
self,
|
||||||
logger.exception(color("!!! Exception while sending command:", "red"))
|
command: hci.HCI_SyncCommand[_RP] | hci.HCI_AsyncCommand,
|
||||||
raise
|
check_result: bool = False,
|
||||||
finally:
|
response_timeout: float | None = None,
|
||||||
self.pending_command = None
|
) -> hci.HCI_Command_Complete_Event[_RP] | hci.HCI_Command_Status_Event:
|
||||||
self.pending_response = None
|
response = await self._send_command(command, response_timeout)
|
||||||
|
|
||||||
# Use this method to send a command from a task
|
# Check the return parameters if required
|
||||||
def send_command_sync(self, command: hci.HCI_Command) -> None:
|
if check_result:
|
||||||
async def send_command(command: hci.HCI_Command) -> None:
|
if isinstance(response, hci.HCI_Command_Status_Event):
|
||||||
await self.send_command(command)
|
status = response.status # type: ignore[attr-defined]
|
||||||
|
elif isinstance(response.return_parameters, int):
|
||||||
|
status = response.return_parameters
|
||||||
|
elif isinstance(response.return_parameters, bytes):
|
||||||
|
# return parameters first field is a one byte status code
|
||||||
|
status = response.return_parameters[0]
|
||||||
|
elif isinstance(
|
||||||
|
response.return_parameters, hci.HCI_GenericReturnParameters
|
||||||
|
):
|
||||||
|
# FIXME: temporary workaround
|
||||||
|
# NO STATUS
|
||||||
|
status = hci.HCI_SUCCESS
|
||||||
|
else:
|
||||||
|
status = response.return_parameters.status
|
||||||
|
|
||||||
asyncio.create_task(send_command(command))
|
if status != hci.HCI_SUCCESS:
|
||||||
|
logger.warning(
|
||||||
|
f'{command.name} failed ' f'({hci.HCI_Constant.error_name(status)})'
|
||||||
|
)
|
||||||
|
raise hci.HCI_Error(status)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def send_sync_command(
|
||||||
|
self, command: hci.HCI_SyncCommand[_RP], response_timeout: float | None = None
|
||||||
|
) -> _RP:
|
||||||
|
response = await self.send_sync_command_raw(command, response_timeout)
|
||||||
|
return_parameters = response.return_parameters
|
||||||
|
|
||||||
|
# Check the return parameters's status
|
||||||
|
if isinstance(return_parameters, hci.HCI_StatusReturnParameters):
|
||||||
|
status = return_parameters.status
|
||||||
|
elif isinstance(return_parameters, hci.HCI_GenericReturnParameters):
|
||||||
|
# if the payload has at least one byte, assume the first byte is the status
|
||||||
|
if not return_parameters.data:
|
||||||
|
raise RuntimeError('no status byte in return parameters')
|
||||||
|
status = hci.HCI_ErrorCode(return_parameters.data[0])
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'unexpected return parameters type ({type(return_parameters)})'
|
||||||
|
)
|
||||||
|
if status != hci.HCI_ErrorCode.SUCCESS:
|
||||||
|
logger.warning(
|
||||||
|
f'{command.name} failed ' f'({hci.HCI_Constant.error_name(status)})'
|
||||||
|
)
|
||||||
|
raise hci.HCI_Error(status)
|
||||||
|
|
||||||
|
return return_parameters
|
||||||
|
|
||||||
|
async def send_sync_command_raw(
|
||||||
|
self,
|
||||||
|
command: hci.HCI_SyncCommand[_RP],
|
||||||
|
response_timeout: float | None = None,
|
||||||
|
) -> hci.HCI_Command_Complete_Event[_RP]:
|
||||||
|
response = await self._send_command(command, response_timeout)
|
||||||
|
|
||||||
|
# For unknown HCI commands, some controllers return Command Status instead of
|
||||||
|
# Command Complete.
|
||||||
|
if (
|
||||||
|
isinstance(response, hci.HCI_Command_Status_Event)
|
||||||
|
and response.status == hci.HCI_ErrorCode.UNKNOWN_HCI_COMMAND_ERROR
|
||||||
|
):
|
||||||
|
return hci.HCI_Command_Complete_Event(
|
||||||
|
num_hci_command_packets=response.num_hci_command_packets,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
return_parameters=hci.HCI_StatusReturnParameters(
|
||||||
|
status=hci.HCI_ErrorCode(response.status)
|
||||||
|
), # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that the response is of the expected type
|
||||||
|
assert isinstance(response, hci.HCI_Command_Complete_Event)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def send_async_command(
|
||||||
|
self,
|
||||||
|
command: hci.HCI_AsyncCommand,
|
||||||
|
check_status: bool = True,
|
||||||
|
response_timeout: float | None = None,
|
||||||
|
) -> hci.HCI_ErrorCode:
|
||||||
|
response = await self._send_command(command, response_timeout)
|
||||||
|
|
||||||
|
# For unknown HCI commands, some controllers return Command Complete instead of
|
||||||
|
# Command Status.
|
||||||
|
if isinstance(response, hci.HCI_Command_Complete_Event):
|
||||||
|
# Assume the first byte of the return parameters is the status
|
||||||
|
if (
|
||||||
|
status := hci.HCI_ErrorCode(response.parameters[3])
|
||||||
|
) != hci.HCI_ErrorCode.UNKNOWN_HCI_COMMAND_ERROR:
|
||||||
|
logger.warning(f'unexpected return paramerers status {status}')
|
||||||
|
else:
|
||||||
|
assert isinstance(response, hci.HCI_Command_Status_Event)
|
||||||
|
status = hci.HCI_ErrorCode(response.status)
|
||||||
|
|
||||||
|
# Check the status if required
|
||||||
|
if check_status:
|
||||||
|
if status != hci.HCI_CommandStatus.PENDING:
|
||||||
|
logger.warning(f'{command.name} failed ' f'({status.name})')
|
||||||
|
raise hci.HCI_Error(status)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
@utils.deprecated("Use utils.AsyncRunner.spawn() instead.")
|
||||||
|
def send_command_sync(self, command: hci.HCI_AsyncCommand) -> None:
|
||||||
|
utils.AsyncRunner.spawn(self.send_async_command(command))
|
||||||
|
|
||||||
def send_acl_sdu(self, connection_handle: int, sdu: bytes) -> None:
|
def send_acl_sdu(self, connection_handle: int, sdu: bytes) -> None:
|
||||||
if not (connection := self.connections.get(connection_handle)):
|
if not (connection := self.connections.get(connection_handle)):
|
||||||
@@ -728,10 +854,22 @@ class Host(utils.EventEmitter):
|
|||||||
data=pdu,
|
data=pdu,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'>>> ACL packet enqueue: (Handle=0x%04X) %s', connection_handle, pdu
|
'>>> ACL packet enqueue: (handle=0x%04X) %s',
|
||||||
|
connection_handle,
|
||||||
|
pdu.hex(),
|
||||||
)
|
)
|
||||||
packet_queue.enqueue(acl_packet, connection_handle)
|
packet_queue.enqueue(acl_packet, connection_handle)
|
||||||
|
|
||||||
|
def send_sco_sdu(self, connection_handle: int, sdu: bytes) -> None:
|
||||||
|
self.send_hci_packet(
|
||||||
|
hci.HCI_SynchronousDataPacket(
|
||||||
|
connection_handle=connection_handle,
|
||||||
|
packet_status=0,
|
||||||
|
data_total_length=len(sdu),
|
||||||
|
data=sdu,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||||
self.send_acl_sdu(connection_handle, bytes(L2CAP_PDU(cid, pdu)))
|
self.send_acl_sdu(connection_handle, bytes(L2CAP_PDU(cid, pdu)))
|
||||||
|
|
||||||
@@ -816,16 +954,18 @@ class Host(utils.EventEmitter):
|
|||||||
if self.local_supported_commands & mask
|
if self.local_supported_commands & mask
|
||||||
)
|
)
|
||||||
|
|
||||||
def supports_le_features(self, feature: hci.LeFeatureMask) -> bool:
|
def supports_le_features(self, features: hci.LeFeatureMask) -> bool:
|
||||||
return (self.local_le_features & feature) == feature
|
return (self.local_le_features & features) == features
|
||||||
|
|
||||||
def supports_lmp_features(self, feature: hci.LmpFeatureMask) -> bool:
|
def supports_lmp_features(self, features: hci.LmpFeatureMask) -> bool:
|
||||||
return self.local_lmp_features & (feature) == feature
|
return self.local_lmp_features & (features) == features
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_le_features(self):
|
def supported_le_features(self) -> list[hci.LeFeature]:
|
||||||
return [
|
return [
|
||||||
feature for feature in range(64) if self.local_le_features & (1 << feature)
|
feature
|
||||||
|
for feature in hci.LeFeature
|
||||||
|
if self.local_le_features & (1 << feature)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Packet Sink protocol (packets coming from the controller via HCI)
|
# Packet Sink protocol (packets coming from the controller via HCI)
|
||||||
@@ -860,18 +1000,19 @@ class Host(utils.EventEmitter):
|
|||||||
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
||||||
|
|
||||||
# If the packet is a command, invoke the handler for this packet
|
# If the packet is a command, invoke the handler for this packet
|
||||||
if packet.hci_packet_type == hci.HCI_COMMAND_PACKET:
|
match packet:
|
||||||
self.on_hci_command_packet(cast(hci.HCI_Command, packet))
|
case hci.HCI_Command():
|
||||||
elif packet.hci_packet_type == hci.HCI_EVENT_PACKET:
|
self.on_hci_command_packet(packet)
|
||||||
self.on_hci_event_packet(cast(hci.HCI_Event, packet))
|
case hci.HCI_Event():
|
||||||
elif packet.hci_packet_type == hci.HCI_ACL_DATA_PACKET:
|
self.on_hci_event_packet(packet)
|
||||||
self.on_hci_acl_data_packet(cast(hci.HCI_AclDataPacket, packet))
|
case hci.HCI_AclDataPacket():
|
||||||
elif packet.hci_packet_type == hci.HCI_SYNCHRONOUS_DATA_PACKET:
|
self.on_hci_acl_data_packet(packet)
|
||||||
self.on_hci_sco_data_packet(cast(hci.HCI_SynchronousDataPacket, packet))
|
case hci.HCI_SynchronousDataPacket():
|
||||||
elif packet.hci_packet_type == hci.HCI_ISO_DATA_PACKET:
|
self.on_hci_sco_data_packet(packet)
|
||||||
self.on_hci_iso_data_packet(cast(hci.HCI_IsoDataPacket, packet))
|
case hci.HCI_IsoDataPacket():
|
||||||
else:
|
self.on_hci_iso_data_packet(packet)
|
||||||
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
case _:
|
||||||
|
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
||||||
|
|
||||||
def on_hci_command_packet(self, command: hci.HCI_Command) -> None:
|
def on_hci_command_packet(self, command: hci.HCI_Command) -> None:
|
||||||
logger.warning(f'!!! unexpected command packet: {command}')
|
logger.warning(f'!!! unexpected command packet: {command}')
|
||||||
@@ -914,6 +1055,8 @@ class Host(utils.EventEmitter):
|
|||||||
self.pending_response.set_result(event)
|
self.pending_response.set_result(event)
|
||||||
else:
|
else:
|
||||||
logger.warning('!!! no pending response future to set')
|
logger.warning('!!! no pending response future to set')
|
||||||
|
if event.num_hci_command_packets and self.command_semaphore.locked():
|
||||||
|
self.command_semaphore.release()
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# HCI handlers
|
# HCI handlers
|
||||||
@@ -925,7 +1068,13 @@ class Host(utils.EventEmitter):
|
|||||||
if event.command_opcode == 0:
|
if event.command_opcode == 0:
|
||||||
# This is used just for the Num_HCI_Command_Packets field, not related to
|
# This is used just for the Num_HCI_Command_Packets field, not related to
|
||||||
# an actual command
|
# an actual command
|
||||||
logger.debug('no-command event')
|
logger.debug('no-command event for flow control')
|
||||||
|
|
||||||
|
# Release the command semaphore if needed
|
||||||
|
if event.num_hci_command_packets and self.command_semaphore.locked():
|
||||||
|
logger.debug('command complete event releasing semaphore')
|
||||||
|
self.command_semaphore.release()
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
return self.on_command_processed(event)
|
return self.on_command_processed(event)
|
||||||
@@ -1106,7 +1255,7 @@ class Host(utils.EventEmitter):
|
|||||||
self, event: hci.HCI_LE_Connection_Update_Complete_Event
|
self, event: hci.HCI_LE_Connection_Update_Complete_Event
|
||||||
):
|
):
|
||||||
if (connection := self.connections.get(event.connection_handle)) is None:
|
if (connection := self.connections.get(event.connection_handle)) is None:
|
||||||
logger.warning('!!! CONNECTION PARAMETERS UPDATE COMPLETE: unknown handle')
|
logger.warning('!!! CONNECTION UPDATE COMPLETE: unknown handle')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Notify the client
|
# Notify the client
|
||||||
@@ -1123,6 +1272,29 @@ class Host(utils.EventEmitter):
|
|||||||
'connection_parameters_update_failure', connection.handle, event.status
|
'connection_parameters_update_failure', connection.handle, event.status
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_hci_le_connection_rate_change_event(
|
||||||
|
self, event: hci.HCI_LE_Connection_Rate_Change_Event
|
||||||
|
):
|
||||||
|
if (connection := self.connections.get(event.connection_handle)) is None:
|
||||||
|
logger.warning('!!! CONNECTION RATE CHANGE: unknown handle')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Notify the client
|
||||||
|
if event.status == hci.HCI_SUCCESS:
|
||||||
|
self.emit(
|
||||||
|
'le_connection_rate_change',
|
||||||
|
connection.handle,
|
||||||
|
event.connection_interval,
|
||||||
|
event.subrate_factor,
|
||||||
|
event.peripheral_latency,
|
||||||
|
event.continuation_number,
|
||||||
|
event.supervision_timeout,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.emit(
|
||||||
|
'le_connection_rate_change_failure', connection.handle, event.status
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_le_phy_update_complete_event(
|
def on_hci_le_phy_update_complete_event(
|
||||||
self, event: hci.HCI_LE_PHY_Update_Complete_Event
|
self, event: hci.HCI_LE_PHY_Update_Complete_Event
|
||||||
):
|
):
|
||||||
@@ -1338,15 +1510,17 @@ class Host(utils.EventEmitter):
|
|||||||
|
|
||||||
# For now, just accept everything
|
# For now, just accept everything
|
||||||
# TODO: delegate the decision
|
# TODO: delegate the decision
|
||||||
self.send_command_sync(
|
utils.AsyncRunner.spawn(
|
||||||
hci.HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(
|
self.send_sync_command(
|
||||||
connection_handle=event.connection_handle,
|
hci.HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(
|
||||||
interval_min=event.interval_min,
|
connection_handle=event.connection_handle,
|
||||||
interval_max=event.interval_max,
|
interval_min=event.interval_min,
|
||||||
max_latency=event.max_latency,
|
interval_max=event.interval_max,
|
||||||
timeout=event.timeout,
|
max_latency=event.max_latency,
|
||||||
min_ce_length=0,
|
timeout=event.timeout,
|
||||||
max_ce_length=0,
|
min_ce_length=0,
|
||||||
|
max_ce_length=0,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1382,9 +1556,9 @@ class Host(utils.EventEmitter):
|
|||||||
connection_handle=event.connection_handle
|
connection_handle=event.connection_handle
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.send_command(response)
|
await self.send_sync_command(response)
|
||||||
|
|
||||||
asyncio.create_task(send_long_term_key())
|
utils.AsyncRunner.spawn(send_long_term_key())
|
||||||
|
|
||||||
def on_hci_synchronous_connection_complete_event(
|
def on_hci_synchronous_connection_complete_event(
|
||||||
self, event: hci.HCI_Synchronous_Connection_Complete_Event
|
self, event: hci.HCI_Synchronous_Connection_Complete_Event
|
||||||
@@ -1484,6 +1658,19 @@ class Host(utils.EventEmitter):
|
|||||||
'connection_encryption_failure', event.connection_handle, event.status
|
'connection_encryption_failure', event.connection_handle, event.status
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_hci_read_remote_supported_features_complete_event(
|
||||||
|
self, event: hci.HCI_Read_Remote_Supported_Features_Complete_Event
|
||||||
|
) -> None:
|
||||||
|
# Notify the client
|
||||||
|
self.emit(
|
||||||
|
'classic_remote_features',
|
||||||
|
event.connection_handle,
|
||||||
|
event.status,
|
||||||
|
int.from_bytes(event.lmp_features, 'little'),
|
||||||
|
0, # page number
|
||||||
|
0, # max page number
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_encryption_change_v2_event(
|
def on_hci_encryption_change_v2_event(
|
||||||
self, event: hci.HCI_Encryption_Change_V2_Event
|
self, event: hci.HCI_Encryption_Change_V2_Event
|
||||||
):
|
):
|
||||||
@@ -1583,9 +1770,9 @@ class Host(utils.EventEmitter):
|
|||||||
bd_addr=event.bd_addr
|
bd_addr=event.bd_addr
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.send_command(response)
|
await self.send_sync_command(response)
|
||||||
|
|
||||||
asyncio.create_task(send_link_key())
|
utils.AsyncRunner.spawn(send_link_key())
|
||||||
|
|
||||||
def on_hci_io_capability_request_event(
|
def on_hci_io_capability_request_event(
|
||||||
self, event: hci.HCI_IO_Capability_Request_Event
|
self, event: hci.HCI_IO_Capability_Request_Event
|
||||||
@@ -1640,6 +1827,18 @@ class Host(utils.EventEmitter):
|
|||||||
rssi,
|
rssi,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_hci_read_remote_extended_features_complete_event(
|
||||||
|
self, event: hci.HCI_Read_Remote_Extended_Features_Complete_Event
|
||||||
|
):
|
||||||
|
self.emit(
|
||||||
|
'classic_remote_features',
|
||||||
|
event.connection_handle,
|
||||||
|
event.status,
|
||||||
|
int.from_bytes(event.extended_lmp_features, 'little'),
|
||||||
|
event.page_number,
|
||||||
|
event.maximum_page_number,
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_extended_inquiry_result_event(
|
def on_hci_extended_inquiry_result_event(
|
||||||
self, event: hci.HCI_Extended_Inquiry_Result_Event
|
self, event: hci.HCI_Extended_Inquiry_Result_Event
|
||||||
):
|
):
|
||||||
@@ -1680,12 +1879,13 @@ class Host(utils.EventEmitter):
|
|||||||
self.emit(
|
self.emit(
|
||||||
'le_remote_features_failure', event.connection_handle, event.status
|
'le_remote_features_failure', event.connection_handle, event.status
|
||||||
)
|
)
|
||||||
else:
|
return
|
||||||
self.emit(
|
|
||||||
'le_remote_features',
|
self.emit(
|
||||||
event.connection_handle,
|
'le_remote_features',
|
||||||
int.from_bytes(event.le_features, 'little'),
|
event.connection_handle,
|
||||||
)
|
hci.LeFeatureMask(int.from_bytes(event.le_features, 'little')),
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_le_cs_read_remote_supported_capabilities_complete_event(
|
def on_hci_le_cs_read_remote_supported_capabilities_complete_event(
|
||||||
self, event: hci.HCI_LE_CS_Read_Remote_Supported_Capabilities_Complete_Event
|
self, event: hci.HCI_LE_CS_Read_Remote_Supported_Capabilities_Complete_Event
|
||||||
@@ -1718,6 +1918,12 @@ class Host(utils.EventEmitter):
|
|||||||
self.emit('cs_subevent_result_continue', event)
|
self.emit('cs_subevent_result_continue', event)
|
||||||
|
|
||||||
def on_hci_le_subrate_change_event(self, event: hci.HCI_LE_Subrate_Change_Event):
|
def on_hci_le_subrate_change_event(self, event: hci.HCI_LE_Subrate_Change_Event):
|
||||||
|
if event.status != hci.HCI_SUCCESS:
|
||||||
|
self.emit(
|
||||||
|
'le_subrate_change_failure', event.connection_handle, event.status
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
self.emit(
|
self.emit(
|
||||||
'le_subrate_change',
|
'le_subrate_change',
|
||||||
event.connection_handle,
|
event.connection_handle,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import dataclasses
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
@@ -248,29 +249,26 @@ class JsonKeyStore(KeyStore):
|
|||||||
DEFAULT_NAMESPACE = '__DEFAULT__'
|
DEFAULT_NAMESPACE = '__DEFAULT__'
|
||||||
DEFAULT_BASE_NAME = "keys"
|
DEFAULT_BASE_NAME = "keys"
|
||||||
|
|
||||||
def __init__(self, namespace, filename=None):
|
def __init__(
|
||||||
self.namespace = namespace if namespace is not None else self.DEFAULT_NAMESPACE
|
self, namespace: str | None = None, filename: str | None = None
|
||||||
|
) -> None:
|
||||||
|
self.namespace = namespace or self.DEFAULT_NAMESPACE
|
||||||
|
|
||||||
if filename is None:
|
if filename:
|
||||||
# Use a default for the current user
|
self.filename = pathlib.Path(filename).resolve()
|
||||||
|
self.directory_name = self.filename.parent
|
||||||
# Import here because this may not exist on all platforms
|
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
import appdirs
|
|
||||||
|
|
||||||
self.directory_name = os.path.join(
|
|
||||||
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
|
|
||||||
)
|
|
||||||
base_name = self.DEFAULT_BASE_NAME if namespace is None else self.namespace
|
|
||||||
json_filename = (
|
|
||||||
f'{base_name}.json'.lower().replace(':', '-').replace('/p', '-p')
|
|
||||||
)
|
|
||||||
self.filename = os.path.join(self.directory_name, json_filename)
|
|
||||||
else:
|
else:
|
||||||
self.filename = filename
|
import platformdirs # Deferred import
|
||||||
self.directory_name = os.path.dirname(os.path.abspath(self.filename))
|
|
||||||
|
|
||||||
logger.debug(f'JSON keystore: {self.filename}')
|
base_dir = platformdirs.user_data_path(self.APP_NAME, self.APP_AUTHOR)
|
||||||
|
self.directory_name = base_dir / self.KEYS_DIR
|
||||||
|
|
||||||
|
base_name = self.namespace if namespace else self.DEFAULT_BASE_NAME
|
||||||
|
safe_name = base_name.lower().replace(':', '-').replace('/', '-')
|
||||||
|
|
||||||
|
self.filename = self.directory_name / f"{safe_name}.json"
|
||||||
|
|
||||||
|
logger.debug('JSON keystore: %s', self.filename)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_device(
|
def from_device(
|
||||||
@@ -293,7 +291,9 @@ class JsonKeyStore(KeyStore):
|
|||||||
|
|
||||||
return cls(namespace, filename)
|
return cls(namespace, filename)
|
||||||
|
|
||||||
async def load(self):
|
async def load(
|
||||||
|
self,
|
||||||
|
) -> tuple[dict[str, dict[str, dict[str, Any]]], dict[str, dict[str, Any]]]:
|
||||||
# Try to open the file, without failing. If the file does not exist, it
|
# Try to open the file, without failing. If the file does not exist, it
|
||||||
# will be created upon saving.
|
# will be created upon saving.
|
||||||
try:
|
try:
|
||||||
@@ -312,17 +312,17 @@ class JsonKeyStore(KeyStore):
|
|||||||
return next(iter(db.items()))
|
return next(iter(db.items()))
|
||||||
|
|
||||||
# Finally, just create an empty key map for the namespace
|
# Finally, just create an empty key map for the namespace
|
||||||
key_map = {}
|
key_map: dict[str, dict[str, Any]] = {}
|
||||||
db[self.namespace] = key_map
|
db[self.namespace] = key_map
|
||||||
return (db, key_map)
|
return (db, key_map)
|
||||||
|
|
||||||
async def save(self, db):
|
async def save(self, db: dict[str, dict[str, dict[str, Any]]]) -> None:
|
||||||
# Create the directory if it doesn't exist
|
# Create the directory if it doesn't exist
|
||||||
if not os.path.exists(self.directory_name):
|
if not self.directory_name.exists():
|
||||||
os.makedirs(self.directory_name, exist_ok=True)
|
self.directory_name.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Save to a temporary file
|
# Save to a temporary file
|
||||||
temp_filename = self.filename + '.tmp'
|
temp_filename = self.filename.with_name(self.filename.name + ".tmp")
|
||||||
with open(temp_filename, 'w', encoding='utf-8') as output:
|
with open(temp_filename, 'w', encoding='utf-8') as output:
|
||||||
json.dump(db, output, sort_keys=True, indent=4)
|
json.dump(db, output, sort_keys=True, indent=4)
|
||||||
|
|
||||||
@@ -334,16 +334,16 @@ class JsonKeyStore(KeyStore):
|
|||||||
del key_map[name]
|
del key_map[name]
|
||||||
await self.save(db)
|
await self.save(db)
|
||||||
|
|
||||||
async def update(self, name, keys):
|
async def update(self, name: str, keys: PairingKeys) -> None:
|
||||||
db, key_map = await self.load()
|
db, key_map = await self.load()
|
||||||
key_map.setdefault(name, {}).update(keys.to_dict())
|
key_map.setdefault(name, {}).update(keys.to_dict())
|
||||||
await self.save(db)
|
await self.save(db)
|
||||||
|
|
||||||
async def get_all(self):
|
async def get_all(self) -> list[tuple[str, PairingKeys]]:
|
||||||
_, key_map = await self.load()
|
_, key_map = await self.load()
|
||||||
return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()]
|
return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()]
|
||||||
|
|
||||||
async def delete_all(self):
|
async def delete_all(self) -> None:
|
||||||
db, key_map = await self.load()
|
db, key_map = await self.load()
|
||||||
key_map.clear()
|
key_map.clear()
|
||||||
await self.save(db)
|
await self.save(db)
|
||||||
|
|||||||
@@ -1647,7 +1647,9 @@ class LeCreditBasedChannel(utils.EventEmitter):
|
|||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
self.drained = asyncio.Event()
|
self.drained = asyncio.Event()
|
||||||
self.att_mtu = 0 # Filled by GATT client or server later.
|
# Core Specification Vol 3, Part G, 5.3.1 ATT_MTU
|
||||||
|
# ATT_MTU shall be set to the minimum of the MTU field values of the two devices.
|
||||||
|
self.att_mtu = min(mtu, peer_mtu)
|
||||||
|
|
||||||
self.drained.set()
|
self.drained.set()
|
||||||
|
|
||||||
@@ -2340,8 +2342,8 @@ class ChannelManager:
|
|||||||
cid,
|
cid,
|
||||||
L2CAP_Connection_Response(
|
L2CAP_Connection_Response(
|
||||||
identifier=request.identifier,
|
identifier=request.identifier,
|
||||||
destination_cid=request.source_cid,
|
destination_cid=0,
|
||||||
source_cid=0,
|
source_cid=request.source_cid,
|
||||||
result=L2CAP_Connection_Response.Result.CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE,
|
result=L2CAP_Connection_Response.Result.CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE,
|
||||||
status=0x0000,
|
status=0x0000,
|
||||||
),
|
),
|
||||||
@@ -2353,7 +2355,12 @@ class ChannelManager:
|
|||||||
f'creating server channel with cid={source_cid} for psm {request.psm}'
|
f'creating server channel with cid={source_cid} for psm {request.psm}'
|
||||||
)
|
)
|
||||||
channel = ClassicChannel(
|
channel = ClassicChannel(
|
||||||
self, connection, cid, request.psm, source_cid, server.spec
|
manager=self,
|
||||||
|
connection=connection,
|
||||||
|
signaling_cid=cid,
|
||||||
|
psm=request.psm,
|
||||||
|
source_cid=source_cid,
|
||||||
|
spec=server.spec,
|
||||||
)
|
)
|
||||||
connection_channels[source_cid] = channel
|
connection_channels[source_cid] = channel
|
||||||
|
|
||||||
@@ -2370,8 +2377,8 @@ class ChannelManager:
|
|||||||
cid,
|
cid,
|
||||||
L2CAP_Connection_Response(
|
L2CAP_Connection_Response(
|
||||||
identifier=request.identifier,
|
identifier=request.identifier,
|
||||||
destination_cid=request.source_cid,
|
destination_cid=0,
|
||||||
source_cid=0,
|
source_cid=request.source_cid,
|
||||||
result=L2CAP_Connection_Response.Result.CONNECTION_REFUSED_PSM_NOT_SUPPORTED,
|
result=L2CAP_Connection_Response.Result.CONNECTION_REFUSED_PSM_NOT_SUPPORTED,
|
||||||
status=0x0000,
|
status=0x0000,
|
||||||
),
|
),
|
||||||
|
|||||||
21
bumble/ll.py
21
bumble/ll.py
@@ -198,3 +198,24 @@ class CisTerminateInd(ControlPdu):
|
|||||||
cig_id: int
|
cig_id: int
|
||||||
cis_id: int
|
cis_id: int
|
||||||
error_code: int
|
error_code: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class FeatureReq(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_FEATURE_REQ
|
||||||
|
|
||||||
|
feature_set: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class FeatureRsp(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_FEATURE_RSP
|
||||||
|
|
||||||
|
feature_set: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class PeripheralFeatureReq(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_PERIPHERAL_FEATURE_REQ
|
||||||
|
|
||||||
|
feature_set: bytes
|
||||||
|
|||||||
@@ -322,3 +322,38 @@ class LmpNameRes(Packet):
|
|||||||
name_offset: int = field(metadata=hci.metadata(2))
|
name_offset: int = field(metadata=hci.metadata(2))
|
||||||
name_length: int = field(metadata=hci.metadata(3))
|
name_length: int = field(metadata=hci.metadata(3))
|
||||||
name_fregment: bytes = field(metadata=hci.metadata('*'))
|
name_fregment: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpFeaturesReq(Packet):
|
||||||
|
opcode = Opcode.LMP_FEATURES_REQ
|
||||||
|
|
||||||
|
features: bytes = field(metadata=hci.metadata(8))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpFeaturesRes(Packet):
|
||||||
|
opcode = Opcode.LMP_FEATURES_RES
|
||||||
|
|
||||||
|
features: bytes = field(metadata=hci.metadata(8))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpFeaturesReqExt(Packet):
|
||||||
|
opcode = Opcode.LMP_FEATURES_REQ_EXT
|
||||||
|
|
||||||
|
features_page: int = field(metadata=hci.metadata(1))
|
||||||
|
features: bytes = field(metadata=hci.metadata(8))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpFeaturesResExt(Packet):
|
||||||
|
opcode = Opcode.LMP_FEATURES_RES_EXT
|
||||||
|
|
||||||
|
features_page: int = field(metadata=hci.metadata(1))
|
||||||
|
max_features_page: int = field(metadata=hci.metadata(1))
|
||||||
|
features: bytes = field(metadata=hci.metadata(8))
|
||||||
|
|||||||
@@ -21,18 +21,9 @@ import enum
|
|||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from bumble import hci
|
from bumble import hci, smp
|
||||||
from bumble.core import AdvertisingData, LeRole
|
from bumble.core import AdvertisingData, LeRole
|
||||||
from bumble.smp import (
|
from bumble.smp import (
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY,
|
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY,
|
|
||||||
SMP_ENC_KEY_DISTRIBUTION_FLAG,
|
|
||||||
SMP_ID_KEY_DISTRIBUTION_FLAG,
|
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY,
|
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY,
|
|
||||||
SMP_LINK_KEY_DISTRIBUTION_FLAG,
|
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
|
||||||
SMP_SIGN_KEY_DISTRIBUTION_FLAG,
|
|
||||||
OobContext,
|
OobContext,
|
||||||
OobLegacyContext,
|
OobLegacyContext,
|
||||||
OobSharedData,
|
OobSharedData,
|
||||||
@@ -96,11 +87,11 @@ class PairingDelegate:
|
|||||||
# These are defined abstractly, and can be mapped to specific Classic pairing
|
# These are defined abstractly, and can be mapped to specific Classic pairing
|
||||||
# and/or SMP constants.
|
# and/or SMP constants.
|
||||||
class IoCapability(enum.IntEnum):
|
class IoCapability(enum.IntEnum):
|
||||||
NO_OUTPUT_NO_INPUT = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
|
NO_OUTPUT_NO_INPUT = smp.IoCapability.NO_INPUT_NO_OUTPUT
|
||||||
KEYBOARD_INPUT_ONLY = SMP_KEYBOARD_ONLY_IO_CAPABILITY
|
KEYBOARD_INPUT_ONLY = smp.IoCapability.KEYBOARD_ONLY
|
||||||
DISPLAY_OUTPUT_ONLY = SMP_DISPLAY_ONLY_IO_CAPABILITY
|
DISPLAY_OUTPUT_ONLY = smp.IoCapability.DISPLAY_ONLY
|
||||||
DISPLAY_OUTPUT_AND_YES_NO_INPUT = SMP_DISPLAY_YES_NO_IO_CAPABILITY
|
DISPLAY_OUTPUT_AND_YES_NO_INPUT = smp.IoCapability.DISPLAY_YES_NO
|
||||||
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY
|
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = smp.IoCapability.KEYBOARD_DISPLAY
|
||||||
|
|
||||||
# Direct names for backward compatibility.
|
# Direct names for backward compatibility.
|
||||||
NO_OUTPUT_NO_INPUT = IoCapability.NO_OUTPUT_NO_INPUT
|
NO_OUTPUT_NO_INPUT = IoCapability.NO_OUTPUT_NO_INPUT
|
||||||
@@ -111,10 +102,10 @@ class PairingDelegate:
|
|||||||
|
|
||||||
# Key Distribution [LE only]
|
# Key Distribution [LE only]
|
||||||
class KeyDistribution(enum.IntFlag):
|
class KeyDistribution(enum.IntFlag):
|
||||||
DISTRIBUTE_ENCRYPTION_KEY = SMP_ENC_KEY_DISTRIBUTION_FLAG
|
DISTRIBUTE_ENCRYPTION_KEY = smp.KeyDistribution.ENC_KEY
|
||||||
DISTRIBUTE_IDENTITY_KEY = SMP_ID_KEY_DISTRIBUTION_FLAG
|
DISTRIBUTE_IDENTITY_KEY = smp.KeyDistribution.ID_KEY
|
||||||
DISTRIBUTE_SIGNING_KEY = SMP_SIGN_KEY_DISTRIBUTION_FLAG
|
DISTRIBUTE_SIGNING_KEY = smp.KeyDistribution.SIGN_KEY
|
||||||
DISTRIBUTE_LINK_KEY = SMP_LINK_KEY_DISTRIBUTION_FLAG
|
DISTRIBUTE_LINK_KEY = smp.KeyDistribution.LINK_KEY
|
||||||
|
|
||||||
DEFAULT_KEY_DISTRIBUTION: KeyDistribution = (
|
DEFAULT_KEY_DISTRIBUTION: KeyDistribution = (
|
||||||
KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY
|
KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ class L2CAPService(L2CAPServicer):
|
|||||||
if not l2cap_channel:
|
if not l2cap_channel:
|
||||||
return SendResponse(error=COMMAND_NOT_UNDERSTOOD)
|
return SendResponse(error=COMMAND_NOT_UNDERSTOOD)
|
||||||
if isinstance(l2cap_channel, ClassicChannel):
|
if isinstance(l2cap_channel, ClassicChannel):
|
||||||
l2cap_channel.send_pdu(request.data)
|
l2cap_channel.write(request.data)
|
||||||
else:
|
else:
|
||||||
l2cap_channel.write(request.data)
|
l2cap_channel.write(request.data)
|
||||||
return SendResponse(success=empty_pb2.Empty())
|
return SendResponse(success=empty_pb2.Empty())
|
||||||
|
|||||||
@@ -664,46 +664,44 @@ class AudioStreamControlService(gatt.TemplateService):
|
|||||||
responses = []
|
responses = []
|
||||||
logger.debug(f'*** ASCS Write {operation} ***')
|
logger.debug(f'*** ASCS Write {operation} ***')
|
||||||
|
|
||||||
if isinstance(operation, ASE_Config_Codec):
|
match operation:
|
||||||
for ase_id, *args in zip(
|
case ASE_Config_Codec():
|
||||||
operation.ase_id,
|
for ase_id, *args in zip(
|
||||||
operation.target_latency,
|
operation.ase_id,
|
||||||
operation.target_phy,
|
operation.target_latency,
|
||||||
operation.codec_id,
|
operation.target_phy,
|
||||||
operation.codec_specific_configuration,
|
operation.codec_id,
|
||||||
|
operation.codec_specific_configuration,
|
||||||
|
):
|
||||||
|
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
||||||
|
case ASE_Config_QOS():
|
||||||
|
for ase_id, *args in zip(
|
||||||
|
operation.ase_id,
|
||||||
|
operation.cig_id,
|
||||||
|
operation.cis_id,
|
||||||
|
operation.sdu_interval,
|
||||||
|
operation.framing,
|
||||||
|
operation.phy,
|
||||||
|
operation.max_sdu,
|
||||||
|
operation.retransmission_number,
|
||||||
|
operation.max_transport_latency,
|
||||||
|
operation.presentation_delay,
|
||||||
|
):
|
||||||
|
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
||||||
|
case ASE_Enable() | ASE_Update_Metadata():
|
||||||
|
for ase_id, *args in zip(
|
||||||
|
operation.ase_id,
|
||||||
|
operation.metadata,
|
||||||
|
):
|
||||||
|
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
||||||
|
case (
|
||||||
|
ASE_Receiver_Start_Ready()
|
||||||
|
| ASE_Disable()
|
||||||
|
| ASE_Receiver_Stop_Ready()
|
||||||
|
| ASE_Release()
|
||||||
):
|
):
|
||||||
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
for ase_id in operation.ase_id:
|
||||||
elif isinstance(operation, ASE_Config_QOS):
|
responses.append(self.on_operation(operation.op_code, ase_id, []))
|
||||||
for ase_id, *args in zip(
|
|
||||||
operation.ase_id,
|
|
||||||
operation.cig_id,
|
|
||||||
operation.cis_id,
|
|
||||||
operation.sdu_interval,
|
|
||||||
operation.framing,
|
|
||||||
operation.phy,
|
|
||||||
operation.max_sdu,
|
|
||||||
operation.retransmission_number,
|
|
||||||
operation.max_transport_latency,
|
|
||||||
operation.presentation_delay,
|
|
||||||
):
|
|
||||||
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
|
||||||
elif isinstance(operation, (ASE_Enable, ASE_Update_Metadata)):
|
|
||||||
for ase_id, *args in zip(
|
|
||||||
operation.ase_id,
|
|
||||||
operation.metadata,
|
|
||||||
):
|
|
||||||
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
|
||||||
elif isinstance(
|
|
||||||
operation,
|
|
||||||
(
|
|
||||||
ASE_Receiver_Start_Ready,
|
|
||||||
ASE_Disable,
|
|
||||||
ASE_Receiver_Stop_Ready,
|
|
||||||
ASE_Release,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
for ase_id in operation.ase_id:
|
|
||||||
responses.append(self.on_operation(operation.op_code, ase_id, []))
|
|
||||||
|
|
||||||
control_point_notification = bytes(
|
control_point_notification = bytes(
|
||||||
[operation.op_code, len(responses)]
|
[operation.op_code, len(responses)]
|
||||||
|
|||||||
@@ -333,17 +333,18 @@ class CodecSpecificCapabilities:
|
|||||||
value = int.from_bytes(data[offset : offset + length - 1], 'little')
|
value = int.from_bytes(data[offset : offset + length - 1], 'little')
|
||||||
offset += length - 1
|
offset += length - 1
|
||||||
|
|
||||||
if type == CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY:
|
match type:
|
||||||
supported_sampling_frequencies = SupportedSamplingFrequency(value)
|
case CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY:
|
||||||
elif type == CodecSpecificCapabilities.Type.FRAME_DURATION:
|
supported_sampling_frequencies = SupportedSamplingFrequency(value)
|
||||||
supported_frame_durations = SupportedFrameDuration(value)
|
case CodecSpecificCapabilities.Type.FRAME_DURATION:
|
||||||
elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
|
supported_frame_durations = SupportedFrameDuration(value)
|
||||||
supported_audio_channel_count = bits_to_channel_counts(value)
|
case CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
|
||||||
elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
|
supported_audio_channel_count = bits_to_channel_counts(value)
|
||||||
min_octets_per_sample = value & 0xFFFF
|
case CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
|
||||||
max_octets_per_sample = value >> 16
|
min_octets_per_sample = value & 0xFFFF
|
||||||
elif type == CodecSpecificCapabilities.Type.CODEC_FRAMES_PER_SDU:
|
max_octets_per_sample = value >> 16
|
||||||
supported_max_codec_frames_per_sdu = value
|
case CodecSpecificCapabilities.Type.CODEC_FRAMES_PER_SDU:
|
||||||
|
supported_max_codec_frames_per_sdu = value
|
||||||
|
|
||||||
# It is expected here that if some fields are missing, an error should be raised.
|
# It is expected here that if some fields are missing, an error should be raised.
|
||||||
# pylint: disable=possibly-used-before-assignment,used-before-assignment
|
# pylint: disable=possibly-used-before-assignment,used-before-assignment
|
||||||
|
|||||||
@@ -16,35 +16,28 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from bumble.gatt import (
|
from bumble import device, gatt, gatt_adapters, gatt_client
|
||||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
|
||||||
GATT_BATTERY_SERVICE,
|
|
||||||
Characteristic,
|
|
||||||
CharacteristicValue,
|
|
||||||
TemplateService,
|
|
||||||
)
|
|
||||||
from bumble.gatt_adapters import (
|
|
||||||
PackedCharacteristicAdapter,
|
|
||||||
PackedCharacteristicProxyAdapter,
|
|
||||||
)
|
|
||||||
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class BatteryService(TemplateService):
|
class BatteryService(gatt.TemplateService):
|
||||||
UUID = GATT_BATTERY_SERVICE
|
UUID = gatt.GATT_BATTERY_SERVICE
|
||||||
BATTERY_LEVEL_FORMAT = 'B'
|
BATTERY_LEVEL_FORMAT = 'B'
|
||||||
|
|
||||||
battery_level_characteristic: Characteristic[int]
|
battery_level_characteristic: gatt.Characteristic[int]
|
||||||
|
|
||||||
def __init__(self, read_battery_level):
|
def __init__(self, read_battery_level: Callable[[device.Connection], int]) -> None:
|
||||||
self.battery_level_characteristic = PackedCharacteristicAdapter(
|
self.battery_level_characteristic = gatt_adapters.PackedCharacteristicAdapter(
|
||||||
Characteristic(
|
gatt.Characteristic(
|
||||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
gatt.GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
||||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
properties=(
|
||||||
Characteristic.READABLE,
|
gatt.Characteristic.Properties.READ
|
||||||
CharacteristicValue(read=read_battery_level),
|
| gatt.Characteristic.Properties.NOTIFY
|
||||||
|
),
|
||||||
|
permissions=gatt.Characteristic.READABLE,
|
||||||
|
value=gatt.CharacteristicValue(read=read_battery_level),
|
||||||
),
|
),
|
||||||
pack_format=BatteryService.BATTERY_LEVEL_FORMAT,
|
pack_format=BatteryService.BATTERY_LEVEL_FORMAT,
|
||||||
)
|
)
|
||||||
@@ -52,19 +45,17 @@ class BatteryService(TemplateService):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class BatteryServiceProxy(ProfileServiceProxy):
|
class BatteryServiceProxy(gatt_client.ProfileServiceProxy):
|
||||||
SERVICE_CLASS = BatteryService
|
SERVICE_CLASS = BatteryService
|
||||||
|
|
||||||
battery_level: CharacteristicProxy[int] | None
|
battery_level: gatt_client.CharacteristicProxy[int]
|
||||||
|
|
||||||
def __init__(self, service_proxy):
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||||
self.service_proxy = service_proxy
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
self.battery_level = gatt_adapters.PackedCharacteristicProxyAdapter(
|
||||||
GATT_BATTERY_LEVEL_CHARACTERISTIC
|
service_proxy.get_required_characteristic_by_uuid(
|
||||||
):
|
gatt.GATT_BATTERY_LEVEL_CHARACTERISTIC
|
||||||
self.battery_level = PackedCharacteristicProxyAdapter(
|
),
|
||||||
characteristics[0], pack_format=BatteryService.BATTERY_LEVEL_FORMAT
|
pack_format=BatteryService.BATTERY_LEVEL_FORMAT,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
self.battery_level = None
|
|
||||||
|
|||||||
@@ -55,14 +55,15 @@ class GenericAccessService(TemplateService):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self, device_name: str, appearance: Appearance | tuple[int, int] | int = 0
|
self, device_name: str, appearance: Appearance | tuple[int, int] | int = 0
|
||||||
):
|
):
|
||||||
if isinstance(appearance, int):
|
match appearance:
|
||||||
appearance_int = appearance
|
case int():
|
||||||
elif isinstance(appearance, tuple):
|
appearance_int = appearance
|
||||||
appearance_int = (appearance[0] << 6) | appearance[1]
|
case tuple():
|
||||||
elif isinstance(appearance, Appearance):
|
appearance_int = (appearance[0] << 6) | appearance[1]
|
||||||
appearance_int = int(appearance)
|
case Appearance():
|
||||||
else:
|
appearance_int = int(appearance)
|
||||||
raise TypeError()
|
case _:
|
||||||
|
raise TypeError()
|
||||||
|
|
||||||
self.device_name_characteristic = Characteristic(
|
self.device_name_characteristic = Characteristic(
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
GATT_DEVICE_NAME_CHARACTERISTIC,
|
||||||
|
|||||||
@@ -18,40 +18,30 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from enum import IntEnum
|
from collections.abc import Callable, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from bumble import core
|
from typing_extensions import Self
|
||||||
from bumble.att import ATT_Error
|
|
||||||
from bumble.gatt import (
|
from bumble import att, core, device, gatt, gatt_adapters, gatt_client, utils
|
||||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
|
|
||||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
|
|
||||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
|
||||||
GATT_HEART_RATE_SERVICE,
|
|
||||||
Characteristic,
|
|
||||||
CharacteristicValue,
|
|
||||||
TemplateService,
|
|
||||||
)
|
|
||||||
from bumble.gatt_adapters import (
|
|
||||||
DelegatedCharacteristicAdapter,
|
|
||||||
PackedCharacteristicAdapter,
|
|
||||||
SerializableCharacteristicAdapter,
|
|
||||||
)
|
|
||||||
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HeartRateService(TemplateService):
|
class HeartRateService(gatt.TemplateService):
|
||||||
UUID = GATT_HEART_RATE_SERVICE
|
UUID = gatt.GATT_HEART_RATE_SERVICE
|
||||||
|
|
||||||
HEART_RATE_CONTROL_POINT_FORMAT = 'B'
|
HEART_RATE_CONTROL_POINT_FORMAT = 'B'
|
||||||
CONTROL_POINT_NOT_SUPPORTED = 0x80
|
CONTROL_POINT_NOT_SUPPORTED = 0x80
|
||||||
RESET_ENERGY_EXPENDED = 0x01
|
RESET_ENERGY_EXPENDED = 0x01
|
||||||
|
|
||||||
heart_rate_measurement_characteristic: Characteristic[HeartRateMeasurement]
|
heart_rate_measurement_characteristic: gatt.Characteristic[HeartRateMeasurement]
|
||||||
body_sensor_location_characteristic: Characteristic[BodySensorLocation]
|
body_sensor_location_characteristic: gatt.Characteristic[BodySensorLocation]
|
||||||
heart_rate_control_point_characteristic: Characteristic[int]
|
heart_rate_control_point_characteristic: gatt.Characteristic[int]
|
||||||
|
|
||||||
class BodySensorLocation(IntEnum):
|
class BodySensorLocation(utils.OpenIntEnum):
|
||||||
OTHER = 0
|
OTHER = 0
|
||||||
CHEST = 1
|
CHEST = 1
|
||||||
WRIST = 2
|
WRIST = 2
|
||||||
@@ -60,82 +50,90 @@ class HeartRateService(TemplateService):
|
|||||||
EAR_LOBE = 5
|
EAR_LOBE = 5
|
||||||
FOOT = 6
|
FOOT = 6
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
class HeartRateMeasurement:
|
class HeartRateMeasurement:
|
||||||
def __init__(
|
heart_rate: int
|
||||||
self,
|
sensor_contact_detected: bool | None = None
|
||||||
heart_rate,
|
energy_expended: int | None = None
|
||||||
sensor_contact_detected=None,
|
rr_intervals: Sequence[float] | None = None
|
||||||
energy_expended=None,
|
|
||||||
rr_intervals=None,
|
class Flag(enum.IntFlag):
|
||||||
):
|
INT16_HEART_RATE = 1 << 0
|
||||||
if heart_rate < 0 or heart_rate > 0xFFFF:
|
SENSOR_CONTACT_DETECTED = 1 << 1
|
||||||
|
SENSOR_CONTACT_SUPPORTED = 1 << 2
|
||||||
|
ENERGY_EXPENDED_STATUS = 1 << 3
|
||||||
|
RR_INTERVAL = 1 << 4
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.heart_rate < 0 or self.heart_rate > 0xFFFF:
|
||||||
raise core.InvalidArgumentError('heart_rate out of range')
|
raise core.InvalidArgumentError('heart_rate out of range')
|
||||||
|
|
||||||
if energy_expended is not None and (
|
if self.energy_expended is not None and (
|
||||||
energy_expended < 0 or energy_expended > 0xFFFF
|
self.energy_expended < 0 or self.energy_expended > 0xFFFF
|
||||||
):
|
):
|
||||||
raise core.InvalidArgumentError('energy_expended out of range')
|
raise core.InvalidArgumentError('energy_expended out of range')
|
||||||
|
|
||||||
if rr_intervals:
|
if self.rr_intervals:
|
||||||
for rr_interval in rr_intervals:
|
for rr_interval in self.rr_intervals:
|
||||||
if rr_interval < 0 or rr_interval * 1024 > 0xFFFF:
|
if rr_interval < 0 or rr_interval * 1024 > 0xFFFF:
|
||||||
raise core.InvalidArgumentError('rr_intervals out of range')
|
raise core.InvalidArgumentError('rr_intervals out of range')
|
||||||
|
|
||||||
self.heart_rate = heart_rate
|
|
||||||
self.sensor_contact_detected = sensor_contact_detected
|
|
||||||
self.energy_expended = energy_expended
|
|
||||||
self.rr_intervals = rr_intervals
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_bytes(cls, data):
|
def from_bytes(cls, data: bytes) -> Self:
|
||||||
flags = data[0]
|
flags = data[0]
|
||||||
offset = 1
|
offset = 1
|
||||||
|
|
||||||
if flags & 1:
|
if flags & cls.Flag.INT16_HEART_RATE:
|
||||||
hr = struct.unpack_from('<H', data, offset)[0]
|
heart_rate = struct.unpack_from('<H', data, offset)[0]
|
||||||
offset += 2
|
offset += 2
|
||||||
else:
|
else:
|
||||||
hr = struct.unpack_from('B', data, offset)[0]
|
heart_rate = struct.unpack_from('B', data, offset)[0]
|
||||||
offset += 1
|
offset += 1
|
||||||
|
|
||||||
if flags & (1 << 2):
|
if flags & cls.Flag.SENSOR_CONTACT_SUPPORTED:
|
||||||
sensor_contact_detected = flags & (1 << 1) != 0
|
sensor_contact_detected = flags & cls.Flag.SENSOR_CONTACT_DETECTED != 0
|
||||||
else:
|
else:
|
||||||
sensor_contact_detected = None
|
sensor_contact_detected = None
|
||||||
|
|
||||||
if flags & (1 << 3):
|
if flags & cls.Flag.ENERGY_EXPENDED_STATUS:
|
||||||
energy_expended = struct.unpack_from('<H', data, offset)[0]
|
energy_expended = struct.unpack_from('<H', data, offset)[0]
|
||||||
offset += 2
|
offset += 2
|
||||||
else:
|
else:
|
||||||
energy_expended = None
|
energy_expended = None
|
||||||
|
|
||||||
if flags & (1 << 4):
|
rr_intervals: Sequence[float] | None = None
|
||||||
|
if flags & cls.Flag.RR_INTERVAL:
|
||||||
rr_intervals = tuple(
|
rr_intervals = tuple(
|
||||||
struct.unpack_from('<H', data, offset + i * 2)[0] / 1024
|
struct.unpack_from('<H', data, i)[0] / 1024
|
||||||
for i in range((len(data) - offset) // 2)
|
for i in range(offset, len(data), 2)
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
rr_intervals = ()
|
|
||||||
|
|
||||||
return cls(hr, sensor_contact_detected, energy_expended, rr_intervals)
|
return cls(
|
||||||
|
heart_rate=heart_rate,
|
||||||
|
sensor_contact_detected=sensor_contact_detected,
|
||||||
|
energy_expended=energy_expended,
|
||||||
|
rr_intervals=rr_intervals,
|
||||||
|
)
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
|
flags = 0
|
||||||
if self.heart_rate < 256:
|
if self.heart_rate < 256:
|
||||||
flags = 0
|
|
||||||
data = struct.pack('B', self.heart_rate)
|
data = struct.pack('B', self.heart_rate)
|
||||||
else:
|
else:
|
||||||
flags = 1
|
flags |= self.Flag.INT16_HEART_RATE
|
||||||
data = struct.pack('<H', self.heart_rate)
|
data = struct.pack('<H', self.heart_rate)
|
||||||
|
|
||||||
if self.sensor_contact_detected is not None:
|
if self.sensor_contact_detected is not None:
|
||||||
flags |= ((1 if self.sensor_contact_detected else 0) << 1) | (1 << 2)
|
flags |= self.Flag.SENSOR_CONTACT_SUPPORTED
|
||||||
|
if self.sensor_contact_detected:
|
||||||
|
flags |= self.Flag.SENSOR_CONTACT_DETECTED
|
||||||
|
|
||||||
if self.energy_expended is not None:
|
if self.energy_expended is not None:
|
||||||
flags |= 1 << 3
|
flags |= self.Flag.ENERGY_EXPENDED_STATUS
|
||||||
data += struct.pack('<H', self.energy_expended)
|
data += struct.pack('<H', self.energy_expended)
|
||||||
|
|
||||||
if self.rr_intervals:
|
if self.rr_intervals is not None:
|
||||||
flags |= 1 << 4
|
flags |= self.Flag.RR_INTERVAL
|
||||||
data += b''.join(
|
data += b''.join(
|
||||||
[
|
[
|
||||||
struct.pack('<H', int(rr_interval * 1024))
|
struct.pack('<H', int(rr_interval * 1024))
|
||||||
@@ -145,57 +143,67 @@ class HeartRateService(TemplateService):
|
|||||||
|
|
||||||
return bytes([flags]) + data
|
return bytes([flags]) + data
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return (
|
|
||||||
f'HeartRateMeasurement(heart_rate={self.heart_rate},'
|
|
||||||
f' sensor_contact_detected={self.sensor_contact_detected},'
|
|
||||||
f' energy_expended={self.energy_expended},'
|
|
||||||
f' rr_intervals={self.rr_intervals})'
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
read_heart_rate_measurement,
|
read_heart_rate_measurement: Callable[
|
||||||
body_sensor_location=None,
|
[device.Connection], HeartRateMeasurement
|
||||||
reset_energy_expended=None,
|
],
|
||||||
|
body_sensor_location: HeartRateService.BodySensorLocation | None = None,
|
||||||
|
reset_energy_expended: Callable[[device.Connection], Any] | None = None,
|
||||||
):
|
):
|
||||||
self.heart_rate_measurement_characteristic = SerializableCharacteristicAdapter(
|
self.heart_rate_measurement_characteristic = (
|
||||||
Characteristic(
|
gatt_adapters.SerializableCharacteristicAdapter(
|
||||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
gatt.Characteristic(
|
||||||
Characteristic.Properties.NOTIFY,
|
uuid=gatt.GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
||||||
0,
|
properties=gatt.Characteristic.Properties.NOTIFY,
|
||||||
CharacteristicValue(read=read_heart_rate_measurement),
|
permissions=gatt.Characteristic.Permissions(0),
|
||||||
),
|
value=gatt.CharacteristicValue(read=read_heart_rate_measurement),
|
||||||
HeartRateService.HeartRateMeasurement,
|
),
|
||||||
|
HeartRateService.HeartRateMeasurement,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
characteristics = [self.heart_rate_measurement_characteristic]
|
characteristics: list[gatt.Characteristic] = [
|
||||||
|
self.heart_rate_measurement_characteristic
|
||||||
|
]
|
||||||
|
|
||||||
if body_sensor_location is not None:
|
if body_sensor_location is not None:
|
||||||
self.body_sensor_location_characteristic = Characteristic(
|
self.body_sensor_location_characteristic = (
|
||||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
|
gatt_adapters.EnumCharacteristicAdapter(
|
||||||
Characteristic.Properties.READ,
|
gatt.Characteristic(
|
||||||
Characteristic.READABLE,
|
uuid=gatt.GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
|
||||||
bytes([int(body_sensor_location)]),
|
properties=gatt.Characteristic.Properties.READ,
|
||||||
|
permissions=gatt.Characteristic.READABLE,
|
||||||
|
value=body_sensor_location,
|
||||||
|
),
|
||||||
|
cls=self.BodySensorLocation,
|
||||||
|
length=1,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
characteristics.append(self.body_sensor_location_characteristic)
|
characteristics.append(self.body_sensor_location_characteristic)
|
||||||
|
|
||||||
if reset_energy_expended:
|
if reset_energy_expended:
|
||||||
|
|
||||||
def write_heart_rate_control_point_value(connection, value):
|
def write_heart_rate_control_point_value(
|
||||||
|
connection: device.Connection, value: bytes
|
||||||
|
) -> None:
|
||||||
if value == self.RESET_ENERGY_EXPENDED:
|
if value == self.RESET_ENERGY_EXPENDED:
|
||||||
if reset_energy_expended is not None:
|
if reset_energy_expended is not None:
|
||||||
reset_energy_expended(connection)
|
reset_energy_expended(connection)
|
||||||
else:
|
else:
|
||||||
raise ATT_Error(self.CONTROL_POINT_NOT_SUPPORTED)
|
raise att.ATT_Error(self.CONTROL_POINT_NOT_SUPPORTED)
|
||||||
|
|
||||||
self.heart_rate_control_point_characteristic = PackedCharacteristicAdapter(
|
self.heart_rate_control_point_characteristic = (
|
||||||
Characteristic(
|
gatt_adapters.PackedCharacteristicAdapter(
|
||||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
|
gatt.Characteristic(
|
||||||
Characteristic.Properties.WRITE,
|
uuid=gatt.GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
|
||||||
Characteristic.WRITEABLE,
|
properties=gatt.Characteristic.Properties.WRITE,
|
||||||
CharacteristicValue(write=write_heart_rate_control_point_value),
|
permissions=gatt.Characteristic.WRITEABLE,
|
||||||
),
|
value=gatt.CharacteristicValue(
|
||||||
pack_format=HeartRateService.HEART_RATE_CONTROL_POINT_FORMAT,
|
write=write_heart_rate_control_point_value
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pack_format=HeartRateService.HEART_RATE_CONTROL_POINT_FORMAT,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
characteristics.append(self.heart_rate_control_point_characteristic)
|
characteristics.append(self.heart_rate_control_point_characteristic)
|
||||||
|
|
||||||
@@ -203,50 +211,51 @@ class HeartRateService(TemplateService):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HeartRateServiceProxy(ProfileServiceProxy):
|
class HeartRateServiceProxy(gatt_client.ProfileServiceProxy):
|
||||||
SERVICE_CLASS = HeartRateService
|
SERVICE_CLASS = HeartRateService
|
||||||
|
|
||||||
heart_rate_measurement: (
|
heart_rate_measurement: gatt_client.CharacteristicProxy[
|
||||||
CharacteristicProxy[HeartRateService.HeartRateMeasurement] | None
|
HeartRateService.HeartRateMeasurement
|
||||||
)
|
]
|
||||||
body_sensor_location: (
|
body_sensor_location: (
|
||||||
CharacteristicProxy[HeartRateService.BodySensorLocation] | None
|
gatt_client.CharacteristicProxy[HeartRateService.BodySensorLocation] | None
|
||||||
)
|
)
|
||||||
heart_rate_control_point: CharacteristicProxy[int] | None
|
heart_rate_control_point: gatt_client.CharacteristicProxy[int] | None
|
||||||
|
|
||||||
def __init__(self, service_proxy):
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||||
self.service_proxy = service_proxy
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
self.heart_rate_measurement = (
|
||||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC
|
gatt_adapters.SerializableCharacteristicProxyAdapter(
|
||||||
):
|
service_proxy.get_required_characteristic_by_uuid(
|
||||||
self.heart_rate_measurement = SerializableCharacteristicAdapter(
|
gatt.GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC
|
||||||
characteristics[0], HeartRateService.HeartRateMeasurement
|
),
|
||||||
|
HeartRateService.HeartRateMeasurement,
|
||||||
)
|
)
|
||||||
else:
|
)
|
||||||
self.heart_rate_measurement = None
|
|
||||||
|
|
||||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC
|
gatt.GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC
|
||||||
):
|
):
|
||||||
self.body_sensor_location = DelegatedCharacteristicAdapter(
|
self.body_sensor_location = gatt_adapters.EnumCharacteristicProxyAdapter(
|
||||||
characteristics[0],
|
characteristics[0], cls=HeartRateService.BodySensorLocation, length=1
|
||||||
decode=lambda value: HeartRateService.BodySensorLocation(value[0]),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.body_sensor_location = None
|
self.body_sensor_location = None
|
||||||
|
|
||||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC
|
gatt.GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC
|
||||||
):
|
):
|
||||||
self.heart_rate_control_point = PackedCharacteristicAdapter(
|
self.heart_rate_control_point = (
|
||||||
characteristics[0],
|
gatt_adapters.PackedCharacteristicProxyAdapter(
|
||||||
pack_format=HeartRateService.HEART_RATE_CONTROL_POINT_FORMAT,
|
characteristics[0],
|
||||||
|
pack_format=HeartRateService.HEART_RATE_CONTROL_POINT_FORMAT,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.heart_rate_control_point = None
|
self.heart_rate_control_point = None
|
||||||
|
|
||||||
async def reset_energy_expended(self):
|
async def reset_energy_expended(self) -> None:
|
||||||
if self.heart_rate_control_point is not None:
|
if self.heart_rate_control_point is not None:
|
||||||
return await self.heart_rate_control_point.write_value(
|
return await self.heart_rate_control_point.write_value(
|
||||||
HeartRateService.RESET_ENERGY_EXPENDED
|
HeartRateService.RESET_ENERGY_EXPENDED
|
||||||
|
|||||||
@@ -800,7 +800,7 @@ class Multiplexer(utils.EventEmitter):
|
|||||||
|
|
||||||
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
logger.debug(f'>>> Multiplexer sending {frame}')
|
logger.debug(f'>>> Multiplexer sending {frame}')
|
||||||
self.l2cap_channel.send_pdu(frame)
|
self.l2cap_channel.write(bytes(frame))
|
||||||
|
|
||||||
def on_pdu(self, pdu: bytes) -> None:
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
frame = RFCOMM_Frame.from_bytes(pdu)
|
frame = RFCOMM_Frame.from_bytes(pdu)
|
||||||
|
|||||||
459
bumble/sdp.py
459
bumble/sdp.py
@@ -21,11 +21,12 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from collections.abc import Iterable, Sequence
|
from collections.abc import Iterable, Sequence
|
||||||
from typing import TYPE_CHECKING, NewType
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any, ClassVar, NewType, TypeVar
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble import core, l2cap
|
from bumble import core, hci, l2cap, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
InvalidArgumentError,
|
InvalidArgumentError,
|
||||||
@@ -33,7 +34,6 @@ from bumble.core import (
|
|||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
)
|
)
|
||||||
from bumble.hci import HCI_Object, key_with_value, name_or_number
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.device import Connection, Device
|
from bumble.device import Connection, Device
|
||||||
@@ -54,39 +54,22 @@ SDP_CONTINUATION_WATCHDOG = 64 # Maximum number of continuations we're willing
|
|||||||
|
|
||||||
SDP_PSM = 0x0001
|
SDP_PSM = 0x0001
|
||||||
|
|
||||||
SDP_ERROR_RESPONSE = 0x01
|
class PduId(hci.SpecableEnum):
|
||||||
SDP_SERVICE_SEARCH_REQUEST = 0x02
|
SDP_ERROR_RESPONSE = 0x01
|
||||||
SDP_SERVICE_SEARCH_RESPONSE = 0x03
|
SDP_SERVICE_SEARCH_REQUEST = 0x02
|
||||||
SDP_SERVICE_ATTRIBUTE_REQUEST = 0x04
|
SDP_SERVICE_SEARCH_RESPONSE = 0x03
|
||||||
SDP_SERVICE_ATTRIBUTE_RESPONSE = 0x05
|
SDP_SERVICE_ATTRIBUTE_REQUEST = 0x04
|
||||||
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST = 0x06
|
SDP_SERVICE_ATTRIBUTE_RESPONSE = 0x05
|
||||||
SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE = 0x07
|
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST = 0x06
|
||||||
|
SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE = 0x07
|
||||||
|
|
||||||
SDP_PDU_NAMES = {
|
class ErrorCode(hci.SpecableEnum):
|
||||||
SDP_ERROR_RESPONSE: 'SDP_ERROR_RESPONSE',
|
INVALID_SDP_VERSION = 0x0001
|
||||||
SDP_SERVICE_SEARCH_REQUEST: 'SDP_SERVICE_SEARCH_REQUEST',
|
INVALID_SERVICE_RECORD_HANDLE = 0x0002
|
||||||
SDP_SERVICE_SEARCH_RESPONSE: 'SDP_SERVICE_SEARCH_RESPONSE',
|
INVALID_REQUEST_SYNTAX = 0x0003
|
||||||
SDP_SERVICE_ATTRIBUTE_REQUEST: 'SDP_SERVICE_ATTRIBUTE_REQUEST',
|
INVALID_PDU_SIZE = 0x0004
|
||||||
SDP_SERVICE_ATTRIBUTE_RESPONSE: 'SDP_SERVICE_ATTRIBUTE_RESPONSE',
|
INVALID_CONTINUATION_STATE = 0x0005
|
||||||
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST: 'SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST',
|
INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST = 0x0006
|
||||||
SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE: 'SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE'
|
|
||||||
}
|
|
||||||
|
|
||||||
SDP_INVALID_SDP_VERSION_ERROR = 0x0001
|
|
||||||
SDP_INVALID_SERVICE_RECORD_HANDLE_ERROR = 0x0002
|
|
||||||
SDP_INVALID_REQUEST_SYNTAX_ERROR = 0x0003
|
|
||||||
SDP_INVALID_PDU_SIZE_ERROR = 0x0004
|
|
||||||
SDP_INVALID_CONTINUATION_STATE_ERROR = 0x0005
|
|
||||||
SDP_INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST_ERROR = 0x0006
|
|
||||||
|
|
||||||
SDP_ERROR_NAMES = {
|
|
||||||
SDP_INVALID_SDP_VERSION_ERROR: 'SDP_INVALID_SDP_VERSION_ERROR',
|
|
||||||
SDP_INVALID_SERVICE_RECORD_HANDLE_ERROR: 'SDP_INVALID_SERVICE_RECORD_HANDLE_ERROR',
|
|
||||||
SDP_INVALID_REQUEST_SYNTAX_ERROR: 'SDP_INVALID_REQUEST_SYNTAX_ERROR',
|
|
||||||
SDP_INVALID_PDU_SIZE_ERROR: 'SDP_INVALID_PDU_SIZE_ERROR',
|
|
||||||
SDP_INVALID_CONTINUATION_STATE_ERROR: 'SDP_INVALID_CONTINUATION_STATE_ERROR',
|
|
||||||
SDP_INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST_ERROR: 'SDP_INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST_ERROR'
|
|
||||||
}
|
|
||||||
|
|
||||||
SDP_SERVICE_NAME_ATTRIBUTE_ID_OFFSET = 0x0000
|
SDP_SERVICE_NAME_ATTRIBUTE_ID_OFFSET = 0x0000
|
||||||
SDP_SERVICE_DESCRIPTION_ATTRIBUTE_ID_OFFSET = 0x0001
|
SDP_SERVICE_DESCRIPTION_ATTRIBUTE_ID_OFFSET = 0x0001
|
||||||
@@ -141,30 +124,31 @@ SDP_ALL_ATTRIBUTES_RANGE = (0x0000, 0xFFFF)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
class DataElement:
|
class DataElement:
|
||||||
NIL = 0
|
|
||||||
UNSIGNED_INTEGER = 1
|
|
||||||
SIGNED_INTEGER = 2
|
|
||||||
UUID = 3
|
|
||||||
TEXT_STRING = 4
|
|
||||||
BOOLEAN = 5
|
|
||||||
SEQUENCE = 6
|
|
||||||
ALTERNATIVE = 7
|
|
||||||
URL = 8
|
|
||||||
|
|
||||||
TYPE_NAMES = {
|
class Type(utils.OpenIntEnum):
|
||||||
NIL: 'NIL',
|
NIL = 0
|
||||||
UNSIGNED_INTEGER: 'UNSIGNED_INTEGER',
|
UNSIGNED_INTEGER = 1
|
||||||
SIGNED_INTEGER: 'SIGNED_INTEGER',
|
SIGNED_INTEGER = 2
|
||||||
UUID: 'UUID',
|
UUID = 3
|
||||||
TEXT_STRING: 'TEXT_STRING',
|
TEXT_STRING = 4
|
||||||
BOOLEAN: 'BOOLEAN',
|
BOOLEAN = 5
|
||||||
SEQUENCE: 'SEQUENCE',
|
SEQUENCE = 6
|
||||||
ALTERNATIVE: 'ALTERNATIVE',
|
ALTERNATIVE = 7
|
||||||
URL: 'URL',
|
URL = 8
|
||||||
}
|
|
||||||
|
|
||||||
type_constructors = {
|
NIL = Type.NIL
|
||||||
|
UNSIGNED_INTEGER = Type.UNSIGNED_INTEGER
|
||||||
|
SIGNED_INTEGER = Type.SIGNED_INTEGER
|
||||||
|
UUID = Type.UUID
|
||||||
|
TEXT_STRING = Type.TEXT_STRING
|
||||||
|
BOOLEAN = Type.BOOLEAN
|
||||||
|
SEQUENCE = Type.SEQUENCE
|
||||||
|
ALTERNATIVE = Type.ALTERNATIVE
|
||||||
|
URL = Type.URL
|
||||||
|
|
||||||
|
TYPE_CONSTRUCTORS = {
|
||||||
NIL: lambda x: DataElement(DataElement.NIL, None),
|
NIL: lambda x: DataElement(DataElement.NIL, None),
|
||||||
UNSIGNED_INTEGER: lambda x, y: DataElement(
|
UNSIGNED_INTEGER: lambda x, y: DataElement(
|
||||||
DataElement.UNSIGNED_INTEGER,
|
DataElement.UNSIGNED_INTEGER,
|
||||||
@@ -190,14 +174,18 @@ class DataElement:
|
|||||||
URL: lambda x: DataElement(DataElement.URL, x.decode('utf8')),
|
URL: lambda x: DataElement(DataElement.URL, x.decode('utf8')),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, element_type, value, value_size=None):
|
type: Type
|
||||||
self.type = element_type
|
value: Any
|
||||||
self.value = value
|
value_size: int | None = None
|
||||||
self.value_size = value_size
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
# Used as a cache when parsing from bytes so we can emit a byte-for-byte replica
|
# Used as a cache when parsing from bytes so we can emit a byte-for-byte replica
|
||||||
self.bytes = None
|
self._bytes: bytes | None = None
|
||||||
if element_type in (DataElement.UNSIGNED_INTEGER, DataElement.SIGNED_INTEGER):
|
if self.type in (
|
||||||
if value_size is None:
|
DataElement.UNSIGNED_INTEGER,
|
||||||
|
DataElement.SIGNED_INTEGER,
|
||||||
|
):
|
||||||
|
if self.value_size is None:
|
||||||
raise InvalidArgumentError(
|
raise InvalidArgumentError(
|
||||||
'integer types must have a value size specified'
|
'integer types must have a value size specified'
|
||||||
)
|
)
|
||||||
@@ -337,7 +325,7 @@ class DataElement:
|
|||||||
value_offset = 4
|
value_offset = 4
|
||||||
|
|
||||||
value_data = data[1 + value_offset : 1 + value_offset + value_size]
|
value_data = data[1 + value_offset : 1 + value_offset + value_size]
|
||||||
constructor = DataElement.type_constructors.get(element_type)
|
constructor = DataElement.TYPE_CONSTRUCTORS.get(element_type)
|
||||||
if constructor:
|
if constructor:
|
||||||
if element_type in (
|
if element_type in (
|
||||||
DataElement.UNSIGNED_INTEGER,
|
DataElement.UNSIGNED_INTEGER,
|
||||||
@@ -348,15 +336,15 @@ class DataElement:
|
|||||||
result = constructor(value_data)
|
result = constructor(value_data)
|
||||||
else:
|
else:
|
||||||
result = DataElement(element_type, value_data)
|
result = DataElement(element_type, value_data)
|
||||||
result.bytes = data[
|
result._bytes = data[
|
||||||
: 1 + value_offset + value_size
|
: 1 + value_offset + value_size
|
||||||
] # Keep a copy so we can re-serialize to an exact replica
|
] # Keep a copy so we can re-serialize to an exact replica
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self):
|
||||||
# Return early if we have a cache
|
# Return early if we have a cache
|
||||||
if self.bytes:
|
if self._bytes:
|
||||||
return self.bytes
|
return self._bytes
|
||||||
|
|
||||||
if self.type == DataElement.NIL:
|
if self.type == DataElement.NIL:
|
||||||
data = b''
|
data = b''
|
||||||
@@ -443,12 +431,12 @@ class DataElement:
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError("internal error - self.type not supported")
|
raise RuntimeError("internal error - self.type not supported")
|
||||||
|
|
||||||
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
self._bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
||||||
return self.bytes
|
return self._bytes
|
||||||
|
|
||||||
def to_string(self, pretty=False, indentation=0):
|
def to_string(self, pretty=False, indentation=0):
|
||||||
prefix = ' ' * indentation
|
prefix = ' ' * indentation
|
||||||
type_name = name_or_number(self.TYPE_NAMES, self.type)
|
type_name = self.type.name
|
||||||
if self.type == DataElement.NIL:
|
if self.type == DataElement.NIL:
|
||||||
value_string = ''
|
value_string = ''
|
||||||
elif self.type in (DataElement.SEQUENCE, DataElement.ALTERNATIVE):
|
elif self.type in (DataElement.SEQUENCE, DataElement.ALTERNATIVE):
|
||||||
@@ -476,10 +464,10 @@ class DataElement:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
class ServiceAttribute:
|
class ServiceAttribute:
|
||||||
def __init__(self, attribute_id: int, value: DataElement) -> None:
|
id: int
|
||||||
self.id = attribute_id
|
value: DataElement
|
||||||
self.value = value
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_from_data_elements(
|
def list_from_data_elements(
|
||||||
@@ -510,7 +498,7 @@ class ServiceAttribute:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def id_name(id_code):
|
def id_name(id_code):
|
||||||
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
|
return hci.name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
|
def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
|
||||||
@@ -540,239 +528,228 @@ class ServiceAttribute:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
def _parse_service_record_handle_list(
|
||||||
|
data: bytes, offset: int
|
||||||
|
) -> tuple[int, list[int]]:
|
||||||
|
count = struct.unpack_from('>H', data, offset)[0]
|
||||||
|
offset += 2
|
||||||
|
handle_list = [
|
||||||
|
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
||||||
|
]
|
||||||
|
return offset + count * 4, handle_list
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_service_record_handle_list(
|
||||||
|
handles: list[int],
|
||||||
|
) -> bytes:
|
||||||
|
return struct.pack('>H', len(handles)) + b''.join(
|
||||||
|
struct.pack('>I', handle) for handle in handles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bytes_preceded_by_length(data: bytes, offset: int) -> tuple[int, bytes]:
|
||||||
|
length = struct.unpack_from('>H', data, offset)[0]
|
||||||
|
offset += 2
|
||||||
|
return offset + length, data[offset : offset + length]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_bytes_preceded_by_length(data: bytes) -> bytes:
|
||||||
|
return struct.pack('>H', len(data)) + data
|
||||||
|
|
||||||
|
|
||||||
|
_SERVICE_RECORD_HANDLE_LIST_METADATA = hci.metadata(
|
||||||
|
{
|
||||||
|
'parser': _parse_service_record_handle_list,
|
||||||
|
'serializer': _serialize_service_record_handle_list,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_BYTES_PRECEDED_BY_LENGTH_METADATA = hci.metadata(
|
||||||
|
{
|
||||||
|
'parser': _parse_bytes_preceded_by_length,
|
||||||
|
'serializer': _serialize_bytes_preceded_by_length,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
class SDP_PDU:
|
class SDP_PDU:
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.2 PROTOCOL DATA UNIT FORMAT
|
See Bluetooth spec @ Vol 3, Part B - 4.2 PROTOCOL DATA UNIT FORMAT
|
||||||
'''
|
'''
|
||||||
|
|
||||||
RESPONSE_PDU_IDS = {
|
RESPONSE_PDU_IDS = {
|
||||||
SDP_SERVICE_SEARCH_REQUEST: SDP_SERVICE_SEARCH_RESPONSE,
|
PduId.SDP_SERVICE_SEARCH_REQUEST: PduId.SDP_SERVICE_SEARCH_RESPONSE,
|
||||||
SDP_SERVICE_ATTRIBUTE_REQUEST: SDP_SERVICE_ATTRIBUTE_RESPONSE,
|
PduId.SDP_SERVICE_ATTRIBUTE_REQUEST: PduId.SDP_SERVICE_ATTRIBUTE_RESPONSE,
|
||||||
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST: SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE,
|
PduId.SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST: PduId.SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE,
|
||||||
}
|
}
|
||||||
sdp_pdu_classes: dict[int, type[SDP_PDU]] = {}
|
subclasses: ClassVar[dict[int, type[SDP_PDU]]] = {}
|
||||||
name = None
|
pdu_id: ClassVar[PduId]
|
||||||
pdu_id = 0
|
fields: ClassVar[hci.Fields]
|
||||||
|
|
||||||
@staticmethod
|
transaction_id: int
|
||||||
def from_bytes(pdu):
|
_payload: bytes | None = field(init=False, repr=False, default=None)
|
||||||
pdu_id, transaction_id, _parameters_length = struct.unpack_from('>BHH', pdu, 0)
|
|
||||||
|
|
||||||
cls = SDP_PDU.sdp_pdu_classes.get(pdu_id)
|
@classmethod
|
||||||
if cls is None:
|
def from_bytes(cls, pdu: bytes) -> SDP_PDU:
|
||||||
instance = SDP_PDU(pdu)
|
pdu_id, transaction_id, parameters_length = struct.unpack_from('>BHH', pdu, 0)
|
||||||
instance.name = SDP_PDU.pdu_name(pdu_id)
|
|
||||||
instance.pdu_id = pdu_id
|
|
||||||
instance.transaction_id = transaction_id
|
|
||||||
return instance
|
|
||||||
self = cls.__new__(cls)
|
|
||||||
SDP_PDU.__init__(self, pdu, transaction_id)
|
|
||||||
if hasattr(self, 'fields'):
|
|
||||||
self.init_from_bytes(pdu, 5)
|
|
||||||
return self
|
|
||||||
|
|
||||||
@staticmethod
|
if len(pdu) != 5 + parameters_length:
|
||||||
def parse_service_record_handle_list_preceded_by_count(
|
logger.warning("Expect %d bytes, got %d", 5 + parameters_length, len(pdu))
|
||||||
data: bytes, offset: int
|
|
||||||
) -> tuple[int, list[int]]:
|
|
||||||
count = struct.unpack_from('>H', data, offset - 2)[0]
|
|
||||||
handle_list = [
|
|
||||||
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
|
||||||
]
|
|
||||||
return offset + count * 4, handle_list
|
|
||||||
|
|
||||||
@staticmethod
|
subclass = cls.subclasses.get(pdu_id)
|
||||||
def parse_bytes_preceded_by_length(data, offset):
|
if not (subclass := cls.subclasses.get(pdu_id)):
|
||||||
length = struct.unpack_from('>H', data, offset - 2)[0]
|
raise InvalidPacketError(f"Unknown PDU type {pdu_id}")
|
||||||
return offset + length, data[offset : offset + length]
|
instance = subclass(
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
**hci.HCI_Object.dict_from_bytes(pdu, 5, subclass.fields),
|
||||||
|
)
|
||||||
|
instance._payload = pdu
|
||||||
|
return instance
|
||||||
|
|
||||||
@staticmethod
|
_PDU = TypeVar('_PDU', bound='SDP_PDU')
|
||||||
def error_name(error_code):
|
|
||||||
return name_or_number(SDP_ERROR_NAMES, error_code)
|
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def pdu_name(code):
|
def subclass(cls, subclass: type[_PDU]) -> type[_PDU]:
|
||||||
return name_or_number(SDP_PDU_NAMES, code)
|
subclass.fields = hci.HCI_Object.fields_from_dataclass(subclass)
|
||||||
|
cls.subclasses[subclass.pdu_id] = subclass
|
||||||
@staticmethod
|
return subclass
|
||||||
def subclass(fields):
|
|
||||||
def inner(cls):
|
|
||||||
name = cls.__name__
|
|
||||||
|
|
||||||
# add a _ character before every uppercase letter, except the SDP_ prefix
|
|
||||||
location = len(name) - 1
|
|
||||||
while location > 4:
|
|
||||||
if not name[location].isupper():
|
|
||||||
location -= 1
|
|
||||||
continue
|
|
||||||
name = name[:location] + '_' + name[location:]
|
|
||||||
location -= 1
|
|
||||||
|
|
||||||
cls.name = name.upper()
|
|
||||||
cls.pdu_id = key_with_value(SDP_PDU_NAMES, cls.name)
|
|
||||||
if cls.pdu_id is None:
|
|
||||||
raise KeyError(f'PDU name {cls.name} not found in SDP_PDU_NAMES')
|
|
||||||
cls.fields = fields
|
|
||||||
|
|
||||||
# Register a factory for this class
|
|
||||||
SDP_PDU.sdp_pdu_classes[cls.pdu_id] = cls
|
|
||||||
|
|
||||||
return cls
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
def __init__(self, pdu=None, transaction_id=0, **kwargs):
|
|
||||||
if hasattr(self, 'fields') and kwargs:
|
|
||||||
HCI_Object.init_from_fields(self, self.fields, kwargs)
|
|
||||||
if pdu is None:
|
|
||||||
parameters = HCI_Object.dict_to_bytes(kwargs, self.fields)
|
|
||||||
pdu = (
|
|
||||||
struct.pack('>BHH', self.pdu_id, transaction_id, len(parameters))
|
|
||||||
+ parameters
|
|
||||||
)
|
|
||||||
self.pdu = pdu
|
|
||||||
self.transaction_id = transaction_id
|
|
||||||
|
|
||||||
def init_from_bytes(self, pdu, offset):
|
|
||||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self):
|
||||||
return self.pdu
|
if self._payload is None:
|
||||||
|
parameters = hci.HCI_Object.dict_to_bytes(self.__dict__, self.fields)
|
||||||
|
self._payload = (
|
||||||
|
struct.pack('>BHH', self.pdu_id, self.transaction_id, len(parameters))
|
||||||
|
+ parameters
|
||||||
|
)
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.pdu_id.name
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
result = f'{color(self.name, "blue")} [TID={self.transaction_id}]'
|
result = f'{color(self.name, "blue")} [TID={self.transaction_id}]'
|
||||||
if fields := getattr(self, 'fields', None):
|
if fields := getattr(self, 'fields', None):
|
||||||
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
|
result += ':\n' + hci.HCI_Object.format_fields(self.__dict__, fields, ' ')
|
||||||
elif len(self.pdu) > 1:
|
elif len(self.pdu) > 1:
|
||||||
result += f': {self.pdu.hex()}'
|
result += f': {self.pdu.hex()}'
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass([('error_code', {'size': 2, 'mapper': SDP_PDU.error_name})])
|
@SDP_PDU.subclass
|
||||||
|
@dataclass
|
||||||
class SDP_ErrorResponse(SDP_PDU):
|
class SDP_ErrorResponse(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.4.1 SDP_ErrorResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.4.1 SDP_ErrorResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
error_code: int
|
pdu_id = PduId.SDP_ERROR_RESPONSE
|
||||||
|
|
||||||
|
error_code: ErrorCode = field(metadata=ErrorCode.type_metadata(2))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('service_search_pattern', DataElement.parse_from_bytes),
|
|
||||||
('maximum_service_record_count', '>2'),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceSearchRequest(SDP_PDU):
|
class SDP_ServiceSearchRequest(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
service_search_pattern: DataElement
|
pdu_id = PduId.SDP_SERVICE_SEARCH_REQUEST
|
||||||
maximum_service_record_count: int
|
|
||||||
continuation_state: bytes
|
service_search_pattern: DataElement = field(
|
||||||
|
metadata=hci.metadata(DataElement.parse_from_bytes)
|
||||||
|
)
|
||||||
|
maximum_service_record_count: int = field(metadata=hci.metadata('>2'))
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('total_service_record_count', '>2'),
|
|
||||||
('current_service_record_count', '>2'),
|
|
||||||
(
|
|
||||||
'service_record_handle_list',
|
|
||||||
SDP_PDU.parse_service_record_handle_list_preceded_by_count,
|
|
||||||
),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceSearchResponse(SDP_PDU):
|
class SDP_ServiceSearchResponse(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
service_record_handle_list: list[int]
|
pdu_id = PduId.SDP_SERVICE_SEARCH_RESPONSE
|
||||||
total_service_record_count: int
|
|
||||||
current_service_record_count: int
|
total_service_record_count: int = field(metadata=hci.metadata('>2'))
|
||||||
continuation_state: bytes
|
service_record_handle_list: Sequence[int] = field(
|
||||||
|
metadata=_SERVICE_RECORD_HANDLE_LIST_METADATA
|
||||||
|
)
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('service_record_handle', '>4'),
|
|
||||||
('maximum_attribute_byte_count', '>2'),
|
|
||||||
('attribute_id_list', DataElement.parse_from_bytes),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceAttributeRequest(SDP_PDU):
|
class SDP_ServiceAttributeRequest(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
service_record_handle: int
|
pdu_id = PduId.SDP_SERVICE_ATTRIBUTE_REQUEST
|
||||||
maximum_attribute_byte_count: int
|
|
||||||
attribute_id_list: DataElement
|
service_record_handle: int = field(metadata=hci.metadata('>4'))
|
||||||
continuation_state: bytes
|
maximum_attribute_byte_count: int = field(metadata=hci.metadata('>2'))
|
||||||
|
attribute_id_list: DataElement = field(
|
||||||
|
metadata=hci.metadata(DataElement.parse_from_bytes)
|
||||||
|
)
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('attribute_list_byte_count', '>2'),
|
|
||||||
('attribute_list', SDP_PDU.parse_bytes_preceded_by_length),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceAttributeResponse(SDP_PDU):
|
class SDP_ServiceAttributeResponse(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
attribute_list_byte_count: int
|
pdu_id = PduId.SDP_SERVICE_ATTRIBUTE_RESPONSE
|
||||||
attribute_list: bytes
|
|
||||||
continuation_state: bytes
|
attribute_list: bytes = field(metadata=_BYTES_PRECEDED_BY_LENGTH_METADATA)
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('service_search_pattern', DataElement.parse_from_bytes),
|
|
||||||
('maximum_attribute_byte_count', '>2'),
|
|
||||||
('attribute_id_list', DataElement.parse_from_bytes),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceSearchAttributeRequest(SDP_PDU):
|
class SDP_ServiceSearchAttributeRequest(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
service_search_pattern: DataElement
|
pdu_id = PduId.SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST
|
||||||
maximum_attribute_byte_count: int
|
|
||||||
attribute_id_list: DataElement
|
service_search_pattern: DataElement = field(
|
||||||
continuation_state: bytes
|
metadata=hci.metadata(DataElement.parse_from_bytes)
|
||||||
|
)
|
||||||
|
maximum_attribute_byte_count: int = field(metadata=hci.metadata('>2'))
|
||||||
|
attribute_id_list: DataElement = field(
|
||||||
|
metadata=hci.metadata(DataElement.parse_from_bytes)
|
||||||
|
)
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('attribute_lists_byte_count', '>2'),
|
|
||||||
('attribute_lists', SDP_PDU.parse_bytes_preceded_by_length),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceSearchAttributeResponse(SDP_PDU):
|
class SDP_ServiceSearchAttributeResponse(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
attribute_lists_byte_count: int
|
pdu_id = PduId.SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE
|
||||||
attribute_lists: bytes
|
|
||||||
continuation_state: bytes
|
attribute_lists: bytes = field(metadata=_BYTES_PRECEDED_BY_LENGTH_METADATA)
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -847,7 +824,7 @@ class Client:
|
|||||||
self.pending_request = request
|
self.pending_request = request
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.channel.send_pdu(bytes(request))
|
self.channel.write(bytes(request))
|
||||||
return await self.pending_response
|
return await self.pending_response
|
||||||
finally:
|
finally:
|
||||||
self.pending_request = None
|
self.pending_request = None
|
||||||
@@ -873,7 +850,7 @@ class Client:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Request and accumulate until there's no more continuation
|
# Request and accumulate until there's no more continuation
|
||||||
service_record_handle_list = []
|
service_record_handle_list: list[int] = []
|
||||||
continuation_state = bytes([0])
|
continuation_state = bytes([0])
|
||||||
watchdog = SDP_CONTINUATION_WATCHDOG
|
watchdog = SDP_CONTINUATION_WATCHDOG
|
||||||
while watchdog > 0:
|
while watchdog > 0:
|
||||||
@@ -1061,7 +1038,7 @@ class Server:
|
|||||||
|
|
||||||
def send_response(self, response):
|
def send_response(self, response):
|
||||||
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
||||||
self.channel.send_pdu(response)
|
self.channel.write(response)
|
||||||
|
|
||||||
def match_services(self, search_pattern: DataElement) -> dict[int, Service]:
|
def match_services(self, search_pattern: DataElement) -> dict[int, Service]:
|
||||||
# Find the services for which the attributes in the pattern is a subset of the
|
# Find the services for which the attributes in the pattern is a subset of the
|
||||||
@@ -1091,7 +1068,7 @@ class Server:
|
|||||||
logger.exception(color('failed to parse SDP Request PDU', 'red'))
|
logger.exception(color('failed to parse SDP Request PDU', 'red'))
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=0, error_code=SDP_INVALID_REQUEST_SYNTAX_ERROR
|
transaction_id=0, error_code=ErrorCode.INVALID_REQUEST_SYNTAX
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1108,7 +1085,7 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=sdp_pdu.transaction_id,
|
transaction_id=sdp_pdu.transaction_id,
|
||||||
error_code=SDP_INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST_ERROR,
|
error_code=ErrorCode.INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1116,7 +1093,7 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=sdp_pdu.transaction_id,
|
transaction_id=sdp_pdu.transaction_id,
|
||||||
error_code=SDP_INVALID_REQUEST_SYNTAX_ERROR,
|
error_code=ErrorCode.INVALID_REQUEST_SYNTAX,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1134,7 +1111,7 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=transaction_id,
|
transaction_id=transaction_id,
|
||||||
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
|
error_code=ErrorCode.INVALID_CONTINUATION_STATE,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -1228,15 +1205,11 @@ class Server:
|
|||||||
if service_record_handles_remaining
|
if service_record_handles_remaining
|
||||||
else bytes([0])
|
else bytes([0])
|
||||||
)
|
)
|
||||||
service_record_handle_list = b''.join(
|
|
||||||
[struct.pack('>I', handle) for handle in service_record_handles]
|
|
||||||
)
|
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceSearchResponse(
|
SDP_ServiceSearchResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
total_service_record_count=total_service_record_count,
|
total_service_record_count=total_service_record_count,
|
||||||
current_service_record_count=len(service_record_handles),
|
service_record_handle_list=service_record_handles,
|
||||||
service_record_handle_list=service_record_handle_list,
|
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1259,7 +1232,7 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
error_code=SDP_INVALID_SERVICE_RECORD_HANDLE_ERROR,
|
error_code=ErrorCode.INVALID_SERVICE_RECORD_HANDLE,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -1284,7 +1257,6 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceAttributeResponse(
|
SDP_ServiceAttributeResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
attribute_list_byte_count=len(attribute_list_response),
|
|
||||||
attribute_list=attribute_list_response,
|
attribute_list=attribute_list_response,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
@@ -1331,7 +1303,6 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceSearchAttributeResponse(
|
SDP_ServiceSearchAttributeResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
attribute_lists_byte_count=len(attribute_lists_response),
|
|
||||||
attribute_lists=attribute_lists_response,
|
attribute_lists=attribute_lists_response,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
|
|||||||
542
bumble/smp.py
542
bumble/smp.py
@@ -27,18 +27,18 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, ClassVar, TypeVar, cast
|
from typing import TYPE_CHECKING, ClassVar, TypeVar, cast
|
||||||
|
|
||||||
from bumble import crypto, utils
|
from bumble import crypto, hci, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
AdvertisingData,
|
AdvertisingData,
|
||||||
InvalidArgumentError,
|
InvalidArgumentError,
|
||||||
|
InvalidPacketError,
|
||||||
PhysicalTransport,
|
PhysicalTransport,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
name_or_number,
|
|
||||||
)
|
)
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
Address,
|
Address,
|
||||||
@@ -46,7 +46,6 @@ from bumble.hci import (
|
|||||||
HCI_LE_Enable_Encryption_Command,
|
HCI_LE_Enable_Encryption_Command,
|
||||||
HCI_Object,
|
HCI_Object,
|
||||||
Role,
|
Role,
|
||||||
key_with_value,
|
|
||||||
metadata,
|
metadata,
|
||||||
)
|
)
|
||||||
from bumble.keys import PairingKeys
|
from bumble.keys import PairingKeys
|
||||||
@@ -71,115 +70,125 @@ logger = logging.getLogger(__name__)
|
|||||||
SMP_CID = 0x06
|
SMP_CID = 0x06
|
||||||
SMP_BR_CID = 0x07
|
SMP_BR_CID = 0x07
|
||||||
|
|
||||||
SMP_PAIRING_REQUEST_COMMAND = 0x01
|
class CommandCode(hci.SpecableEnum):
|
||||||
SMP_PAIRING_RESPONSE_COMMAND = 0x02
|
PAIRING_REQUEST = 0x01
|
||||||
SMP_PAIRING_CONFIRM_COMMAND = 0x03
|
PAIRING_RESPONSE = 0x02
|
||||||
SMP_PAIRING_RANDOM_COMMAND = 0x04
|
PAIRING_CONFIRM = 0x03
|
||||||
SMP_PAIRING_FAILED_COMMAND = 0x05
|
PAIRING_RANDOM = 0x04
|
||||||
SMP_ENCRYPTION_INFORMATION_COMMAND = 0x06
|
PAIRING_FAILED = 0x05
|
||||||
SMP_MASTER_IDENTIFICATION_COMMAND = 0x07
|
ENCRYPTION_INFORMATION = 0x06
|
||||||
SMP_IDENTITY_INFORMATION_COMMAND = 0x08
|
MASTER_IDENTIFICATION = 0x07
|
||||||
SMP_IDENTITY_ADDRESS_INFORMATION_COMMAND = 0x09
|
IDENTITY_INFORMATION = 0x08
|
||||||
SMP_SIGNING_INFORMATION_COMMAND = 0x0A
|
IDENTITY_ADDRESS_INFORMATION = 0x09
|
||||||
SMP_SECURITY_REQUEST_COMMAND = 0x0B
|
SIGNING_INFORMATION = 0x0A
|
||||||
SMP_PAIRING_PUBLIC_KEY_COMMAND = 0x0C
|
SECURITY_REQUEST = 0x0B
|
||||||
SMP_PAIRING_DHKEY_CHECK_COMMAND = 0x0D
|
PAIRING_PUBLIC_KEY = 0x0C
|
||||||
SMP_PAIRING_KEYPRESS_NOTIFICATION_COMMAND = 0x0E
|
PAIRING_DHKEY_CHECK = 0x0D
|
||||||
|
PAIRING_KEYPRESS_NOTIFICATION = 0x0E
|
||||||
|
|
||||||
SMP_COMMAND_NAMES = {
|
|
||||||
SMP_PAIRING_REQUEST_COMMAND: 'SMP_PAIRING_REQUEST_COMMAND',
|
|
||||||
SMP_PAIRING_RESPONSE_COMMAND: 'SMP_PAIRING_RESPONSE_COMMAND',
|
|
||||||
SMP_PAIRING_CONFIRM_COMMAND: 'SMP_PAIRING_CONFIRM_COMMAND',
|
|
||||||
SMP_PAIRING_RANDOM_COMMAND: 'SMP_PAIRING_RANDOM_COMMAND',
|
|
||||||
SMP_PAIRING_FAILED_COMMAND: 'SMP_PAIRING_FAILED_COMMAND',
|
|
||||||
SMP_ENCRYPTION_INFORMATION_COMMAND: 'SMP_ENCRYPTION_INFORMATION_COMMAND',
|
|
||||||
SMP_MASTER_IDENTIFICATION_COMMAND: 'SMP_MASTER_IDENTIFICATION_COMMAND',
|
|
||||||
SMP_IDENTITY_INFORMATION_COMMAND: 'SMP_IDENTITY_INFORMATION_COMMAND',
|
|
||||||
SMP_IDENTITY_ADDRESS_INFORMATION_COMMAND: 'SMP_IDENTITY_ADDRESS_INFORMATION_COMMAND',
|
|
||||||
SMP_SIGNING_INFORMATION_COMMAND: 'SMP_SIGNING_INFORMATION_COMMAND',
|
|
||||||
SMP_SECURITY_REQUEST_COMMAND: 'SMP_SECURITY_REQUEST_COMMAND',
|
|
||||||
SMP_PAIRING_PUBLIC_KEY_COMMAND: 'SMP_PAIRING_PUBLIC_KEY_COMMAND',
|
|
||||||
SMP_PAIRING_DHKEY_CHECK_COMMAND: 'SMP_PAIRING_DHKEY_CHECK_COMMAND',
|
|
||||||
SMP_PAIRING_KEYPRESS_NOTIFICATION_COMMAND: 'SMP_PAIRING_KEYPRESS_NOTIFICATION_COMMAND'
|
|
||||||
}
|
|
||||||
|
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY = 0x00
|
class IoCapability(hci.SpecableEnum):
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY = 0x01
|
DISPLAY_ONLY = 0x00
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY = 0x02
|
DISPLAY_YES_NO = 0x01
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY = 0x03
|
KEYBOARD_ONLY = 0x02
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY = 0x04
|
NO_INPUT_NO_OUTPUT = 0x03
|
||||||
|
KEYBOARD_DISPLAY = 0x04
|
||||||
|
|
||||||
SMP_IO_CAPABILITY_NAMES = {
|
SMP_DISPLAY_ONLY_IO_CAPABILITY = IoCapability.DISPLAY_ONLY
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: 'SMP_DISPLAY_ONLY_IO_CAPABILITY',
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY = IoCapability.DISPLAY_YES_NO
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: 'SMP_DISPLAY_YES_NO_IO_CAPABILITY',
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY = IoCapability.KEYBOARD_ONLY
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: 'SMP_KEYBOARD_ONLY_IO_CAPABILITY',
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY = IoCapability.NO_INPUT_NO_OUTPUT
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: 'SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY',
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY = IoCapability.KEYBOARD_DISPLAY
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: 'SMP_KEYBOARD_DISPLAY_IO_CAPABILITY'
|
|
||||||
}
|
|
||||||
|
|
||||||
SMP_PASSKEY_ENTRY_FAILED_ERROR = 0x01
|
class ErrorCode(hci.SpecableEnum):
|
||||||
SMP_OOB_NOT_AVAILABLE_ERROR = 0x02
|
PASSKEY_ENTRY_FAILED = 0x01
|
||||||
SMP_AUTHENTICATION_REQUIREMENTS_ERROR = 0x03
|
OOB_NOT_AVAILABLE = 0x02
|
||||||
SMP_CONFIRM_VALUE_FAILED_ERROR = 0x04
|
AUTHENTICATION_REQUIREMENTS = 0x03
|
||||||
SMP_PAIRING_NOT_SUPPORTED_ERROR = 0x05
|
CONFIRM_VALUE_FAILED = 0x04
|
||||||
SMP_ENCRYPTION_KEY_SIZE_ERROR = 0x06
|
PAIRING_NOT_SUPPORTED = 0x05
|
||||||
SMP_COMMAND_NOT_SUPPORTED_ERROR = 0x07
|
ENCRYPTION_KEY_SIZE = 0x06
|
||||||
SMP_UNSPECIFIED_REASON_ERROR = 0x08
|
COMMAND_NOT_SUPPORTED = 0x07
|
||||||
SMP_REPEATED_ATTEMPTS_ERROR = 0x09
|
UNSPECIFIED_REASON = 0x08
|
||||||
SMP_INVALID_PARAMETERS_ERROR = 0x0A
|
REPEATED_ATTEMPTS = 0x09
|
||||||
SMP_DHKEY_CHECK_FAILED_ERROR = 0x0B
|
INVALID_PARAMETERS = 0x0A
|
||||||
SMP_NUMERIC_COMPARISON_FAILED_ERROR = 0x0C
|
DHKEY_CHECK_FAILED = 0x0B
|
||||||
SMP_BD_EDR_PAIRING_IN_PROGRESS_ERROR = 0x0D
|
NUMERIC_COMPARISON_FAILED = 0x0C
|
||||||
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR = 0x0E
|
BD_EDR_PAIRING_IN_PROGRESS = 0x0D
|
||||||
|
CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED = 0x0E
|
||||||
|
|
||||||
SMP_ERROR_NAMES = {
|
SMP_PASSKEY_ENTRY_FAILED_ERROR = ErrorCode.PASSKEY_ENTRY_FAILED
|
||||||
SMP_PASSKEY_ENTRY_FAILED_ERROR: 'SMP_PASSKEY_ENTRY_FAILED_ERROR',
|
SMP_OOB_NOT_AVAILABLE_ERROR = ErrorCode.OOB_NOT_AVAILABLE
|
||||||
SMP_OOB_NOT_AVAILABLE_ERROR: 'SMP_OOB_NOT_AVAILABLE_ERROR',
|
SMP_AUTHENTICATION_REQUIREMENTS_ERROR = ErrorCode.AUTHENTICATION_REQUIREMENTS
|
||||||
SMP_AUTHENTICATION_REQUIREMENTS_ERROR: 'SMP_AUTHENTICATION_REQUIREMENTS_ERROR',
|
SMP_CONFIRM_VALUE_FAILED_ERROR = ErrorCode.CONFIRM_VALUE_FAILED
|
||||||
SMP_CONFIRM_VALUE_FAILED_ERROR: 'SMP_CONFIRM_VALUE_FAILED_ERROR',
|
SMP_PAIRING_NOT_SUPPORTED_ERROR = ErrorCode.PAIRING_NOT_SUPPORTED
|
||||||
SMP_PAIRING_NOT_SUPPORTED_ERROR: 'SMP_PAIRING_NOT_SUPPORTED_ERROR',
|
SMP_ENCRYPTION_KEY_SIZE_ERROR = ErrorCode.ENCRYPTION_KEY_SIZE
|
||||||
SMP_ENCRYPTION_KEY_SIZE_ERROR: 'SMP_ENCRYPTION_KEY_SIZE_ERROR',
|
SMP_COMMAND_NOT_SUPPORTED_ERROR = ErrorCode.COMMAND_NOT_SUPPORTED
|
||||||
SMP_COMMAND_NOT_SUPPORTED_ERROR: 'SMP_COMMAND_NOT_SUPPORTED_ERROR',
|
SMP_UNSPECIFIED_REASON_ERROR = ErrorCode.UNSPECIFIED_REASON
|
||||||
SMP_UNSPECIFIED_REASON_ERROR: 'SMP_UNSPECIFIED_REASON_ERROR',
|
SMP_REPEATED_ATTEMPTS_ERROR = ErrorCode.REPEATED_ATTEMPTS
|
||||||
SMP_REPEATED_ATTEMPTS_ERROR: 'SMP_REPEATED_ATTEMPTS_ERROR',
|
SMP_INVALID_PARAMETERS_ERROR = ErrorCode.INVALID_PARAMETERS
|
||||||
SMP_INVALID_PARAMETERS_ERROR: 'SMP_INVALID_PARAMETERS_ERROR',
|
SMP_DHKEY_CHECK_FAILED_ERROR = ErrorCode.DHKEY_CHECK_FAILED
|
||||||
SMP_DHKEY_CHECK_FAILED_ERROR: 'SMP_DHKEY_CHECK_FAILED_ERROR',
|
SMP_NUMERIC_COMPARISON_FAILED_ERROR = ErrorCode.NUMERIC_COMPARISON_FAILED
|
||||||
SMP_NUMERIC_COMPARISON_FAILED_ERROR: 'SMP_NUMERIC_COMPARISON_FAILED_ERROR',
|
SMP_BD_EDR_PAIRING_IN_PROGRESS_ERROR = ErrorCode.BD_EDR_PAIRING_IN_PROGRESS
|
||||||
SMP_BD_EDR_PAIRING_IN_PROGRESS_ERROR: 'SMP_BD_EDR_PAIRING_IN_PROGRESS_ERROR',
|
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR = ErrorCode.CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED
|
||||||
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR: 'SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR'
|
|
||||||
}
|
|
||||||
|
|
||||||
SMP_PASSKEY_ENTRY_STARTED_KEYPRESS_NOTIFICATION_TYPE = 0
|
class KeypressNotificationType(hci.SpecableEnum):
|
||||||
SMP_PASSKEY_DIGIT_ENTERED_KEYPRESS_NOTIFICATION_TYPE = 1
|
PASSKEY_ENTRY_STARTED = 0
|
||||||
SMP_PASSKEY_DIGIT_ERASED_KEYPRESS_NOTIFICATION_TYPE = 2
|
PASSKEY_DIGIT_ENTERED = 1
|
||||||
SMP_PASSKEY_CLEARED_KEYPRESS_NOTIFICATION_TYPE = 3
|
PASSKEY_DIGIT_ERASED = 2
|
||||||
SMP_PASSKEY_ENTRY_COMPLETED_KEYPRESS_NOTIFICATION_TYPE = 4
|
PASSKEY_CLEARED = 3
|
||||||
|
PASSKEY_ENTRY_COMPLETED = 4
|
||||||
SMP_KEYPRESS_NOTIFICATION_TYPE_NAMES = {
|
|
||||||
SMP_PASSKEY_ENTRY_STARTED_KEYPRESS_NOTIFICATION_TYPE: 'SMP_PASSKEY_ENTRY_STARTED_KEYPRESS_NOTIFICATION_TYPE',
|
|
||||||
SMP_PASSKEY_DIGIT_ENTERED_KEYPRESS_NOTIFICATION_TYPE: 'SMP_PASSKEY_DIGIT_ENTERED_KEYPRESS_NOTIFICATION_TYPE',
|
|
||||||
SMP_PASSKEY_DIGIT_ERASED_KEYPRESS_NOTIFICATION_TYPE: 'SMP_PASSKEY_DIGIT_ERASED_KEYPRESS_NOTIFICATION_TYPE',
|
|
||||||
SMP_PASSKEY_CLEARED_KEYPRESS_NOTIFICATION_TYPE: 'SMP_PASSKEY_CLEARED_KEYPRESS_NOTIFICATION_TYPE',
|
|
||||||
SMP_PASSKEY_ENTRY_COMPLETED_KEYPRESS_NOTIFICATION_TYPE: 'SMP_PASSKEY_ENTRY_COMPLETED_KEYPRESS_NOTIFICATION_TYPE'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Bit flags for key distribution/generation
|
# Bit flags for key distribution/generation
|
||||||
SMP_ENC_KEY_DISTRIBUTION_FLAG = 0b0001
|
class KeyDistribution(hci.SpecableFlag):
|
||||||
SMP_ID_KEY_DISTRIBUTION_FLAG = 0b0010
|
ENC_KEY = 0b0001
|
||||||
SMP_SIGN_KEY_DISTRIBUTION_FLAG = 0b0100
|
ID_KEY = 0b0010
|
||||||
SMP_LINK_KEY_DISTRIBUTION_FLAG = 0b1000
|
SIGN_KEY = 0b0100
|
||||||
|
LINK_KEY = 0b1000
|
||||||
|
|
||||||
# AuthReq fields
|
# AuthReq fields
|
||||||
SMP_BONDING_AUTHREQ = 0b00000001
|
class AuthReq(hci.SpecableFlag):
|
||||||
SMP_MITM_AUTHREQ = 0b00000100
|
BONDING = 0b00000001
|
||||||
SMP_SC_AUTHREQ = 0b00001000
|
MITM = 0b00000100
|
||||||
SMP_KEYPRESS_AUTHREQ = 0b00010000
|
SC = 0b00001000
|
||||||
SMP_CT2_AUTHREQ = 0b00100000
|
KEYPRESS = 0b00010000
|
||||||
|
CT2 = 0b00100000
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_booleans(
|
||||||
|
cls,
|
||||||
|
bonding: bool = False,
|
||||||
|
sc: bool = False,
|
||||||
|
mitm: bool = False,
|
||||||
|
keypress: bool = False,
|
||||||
|
ct2: bool = False,
|
||||||
|
) -> AuthReq:
|
||||||
|
auth_req = AuthReq(0)
|
||||||
|
if bonding:
|
||||||
|
auth_req |= AuthReq.BONDING
|
||||||
|
if sc:
|
||||||
|
auth_req |= AuthReq.SC
|
||||||
|
if mitm:
|
||||||
|
auth_req |= AuthReq.MITM
|
||||||
|
if keypress:
|
||||||
|
auth_req |= AuthReq.KEYPRESS
|
||||||
|
if ct2:
|
||||||
|
auth_req |= AuthReq.CT2
|
||||||
|
return auth_req
|
||||||
|
|
||||||
# Crypto salt
|
# Crypto salt
|
||||||
SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('000000000000000000000000746D7031')
|
SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('000000000000000000000000746D7031')
|
||||||
SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('000000000000000000000000746D7032')
|
SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('000000000000000000000000746D7032')
|
||||||
|
|
||||||
|
# Diffie-Hellman private / public key pair in Debug Mode (Core - Vol. 3, Part H)
|
||||||
|
SMP_DEBUG_KEY_PRIVATE = bytes.fromhex(
|
||||||
|
'3f49f6d4 a3c55f38 74c9b3e3 d2103f50 4aff607b eb40b799 5899b8a6 cd3c1abd'
|
||||||
|
)
|
||||||
|
SMP_DEBUG_KEY_PUBLIC_X = bytes.fromhex(
|
||||||
|
'20b003d2 f297be2c 5e2c83a7 e9f9a5b9 eff49111 acf4fddb cc030148 0e359de6'
|
||||||
|
)
|
||||||
|
SMP_DEBUG_KEY_PUBLIC_Y= bytes.fromhex(
|
||||||
|
'dc809c49 652aeb6d 63329abf 5a52155c 766345c2 8fed3024 741c8ed0 1589d28b'
|
||||||
|
)
|
||||||
# fmt: on
|
# fmt: on
|
||||||
# pylint: enable=line-too-long
|
# pylint: enable=line-too-long
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@@ -188,8 +197,6 @@ SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('000000000000000000000000746D7032')
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Utils
|
# Utils
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def error_name(error_code: int) -> str:
|
|
||||||
return name_or_number(SMP_ERROR_NAMES, error_code)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -201,20 +208,22 @@ class SMP_Command:
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3 SECURITY MANAGER PROTOCOL
|
See Bluetooth spec @ Vol 3, Part H - 3 SECURITY MANAGER PROTOCOL
|
||||||
'''
|
'''
|
||||||
|
|
||||||
smp_classes: ClassVar[dict[int, type[SMP_Command]]] = {}
|
smp_classes: ClassVar[dict[CommandCode, type[SMP_Command]]] = {}
|
||||||
fields: ClassVar[Fields]
|
fields: ClassVar[Fields]
|
||||||
code: int = field(default=0, init=False)
|
code: CommandCode = field(default=CommandCode(0), init=False)
|
||||||
name: str = field(default='', init=False)
|
name: str = field(default='', init=False)
|
||||||
_payload: bytes | None = field(default=None, init=False)
|
_payload: bytes | None = field(default=None, init=False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_bytes(cls, pdu: bytes) -> SMP_Command:
|
def from_bytes(cls, pdu: bytes) -> SMP_Command:
|
||||||
code = pdu[0]
|
if not pdu:
|
||||||
|
raise InvalidPacketError("Empty SMP PDU")
|
||||||
|
code = CommandCode(pdu[0])
|
||||||
|
|
||||||
subclass = SMP_Command.smp_classes.get(code)
|
subclass = SMP_Command.smp_classes.get(code)
|
||||||
if subclass is None:
|
if subclass is None:
|
||||||
instance = SMP_Command()
|
instance = SMP_Command()
|
||||||
instance.name = SMP_Command.command_name(code)
|
instance.name = code.name
|
||||||
instance.code = code
|
instance.code = code
|
||||||
instance.payload = pdu
|
instance.payload = pdu
|
||||||
return instance
|
return instance
|
||||||
@@ -222,59 +231,14 @@ class SMP_Command:
|
|||||||
instance.payload = pdu[1:]
|
instance.payload = pdu[1:]
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def command_name(code: int) -> str:
|
|
||||||
return name_or_number(SMP_COMMAND_NAMES, code)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def auth_req_str(value: int) -> str:
|
|
||||||
bonding_flags = value & 3
|
|
||||||
mitm = (value >> 2) & 1
|
|
||||||
sc = (value >> 3) & 1
|
|
||||||
keypress = (value >> 4) & 1
|
|
||||||
ct2 = (value >> 5) & 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
f'bonding_flags={bonding_flags}, '
|
|
||||||
f'MITM={mitm}, sc={sc}, keypress={keypress}, ct2={ct2}'
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def io_capability_name(io_capability: int) -> str:
|
|
||||||
return name_or_number(SMP_IO_CAPABILITY_NAMES, io_capability)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def key_distribution_str(value: int) -> str:
|
|
||||||
key_types: list[str] = []
|
|
||||||
if value & SMP_ENC_KEY_DISTRIBUTION_FLAG:
|
|
||||||
key_types.append('ENC')
|
|
||||||
if value & SMP_ID_KEY_DISTRIBUTION_FLAG:
|
|
||||||
key_types.append('ID')
|
|
||||||
if value & SMP_SIGN_KEY_DISTRIBUTION_FLAG:
|
|
||||||
key_types.append('SIGN')
|
|
||||||
if value & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
|
||||||
key_types.append('LINK')
|
|
||||||
return ','.join(key_types)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def keypress_notification_type_name(notification_type: int) -> str:
|
|
||||||
return name_or_number(SMP_KEYPRESS_NOTIFICATION_TYPE_NAMES, notification_type)
|
|
||||||
|
|
||||||
_Command = TypeVar("_Command", bound="SMP_Command")
|
_Command = TypeVar("_Command", bound="SMP_Command")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def subclass(cls, subclass: type[_Command]) -> type[_Command]:
|
def subclass(cls, subclass: type[_Command]) -> type[_Command]:
|
||||||
subclass.name = subclass.__name__.upper()
|
|
||||||
subclass.code = key_with_value(SMP_COMMAND_NAMES, subclass.name)
|
|
||||||
if subclass.code is None:
|
|
||||||
raise KeyError(
|
|
||||||
f'Command name {subclass.name} not found in SMP_COMMAND_NAMES'
|
|
||||||
)
|
|
||||||
subclass.fields = HCI_Object.fields_from_dataclass(subclass)
|
subclass.fields = HCI_Object.fields_from_dataclass(subclass)
|
||||||
|
subclass.name = subclass.__name__.upper()
|
||||||
# Register a factory for this class
|
# Register a factory for this class
|
||||||
SMP_Command.smp_classes[subclass.code] = subclass
|
SMP_Command.smp_classes[subclass.code] = subclass
|
||||||
|
|
||||||
return subclass
|
return subclass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -308,19 +272,17 @@ class SMP_Pairing_Request_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.5.1 Pairing Request
|
See Bluetooth spec @ Vol 3, Part H - 3.5.1 Pairing Request
|
||||||
'''
|
'''
|
||||||
|
|
||||||
io_capability: int = field(
|
code = CommandCode.PAIRING_REQUEST
|
||||||
metadata=metadata({'size': 1, 'mapper': SMP_Command.io_capability_name})
|
|
||||||
)
|
io_capability: IoCapability = field(metadata=IoCapability.type_metadata(1))
|
||||||
oob_data_flag: int = field(metadata=metadata(1))
|
oob_data_flag: int = field(metadata=metadata(1))
|
||||||
auth_req: int = field(
|
auth_req: AuthReq = field(metadata=AuthReq.type_metadata(1))
|
||||||
metadata=metadata({'size': 1, 'mapper': SMP_Command.auth_req_str})
|
|
||||||
)
|
|
||||||
maximum_encryption_key_size: int = field(metadata=metadata(1))
|
maximum_encryption_key_size: int = field(metadata=metadata(1))
|
||||||
initiator_key_distribution: int = field(
|
initiator_key_distribution: KeyDistribution = field(
|
||||||
metadata=metadata({'size': 1, 'mapper': SMP_Command.key_distribution_str})
|
metadata=KeyDistribution.type_metadata(1)
|
||||||
)
|
)
|
||||||
responder_key_distribution: int = field(
|
responder_key_distribution: KeyDistribution = field(
|
||||||
metadata=metadata({'size': 1, 'mapper': SMP_Command.key_distribution_str})
|
metadata=KeyDistribution.type_metadata(1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -332,19 +294,17 @@ class SMP_Pairing_Response_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.5.2 Pairing Response
|
See Bluetooth spec @ Vol 3, Part H - 3.5.2 Pairing Response
|
||||||
'''
|
'''
|
||||||
|
|
||||||
io_capability: int = field(
|
code = CommandCode.PAIRING_RESPONSE
|
||||||
metadata=metadata({'size': 1, 'mapper': SMP_Command.io_capability_name})
|
|
||||||
)
|
io_capability: IoCapability = field(metadata=IoCapability.type_metadata(1))
|
||||||
oob_data_flag: int = field(metadata=metadata(1))
|
oob_data_flag: int = field(metadata=metadata(1))
|
||||||
auth_req: int = field(
|
auth_req: AuthReq = field(metadata=AuthReq.type_metadata(1))
|
||||||
metadata=metadata({'size': 1, 'mapper': SMP_Command.auth_req_str})
|
|
||||||
)
|
|
||||||
maximum_encryption_key_size: int = field(metadata=metadata(1))
|
maximum_encryption_key_size: int = field(metadata=metadata(1))
|
||||||
initiator_key_distribution: int = field(
|
initiator_key_distribution: KeyDistribution = field(
|
||||||
metadata=metadata({'size': 1, 'mapper': SMP_Command.key_distribution_str})
|
metadata=KeyDistribution.type_metadata(1)
|
||||||
)
|
)
|
||||||
responder_key_distribution: int = field(
|
responder_key_distribution: KeyDistribution = field(
|
||||||
metadata=metadata({'size': 1, 'mapper': SMP_Command.key_distribution_str})
|
metadata=KeyDistribution.type_metadata(1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,6 +316,8 @@ class SMP_Pairing_Confirm_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.5.3 Pairing Confirm
|
See Bluetooth spec @ Vol 3, Part H - 3.5.3 Pairing Confirm
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
code = CommandCode.PAIRING_CONFIRM
|
||||||
|
|
||||||
confirm_value: bytes = field(metadata=metadata(16))
|
confirm_value: bytes = field(metadata=metadata(16))
|
||||||
|
|
||||||
|
|
||||||
@@ -367,6 +329,8 @@ class SMP_Pairing_Random_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.5.4 Pairing Random
|
See Bluetooth spec @ Vol 3, Part H - 3.5.4 Pairing Random
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
code = CommandCode.PAIRING_RANDOM
|
||||||
|
|
||||||
random_value: bytes = field(metadata=metadata(16))
|
random_value: bytes = field(metadata=metadata(16))
|
||||||
|
|
||||||
|
|
||||||
@@ -378,7 +342,9 @@ class SMP_Pairing_Failed_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.5.5 Pairing Failed
|
See Bluetooth spec @ Vol 3, Part H - 3.5.5 Pairing Failed
|
||||||
'''
|
'''
|
||||||
|
|
||||||
reason: int = field(metadata=metadata({'size': 1, 'mapper': error_name}))
|
code = CommandCode.PAIRING_FAILED
|
||||||
|
|
||||||
|
reason: ErrorCode = field(metadata=ErrorCode.type_metadata(1))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -389,6 +355,8 @@ class SMP_Pairing_Public_Key_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.5.6 Pairing Public Key
|
See Bluetooth spec @ Vol 3, Part H - 3.5.6 Pairing Public Key
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
code = CommandCode.PAIRING_PUBLIC_KEY
|
||||||
|
|
||||||
public_key_x: bytes = field(metadata=metadata(32))
|
public_key_x: bytes = field(metadata=metadata(32))
|
||||||
public_key_y: bytes = field(metadata=metadata(32))
|
public_key_y: bytes = field(metadata=metadata(32))
|
||||||
|
|
||||||
@@ -401,6 +369,8 @@ class SMP_Pairing_DHKey_Check_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.5.7 Pairing DHKey Check
|
See Bluetooth spec @ Vol 3, Part H - 3.5.7 Pairing DHKey Check
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
code = CommandCode.PAIRING_DHKEY_CHECK
|
||||||
|
|
||||||
dhkey_check: bytes = field(metadata=metadata(16))
|
dhkey_check: bytes = field(metadata=metadata(16))
|
||||||
|
|
||||||
|
|
||||||
@@ -412,10 +382,10 @@ class SMP_Pairing_Keypress_Notification_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.5.8 Keypress Notification
|
See Bluetooth spec @ Vol 3, Part H - 3.5.8 Keypress Notification
|
||||||
'''
|
'''
|
||||||
|
|
||||||
notification_type: int = field(
|
code = CommandCode.PAIRING_KEYPRESS_NOTIFICATION
|
||||||
metadata=metadata(
|
|
||||||
{'size': 1, 'mapper': SMP_Command.keypress_notification_type_name}
|
notification_type: KeypressNotificationType = field(
|
||||||
)
|
metadata=KeypressNotificationType.type_metadata(1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -427,6 +397,8 @@ class SMP_Encryption_Information_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.6.2 Encryption Information
|
See Bluetooth spec @ Vol 3, Part H - 3.6.2 Encryption Information
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
code = CommandCode.ENCRYPTION_INFORMATION
|
||||||
|
|
||||||
long_term_key: bytes = field(metadata=metadata(16))
|
long_term_key: bytes = field(metadata=metadata(16))
|
||||||
|
|
||||||
|
|
||||||
@@ -438,6 +410,8 @@ class SMP_Master_Identification_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.6.3 Master Identification
|
See Bluetooth spec @ Vol 3, Part H - 3.6.3 Master Identification
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
code = CommandCode.MASTER_IDENTIFICATION
|
||||||
|
|
||||||
ediv: int = field(metadata=metadata(2))
|
ediv: int = field(metadata=metadata(2))
|
||||||
rand: bytes = field(metadata=metadata(8))
|
rand: bytes = field(metadata=metadata(8))
|
||||||
|
|
||||||
@@ -450,6 +424,8 @@ class SMP_Identity_Information_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.6.4 Identity Information
|
See Bluetooth spec @ Vol 3, Part H - 3.6.4 Identity Information
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
code = CommandCode.IDENTITY_INFORMATION
|
||||||
|
|
||||||
identity_resolving_key: bytes = field(metadata=metadata(16))
|
identity_resolving_key: bytes = field(metadata=metadata(16))
|
||||||
|
|
||||||
|
|
||||||
@@ -461,6 +437,8 @@ class SMP_Identity_Address_Information_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.6.5 Identity Address Information
|
See Bluetooth spec @ Vol 3, Part H - 3.6.5 Identity Address Information
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
code = CommandCode.IDENTITY_ADDRESS_INFORMATION
|
||||||
|
|
||||||
addr_type: int = field(metadata=metadata(Address.ADDRESS_TYPE_SPEC))
|
addr_type: int = field(metadata=metadata(Address.ADDRESS_TYPE_SPEC))
|
||||||
bd_addr: Address = field(metadata=metadata(Address.parse_address_preceded_by_type))
|
bd_addr: Address = field(metadata=metadata(Address.parse_address_preceded_by_type))
|
||||||
|
|
||||||
@@ -473,6 +451,8 @@ class SMP_Signing_Information_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.6.6 Signing Information
|
See Bluetooth spec @ Vol 3, Part H - 3.6.6 Signing Information
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
code = CommandCode.SIGNING_INFORMATION
|
||||||
|
|
||||||
signature_key: bytes = field(metadata=metadata(16))
|
signature_key: bytes = field(metadata=metadata(16))
|
||||||
|
|
||||||
|
|
||||||
@@ -484,33 +464,22 @@ class SMP_Security_Request_Command(SMP_Command):
|
|||||||
See Bluetooth spec @ Vol 3, Part H - 3.6.7 Security Request
|
See Bluetooth spec @ Vol 3, Part H - 3.6.7 Security Request
|
||||||
'''
|
'''
|
||||||
|
|
||||||
auth_req: int = field(
|
code = CommandCode.SECURITY_REQUEST
|
||||||
metadata=metadata({'size': 1, 'mapper': SMP_Command.auth_req_str})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
auth_req: AuthReq = field(metadata=AuthReq.type_metadata(1))
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
def smp_auth_req(bonding: bool, mitm: bool, sc: bool, keypress: bool, ct2: bool) -> int:
|
|
||||||
value = 0
|
|
||||||
if bonding:
|
|
||||||
value |= SMP_BONDING_AUTHREQ
|
|
||||||
if mitm:
|
|
||||||
value |= SMP_MITM_AUTHREQ
|
|
||||||
if sc:
|
|
||||||
value |= SMP_SC_AUTHREQ
|
|
||||||
if keypress:
|
|
||||||
value |= SMP_KEYPRESS_AUTHREQ
|
|
||||||
if ct2:
|
|
||||||
value |= SMP_CT2_AUTHREQ
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class AddressResolver:
|
class AddressResolver:
|
||||||
def __init__(self, resolving_keys):
|
def __init__(self, resolving_keys: Sequence[tuple[bytes, Address]]) -> None:
|
||||||
self.resolving_keys = resolving_keys
|
self.resolving_keys = resolving_keys
|
||||||
|
|
||||||
def resolve(self, address):
|
def can_resolve_to(self, address: Address) -> bool:
|
||||||
|
return any(
|
||||||
|
resolved_address == address for _, resolved_address in self.resolving_keys
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve(self, address: Address) -> Address | None:
|
||||||
address_bytes = bytes(address)
|
address_bytes = bytes(address)
|
||||||
hash_part = address_bytes[0:3]
|
hash_part = address_bytes[0:3]
|
||||||
prand = address_bytes[3:6]
|
prand = address_bytes[3:6]
|
||||||
@@ -671,8 +640,8 @@ class Session:
|
|||||||
self.ltk_rand = bytes(8)
|
self.ltk_rand = bytes(8)
|
||||||
self.link_key: bytes | None = None
|
self.link_key: bytes | None = None
|
||||||
self.maximum_encryption_key_size: int = 0
|
self.maximum_encryption_key_size: int = 0
|
||||||
self.initiator_key_distribution: int = 0
|
self.initiator_key_distribution: KeyDistribution = KeyDistribution(0)
|
||||||
self.responder_key_distribution: int = 0
|
self.responder_key_distribution: KeyDistribution = KeyDistribution(0)
|
||||||
self.peer_random_value: bytes | None = None
|
self.peer_random_value: bytes | None = None
|
||||||
self.peer_public_key_x: bytes = bytes(32)
|
self.peer_public_key_x: bytes = bytes(32)
|
||||||
self.peer_public_key_y = bytes(32)
|
self.peer_public_key_y = bytes(32)
|
||||||
@@ -723,10 +692,10 @@ class Session:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Key Distribution (default values before negotiation)
|
# Key Distribution (default values before negotiation)
|
||||||
self.initiator_key_distribution = (
|
self.initiator_key_distribution = KeyDistribution(
|
||||||
pairing_config.delegate.local_initiator_key_distribution
|
pairing_config.delegate.local_initiator_key_distribution
|
||||||
)
|
)
|
||||||
self.responder_key_distribution = (
|
self.responder_key_distribution = KeyDistribution(
|
||||||
pairing_config.delegate.local_responder_key_distribution
|
pairing_config.delegate.local_responder_key_distribution
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -738,7 +707,7 @@ class Session:
|
|||||||
self.ct2: bool = False
|
self.ct2: bool = False
|
||||||
|
|
||||||
# I/O Capabilities
|
# I/O Capabilities
|
||||||
self.io_capability = pairing_config.delegate.io_capability
|
self.io_capability = IoCapability(pairing_config.delegate.io_capability)
|
||||||
self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
|
self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
|
||||||
|
|
||||||
# OOB
|
# OOB
|
||||||
@@ -817,8 +786,14 @@ class Session:
|
|||||||
return self.nx[0 if self.is_responder else 1]
|
return self.nx[0 if self.is_responder else 1]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_req(self) -> int:
|
def auth_req(self) -> AuthReq:
|
||||||
return smp_auth_req(self.bonding, self.mitm, self.sc, self.keypress, self.ct2)
|
return AuthReq.from_booleans(
|
||||||
|
bonding=self.bonding,
|
||||||
|
sc=self.sc,
|
||||||
|
mitm=self.mitm,
|
||||||
|
keypress=self.keypress,
|
||||||
|
ct2=self.ct2,
|
||||||
|
)
|
||||||
|
|
||||||
def get_long_term_key(self, rand: bytes, ediv: int) -> bytes | None:
|
def get_long_term_key(self, rand: bytes, ediv: int) -> bytes | None:
|
||||||
if not self.sc and not self.completed:
|
if not self.sc and not self.completed:
|
||||||
@@ -838,7 +813,7 @@ class Session:
|
|||||||
if self.connection.transport == PhysicalTransport.BR_EDR:
|
if self.connection.transport == PhysicalTransport.BR_EDR:
|
||||||
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
|
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
|
||||||
return
|
return
|
||||||
if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0):
|
if (not self.mitm) and (auth_req & AuthReq.MITM == 0):
|
||||||
self.pairing_method = PairingMethod.JUST_WORKS
|
self.pairing_method = PairingMethod.JUST_WORKS
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -856,7 +831,7 @@ class Session:
|
|||||||
self.passkey_display = details[1 if self.is_initiator else 2]
|
self.passkey_display = details[1 if self.is_initiator else 2]
|
||||||
|
|
||||||
def check_expected_value(
|
def check_expected_value(
|
||||||
self, expected: bytes, received: bytes, error: int
|
self, expected: bytes, received: bytes, error: ErrorCode
|
||||||
) -> bool:
|
) -> bool:
|
||||||
logger.debug(f'expected={expected.hex()} got={received.hex()}')
|
logger.debug(f'expected={expected.hex()} got={received.hex()}')
|
||||||
if expected != received:
|
if expected != received:
|
||||||
@@ -876,7 +851,7 @@ class Session:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('exception while confirm')
|
logger.exception('exception while confirm')
|
||||||
|
|
||||||
self.send_pairing_failed(SMP_CONFIRM_VALUE_FAILED_ERROR)
|
self.send_pairing_failed(ErrorCode.CONFIRM_VALUE_FAILED)
|
||||||
|
|
||||||
self.connection.cancel_on_disconnection(prompt())
|
self.connection.cancel_on_disconnection(prompt())
|
||||||
|
|
||||||
@@ -895,7 +870,7 @@ class Session:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('exception while prompting')
|
logger.exception('exception while prompting')
|
||||||
|
|
||||||
self.send_pairing_failed(SMP_CONFIRM_VALUE_FAILED_ERROR)
|
self.send_pairing_failed(ErrorCode.CONFIRM_VALUE_FAILED)
|
||||||
|
|
||||||
self.connection.cancel_on_disconnection(prompt())
|
self.connection.cancel_on_disconnection(prompt())
|
||||||
|
|
||||||
@@ -906,13 +881,13 @@ class Session:
|
|||||||
passkey = await self.pairing_config.delegate.get_number()
|
passkey = await self.pairing_config.delegate.get_number()
|
||||||
if passkey is None:
|
if passkey is None:
|
||||||
logger.debug('Passkey request rejected')
|
logger.debug('Passkey request rejected')
|
||||||
self.send_pairing_failed(SMP_PASSKEY_ENTRY_FAILED_ERROR)
|
self.send_pairing_failed(ErrorCode.PASSKEY_ENTRY_FAILED)
|
||||||
return
|
return
|
||||||
logger.debug(f'user input: {passkey}')
|
logger.debug(f'user input: {passkey}')
|
||||||
next_steps(passkey)
|
next_steps(passkey)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('exception while prompting')
|
logger.exception('exception while prompting')
|
||||||
self.send_pairing_failed(SMP_PASSKEY_ENTRY_FAILED_ERROR)
|
self.send_pairing_failed(ErrorCode.PASSKEY_ENTRY_FAILED)
|
||||||
|
|
||||||
self.connection.cancel_on_disconnection(prompt())
|
self.connection.cancel_on_disconnection(prompt())
|
||||||
|
|
||||||
@@ -967,7 +942,7 @@ class Session:
|
|||||||
def send_command(self, command: SMP_Command) -> None:
|
def send_command(self, command: SMP_Command) -> None:
|
||||||
self.manager.send_command(self.connection, command)
|
self.manager.send_command(self.connection, command)
|
||||||
|
|
||||||
def send_pairing_failed(self, error: int) -> None:
|
def send_pairing_failed(self, error: ErrorCode) -> None:
|
||||||
self.send_command(SMP_Pairing_Failed_Command(reason=error))
|
self.send_command(SMP_Pairing_Failed_Command(reason=error))
|
||||||
self.on_pairing_failure(error)
|
self.on_pairing_failure(error)
|
||||||
|
|
||||||
@@ -1139,7 +1114,7 @@ class Session:
|
|||||||
'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!'
|
||||||
)
|
)
|
||||||
self.send_pairing_failed(
|
self.send_pairing_failed(
|
||||||
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR
|
ErrorCode.CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.ltk = self.derive_ltk(self.link_key, self.ct2)
|
self.ltk = self.derive_ltk(self.link_key, self.ct2)
|
||||||
@@ -1150,14 +1125,14 @@ class Session:
|
|||||||
# CTKD: Derive LTK from LinkKey
|
# CTKD: Derive LTK from LinkKey
|
||||||
if (
|
if (
|
||||||
self.connection.transport == PhysicalTransport.BR_EDR
|
self.connection.transport == PhysicalTransport.BR_EDR
|
||||||
and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
|
and self.initiator_key_distribution & KeyDistribution.ENC_KEY
|
||||||
):
|
):
|
||||||
self.ctkd_task = self.connection.cancel_on_disconnection(
|
self.ctkd_task = self.connection.cancel_on_disconnection(
|
||||||
self.get_link_key_and_derive_ltk()
|
self.get_link_key_and_derive_ltk()
|
||||||
)
|
)
|
||||||
elif not self.sc:
|
elif not self.sc:
|
||||||
# Distribute the LTK, EDIV and RAND
|
# Distribute the LTK, EDIV and RAND
|
||||||
if self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG:
|
if self.initiator_key_distribution & KeyDistribution.ENC_KEY:
|
||||||
self.send_command(
|
self.send_command(
|
||||||
SMP_Encryption_Information_Command(long_term_key=self.ltk)
|
SMP_Encryption_Information_Command(long_term_key=self.ltk)
|
||||||
)
|
)
|
||||||
@@ -1168,7 +1143,7 @@ class Session:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Distribute IRK & BD ADDR
|
# Distribute IRK & BD ADDR
|
||||||
if self.initiator_key_distribution & SMP_ID_KEY_DISTRIBUTION_FLAG:
|
if self.initiator_key_distribution & KeyDistribution.ID_KEY:
|
||||||
self.send_command(
|
self.send_command(
|
||||||
SMP_Identity_Information_Command(
|
SMP_Identity_Information_Command(
|
||||||
identity_resolving_key=self.manager.device.irk
|
identity_resolving_key=self.manager.device.irk
|
||||||
@@ -1178,25 +1153,25 @@ class Session:
|
|||||||
|
|
||||||
# Distribute CSRK
|
# Distribute CSRK
|
||||||
csrk = bytes(16) # FIXME: testing
|
csrk = bytes(16) # FIXME: testing
|
||||||
if self.initiator_key_distribution & SMP_SIGN_KEY_DISTRIBUTION_FLAG:
|
if self.initiator_key_distribution & KeyDistribution.SIGN_KEY:
|
||||||
self.send_command(SMP_Signing_Information_Command(signature_key=csrk))
|
self.send_command(SMP_Signing_Information_Command(signature_key=csrk))
|
||||||
|
|
||||||
# CTKD, calculate BR/EDR link key
|
# CTKD, calculate BR/EDR link key
|
||||||
if self.initiator_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
if self.initiator_key_distribution & KeyDistribution.LINK_KEY:
|
||||||
self.link_key = self.derive_link_key(self.ltk, self.ct2)
|
self.link_key = self.derive_link_key(self.ltk, self.ct2)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# CTKD: Derive LTK from LinkKey
|
# CTKD: Derive LTK from LinkKey
|
||||||
if (
|
if (
|
||||||
self.connection.transport == PhysicalTransport.BR_EDR
|
self.connection.transport == PhysicalTransport.BR_EDR
|
||||||
and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
|
and self.responder_key_distribution & KeyDistribution.ENC_KEY
|
||||||
):
|
):
|
||||||
self.ctkd_task = self.connection.cancel_on_disconnection(
|
self.ctkd_task = self.connection.cancel_on_disconnection(
|
||||||
self.get_link_key_and_derive_ltk()
|
self.get_link_key_and_derive_ltk()
|
||||||
)
|
)
|
||||||
# Distribute the LTK, EDIV and RAND
|
# Distribute the LTK, EDIV and RAND
|
||||||
elif not self.sc:
|
elif not self.sc:
|
||||||
if self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG:
|
if self.responder_key_distribution & KeyDistribution.ENC_KEY:
|
||||||
self.send_command(
|
self.send_command(
|
||||||
SMP_Encryption_Information_Command(long_term_key=self.ltk)
|
SMP_Encryption_Information_Command(long_term_key=self.ltk)
|
||||||
)
|
)
|
||||||
@@ -1207,7 +1182,7 @@ class Session:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Distribute IRK & BD ADDR
|
# Distribute IRK & BD ADDR
|
||||||
if self.responder_key_distribution & SMP_ID_KEY_DISTRIBUTION_FLAG:
|
if self.responder_key_distribution & KeyDistribution.ID_KEY:
|
||||||
self.send_command(
|
self.send_command(
|
||||||
SMP_Identity_Information_Command(
|
SMP_Identity_Information_Command(
|
||||||
identity_resolving_key=self.manager.device.irk
|
identity_resolving_key=self.manager.device.irk
|
||||||
@@ -1217,30 +1192,30 @@ class Session:
|
|||||||
|
|
||||||
# Distribute CSRK
|
# Distribute CSRK
|
||||||
csrk = bytes(16) # FIXME: testing
|
csrk = bytes(16) # FIXME: testing
|
||||||
if self.responder_key_distribution & SMP_SIGN_KEY_DISTRIBUTION_FLAG:
|
if self.responder_key_distribution & KeyDistribution.SIGN_KEY:
|
||||||
self.send_command(SMP_Signing_Information_Command(signature_key=csrk))
|
self.send_command(SMP_Signing_Information_Command(signature_key=csrk))
|
||||||
|
|
||||||
# CTKD, calculate BR/EDR link key
|
# CTKD, calculate BR/EDR link key
|
||||||
if self.responder_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
if self.responder_key_distribution & KeyDistribution.LINK_KEY:
|
||||||
self.link_key = self.derive_link_key(self.ltk, self.ct2)
|
self.link_key = self.derive_link_key(self.ltk, self.ct2)
|
||||||
|
|
||||||
def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None:
|
def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None:
|
||||||
# Set our expectations for what to wait for in the key distribution phase
|
# Set our expectations for what to wait for in the key distribution phase
|
||||||
self.peer_expected_distributions = []
|
self.peer_expected_distributions = []
|
||||||
if not self.sc and self.connection.transport == PhysicalTransport.LE:
|
if not self.sc and self.connection.transport == PhysicalTransport.LE:
|
||||||
if key_distribution_flags & SMP_ENC_KEY_DISTRIBUTION_FLAG != 0:
|
if key_distribution_flags & KeyDistribution.ENC_KEY != 0:
|
||||||
self.peer_expected_distributions.append(
|
self.peer_expected_distributions.append(
|
||||||
SMP_Encryption_Information_Command
|
SMP_Encryption_Information_Command
|
||||||
)
|
)
|
||||||
self.peer_expected_distributions.append(
|
self.peer_expected_distributions.append(
|
||||||
SMP_Master_Identification_Command
|
SMP_Master_Identification_Command
|
||||||
)
|
)
|
||||||
if key_distribution_flags & SMP_ID_KEY_DISTRIBUTION_FLAG != 0:
|
if key_distribution_flags & KeyDistribution.ID_KEY != 0:
|
||||||
self.peer_expected_distributions.append(SMP_Identity_Information_Command)
|
self.peer_expected_distributions.append(SMP_Identity_Information_Command)
|
||||||
self.peer_expected_distributions.append(
|
self.peer_expected_distributions.append(
|
||||||
SMP_Identity_Address_Information_Command
|
SMP_Identity_Address_Information_Command
|
||||||
)
|
)
|
||||||
if key_distribution_flags & SMP_SIGN_KEY_DISTRIBUTION_FLAG != 0:
|
if key_distribution_flags & KeyDistribution.SIGN_KEY != 0:
|
||||||
self.peer_expected_distributions.append(SMP_Signing_Information_Command)
|
self.peer_expected_distributions.append(SMP_Signing_Information_Command)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'expecting distributions: '
|
'expecting distributions: '
|
||||||
@@ -1253,7 +1228,7 @@ class Session:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
color('received key distribution on a non-encrypted connection', 'red')
|
color('received key distribution on a non-encrypted connection', 'red')
|
||||||
)
|
)
|
||||||
self.send_pairing_failed(SMP_UNSPECIFIED_REASON_ERROR)
|
self.send_pairing_failed(ErrorCode.UNSPECIFIED_REASON)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check that this command class is expected
|
# Check that this command class is expected
|
||||||
@@ -1273,7 +1248,7 @@ class Session:
|
|||||||
'red',
|
'red',
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.send_pairing_failed(SMP_UNSPECIFIED_REASON_ERROR)
|
self.send_pairing_failed(ErrorCode.UNSPECIFIED_REASON)
|
||||||
|
|
||||||
async def pair(self) -> None:
|
async def pair(self) -> None:
|
||||||
# Start pairing as an initiator
|
# Start pairing as an initiator
|
||||||
@@ -1384,34 +1359,56 @@ class Session:
|
|||||||
)
|
)
|
||||||
await self.manager.on_pairing(self, peer_address, keys)
|
await self.manager.on_pairing(self, peer_address, keys)
|
||||||
|
|
||||||
def on_pairing_failure(self, reason: int) -> None:
|
def on_pairing_failure(self, reason: ErrorCode) -> None:
|
||||||
logger.warning(f'pairing failure ({error_name(reason)})')
|
logger.warning('pairing failure (%s)', reason.name)
|
||||||
|
|
||||||
if self.completed:
|
if self.completed:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.completed = True
|
self.completed = True
|
||||||
|
|
||||||
error = ProtocolError(reason, 'smp', error_name(reason))
|
error = ProtocolError(reason, 'smp', reason.name)
|
||||||
if self.pairing_result is not None and not self.pairing_result.done():
|
if self.pairing_result is not None and not self.pairing_result.done():
|
||||||
self.pairing_result.set_exception(error)
|
self.pairing_result.set_exception(error)
|
||||||
self.manager.on_pairing_failure(self, reason)
|
self.manager.on_pairing_failure(self, reason)
|
||||||
|
|
||||||
def on_smp_command(self, command: SMP_Command) -> None:
|
def on_smp_command(self, command: SMP_Command) -> None:
|
||||||
# Find the handler method
|
try:
|
||||||
handler_name = f'on_{command.name.lower()}'
|
match command:
|
||||||
handler = getattr(self, handler_name, None)
|
case SMP_Pairing_Request_Command():
|
||||||
if handler is not None:
|
self.on_smp_pairing_request_command(command)
|
||||||
try:
|
case SMP_Pairing_Response_Command():
|
||||||
handler(command)
|
self.on_smp_pairing_response_command(command)
|
||||||
except Exception:
|
case SMP_Pairing_Confirm_Command():
|
||||||
logger.exception(color("!!! Exception in handler:", "red"))
|
self.on_smp_pairing_confirm_command(command)
|
||||||
response = SMP_Pairing_Failed_Command(
|
case SMP_Pairing_Random_Command():
|
||||||
reason=SMP_UNSPECIFIED_REASON_ERROR
|
self.on_smp_pairing_random_command(command)
|
||||||
)
|
case SMP_Pairing_Failed_Command():
|
||||||
self.send_command(response)
|
self.on_smp_pairing_failed_command(command)
|
||||||
else:
|
case SMP_Encryption_Information_Command():
|
||||||
logger.error(color('SMP command not handled???', 'red'))
|
self.on_smp_encryption_information_command(command)
|
||||||
|
case SMP_Master_Identification_Command():
|
||||||
|
self.on_smp_master_identification_command(command)
|
||||||
|
case SMP_Identity_Information_Command():
|
||||||
|
self.on_smp_identity_information_command(command)
|
||||||
|
case SMP_Identity_Address_Information_Command():
|
||||||
|
self.on_smp_identity_address_information_command(command)
|
||||||
|
case SMP_Signing_Information_Command():
|
||||||
|
self.on_smp_signing_information_command(command)
|
||||||
|
case SMP_Pairing_Public_Key_Command():
|
||||||
|
self.on_smp_pairing_public_key_command(command)
|
||||||
|
case SMP_Pairing_DHKey_Check_Command():
|
||||||
|
self.on_smp_pairing_dhkey_check_command(command)
|
||||||
|
# case SMP_Security_Request_Command():
|
||||||
|
# self.on_smp_security_request_command(command)
|
||||||
|
# case SMP_Pairing_Keypress_Notification_Command():
|
||||||
|
# self.on_smp_pairing_keypress_notification_command(command)
|
||||||
|
case _:
|
||||||
|
logger.error(color('SMP command not handled', 'red'))
|
||||||
|
except Exception:
|
||||||
|
logger.exception(color("!!! Exception in handler:", "red"))
|
||||||
|
response = SMP_Pairing_Failed_Command(reason=ErrorCode.UNSPECIFIED_REASON)
|
||||||
|
self.send_command(response)
|
||||||
|
|
||||||
def on_smp_pairing_request_command(
|
def on_smp_pairing_request_command(
|
||||||
self, command: SMP_Pairing_Request_Command
|
self, command: SMP_Pairing_Request_Command
|
||||||
@@ -1431,16 +1428,16 @@ class Session:
|
|||||||
accepted = False
|
accepted = False
|
||||||
if not accepted:
|
if not accepted:
|
||||||
logger.debug('pairing rejected by delegate')
|
logger.debug('pairing rejected by delegate')
|
||||||
self.send_pairing_failed(SMP_PAIRING_NOT_SUPPORTED_ERROR)
|
self.send_pairing_failed(ErrorCode.PAIRING_NOT_SUPPORTED)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Save the request
|
# Save the request
|
||||||
self.preq = bytes(command)
|
self.preq = bytes(command)
|
||||||
|
|
||||||
# Bonding and SC require both sides to request/support it
|
# Bonding and SC require both sides to request/support it
|
||||||
self.bonding = self.bonding and (command.auth_req & SMP_BONDING_AUTHREQ != 0)
|
self.bonding = self.bonding and (command.auth_req & AuthReq.BONDING != 0)
|
||||||
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
|
self.sc = self.sc and (command.auth_req & AuthReq.SC != 0)
|
||||||
self.ct2 = self.ct2 and (command.auth_req & SMP_CT2_AUTHREQ != 0)
|
self.ct2 = self.ct2 and (command.auth_req & AuthReq.CT2 != 0)
|
||||||
|
|
||||||
# Infer the pairing method
|
# Infer the pairing method
|
||||||
if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
|
if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
|
||||||
@@ -1451,7 +1448,7 @@ class Session:
|
|||||||
if not self.sc and self.tk is None:
|
if not self.sc and self.tk is None:
|
||||||
# For legacy OOB, TK is required.
|
# For legacy OOB, TK is required.
|
||||||
logger.warning("legacy OOB without TK")
|
logger.warning("legacy OOB without TK")
|
||||||
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
|
self.send_pairing_failed(ErrorCode.OOB_NOT_AVAILABLE)
|
||||||
return
|
return
|
||||||
if command.oob_data_flag == 0:
|
if command.oob_data_flag == 0:
|
||||||
# The peer doesn't have OOB data, use r=0
|
# The peer doesn't have OOB data, use r=0
|
||||||
@@ -1470,8 +1467,11 @@ class Session:
|
|||||||
(
|
(
|
||||||
self.initiator_key_distribution,
|
self.initiator_key_distribution,
|
||||||
self.responder_key_distribution,
|
self.responder_key_distribution,
|
||||||
) = await self.pairing_config.delegate.key_distribution_response(
|
) = map(
|
||||||
command.initiator_key_distribution, command.responder_key_distribution
|
KeyDistribution,
|
||||||
|
await self.pairing_config.delegate.key_distribution_response(
|
||||||
|
command.initiator_key_distribution, command.responder_key_distribution
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.compute_peer_expected_distributions(self.initiator_key_distribution)
|
self.compute_peer_expected_distributions(self.initiator_key_distribution)
|
||||||
|
|
||||||
@@ -1509,8 +1509,8 @@ class Session:
|
|||||||
self.peer_io_capability = command.io_capability
|
self.peer_io_capability = command.io_capability
|
||||||
|
|
||||||
# Bonding and SC require both sides to request/support it
|
# Bonding and SC require both sides to request/support it
|
||||||
self.bonding = self.bonding and (command.auth_req & SMP_BONDING_AUTHREQ != 0)
|
self.bonding = self.bonding and (command.auth_req & AuthReq.BONDING != 0)
|
||||||
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
|
self.sc = self.sc and (command.auth_req & AuthReq.SC != 0)
|
||||||
|
|
||||||
# Infer the pairing method
|
# Infer the pairing method
|
||||||
if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
|
if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
|
||||||
@@ -1521,7 +1521,7 @@ class Session:
|
|||||||
if not self.sc and self.tk is None:
|
if not self.sc and self.tk is None:
|
||||||
# For legacy OOB, TK is required.
|
# For legacy OOB, TK is required.
|
||||||
logger.warning("legacy OOB without TK")
|
logger.warning("legacy OOB without TK")
|
||||||
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
|
self.send_pairing_failed(ErrorCode.OOB_NOT_AVAILABLE)
|
||||||
return
|
return
|
||||||
if command.oob_data_flag == 0:
|
if command.oob_data_flag == 0:
|
||||||
# The peer doesn't have OOB data, use r=0
|
# The peer doesn't have OOB data, use r=0
|
||||||
@@ -1541,7 +1541,7 @@ class Session:
|
|||||||
command.responder_key_distribution & ~self.responder_key_distribution != 0
|
command.responder_key_distribution & ~self.responder_key_distribution != 0
|
||||||
):
|
):
|
||||||
# The response isn't a subset of the request
|
# The response isn't a subset of the request
|
||||||
self.send_pairing_failed(SMP_INVALID_PARAMETERS_ERROR)
|
self.send_pairing_failed(ErrorCode.INVALID_PARAMETERS)
|
||||||
return
|
return
|
||||||
self.initiator_key_distribution = command.initiator_key_distribution
|
self.initiator_key_distribution = command.initiator_key_distribution
|
||||||
self.responder_key_distribution = command.responder_key_distribution
|
self.responder_key_distribution = command.responder_key_distribution
|
||||||
@@ -1619,7 +1619,7 @@ class Session:
|
|||||||
)
|
)
|
||||||
assert self.confirm_value
|
assert self.confirm_value
|
||||||
if not self.check_expected_value(
|
if not self.check_expected_value(
|
||||||
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
|
self.confirm_value, confirm_verifier, ErrorCode.CONFIRM_VALUE_FAILED
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1660,7 +1660,7 @@ class Session:
|
|||||||
self.pkb, self.pka, command.random_value, bytes([0])
|
self.pkb, self.pka, command.random_value, bytes([0])
|
||||||
)
|
)
|
||||||
if not self.check_expected_value(
|
if not self.check_expected_value(
|
||||||
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
|
self.confirm_value, confirm_verifier, ErrorCode.CONFIRM_VALUE_FAILED
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
elif self.pairing_method == PairingMethod.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
@@ -1673,7 +1673,7 @@ class Session:
|
|||||||
bytes([0x80 + ((self.passkey >> self.passkey_step) & 1)]),
|
bytes([0x80 + ((self.passkey >> self.passkey_step) & 1)]),
|
||||||
)
|
)
|
||||||
if not self.check_expected_value(
|
if not self.check_expected_value(
|
||||||
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
|
self.confirm_value, confirm_verifier, ErrorCode.CONFIRM_VALUE_FAILED
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1702,7 +1702,7 @@ class Session:
|
|||||||
bytes([0x80 + ((self.passkey >> self.passkey_step) & 1)]),
|
bytes([0x80 + ((self.passkey >> self.passkey_step) & 1)]),
|
||||||
)
|
)
|
||||||
if not self.check_expected_value(
|
if not self.check_expected_value(
|
||||||
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
|
self.confirm_value, confirm_verifier, ErrorCode.CONFIRM_VALUE_FAILED
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1819,7 +1819,7 @@ class Session:
|
|||||||
if not self.check_expected_value(
|
if not self.check_expected_value(
|
||||||
self.peer_oob_data.c,
|
self.peer_oob_data.c,
|
||||||
confirm_verifier,
|
confirm_verifier,
|
||||||
SMP_CONFIRM_VALUE_FAILED_ERROR,
|
ErrorCode.CONFIRM_VALUE_FAILED,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1853,7 +1853,7 @@ class Session:
|
|||||||
expected = self.eb if self.is_initiator else self.ea
|
expected = self.eb if self.is_initiator else self.ea
|
||||||
assert expected
|
assert expected
|
||||||
if not self.check_expected_value(
|
if not self.check_expected_value(
|
||||||
expected, command.dhkey_check, SMP_DHKEY_CHECK_FAILED_ERROR
|
expected, command.dhkey_check, ErrorCode.DHKEY_CHECK_FAILED
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1932,6 +1932,7 @@ class Manager(utils.EventEmitter):
|
|||||||
self._ecc_key = None
|
self._ecc_key = None
|
||||||
self.pairing_config_factory = pairing_config_factory
|
self.pairing_config_factory = pairing_config_factory
|
||||||
self.session_proxy = Session
|
self.session_proxy = Session
|
||||||
|
self.debug_mode = False
|
||||||
|
|
||||||
def send_command(self, connection: Connection, command: SMP_Command) -> None:
|
def send_command(self, connection: Connection, command: SMP_Command) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -1957,7 +1958,7 @@ class Manager(utils.EventEmitter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Security request is more than just pairing, so let applications handle them
|
# Security request is more than just pairing, so let applications handle them
|
||||||
if command.code == SMP_SECURITY_REQUEST_COMMAND:
|
if command.code == CommandCode.SECURITY_REQUEST:
|
||||||
self.on_smp_security_request_command(
|
self.on_smp_security_request_command(
|
||||||
connection, cast(SMP_Security_Request_Command, command)
|
connection, cast(SMP_Security_Request_Command, command)
|
||||||
)
|
)
|
||||||
@@ -1978,6 +1979,13 @@ class Manager(utils.EventEmitter):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def ecc_key(self) -> crypto.EccKey:
|
def ecc_key(self) -> crypto.EccKey:
|
||||||
|
if self.debug_mode:
|
||||||
|
# Core - Vol 3, Part H:
|
||||||
|
# When the Security Manager is placed in a Debug mode it shall use the
|
||||||
|
# following Diffie-Hellman private / public key pair:
|
||||||
|
debug_key = crypto.EccKey.from_private_key_bytes(SMP_DEBUG_KEY_PRIVATE)
|
||||||
|
return debug_key
|
||||||
|
|
||||||
if self._ecc_key is None:
|
if self._ecc_key is None:
|
||||||
self._ecc_key = crypto.EccKey.generate()
|
self._ecc_key = crypto.EccKey.generate()
|
||||||
assert self._ecc_key
|
assert self._ecc_key
|
||||||
@@ -1997,15 +2005,13 @@ class Manager(utils.EventEmitter):
|
|||||||
def request_pairing(self, connection: Connection) -> None:
|
def request_pairing(self, connection: Connection) -> None:
|
||||||
pairing_config = self.pairing_config_factory(connection)
|
pairing_config = self.pairing_config_factory(connection)
|
||||||
if pairing_config:
|
if pairing_config:
|
||||||
auth_req = smp_auth_req(
|
auth_req = AuthReq.from_booleans(
|
||||||
pairing_config.bonding,
|
bonding=pairing_config.bonding,
|
||||||
pairing_config.mitm,
|
sc=pairing_config.sc,
|
||||||
pairing_config.sc,
|
mitm=pairing_config.mitm,
|
||||||
False,
|
|
||||||
False,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
auth_req = 0
|
auth_req = AuthReq(0)
|
||||||
self.send_command(connection, SMP_Security_Request_Command(auth_req=auth_req))
|
self.send_command(connection, SMP_Security_Request_Command(auth_req=auth_req))
|
||||||
|
|
||||||
def on_session_start(self, session: Session) -> None:
|
def on_session_start(self, session: Session) -> None:
|
||||||
@@ -2021,7 +2027,7 @@ class Manager(utils.EventEmitter):
|
|||||||
# 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)
|
||||||
|
|
||||||
def on_pairing_failure(self, session: Session, reason: int) -> None:
|
def on_pairing_failure(self, session: Session, reason: ErrorCode) -> None:
|
||||||
self.device.on_pairing_failure(session.connection, reason)
|
self.device.on_pairing_failure(session.connection, reason)
|
||||||
|
|
||||||
def on_session_end(self, session: Session) -> None:
|
def on_session_end(self, session: Session) -> None:
|
||||||
|
|||||||
112
bumble/snoop.py
112
bumble/snoop.py
@@ -110,6 +110,53 @@ class BtSnooper(Snooper):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class PcapSnooper(Snooper):
|
||||||
|
"""
|
||||||
|
Snooper that saves or streames HCI packets using the PCAP format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PCAP_MAGIC = 0xA1B2C3D4
|
||||||
|
DLT_BLUETOOTH_HCI_H4_WITH_PHDR = 201
|
||||||
|
|
||||||
|
def __init__(self, output: BinaryIO):
|
||||||
|
self.output = output
|
||||||
|
|
||||||
|
# Write the header
|
||||||
|
self.output.write(
|
||||||
|
struct.pack(
|
||||||
|
"<IHHIIII",
|
||||||
|
self.PCAP_MAGIC,
|
||||||
|
2, # Major PCAP Version
|
||||||
|
4, # Minor PCAP Version
|
||||||
|
0, # Reserved 1
|
||||||
|
0, # Reserved 2
|
||||||
|
65535, # SnapLen
|
||||||
|
# FCS and f are set to 0 implicitly by the next line
|
||||||
|
self.DLT_BLUETOOTH_HCI_H4_WITH_PHDR, # The DLT in this PCAP
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def snoop(self, hci_packet: bytes, direction: Snooper.Direction):
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
sec = int(now.timestamp())
|
||||||
|
usec = now.microsecond
|
||||||
|
|
||||||
|
# Emit the record
|
||||||
|
self.output.write(
|
||||||
|
struct.pack(
|
||||||
|
"<IIII",
|
||||||
|
sec, # Timestamp (Seconds)
|
||||||
|
usec, # Timestamp (Microseconds)
|
||||||
|
len(hci_packet) + 4,
|
||||||
|
len(hci_packet) + 4, # +4 because of the addtional direction info...
|
||||||
|
)
|
||||||
|
+ struct.pack(">I", int(direction)) # ...thats being added here
|
||||||
|
+ hci_packet
|
||||||
|
)
|
||||||
|
self.output.flush() # flush after every packet for live logging
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
_SNOOPER_INSTANCE_COUNT = 0
|
_SNOOPER_INSTANCE_COUNT = 0
|
||||||
|
|
||||||
@@ -140,9 +187,38 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
|||||||
pid: the current process ID.
|
pid: the current process ID.
|
||||||
instance: the instance ID in the current process.
|
instance: the instance ID in the current process.
|
||||||
|
|
||||||
|
pcapsnoop
|
||||||
|
The syntax for the type-specific arguments for this type is:
|
||||||
|
<io-type>:<io-type-specific-arguments>
|
||||||
|
|
||||||
|
Supported I/O types are:
|
||||||
|
|
||||||
|
file
|
||||||
|
The type-specific arguments for this I/O type is a string that is converted
|
||||||
|
to a file path using the python `str.format()` string formatting. The log
|
||||||
|
records will be written to that file if it can be opened/created.
|
||||||
|
The keyword args that may be referenced by the string pattern are:
|
||||||
|
now: the value of `datetime.now()`
|
||||||
|
utcnow: the value of `datetime.now(tz=datetime.timezone.utc)`
|
||||||
|
pid: the current process ID.
|
||||||
|
instance: the instance ID in the current process.
|
||||||
|
|
||||||
|
pipe
|
||||||
|
The type-specific arguments for this I/O type is a string that is converted
|
||||||
|
to a path using the python `str.format()` string formatting. The log
|
||||||
|
records will be written to the named pipe referenced by this path
|
||||||
|
if it can be opened. The keyword args that may be referenced by the
|
||||||
|
string pattern are:
|
||||||
|
now: the value of `datetime.now()`
|
||||||
|
utcnow: the value of `datetime.now(tz=datetime.timezone.utc)`
|
||||||
|
pid: the current process ID.
|
||||||
|
instance: the instance ID in the current process.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
btsnoop:file:my_btsnoop.log
|
btsnoop:file:my_btsnoop.log
|
||||||
btsnoop:file:/tmp/bumble_{now:%Y-%m-%d-%H:%M:%S}_{pid}.log
|
btsnoop:file:/tmp/bumble_{now:%Y-%m-%d-%H:%M:%S}_{pid}.log
|
||||||
|
pcapsnoop:pipe:/tmp/bumble-extcap
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if ':' not in spec:
|
if ':' not in spec:
|
||||||
@@ -150,6 +226,8 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
|||||||
|
|
||||||
snooper_type, snooper_args = spec.split(':', maxsplit=1)
|
snooper_type, snooper_args = spec.split(':', maxsplit=1)
|
||||||
|
|
||||||
|
global _SNOOPER_INSTANCE_COUNT
|
||||||
|
|
||||||
if snooper_type == 'btsnoop':
|
if snooper_type == 'btsnoop':
|
||||||
if ':' not in snooper_args:
|
if ':' not in snooper_args:
|
||||||
raise core.InvalidArgumentError('I/O type for btsnoop snooper type missing')
|
raise core.InvalidArgumentError('I/O type for btsnoop snooper type missing')
|
||||||
@@ -157,7 +235,6 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
|||||||
io_type, io_name = snooper_args.split(':', maxsplit=1)
|
io_type, io_name = snooper_args.split(':', maxsplit=1)
|
||||||
if io_type == 'file':
|
if io_type == 'file':
|
||||||
# Process the file name string pattern.
|
# Process the file name string pattern.
|
||||||
global _SNOOPER_INSTANCE_COUNT
|
|
||||||
file_path = io_name.format(
|
file_path = io_name.format(
|
||||||
now=datetime.datetime.now(),
|
now=datetime.datetime.now(),
|
||||||
utcnow=datetime.datetime.now(tz=datetime.timezone.utc),
|
utcnow=datetime.datetime.now(tz=datetime.timezone.utc),
|
||||||
@@ -173,6 +250,39 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
|||||||
_SNOOPER_INSTANCE_COUNT -= 1
|
_SNOOPER_INSTANCE_COUNT -= 1
|
||||||
return
|
return
|
||||||
|
|
||||||
|
elif snooper_type == 'pcapsnoop':
|
||||||
|
if ':' not in snooper_args:
|
||||||
|
raise core.InvalidArgumentError(
|
||||||
|
'I/O type for pcapsnoop snooper type missing'
|
||||||
|
)
|
||||||
|
|
||||||
|
io_type, io_name = snooper_args.split(':', maxsplit=1)
|
||||||
|
if io_type in {'pipe', 'file'}:
|
||||||
|
# Process the file name string pattern.
|
||||||
|
file_path = io_name.format(
|
||||||
|
now=datetime.datetime.now(),
|
||||||
|
utcnow=datetime.datetime.now(tz=datetime.timezone.utc),
|
||||||
|
pid=os.getpid(),
|
||||||
|
instance=_SNOOPER_INSTANCE_COUNT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Open a file or pipe
|
||||||
|
logger.debug(f'PCAP file: {file_path}')
|
||||||
|
|
||||||
|
# Pipes we have to open with unbuffered binary I/O
|
||||||
|
# so we pass ``buffering`` for pipes but not for files
|
||||||
|
pcap_file: BinaryIO
|
||||||
|
if io_type == 'pipe':
|
||||||
|
pcap_file = open(file_path, 'wb', buffering=0)
|
||||||
|
else:
|
||||||
|
pcap_file = open(file_path, 'wb')
|
||||||
|
|
||||||
|
with pcap_file:
|
||||||
|
_SNOOPER_INSTANCE_COUNT += 1
|
||||||
|
yield PcapSnooper(pcap_file)
|
||||||
|
_SNOOPER_INSTANCE_COUNT -= 1
|
||||||
|
return
|
||||||
|
|
||||||
raise core.InvalidArgumentError(f'I/O type {io_type} not supported')
|
raise core.InvalidArgumentError(f'I/O type {io_type} not supported')
|
||||||
|
|
||||||
raise core.InvalidArgumentError(f'snooper type {snooper_type} not found')
|
raise core.InvalidArgumentError(f'snooper type {snooper_type} not found')
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ async def open_android_netsim_controller_transport(
|
|||||||
|
|
||||||
# We only accept BLUETOOTH
|
# We only accept BLUETOOTH
|
||||||
if request.initial_info.chip.kind != ChipKind.BLUETOOTH:
|
if request.initial_info.chip.kind != ChipKind.BLUETOOTH:
|
||||||
logger.warning('Unsupported chip type')
|
logger.debug('Request for unsupported chip type')
|
||||||
error = PacketResponse(error='Unsupported chip type')
|
error = PacketResponse(error='Unsupported chip type')
|
||||||
await self.context.write(error)
|
await self.context.write(error)
|
||||||
# return
|
# return
|
||||||
|
|||||||
194
bumble/vendor/android/hci.py
vendored
194
bumble/vendor/android/hci.py
vendored
@@ -43,44 +43,53 @@ hci.HCI_Command.register_commands(globals())
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_LE_Get_Vendor_Capabilities_Command(hci.HCI_Command):
|
class HCI_LE_Get_Vendor_Capabilities_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
|
max_advt_instances: int = field(metadata=hci.metadata(1), default=0)
|
||||||
|
offloaded_resolution_of_private_address: int = field(
|
||||||
|
metadata=hci.metadata(1), default=0
|
||||||
|
)
|
||||||
|
total_scan_results_storage: int = field(metadata=hci.metadata(2), default=0)
|
||||||
|
max_irk_list_sz: int = field(metadata=hci.metadata(1), default=0)
|
||||||
|
filtering_support: int = field(metadata=hci.metadata(1), default=0)
|
||||||
|
max_filter: int = field(metadata=hci.metadata(1), default=0)
|
||||||
|
activity_energy_info_support: int = field(metadata=hci.metadata(1), default=0)
|
||||||
|
version_supported: int = field(metadata=hci.metadata(2), default=0)
|
||||||
|
total_num_of_advt_tracked: int = field(metadata=hci.metadata(2), default=0)
|
||||||
|
extended_scan_support: int = field(metadata=hci.metadata(1), default=0)
|
||||||
|
debug_logging_supported: int = field(metadata=hci.metadata(1), default=0)
|
||||||
|
le_address_generation_offloading_support: int = field(
|
||||||
|
metadata=hci.metadata(1), default=0
|
||||||
|
)
|
||||||
|
a2dp_source_offload_capability_mask: int = field(
|
||||||
|
metadata=hci.metadata(4), default=0
|
||||||
|
)
|
||||||
|
bluetooth_quality_report_support: int = field(metadata=hci.metadata(1), default=0)
|
||||||
|
dynamic_audio_buffer_support: int = field(metadata=hci.metadata(4), default=0)
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_LE_Get_Vendor_Capabilities_ReturnParameters)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_LE_Get_Vendor_Capabilities_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_LE_Get_Vendor_Capabilities_ReturnParameters]
|
||||||
|
):
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
'''
|
'''
|
||||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#vendor-specific-capabilities
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#vendor-specific-capabilities
|
||||||
'''
|
'''
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
('status', hci.STATUS_SPEC),
|
|
||||||
('max_advt_instances', 1),
|
|
||||||
('offloaded_resolution_of_private_address', 1),
|
|
||||||
('total_scan_results_storage', 2),
|
|
||||||
('max_irk_list_sz', 1),
|
|
||||||
('filtering_support', 1),
|
|
||||||
('max_filter', 1),
|
|
||||||
('activity_energy_info_support', 1),
|
|
||||||
('version_supported', 2),
|
|
||||||
('total_num_of_advt_tracked', 2),
|
|
||||||
('extended_scan_support', 1),
|
|
||||||
('debug_logging_supported', 1),
|
|
||||||
('le_address_generation_offloading_support', 1),
|
|
||||||
('a2dp_source_offload_capability_mask', 4),
|
|
||||||
('bluetooth_quality_report_support', 1),
|
|
||||||
('dynamic_audio_buffer_support', 4),
|
|
||||||
]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_return_parameters(cls, parameters):
|
def parse_return_parameters(cls, parameters):
|
||||||
# There are many versions of this data structure, so we need to parse until
|
# There are many versions of this data structure, so we need to parse until
|
||||||
# there are no more bytes to parse, and leave un-signal parameters set to
|
# there are no more bytes to parse, and leave un-signaled parameters set to
|
||||||
# None (older versions)
|
# 0
|
||||||
nones = {field: None for field, _ in cls.return_parameters_fields}
|
return_parameters = HCI_LE_Get_Vendor_Capabilities_ReturnParameters(
|
||||||
return_parameters = hci.HCI_Object(cls.return_parameters_fields, **nones)
|
hci.HCI_ErrorCode.SUCCESS
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
offset = 0
|
offset = 0
|
||||||
for field in cls.return_parameters_fields:
|
for field in cls.return_parameters_class.fields:
|
||||||
field_name, field_type = field
|
field_name, field_type = field
|
||||||
field_value, field_size = hci.HCI_Object.parse_field(
|
field_value, field_size = hci.HCI_Object.parse_field(
|
||||||
parameters, offset, field_type
|
parameters, offset, field_type
|
||||||
@@ -94,9 +103,30 @@ class HCI_LE_Get_Vendor_Capabilities_Command(hci.HCI_Command):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@hci.HCI_Command.command
|
# APCF Subcommands
|
||||||
|
class LeApcfOpcode(hci.SpecableEnum):
|
||||||
|
ENABLE = 0x00
|
||||||
|
SET_FILTERING_PARAMETERS = 0x01
|
||||||
|
BROADCASTER_ADDRESS = 0x02
|
||||||
|
SERVICE_UUID = 0x03
|
||||||
|
SERVICE_SOLICITATION_UUID = 0x04
|
||||||
|
LOCAL_NAME = 0x05
|
||||||
|
MANUFACTURER_DATA = 0x06
|
||||||
|
SERVICE_DATA = 0x07
|
||||||
|
TRANSPORT_DISCOVERY_SERVICE = 0x08
|
||||||
|
AD_TYPE_FILTER = 0x09
|
||||||
|
READ_EXTENDED_FEATURES = 0xFF
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_LE_APCF_Command(hci.HCI_Command):
|
class HCI_LE_APCF_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
|
opcode: int = field(metadata=LeApcfOpcode.type_metadata(1))
|
||||||
|
payload: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_LE_APCF_ReturnParameters)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_LE_APCF_Command(hci.HCI_SyncCommand[HCI_LE_APCF_ReturnParameters]):
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
'''
|
'''
|
||||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_apcf_command
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_apcf_command
|
||||||
@@ -105,52 +135,52 @@ class HCI_LE_APCF_Command(hci.HCI_Command):
|
|||||||
implementation. A future enhancement may define subcommand-specific data structures.
|
implementation. A future enhancement may define subcommand-specific data structures.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# APCF Subcommands
|
opcode: int = dataclasses.field(metadata=LeApcfOpcode.type_metadata(1))
|
||||||
class Opcode(hci.SpecableEnum):
|
|
||||||
ENABLE = 0x00
|
|
||||||
SET_FILTERING_PARAMETERS = 0x01
|
|
||||||
BROADCASTER_ADDRESS = 0x02
|
|
||||||
SERVICE_UUID = 0x03
|
|
||||||
SERVICE_SOLICITATION_UUID = 0x04
|
|
||||||
LOCAL_NAME = 0x05
|
|
||||||
MANUFACTURER_DATA = 0x06
|
|
||||||
SERVICE_DATA = 0x07
|
|
||||||
TRANSPORT_DISCOVERY_SERVICE = 0x08
|
|
||||||
AD_TYPE_FILTER = 0x09
|
|
||||||
READ_EXTENDED_FEATURES = 0xFF
|
|
||||||
|
|
||||||
opcode: int = dataclasses.field(metadata=Opcode.type_metadata(1))
|
|
||||||
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
('status', hci.STATUS_SPEC),
|
|
||||||
('opcode', Opcode.type_spec(1)),
|
|
||||||
('payload', '*'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_Get_Controller_Activity_Energy_Info_Command(hci.HCI_Command):
|
class HCI_Get_Controller_Activity_Energy_Info_ReturnParameters(
|
||||||
|
hci.HCI_StatusReturnParameters
|
||||||
|
):
|
||||||
|
total_tx_time_ms: int = field(metadata=hci.metadata(4))
|
||||||
|
total_rx_time_ms: int = field(metadata=hci.metadata(4))
|
||||||
|
total_idle_time_ms: int = field(metadata=hci.metadata(4))
|
||||||
|
total_energy_used: int = field(metadata=hci.metadata(4))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(
|
||||||
|
HCI_Get_Controller_Activity_Energy_Info_ReturnParameters
|
||||||
|
)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_Get_Controller_Activity_Energy_Info_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_Get_Controller_Activity_Energy_Info_ReturnParameters]
|
||||||
|
):
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
'''
|
'''
|
||||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_get_controller_activity_energy_info
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_get_controller_activity_energy_info
|
||||||
'''
|
'''
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
('status', hci.STATUS_SPEC),
|
|
||||||
('total_tx_time_ms', 4),
|
|
||||||
('total_rx_time_ms', 4),
|
|
||||||
('total_idle_time_ms', 4),
|
|
||||||
('total_energy_used', 4),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@hci.HCI_Command.command
|
# A2DP Hardware Offload Subcommands
|
||||||
|
class A2dpHardwareOffloadOpcode(hci.SpecableEnum):
|
||||||
|
START_A2DP_OFFLOAD = 0x01
|
||||||
|
STOP_A2DP_OFFLOAD = 0x02
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_A2DP_Hardware_Offload_Command(hci.HCI_Command):
|
class HCI_A2DP_Hardware_Offload_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
|
opcode: int = dataclasses.field(metadata=A2dpHardwareOffloadOpcode.type_metadata(1))
|
||||||
|
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_A2DP_Hardware_Offload_ReturnParameters)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_A2DP_Hardware_Offload_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_A2DP_Hardware_Offload_ReturnParameters]
|
||||||
|
):
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
'''
|
'''
|
||||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#a2dp-hardware-offload-support
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#a2dp-hardware-offload-support
|
||||||
@@ -159,25 +189,27 @@ class HCI_A2DP_Hardware_Offload_Command(hci.HCI_Command):
|
|||||||
implementation. A future enhancement may define subcommand-specific data structures.
|
implementation. A future enhancement may define subcommand-specific data structures.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# A2DP Hardware Offload Subcommands
|
opcode: int = dataclasses.field(metadata=A2dpHardwareOffloadOpcode.type_metadata(1))
|
||||||
class Opcode(hci.SpecableEnum):
|
|
||||||
START_A2DP_OFFLOAD = 0x01
|
|
||||||
STOP_A2DP_OFFLOAD = 0x02
|
|
||||||
|
|
||||||
opcode: int = dataclasses.field(metadata=Opcode.type_metadata(1))
|
|
||||||
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
('status', hci.STATUS_SPEC),
|
|
||||||
('opcode', Opcode.type_spec(1)),
|
|
||||||
('payload', '*'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@hci.HCI_Command.command
|
# Dynamic Audio Buffer Subcommands
|
||||||
|
class DynamicAudioBufferOpcode(hci.SpecableEnum):
|
||||||
|
GET_AUDIO_BUFFER_TIME_CAPABILITY = 0x01
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_Dynamic_Audio_Buffer_Command(hci.HCI_Command):
|
class HCI_Dynamic_Audio_Buffer_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
|
opcode: int = dataclasses.field(metadata=DynamicAudioBufferOpcode.type_metadata(1))
|
||||||
|
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_Dynamic_Audio_Buffer_ReturnParameters)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_Dynamic_Audio_Buffer_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_Dynamic_Audio_Buffer_ReturnParameters]
|
||||||
|
):
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
'''
|
'''
|
||||||
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#dynamic-audio-buffer-command
|
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#dynamic-audio-buffer-command
|
||||||
@@ -186,19 +218,9 @@ class HCI_Dynamic_Audio_Buffer_Command(hci.HCI_Command):
|
|||||||
implementation. A future enhancement may define subcommand-specific data structures.
|
implementation. A future enhancement may define subcommand-specific data structures.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# Dynamic Audio Buffer Subcommands
|
opcode: int = dataclasses.field(metadata=DynamicAudioBufferOpcode.type_metadata(1))
|
||||||
class Opcode(hci.SpecableEnum):
|
|
||||||
GET_AUDIO_BUFFER_TIME_CAPABILITY = 0x01
|
|
||||||
|
|
||||||
opcode: int = dataclasses.field(metadata=Opcode.type_metadata(1))
|
|
||||||
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
payload: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
('status', hci.STATUS_SPEC),
|
|
||||||
('opcode', Opcode.type_spec(1)),
|
|
||||||
('payload', '*'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HCI_Android_Vendor_Event(hci.HCI_Extended_Event):
|
class HCI_Android_Vendor_Event(hci.HCI_Extended_Event):
|
||||||
|
|||||||
42
bumble/vendor/zephyr/hci.py
vendored
42
bumble/vendor/zephyr/hci.py
vendored
@@ -46,9 +46,19 @@ class TX_Power_Level_Command:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_Write_Tx_Power_Level_Command(hci.HCI_Command, TX_Power_Level_Command):
|
class HCI_Write_Tx_Power_Level_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
|
handle_type: int = hci.field(metadata=hci.metadata(1))
|
||||||
|
connection_handle: int = hci.field(metadata=hci.metadata(2))
|
||||||
|
selected_tx_power_level: int = hci.field(metadata=hci.metadata(-1))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_Write_Tx_Power_Level_ReturnParameters)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_Write_Tx_Power_Level_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_Write_Tx_Power_Level_ReturnParameters],
|
||||||
|
TX_Power_Level_Command,
|
||||||
|
):
|
||||||
'''
|
'''
|
||||||
Write TX power level. See BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL in
|
Write TX power level. See BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL in
|
||||||
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||||
@@ -61,18 +71,21 @@ class HCI_Write_Tx_Power_Level_Command(hci.HCI_Command, TX_Power_Level_Command):
|
|||||||
connection_handle: int = dataclasses.field(metadata=hci.metadata(2))
|
connection_handle: int = dataclasses.field(metadata=hci.metadata(2))
|
||||||
tx_power_level: int = dataclasses.field(metadata=hci.metadata(-1))
|
tx_power_level: int = dataclasses.field(metadata=hci.metadata(-1))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
('status', hci.STATUS_SPEC),
|
|
||||||
('handle_type', 1),
|
|
||||||
('connection_handle', 2),
|
|
||||||
('selected_tx_power_level', -1),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_Read_Tx_Power_Level_Command(hci.HCI_Command, TX_Power_Level_Command):
|
class HCI_Read_Tx_Power_Level_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
|
handle_type: int = hci.field(metadata=hci.metadata(1))
|
||||||
|
connection_handle: int = hci.field(metadata=hci.metadata(2))
|
||||||
|
tx_power_level: int = hci.field(metadata=hci.metadata(-1))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_Read_Tx_Power_Level_ReturnParameters)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_Read_Tx_Power_Level_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_Read_Tx_Power_Level_ReturnParameters],
|
||||||
|
TX_Power_Level_Command,
|
||||||
|
):
|
||||||
'''
|
'''
|
||||||
Read TX power level. See BT_HCI_OP_VS_READ_TX_POWER_LEVEL in
|
Read TX power level. See BT_HCI_OP_VS_READ_TX_POWER_LEVEL in
|
||||||
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
|
||||||
@@ -83,10 +96,3 @@ class HCI_Read_Tx_Power_Level_Command(hci.HCI_Command, TX_Power_Level_Command):
|
|||||||
|
|
||||||
handle_type: int = dataclasses.field(metadata=hci.metadata(1))
|
handle_type: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
connection_handle: int = dataclasses.field(metadata=hci.metadata(2))
|
connection_handle: int = dataclasses.field(metadata=hci.metadata(2))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
('status', hci.STATUS_SPEC),
|
|
||||||
('handle_type', 1),
|
|
||||||
('connection_handle', 2),
|
|
||||||
('tx_power_level', -1),
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ The vendor specific HCI commands to read and write TX power are defined in
|
|||||||
from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
|
from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
|
||||||
|
|
||||||
# set advertising power to -4 dB
|
# set advertising power to -4 dB
|
||||||
response = await host.send_command(
|
response = await host.send_sync_command(
|
||||||
HCI_Write_Tx_Power_Level_Command(
|
HCI_Write_Tx_Power_Level_Command(
|
||||||
handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
|
handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
|
||||||
connection_handle=0,
|
connection_handle=0,
|
||||||
@@ -45,7 +45,7 @@ response = await host.send_command(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.return_parameters.status == HCI_SUCCESS:
|
if response.status == HCI_SUCCESS:
|
||||||
print(f"TX power set to {response.return_parameters.selected_tx_power_level}")
|
print(f"TX power set to {response.selected_tx_power_level}")
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ async def main() -> None:
|
|||||||
# Go!
|
# Go!
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_advertising(auto_restart=True)
|
await device.start_advertising(auto_restart=True)
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ async def main() -> None:
|
|||||||
rr_intervals=random.choice(
|
rr_intervals=random.choice(
|
||||||
(
|
(
|
||||||
(
|
(
|
||||||
random.randint(900, 1100) / 1000,
|
random.randint(900, 1100) // 1000,
|
||||||
random.randint(900, 1100) / 1000,
|
random.randint(900, 1100) // 1000,
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ async def main() -> None:
|
|||||||
await device.set_discoverable(True)
|
await device.set_discoverable(True)
|
||||||
await device.set_connectable(True)
|
await device.set_connectable(True)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ async def main() -> None:
|
|||||||
await device.set_discoverable(True)
|
await device.set_discoverable(True)
|
||||||
await device.set_connectable(True)
|
await device.set_connectable(True)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async def main() -> None:
|
|||||||
|
|
||||||
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_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import sys
|
|||||||
import websockets.asyncio.server
|
import websockets.asyncio.server
|
||||||
|
|
||||||
import bumble.logging
|
import bumble.logging
|
||||||
from bumble import a2dp, avc, avdtp, avrcp, utils
|
from bumble import a2dp, avc, avdtp, avrcp, sdp, utils
|
||||||
from bumble.core import PhysicalTransport
|
from bumble.core import PhysicalTransport
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def sdp_records():
|
def sdp_records() -> dict[int, list[sdp.ServiceAttribute]]:
|
||||||
a2dp_sink_service_record_handle = 0x00010001
|
a2dp_sink_service_record_handle = 0x00010001
|
||||||
avrcp_controller_service_record_handle = 0x00010002
|
avrcp_controller_service_record_handle = 0x00010002
|
||||||
avrcp_target_service_record_handle = 0x00010003
|
avrcp_target_service_record_handle = 0x00010003
|
||||||
@@ -43,17 +43,17 @@ def sdp_records():
|
|||||||
a2dp_sink_service_record_handle: a2dp.make_audio_sink_service_sdp_records(
|
a2dp_sink_service_record_handle: a2dp.make_audio_sink_service_sdp_records(
|
||||||
a2dp_sink_service_record_handle
|
a2dp_sink_service_record_handle
|
||||||
),
|
),
|
||||||
avrcp_controller_service_record_handle: avrcp.make_controller_service_sdp_records(
|
avrcp_controller_service_record_handle: avrcp.ControllerServiceSdpRecord(
|
||||||
avrcp_controller_service_record_handle
|
avrcp_controller_service_record_handle
|
||||||
),
|
).to_service_attributes(),
|
||||||
avrcp_target_service_record_handle: avrcp.make_target_service_sdp_records(
|
avrcp_target_service_record_handle: avrcp.TargetServiceSdpRecord(
|
||||||
avrcp_controller_service_record_handle
|
avrcp_target_service_record_handle
|
||||||
),
|
).to_service_attributes(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def codec_capabilities():
|
def codec_capabilities() -> avdtp.MediaCodecCapabilities:
|
||||||
return avdtp.MediaCodecCapabilities(
|
return avdtp.MediaCodecCapabilities(
|
||||||
media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
|
media_type=avdtp.AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
|
media_codec_type=a2dp.A2DP_SBC_CODEC_TYPE,
|
||||||
@@ -81,20 +81,22 @@ def codec_capabilities():
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_avdtp_connection(server):
|
def on_avdtp_connection(server: avdtp.Protocol) -> None:
|
||||||
# Add a sink endpoint to the server
|
# Add a sink endpoint to the server
|
||||||
sink = server.add_sink(codec_capabilities())
|
sink = server.add_sink(codec_capabilities())
|
||||||
sink.on('rtp_packet', on_rtp_packet)
|
sink.on(sink.EVENT_RTP_PACKET, on_rtp_packet)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_rtp_packet(packet):
|
def on_rtp_packet(packet: avdtp.MediaPacket) -> None:
|
||||||
print(f'RTP: {packet}')
|
print(f'RTP: {packet}')
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketServer):
|
def on_avrcp_start(
|
||||||
async def get_supported_events():
|
avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketServer
|
||||||
|
) -> None:
|
||||||
|
async def get_supported_events() -> None:
|
||||||
events = await avrcp_protocol.get_supported_events()
|
events = await avrcp_protocol.get_supported_events()
|
||||||
print("SUPPORTED EVENTS:", events)
|
print("SUPPORTED EVENTS:", events)
|
||||||
websocket_server.send_message(
|
websocket_server.send_message(
|
||||||
@@ -130,14 +132,14 @@ def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketSe
|
|||||||
|
|
||||||
utils.AsyncRunner.spawn(get_supported_events())
|
utils.AsyncRunner.spawn(get_supported_events())
|
||||||
|
|
||||||
async def monitor_track_changed():
|
async def monitor_track_changed() -> None:
|
||||||
async for identifier in avrcp_protocol.monitor_track_changed():
|
async for uid in avrcp_protocol.monitor_track_changed():
|
||||||
print("TRACK CHANGED:", identifier.hex())
|
print("TRACK CHANGED:", hex(uid))
|
||||||
websocket_server.send_message(
|
websocket_server.send_message(
|
||||||
{"type": "track-changed", "params": {"identifier": identifier.hex()}}
|
{"type": "track-changed", "params": {"identifier": hex(uid)}}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def monitor_playback_status():
|
async def monitor_playback_status() -> None:
|
||||||
async for playback_status in avrcp_protocol.monitor_playback_status():
|
async for playback_status in avrcp_protocol.monitor_playback_status():
|
||||||
print("PLAYBACK STATUS CHANGED:", playback_status.name)
|
print("PLAYBACK STATUS CHANGED:", playback_status.name)
|
||||||
websocket_server.send_message(
|
websocket_server.send_message(
|
||||||
@@ -147,7 +149,7 @@ def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketSe
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def monitor_playback_position():
|
async def monitor_playback_position() -> None:
|
||||||
async for playback_position in avrcp_protocol.monitor_playback_position(
|
async for playback_position in avrcp_protocol.monitor_playback_position(
|
||||||
playback_interval=1
|
playback_interval=1
|
||||||
):
|
):
|
||||||
@@ -159,7 +161,7 @@ def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketSe
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def monitor_player_application_settings():
|
async def monitor_player_application_settings() -> None:
|
||||||
async for settings in avrcp_protocol.monitor_player_application_settings():
|
async for settings in avrcp_protocol.monitor_player_application_settings():
|
||||||
print("PLAYER APPLICATION SETTINGS:", settings)
|
print("PLAYER APPLICATION SETTINGS:", settings)
|
||||||
settings_as_dict = [
|
settings_as_dict = [
|
||||||
@@ -173,14 +175,14 @@ def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketSe
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def monitor_available_players():
|
async def monitor_available_players() -> None:
|
||||||
async for _ in avrcp_protocol.monitor_available_players():
|
async for _ in avrcp_protocol.monitor_available_players():
|
||||||
print("AVAILABLE PLAYERS CHANGED")
|
print("AVAILABLE PLAYERS CHANGED")
|
||||||
websocket_server.send_message(
|
websocket_server.send_message(
|
||||||
{"type": "available-players-changed", "params": {}}
|
{"type": "available-players-changed", "params": {}}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def monitor_addressed_player():
|
async def monitor_addressed_player() -> None:
|
||||||
async for player in avrcp_protocol.monitor_addressed_player():
|
async for player in avrcp_protocol.monitor_addressed_player():
|
||||||
print("ADDRESSED PLAYER CHANGED")
|
print("ADDRESSED PLAYER CHANGED")
|
||||||
websocket_server.send_message(
|
websocket_server.send_message(
|
||||||
@@ -195,7 +197,7 @@ def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketSe
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def monitor_uids():
|
async def monitor_uids() -> None:
|
||||||
async for uid_counter in avrcp_protocol.monitor_uids():
|
async for uid_counter in avrcp_protocol.monitor_uids():
|
||||||
print("UIDS CHANGED")
|
print("UIDS CHANGED")
|
||||||
websocket_server.send_message(
|
websocket_server.send_message(
|
||||||
@@ -207,7 +209,7 @@ def on_avrcp_start(avrcp_protocol: avrcp.Protocol, websocket_server: WebSocketSe
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def monitor_volume():
|
async def monitor_volume() -> None:
|
||||||
async for volume in avrcp_protocol.monitor_volume():
|
async for volume in avrcp_protocol.monitor_volume():
|
||||||
print("VOLUME CHANGED:", volume)
|
print("VOLUME CHANGED:", volume)
|
||||||
websocket_server.send_message(
|
websocket_server.send_message(
|
||||||
@@ -360,7 +362,7 @@ async def main() -> None:
|
|||||||
|
|
||||||
# Create a listener to wait for AVDTP connections
|
# Create a listener to wait for AVDTP connections
|
||||||
listener = avdtp.Listener(avdtp.Listener.create_registrar(device))
|
listener = avdtp.Listener(avdtp.Listener.create_registrar(device))
|
||||||
listener.on('connection', on_avdtp_connection)
|
listener.on(listener.EVENT_CONNECTION, on_avdtp_connection)
|
||||||
|
|
||||||
avrcp_delegate = Delegate()
|
avrcp_delegate = Delegate()
|
||||||
avrcp_protocol = avrcp.Protocol(avrcp_delegate)
|
avrcp_protocol = avrcp.Protocol(avrcp_delegate)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ async def main() -> None:
|
|||||||
await device.set_discoverable(True)
|
await device.set_discoverable(True)
|
||||||
await device.set_connectable(True)
|
await device.set_connectable(True)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ async def main() -> None:
|
|||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_discovery()
|
await device.start_discovery()
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ async def main() -> None:
|
|||||||
print(f'!!! Encryption failed: {error}')
|
print(f'!!! Encryption failed: {error}')
|
||||||
return
|
return
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
201
examples/run_connection_updates.py
Normal file
201
examples/run_connection_updates.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Copyright 2026 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
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
|
from bumble.core import BaseError
|
||||||
|
from bumble.device import Connection, Device
|
||||||
|
from bumble.hci import Address, LeFeatureMask
|
||||||
|
from bumble.transport import open_transport
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DEFAULT_CENTRAL_ADDRESS = Address("F0:F0:F0:F0:F0:F0")
|
||||||
|
DEFAULT_PERIPHERAL_ADDRESS = Address("F1:F1:F1:F1:F1:F1")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
async def run_as_central(
|
||||||
|
device: Device,
|
||||||
|
scenario: Callable | None,
|
||||||
|
) -> None:
|
||||||
|
# Connect to the peripheral
|
||||||
|
print(f'=== Connecting to {DEFAULT_PERIPHERAL_ADDRESS}...')
|
||||||
|
connection = await device.connect(DEFAULT_PERIPHERAL_ADDRESS)
|
||||||
|
print("=== Connected")
|
||||||
|
|
||||||
|
if scenario is not None:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await scenario(connection)
|
||||||
|
|
||||||
|
await asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_as_peripheral(device: Device, scenario: Callable | None) -> None:
|
||||||
|
# Wait for a connection from the central
|
||||||
|
print(f'=== Advertising as {DEFAULT_PERIPHERAL_ADDRESS}...')
|
||||||
|
await device.start_advertising(auto_restart=True)
|
||||||
|
|
||||||
|
async def on_connection(connection: Connection) -> None:
|
||||||
|
assert scenario is not None
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await scenario(connection)
|
||||||
|
|
||||||
|
if scenario is not None:
|
||||||
|
device.on(Device.EVENT_CONNECTION, on_connection)
|
||||||
|
|
||||||
|
await asyncio.get_running_loop().create_future()
|
||||||
|
|
||||||
|
|
||||||
|
async def change_parameters(
|
||||||
|
connection: Connection,
|
||||||
|
parameter_request_procedure_supported: bool,
|
||||||
|
subrating_supported: bool,
|
||||||
|
shorter_connection_intervals_supported: bool,
|
||||||
|
) -> None:
|
||||||
|
if parameter_request_procedure_supported:
|
||||||
|
try:
|
||||||
|
print(">>> update_parameters(7.5, 200, 0, 4000)")
|
||||||
|
await connection.update_parameters(7.5, 200, 0, 4000)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
except BaseError as error:
|
||||||
|
print(f"Error: {error}")
|
||||||
|
|
||||||
|
if subrating_supported:
|
||||||
|
try:
|
||||||
|
print(">>> update_subrate(1, 2, 2, 1, 4000)")
|
||||||
|
await connection.update_subrate(1, 2, 2, 1, 4000)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
except BaseError as error:
|
||||||
|
print(f"Error: {error}")
|
||||||
|
|
||||||
|
if shorter_connection_intervals_supported:
|
||||||
|
try:
|
||||||
|
print(
|
||||||
|
">>> update_parameters_with_subrate(7.5, 200, 1, 1, 0, 0, 4000, 5, 1000)"
|
||||||
|
)
|
||||||
|
await connection.update_parameters_with_subrate(
|
||||||
|
7.5, 200, 1, 1, 0, 0, 4000, 5, 1000
|
||||||
|
)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
except BaseError as error:
|
||||||
|
print(f"Error: {error}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(
|
||||||
|
">>> update_parameters_with_subrate(0.750, 5, 1, 1, 0, 0, 4000, 0.125, 1000)"
|
||||||
|
)
|
||||||
|
await connection.update_parameters_with_subrate(
|
||||||
|
0.750, 5, 1, 1, 0, 0, 4000, 0.125, 1000
|
||||||
|
)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
except BaseError as error:
|
||||||
|
print(f"Error: {error}")
|
||||||
|
|
||||||
|
print(">>> done")
|
||||||
|
|
||||||
|
|
||||||
|
def on_connection(connection: Connection) -> None:
|
||||||
|
print(f"+++ Connection established: {connection}")
|
||||||
|
|
||||||
|
def on_le_remote_features_change() -> None:
|
||||||
|
print(f'... LE Remote Features change: {connection.peer_le_features.name}')
|
||||||
|
|
||||||
|
connection.on(
|
||||||
|
connection.EVENT_LE_REMOTE_FEATURES_CHANGE, on_le_remote_features_change
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_connection_parameters_change() -> None:
|
||||||
|
print(f'... LE Connection Parameters change: {connection.parameters}')
|
||||||
|
|
||||||
|
connection.on(
|
||||||
|
connection.EVENT_CONNECTION_PARAMETERS_UPDATE, on_connection_parameters_change
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(
|
||||||
|
'Usage: run_connection_updates.py <transport-spec> '
|
||||||
|
'central|peripheral initiator|responder'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
print('<<< connecting to HCI...')
|
||||||
|
async with await open_transport(sys.argv[1]) as hci_transport:
|
||||||
|
print('<<< connected')
|
||||||
|
|
||||||
|
role = sys.argv[2]
|
||||||
|
direction = sys.argv[3]
|
||||||
|
device = Device.with_hci(
|
||||||
|
role,
|
||||||
|
(
|
||||||
|
DEFAULT_CENTRAL_ADDRESS
|
||||||
|
if role == "central"
|
||||||
|
else DEFAULT_PERIPHERAL_ADDRESS
|
||||||
|
),
|
||||||
|
hci_transport.source,
|
||||||
|
hci_transport.sink,
|
||||||
|
)
|
||||||
|
device.le_subrate_enabled = True
|
||||||
|
device.le_shorter_connection_intervals_enabled = True
|
||||||
|
await device.power_on()
|
||||||
|
|
||||||
|
parameter_request_procedure_supported = device.supports_le_features(
|
||||||
|
LeFeatureMask.CONNECTION_PARAMETERS_REQUEST_PROCEDURE
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Parameters Request Procedure supported: "
|
||||||
|
f"{parameter_request_procedure_supported}"
|
||||||
|
)
|
||||||
|
|
||||||
|
subrating_supported = device.supports_le_features(
|
||||||
|
LeFeatureMask.CONNECTION_SUBRATING
|
||||||
|
)
|
||||||
|
print(f"Subrating supported: {subrating_supported}")
|
||||||
|
|
||||||
|
shorter_connection_intervals_supported = device.supports_le_features(
|
||||||
|
LeFeatureMask.SHORTER_CONNECTION_INTERVALS
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Shorter Connection Intervals supported: "
|
||||||
|
f"{shorter_connection_intervals_supported}"
|
||||||
|
)
|
||||||
|
|
||||||
|
device.on(Device.EVENT_CONNECTION, on_connection)
|
||||||
|
|
||||||
|
async def run(connection: Connection) -> None:
|
||||||
|
await change_parameters(
|
||||||
|
connection,
|
||||||
|
parameter_request_procedure_supported,
|
||||||
|
subrating_supported,
|
||||||
|
shorter_connection_intervals_supported,
|
||||||
|
)
|
||||||
|
|
||||||
|
scenario = run if direction == "initiator" else None
|
||||||
|
|
||||||
|
if role == "central":
|
||||||
|
await run_as_central(device, scenario)
|
||||||
|
else:
|
||||||
|
await run_as_peripheral(device, scenario)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
bumble.logging.setup_basic_logging('DEBUG')
|
||||||
|
asyncio.run(main())
|
||||||
@@ -101,7 +101,7 @@ async def main() -> None:
|
|||||||
await device.start_advertising()
|
await device.start_advertising()
|
||||||
await device.start_scanning()
|
await device.start_scanning()
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async def main() -> None:
|
|||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_scanning()
|
await device.start_scanning()
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ async def main() -> None:
|
|||||||
GATT_DEVICE_INFORMATION_SERVICE, [manufacturer_name_characteristic]
|
GATT_DEVICE_INFORMATION_SERVICE, [manufacturer_name_characteristic]
|
||||||
)
|
)
|
||||||
server_device.add_service(device_info_service)
|
server_device.add_service(device_info_service)
|
||||||
|
await server_device.start_advertising()
|
||||||
|
|
||||||
# Connect the client to the server
|
# Connect the client to the server
|
||||||
connection = await client_device.connect(server_device.random_address)
|
connection = await client_device.connect(server_device.random_address)
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ async def main() -> None:
|
|||||||
else:
|
else:
|
||||||
await device.start_advertising(auto_restart=True)
|
await device.start_advertising(auto_restart=True)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ async def main() -> None:
|
|||||||
else:
|
else:
|
||||||
await device.start_advertising(auto_restart=True)
|
await device.start_advertising(auto_restart=True)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ async def main() -> None:
|
|||||||
# Setup a server
|
# Setup a server
|
||||||
await server(device)
|
await server(device)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -100,13 +100,9 @@ def on_sco_packet(packet: hci.HCI_SynchronousDataPacket):
|
|||||||
if source_file and (pcm_data := source_file.read(packet.data_total_length)):
|
if source_file and (pcm_data := source_file.read(packet.data_total_length)):
|
||||||
assert ag_protocol
|
assert ag_protocol
|
||||||
host = ag_protocol.dlc.multiplexer.l2cap_channel.connection.device.host
|
host = ag_protocol.dlc.multiplexer.l2cap_channel.connection.device.host
|
||||||
host.send_hci_packet(
|
host.send_sco_sdu(
|
||||||
hci.HCI_SynchronousDataPacket(
|
connection_handle=packet.connection_handle,
|
||||||
connection_handle=packet.connection_handle,
|
sdu=pcm_data,
|
||||||
packet_status=0,
|
|
||||||
data_total_length=len(pcm_data),
|
|
||||||
data=pcm_data,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ async def main() -> None:
|
|||||||
|
|
||||||
await websockets.asyncio.server.serve(serve, 'localhost', 8989)
|
await websockets.asyncio.server.serve(serve, 'localhost', 8989)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -735,7 +735,7 @@ async def main() -> None:
|
|||||||
print("Executing in Web mode")
|
print("Executing in Web mode")
|
||||||
await keyboard_device(hid_device)
|
await keyboard_device(hid_device)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ async def main() -> None:
|
|||||||
# Interrupt Channel
|
# Interrupt Channel
|
||||||
await hid_host.connect_interrupt_channel()
|
await hid_host.connect_interrupt_channel()
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ async def main() -> None:
|
|||||||
tcp_port = int(sys.argv[5])
|
tcp_port = int(sys.argv[5])
|
||||||
asyncio.create_task(tcp_server(tcp_port, session))
|
asyncio.create_task(tcp_server(tcp_port, session))
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async def main() -> None:
|
|||||||
await device.set_discoverable(True)
|
await device.set_discoverable(True)
|
||||||
await device.set_connectable(True)
|
await device.set_connectable(True)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async def main() -> None:
|
|||||||
await device.power_on()
|
await device.power_on()
|
||||||
await device.start_scanning(filter_duplicates=filter_duplicates)
|
await device.start_scanning(filter_duplicates=filter_duplicates)
|
||||||
|
|
||||||
await hci_transport.source.wait_for_termination()
|
await hci_transport.source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -13,17 +13,21 @@ authors = [{ name = "Google", email = "bumble-dev@google.com" }]
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp ~= 3.8; platform_system!='Emscripten'",
|
"aiohttp ~= 3.8; platform_system!='Emscripten'",
|
||||||
"appdirs >= 1.4; platform_system!='Emscripten'",
|
|
||||||
"click >= 8.1.3; platform_system!='Emscripten'",
|
"click >= 8.1.3; platform_system!='Emscripten'",
|
||||||
"cryptography >= 44.0.3; platform_system!='Emscripten'",
|
"cryptography >= 44.0.3; platform_system!='Emscripten' and platform_system!='Android'",
|
||||||
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the
|
# Pyodide bundles a version of cryptography that is built for wasm, which may not match the
|
||||||
# versions available on PyPI. Relax the version requirement since it's better than being
|
# versions available on PyPI. Relax the version requirement since it's better than being
|
||||||
# completely unable to import the package in case of version mismatch.
|
# completely unable to import the package in case of version mismatch.
|
||||||
"cryptography >= 44.0.3; platform_system=='Emscripten'",
|
"cryptography >= 39.0.0; platform_system=='Emscripten'",
|
||||||
|
# Android wheels for cryptography are not yet available on PyPI, so chaquopy uses
|
||||||
|
# the builds from https://chaquo.com/pypi-13.1/cryptography/. But these are not regually
|
||||||
|
# updated. Relax the version requirement since it's better than being completely unable
|
||||||
|
# to import the package in case of version mismatch.
|
||||||
|
"cryptography >= 42.0.8; platform_system=='Android'",
|
||||||
"grpcio >= 1.62.1; platform_system!='Emscripten'",
|
"grpcio >= 1.62.1; platform_system!='Emscripten'",
|
||||||
"humanize >= 4.6.0; platform_system!='Emscripten'",
|
"humanize >= 4.6.0; platform_system!='Emscripten'",
|
||||||
"libusb1 >= 2.0.1; platform_system!='Emscripten'",
|
"libusb1 >= 2.0.1; platform_system!='Emscripten'",
|
||||||
"libusb-package == 1.0.26.1; platform_system!='Emscripten'",
|
"libusb-package == 1.0.26.1; platform_system!='Emscripten' and platform_system!='Android'",
|
||||||
"platformdirs >= 3.10.0; platform_system!='Emscripten'",
|
"platformdirs >= 3.10.0; platform_system!='Emscripten'",
|
||||||
"prompt_toolkit >= 3.0.16; platform_system!='Emscripten'",
|
"prompt_toolkit >= 3.0.16; platform_system!='Emscripten'",
|
||||||
"prettytable >= 3.6.0; platform_system!='Emscripten'",
|
"prettytable >= 3.6.0; platform_system!='Emscripten'",
|
||||||
@@ -32,7 +36,7 @@ dependencies = [
|
|||||||
"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'",
|
||||||
"tomli ~= 2.2.1; platform_system!='Emscripten'",
|
"tomli ~= 2.2.1; platform_system!='Emscripten' and python_version<'3.11'",
|
||||||
"websockets >= 15.0.1; platform_system!='Emscripten'",
|
"websockets >= 15.0.1; platform_system!='Emscripten'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
4
rust/Cargo.lock
generated
4
rust/Cargo.lock
generated
@@ -221,9 +221,9 @@ checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.5.0"
|
version = "1.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ hex = "0.4.3"
|
|||||||
itertools = "0.11.0"
|
itertools = "0.11.0"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
thiserror = "1.0.41"
|
thiserror = "1.0.41"
|
||||||
bytes = "1.5.0"
|
bytes = "1.11.1"
|
||||||
pdl-derive = "0.2.0"
|
pdl-derive = "0.2.0"
|
||||||
pdl-runtime = "0.2.0"
|
pdl-runtime = "0.2.0"
|
||||||
futures = "0.3.28"
|
futures = "0.3.28"
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ use pyo3::PyResult;
|
|||||||
|
|
||||||
#[pyo3_asyncio::tokio::test]
|
#[pyo3_asyncio::tokio::test]
|
||||||
async fn realtek_driver_info_all_drivers() -> PyResult<()> {
|
async fn realtek_driver_info_all_drivers() -> PyResult<()> {
|
||||||
assert_eq!(12, DriverInfo::all_drivers()?.len());
|
assert_eq!(13, DriverInfo::all_drivers()?.len());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,10 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import struct
|
import struct
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -117,8 +119,6 @@ class TwoDevices(test_utils.TwoDevices):
|
|||||||
scope=avrcp.Scope.NOW_PLAYING,
|
scope=avrcp.Scope.NOW_PLAYING,
|
||||||
uid=0,
|
uid=0,
|
||||||
uid_counter=1,
|
uid_counter=1,
|
||||||
start_item=0,
|
|
||||||
end_item=0,
|
|
||||||
attributes=[avrcp.MediaAttributeId.DEFAULT_COVER_ART],
|
attributes=[avrcp.MediaAttributeId.DEFAULT_COVER_ART],
|
||||||
),
|
),
|
||||||
avrcp.GetTotalNumberOfItemsCommand(scope=avrcp.Scope.NOW_PLAYING),
|
avrcp.GetTotalNumberOfItemsCommand(scope=avrcp.Scope.NOW_PLAYING),
|
||||||
@@ -135,7 +135,7 @@ def test_command(command: avrcp.Command):
|
|||||||
"event,",
|
"event,",
|
||||||
[
|
[
|
||||||
avrcp.UidsChangedEvent(uid_counter=7),
|
avrcp.UidsChangedEvent(uid_counter=7),
|
||||||
avrcp.TrackChangedEvent(identifier=b'12356'),
|
avrcp.TrackChangedEvent(uid=12356),
|
||||||
avrcp.VolumeChangedEvent(volume=9),
|
avrcp.VolumeChangedEvent(volume=9),
|
||||||
avrcp.PlaybackStatusChangedEvent(play_status=avrcp.PlayStatus.PLAYING),
|
avrcp.PlaybackStatusChangedEvent(play_status=avrcp.PlayStatus.PLAYING),
|
||||||
avrcp.AddressedPlayerChangedEvent(
|
avrcp.AddressedPlayerChangedEvent(
|
||||||
@@ -233,7 +233,21 @@ def test_event(event: avrcp.Event):
|
|||||||
feature_bitmask=avrcp.MediaPlayerItem.Features.ADD_TO_NOW_PLAYING,
|
feature_bitmask=avrcp.MediaPlayerItem.Features.ADD_TO_NOW_PLAYING,
|
||||||
character_set_id=avrcp.CharacterSetId.UTF_8,
|
character_set_id=avrcp.CharacterSetId.UTF_8,
|
||||||
displayable_name="Woo",
|
displayable_name="Woo",
|
||||||
)
|
),
|
||||||
|
avrcp.FolderItem(
|
||||||
|
folder_uid=1,
|
||||||
|
folder_type=avrcp.FolderItem.FolderType.ALBUMS,
|
||||||
|
is_playable=avrcp.FolderItem.Playable.PLAYABLE,
|
||||||
|
character_set_id=avrcp.CharacterSetId.UTF_8,
|
||||||
|
displayable_name="Album",
|
||||||
|
),
|
||||||
|
avrcp.MediaElementItem(
|
||||||
|
media_element_uid=1,
|
||||||
|
media_type=avrcp.MediaElementItem.MediaType.AUDIO,
|
||||||
|
character_set_id=avrcp.CharacterSetId.UTF_8,
|
||||||
|
displayable_name="Song",
|
||||||
|
attribute_value_entry_list=[],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
avrcp.ChangePathResponse(
|
avrcp.ChangePathResponse(
|
||||||
@@ -408,6 +422,47 @@ def test_passthrough_commands():
|
|||||||
assert bytes(parsed) == play_pressed_bytes
|
assert bytes(parsed) == play_pressed_bytes
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_find_sdp_records():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
# Add SDP records to device 1
|
||||||
|
controller_record = avrcp.ControllerServiceSdpRecord(
|
||||||
|
service_record_handle=0x10001,
|
||||||
|
avctp_version=(1, 4),
|
||||||
|
avrcp_version=(1, 6),
|
||||||
|
supported_features=(
|
||||||
|
avrcp.ControllerFeatures.CATEGORY_1
|
||||||
|
| avrcp.ControllerFeatures.SUPPORTS_BROWSING
|
||||||
|
),
|
||||||
|
)
|
||||||
|
target_record = avrcp.TargetServiceSdpRecord(
|
||||||
|
service_record_handle=0x10002,
|
||||||
|
avctp_version=(1, 4),
|
||||||
|
avrcp_version=(1, 6),
|
||||||
|
supported_features=(
|
||||||
|
avrcp.TargetFeatures.CATEGORY_1 | avrcp.TargetFeatures.SUPPORTS_BROWSING
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
two_devices.devices[1].sdp_service_records = {
|
||||||
|
0x10001: controller_record.to_service_attributes(),
|
||||||
|
0x10002: target_record.to_service_attributes(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find records from device 0
|
||||||
|
controller_records = await avrcp.ControllerServiceSdpRecord.find(
|
||||||
|
two_devices.connections[0]
|
||||||
|
)
|
||||||
|
assert len(controller_records) == 1
|
||||||
|
assert controller_records[0] == controller_record
|
||||||
|
|
||||||
|
target_records = await avrcp.TargetServiceSdpRecord.find(two_devices.connections[0])
|
||||||
|
assert len(target_records) == 1
|
||||||
|
assert target_records[0] == target_record
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_supported_events():
|
async def test_get_supported_events():
|
||||||
@@ -422,6 +477,340 @@ async def test_get_supported_events():
|
|||||||
assert supported_events == [avrcp.EventId.VOLUME_CHANGED]
|
assert supported_events == [avrcp.EventId.VOLUME_CHANGED]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_passthrough_key_event():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
q = asyncio.Queue[tuple[avc.PassThroughFrame.OperationId, bool, bytes]]()
|
||||||
|
|
||||||
|
class Delegate(avrcp.Delegate):
|
||||||
|
async def on_key_event(
|
||||||
|
self, key: avc.PassThroughFrame.OperationId, pressed: bool, data: bytes
|
||||||
|
) -> None:
|
||||||
|
q.put_nowait((key, pressed, data))
|
||||||
|
|
||||||
|
two_devices.protocols[1].delegate = Delegate()
|
||||||
|
|
||||||
|
for key, pressed in [
|
||||||
|
(avc.PassThroughFrame.OperationId.PLAY, True),
|
||||||
|
(avc.PassThroughFrame.OperationId.PLAY, False),
|
||||||
|
(avc.PassThroughFrame.OperationId.PAUSE, True),
|
||||||
|
(avc.PassThroughFrame.OperationId.PAUSE, False),
|
||||||
|
]:
|
||||||
|
await two_devices.protocols[0].send_key_event(key, pressed)
|
||||||
|
assert (await q.get()) == (key, pressed, b'')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_passthrough_key_event_rejected():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
class Delegate(avrcp.Delegate):
|
||||||
|
async def on_key_event(
|
||||||
|
self, key: avc.PassThroughFrame.OperationId, pressed: bool, data: bytes
|
||||||
|
) -> None:
|
||||||
|
raise avrcp.Delegate.AvcError(avc.ResponseFrame.ResponseCode.REJECTED)
|
||||||
|
|
||||||
|
two_devices.protocols[1].delegate = Delegate()
|
||||||
|
|
||||||
|
response = await two_devices.protocols[0].send_key_event(
|
||||||
|
avc.PassThroughFrame.OperationId.PLAY, True
|
||||||
|
)
|
||||||
|
assert response.response == avc.ResponseFrame.ResponseCode.REJECTED
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_passthrough_key_event_exception():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
class Delegate(avrcp.Delegate):
|
||||||
|
async def on_key_event(
|
||||||
|
self, key: avc.PassThroughFrame.OperationId, pressed: bool, data: bytes
|
||||||
|
) -> None:
|
||||||
|
raise Exception()
|
||||||
|
|
||||||
|
two_devices.protocols[1].delegate = Delegate()
|
||||||
|
|
||||||
|
response = await two_devices.protocols[0].send_key_event(
|
||||||
|
avc.PassThroughFrame.OperationId.PLAY, True
|
||||||
|
)
|
||||||
|
assert response.response == avc.ResponseFrame.ResponseCode.REJECTED
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_volume():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
for volume in range(avrcp.SetAbsoluteVolumeCommand.MAXIMUM_VOLUME + 1):
|
||||||
|
response = await two_devices.protocols[1].send_avrcp_command(
|
||||||
|
avc.CommandFrame.CommandType.CONTROL, avrcp.SetAbsoluteVolumeCommand(volume)
|
||||||
|
)
|
||||||
|
assert isinstance(response.response, avrcp.SetAbsoluteVolumeResponse)
|
||||||
|
assert response.response.volume == volume
|
||||||
|
assert two_devices.protocols[0].delegate.volume == volume
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_playback_status():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
for status in avrcp.PlayStatus:
|
||||||
|
two_devices.protocols[0].delegate.playback_status = status
|
||||||
|
response = await two_devices.protocols[1].get_play_status()
|
||||||
|
assert response.play_status == status
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_supported_company_ids():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
for status in avrcp.PlayStatus:
|
||||||
|
two_devices.protocols[0].delegate = avrcp.Delegate(
|
||||||
|
supported_company_ids=[avrcp.AVRCP_BLUETOOTH_SIG_COMPANY_ID]
|
||||||
|
)
|
||||||
|
supported_company_ids = await two_devices.protocols[
|
||||||
|
1
|
||||||
|
].get_supported_company_ids()
|
||||||
|
assert supported_company_ids == [avrcp.AVRCP_BLUETOOTH_SIG_COMPANY_ID]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_player_application_settings():
|
||||||
|
two_devices: TwoDevices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
expected_settings = {
|
||||||
|
avrcp.ApplicationSetting.AttributeId.REPEAT_MODE: [
|
||||||
|
avrcp.ApplicationSetting.RepeatModeStatus.ALL_TRACK_REPEAT,
|
||||||
|
avrcp.ApplicationSetting.RepeatModeStatus.GROUP_REPEAT,
|
||||||
|
avrcp.ApplicationSetting.RepeatModeStatus.SINGLE_TRACK_REPEAT,
|
||||||
|
avrcp.ApplicationSetting.RepeatModeStatus.OFF,
|
||||||
|
],
|
||||||
|
avrcp.ApplicationSetting.AttributeId.SHUFFLE_ON_OFF: [
|
||||||
|
avrcp.ApplicationSetting.ShuffleOnOffStatus.OFF,
|
||||||
|
avrcp.ApplicationSetting.ShuffleOnOffStatus.ALL_TRACKS_SHUFFLE,
|
||||||
|
avrcp.ApplicationSetting.ShuffleOnOffStatus.GROUP_SHUFFLE,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
two_devices.protocols[1].delegate = avrcp.Delegate(
|
||||||
|
supported_player_app_settings=expected_settings
|
||||||
|
)
|
||||||
|
actual_settings = await two_devices.protocols[
|
||||||
|
0
|
||||||
|
].list_supported_player_app_settings()
|
||||||
|
assert actual_settings == expected_settings
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_set_player_app_settings():
|
||||||
|
two_devices: TwoDevices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
delegate = two_devices.protocols[1].delegate
|
||||||
|
await two_devices.protocols[0].send_avrcp_command(
|
||||||
|
avc.CommandFrame.CommandType.CONTROL,
|
||||||
|
avrcp.SetPlayerApplicationSettingValueCommand(
|
||||||
|
attribute=[
|
||||||
|
avrcp.ApplicationSetting.AttributeId.REPEAT_MODE,
|
||||||
|
avrcp.ApplicationSetting.AttributeId.SHUFFLE_ON_OFF,
|
||||||
|
],
|
||||||
|
value=[
|
||||||
|
avrcp.ApplicationSetting.RepeatModeStatus.ALL_TRACK_REPEAT,
|
||||||
|
avrcp.ApplicationSetting.ShuffleOnOffStatus.GROUP_SHUFFLE,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expected_settings = {
|
||||||
|
avrcp.ApplicationSetting.AttributeId.REPEAT_MODE: avrcp.ApplicationSetting.RepeatModeStatus.ALL_TRACK_REPEAT,
|
||||||
|
avrcp.ApplicationSetting.AttributeId.SHUFFLE_ON_OFF: avrcp.ApplicationSetting.ShuffleOnOffStatus.GROUP_SHUFFLE,
|
||||||
|
}
|
||||||
|
assert delegate.player_app_settings == expected_settings
|
||||||
|
|
||||||
|
actual_settings = await two_devices.protocols[0].get_player_app_settings(
|
||||||
|
[
|
||||||
|
avrcp.ApplicationSetting.AttributeId.REPEAT_MODE,
|
||||||
|
avrcp.ApplicationSetting.AttributeId.SHUFFLE_ON_OFF,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert actual_settings == expected_settings
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_play_item():
|
||||||
|
two_devices: TwoDevices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
delegate = two_devices.protocols[1].delegate
|
||||||
|
|
||||||
|
with mock.patch.object(delegate, delegate.play_item.__name__) as play_item_mock:
|
||||||
|
await two_devices.protocols[0].send_avrcp_command(
|
||||||
|
avc.CommandFrame.CommandType.CONTROL,
|
||||||
|
avrcp.PlayItemCommand(
|
||||||
|
scope=avrcp.Scope.MEDIA_PLAYER_LIST, uid=0, uid_counter=1
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
play_item_mock.assert_called_once_with(
|
||||||
|
scope=avrcp.Scope.MEDIA_PLAYER_LIST, uid=0, uid_counter=1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_monitor_volume():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
two_devices.protocols[1].delegate = avrcp.Delegate([avrcp.EventId.VOLUME_CHANGED])
|
||||||
|
volume_iter = two_devices.protocols[0].monitor_volume()
|
||||||
|
|
||||||
|
for volume in range(avrcp.SetAbsoluteVolumeCommand.MAXIMUM_VOLUME + 1):
|
||||||
|
# Interim
|
||||||
|
two_devices.protocols[1].delegate.volume = 0
|
||||||
|
assert (await anext(volume_iter)) == 0
|
||||||
|
# Changed
|
||||||
|
two_devices.protocols[1].notify_volume_changed(volume)
|
||||||
|
assert (await anext(volume_iter)) == volume
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_monitor_playback_status():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
two_devices.protocols[1].delegate = avrcp.Delegate(
|
||||||
|
[avrcp.EventId.PLAYBACK_STATUS_CHANGED]
|
||||||
|
)
|
||||||
|
playback_status_iter = two_devices.protocols[0].monitor_playback_status()
|
||||||
|
|
||||||
|
for playback_status in avrcp.PlayStatus:
|
||||||
|
# Interim
|
||||||
|
two_devices.protocols[1].delegate.playback_status = avrcp.PlayStatus.STOPPED
|
||||||
|
assert (await anext(playback_status_iter)) == avrcp.PlayStatus.STOPPED
|
||||||
|
# Changed
|
||||||
|
two_devices.protocols[1].notify_playback_status_changed(playback_status)
|
||||||
|
assert (await anext(playback_status_iter)) == playback_status
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_monitor_now_playing_content():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
two_devices.protocols[1].delegate = avrcp.Delegate(
|
||||||
|
[avrcp.EventId.NOW_PLAYING_CONTENT_CHANGED]
|
||||||
|
)
|
||||||
|
now_playing_iter = two_devices.protocols[0].monitor_now_playing_content()
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
# Interim
|
||||||
|
await anext(now_playing_iter)
|
||||||
|
# Changed
|
||||||
|
two_devices.protocols[1].notify_now_playing_content_changed()
|
||||||
|
await anext(now_playing_iter)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_monitor_track_changed():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
delegate = two_devices.protocols[1].delegate = avrcp.Delegate(
|
||||||
|
[avrcp.EventId.TRACK_CHANGED]
|
||||||
|
)
|
||||||
|
delegate.current_track_uid = avrcp.TrackChangedEvent.NO_TRACK
|
||||||
|
track_iter = two_devices.protocols[0].monitor_track_changed()
|
||||||
|
|
||||||
|
# Interim
|
||||||
|
assert (await anext(track_iter)) == avrcp.TrackChangedEvent.NO_TRACK
|
||||||
|
# Changed
|
||||||
|
two_devices.protocols[1].notify_track_changed(1)
|
||||||
|
assert (await anext(track_iter)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_monitor_uid_changed():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
delegate = two_devices.protocols[1].delegate = avrcp.Delegate(
|
||||||
|
[avrcp.EventId.UIDS_CHANGED]
|
||||||
|
)
|
||||||
|
delegate.uid_counter = 0
|
||||||
|
uid_iter = two_devices.protocols[0].monitor_uids()
|
||||||
|
|
||||||
|
# Interim
|
||||||
|
assert (await anext(uid_iter)) == 0
|
||||||
|
# Changed
|
||||||
|
two_devices.protocols[1].notify_uids_changed(1)
|
||||||
|
assert (await anext(uid_iter)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_monitor_addressed_player():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
delegate = two_devices.protocols[1].delegate = avrcp.Delegate(
|
||||||
|
[avrcp.EventId.ADDRESSED_PLAYER_CHANGED]
|
||||||
|
)
|
||||||
|
delegate.uid_counter = 0
|
||||||
|
delegate.addressed_player_id = 0
|
||||||
|
addressed_player_iter = two_devices.protocols[0].monitor_addressed_player()
|
||||||
|
|
||||||
|
# Interim
|
||||||
|
assert (
|
||||||
|
await anext(addressed_player_iter)
|
||||||
|
) == avrcp.AddressedPlayerChangedEvent.Player(player_id=0, uid_counter=0)
|
||||||
|
# Changed
|
||||||
|
two_devices.protocols[1].notify_addressed_player_changed(
|
||||||
|
avrcp.AddressedPlayerChangedEvent.Player(player_id=1, uid_counter=1)
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await anext(addressed_player_iter)
|
||||||
|
) == avrcp.AddressedPlayerChangedEvent.Player(player_id=1, uid_counter=1)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_monitor_player_app_settings():
|
||||||
|
two_devices = await TwoDevices.create_with_avdtp()
|
||||||
|
|
||||||
|
delegate = two_devices.protocols[1].delegate = avrcp.Delegate(
|
||||||
|
supported_events=[avrcp.EventId.PLAYER_APPLICATION_SETTING_CHANGED]
|
||||||
|
)
|
||||||
|
delegate.player_app_settings = {
|
||||||
|
avrcp.ApplicationSetting.AttributeId.REPEAT_MODE: avrcp.ApplicationSetting.RepeatModeStatus.ALL_TRACK_REPEAT
|
||||||
|
}
|
||||||
|
settings_iter = two_devices.protocols[0].monitor_player_application_settings()
|
||||||
|
|
||||||
|
# Interim
|
||||||
|
interim = await anext(settings_iter)
|
||||||
|
assert interim[0].attribute_id == avrcp.ApplicationSetting.AttributeId.REPEAT_MODE
|
||||||
|
assert (
|
||||||
|
interim[0].value_id
|
||||||
|
== avrcp.ApplicationSetting.RepeatModeStatus.ALL_TRACK_REPEAT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Changed
|
||||||
|
two_devices.protocols[1].notify_player_application_settings_changed(
|
||||||
|
[
|
||||||
|
avrcp.PlayerApplicationSettingChangedEvent.Setting(
|
||||||
|
avrcp.ApplicationSetting.AttributeId.REPEAT_MODE,
|
||||||
|
avrcp.ApplicationSetting.RepeatModeStatus.GROUP_REPEAT,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
changed = await anext(settings_iter)
|
||||||
|
assert changed[0].attribute_id == avrcp.ApplicationSetting.AttributeId.REPEAT_MODE
|
||||||
|
assert changed[0].value_id == avrcp.ApplicationSetting.RepeatModeStatus.GROUP_REPEAT
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
test_frame_parser()
|
test_frame_parser()
|
||||||
|
|||||||
34
tests/battery_service_test.py
Normal file
34
tests/battery_service_test.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Copyright 2021-2026 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 pytest
|
||||||
|
|
||||||
|
from bumble import device as device_module
|
||||||
|
from bumble.profiles import battery_service
|
||||||
|
|
||||||
|
from . import test_utils
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_battery_level():
|
||||||
|
devices = await test_utils.TwoDevices.create_with_connection()
|
||||||
|
service = battery_service.BatteryService(lambda _: 1)
|
||||||
|
devices[0].add_service(service)
|
||||||
|
|
||||||
|
async with device_module.Peer(devices.connections[1]) as peer:
|
||||||
|
client = peer.create_service_proxy(battery_service.BatteryServiceProxy)
|
||||||
|
assert client
|
||||||
|
assert await client.battery_level.read_value() == 1
|
||||||
@@ -73,6 +73,14 @@ def test_uuid_to_hex_str() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_uuid_hash() -> None:
|
||||||
|
uuid = UUID("1234")
|
||||||
|
uuid_128_bytes = UUID.from_bytes(uuid.to_bytes(force_128=True))
|
||||||
|
assert uuid in {uuid_128_bytes}
|
||||||
|
assert uuid_128_bytes in {uuid}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def test_appearance() -> None:
|
def test_appearance() -> None:
|
||||||
a = Appearance(Appearance.Category.COMPUTER, Appearance.ComputerSubcategory.LAPTOP)
|
a = Appearance(Appearance.Category.COMPUTER, Appearance.ComputerSubcategory.LAPTOP)
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ from bumble.hci import (
|
|||||||
HCI_CREATE_CONNECTION_COMMAND,
|
HCI_CREATE_CONNECTION_COMMAND,
|
||||||
HCI_SUCCESS,
|
HCI_SUCCESS,
|
||||||
Address,
|
Address,
|
||||||
HCI_Command_Complete_Event,
|
|
||||||
HCI_Command_Status_Event,
|
HCI_Command_Status_Event,
|
||||||
HCI_Connection_Complete_Event,
|
HCI_Connection_Complete_Event,
|
||||||
HCI_Connection_Request_Event,
|
HCI_Connection_Request_Event,
|
||||||
@@ -154,10 +153,10 @@ async def test_device_connect_parallel():
|
|||||||
assert packet.name == 'HCI_ACCEPT_CONNECTION_REQUEST_COMMAND'
|
assert packet.name == 'HCI_ACCEPT_CONNECTION_REQUEST_COMMAND'
|
||||||
|
|
||||||
d1.host.on_hci_packet(
|
d1.host.on_hci_packet(
|
||||||
HCI_Command_Complete_Event(
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_COMMAND_STATUS_PENDING,
|
||||||
num_hci_command_packets=1,
|
num_hci_command_packets=1,
|
||||||
command_opcode=HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
command_opcode=HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
||||||
return_parameters=b"\x00",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -188,10 +187,10 @@ async def test_device_connect_parallel():
|
|||||||
assert packet.name == 'HCI_ACCEPT_CONNECTION_REQUEST_COMMAND'
|
assert packet.name == 'HCI_ACCEPT_CONNECTION_REQUEST_COMMAND'
|
||||||
|
|
||||||
d2.host.on_hci_packet(
|
d2.host.on_hci_packet(
|
||||||
HCI_Command_Complete_Event(
|
HCI_Command_Status_Event(
|
||||||
|
status=HCI_COMMAND_STATUS_PENDING,
|
||||||
num_hci_command_packets=1,
|
num_hci_command_packets=1,
|
||||||
command_opcode=HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
command_opcode=HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
||||||
return_parameters=b"\x00",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -310,6 +309,27 @@ async def test_legacy_advertising_disconnection(auto_restart):
|
|||||||
assert not devices[0].is_advertising
|
assert not devices[0].is_advertising
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_le_multiple_connects():
|
||||||
|
devices = TwoDevices()
|
||||||
|
for controller in devices.controllers:
|
||||||
|
controller.le_features |= hci.LeFeatureMask.LE_EXTENDED_ADVERTISING
|
||||||
|
for dev in devices:
|
||||||
|
await dev.power_on()
|
||||||
|
await devices[0].start_advertising(auto_restart=True, advertising_interval_min=1.0)
|
||||||
|
|
||||||
|
connection = await devices[1].connect(devices[0].random_address)
|
||||||
|
await connection.disconnect()
|
||||||
|
|
||||||
|
await async_barrier()
|
||||||
|
await async_barrier()
|
||||||
|
|
||||||
|
# a second connection attempt is working
|
||||||
|
connection = await devices[1].connect(devices[0].random_address)
|
||||||
|
await connection.disconnect()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_advertising_and_scanning():
|
async def test_advertising_and_scanning():
|
||||||
@@ -446,7 +466,9 @@ async def test_get_remote_le_features():
|
|||||||
devices = TwoDevices()
|
devices = TwoDevices()
|
||||||
await devices.setup_connection()
|
await devices.setup_connection()
|
||||||
|
|
||||||
assert (await devices.connections[0].get_remote_le_features()) is not None
|
assert (
|
||||||
|
await devices.connections[0].get_remote_le_features()
|
||||||
|
) == devices.controllers[1].le_features
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -620,7 +642,9 @@ async def test_le_request_subrate():
|
|||||||
def on_le_subrate_change():
|
def on_le_subrate_change():
|
||||||
q.put_nowait(lambda: None)
|
q.put_nowait(lambda: None)
|
||||||
|
|
||||||
devices.connections[0].on(Connection.EVENT_LE_SUBRATE_CHANGE, on_le_subrate_change)
|
devices.connections[0].on(
|
||||||
|
Connection.EVENT_CONNECTION_PARAMETERS_UPDATE, on_le_subrate_change
|
||||||
|
)
|
||||||
|
|
||||||
await devices[0].send_command(
|
await devices[0].send_command(
|
||||||
hci.HCI_LE_Subrate_Request_Command(
|
hci.HCI_LE_Subrate_Request_Command(
|
||||||
@@ -802,6 +826,22 @@ async def test_remote_name_request():
|
|||||||
assert actual_name == expected_name
|
assert actual_name == expected_name
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_remote_classic_features():
|
||||||
|
devices = TwoDevices()
|
||||||
|
devices[0].classic_enabled = True
|
||||||
|
devices[1].classic_enabled = True
|
||||||
|
await devices[0].power_on()
|
||||||
|
await devices[1].power_on()
|
||||||
|
connection = await devices[0].connect_classic(devices[1].public_address)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await asyncio.wait_for(connection.get_remote_classic_features(), _TIMEOUT)
|
||||||
|
== devices.controllers[1].lmp_features
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def run_test_device():
|
async def run_test_device():
|
||||||
await test_device_connect_parallel()
|
await test_device_connect_parallel()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from unittest.mock import ANY, AsyncMock, Mock
|
|||||||
import pytest
|
import pytest
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble import gatt_client, l2cap
|
from bumble import att, gatt_client, l2cap
|
||||||
from bumble.att import (
|
from bumble.att import (
|
||||||
ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
ATT_PDU,
|
ATT_PDU,
|
||||||
@@ -1638,6 +1638,104 @@ async def test_eatt_connection_failure():
|
|||||||
await gatt_client.Client.connect_eatt(devices.connections[0])
|
await gatt_client.Client.connect_eatt(devices.connections[0])
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_multiple() -> None:
|
||||||
|
devices = await TwoDevices.create_with_connection()
|
||||||
|
|
||||||
|
characteristic1 = Characteristic(
|
||||||
|
'0001', Characteristic.Properties.READ, Characteristic.READABLE, b'1234'
|
||||||
|
)
|
||||||
|
|
||||||
|
characteristic2 = Characteristic(
|
||||||
|
'0002',
|
||||||
|
Characteristic.Properties.READ,
|
||||||
|
Characteristic.READABLE,
|
||||||
|
b'5678',
|
||||||
|
)
|
||||||
|
|
||||||
|
service = Service('0000', [characteristic1, characteristic2])
|
||||||
|
devices[1].add_service(service)
|
||||||
|
|
||||||
|
client = devices.connections[0].gatt_client
|
||||||
|
server = devices[1].gatt_server
|
||||||
|
|
||||||
|
await client.discover_services()
|
||||||
|
characteristics = await client.discover_characteristics(
|
||||||
|
[characteristic1.uuid, characteristic2.uuid], None
|
||||||
|
)
|
||||||
|
response = await client.send_request(
|
||||||
|
att.ATT_Read_Multiple_Request(
|
||||||
|
set_of_handles=[c.handle for c in characteristics]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert isinstance(response, att.ATT_Read_Multiple_Response)
|
||||||
|
assert response.set_of_values == b'12345678'
|
||||||
|
|
||||||
|
response = await client.send_request(
|
||||||
|
att.ATT_Read_Multiple_Request(
|
||||||
|
set_of_handles=[
|
||||||
|
next(
|
||||||
|
handle
|
||||||
|
for handle in range(0x0001, 0xFFFF)
|
||||||
|
if not server.get_attribute(handle)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert isinstance(response, att.ATT_Error_Response)
|
||||||
|
assert response.error_code == att.ATT_ATTRIBUTE_NOT_FOUND_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_multiple_variable() -> None:
|
||||||
|
devices = await TwoDevices.create_with_connection()
|
||||||
|
|
||||||
|
characteristic1 = Characteristic(
|
||||||
|
'0001', Characteristic.Properties.READ, Characteristic.READABLE, b'1234'
|
||||||
|
)
|
||||||
|
|
||||||
|
characteristic2 = Characteristic(
|
||||||
|
'0002',
|
||||||
|
Characteristic.Properties.READ,
|
||||||
|
Characteristic.READABLE,
|
||||||
|
b'99',
|
||||||
|
)
|
||||||
|
|
||||||
|
service = Service('0000', [characteristic1, characteristic2])
|
||||||
|
devices[1].add_service(service)
|
||||||
|
|
||||||
|
client = devices.connections[0].gatt_client
|
||||||
|
server = devices[1].gatt_server
|
||||||
|
|
||||||
|
await client.discover_services()
|
||||||
|
characteristics = await client.discover_characteristics(
|
||||||
|
[characteristic1.uuid, characteristic2.uuid], None
|
||||||
|
)
|
||||||
|
response = await client.send_request(
|
||||||
|
att.ATT_Read_Multiple_Variable_Request(
|
||||||
|
set_of_handles=[c.handle for c in characteristics]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert isinstance(response, att.ATT_Read_Multiple_Variable_Response)
|
||||||
|
assert response.length_value_tuple_list == [(4, b'1234'), (2, b'99')]
|
||||||
|
|
||||||
|
response = await client.send_request(
|
||||||
|
att.ATT_Read_Multiple_Variable_Request(
|
||||||
|
set_of_handles=[
|
||||||
|
next(
|
||||||
|
handle
|
||||||
|
for handle in range(0x0001, 0xFFFF)
|
||||||
|
if not server.get_attribute(handle)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert isinstance(response, att.ATT_Error_Response)
|
||||||
|
assert response.error_code == att.ATT_ATTRIBUTE_NOT_FOUND_ERROR
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import struct
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from bumble import hci
|
from bumble import hci, utils
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@@ -136,43 +136,25 @@ def test_HCI_LE_Channel_Selection_Algorithm_Event():
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def test_HCI_Command_Complete_Event():
|
def test_HCI_Command_Complete_Event():
|
||||||
# With a serializable object
|
# With a serializable object
|
||||||
event = hci.HCI_Command_Complete_Event(
|
event1 = hci.HCI_Command_Complete_Event(
|
||||||
num_hci_command_packets=34,
|
num_hci_command_packets=34,
|
||||||
command_opcode=hci.HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
command_opcode=hci.HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||||
return_parameters=hci.HCI_LE_Read_Buffer_Size_Command.create_return_parameters(
|
return_parameters=hci.HCI_LE_Read_Buffer_Size_Command.return_parameters_class(
|
||||||
status=0,
|
status=0,
|
||||||
le_acl_data_packet_length=1234,
|
le_acl_data_packet_length=1234,
|
||||||
total_num_le_acl_data_packets=56,
|
total_num_le_acl_data_packets=56,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
basic_check(event)
|
basic_check(event1)
|
||||||
|
|
||||||
# With an arbitrary byte array
|
|
||||||
event = hci.HCI_Command_Complete_Event(
|
|
||||||
num_hci_command_packets=1,
|
|
||||||
command_opcode=hci.HCI_RESET_COMMAND,
|
|
||||||
return_parameters=bytes([1, 2, 3, 4]),
|
|
||||||
)
|
|
||||||
basic_check(event)
|
|
||||||
|
|
||||||
# With a simple status as a 1-byte array
|
|
||||||
event = hci.HCI_Command_Complete_Event(
|
|
||||||
num_hci_command_packets=1,
|
|
||||||
command_opcode=hci.HCI_RESET_COMMAND,
|
|
||||||
return_parameters=bytes([7]),
|
|
||||||
)
|
|
||||||
basic_check(event)
|
|
||||||
event = hci.HCI_Packet.from_bytes(bytes(event))
|
|
||||||
assert event.return_parameters == 7
|
|
||||||
|
|
||||||
# With a simple status as an integer status
|
# With a simple status as an integer status
|
||||||
event = hci.HCI_Command_Complete_Event(
|
event3 = hci.HCI_Command_Complete_Event(
|
||||||
num_hci_command_packets=1,
|
num_hci_command_packets=1,
|
||||||
command_opcode=hci.HCI_RESET_COMMAND,
|
command_opcode=hci.HCI_RESET_COMMAND,
|
||||||
return_parameters=9,
|
return_parameters=hci.HCI_StatusReturnParameters(hci.HCI_ErrorCode(9)),
|
||||||
)
|
)
|
||||||
basic_check(event)
|
basic_check(event3)
|
||||||
assert event.return_parameters == 9
|
assert event3.return_parameters.status == 9
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -229,6 +211,36 @@ def test_HCI_Vendor_Event():
|
|||||||
assert isinstance(parsed, hci.HCI_Vendor_Event)
|
assert isinstance(parsed, hci.HCI_Vendor_Event)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_return_parameters() -> None:
|
||||||
|
params = hci.HCI_Reset_Command.parse_return_parameters(bytes.fromhex('3C'))
|
||||||
|
assert params.status == hci.HCI_ErrorCode.ADVERTISING_TIMEOUT_ERROR
|
||||||
|
assert isinstance(params.status, utils.OpenIntEnum)
|
||||||
|
|
||||||
|
params = hci.HCI_Read_BD_ADDR_Command.parse_return_parameters(
|
||||||
|
bytes.fromhex('00001122334455')
|
||||||
|
)
|
||||||
|
assert params.status == hci.HCI_ErrorCode.SUCCESS
|
||||||
|
assert isinstance(params.status, utils.OpenIntEnum)
|
||||||
|
assert isinstance(params.bd_addr, hci.Address)
|
||||||
|
|
||||||
|
params = hci.HCI_Read_Local_Name_Command.parse_return_parameters(
|
||||||
|
bytes.fromhex('0068656c6c6f') + bytes(248 - 5)
|
||||||
|
)
|
||||||
|
assert params.status == hci.HCI_ErrorCode.SUCCESS
|
||||||
|
assert isinstance(params.local_name, bytes)
|
||||||
|
assert len(params.local_name) == 248
|
||||||
|
assert hci.map_null_terminated_utf8_string(params.local_name) == 'hello'
|
||||||
|
|
||||||
|
# Some return parameters may be shorter than the full length
|
||||||
|
# (for Command Complete events with errors)
|
||||||
|
params = hci.HCI_Read_BD_ADDR_Command.parse_return_parameters(
|
||||||
|
bytes.fromhex('010011223344')
|
||||||
|
)
|
||||||
|
assert isinstance(params, hci.HCI_StatusReturnParameters)
|
||||||
|
assert params.status == hci.HCI_ErrorCode.UNKNOWN_HCI_COMMAND_ERROR
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def test_HCI_Command():
|
def test_HCI_Command():
|
||||||
command = hci.HCI_Command(op_code=0x5566)
|
command = hci.HCI_Command(op_code=0x5566)
|
||||||
@@ -291,7 +303,7 @@ def test_custom_le_meta_event():
|
|||||||
for clazz in inspect.getmembers(hci)
|
for clazz in inspect.getmembers(hci)
|
||||||
if isinstance(clazz[1], type)
|
if isinstance(clazz[1], type)
|
||||||
and issubclass(clazz[1], hci.HCI_Command)
|
and issubclass(clazz[1], hci.HCI_Command)
|
||||||
and clazz[1] is not hci.HCI_Command
|
and clazz[1] not in (hci.HCI_Command, hci.HCI_SyncCommand, hci.HCI_AsyncCommand)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_hci_command_subclasses_op_code(clazz: type[hci.HCI_Command]):
|
def test_hci_command_subclasses_op_code(clazz: type[hci.HCI_Command]):
|
||||||
@@ -620,21 +632,19 @@ def test_HCI_Read_Local_Supported_Codecs_Command_Complete():
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def test_HCI_Read_Local_Supported_Codecs_V2_Command_Complete():
|
def test_HCI_Read_Local_Supported_Codecs_V2_Command_Complete():
|
||||||
returned_parameters = (
|
returned_parameters = hci.HCI_Read_Local_Supported_Codecs_V2_Command.parse_return_parameters(
|
||||||
hci.HCI_Read_Local_Supported_Codecs_V2_Command.parse_return_parameters(
|
bytes(
|
||||||
bytes(
|
[
|
||||||
[
|
hci.HCI_SUCCESS,
|
||||||
hci.HCI_SUCCESS,
|
3,
|
||||||
3,
|
hci.CodecID.A_LOG,
|
||||||
hci.CodecID.A_LOG,
|
hci.HCI_Read_Local_Supported_Codecs_V2_ReturnParameters.Transport.BR_EDR_ACL,
|
||||||
hci.HCI_Read_Local_Supported_Codecs_V2_Command.Transport.BR_EDR_ACL,
|
hci.CodecID.CVSD,
|
||||||
hci.CodecID.CVSD,
|
hci.HCI_Read_Local_Supported_Codecs_V2_ReturnParameters.Transport.BR_EDR_SCO,
|
||||||
hci.HCI_Read_Local_Supported_Codecs_V2_Command.Transport.BR_EDR_SCO,
|
hci.CodecID.LINEAR_PCM,
|
||||||
hci.CodecID.LINEAR_PCM,
|
hci.HCI_Read_Local_Supported_Codecs_V2_ReturnParameters.Transport.LE_CIS,
|
||||||
hci.HCI_Read_Local_Supported_Codecs_V2_Command.Transport.LE_CIS,
|
0,
|
||||||
0,
|
]
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert returned_parameters.standard_codec_ids == [
|
assert returned_parameters.standard_codec_ids == [
|
||||||
@@ -643,9 +653,9 @@ def test_HCI_Read_Local_Supported_Codecs_V2_Command_Complete():
|
|||||||
hci.CodecID.LINEAR_PCM,
|
hci.CodecID.LINEAR_PCM,
|
||||||
]
|
]
|
||||||
assert returned_parameters.standard_codec_transports == [
|
assert returned_parameters.standard_codec_transports == [
|
||||||
hci.HCI_Read_Local_Supported_Codecs_V2_Command.Transport.BR_EDR_ACL,
|
hci.HCI_Read_Local_Supported_Codecs_V2_ReturnParameters.Transport.BR_EDR_ACL,
|
||||||
hci.HCI_Read_Local_Supported_Codecs_V2_Command.Transport.BR_EDR_SCO,
|
hci.HCI_Read_Local_Supported_Codecs_V2_ReturnParameters.Transport.BR_EDR_SCO,
|
||||||
hci.HCI_Read_Local_Supported_Codecs_V2_Command.Transport.LE_CIS,
|
hci.HCI_Read_Local_Supported_Codecs_V2_ReturnParameters.Transport.LE_CIS,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -737,6 +747,7 @@ def run_test_commands():
|
|||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
run_test_events()
|
run_test_events()
|
||||||
run_test_commands()
|
run_test_commands()
|
||||||
|
test_return_parameters()
|
||||||
test_address()
|
test_address()
|
||||||
test_custom()
|
test_custom()
|
||||||
test_iso_data_packet()
|
test_iso_data_packet()
|
||||||
|
|||||||
89
tests/heart_rate_service_test.py
Normal file
89
tests/heart_rate_service_test.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Copyright 2021-2026 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 asyncio
|
||||||
|
import itertools
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bumble import device as device_module
|
||||||
|
from bumble.profiles import heart_rate_service
|
||||||
|
|
||||||
|
from . import test_utils
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"heart_rate, sensor_contact_detected, energy_expanded, rr_intervals",
|
||||||
|
itertools.product(
|
||||||
|
(1, 1000), (True, False, None), (2, None), ((3.0, 4.0, 5.0), None)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def test_read_measurement(
|
||||||
|
heart_rate: int,
|
||||||
|
sensor_contact_detected: bool | None,
|
||||||
|
energy_expanded: int | None,
|
||||||
|
rr_intervals: Sequence[int] | None,
|
||||||
|
):
|
||||||
|
devices = await test_utils.TwoDevices.create_with_connection()
|
||||||
|
measurement = heart_rate_service.HeartRateService.HeartRateMeasurement(
|
||||||
|
heart_rate, sensor_contact_detected, energy_expanded, rr_intervals
|
||||||
|
)
|
||||||
|
service = heart_rate_service.HeartRateService(lambda _: measurement)
|
||||||
|
devices[0].add_service(service)
|
||||||
|
|
||||||
|
async with device_module.Peer(devices.connections[1]) as peer:
|
||||||
|
client = peer.create_service_proxy(heart_rate_service.HeartRateServiceProxy)
|
||||||
|
assert client
|
||||||
|
assert await client.heart_rate_measurement.read_value() == measurement
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_body_sensor_location():
|
||||||
|
devices = await test_utils.TwoDevices.create_with_connection()
|
||||||
|
measurement = heart_rate_service.HeartRateService.HeartRateMeasurement(0)
|
||||||
|
location = heart_rate_service.HeartRateService.BodySensorLocation.FINGER
|
||||||
|
service = heart_rate_service.HeartRateService(
|
||||||
|
lambda _: measurement,
|
||||||
|
body_sensor_location=location,
|
||||||
|
)
|
||||||
|
devices[0].add_service(service)
|
||||||
|
|
||||||
|
async with device_module.Peer(devices.connections[1]) as peer:
|
||||||
|
client = peer.create_service_proxy(heart_rate_service.HeartRateServiceProxy)
|
||||||
|
assert client
|
||||||
|
assert client.body_sensor_location
|
||||||
|
assert await client.body_sensor_location.read_value() == location
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_energy_expended() -> None:
|
||||||
|
devices = await test_utils.TwoDevices.create_with_connection()
|
||||||
|
measurement = heart_rate_service.HeartRateService.HeartRateMeasurement(1)
|
||||||
|
reset_energy_expended = asyncio.Queue[None]()
|
||||||
|
service = heart_rate_service.HeartRateService(
|
||||||
|
lambda _: measurement,
|
||||||
|
reset_energy_expended=lambda _: reset_energy_expended.put_nowait(None),
|
||||||
|
)
|
||||||
|
devices[0].add_service(service)
|
||||||
|
|
||||||
|
async with device_module.Peer(devices.connections[1]) as peer:
|
||||||
|
client = peer.create_service_proxy(heart_rate_service.HeartRateServiceProxy)
|
||||||
|
assert client
|
||||||
|
await client.reset_energy_expended()
|
||||||
|
await reset_energy_expended.get()
|
||||||
@@ -15,16 +15,31 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import unittest
|
import unittest
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from bumble import controller, hci
|
||||||
from bumble.controller import Controller
|
from bumble.controller import Controller
|
||||||
from bumble.hci import HCI_AclDataPacket
|
from bumble.hci import (
|
||||||
|
HCI_AclDataPacket,
|
||||||
|
HCI_Command_Complete_Event,
|
||||||
|
HCI_Command_Status_Event,
|
||||||
|
HCI_CommandStatus,
|
||||||
|
HCI_Disconnect_Command,
|
||||||
|
HCI_Error,
|
||||||
|
HCI_ErrorCode,
|
||||||
|
HCI_Event,
|
||||||
|
HCI_GenericReturnParameters,
|
||||||
|
HCI_LE_Terminate_BIG_Command,
|
||||||
|
HCI_Reset_Command,
|
||||||
|
HCI_StatusReturnParameters,
|
||||||
|
)
|
||||||
from bumble.host import DataPacketQueue, Host
|
from bumble.host import DataPacketQueue, Host
|
||||||
from bumble.transport.common import AsyncPipeSink
|
from bumble.transport.common import AsyncPipeSink, TransportSink
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -35,34 +50,27 @@ logger = logging.getLogger(__name__)
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'supported_commands, lmp_features',
|
'supported_commands, max_lmp_features_page_number',
|
||||||
[
|
[
|
||||||
(
|
(controller.Controller.supported_commands, 0),
|
||||||
# Default commands
|
|
||||||
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
|
|
||||||
'30f0f9ff01008004000000000000000000000000000000000000000000000000',
|
|
||||||
# Only LE LMP feature
|
|
||||||
'0000000060000000',
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
# All commands
|
# All commands
|
||||||
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
|
set(hci.HCI_Command.command_names.keys()),
|
||||||
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
|
|
||||||
# 3 pages of LMP features
|
# 3 pages of LMP features
|
||||||
'000102030405060708090A0B0C0D0E0F011112131415161718191A1B1C1D1E1F',
|
2,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_reset(supported_commands: str, lmp_features: str):
|
async def test_reset(supported_commands: set[int], max_lmp_features_page_number: int):
|
||||||
controller = Controller('C')
|
controller = Controller('C')
|
||||||
controller.supported_commands = bytes.fromhex(supported_commands)
|
controller.supported_commands = supported_commands
|
||||||
controller.lmp_features = bytes.fromhex(lmp_features)
|
controller.lmp_features_max_page_number = max_lmp_features_page_number
|
||||||
host = Host(controller, AsyncPipeSink(controller))
|
host = Host(controller, AsyncPipeSink(controller))
|
||||||
|
|
||||||
await host.reset()
|
await host.reset()
|
||||||
|
|
||||||
assert host.local_lmp_features == int.from_bytes(
|
assert host.local_lmp_features == (
|
||||||
bytes.fromhex(lmp_features), 'little'
|
controller.lmp_features & ~(1 << (64 * max_lmp_features_page_number + 1))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,3 +159,132 @@ def test_data_packet_queue():
|
|||||||
assert drain_listener.on_flow.call_count == 1
|
assert drain_listener.on_flow.call_count == 1
|
||||||
assert queue.queued == 15
|
assert queue.queued == 15
|
||||||
assert queue.completed == 15
|
assert queue.completed == 15
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Source:
|
||||||
|
terminated: asyncio.Future[None]
|
||||||
|
sink: TransportSink
|
||||||
|
|
||||||
|
def set_packet_sink(self, sink: TransportSink) -> None:
|
||||||
|
self.sink = sink
|
||||||
|
|
||||||
|
|
||||||
|
class Sink:
|
||||||
|
response: HCI_Event | None
|
||||||
|
|
||||||
|
def __init__(self, source: Source, response: HCI_Event | None) -> None:
|
||||||
|
self.source = source
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
def on_packet(self, packet: bytes) -> None:
|
||||||
|
if self.response is not None:
|
||||||
|
self.source.sink.on_packet(bytes(self.response))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_sync_command() -> None:
|
||||||
|
source = Source()
|
||||||
|
sink = Sink(
|
||||||
|
source,
|
||||||
|
HCI_Command_Complete_Event(
|
||||||
|
1,
|
||||||
|
HCI_Reset_Command.op_code,
|
||||||
|
HCI_StatusReturnParameters(status=HCI_ErrorCode.SUCCESS),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
host = Host(source, sink)
|
||||||
|
host.ready = True
|
||||||
|
|
||||||
|
# Sync command with success
|
||||||
|
response1 = await host.send_sync_command(HCI_Reset_Command())
|
||||||
|
assert response1.status == HCI_ErrorCode.SUCCESS
|
||||||
|
|
||||||
|
# Sync command with error status should raise
|
||||||
|
error_response = HCI_Command_Complete_Event(
|
||||||
|
1,
|
||||||
|
HCI_Reset_Command.op_code,
|
||||||
|
HCI_StatusReturnParameters(status=HCI_ErrorCode.COMMAND_DISALLOWED_ERROR),
|
||||||
|
)
|
||||||
|
sink.response = error_response
|
||||||
|
with pytest.raises(HCI_Error) as excinfo:
|
||||||
|
await host.send_sync_command(HCI_Reset_Command())
|
||||||
|
|
||||||
|
assert excinfo.value.error_code == error_response.return_parameters.status
|
||||||
|
|
||||||
|
# Sync command with raw result
|
||||||
|
response2 = await host.send_sync_command_raw(HCI_Reset_Command())
|
||||||
|
assert response2.return_parameters.status == HCI_ErrorCode.COMMAND_DISALLOWED_ERROR
|
||||||
|
|
||||||
|
# Sync command with a command that's not an HCI_SyncCommand
|
||||||
|
# (here, for convenience, we use an HCI_AsyncCommand instance)
|
||||||
|
command = HCI_Disconnect_Command(connection_handle=0x1234, reason=0x13)
|
||||||
|
sink.response = HCI_Command_Complete_Event(
|
||||||
|
1,
|
||||||
|
command.op_code,
|
||||||
|
HCI_GenericReturnParameters(data=bytes.fromhex("00112233")),
|
||||||
|
)
|
||||||
|
response3 = await host.send_sync_command_raw(command) # type: ignore
|
||||||
|
assert isinstance(response3.return_parameters, HCI_GenericReturnParameters)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_sync_command_timeout() -> None:
|
||||||
|
source = Source()
|
||||||
|
sink = Sink(source, None)
|
||||||
|
|
||||||
|
host = Host(source, sink)
|
||||||
|
host.ready = True
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.TimeoutError):
|
||||||
|
await host.send_sync_command(HCI_Reset_Command(), response_timeout=0.01)
|
||||||
|
|
||||||
|
# The sending semaphore should have been released, so this should not block
|
||||||
|
# indefinitely
|
||||||
|
with pytest.raises(asyncio.TimeoutError):
|
||||||
|
await host.send_sync_command(hci.HCI_Reset_Command(), response_timeout=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_async_command() -> None:
|
||||||
|
source = Source()
|
||||||
|
sink = Sink(
|
||||||
|
source,
|
||||||
|
HCI_Command_Status_Event(
|
||||||
|
HCI_CommandStatus.PENDING,
|
||||||
|
1,
|
||||||
|
HCI_Reset_Command.op_code,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
host = Host(source, sink)
|
||||||
|
host.ready = True
|
||||||
|
|
||||||
|
# Normal pending status
|
||||||
|
response = await host.send_async_command(
|
||||||
|
HCI_LE_Terminate_BIG_Command(big_handle=0, reason=0)
|
||||||
|
)
|
||||||
|
assert response == HCI_CommandStatus.PENDING
|
||||||
|
|
||||||
|
# Unknown HCI command result returned as a Command Status
|
||||||
|
sink.response = HCI_Command_Status_Event(
|
||||||
|
HCI_ErrorCode.UNKNOWN_HCI_COMMAND_ERROR,
|
||||||
|
1,
|
||||||
|
HCI_LE_Terminate_BIG_Command.op_code,
|
||||||
|
)
|
||||||
|
response = await host.send_async_command(
|
||||||
|
HCI_LE_Terminate_BIG_Command(big_handle=0, reason=0), check_status=False
|
||||||
|
)
|
||||||
|
assert response == HCI_ErrorCode.UNKNOWN_HCI_COMMAND_ERROR
|
||||||
|
|
||||||
|
# Unknown HCI command result returned as a Command Complete
|
||||||
|
sink.response = HCI_Command_Complete_Event(
|
||||||
|
1,
|
||||||
|
HCI_LE_Terminate_BIG_Command.op_code,
|
||||||
|
HCI_StatusReturnParameters(HCI_ErrorCode.UNKNOWN_HCI_COMMAND_ERROR),
|
||||||
|
)
|
||||||
|
response = await host.send_async_command(
|
||||||
|
HCI_LE_Terminate_BIG_Command(big_handle=0, reason=0), check_status=False
|
||||||
|
)
|
||||||
|
assert response == HCI_ErrorCode.UNKNOWN_HCI_COMMAND_ERROR
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -179,11 +180,55 @@ async def test_default_namespace(temporary_file):
|
|||||||
assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
|
assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_filename(tmp_path):
|
||||||
|
import platformdirs
|
||||||
|
|
||||||
|
with mock.patch.object(platformdirs, 'user_data_path', return_value=tmp_path):
|
||||||
|
# Case 1: no namespace, no filename
|
||||||
|
keystore = JsonKeyStore(None, None)
|
||||||
|
expected_directory = tmp_path / 'Pairing'
|
||||||
|
expected_filename = expected_directory / 'keys.json'
|
||||||
|
assert keystore.directory_name == expected_directory
|
||||||
|
assert keystore.filename == expected_filename
|
||||||
|
|
||||||
|
# Save some data
|
||||||
|
keys = PairingKeys()
|
||||||
|
ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
|
||||||
|
keys.ltk = PairingKeys.Key(ltk)
|
||||||
|
await keystore.update('foo', keys)
|
||||||
|
assert expected_filename.exists()
|
||||||
|
|
||||||
|
# Load back
|
||||||
|
keystore2 = JsonKeyStore(None, None)
|
||||||
|
foo = await keystore2.get('foo')
|
||||||
|
assert foo is not None
|
||||||
|
assert foo.ltk.value == ltk
|
||||||
|
|
||||||
|
# Case 2: namespace, no filename
|
||||||
|
keystore3 = JsonKeyStore('my:namespace', None)
|
||||||
|
# safe_name = 'my-namespace' (lower is already 'my:namespace', then replace ':' with '-')
|
||||||
|
expected_filename3 = expected_directory / 'my-namespace.json'
|
||||||
|
assert keystore3.filename == expected_filename3
|
||||||
|
|
||||||
|
# Save some data
|
||||||
|
await keystore3.update('bar', keys)
|
||||||
|
assert expected_filename3.exists()
|
||||||
|
|
||||||
|
# Load back
|
||||||
|
keystore4 = JsonKeyStore('my:namespace', None)
|
||||||
|
bar = await keystore4.get('bar')
|
||||||
|
assert bar is not None
|
||||||
|
assert bar.ltk.value == ltk
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def run_tests():
|
async def run_tests():
|
||||||
await test_basic()
|
await test_basic()
|
||||||
await test_parsing()
|
await test_parsing()
|
||||||
await test_default_namespace()
|
await test_default_namespace()
|
||||||
|
await test_no_filename()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -18,9 +18,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from bumble import sdp
|
||||||
from bumble.core import BT_L2CAP_PROTOCOL_ID, UUID
|
from bumble.core import BT_L2CAP_PROTOCOL_ID, UUID
|
||||||
from bumble.sdp import (
|
from bumble.sdp import (
|
||||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
@@ -206,6 +208,16 @@ def sdp_records(record_count=1):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_pdu_parameter_length(caplog) -> None:
|
||||||
|
caplog.set_level(logging.WARNING)
|
||||||
|
pdu = sdp.SDP_ErrorResponse(
|
||||||
|
transaction_id=0, error_code=sdp.ErrorCode.INVALID_SDP_VERSION
|
||||||
|
)
|
||||||
|
assert sdp.SDP_PDU.from_bytes(bytes(pdu)) == pdu
|
||||||
|
assert not re.search("Expect \d+ bytes, got \d+", caplog.text)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_service_search():
|
async def test_service_search():
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ from bumble.gatt import Characteristic, Service
|
|||||||
from bumble.hci import Role
|
from bumble.hci import Role
|
||||||
from bumble.pairing import PairingConfig, PairingDelegate
|
from bumble.pairing import PairingConfig, PairingDelegate
|
||||||
from bumble.smp import (
|
from bumble.smp import (
|
||||||
SMP_CONFIRM_VALUE_FAILED_ERROR,
|
ErrorCode,
|
||||||
SMP_PAIRING_NOT_SUPPORTED_ERROR,
|
|
||||||
OobContext,
|
OobContext,
|
||||||
OobLegacyContext,
|
OobLegacyContext,
|
||||||
)
|
)
|
||||||
@@ -378,7 +377,7 @@ async def test_self_smp_reject():
|
|||||||
await _test_self_smp_with_configs(None, rejecting_pairing_config)
|
await _test_self_smp_with_configs(None, rejecting_pairing_config)
|
||||||
paired = True
|
paired = True
|
||||||
except ProtocolError as error:
|
except ProtocolError as error:
|
||||||
assert error.error_code == SMP_PAIRING_NOT_SUPPORTED_ERROR
|
assert error.error_code == ErrorCode.PAIRING_NOT_SUPPORTED
|
||||||
|
|
||||||
assert not paired
|
assert not paired
|
||||||
|
|
||||||
@@ -403,7 +402,7 @@ async def test_self_smp_wrong_pin():
|
|||||||
)
|
)
|
||||||
paired = True
|
paired = True
|
||||||
except ProtocolError as error:
|
except ProtocolError as error:
|
||||||
assert error.error_code == SMP_CONFIRM_VALUE_FAILED_ERROR
|
assert error.error_code == ErrorCode.CONFIRM_VALUE_FAILED
|
||||||
|
|
||||||
assert not paired
|
assert not paired
|
||||||
|
|
||||||
@@ -534,11 +533,11 @@ async def test_self_smp_oob_sc():
|
|||||||
|
|
||||||
with pytest.raises(ProtocolError) as error:
|
with pytest.raises(ProtocolError) as error:
|
||||||
await _test_self_smp_with_configs(pairing_config_1, pairing_config_4)
|
await _test_self_smp_with_configs(pairing_config_1, pairing_config_4)
|
||||||
assert error.value.error_code == SMP_CONFIRM_VALUE_FAILED_ERROR
|
assert error.value.error_code == ErrorCode.CONFIRM_VALUE_FAILED
|
||||||
|
|
||||||
with pytest.raises(ProtocolError):
|
with pytest.raises(ProtocolError):
|
||||||
await _test_self_smp_with_configs(pairing_config_4, pairing_config_1)
|
await _test_self_smp_with_configs(pairing_config_4, pairing_config_1)
|
||||||
assert error.value.error_code == SMP_CONFIRM_VALUE_FAILED_ERROR
|
assert error.value.error_code == ErrorCode.CONFIRM_VALUE_FAILED
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import pytest
|
|||||||
from bumble import crypto, pairing, smp
|
from bumble import crypto, pairing, smp
|
||||||
from bumble.core import AdvertisingData
|
from bumble.core import AdvertisingData
|
||||||
from bumble.crypto import EccKey, aes_cmac, ah, c1, f4, f5, f6, g2, h6, h7, s1
|
from bumble.crypto import EccKey, aes_cmac, ah, c1, f4, f5, f6, g2, h6, h7, s1
|
||||||
from bumble.device import Device
|
from bumble.device import Device, DeviceConfiguration
|
||||||
from bumble.hci import Address
|
from bumble.hci import Address
|
||||||
from bumble.pairing import LeRole, OobData, OobSharedData
|
from bumble.pairing import LeRole, OobData, OobSharedData
|
||||||
|
|
||||||
@@ -312,3 +312,17 @@ async def test_send_identity_address_command(
|
|||||||
actual_command = mock_method.call_args.args[0]
|
actual_command = mock_method.call_args.args[0]
|
||||||
assert actual_command.addr_type == expected_identity_address.address_type
|
assert actual_command.addr_type == expected_identity_address.address_type
|
||||||
assert actual_command.bd_addr == expected_identity_address
|
assert actual_command.bd_addr == expected_identity_address
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_smp_debug_mode():
|
||||||
|
config = DeviceConfiguration(smp_debug_mode=True)
|
||||||
|
device = Device(config=config)
|
||||||
|
|
||||||
|
assert device.smp_manager.ecc_key.x == smp.SMP_DEBUG_KEY_PUBLIC_X
|
||||||
|
assert device.smp_manager.ecc_key.y == smp.SMP_DEBUG_KEY_PUBLIC_Y
|
||||||
|
|
||||||
|
device.smp_manager.debug_mode = False
|
||||||
|
|
||||||
|
assert not device.smp_manager.ecc_key.x == smp.SMP_DEBUG_KEY_PUBLIC_X
|
||||||
|
assert not device.smp_manager.ecc_key.y == smp.SMP_DEBUG_KEY_PUBLIC_Y
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
|
||||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
|
<script src="https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js"></script>
|
||||||
<script type="module" src="../ui.js"></script>
|
<script type="module" src="../ui.js"></script>
|
||||||
<script type="module" src="heart_rate_monitor.js"></script>
|
<script type="module" src="heart_rate_monitor.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class HeartRateMonitor:
|
|||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
# TODO: replace this once a proper reset is implemented in the lib.
|
# TODO: replace this once a proper reset is implemented in the lib.
|
||||||
await self.device.host.send_command(HCI_Reset_Command())
|
await self.device.host.send_sync_command(HCI_Reset_Command())
|
||||||
await self.device.power_off()
|
await self.device.power_off()
|
||||||
print('### Monitor stopped')
|
print('### Monitor stopped')
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
<link rel="stylesheet" href="scanner.css">
|
<link rel="stylesheet" href="scanner.css">
|
||||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
|
<script src="https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js"></script>
|
||||||
<script type="module" src="../ui.js"></script>
|
<script type="module" src="../ui.js"></script>
|
||||||
<script type="module" src="scanner.js"></script>
|
<script type="module" src="scanner.js"></script>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class Scanner(utils.EventEmitter):
|
|||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
# TODO: replace this once a proper reset is implemented in the lib.
|
# TODO: replace this once a proper reset is implemented in the lib.
|
||||||
await self.device.host.send_command(HCI_Reset_Command())
|
await self.device.host.send_sync_command(HCI_Reset_Command())
|
||||||
await self.device.power_off()
|
await self.device.power_off()
|
||||||
print('### Scanner stopped')
|
print('### Scanner stopped')
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<title>Bumble Speaker</title>
|
<title>Bumble Speaker</title>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||||
<link rel="stylesheet" href="speaker.css">
|
<link rel="stylesheet" href="speaker.css">
|
||||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js"></script>
|
<script src="https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js"></script>
|
||||||
<script type="module" src="speaker.js"></script>
|
<script type="module" src="speaker.js"></script>
|
||||||
<script type="module" src="../ui.js"></script>
|
<script type="module" src="../ui.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ class Speaker:
|
|||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
# TODO: replace this once a proper reset is implemented in the lib.
|
# TODO: replace this once a proper reset is implemented in the lib.
|
||||||
await self.device.host.send_command(HCI_Reset_Command())
|
await self.device.host.send_sync_command(HCI_Reset_Command())
|
||||||
await self.device.power_off()
|
await self.device.power_off()
|
||||||
print('Speaker stopped')
|
print('Speaker stopped')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user