forked from auracaster/bumble_mirror
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdad225033 | |||
| 8eeb58e467 | |||
| 91971433d2 | |||
| a0a4bd457f | |||
| 4ffc050eed | |||
| 60678419a0 | |||
| 648dcc9305 | |||
| 190529184e | |||
| 46eb81466d | |||
| 9c70c487b9 | |||
| 43234d7c3e | |||
| dbf878dc3f | |||
| f6c0bd88d7 | |||
| 8440b7fbf1 | |||
| 808ab54135 | |||
| 52b29ad680 | |||
| d41bf9c587 | |||
| b758825164 | |||
| 779dfe5473 | |||
| afb21220e2 |
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10"]
|
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -41,3 +41,30 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
inv build
|
inv build
|
||||||
inv build.mkdocs
|
inv build.mkdocs
|
||||||
|
build-rust:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: [ "3.8", "3.9", "3.10" ]
|
||||||
|
fail-fast: false
|
||||||
|
steps:
|
||||||
|
- name: Check out from Git
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install ".[build,test,development,documentation]"
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
components: clippy,rustfmt
|
||||||
|
- name: Rust Lints
|
||||||
|
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings
|
||||||
|
- name: Rust Build
|
||||||
|
run: cd rust && cargo build --all-targets
|
||||||
|
- name: Rust Tests
|
||||||
|
run: cd rust && cargo test
|
||||||
@@ -9,3 +9,4 @@ __pycache__
|
|||||||
# generated by setuptools_scm
|
# generated by setuptools_scm
|
||||||
bumble/_version.py
|
bumble/_version.py
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
|
/.idea
|
||||||
|
|||||||
+73
-55
@@ -654,7 +654,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_create_connection_command(self, command):
|
def on_hci_create_connection_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command
|
See Bluetooth spec Vol 4, Part E - 7.1.5 Create Connection command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if self.link is None:
|
if self.link is None:
|
||||||
@@ -685,7 +685,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_disconnect_command(self, command):
|
def on_hci_disconnect_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.1.6 Disconnect Command
|
See Bluetooth spec Vol 4, Part E - 7.1.6 Disconnect Command
|
||||||
'''
|
'''
|
||||||
# First, say that the disconnection is pending
|
# First, say that the disconnection is pending
|
||||||
self.send_hci_packet(
|
self.send_hci_packet(
|
||||||
@@ -719,7 +719,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_accept_connection_request_command(self, command):
|
def on_hci_accept_connection_request_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.1.8 Accept Connection Request command
|
See Bluetooth spec Vol 4, Part E - 7.1.8 Accept Connection Request command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if self.link is None:
|
if self.link is None:
|
||||||
@@ -735,7 +735,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_switch_role_command(self, command):
|
def on_hci_switch_role_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.2.8 Switch Role command
|
See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if self.link is None:
|
if self.link is None:
|
||||||
@@ -751,21 +751,21 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_set_event_mask_command(self, command):
|
def on_hci_set_event_mask_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.1 Set Event Mask Command
|
See Bluetooth spec Vol 4, Part E - 7.3.1 Set Event Mask Command
|
||||||
'''
|
'''
|
||||||
self.event_mask = command.event_mask
|
self.event_mask = command.event_mask
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_reset_command(self, _command):
|
def on_hci_reset_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.2 Reset Command
|
See Bluetooth spec Vol 4, Part E - 7.3.2 Reset Command
|
||||||
'''
|
'''
|
||||||
# TODO: cleanup what needs to be reset
|
# TODO: cleanup what needs to be reset
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_write_local_name_command(self, command):
|
def on_hci_write_local_name_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.11 Write Local Name Command
|
See Bluetooth spec Vol 4, Part E - 7.3.11 Write Local Name Command
|
||||||
'''
|
'''
|
||||||
local_name = command.local_name
|
local_name = command.local_name
|
||||||
if len(local_name):
|
if len(local_name):
|
||||||
@@ -780,7 +780,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_read_local_name_command(self, _command):
|
def on_hci_read_local_name_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.12 Read Local Name Command
|
See Bluetooth spec Vol 4, Part E - 7.3.12 Read Local Name Command
|
||||||
'''
|
'''
|
||||||
local_name = bytes(self.local_name, 'utf-8')[:248]
|
local_name = bytes(self.local_name, 'utf-8')[:248]
|
||||||
if len(local_name) < 248:
|
if len(local_name) < 248:
|
||||||
@@ -790,19 +790,19 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_read_class_of_device_command(self, _command):
|
def on_hci_read_class_of_device_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.25 Read Class of Device Command
|
See Bluetooth spec Vol 4, Part E - 7.3.25 Read Class of Device Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS, 0, 0, 0])
|
return bytes([HCI_SUCCESS, 0, 0, 0])
|
||||||
|
|
||||||
def on_hci_write_class_of_device_command(self, _command):
|
def on_hci_write_class_of_device_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.26 Write Class of Device Command
|
See Bluetooth spec Vol 4, Part E - 7.3.26 Write Class of Device Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_read_synchronous_flow_control_enable_command(self, _command):
|
def on_hci_read_synchronous_flow_control_enable_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.36 Read Synchronous Flow Control Enable
|
See Bluetooth spec Vol 4, Part E - 7.3.36 Read Synchronous Flow Control Enable
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
if self.sync_flow_control:
|
if self.sync_flow_control:
|
||||||
@@ -813,7 +813,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_write_synchronous_flow_control_enable_command(self, command):
|
def on_hci_write_synchronous_flow_control_enable_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.37 Write Synchronous Flow Control Enable
|
See Bluetooth spec Vol 4, Part E - 7.3.37 Write Synchronous Flow Control Enable
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
ret = HCI_SUCCESS
|
ret = HCI_SUCCESS
|
||||||
@@ -825,41 +825,59 @@ class Controller:
|
|||||||
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
|
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
|
||||||
return bytes([ret])
|
return bytes([ret])
|
||||||
|
|
||||||
|
def on_hci_set_controller_to_host_flow_control_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.3.38 Set Controller To Host Flow Control
|
||||||
|
Command
|
||||||
|
'''
|
||||||
|
# For now we just accept the command but ignore the values.
|
||||||
|
# TODO: respect the passed in values.
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
|
def on_hci_host_buffer_size_command(self, _command):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 4, Part E - 7.3.39 Host Buffer Size Command
|
||||||
|
'''
|
||||||
|
# For now we just accept the command but ignore the values.
|
||||||
|
# TODO: respect the passed in values.
|
||||||
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_write_extended_inquiry_response_command(self, _command):
|
def on_hci_write_extended_inquiry_response_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
|
See Bluetooth spec Vol 4, Part E - 7.3.56 Write Extended Inquiry Response
|
||||||
|
Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_write_simple_pairing_mode_command(self, _command):
|
def on_hci_write_simple_pairing_mode_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
|
See Bluetooth spec Vol 4, Part E - 7.3.59 Write Simple Pairing Mode Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_set_event_mask_page_2_command(self, command):
|
def on_hci_set_event_mask_page_2_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.69 Set Event Mask Page 2 Command
|
See Bluetooth spec Vol 4, Part E - 7.3.69 Set Event Mask Page 2 Command
|
||||||
'''
|
'''
|
||||||
self.event_mask_page_2 = command.event_mask_page_2
|
self.event_mask_page_2 = command.event_mask_page_2
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_read_le_host_support_command(self, _command):
|
def on_hci_read_le_host_support_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.78 Write LE Host Support Command
|
See Bluetooth spec Vol 4, Part E - 7.3.78 Write LE Host Support Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS, 1, 0])
|
return bytes([HCI_SUCCESS, 1, 0])
|
||||||
|
|
||||||
def on_hci_write_le_host_support_command(self, _command):
|
def on_hci_write_le_host_support_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.79 Write LE Host Support Command
|
See Bluetooth spec Vol 4, Part E - 7.3.79 Write LE Host Support Command
|
||||||
'''
|
'''
|
||||||
# TODO / Just ignore for now
|
# TODO / Just ignore for now
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_write_authenticated_payload_timeout_command(self, command):
|
def on_hci_write_authenticated_payload_timeout_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.3.94 Write Authenticated Payload Timeout
|
See Bluetooth spec Vol 4, Part E - 7.3.94 Write Authenticated Payload Timeout
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
# TODO
|
# TODO
|
||||||
@@ -867,7 +885,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_read_local_version_information_command(self, _command):
|
def on_hci_read_local_version_information_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.4.1 Read Local Version Information Command
|
See Bluetooth spec Vol 4, Part E - 7.4.1 Read Local Version Information Command
|
||||||
'''
|
'''
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
'<BBHBHH',
|
'<BBHBHH',
|
||||||
@@ -881,19 +899,19 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_read_local_supported_commands_command(self, _command):
|
def on_hci_read_local_supported_commands_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.4.2 Read Local Supported Commands Command
|
See Bluetooth spec Vol 4, Part E - 7.4.2 Read Local Supported Commands Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + self.supported_commands
|
return bytes([HCI_SUCCESS]) + self.supported_commands
|
||||||
|
|
||||||
def on_hci_read_local_supported_features_command(self, _command):
|
def on_hci_read_local_supported_features_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.4.3 Read Local Supported Features Command
|
See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + self.lmp_features
|
return bytes([HCI_SUCCESS]) + self.lmp_features
|
||||||
|
|
||||||
def on_hci_read_bd_addr_command(self, _command):
|
def on_hci_read_bd_addr_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.4.6 Read BD_ADDR Command
|
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
|
||||||
'''
|
'''
|
||||||
bd_addr = (
|
bd_addr = (
|
||||||
self._public_address.to_bytes()
|
self._public_address.to_bytes()
|
||||||
@@ -904,14 +922,14 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_set_event_mask_command(self, command):
|
def on_hci_le_set_event_mask_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.1 LE Set Event Mask Command
|
See Bluetooth spec Vol 4, Part E - 7.8.1 LE Set Event Mask Command
|
||||||
'''
|
'''
|
||||||
self.le_event_mask = command.le_event_mask
|
self.le_event_mask = command.le_event_mask
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_buffer_size_command(self, _command):
|
def on_hci_le_read_buffer_size_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.2 LE Read Buffer Size Command
|
See Bluetooth spec Vol 4, Part E - 7.8.2 LE Read Buffer Size Command
|
||||||
'''
|
'''
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
'<BHB',
|
'<BHB',
|
||||||
@@ -922,49 +940,49 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_local_supported_features_command(self, _command):
|
def on_hci_le_read_local_supported_features_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.3 LE Read Local Supported Features
|
See Bluetooth spec Vol 4, Part E - 7.8.3 LE Read Local Supported Features
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + self.le_features
|
return bytes([HCI_SUCCESS]) + self.le_features
|
||||||
|
|
||||||
def on_hci_le_set_random_address_command(self, command):
|
def on_hci_le_set_random_address_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.4 LE Set Random Address Command
|
See Bluetooth spec Vol 4, Part E - 7.8.4 LE Set Random Address Command
|
||||||
'''
|
'''
|
||||||
self.random_address = command.random_address
|
self.random_address = command.random_address
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_set_advertising_parameters_command(self, command):
|
def on_hci_le_set_advertising_parameters_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.5 LE Set Advertising Parameters Command
|
See Bluetooth spec Vol 4, Part E - 7.8.5 LE Set Advertising Parameters Command
|
||||||
'''
|
'''
|
||||||
self.advertising_parameters = command
|
self.advertising_parameters = command
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_advertising_physical_channel_tx_power_command(self, _command):
|
def on_hci_le_read_advertising_physical_channel_tx_power_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.6 LE Read Advertising Physical Channel
|
See Bluetooth spec Vol 4, Part E - 7.8.6 LE Read Advertising Physical Channel
|
||||||
Tx Power Command
|
Tx Power Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
|
return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
|
||||||
|
|
||||||
def on_hci_le_set_advertising_data_command(self, command):
|
def on_hci_le_set_advertising_data_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.7 LE Set Advertising Data Command
|
See Bluetooth spec Vol 4, Part E - 7.8.7 LE Set Advertising Data Command
|
||||||
'''
|
'''
|
||||||
self.advertising_data = command.advertising_data
|
self.advertising_data = command.advertising_data
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_set_scan_response_data_command(self, command):
|
def on_hci_le_set_scan_response_data_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.8 LE Set Scan Response Data Command
|
See Bluetooth spec Vol 4, Part E - 7.8.8 LE Set Scan Response Data Command
|
||||||
'''
|
'''
|
||||||
self.le_scan_response_data = command.scan_response_data
|
self.le_scan_response_data = command.scan_response_data
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_set_advertising_enable_command(self, command):
|
def on_hci_le_set_advertising_enable_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.9 LE Set Advertising Enable Command
|
See Bluetooth spec Vol 4, Part E - 7.8.9 LE Set Advertising Enable Command
|
||||||
'''
|
'''
|
||||||
if command.advertising_enable:
|
if command.advertising_enable:
|
||||||
self.start_advertising()
|
self.start_advertising()
|
||||||
@@ -975,7 +993,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_set_scan_parameters_command(self, command):
|
def on_hci_le_set_scan_parameters_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.10 LE Set Scan Parameters Command
|
See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command
|
||||||
'''
|
'''
|
||||||
self.le_scan_type = command.le_scan_type
|
self.le_scan_type = command.le_scan_type
|
||||||
self.le_scan_interval = command.le_scan_interval
|
self.le_scan_interval = command.le_scan_interval
|
||||||
@@ -986,7 +1004,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_set_scan_enable_command(self, command):
|
def on_hci_le_set_scan_enable_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.11 LE Set Scan Enable Command
|
See Bluetooth spec Vol 4, Part E - 7.8.11 LE Set Scan Enable Command
|
||||||
'''
|
'''
|
||||||
self.le_scan_enable = command.le_scan_enable
|
self.le_scan_enable = command.le_scan_enable
|
||||||
self.filter_duplicates = command.filter_duplicates
|
self.filter_duplicates = command.filter_duplicates
|
||||||
@@ -994,7 +1012,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_create_connection_command(self, command):
|
def on_hci_le_create_connection_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.12 LE Create Connection Command
|
See Bluetooth spec Vol 4, Part E - 7.8.12 LE Create Connection Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if not self.link:
|
if not self.link:
|
||||||
@@ -1027,40 +1045,40 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_create_connection_cancel_command(self, _command):
|
def on_hci_le_create_connection_cancel_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.13 LE Create Connection Cancel Command
|
See Bluetooth spec Vol 4, Part E - 7.8.13 LE Create Connection Cancel Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_filter_accept_list_size_command(self, _command):
|
def on_hci_le_read_filter_accept_list_size_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.14 LE Read Filter Accept List Size
|
See Bluetooth spec Vol 4, Part E - 7.8.14 LE Read Filter Accept List Size
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS, self.filter_accept_list_size])
|
return bytes([HCI_SUCCESS, self.filter_accept_list_size])
|
||||||
|
|
||||||
def on_hci_le_clear_filter_accept_list_command(self, _command):
|
def on_hci_le_clear_filter_accept_list_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.15 LE Clear Filter Accept List Command
|
See Bluetooth spec Vol 4, Part E - 7.8.15 LE Clear Filter Accept List Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_add_device_to_filter_accept_list_command(self, _command):
|
def on_hci_le_add_device_to_filter_accept_list_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.16 LE Add Device To Filter Accept List
|
See Bluetooth spec Vol 4, Part E - 7.8.16 LE Add Device To Filter Accept List
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_remove_device_from_filter_accept_list_command(self, _command):
|
def on_hci_le_remove_device_from_filter_accept_list_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.17 LE Remove Device From Filter Accept
|
See Bluetooth spec Vol 4, Part E - 7.8.17 LE Remove Device From Filter Accept
|
||||||
List Command
|
List Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_remote_features_command(self, command):
|
def on_hci_le_read_remote_features_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.21 LE Read Remote Features Command
|
See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# First, say that the command is pending
|
# First, say that the command is pending
|
||||||
@@ -1083,13 +1101,13 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_rand_command(self, _command):
|
def on_hci_le_rand_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.23 LE Rand Command
|
See Bluetooth spec Vol 4, Part E - 7.8.23 LE Rand Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + struct.pack('Q', random.randint(0, 1 << 64))
|
return bytes([HCI_SUCCESS]) + struct.pack('Q', random.randint(0, 1 << 64))
|
||||||
|
|
||||||
def on_hci_le_enable_encryption_command(self, command):
|
def on_hci_le_enable_encryption_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.24 LE Enable Encryption Command
|
See Bluetooth spec Vol 4, Part E - 7.8.24 LE Enable Encryption Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# Check the parameters
|
# Check the parameters
|
||||||
@@ -1122,13 +1140,13 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_supported_states_command(self, _command):
|
def on_hci_le_read_supported_states_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.27 LE Read Supported States Command
|
See Bluetooth spec Vol 4, Part E - 7.8.27 LE Read Supported States Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS]) + self.le_states
|
return bytes([HCI_SUCCESS]) + self.le_states
|
||||||
|
|
||||||
def on_hci_le_read_suggested_default_data_length_command(self, _command):
|
def on_hci_le_read_suggested_default_data_length_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.34 LE Read Suggested Default Data Length
|
See Bluetooth spec Vol 4, Part E - 7.8.34 LE Read Suggested Default Data Length
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
@@ -1140,7 +1158,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_write_suggested_default_data_length_command(self, command):
|
def on_hci_le_write_suggested_default_data_length_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.35 LE Write Suggested Default Data Length
|
See Bluetooth spec Vol 4, Part E - 7.8.35 LE Write Suggested Default Data Length
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
self.suggested_max_tx_octets, self.suggested_max_tx_time = struct.unpack(
|
self.suggested_max_tx_octets, self.suggested_max_tx_time = struct.unpack(
|
||||||
@@ -1150,33 +1168,33 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_local_p_256_public_key_command(self, _command):
|
def on_hci_le_read_local_p_256_public_key_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.36 LE Read P-256 Public Key Command
|
See Bluetooth spec Vol 4, Part E - 7.8.36 LE Read P-256 Public Key Command
|
||||||
'''
|
'''
|
||||||
# TODO create key and send HCI_LE_Read_Local_P-256_Public_Key_Complete event
|
# TODO create key and send HCI_LE_Read_Local_P-256_Public_Key_Complete event
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_add_device_to_resolving_list_command(self, _command):
|
def on_hci_le_add_device_to_resolving_list_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.38 LE Add Device To Resolving List
|
See Bluetooth spec Vol 4, Part E - 7.8.38 LE Add Device To Resolving List
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_clear_resolving_list_command(self, _command):
|
def on_hci_le_clear_resolving_list_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.40 LE Clear Resolving List Command
|
See Bluetooth spec Vol 4, Part E - 7.8.40 LE Clear Resolving List Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS])
|
return bytes([HCI_SUCCESS])
|
||||||
|
|
||||||
def on_hci_le_read_resolving_list_size_command(self, _command):
|
def on_hci_le_read_resolving_list_size_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.41 LE Read Resolving List Size Command
|
See Bluetooth spec Vol 4, Part E - 7.8.41 LE Read Resolving List Size Command
|
||||||
'''
|
'''
|
||||||
return bytes([HCI_SUCCESS, self.resolving_list_size])
|
return bytes([HCI_SUCCESS, self.resolving_list_size])
|
||||||
|
|
||||||
def on_hci_le_set_address_resolution_enable_command(self, command):
|
def on_hci_le_set_address_resolution_enable_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.44 LE Set Address Resolution Enable
|
See Bluetooth spec Vol 4, Part E - 7.8.44 LE Set Address Resolution Enable
|
||||||
Command
|
Command
|
||||||
'''
|
'''
|
||||||
ret = HCI_SUCCESS
|
ret = HCI_SUCCESS
|
||||||
@@ -1190,7 +1208,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_set_resolvable_private_address_timeout_command(self, command):
|
def on_hci_le_set_resolvable_private_address_timeout_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.45 LE Set Resolvable Private Address
|
See Bluetooth spec Vol 4, Part E - 7.8.45 LE Set Resolvable Private Address
|
||||||
Timeout Command
|
Timeout Command
|
||||||
'''
|
'''
|
||||||
self.le_rpa_timeout = command.rpa_timeout
|
self.le_rpa_timeout = command.rpa_timeout
|
||||||
@@ -1198,7 +1216,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_maximum_data_length_command(self, _command):
|
def on_hci_le_read_maximum_data_length_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.46 LE Read Maximum Data Length Command
|
See Bluetooth spec Vol 4, Part E - 7.8.46 LE Read Maximum Data Length Command
|
||||||
'''
|
'''
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
'<BHHHH',
|
'<BHHHH',
|
||||||
@@ -1211,7 +1229,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_phy_command(self, command):
|
def on_hci_le_read_phy_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.47 LE Read PHY Command
|
See Bluetooth spec Vol 4, Part E - 7.8.47 LE Read PHY Command
|
||||||
'''
|
'''
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
'<BHBB',
|
'<BHBB',
|
||||||
@@ -1223,7 +1241,7 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_set_default_phy_command(self, command):
|
def on_hci_le_set_default_phy_command(self, command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.48 LE Set Default PHY Command
|
See Bluetooth spec Vol 4, Part E - 7.8.48 LE Set Default PHY Command
|
||||||
'''
|
'''
|
||||||
self.default_phy = {
|
self.default_phy = {
|
||||||
'all_phys': command.all_phys,
|
'all_phys': command.all_phys,
|
||||||
@@ -1234,6 +1252,6 @@ class Controller:
|
|||||||
|
|
||||||
def on_hci_le_read_transmit_power_command(self, _command):
|
def on_hci_le_read_transmit_power_command(self, _command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 2, Part E - 7.8.74 LE Read Transmit Power Command
|
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
|
||||||
'''
|
'''
|
||||||
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
|
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
|
||||||
|
|||||||
+74
-3
@@ -17,7 +17,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import struct
|
import struct
|
||||||
from typing import List, Optional, Tuple, Union, cast
|
from typing import List, Optional, Tuple, Union, cast, Dict
|
||||||
|
|
||||||
from .company_ids import COMPANY_IDENTIFIERS
|
from .company_ids import COMPANY_IDENTIFIERS
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ def bit_flags_to_strings(bits, bit_flag_names):
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def name_or_number(dictionary, number, width=2):
|
def name_or_number(dictionary: Dict[int, str], number: int, width: int = 2) -> str:
|
||||||
name = dictionary.get(number)
|
name = dictionary.get(number)
|
||||||
if name is not None:
|
if name is not None:
|
||||||
return name
|
return name
|
||||||
@@ -562,11 +562,82 @@ class DeviceClass:
|
|||||||
PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS: 'Handheld gestural input device'
|
PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS: 'Handheld gestural input device'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
|
||||||
|
WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS = 0x01
|
||||||
|
WEARABLE_PAGER_MINOR_DEVICE_CLASS = 0x02
|
||||||
|
WEARABLE_JACKET_MINOR_DEVICE_CLASS = 0x03
|
||||||
|
WEARABLE_HELMET_MINOR_DEVICE_CLASS = 0x04
|
||||||
|
WEARABLE_GLASSES_MINOR_DEVICE_CLASS = 0x05
|
||||||
|
|
||||||
|
WEARABLE_MINOR_DEVICE_CLASS_NAMES = {
|
||||||
|
WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
|
||||||
|
WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS: 'Wristwatch',
|
||||||
|
WEARABLE_PAGER_MINOR_DEVICE_CLASS: 'Pager',
|
||||||
|
WEARABLE_JACKET_MINOR_DEVICE_CLASS: 'Jacket',
|
||||||
|
WEARABLE_HELMET_MINOR_DEVICE_CLASS: 'Helmet',
|
||||||
|
WEARABLE_GLASSES_MINOR_DEVICE_CLASS: 'Glasses',
|
||||||
|
}
|
||||||
|
|
||||||
|
TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
|
||||||
|
TOY_ROBOT_MINOR_DEVICE_CLASS = 0x01
|
||||||
|
TOY_VEHICLE_MINOR_DEVICE_CLASS = 0x02
|
||||||
|
TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS = 0x03
|
||||||
|
TOY_CONTROLLER_MINOR_DEVICE_CLASS = 0x04
|
||||||
|
TOY_GAME_MINOR_DEVICE_CLASS = 0x05
|
||||||
|
|
||||||
|
TOY_MINOR_DEVICE_CLASS_NAMES = {
|
||||||
|
TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
|
||||||
|
TOY_ROBOT_MINOR_DEVICE_CLASS: 'Robot',
|
||||||
|
TOY_VEHICLE_MINOR_DEVICE_CLASS: 'Vehicle',
|
||||||
|
TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS: 'Doll/Action figure',
|
||||||
|
TOY_CONTROLLER_MINOR_DEVICE_CLASS: 'Controller',
|
||||||
|
TOY_GAME_MINOR_DEVICE_CLASS: 'Game',
|
||||||
|
}
|
||||||
|
|
||||||
|
HEALTH_UNDEFINED_MINOR_DEVICE_CLASS = 0x00
|
||||||
|
HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS = 0x01
|
||||||
|
HEALTH_THERMOMETER_MINOR_DEVICE_CLASS = 0x02
|
||||||
|
HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS = 0x03
|
||||||
|
HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS = 0x04
|
||||||
|
HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS = 0x05
|
||||||
|
HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS = 0x06
|
||||||
|
HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS = 0x07
|
||||||
|
HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS = 0x08
|
||||||
|
HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS = 0x09
|
||||||
|
HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS = 0x0A
|
||||||
|
HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS = 0x0B
|
||||||
|
HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0C
|
||||||
|
HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0D
|
||||||
|
HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS = 0x0E
|
||||||
|
HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS = 0x0F
|
||||||
|
|
||||||
|
HEALTH_MINOR_DEVICE_CLASS_NAMES = {
|
||||||
|
HEALTH_UNDEFINED_MINOR_DEVICE_CLASS: 'Undefined',
|
||||||
|
HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS: 'Blood Pressure Monitor',
|
||||||
|
HEALTH_THERMOMETER_MINOR_DEVICE_CLASS: 'Thermometer',
|
||||||
|
HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS: 'Weighing Scale',
|
||||||
|
HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS: 'Glucose Meter',
|
||||||
|
HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS: 'Pulse Oximeter',
|
||||||
|
HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS: 'Heart/Pulse Rate Monitor',
|
||||||
|
HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS: 'Health Data Display',
|
||||||
|
HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS: 'Step Counter',
|
||||||
|
HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS: 'Body Composition Analyzer',
|
||||||
|
HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS: 'Peak Flow Monitor',
|
||||||
|
HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS: 'Medication Monitor',
|
||||||
|
HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Knee Prosthesis',
|
||||||
|
HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Ankle Prosthesis',
|
||||||
|
HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS: 'Generic Health Manager',
|
||||||
|
HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS: 'Personal Mobility Device',
|
||||||
|
}
|
||||||
|
|
||||||
MINOR_DEVICE_CLASS_NAMES = {
|
MINOR_DEVICE_CLASS_NAMES = {
|
||||||
COMPUTER_MAJOR_DEVICE_CLASS: COMPUTER_MINOR_DEVICE_CLASS_NAMES,
|
COMPUTER_MAJOR_DEVICE_CLASS: COMPUTER_MINOR_DEVICE_CLASS_NAMES,
|
||||||
PHONE_MAJOR_DEVICE_CLASS: PHONE_MINOR_DEVICE_CLASS_NAMES,
|
PHONE_MAJOR_DEVICE_CLASS: PHONE_MINOR_DEVICE_CLASS_NAMES,
|
||||||
AUDIO_VIDEO_MAJOR_DEVICE_CLASS: AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES,
|
AUDIO_VIDEO_MAJOR_DEVICE_CLASS: AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES,
|
||||||
PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES
|
PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES,
|
||||||
|
WEARABLE_MAJOR_DEVICE_CLASS: WEARABLE_MINOR_DEVICE_CLASS_NAMES,
|
||||||
|
TOY_MAJOR_DEVICE_CLASS: TOY_MINOR_DEVICE_CLASS_NAMES,
|
||||||
|
HEALTH_MAJOR_DEVICE_CLASS: HEALTH_MINOR_DEVICE_CLASS_NAMES,
|
||||||
}
|
}
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|||||||
+23
-22
@@ -283,8 +283,7 @@ class IncludedServiceDeclaration(Attribute):
|
|||||||
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
|
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
|
||||||
f'group_starting_handle=0x{self.service.handle:04X}, '
|
f'group_starting_handle=0x{self.service.handle:04X}, '
|
||||||
f'group_ending_handle=0x{self.service.end_group_handle:04X}, '
|
f'group_ending_handle=0x{self.service.end_group_handle:04X}, '
|
||||||
f'uuid={self.service.uuid}, '
|
f'uuid={self.service.uuid})'
|
||||||
f'{self.service.properties!s})'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -309,31 +308,33 @@ class Characteristic(Attribute):
|
|||||||
AUTHENTICATED_SIGNED_WRITES = 0x40
|
AUTHENTICATED_SIGNED_WRITES = 0x40
|
||||||
EXTENDED_PROPERTIES = 0x80
|
EXTENDED_PROPERTIES = 0x80
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def from_string(properties_str: str) -> Characteristic.Properties:
|
def from_string(cls, properties_str: str) -> Characteristic.Properties:
|
||||||
property_names: List[str] = []
|
|
||||||
for property in Characteristic.Properties:
|
|
||||||
if property.name is None:
|
|
||||||
raise TypeError()
|
|
||||||
property_names.append(property.name)
|
|
||||||
|
|
||||||
def string_to_property(property_string) -> Characteristic.Properties:
|
|
||||||
for property in zip(Characteristic.Properties, property_names):
|
|
||||||
if property_string == property[1]:
|
|
||||||
return property[0]
|
|
||||||
raise TypeError(f"Unable to convert {property_string} to Property")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return functools.reduce(
|
return functools.reduce(
|
||||||
lambda x, y: x | string_to_property(y),
|
lambda x, y: x | cls[y],
|
||||||
properties_str.split(","),
|
properties_str.replace("|", ",").split(","),
|
||||||
Characteristic.Properties(0),
|
Characteristic.Properties(0),
|
||||||
)
|
)
|
||||||
except TypeError:
|
except (TypeError, KeyError):
|
||||||
|
# The check for `p.name is not None` here is needed because for InFlag
|
||||||
|
# enums, the .name property can be None, when the enum value is 0,
|
||||||
|
# so the type hint for .name is Optional[str].
|
||||||
|
enum_list: List[str] = [p.name for p in cls if p.name is not None]
|
||||||
|
enum_list_str = ",".join(enum_list)
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by commas: {','.join(property_names)}\nGot: {properties_str}"
|
f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by , or |: {enum_list_str}\nGot: {properties_str}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
# NOTE: we override this method to offer a consistent result between python
|
||||||
|
# versions: the value returned by IntFlag.__str__() changed in version 11.
|
||||||
|
return '|'.join(
|
||||||
|
flag.name
|
||||||
|
for flag in Characteristic.Properties
|
||||||
|
if self.value & flag.value and flag.name is not None
|
||||||
|
)
|
||||||
|
|
||||||
# For backwards compatibility these are defined here
|
# For backwards compatibility these are defined here
|
||||||
# For new code, please use Characteristic.Properties.X
|
# For new code, please use Characteristic.Properties.X
|
||||||
BROADCAST = Properties.BROADCAST
|
BROADCAST = Properties.BROADCAST
|
||||||
@@ -373,7 +374,7 @@ class Characteristic(Attribute):
|
|||||||
f'Characteristic(handle=0x{self.handle:04X}, '
|
f'Characteristic(handle=0x{self.handle:04X}, '
|
||||||
f'end=0x{self.end_group_handle:04X}, '
|
f'end=0x{self.end_group_handle:04X}, '
|
||||||
f'uuid={self.uuid}, '
|
f'uuid={self.uuid}, '
|
||||||
f'{self.properties!s})'
|
f'{self.properties})'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -401,7 +402,7 @@ class CharacteristicDeclaration(Attribute):
|
|||||||
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
|
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
|
||||||
f'value_handle=0x{self.value_handle:04X}, '
|
f'value_handle=0x{self.value_handle:04X}, '
|
||||||
f'uuid={self.characteristic.uuid}, '
|
f'uuid={self.characteristic.uuid}, '
|
||||||
f'{self.characteristic.properties!s})'
|
f'{self.characteristic.properties})'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+230
-221
@@ -1445,8 +1445,14 @@ class HCI_Object:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def init_from_fields(hci_object, fields, values):
|
def init_from_fields(hci_object, fields, values):
|
||||||
if isinstance(values, dict):
|
if isinstance(values, dict):
|
||||||
for field_name, _ in fields:
|
for field in fields:
|
||||||
setattr(hci_object, field_name, values[field_name])
|
if isinstance(field, list):
|
||||||
|
# The field is an array, up-level the array field names
|
||||||
|
for sub_field_name, _ in field:
|
||||||
|
setattr(hci_object, sub_field_name, values[sub_field_name])
|
||||||
|
else:
|
||||||
|
field_name = field[0]
|
||||||
|
setattr(hci_object, field_name, values[field_name])
|
||||||
else:
|
else:
|
||||||
for field_name, field_value in zip(fields, values):
|
for field_name, field_value in zip(fields, values):
|
||||||
setattr(hci_object, field_name, field_value)
|
setattr(hci_object, field_name, field_value)
|
||||||
@@ -1456,133 +1462,161 @@ class HCI_Object:
|
|||||||
parsed = HCI_Object.dict_from_bytes(data, offset, fields)
|
parsed = HCI_Object.dict_from_bytes(data, offset, fields)
|
||||||
HCI_Object.init_from_fields(hci_object, parsed.keys(), parsed.values())
|
HCI_Object.init_from_fields(hci_object, parsed.keys(), parsed.values())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_field(data, offset, field_type):
|
||||||
|
# The field_type may be a dictionary with a mapper, parser, and/or size
|
||||||
|
if isinstance(field_type, dict):
|
||||||
|
if 'size' in field_type:
|
||||||
|
field_type = field_type['size']
|
||||||
|
elif 'parser' in field_type:
|
||||||
|
field_type = field_type['parser']
|
||||||
|
|
||||||
|
# Parse the field
|
||||||
|
if field_type == '*':
|
||||||
|
# The rest of the bytes
|
||||||
|
field_value = data[offset:]
|
||||||
|
return (field_value, len(field_value))
|
||||||
|
if field_type == 1:
|
||||||
|
# 8-bit unsigned
|
||||||
|
return (data[offset], 1)
|
||||||
|
if field_type == -1:
|
||||||
|
# 8-bit signed
|
||||||
|
return (struct.unpack_from('b', data, offset)[0], 1)
|
||||||
|
if field_type == 2:
|
||||||
|
# 16-bit unsigned
|
||||||
|
return (struct.unpack_from('<H', data, offset)[0], 2)
|
||||||
|
if field_type == '>2':
|
||||||
|
# 16-bit unsigned big-endian
|
||||||
|
return (struct.unpack_from('>H', data, offset)[0], 2)
|
||||||
|
if field_type == -2:
|
||||||
|
# 16-bit signed
|
||||||
|
return (struct.unpack_from('<h', data, offset)[0], 2)
|
||||||
|
if field_type == 3:
|
||||||
|
# 24-bit unsigned
|
||||||
|
padded = data[offset : offset + 3] + bytes([0])
|
||||||
|
return (struct.unpack('<I', padded)[0], 3)
|
||||||
|
if field_type == 4:
|
||||||
|
# 32-bit unsigned
|
||||||
|
return (struct.unpack_from('<I', data, offset)[0], 4)
|
||||||
|
if field_type == '>4':
|
||||||
|
# 32-bit unsigned big-endian
|
||||||
|
return (struct.unpack_from('>I', data, offset)[0], 4)
|
||||||
|
if isinstance(field_type, int) and 4 < field_type <= 256:
|
||||||
|
# Byte array (from 5 up to 256 bytes)
|
||||||
|
return (data[offset : offset + field_type], field_type)
|
||||||
|
if callable(field_type):
|
||||||
|
new_offset, field_value = field_type(data, offset)
|
||||||
|
return (field_value, new_offset - offset)
|
||||||
|
|
||||||
|
raise ValueError(f'unknown field type {field_type}')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def dict_from_bytes(data, offset, fields):
|
def dict_from_bytes(data, offset, fields):
|
||||||
result = collections.OrderedDict()
|
result = collections.OrderedDict()
|
||||||
for (field_name, field_type) in fields:
|
for field in fields:
|
||||||
# The field_type may be a dictionary with a mapper, parser, and/or size
|
if isinstance(field, list):
|
||||||
if isinstance(field_type, dict):
|
# This is an array field, starting with a 1-byte item count.
|
||||||
if 'size' in field_type:
|
item_count = data[offset]
|
||||||
field_type = field_type['size']
|
|
||||||
elif 'parser' in field_type:
|
|
||||||
field_type = field_type['parser']
|
|
||||||
|
|
||||||
# Parse the field
|
|
||||||
if field_type == '*':
|
|
||||||
# The rest of the bytes
|
|
||||||
field_value = data[offset:]
|
|
||||||
offset += len(field_value)
|
|
||||||
elif field_type == 1:
|
|
||||||
# 8-bit unsigned
|
|
||||||
field_value = data[offset]
|
|
||||||
offset += 1
|
offset += 1
|
||||||
elif field_type == -1:
|
for _ in range(item_count):
|
||||||
# 8-bit signed
|
for sub_field_name, sub_field_type in field:
|
||||||
field_value = struct.unpack_from('b', data, offset)[0]
|
value, size = HCI_Object.parse_field(
|
||||||
offset += 1
|
data, offset, sub_field_type
|
||||||
elif field_type == 2:
|
)
|
||||||
# 16-bit unsigned
|
result.setdefault(sub_field_name, []).append(value)
|
||||||
field_value = struct.unpack_from('<H', data, offset)[0]
|
offset += size
|
||||||
offset += 2
|
continue
|
||||||
elif field_type == '>2':
|
|
||||||
# 16-bit unsigned big-endian
|
|
||||||
field_value = struct.unpack_from('>H', data, offset)[0]
|
|
||||||
offset += 2
|
|
||||||
elif field_type == -2:
|
|
||||||
# 16-bit signed
|
|
||||||
field_value = struct.unpack_from('<h', data, offset)[0]
|
|
||||||
offset += 2
|
|
||||||
elif field_type == 3:
|
|
||||||
# 24-bit unsigned
|
|
||||||
padded = data[offset : offset + 3] + bytes([0])
|
|
||||||
field_value = struct.unpack('<I', padded)[0]
|
|
||||||
offset += 3
|
|
||||||
elif field_type == 4:
|
|
||||||
# 32-bit unsigned
|
|
||||||
field_value = struct.unpack_from('<I', data, offset)[0]
|
|
||||||
offset += 4
|
|
||||||
elif field_type == '>4':
|
|
||||||
# 32-bit unsigned big-endian
|
|
||||||
field_value = struct.unpack_from('>I', data, offset)[0]
|
|
||||||
offset += 4
|
|
||||||
elif isinstance(field_type, int) and 4 < field_type <= 256:
|
|
||||||
# Byte array (from 5 up to 256 bytes)
|
|
||||||
field_value = data[offset : offset + field_type]
|
|
||||||
offset += field_type
|
|
||||||
elif callable(field_type):
|
|
||||||
offset, field_value = field_type(data, offset)
|
|
||||||
else:
|
|
||||||
raise ValueError(f'unknown field type {field_type}')
|
|
||||||
|
|
||||||
|
field_name, field_type = field
|
||||||
|
field_value, field_size = HCI_Object.parse_field(data, offset, field_type)
|
||||||
result[field_name] = field_value
|
result[field_name] = field_value
|
||||||
|
offset += field_size
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def serialize_field(field_value, field_type):
|
||||||
|
# The field_type may be a dictionary with a mapper, parser, serializer,
|
||||||
|
# and/or size
|
||||||
|
serializer = None
|
||||||
|
if isinstance(field_type, dict):
|
||||||
|
if 'serializer' in field_type:
|
||||||
|
serializer = field_type['serializer']
|
||||||
|
if 'size' in field_type:
|
||||||
|
field_type = field_type['size']
|
||||||
|
|
||||||
|
# Serialize the field
|
||||||
|
if serializer:
|
||||||
|
field_bytes = serializer(field_value)
|
||||||
|
elif field_type == 1:
|
||||||
|
# 8-bit unsigned
|
||||||
|
field_bytes = bytes([field_value])
|
||||||
|
elif field_type == -1:
|
||||||
|
# 8-bit signed
|
||||||
|
field_bytes = struct.pack('b', field_value)
|
||||||
|
elif field_type == 2:
|
||||||
|
# 16-bit unsigned
|
||||||
|
field_bytes = struct.pack('<H', field_value)
|
||||||
|
elif field_type == '>2':
|
||||||
|
# 16-bit unsigned big-endian
|
||||||
|
field_bytes = struct.pack('>H', field_value)
|
||||||
|
elif field_type == -2:
|
||||||
|
# 16-bit signed
|
||||||
|
field_bytes = struct.pack('<h', field_value)
|
||||||
|
elif field_type == 3:
|
||||||
|
# 24-bit unsigned
|
||||||
|
field_bytes = struct.pack('<I', field_value)[0:3]
|
||||||
|
elif field_type == 4:
|
||||||
|
# 32-bit unsigned
|
||||||
|
field_bytes = struct.pack('<I', field_value)
|
||||||
|
elif field_type == '>4':
|
||||||
|
# 32-bit unsigned big-endian
|
||||||
|
field_bytes = struct.pack('>I', field_value)
|
||||||
|
elif field_type == '*':
|
||||||
|
if isinstance(field_value, int):
|
||||||
|
if 0 <= field_value <= 255:
|
||||||
|
field_bytes = bytes([field_value])
|
||||||
|
else:
|
||||||
|
raise ValueError('value too large for *-typed field')
|
||||||
|
else:
|
||||||
|
field_bytes = bytes(field_value)
|
||||||
|
elif isinstance(field_value, (bytes, bytearray)) or hasattr(
|
||||||
|
field_value, 'to_bytes'
|
||||||
|
):
|
||||||
|
field_bytes = bytes(field_value)
|
||||||
|
if isinstance(field_type, int) and 4 < field_type <= 256:
|
||||||
|
# Truncate or pad with zeros if the field is too long or too short
|
||||||
|
if len(field_bytes) < field_type:
|
||||||
|
field_bytes += bytes(field_type - len(field_bytes))
|
||||||
|
elif len(field_bytes) > field_type:
|
||||||
|
field_bytes = field_bytes[:field_type]
|
||||||
|
else:
|
||||||
|
raise ValueError(f"don't know how to serialize type {type(field_value)}")
|
||||||
|
|
||||||
|
return field_bytes
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def dict_to_bytes(hci_object, fields):
|
def dict_to_bytes(hci_object, fields):
|
||||||
result = bytearray()
|
result = bytearray()
|
||||||
for (field_name, field_type) in fields:
|
for field in fields:
|
||||||
# The field_type may be a dictionary with a mapper, parser, serializer,
|
if isinstance(field, list):
|
||||||
# and/or size
|
# The field is an array. The serialized form starts with a 1-byte
|
||||||
serializer = None
|
# item count. We use the length of the first array field as the
|
||||||
if isinstance(field_type, dict):
|
# array count, since all array fields have the same number of items.
|
||||||
if 'serializer' in field_type:
|
item_count = len(hci_object[field[0][0]])
|
||||||
serializer = field_type['serializer']
|
result += bytes([item_count]) + b''.join(
|
||||||
if 'size' in field_type:
|
b''.join(
|
||||||
field_type = field_type['size']
|
HCI_Object.serialize_field(
|
||||||
|
hci_object[sub_field_name][i], sub_field_type
|
||||||
# Serialize the field
|
)
|
||||||
field_value = hci_object[field_name]
|
for sub_field_name, sub_field_type in field
|
||||||
if serializer:
|
)
|
||||||
field_bytes = serializer(field_value)
|
for i in range(item_count)
|
||||||
elif field_type == 1:
|
|
||||||
# 8-bit unsigned
|
|
||||||
field_bytes = bytes([field_value])
|
|
||||||
elif field_type == -1:
|
|
||||||
# 8-bit signed
|
|
||||||
field_bytes = struct.pack('b', field_value)
|
|
||||||
elif field_type == 2:
|
|
||||||
# 16-bit unsigned
|
|
||||||
field_bytes = struct.pack('<H', field_value)
|
|
||||||
elif field_type == '>2':
|
|
||||||
# 16-bit unsigned big-endian
|
|
||||||
field_bytes = struct.pack('>H', field_value)
|
|
||||||
elif field_type == -2:
|
|
||||||
# 16-bit signed
|
|
||||||
field_bytes = struct.pack('<h', field_value)
|
|
||||||
elif field_type == 3:
|
|
||||||
# 24-bit unsigned
|
|
||||||
field_bytes = struct.pack('<I', field_value)[0:3]
|
|
||||||
elif field_type == 4:
|
|
||||||
# 32-bit unsigned
|
|
||||||
field_bytes = struct.pack('<I', field_value)
|
|
||||||
elif field_type == '>4':
|
|
||||||
# 32-bit unsigned big-endian
|
|
||||||
field_bytes = struct.pack('>I', field_value)
|
|
||||||
elif field_type == '*':
|
|
||||||
if isinstance(field_value, int):
|
|
||||||
if 0 <= field_value <= 255:
|
|
||||||
field_bytes = bytes([field_value])
|
|
||||||
else:
|
|
||||||
raise ValueError('value too large for *-typed field')
|
|
||||||
else:
|
|
||||||
field_bytes = bytes(field_value)
|
|
||||||
elif isinstance(field_value, (bytes, bytearray)) or hasattr(
|
|
||||||
field_value, 'to_bytes'
|
|
||||||
):
|
|
||||||
field_bytes = bytes(field_value)
|
|
||||||
if isinstance(field_type, int) and 4 < field_type <= 256:
|
|
||||||
# Truncate or Pad with zeros if the field is too long or too short
|
|
||||||
if len(field_bytes) < field_type:
|
|
||||||
field_bytes += bytes(field_type - len(field_bytes))
|
|
||||||
elif len(field_bytes) > field_type:
|
|
||||||
field_bytes = field_bytes[:field_type]
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"don't know how to serialize type {type(field_value)}"
|
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
result += field_bytes
|
(field_name, field_type) = field
|
||||||
|
result += HCI_Object.serialize_field(hci_object[field_name], field_type)
|
||||||
|
|
||||||
return bytes(result)
|
return bytes(result)
|
||||||
|
|
||||||
@@ -1617,48 +1651,73 @@ class HCI_Object:
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_fields(hci_object, keys, indentation='', value_mappers=None):
|
def stringify_field(
|
||||||
if not keys:
|
field_name, field_type, field_value, indentation, value_mappers
|
||||||
return ''
|
):
|
||||||
|
value_mapper = None
|
||||||
|
if isinstance(field_type, dict):
|
||||||
|
# Get the value mapper from the specifier
|
||||||
|
value_mapper = field_type.get('mapper')
|
||||||
|
|
||||||
# Measure the widest field name
|
# Check if there's a matching mapper passed
|
||||||
max_field_name_length = max(
|
if value_mappers:
|
||||||
(len(key[0] if isinstance(key, tuple) else key) for key in keys)
|
value_mapper = value_mappers.get(field_name, value_mapper)
|
||||||
|
|
||||||
|
# Map the value if we have a mapper
|
||||||
|
if value_mapper is not None:
|
||||||
|
field_value = value_mapper(field_value)
|
||||||
|
|
||||||
|
# Get the string representation of the value
|
||||||
|
return HCI_Object.format_field_value(
|
||||||
|
field_value, indentation=indentation + ' '
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_fields(hci_object, fields, indentation='', value_mappers=None):
|
||||||
|
if not fields:
|
||||||
|
return ''
|
||||||
|
|
||||||
# Build array of formatted key:value pairs
|
# Build array of formatted key:value pairs
|
||||||
fields = []
|
field_strings = []
|
||||||
for key in keys:
|
for field in fields:
|
||||||
value_mapper = None
|
if isinstance(field, list):
|
||||||
if isinstance(key, tuple):
|
for sub_field in field:
|
||||||
# The key has an associated specifier
|
sub_field_name, sub_field_type = sub_field
|
||||||
key, specifier = key
|
item_count = len(hci_object[sub_field_name])
|
||||||
|
for i in range(item_count):
|
||||||
|
field_strings.append(
|
||||||
|
(
|
||||||
|
f'{sub_field_name}[{i}]',
|
||||||
|
HCI_Object.stringify_field(
|
||||||
|
sub_field_name,
|
||||||
|
sub_field_type,
|
||||||
|
hci_object[sub_field_name][i],
|
||||||
|
indentation,
|
||||||
|
value_mappers,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Get the value mapper from the specifier
|
field_name, field_type = field
|
||||||
if isinstance(specifier, dict):
|
field_value = hci_object[field_name]
|
||||||
value_mapper = specifier.get('mapper')
|
field_strings.append(
|
||||||
|
(
|
||||||
# Get the value for the field
|
field_name,
|
||||||
value = hci_object[key]
|
HCI_Object.stringify_field(
|
||||||
|
field_name, field_type, field_value, indentation, value_mappers
|
||||||
# Check if there's a matching mapper passed
|
),
|
||||||
if value_mappers:
|
),
|
||||||
value_mapper = value_mappers.get(key, value_mapper)
|
|
||||||
|
|
||||||
# Map the value if we have a mapper
|
|
||||||
if value_mapper is not None:
|
|
||||||
value = value_mapper(value)
|
|
||||||
|
|
||||||
# Get the string representation of the value
|
|
||||||
value_str = HCI_Object.format_field_value(
|
|
||||||
value, indentation=indentation + ' '
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add the field to the formatted result
|
# Measure the widest field name
|
||||||
key_str = color(f'{key + ":":{1 + max_field_name_length}}', 'cyan')
|
max_field_name_length = max(len(s[0]) for s in field_strings)
|
||||||
fields.append(f'{indentation}{key_str} {value_str}')
|
sep = ':'
|
||||||
|
return '\n'.join(
|
||||||
return '\n'.join(fields)
|
f'{indentation}'
|
||||||
|
f'{color(f"{field_name + sep:{1 + max_field_name_length}}", "cyan")} {field_value}'
|
||||||
|
for field_name, field_value in field_strings
|
||||||
|
)
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self):
|
||||||
return self.to_bytes()
|
return self.to_bytes()
|
||||||
@@ -3769,9 +3828,7 @@ class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
|
|||||||
'advertising_data',
|
'advertising_data',
|
||||||
{
|
{
|
||||||
'parser': HCI_Object.parse_length_prefixed_bytes,
|
'parser': HCI_Object.parse_length_prefixed_bytes,
|
||||||
'serializer': functools.partial(
|
'serializer': HCI_Object.serialize_length_prefixed_bytes,
|
||||||
HCI_Object.serialize_length_prefixed_bytes
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -3819,9 +3876,7 @@ class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command):
|
|||||||
'scan_response_data',
|
'scan_response_data',
|
||||||
{
|
{
|
||||||
'parser': HCI_Object.parse_length_prefixed_bytes,
|
'parser': HCI_Object.parse_length_prefixed_bytes,
|
||||||
'serializer': functools.partial(
|
'serializer': HCI_Object.serialize_length_prefixed_bytes,
|
||||||
HCI_Object.serialize_length_prefixed_bytes
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -3849,73 +3904,21 @@ class HCI_LE_Set_Extended_Scan_Response_Data_Command(HCI_Command):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command(fields=None)
|
@HCI_Command.command(
|
||||||
|
[
|
||||||
|
('enable', 1),
|
||||||
|
[
|
||||||
|
('advertising_handles', 1),
|
||||||
|
('durations', 2),
|
||||||
|
('max_extended_advertising_events', 1),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
class HCI_LE_Set_Extended_Advertising_Enable_Command(HCI_Command):
|
class HCI_LE_Set_Extended_Advertising_Enable_Command(HCI_Command):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ 7.8.56 LE Set Extended Advertising Enable Command
|
See Bluetooth spec @ 7.8.56 LE Set Extended Advertising Enable Command
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_parameters(cls, parameters):
|
|
||||||
enable = parameters[0]
|
|
||||||
num_sets = parameters[1]
|
|
||||||
advertising_handles = []
|
|
||||||
durations = []
|
|
||||||
max_extended_advertising_events = []
|
|
||||||
offset = 2
|
|
||||||
for _ in range(num_sets):
|
|
||||||
advertising_handles.append(parameters[offset])
|
|
||||||
durations.append(struct.unpack_from('<H', parameters, offset + 1)[0])
|
|
||||||
max_extended_advertising_events.append(parameters[offset + 3])
|
|
||||||
offset += 4
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
enable, advertising_handles, durations, max_extended_advertising_events
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, enable, advertising_handles, durations, max_extended_advertising_events
|
|
||||||
):
|
|
||||||
super().__init__(HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND)
|
|
||||||
self.enable = enable
|
|
||||||
self.advertising_handles = advertising_handles
|
|
||||||
self.durations = durations
|
|
||||||
self.max_extended_advertising_events = max_extended_advertising_events
|
|
||||||
|
|
||||||
self.parameters = bytes([enable, len(advertising_handles)]) + b''.join(
|
|
||||||
[
|
|
||||||
struct.pack(
|
|
||||||
'<BHB',
|
|
||||||
advertising_handles[i],
|
|
||||||
durations[i],
|
|
||||||
max_extended_advertising_events[i],
|
|
||||||
)
|
|
||||||
for i in range(len(advertising_handles))
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
fields = [('enable:', self.enable)]
|
|
||||||
for i, advertising_handle in enumerate(self.advertising_handles):
|
|
||||||
fields.append(
|
|
||||||
(f'advertising_handle[{i}]: ', advertising_handle)
|
|
||||||
)
|
|
||||||
fields.append((f'duration[{i}]: ', self.durations[i]))
|
|
||||||
fields.append(
|
|
||||||
(
|
|
||||||
f'max_extended_advertising_events[{i}]:',
|
|
||||||
self.max_extended_advertising_events[i],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
color(self.name, 'green')
|
|
||||||
+ ':\n'
|
|
||||||
+ '\n'.join(
|
|
||||||
[color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@HCI_Command.command(
|
@HCI_Command.command(
|
||||||
@@ -4066,7 +4069,10 @@ class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command):
|
|||||||
color(self.name, 'green')
|
color(self.name, 'green')
|
||||||
+ ':\n'
|
+ ':\n'
|
||||||
+ '\n'.join(
|
+ '\n'.join(
|
||||||
[color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
|
[
|
||||||
|
color(' ' + field[0], 'cyan') + ' ' + str(field[1])
|
||||||
|
for field in fields
|
||||||
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4242,7 +4248,10 @@ class HCI_LE_Extended_Create_Connection_Command(HCI_Command):
|
|||||||
color(self.name, 'green')
|
color(self.name, 'green')
|
||||||
+ ':\n'
|
+ ':\n'
|
||||||
+ '\n'.join(
|
+ '\n'.join(
|
||||||
[color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
|
[
|
||||||
|
color(' ' + field[0], 'cyan') + ' ' + str(field[1])
|
||||||
|
for field in fields
|
||||||
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -5205,7 +5214,7 @@ class HCI_Number_Of_Completed_Packets_Event(HCI_Event):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
lines = [
|
lines = [
|
||||||
color(self.name, 'magenta') + ':',
|
color(self.name, 'magenta') + ':',
|
||||||
color(' number_of_handles: ', 'cyan')
|
color(' number_of_handles: ', 'cyan')
|
||||||
+ f'{len(self.connection_handles)}',
|
+ f'{len(self.connection_handles)}',
|
||||||
]
|
]
|
||||||
for i, connection_handle in enumerate(self.connection_handles):
|
for i, connection_handle in enumerate(self.connection_handles):
|
||||||
|
|||||||
+210
-122
@@ -22,7 +22,19 @@ import struct
|
|||||||
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from pyee import EventEmitter
|
from pyee import EventEmitter
|
||||||
from typing import Dict, Type
|
from typing import (
|
||||||
|
Dict,
|
||||||
|
Type,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
Callable,
|
||||||
|
Any,
|
||||||
|
Union,
|
||||||
|
Deque,
|
||||||
|
Iterable,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
from .colors import color
|
from .colors import color
|
||||||
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
||||||
@@ -33,6 +45,9 @@ from .hci import (
|
|||||||
name_or_number,
|
name_or_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.device import Connection
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -155,7 +170,7 @@ class L2CAP_PDU:
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(data):
|
def from_bytes(data: bytes) -> L2CAP_PDU:
|
||||||
# Sanity check
|
# Sanity check
|
||||||
if len(data) < 4:
|
if len(data) < 4:
|
||||||
raise ValueError('not enough data for L2CAP header')
|
raise ValueError('not enough data for L2CAP header')
|
||||||
@@ -165,18 +180,18 @@ class L2CAP_PDU:
|
|||||||
|
|
||||||
return L2CAP_PDU(l2cap_pdu_cid, l2cap_pdu_payload)
|
return L2CAP_PDU(l2cap_pdu_cid, l2cap_pdu_payload)
|
||||||
|
|
||||||
def to_bytes(self):
|
def to_bytes(self) -> bytes:
|
||||||
header = struct.pack('<HH', len(self.payload), self.cid)
|
header = struct.pack('<HH', len(self.payload), self.cid)
|
||||||
return header + self.payload
|
return header + self.payload
|
||||||
|
|
||||||
def __init__(self, cid, payload):
|
def __init__(self, cid: int, payload: bytes) -> None:
|
||||||
self.cid = cid
|
self.cid = cid
|
||||||
self.payload = payload
|
self.payload = payload
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
return self.to_bytes()
|
return self.to_bytes()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f'{color("L2CAP", "green")} [CID={self.cid}]: {self.payload.hex()}'
|
return f'{color("L2CAP", "green")} [CID={self.cid}]: {self.payload.hex()}'
|
||||||
|
|
||||||
|
|
||||||
@@ -188,10 +203,10 @@ class L2CAP_Control_Frame:
|
|||||||
|
|
||||||
classes: Dict[int, Type[L2CAP_Control_Frame]] = {}
|
classes: Dict[int, Type[L2CAP_Control_Frame]] = {}
|
||||||
code = 0
|
code = 0
|
||||||
name = None
|
name: str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_bytes(pdu):
|
def from_bytes(pdu: bytes) -> L2CAP_Control_Frame:
|
||||||
code = pdu[0]
|
code = pdu[0]
|
||||||
|
|
||||||
cls = L2CAP_Control_Frame.classes.get(code)
|
cls = L2CAP_Control_Frame.classes.get(code)
|
||||||
@@ -216,11 +231,11 @@ class L2CAP_Control_Frame:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def code_name(code):
|
def code_name(code: int) -> str:
|
||||||
return name_or_number(L2CAP_CONTROL_FRAME_NAMES, code)
|
return name_or_number(L2CAP_CONTROL_FRAME_NAMES, code)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decode_configuration_options(data):
|
def decode_configuration_options(data: bytes) -> List[Tuple[int, bytes]]:
|
||||||
options = []
|
options = []
|
||||||
while len(data) >= 2:
|
while len(data) >= 2:
|
||||||
value_type = data[0]
|
value_type = data[0]
|
||||||
@@ -232,7 +247,7 @@ class L2CAP_Control_Frame:
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encode_configuration_options(options):
|
def encode_configuration_options(options: List[Tuple[int, bytes]]) -> bytes:
|
||||||
return b''.join(
|
return b''.join(
|
||||||
[bytes([option[0], len(option[1])]) + option[1] for option in options]
|
[bytes([option[0], len(option[1])]) + option[1] for option in options]
|
||||||
)
|
)
|
||||||
@@ -256,29 +271,30 @@ class L2CAP_Control_Frame:
|
|||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
def __init__(self, pdu=None, **kwargs):
|
def __init__(self, pdu=None, **kwargs) -> None:
|
||||||
self.identifier = kwargs.get('identifier', 0)
|
self.identifier = kwargs.get('identifier', 0)
|
||||||
if hasattr(self, 'fields') and kwargs:
|
if hasattr(self, 'fields'):
|
||||||
HCI_Object.init_from_fields(self, self.fields, kwargs)
|
if kwargs:
|
||||||
if pdu is None:
|
HCI_Object.init_from_fields(self, self.fields, kwargs)
|
||||||
data = HCI_Object.dict_to_bytes(kwargs, self.fields)
|
if pdu is None:
|
||||||
pdu = (
|
data = HCI_Object.dict_to_bytes(kwargs, self.fields)
|
||||||
bytes([self.code, self.identifier])
|
pdu = (
|
||||||
+ struct.pack('<H', len(data))
|
bytes([self.code, self.identifier])
|
||||||
+ data
|
+ struct.pack('<H', len(data))
|
||||||
)
|
+ data
|
||||||
|
)
|
||||||
self.pdu = pdu
|
self.pdu = pdu
|
||||||
|
|
||||||
def init_from_bytes(self, pdu, offset):
|
def init_from_bytes(self, pdu, offset):
|
||||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
||||||
|
|
||||||
def to_bytes(self):
|
def to_bytes(self) -> bytes:
|
||||||
return self.pdu
|
return self.pdu
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
return self.to_bytes()
|
return self.to_bytes()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
|
result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
|
||||||
if fields := getattr(self, 'fields', None):
|
if fields := getattr(self, 'fields', None):
|
||||||
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
|
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
|
||||||
@@ -315,7 +331,7 @@ class L2CAP_Command_Reject(L2CAP_Control_Frame):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reason_name(reason):
|
def reason_name(reason: int) -> str:
|
||||||
return name_or_number(L2CAP_Command_Reject.REASON_NAMES, reason)
|
return name_or_number(L2CAP_Command_Reject.REASON_NAMES, reason)
|
||||||
|
|
||||||
|
|
||||||
@@ -343,7 +359,7 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_psm(data, offset=0):
|
def parse_psm(data: bytes, offset: int = 0) -> Tuple[int, int]:
|
||||||
psm_length = 2
|
psm_length = 2
|
||||||
psm = data[offset] | data[offset + 1] << 8
|
psm = data[offset] | data[offset + 1] << 8
|
||||||
|
|
||||||
@@ -355,7 +371,7 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame):
|
|||||||
return offset + psm_length, psm
|
return offset + psm_length, psm
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def serialize_psm(psm):
|
def serialize_psm(psm: int) -> bytes:
|
||||||
serialized = struct.pack('<H', psm & 0xFFFF)
|
serialized = struct.pack('<H', psm & 0xFFFF)
|
||||||
psm >>= 16
|
psm >>= 16
|
||||||
while psm:
|
while psm:
|
||||||
@@ -405,7 +421,7 @@ class L2CAP_Connection_Response(L2CAP_Control_Frame):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def result_name(result):
|
def result_name(result: int) -> str:
|
||||||
return name_or_number(L2CAP_Connection_Response.RESULT_NAMES, result)
|
return name_or_number(L2CAP_Connection_Response.RESULT_NAMES, result)
|
||||||
|
|
||||||
|
|
||||||
@@ -452,7 +468,7 @@ class L2CAP_Configure_Response(L2CAP_Control_Frame):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def result_name(result):
|
def result_name(result: int) -> str:
|
||||||
return name_or_number(L2CAP_Configure_Response.RESULT_NAMES, result)
|
return name_or_number(L2CAP_Configure_Response.RESULT_NAMES, result)
|
||||||
|
|
||||||
|
|
||||||
@@ -529,7 +545,7 @@ class L2CAP_Information_Request(L2CAP_Control_Frame):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def info_type_name(info_type):
|
def info_type_name(info_type: int) -> str:
|
||||||
return name_or_number(L2CAP_Information_Request.INFO_TYPE_NAMES, info_type)
|
return name_or_number(L2CAP_Information_Request.INFO_TYPE_NAMES, info_type)
|
||||||
|
|
||||||
|
|
||||||
@@ -556,7 +572,7 @@ class L2CAP_Information_Response(L2CAP_Control_Frame):
|
|||||||
RESULT_NAMES = {SUCCESS: 'SUCCESS', NOT_SUPPORTED: 'NOT_SUPPORTED'}
|
RESULT_NAMES = {SUCCESS: 'SUCCESS', NOT_SUPPORTED: 'NOT_SUPPORTED'}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def result_name(result):
|
def result_name(result: int) -> str:
|
||||||
return name_or_number(L2CAP_Information_Response.RESULT_NAMES, result)
|
return name_or_number(L2CAP_Information_Response.RESULT_NAMES, result)
|
||||||
|
|
||||||
|
|
||||||
@@ -588,6 +604,8 @@ class L2CAP_LE_Credit_Based_Connection_Request(L2CAP_Control_Frame):
|
|||||||
(CODE 0x14)
|
(CODE 0x14)
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
source_cid: int
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@L2CAP_Control_Frame.subclass(
|
@L2CAP_Control_Frame.subclass(
|
||||||
@@ -640,7 +658,7 @@ class L2CAP_LE_Credit_Based_Connection_Response(L2CAP_Control_Frame):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def result_name(result):
|
def result_name(result: int) -> str:
|
||||||
return name_or_number(
|
return name_or_number(
|
||||||
L2CAP_LE_Credit_Based_Connection_Response.RESULT_NAMES, result
|
L2CAP_LE_Credit_Based_Connection_Response.RESULT_NAMES, result
|
||||||
)
|
)
|
||||||
@@ -701,7 +719,22 @@ class Channel(EventEmitter):
|
|||||||
WAIT_CONTROL_IND: 'WAIT_CONTROL_IND',
|
WAIT_CONTROL_IND: 'WAIT_CONTROL_IND',
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, manager, connection, signaling_cid, psm, source_cid, mtu):
|
connection_result: Optional[asyncio.Future[None]]
|
||||||
|
disconnection_result: Optional[asyncio.Future[None]]
|
||||||
|
response: Optional[asyncio.Future[bytes]]
|
||||||
|
sink: Optional[Callable[[bytes], Any]]
|
||||||
|
state: int
|
||||||
|
connection: Connection
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
manager: 'ChannelManager',
|
||||||
|
connection: Connection,
|
||||||
|
signaling_cid: int,
|
||||||
|
psm: int,
|
||||||
|
source_cid: int,
|
||||||
|
mtu: int,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -716,19 +749,19 @@ class Channel(EventEmitter):
|
|||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
self.sink = None
|
self.sink = None
|
||||||
|
|
||||||
def change_state(self, new_state):
|
def change_state(self, new_state: int) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{self} state change -> {color(Channel.STATE_NAMES[new_state], "cyan")}'
|
f'{self} state change -> {color(Channel.STATE_NAMES[new_state], "cyan")}'
|
||||||
)
|
)
|
||||||
self.state = new_state
|
self.state = new_state
|
||||||
|
|
||||||
def send_pdu(self, pdu):
|
def send_pdu(self, pdu) -> None:
|
||||||
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
||||||
|
|
||||||
def send_control_frame(self, frame):
|
def send_control_frame(self, frame) -> None:
|
||||||
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
|
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
|
||||||
|
|
||||||
async def send_request(self, request):
|
async def send_request(self, request) -> bytes:
|
||||||
# Check that there isn't already a request pending
|
# Check that there isn't already a request pending
|
||||||
if self.response:
|
if self.response:
|
||||||
raise InvalidStateError('request already pending')
|
raise InvalidStateError('request already pending')
|
||||||
@@ -739,7 +772,7 @@ class Channel(EventEmitter):
|
|||||||
self.send_pdu(request)
|
self.send_pdu(request)
|
||||||
return await self.response
|
return await self.response
|
||||||
|
|
||||||
def on_pdu(self, pdu):
|
def on_pdu(self, pdu) -> None:
|
||||||
if self.response:
|
if self.response:
|
||||||
self.response.set_result(pdu)
|
self.response.set_result(pdu)
|
||||||
self.response = None
|
self.response = None
|
||||||
@@ -751,7 +784,7 @@ class Channel(EventEmitter):
|
|||||||
color('received pdu without a pending request or sink', 'red')
|
color('received pdu without a pending request or sink', 'red')
|
||||||
)
|
)
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self) -> None:
|
||||||
if self.state != Channel.CLOSED:
|
if self.state != Channel.CLOSED:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
@@ -778,7 +811,7 @@ class Channel(EventEmitter):
|
|||||||
finally:
|
finally:
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self) -> None:
|
||||||
if self.state != Channel.OPEN:
|
if self.state != Channel.OPEN:
|
||||||
raise InvalidStateError('invalid state')
|
raise InvalidStateError('invalid state')
|
||||||
|
|
||||||
@@ -796,12 +829,12 @@ class Channel(EventEmitter):
|
|||||||
self.disconnection_result = asyncio.get_running_loop().create_future()
|
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||||
return await self.disconnection_result
|
return await self.disconnection_result
|
||||||
|
|
||||||
def abort(self):
|
def abort(self) -> None:
|
||||||
if self.state == self.OPEN:
|
if self.state == self.OPEN:
|
||||||
self.change_state(self.CLOSED)
|
self.change_state(self.CLOSED)
|
||||||
self.emit('close')
|
self.emit('close')
|
||||||
|
|
||||||
def send_configure_request(self):
|
def send_configure_request(self) -> None:
|
||||||
options = L2CAP_Control_Frame.encode_configuration_options(
|
options = L2CAP_Control_Frame.encode_configuration_options(
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
@@ -819,7 +852,7 @@ class Channel(EventEmitter):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_connection_request(self, request):
|
def on_connection_request(self, request) -> None:
|
||||||
self.destination_cid = request.source_cid
|
self.destination_cid = request.source_cid
|
||||||
self.change_state(Channel.WAIT_CONNECT)
|
self.change_state(Channel.WAIT_CONNECT)
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
@@ -858,7 +891,7 @@ class Channel(EventEmitter):
|
|||||||
)
|
)
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
|
||||||
def on_configure_request(self, request):
|
def on_configure_request(self, request) -> None:
|
||||||
if self.state not in (
|
if self.state not in (
|
||||||
Channel.WAIT_CONFIG,
|
Channel.WAIT_CONFIG,
|
||||||
Channel.WAIT_CONFIG_REQ,
|
Channel.WAIT_CONFIG_REQ,
|
||||||
@@ -896,7 +929,7 @@ class Channel(EventEmitter):
|
|||||||
elif self.state == Channel.WAIT_CONFIG_REQ_RSP:
|
elif self.state == Channel.WAIT_CONFIG_REQ_RSP:
|
||||||
self.change_state(Channel.WAIT_CONFIG_RSP)
|
self.change_state(Channel.WAIT_CONFIG_RSP)
|
||||||
|
|
||||||
def on_configure_response(self, response):
|
def on_configure_response(self, response) -> None:
|
||||||
if response.result == L2CAP_Configure_Response.SUCCESS:
|
if response.result == L2CAP_Configure_Response.SUCCESS:
|
||||||
if self.state == Channel.WAIT_CONFIG_REQ_RSP:
|
if self.state == Channel.WAIT_CONFIG_REQ_RSP:
|
||||||
self.change_state(Channel.WAIT_CONFIG_REQ)
|
self.change_state(Channel.WAIT_CONFIG_REQ)
|
||||||
@@ -930,7 +963,7 @@ class Channel(EventEmitter):
|
|||||||
)
|
)
|
||||||
# TODO: decide how to fail gracefully
|
# TODO: decide how to fail gracefully
|
||||||
|
|
||||||
def on_disconnection_request(self, request):
|
def on_disconnection_request(self, request) -> None:
|
||||||
if self.state in (Channel.OPEN, Channel.WAIT_DISCONNECT):
|
if self.state in (Channel.OPEN, Channel.WAIT_DISCONNECT):
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
L2CAP_Disconnection_Response(
|
L2CAP_Disconnection_Response(
|
||||||
@@ -945,7 +978,7 @@ class Channel(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
logger.warning(color('invalid state', 'red'))
|
logger.warning(color('invalid state', 'red'))
|
||||||
|
|
||||||
def on_disconnection_response(self, response):
|
def on_disconnection_response(self, response) -> None:
|
||||||
if self.state != Channel.WAIT_DISCONNECT:
|
if self.state != Channel.WAIT_DISCONNECT:
|
||||||
logger.warning(color('invalid state', 'red'))
|
logger.warning(color('invalid state', 'red'))
|
||||||
return
|
return
|
||||||
@@ -964,7 +997,7 @@ class Channel(EventEmitter):
|
|||||||
self.emit('close')
|
self.emit('close')
|
||||||
self.manager.on_channel_closed(self)
|
self.manager.on_channel_closed(self)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'Channel({self.source_cid}->{self.destination_cid}, '
|
f'Channel({self.source_cid}->{self.destination_cid}, '
|
||||||
f'PSM={self.psm}, '
|
f'PSM={self.psm}, '
|
||||||
@@ -995,25 +1028,32 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
CONNECTION_ERROR: 'CONNECTION_ERROR',
|
CONNECTION_ERROR: 'CONNECTION_ERROR',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out_queue: Deque[bytes]
|
||||||
|
connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]]
|
||||||
|
disconnection_result: Optional[asyncio.Future[None]]
|
||||||
|
out_sdu: Optional[bytes]
|
||||||
|
state: int
|
||||||
|
connection: Connection
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def state_name(state):
|
def state_name(state: int) -> str:
|
||||||
return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
|
return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
manager,
|
manager: 'ChannelManager',
|
||||||
connection,
|
connection: Connection,
|
||||||
le_psm,
|
le_psm: int,
|
||||||
source_cid,
|
source_cid: int,
|
||||||
destination_cid,
|
destination_cid: int,
|
||||||
mtu,
|
mtu: int,
|
||||||
mps,
|
mps: int,
|
||||||
credits, # pylint: disable=redefined-builtin
|
credits: int, # pylint: disable=redefined-builtin
|
||||||
peer_mtu,
|
peer_mtu: int,
|
||||||
peer_mps,
|
peer_mps: int,
|
||||||
peer_credits,
|
peer_credits: int,
|
||||||
connected,
|
connected: bool,
|
||||||
):
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -1045,7 +1085,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
self.state = LeConnectionOrientedChannel.INIT
|
self.state = LeConnectionOrientedChannel.INIT
|
||||||
|
|
||||||
def change_state(self, new_state):
|
def change_state(self, new_state: int) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
|
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
|
||||||
)
|
)
|
||||||
@@ -1056,13 +1096,13 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
elif new_state == self.DISCONNECTED:
|
elif new_state == self.DISCONNECTED:
|
||||||
self.emit('close')
|
self.emit('close')
|
||||||
|
|
||||||
def send_pdu(self, pdu):
|
def send_pdu(self, pdu) -> None:
|
||||||
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
||||||
|
|
||||||
def send_control_frame(self, frame):
|
def send_control_frame(self, frame) -> None:
|
||||||
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
|
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
|
||||||
|
|
||||||
async def connect(self):
|
async def connect(self) -> LeConnectionOrientedChannel:
|
||||||
# Check that we're in the right state
|
# Check that we're in the right state
|
||||||
if self.state != self.INIT:
|
if self.state != self.INIT:
|
||||||
raise InvalidStateError('not in a connectable state')
|
raise InvalidStateError('not in a connectable state')
|
||||||
@@ -1090,7 +1130,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
# Wait for the connection to succeed or fail
|
# Wait for the connection to succeed or fail
|
||||||
return await self.connection_result
|
return await self.connection_result
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self) -> None:
|
||||||
# Check that we're connected
|
# Check that we're connected
|
||||||
if self.state != self.CONNECTED:
|
if self.state != self.CONNECTED:
|
||||||
raise InvalidStateError('not connected')
|
raise InvalidStateError('not connected')
|
||||||
@@ -1110,11 +1150,11 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
self.disconnection_result = asyncio.get_running_loop().create_future()
|
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||||
return await self.disconnection_result
|
return await self.disconnection_result
|
||||||
|
|
||||||
def abort(self):
|
def abort(self) -> None:
|
||||||
if self.state == self.CONNECTED:
|
if self.state == self.CONNECTED:
|
||||||
self.change_state(self.DISCONNECTED)
|
self.change_state(self.DISCONNECTED)
|
||||||
|
|
||||||
def on_pdu(self, pdu):
|
def on_pdu(self, pdu) -> None:
|
||||||
if self.sink is None:
|
if self.sink is None:
|
||||||
logger.warning('received pdu without a sink')
|
logger.warning('received pdu without a sink')
|
||||||
return
|
return
|
||||||
@@ -1180,7 +1220,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
self.in_sdu = None
|
self.in_sdu = None
|
||||||
self.in_sdu_length = 0
|
self.in_sdu_length = 0
|
||||||
|
|
||||||
def on_connection_response(self, response):
|
def on_connection_response(self, response) -> None:
|
||||||
# Look for a matching pending response result
|
# Look for a matching pending response result
|
||||||
if self.connection_result is None:
|
if self.connection_result is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1214,14 +1254,14 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
# Cleanup
|
# Cleanup
|
||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
|
|
||||||
def on_credits(self, credits): # pylint: disable=redefined-builtin
|
def on_credits(self, credits: int) -> None: # pylint: disable=redefined-builtin
|
||||||
self.credits += credits
|
self.credits += credits
|
||||||
logger.debug(f'received {credits} credits, total = {self.credits}')
|
logger.debug(f'received {credits} credits, total = {self.credits}')
|
||||||
|
|
||||||
# Try to send more data if we have any queued up
|
# Try to send more data if we have any queued up
|
||||||
self.process_output()
|
self.process_output()
|
||||||
|
|
||||||
def on_disconnection_request(self, request):
|
def on_disconnection_request(self, request) -> None:
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
L2CAP_Disconnection_Response(
|
L2CAP_Disconnection_Response(
|
||||||
identifier=request.identifier,
|
identifier=request.identifier,
|
||||||
@@ -1232,7 +1272,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
self.change_state(self.DISCONNECTED)
|
self.change_state(self.DISCONNECTED)
|
||||||
self.flush_output()
|
self.flush_output()
|
||||||
|
|
||||||
def on_disconnection_response(self, response):
|
def on_disconnection_response(self, response) -> None:
|
||||||
if self.state != self.DISCONNECTING:
|
if self.state != self.DISCONNECTING:
|
||||||
logger.warning(color('invalid state', 'red'))
|
logger.warning(color('invalid state', 'red'))
|
||||||
return
|
return
|
||||||
@@ -1249,11 +1289,11 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
self.disconnection_result.set_result(None)
|
self.disconnection_result.set_result(None)
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
|
|
||||||
def flush_output(self):
|
def flush_output(self) -> None:
|
||||||
self.out_queue.clear()
|
self.out_queue.clear()
|
||||||
self.out_sdu = None
|
self.out_sdu = None
|
||||||
|
|
||||||
def process_output(self):
|
def process_output(self) -> None:
|
||||||
while self.credits > 0:
|
while self.credits > 0:
|
||||||
if self.out_sdu is not None:
|
if self.out_sdu is not None:
|
||||||
# Finish the current SDU
|
# Finish the current SDU
|
||||||
@@ -1296,7 +1336,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
self.drained.set()
|
self.drained.set()
|
||||||
return
|
return
|
||||||
|
|
||||||
def write(self, data):
|
def write(self, data: bytes) -> None:
|
||||||
if self.state != self.CONNECTED:
|
if self.state != self.CONNECTED:
|
||||||
logger.warning('not connected, dropping data')
|
logger.warning('not connected, dropping data')
|
||||||
return
|
return
|
||||||
@@ -1311,18 +1351,18 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
# Send what we can
|
# Send what we can
|
||||||
self.process_output()
|
self.process_output()
|
||||||
|
|
||||||
async def drain(self):
|
async def drain(self) -> None:
|
||||||
await self.drained.wait()
|
await self.drained.wait()
|
||||||
|
|
||||||
def pause_reading(self):
|
def pause_reading(self) -> None:
|
||||||
# TODO: not implemented yet
|
# TODO: not implemented yet
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def resume_reading(self):
|
def resume_reading(self) -> None:
|
||||||
# TODO: not implemented yet
|
# TODO: not implemented yet
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f'CoC({self.source_cid}->{self.destination_cid}, '
|
f'CoC({self.source_cid}->{self.destination_cid}, '
|
||||||
f'State={self.state_name(self.state)}, '
|
f'State={self.state_name(self.state)}, '
|
||||||
@@ -1335,9 +1375,21 @@ class LeConnectionOrientedChannel(EventEmitter):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class ChannelManager:
|
class ChannelManager:
|
||||||
|
identifiers: Dict[int, int]
|
||||||
|
channels: Dict[int, Dict[int, Union[Channel, LeConnectionOrientedChannel]]]
|
||||||
|
servers: Dict[int, Callable[[Channel], Any]]
|
||||||
|
le_coc_channels: Dict[int, Dict[int, LeConnectionOrientedChannel]]
|
||||||
|
le_coc_servers: Dict[
|
||||||
|
int, Tuple[Callable[[LeConnectionOrientedChannel], Any], int, int, int]
|
||||||
|
]
|
||||||
|
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
|
||||||
|
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, extended_features=(), connectionless_mtu=L2CAP_DEFAULT_CONNECTIONLESS_MTU
|
self,
|
||||||
):
|
extended_features: Iterable[int] = (),
|
||||||
|
connectionless_mtu: int = L2CAP_DEFAULT_CONNECTIONLESS_MTU,
|
||||||
|
) -> None:
|
||||||
self._host = None
|
self._host = None
|
||||||
self.identifiers = {} # Incrementing identifier values by connection
|
self.identifiers = {} # Incrementing identifier values by connection
|
||||||
self.channels = {} # All channels, mapped by connection and source cid
|
self.channels = {} # All channels, mapped by connection and source cid
|
||||||
@@ -1366,20 +1418,20 @@ class ChannelManager:
|
|||||||
if host is not None:
|
if host is not None:
|
||||||
host.on('disconnection', self.on_disconnection)
|
host.on('disconnection', self.on_disconnection)
|
||||||
|
|
||||||
def find_channel(self, connection_handle, cid):
|
def find_channel(self, connection_handle: int, cid: int):
|
||||||
if connection_channels := self.channels.get(connection_handle):
|
if connection_channels := self.channels.get(connection_handle):
|
||||||
return connection_channels.get(cid)
|
return connection_channels.get(cid)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def find_le_coc_channel(self, connection_handle, cid):
|
def find_le_coc_channel(self, connection_handle: int, cid: int):
|
||||||
if connection_channels := self.le_coc_channels.get(connection_handle):
|
if connection_channels := self.le_coc_channels.get(connection_handle):
|
||||||
return connection_channels.get(cid)
|
return connection_channels.get(cid)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_free_br_edr_cid(channels):
|
def find_free_br_edr_cid(channels: Iterable[int]) -> int:
|
||||||
# Pick the smallest valid CID that's not already in the list
|
# Pick the smallest valid CID that's not already in the list
|
||||||
# (not necessarily the most efficient algorithm, but the list of CID is
|
# (not necessarily the most efficient algorithm, but the list of CID is
|
||||||
# very small in practice)
|
# very small in practice)
|
||||||
@@ -1392,7 +1444,7 @@ class ChannelManager:
|
|||||||
raise RuntimeError('no free CID available')
|
raise RuntimeError('no free CID available')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_free_le_cid(channels):
|
def find_free_le_cid(channels: Iterable[int]) -> int:
|
||||||
# Pick the smallest valid CID that's not already in the list
|
# Pick the smallest valid CID that's not already in the list
|
||||||
# (not necessarily the most efficient algorithm, but the list of CID is
|
# (not necessarily the most efficient algorithm, but the list of CID is
|
||||||
# very small in practice)
|
# very small in practice)
|
||||||
@@ -1405,7 +1457,7 @@ class ChannelManager:
|
|||||||
raise RuntimeError('no free CID')
|
raise RuntimeError('no free CID')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_le_coc_parameters(max_credits, mtu, mps):
|
def check_le_coc_parameters(max_credits: int, mtu: int, mps: int) -> None:
|
||||||
if (
|
if (
|
||||||
max_credits < 1
|
max_credits < 1
|
||||||
or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
|
||||||
@@ -1419,19 +1471,21 @@ class ChannelManager:
|
|||||||
):
|
):
|
||||||
raise ValueError('MPS out of range')
|
raise ValueError('MPS out of range')
|
||||||
|
|
||||||
def next_identifier(self, connection):
|
def next_identifier(self, connection: Connection) -> int:
|
||||||
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
||||||
self.identifiers[connection.handle] = identifier
|
self.identifiers[connection.handle] = identifier
|
||||||
return identifier
|
return identifier
|
||||||
|
|
||||||
def register_fixed_channel(self, cid, handler):
|
def register_fixed_channel(
|
||||||
|
self, cid: int, handler: Callable[[int, bytes], Any]
|
||||||
|
) -> None:
|
||||||
self.fixed_channels[cid] = handler
|
self.fixed_channels[cid] = handler
|
||||||
|
|
||||||
def deregister_fixed_channel(self, cid):
|
def deregister_fixed_channel(self, cid: int) -> None:
|
||||||
if cid in self.fixed_channels:
|
if cid in self.fixed_channels:
|
||||||
del self.fixed_channels[cid]
|
del self.fixed_channels[cid]
|
||||||
|
|
||||||
def register_server(self, psm, server):
|
def register_server(self, psm: int, server: Callable[[Channel], Any]) -> int:
|
||||||
if psm == 0:
|
if psm == 0:
|
||||||
# Find a free PSM
|
# Find a free PSM
|
||||||
for candidate in range(
|
for candidate in range(
|
||||||
@@ -1465,12 +1519,12 @@ class ChannelManager:
|
|||||||
|
|
||||||
def register_le_coc_server(
|
def register_le_coc_server(
|
||||||
self,
|
self,
|
||||||
psm,
|
psm: int,
|
||||||
server,
|
server: Callable[[LeConnectionOrientedChannel], Any],
|
||||||
max_credits=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
|
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
|
||||||
mtu=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
|
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
|
||||||
mps=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
|
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
|
||||||
):
|
) -> int:
|
||||||
self.check_le_coc_parameters(max_credits, mtu, mps)
|
self.check_le_coc_parameters(max_credits, mtu, mps)
|
||||||
|
|
||||||
if psm == 0:
|
if psm == 0:
|
||||||
@@ -1498,7 +1552,7 @@ class ChannelManager:
|
|||||||
|
|
||||||
return psm
|
return psm
|
||||||
|
|
||||||
def on_disconnection(self, connection_handle, _reason):
|
def on_disconnection(self, connection_handle: int, _reason: int) -> None:
|
||||||
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
|
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
|
||||||
if connection_handle in self.channels:
|
if connection_handle in self.channels:
|
||||||
for _, channel in self.channels[connection_handle].items():
|
for _, channel in self.channels[connection_handle].items():
|
||||||
@@ -1511,7 +1565,7 @@ class ChannelManager:
|
|||||||
if connection_handle in self.identifiers:
|
if connection_handle in self.identifiers:
|
||||||
del self.identifiers[connection_handle]
|
del self.identifiers[connection_handle]
|
||||||
|
|
||||||
def send_pdu(self, connection, cid, pdu):
|
def send_pdu(self, connection, cid: int, pdu) -> None:
|
||||||
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
|
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{color(">>> Sending L2CAP PDU", "blue")} '
|
f'{color(">>> Sending L2CAP PDU", "blue")} '
|
||||||
@@ -1520,14 +1574,16 @@ class ChannelManager:
|
|||||||
)
|
)
|
||||||
self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
|
self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
|
||||||
|
|
||||||
def on_pdu(self, connection, cid, pdu):
|
def on_pdu(self, connection: Connection, cid: int, pdu) -> None:
|
||||||
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
|
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
|
||||||
# Parse the L2CAP payload into a Control Frame object
|
# Parse the L2CAP payload into a Control Frame object
|
||||||
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
|
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
|
||||||
|
|
||||||
self.on_control_frame(connection, cid, control_frame)
|
self.on_control_frame(connection, cid, control_frame)
|
||||||
elif cid in self.fixed_channels:
|
elif cid in self.fixed_channels:
|
||||||
self.fixed_channels[cid](connection.handle, pdu)
|
handler = self.fixed_channels[cid]
|
||||||
|
assert handler is not None
|
||||||
|
handler(connection.handle, pdu)
|
||||||
else:
|
else:
|
||||||
if (channel := self.find_channel(connection.handle, cid)) is None:
|
if (channel := self.find_channel(connection.handle, cid)) is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1539,7 +1595,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_pdu(pdu)
|
channel.on_pdu(pdu)
|
||||||
|
|
||||||
def send_control_frame(self, connection, cid, control_frame):
|
def send_control_frame(
|
||||||
|
self, connection: Connection, cid: int, control_frame
|
||||||
|
) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{color(">>> Sending L2CAP Signaling Control Frame", "blue")} '
|
f'{color(">>> Sending L2CAP Signaling Control Frame", "blue")} '
|
||||||
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
||||||
@@ -1547,7 +1605,7 @@ class ChannelManager:
|
|||||||
)
|
)
|
||||||
self.host.send_l2cap_pdu(connection.handle, cid, bytes(control_frame))
|
self.host.send_l2cap_pdu(connection.handle, cid, bytes(control_frame))
|
||||||
|
|
||||||
def on_control_frame(self, connection, cid, control_frame):
|
def on_control_frame(self, connection: Connection, cid: int, control_frame) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{color("<<< Received L2CAP Signaling Control Frame", "green")} '
|
f'{color("<<< Received L2CAP Signaling Control Frame", "green")} '
|
||||||
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
||||||
@@ -1584,10 +1642,14 @@ class ChannelManager:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_command_reject(self, _connection, _cid, packet):
|
def on_l2cap_command_reject(
|
||||||
|
self, _connection: Connection, _cid: int, packet
|
||||||
|
) -> None:
|
||||||
logger.warning(f'{color("!!! Command rejected:", "red")} {packet.reason}')
|
logger.warning(f'{color("!!! Command rejected:", "red")} {packet.reason}')
|
||||||
|
|
||||||
def on_l2cap_connection_request(self, connection, cid, request):
|
def on_l2cap_connection_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
) -> None:
|
||||||
# Check if there's a server for this PSM
|
# Check if there's a server for this PSM
|
||||||
server = self.servers.get(request.psm)
|
server = self.servers.get(request.psm)
|
||||||
if server:
|
if server:
|
||||||
@@ -1639,7 +1701,9 @@ class ChannelManager:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_connection_response(self, connection, cid, response):
|
def on_l2cap_connection_response(
|
||||||
|
self, connection: Connection, cid: int, response
|
||||||
|
) -> None:
|
||||||
if (
|
if (
|
||||||
channel := self.find_channel(connection.handle, response.source_cid)
|
channel := self.find_channel(connection.handle, response.source_cid)
|
||||||
) is None:
|
) is None:
|
||||||
@@ -1654,7 +1718,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_connection_response(response)
|
channel.on_connection_response(response)
|
||||||
|
|
||||||
def on_l2cap_configure_request(self, connection, cid, request):
|
def on_l2cap_configure_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
) -> None:
|
||||||
if (
|
if (
|
||||||
channel := self.find_channel(connection.handle, request.destination_cid)
|
channel := self.find_channel(connection.handle, request.destination_cid)
|
||||||
) is None:
|
) is None:
|
||||||
@@ -1669,7 +1735,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_configure_request(request)
|
channel.on_configure_request(request)
|
||||||
|
|
||||||
def on_l2cap_configure_response(self, connection, cid, response):
|
def on_l2cap_configure_response(
|
||||||
|
self, connection: Connection, cid: int, response
|
||||||
|
) -> None:
|
||||||
if (
|
if (
|
||||||
channel := self.find_channel(connection.handle, response.source_cid)
|
channel := self.find_channel(connection.handle, response.source_cid)
|
||||||
) is None:
|
) is None:
|
||||||
@@ -1684,7 +1752,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_configure_response(response)
|
channel.on_configure_response(response)
|
||||||
|
|
||||||
def on_l2cap_disconnection_request(self, connection, cid, request):
|
def on_l2cap_disconnection_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
) -> None:
|
||||||
if (
|
if (
|
||||||
channel := self.find_channel(connection.handle, request.destination_cid)
|
channel := self.find_channel(connection.handle, request.destination_cid)
|
||||||
) is None:
|
) is None:
|
||||||
@@ -1699,7 +1769,9 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_disconnection_request(request)
|
channel.on_disconnection_request(request)
|
||||||
|
|
||||||
def on_l2cap_disconnection_response(self, connection, cid, response):
|
def on_l2cap_disconnection_response(
|
||||||
|
self, connection: Connection, cid: int, response
|
||||||
|
) -> None:
|
||||||
if (
|
if (
|
||||||
channel := self.find_channel(connection.handle, response.source_cid)
|
channel := self.find_channel(connection.handle, response.source_cid)
|
||||||
) is None:
|
) is None:
|
||||||
@@ -1714,7 +1786,7 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_disconnection_response(response)
|
channel.on_disconnection_response(response)
|
||||||
|
|
||||||
def on_l2cap_echo_request(self, connection, cid, request):
|
def on_l2cap_echo_request(self, connection: Connection, cid: int, request) -> None:
|
||||||
logger.debug(f'<<< Echo request: data={request.data.hex()}')
|
logger.debug(f'<<< Echo request: data={request.data.hex()}')
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
connection,
|
connection,
|
||||||
@@ -1722,11 +1794,15 @@ class ChannelManager:
|
|||||||
L2CAP_Echo_Response(identifier=request.identifier, data=request.data),
|
L2CAP_Echo_Response(identifier=request.identifier, data=request.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_echo_response(self, _connection, _cid, response):
|
def on_l2cap_echo_response(
|
||||||
|
self, _connection: Connection, _cid: int, response
|
||||||
|
) -> None:
|
||||||
logger.debug(f'<<< Echo response: data={response.data.hex()}')
|
logger.debug(f'<<< Echo response: data={response.data.hex()}')
|
||||||
# TODO notify listeners
|
# TODO notify listeners
|
||||||
|
|
||||||
def on_l2cap_information_request(self, connection, cid, request):
|
def on_l2cap_information_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
) -> None:
|
||||||
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
|
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
|
||||||
result = L2CAP_Information_Response.SUCCESS
|
result = L2CAP_Information_Response.SUCCESS
|
||||||
data = self.connectionless_mtu.to_bytes(2, 'little')
|
data = self.connectionless_mtu.to_bytes(2, 'little')
|
||||||
@@ -1750,7 +1826,9 @@ class ChannelManager:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_connection_parameter_update_request(self, connection, cid, request):
|
def on_l2cap_connection_parameter_update_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
):
|
||||||
if connection.role == BT_CENTRAL_ROLE:
|
if connection.role == BT_CENTRAL_ROLE:
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
connection,
|
connection,
|
||||||
@@ -1769,7 +1847,7 @@ class ChannelManager:
|
|||||||
supervision_timeout=request.timeout,
|
supervision_timeout=request.timeout,
|
||||||
min_ce_length=0,
|
min_ce_length=0,
|
||||||
max_ce_length=0,
|
max_ce_length=0,
|
||||||
)
|
) # type: ignore[call-arg]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.send_control_frame(
|
self.send_control_frame(
|
||||||
@@ -1781,11 +1859,15 @@ class ChannelManager:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_connection_parameter_update_response(self, connection, cid, response):
|
def on_l2cap_connection_parameter_update_response(
|
||||||
|
self, connection: Connection, cid: int, response
|
||||||
|
) -> None:
|
||||||
# TODO: check response
|
# TODO: check response
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def on_l2cap_le_credit_based_connection_request(self, connection, cid, request):
|
def on_l2cap_le_credit_based_connection_request(
|
||||||
|
self, connection: Connection, cid: int, request
|
||||||
|
) -> None:
|
||||||
if request.le_psm in self.le_coc_servers:
|
if request.le_psm in self.le_coc_servers:
|
||||||
(server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
|
(server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
|
||||||
|
|
||||||
@@ -1887,7 +1969,9 @@ class ChannelManager:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_l2cap_le_credit_based_connection_response(self, connection, _cid, response):
|
def on_l2cap_le_credit_based_connection_response(
|
||||||
|
self, connection: Connection, _cid: int, response
|
||||||
|
) -> None:
|
||||||
# Find the pending request by identifier
|
# Find the pending request by identifier
|
||||||
request = self.le_coc_requests.get(response.identifier)
|
request = self.le_coc_requests.get(response.identifier)
|
||||||
if request is None:
|
if request is None:
|
||||||
@@ -1910,7 +1994,9 @@ class ChannelManager:
|
|||||||
# Process the response
|
# Process the response
|
||||||
channel.on_connection_response(response)
|
channel.on_connection_response(response)
|
||||||
|
|
||||||
def on_l2cap_le_flow_control_credit(self, connection, _cid, credit):
|
def on_l2cap_le_flow_control_credit(
|
||||||
|
self, connection: Connection, _cid: int, credit
|
||||||
|
) -> None:
|
||||||
channel = self.find_le_coc_channel(connection.handle, credit.cid)
|
channel = self.find_le_coc_channel(connection.handle, credit.cid)
|
||||||
if channel is None:
|
if channel is None:
|
||||||
logger.warning(f'received credits for an unknown channel (cid={credit.cid}')
|
logger.warning(f'received credits for an unknown channel (cid={credit.cid}')
|
||||||
@@ -1918,13 +2004,15 @@ class ChannelManager:
|
|||||||
|
|
||||||
channel.on_credits(credit.credits)
|
channel.on_credits(credit.credits)
|
||||||
|
|
||||||
def on_channel_closed(self, channel):
|
def on_channel_closed(self, channel: Channel) -> None:
|
||||||
connection_channels = self.channels.get(channel.connection.handle)
|
connection_channels = self.channels.get(channel.connection.handle)
|
||||||
if connection_channels:
|
if connection_channels:
|
||||||
if channel.source_cid in connection_channels:
|
if channel.source_cid in connection_channels:
|
||||||
del connection_channels[channel.source_cid]
|
del connection_channels[channel.source_cid]
|
||||||
|
|
||||||
async def open_le_coc(self, connection, psm, max_credits, mtu, mps):
|
async def open_le_coc(
|
||||||
|
self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
|
||||||
|
) -> LeConnectionOrientedChannel:
|
||||||
self.check_le_coc_parameters(max_credits, mtu, mps)
|
self.check_le_coc_parameters(max_credits, mtu, mps)
|
||||||
|
|
||||||
# Find a free CID for the new channel
|
# Find a free CID for the new channel
|
||||||
@@ -1965,7 +2053,7 @@ class ChannelManager:
|
|||||||
|
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
async def connect(self, connection, psm):
|
async def connect(self, connection: Connection, psm: int) -> Channel:
|
||||||
# NOTE: this implementation hard-codes BR/EDR
|
# NOTE: this implementation hard-codes BR/EDR
|
||||||
|
|
||||||
# Find a free CID for a new channel
|
# Find a free CID for a new channel
|
||||||
|
|||||||
+93
-66
@@ -25,6 +25,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import enum
|
||||||
import secrets
|
import secrets
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
@@ -553,20 +554,16 @@ class AddressResolver:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Session:
|
class PairingMethod(enum.IntEnum):
|
||||||
# Pairing methods
|
|
||||||
JUST_WORKS = 0
|
JUST_WORKS = 0
|
||||||
NUMERIC_COMPARISON = 1
|
NUMERIC_COMPARISON = 1
|
||||||
PASSKEY = 2
|
PASSKEY = 2
|
||||||
OOB = 3
|
OOB = 3
|
||||||
|
CTKD_OVER_CLASSIC = 4
|
||||||
|
|
||||||
PAIRING_METHOD_NAMES = {
|
|
||||||
JUST_WORKS: 'JUST_WORKS',
|
|
||||||
NUMERIC_COMPARISON: 'NUMERIC_COMPARISON',
|
|
||||||
PASSKEY: 'PASSKEY',
|
|
||||||
OOB: 'OOB',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Session:
|
||||||
# I/O Capability to pairing method decision matrix
|
# I/O Capability to pairing method decision matrix
|
||||||
#
|
#
|
||||||
# See Bluetooth spec @ Vol 3, part H - Table 2.8: Mapping of IO Capabilities to Key
|
# See Bluetooth spec @ Vol 3, part H - Table 2.8: Mapping of IO Capabilities to Key
|
||||||
@@ -581,47 +578,50 @@ class Session:
|
|||||||
# (False).
|
# (False).
|
||||||
PAIRING_METHODS = {
|
PAIRING_METHODS = {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: {
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, True, False),
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||||
},
|
},
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: {
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (JUST_WORKS, NUMERIC_COMPARISON),
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
|
PairingMethod.JUST_WORKS,
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
),
|
||||||
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||||
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
|
||||||
(PASSKEY, True, False),
|
(PairingMethod.PASSKEY, True, False),
|
||||||
NUMERIC_COMPARISON,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: {
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PASSKEY, False, True),
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, False, False),
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, False),
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, False, True),
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||||
},
|
},
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: JUST_WORKS,
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: JUST_WORKS,
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
},
|
},
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: {
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: {
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
|
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
|
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
|
||||||
(PASSKEY, False, True),
|
(PairingMethod.PASSKEY, False, True),
|
||||||
NUMERIC_COMPARISON,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
),
|
),
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
|
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
|
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
|
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
|
||||||
(PASSKEY, True, False),
|
(PairingMethod.PASSKEY, True, False),
|
||||||
NUMERIC_COMPARISON,
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -664,7 +664,7 @@ class Session:
|
|||||||
self.passkey_ready = asyncio.Event()
|
self.passkey_ready = asyncio.Event()
|
||||||
self.passkey_step = 0
|
self.passkey_step = 0
|
||||||
self.passkey_display = False
|
self.passkey_display = False
|
||||||
self.pairing_method = 0
|
self.pairing_method: PairingMethod = PairingMethod.JUST_WORKS
|
||||||
self.pairing_config = pairing_config
|
self.pairing_config = pairing_config
|
||||||
self.wait_before_continuing: Optional[asyncio.Future[None]] = None
|
self.wait_before_continuing: Optional[asyncio.Future[None]] = None
|
||||||
self.completed = False
|
self.completed = False
|
||||||
@@ -769,19 +769,23 @@ class Session:
|
|||||||
def decide_pairing_method(
|
def decide_pairing_method(
|
||||||
self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
|
self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if self.connection.transport == BT_BR_EDR_TRANSPORT:
|
||||||
|
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
|
||||||
|
return
|
||||||
if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0):
|
if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0):
|
||||||
self.pairing_method = self.JUST_WORKS
|
self.pairing_method = PairingMethod.JUST_WORKS
|
||||||
return
|
return
|
||||||
|
|
||||||
details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability] # type: ignore[index]
|
details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability] # type: ignore[index]
|
||||||
if isinstance(details, tuple) and len(details) == 2:
|
if isinstance(details, tuple) and len(details) == 2:
|
||||||
# One entry for legacy pairing and one for secure connections
|
# One entry for legacy pairing and one for secure connections
|
||||||
details = details[1 if self.sc else 0]
|
details = details[1 if self.sc else 0]
|
||||||
if isinstance(details, int):
|
if isinstance(details, PairingMethod):
|
||||||
# Just a method ID
|
# Just a method ID
|
||||||
self.pairing_method = details
|
self.pairing_method = details
|
||||||
else:
|
else:
|
||||||
# PASSKEY method, with a method ID and display/input flags
|
# PASSKEY method, with a method ID and display/input flags
|
||||||
|
assert isinstance(details[0], PairingMethod)
|
||||||
self.pairing_method = details[0]
|
self.pairing_method = details[0]
|
||||||
self.passkey_display = details[1 if self.is_initiator else 2]
|
self.passkey_display = details[1 if self.is_initiator else 2]
|
||||||
|
|
||||||
@@ -932,9 +936,12 @@ class Session:
|
|||||||
if self.sc:
|
if self.sc:
|
||||||
|
|
||||||
async def next_steps() -> None:
|
async def next_steps() -> None:
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
z = 0
|
z = 0
|
||||||
elif self.pairing_method == self.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
# We need a passkey
|
# We need a passkey
|
||||||
await self.passkey_ready.wait()
|
await self.passkey_ready.wait()
|
||||||
assert self.passkey
|
assert self.passkey
|
||||||
@@ -1227,7 +1234,7 @@ class Session:
|
|||||||
# Create an object to hold the keys
|
# Create an object to hold the keys
|
||||||
keys = PairingKeys()
|
keys = PairingKeys()
|
||||||
keys.address_type = peer_address.address_type
|
keys.address_type = peer_address.address_type
|
||||||
authenticated = self.pairing_method != self.JUST_WORKS
|
authenticated = self.pairing_method != PairingMethod.JUST_WORKS
|
||||||
if self.sc or self.connection.transport == BT_BR_EDR_TRANSPORT:
|
if self.sc or self.connection.transport == BT_BR_EDR_TRANSPORT:
|
||||||
keys.ltk = PairingKeys.Key(value=self.ltk, authenticated=authenticated)
|
keys.ltk = PairingKeys.Key(value=self.ltk, authenticated=authenticated)
|
||||||
else:
|
else:
|
||||||
@@ -1330,9 +1337,7 @@ class Session:
|
|||||||
self.decide_pairing_method(
|
self.decide_pairing_method(
|
||||||
command.auth_req, command.io_capability, self.io_capability
|
command.auth_req, command.io_capability, self.io_capability
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(f'pairing method: {self.pairing_method.name}')
|
||||||
f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Key distribution
|
# Key distribution
|
||||||
(
|
(
|
||||||
@@ -1348,7 +1353,7 @@ class Session:
|
|||||||
|
|
||||||
# Display a passkey if we need to
|
# Display a passkey if we need to
|
||||||
if not self.sc:
|
if not self.sc:
|
||||||
if self.pairing_method == self.PASSKEY and self.passkey_display:
|
if self.pairing_method == PairingMethod.PASSKEY and self.passkey_display:
|
||||||
self.display_passkey()
|
self.display_passkey()
|
||||||
|
|
||||||
# Respond
|
# Respond
|
||||||
@@ -1389,9 +1394,7 @@ class Session:
|
|||||||
self.decide_pairing_method(
|
self.decide_pairing_method(
|
||||||
command.auth_req, self.io_capability, command.io_capability
|
command.auth_req, self.io_capability, command.io_capability
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(f'pairing method: {self.pairing_method.name}')
|
||||||
f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Key distribution
|
# Key distribution
|
||||||
if (
|
if (
|
||||||
@@ -1407,13 +1410,16 @@ class Session:
|
|||||||
self.compute_peer_expected_distributions(self.responder_key_distribution)
|
self.compute_peer_expected_distributions(self.responder_key_distribution)
|
||||||
|
|
||||||
# Start phase 2
|
# Start phase 2
|
||||||
if self.sc:
|
if self.pairing_method == PairingMethod.CTKD_OVER_CLASSIC:
|
||||||
if self.pairing_method == self.PASSKEY:
|
# Authentication is already done in SMP, so remote shall start keys distribution immediately
|
||||||
|
return
|
||||||
|
elif self.sc:
|
||||||
|
if self.pairing_method == PairingMethod.PASSKEY:
|
||||||
self.display_or_input_passkey()
|
self.display_or_input_passkey()
|
||||||
|
|
||||||
self.send_public_key_command()
|
self.send_public_key_command()
|
||||||
else:
|
else:
|
||||||
if self.pairing_method == self.PASSKEY:
|
if self.pairing_method == PairingMethod.PASSKEY:
|
||||||
self.display_or_input_passkey(self.send_pairing_confirm_command)
|
self.display_or_input_passkey(self.send_pairing_confirm_command)
|
||||||
else:
|
else:
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
@@ -1425,7 +1431,10 @@ class Session:
|
|||||||
self.send_pairing_random_command()
|
self.send_pairing_random_command()
|
||||||
else:
|
else:
|
||||||
# If the method is PASSKEY, now is the time to input the code
|
# If the method is PASSKEY, now is the time to input the code
|
||||||
if self.pairing_method == self.PASSKEY and not self.passkey_display:
|
if (
|
||||||
|
self.pairing_method == PairingMethod.PASSKEY
|
||||||
|
and not self.passkey_display
|
||||||
|
):
|
||||||
self.input_passkey(self.send_pairing_confirm_command)
|
self.input_passkey(self.send_pairing_confirm_command)
|
||||||
else:
|
else:
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
@@ -1433,11 +1442,14 @@ class Session:
|
|||||||
def on_smp_pairing_confirm_command_secure_connections(
|
def on_smp_pairing_confirm_command_secure_connections(
|
||||||
self, _: SMP_Pairing_Confirm_Command
|
self, _: SMP_Pairing_Confirm_Command
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
self.r = crypto.r()
|
self.r = crypto.r()
|
||||||
self.send_pairing_random_command()
|
self.send_pairing_random_command()
|
||||||
elif self.pairing_method == self.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
self.send_pairing_random_command()
|
self.send_pairing_random_command()
|
||||||
else:
|
else:
|
||||||
@@ -1493,13 +1505,16 @@ class Session:
|
|||||||
def on_smp_pairing_random_command_secure_connections(
|
def on_smp_pairing_random_command_secure_connections(
|
||||||
self, command: SMP_Pairing_Random_Command
|
self, command: SMP_Pairing_Random_Command
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.pairing_method == self.PASSKEY and self.passkey is None:
|
if self.pairing_method == PairingMethod.PASSKEY and self.passkey is None:
|
||||||
logger.warning('no passkey entered, ignoring command')
|
logger.warning('no passkey entered, ignoring command')
|
||||||
return
|
return
|
||||||
|
|
||||||
# pylint: disable=too-many-return-statements
|
# pylint: disable=too-many-return-statements
|
||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
assert self.confirm_value
|
assert self.confirm_value
|
||||||
# Check that the random value matches what was committed to earlier
|
# Check that the random value matches what was committed to earlier
|
||||||
confirm_verifier = crypto.f4(
|
confirm_verifier = crypto.f4(
|
||||||
@@ -1509,7 +1524,7 @@ class Session:
|
|||||||
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
|
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
elif self.pairing_method == self.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
assert self.passkey and self.confirm_value
|
assert self.passkey and self.confirm_value
|
||||||
# Check that the random value matches what was committed to earlier
|
# Check that the random value matches what was committed to earlier
|
||||||
confirm_verifier = crypto.f4(
|
confirm_verifier = crypto.f4(
|
||||||
@@ -1532,9 +1547,12 @@ class Session:
|
|||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
self.send_pairing_random_command()
|
self.send_pairing_random_command()
|
||||||
elif self.pairing_method == self.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
assert self.passkey and self.confirm_value
|
assert self.passkey and self.confirm_value
|
||||||
# Check that the random value matches what was committed to earlier
|
# Check that the random value matches what was committed to earlier
|
||||||
confirm_verifier = crypto.f4(
|
confirm_verifier = crypto.f4(
|
||||||
@@ -1565,10 +1583,13 @@ class Session:
|
|||||||
(mac_key, self.ltk) = crypto.f5(self.dh_key, self.na, self.nb, a, b)
|
(mac_key, self.ltk) = crypto.f5(self.dh_key, self.na, self.nb, a, b)
|
||||||
|
|
||||||
# Compute the DH Key checks
|
# Compute the DH Key checks
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
ra = bytes(16)
|
ra = bytes(16)
|
||||||
rb = ra
|
rb = ra
|
||||||
elif self.pairing_method == self.PASSKEY:
|
elif self.pairing_method == PairingMethod.PASSKEY:
|
||||||
assert self.passkey
|
assert self.passkey
|
||||||
ra = self.passkey.to_bytes(16, byteorder='little')
|
ra = self.passkey.to_bytes(16, byteorder='little')
|
||||||
rb = ra
|
rb = ra
|
||||||
@@ -1592,13 +1613,16 @@ class Session:
|
|||||||
self.wait_before_continuing.set_result(None)
|
self.wait_before_continuing.set_result(None)
|
||||||
|
|
||||||
# Prompt the user for confirmation if needed
|
# Prompt the user for confirmation if needed
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
# Compute the 6-digit code
|
# Compute the 6-digit code
|
||||||
code = crypto.g2(self.pka, self.pkb, self.na, self.nb) % 1000000
|
code = crypto.g2(self.pka, self.pkb, self.na, self.nb) % 1000000
|
||||||
|
|
||||||
# Ask for user confirmation
|
# Ask for user confirmation
|
||||||
self.wait_before_continuing = asyncio.get_running_loop().create_future()
|
self.wait_before_continuing = asyncio.get_running_loop().create_future()
|
||||||
if self.pairing_method == self.JUST_WORKS:
|
if self.pairing_method == PairingMethod.JUST_WORKS:
|
||||||
self.prompt_user_for_confirmation(next_steps)
|
self.prompt_user_for_confirmation(next_steps)
|
||||||
else:
|
else:
|
||||||
self.prompt_user_for_numeric_comparison(code, next_steps)
|
self.prompt_user_for_numeric_comparison(code, next_steps)
|
||||||
@@ -1635,13 +1659,16 @@ class Session:
|
|||||||
if self.is_initiator:
|
if self.is_initiator:
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
else:
|
else:
|
||||||
if self.pairing_method == self.PASSKEY:
|
if self.pairing_method == PairingMethod.PASSKEY:
|
||||||
self.display_or_input_passkey()
|
self.display_or_input_passkey()
|
||||||
|
|
||||||
# Send our public key back to the initiator
|
# Send our public key back to the initiator
|
||||||
self.send_public_key_command()
|
self.send_public_key_command()
|
||||||
|
|
||||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
if self.pairing_method in (
|
||||||
|
PairingMethod.JUST_WORKS,
|
||||||
|
PairingMethod.NUMERIC_COMPARISON,
|
||||||
|
):
|
||||||
# We can now send the confirmation value
|
# We can now send the confirmation value
|
||||||
self.send_pairing_confirm_command()
|
self.send_pairing_confirm_command()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
/.idea
|
||||||
Generated
+1235
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
[package]
|
||||||
|
name = "bumble"
|
||||||
|
description = "Rust API for the Bumble Bluetooth stack"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
homepage = "https://google.github.io/bumble/index.html"
|
||||||
|
repository = "https://github.com/google/bumble"
|
||||||
|
documentation = "https://docs.rs/crate/bumble"
|
||||||
|
authors = ["Marshall Pierce <marshallpierce@google.com>"]
|
||||||
|
keywords = ["bluetooth", "ble"]
|
||||||
|
categories = ["api-bindings", "network-programming"]
|
||||||
|
rust-version = "1.69.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pyo3 = { version = "0.18.3", features = ["macros"] }
|
||||||
|
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
|
||||||
|
tokio = { version = "1.28.2" }
|
||||||
|
nom = "7.1.3"
|
||||||
|
strum = "0.25.0"
|
||||||
|
strum_macros = "0.25.0"
|
||||||
|
hex = "0.4.3"
|
||||||
|
itertools = "0.11.0"
|
||||||
|
lazy_static = "1.4.0"
|
||||||
|
thiserror = "1.0.41"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.28.2", features = ["full"] }
|
||||||
|
tempfile = "3.6.0"
|
||||||
|
nix = "0.26.2"
|
||||||
|
anyhow = "1.0.71"
|
||||||
|
pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
|
||||||
|
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] }
|
||||||
|
clap = { version = "4.3.3", features = ["derive"] }
|
||||||
|
owo-colors = "3.5.0"
|
||||||
|
log = "0.4.19"
|
||||||
|
env_logger = "0.10.0"
|
||||||
|
rusb = "0.9.2"
|
||||||
|
rand = "0.8.5"
|
||||||
|
|
||||||
|
# test entry point that uses pyo3_asyncio's test harness
|
||||||
|
[[test]]
|
||||||
|
name = "pytests"
|
||||||
|
path = "pytests/pytests.rs"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[features]
|
||||||
|
anyhow = ["pyo3/anyhow"]
|
||||||
|
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# What is this?
|
||||||
|
|
||||||
|
Rust wrappers around the [Bumble](https://github.com/google/bumble) Python API.
|
||||||
|
|
||||||
|
Method calls are mapped to the equivalent Python, and return types adapted where
|
||||||
|
relevant.
|
||||||
|
|
||||||
|
See the `examples` directory for usage.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
Set up a virtualenv for Bumble, or otherwise have an isolated Python environment
|
||||||
|
for Bumble and its dependencies.
|
||||||
|
|
||||||
|
Due to Python being
|
||||||
|
[picky about how its sys path is set up](https://github.com/PyO3/pyo3/issues/1741,
|
||||||
|
it's necessary to explicitly point to the virtualenv's `site-packages`. Use
|
||||||
|
suitable virtualenv paths as appropriate for your OS, as seen here running
|
||||||
|
the `battery_client` example:
|
||||||
|
|
||||||
|
```
|
||||||
|
PYTHONPATH=..:~/.virtualenvs/bumble/lib/python3.10/site-packages/ \
|
||||||
|
cargo run --example battery_client -- \
|
||||||
|
--transport android-netsim --target-addr F0:F1:F2:F3:F4:F5
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the corresponding `battery_server` Python example, and launch an emulator in
|
||||||
|
Android Studio (currently, Canary is required) to run netsim.
|
||||||
|
|
||||||
|
# Development
|
||||||
|
|
||||||
|
Run the tests:
|
||||||
|
|
||||||
|
```
|
||||||
|
PYTHONPATH=.. cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Check lints:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo clippy --all-targets
|
||||||
|
```
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Counterpart to the Python example `battery_server.py`.
|
||||||
|
//!
|
||||||
|
//! Start an Android emulator from Android Studio, or otherwise have netsim running.
|
||||||
|
//!
|
||||||
|
//! Run the server from the project root:
|
||||||
|
//! ```
|
||||||
|
//! PYTHONPATH=. python examples/battery_server.py \
|
||||||
|
//! examples/device1.json android-netsim
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Then run this example from the `rust` directory:
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! PYTHONPATH=..:/path/to/virtualenv/site-packages/ \
|
||||||
|
//! cargo run --example battery_client -- \
|
||||||
|
//! --transport android-netsim \
|
||||||
|
//! --target-addr F0:F1:F2:F3:F4:F5
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use bumble::wrapper::{
|
||||||
|
device::{Device, Peer},
|
||||||
|
profile::BatteryServiceProxy,
|
||||||
|
transport::Transport,
|
||||||
|
PyObjectExt,
|
||||||
|
};
|
||||||
|
use clap::Parser as _;
|
||||||
|
use log::info;
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::main]
|
||||||
|
async fn main() -> PyResult<()> {
|
||||||
|
env_logger::builder()
|
||||||
|
.filter_level(log::LevelFilter::Info)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let transport = Transport::open(cli.transport).await?;
|
||||||
|
|
||||||
|
let device = Device::with_hci(
|
||||||
|
"Bumble",
|
||||||
|
"F0:F1:F2:F3:F4:F5",
|
||||||
|
transport.source()?,
|
||||||
|
transport.sink()?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
device.power_on().await?;
|
||||||
|
|
||||||
|
let conn = device.connect(&cli.target_addr).await?;
|
||||||
|
let mut peer = Peer::new(conn)?;
|
||||||
|
for mut s in peer.discover_services().await? {
|
||||||
|
s.discover_characteristics().await?;
|
||||||
|
}
|
||||||
|
let battery_service = peer
|
||||||
|
.create_service_proxy::<BatteryServiceProxy>()?
|
||||||
|
.ok_or(anyhow::anyhow!("No battery service found"))?;
|
||||||
|
|
||||||
|
let mut battery_level_char = battery_service
|
||||||
|
.battery_level()?
|
||||||
|
.ok_or(anyhow::anyhow!("No battery level characteristic"))?;
|
||||||
|
info!(
|
||||||
|
"{} {}",
|
||||||
|
"Initial Battery Level:".green(),
|
||||||
|
battery_level_char
|
||||||
|
.read_value()
|
||||||
|
.await?
|
||||||
|
.extract_with_gil::<u32>()?
|
||||||
|
);
|
||||||
|
battery_level_char
|
||||||
|
.subscribe(|_py, args| {
|
||||||
|
info!(
|
||||||
|
"{} {:?}",
|
||||||
|
"Battery level update:".green(),
|
||||||
|
args.get_item(0)?.extract::<u32>()?,
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// wait until user kills the process
|
||||||
|
tokio::signal::ctrl_c().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
/// Bumble transport spec.
|
||||||
|
///
|
||||||
|
/// <https://google.github.io/bumble/transports/index.html>
|
||||||
|
#[arg(long)]
|
||||||
|
transport: String,
|
||||||
|
|
||||||
|
/// Address to connect to
|
||||||
|
#[arg(long)]
|
||||||
|
target_addr: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use bumble::{
|
||||||
|
adv::{AdvertisementDataBuilder, CommonDataType},
|
||||||
|
wrapper::{
|
||||||
|
device::Device,
|
||||||
|
logging::{bumble_env_logging_level, py_logging_basic_config},
|
||||||
|
transport::Transport,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use clap::Parser as _;
|
||||||
|
use pyo3::PyResult;
|
||||||
|
use rand::Rng;
|
||||||
|
use std::path;
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::main]
|
||||||
|
async fn main() -> PyResult<()> {
|
||||||
|
env_logger::builder()
|
||||||
|
.filter_level(log::LevelFilter::Info)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
if cli.log_hci {
|
||||||
|
py_logging_basic_config(bumble_env_logging_level("DEBUG"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transport = Transport::open(cli.transport).await?;
|
||||||
|
|
||||||
|
let mut device = Device::from_config_file_with_hci(
|
||||||
|
&cli.device_config,
|
||||||
|
transport.source()?,
|
||||||
|
transport.sink()?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut adv_data = AdvertisementDataBuilder::new();
|
||||||
|
|
||||||
|
adv_data
|
||||||
|
.append(
|
||||||
|
CommonDataType::CompleteLocalName,
|
||||||
|
"Bumble from Rust".as_bytes(),
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
|
||||||
|
// Randomized TX power
|
||||||
|
adv_data
|
||||||
|
.append(
|
||||||
|
CommonDataType::TxPowerLevel,
|
||||||
|
&[rand::thread_rng().gen_range(-100_i8..=20) as u8],
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!(e))?;
|
||||||
|
|
||||||
|
device.set_advertising_data(adv_data)?;
|
||||||
|
device.power_on().await?;
|
||||||
|
|
||||||
|
println!("Advertising...");
|
||||||
|
device.start_advertising(true).await?;
|
||||||
|
|
||||||
|
// wait until user kills the process
|
||||||
|
tokio::signal::ctrl_c().await?;
|
||||||
|
|
||||||
|
println!("Stopping...");
|
||||||
|
device.stop_advertising().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
/// Bumble device config.
|
||||||
|
///
|
||||||
|
/// See, for instance, `examples/device1.json` in the Python project.
|
||||||
|
#[arg(long)]
|
||||||
|
device_config: path::PathBuf,
|
||||||
|
/// Bumble transport spec.
|
||||||
|
///
|
||||||
|
/// <https://google.github.io/bumble/transports/index.html>
|
||||||
|
#[arg(long)]
|
||||||
|
transport: String,
|
||||||
|
|
||||||
|
/// Log HCI commands
|
||||||
|
#[arg(long)]
|
||||||
|
log_hci: bool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Counterpart to the Python example `run_scanner.py`.
|
||||||
|
//!
|
||||||
|
//! Device deduplication is done here rather than relying on the controller's filtering to provide
|
||||||
|
//! for additional features, like the ability to make deduplication time-bounded.
|
||||||
|
|
||||||
|
use bumble::{
|
||||||
|
adv::CommonDataType,
|
||||||
|
wrapper::{
|
||||||
|
core::AdvertisementDataUnit, device::Device, hci::AddressType, transport::Transport,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use clap::Parser as _;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use owo_colors::{OwoColorize, Style};
|
||||||
|
use pyo3::PyResult;
|
||||||
|
use std::{
|
||||||
|
collections,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::main]
|
||||||
|
async fn main() -> PyResult<()> {
|
||||||
|
env_logger::builder()
|
||||||
|
.filter_level(log::LevelFilter::Info)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let transport = Transport::open(cli.transport).await?;
|
||||||
|
|
||||||
|
let mut device = Device::with_hci(
|
||||||
|
"Bumble",
|
||||||
|
"F0:F1:F2:F3:F4:F5",
|
||||||
|
transport.source()?,
|
||||||
|
transport.sink()?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// in practice, devices can send multiple advertisements from the same address, so we keep
|
||||||
|
// track of a timestamp for each set of data
|
||||||
|
let seen_advertisements = Arc::new(Mutex::new(collections::HashMap::<
|
||||||
|
Vec<u8>,
|
||||||
|
collections::HashMap<Vec<AdvertisementDataUnit>, time::Instant>,
|
||||||
|
>::new()));
|
||||||
|
|
||||||
|
let seen_adv_clone = seen_advertisements.clone();
|
||||||
|
device.on_advertisement(move |_py, adv| {
|
||||||
|
let rssi = adv.rssi()?;
|
||||||
|
let data_units = adv.data()?.data_units()?;
|
||||||
|
let addr = adv.address()?;
|
||||||
|
|
||||||
|
let show_adv = if cli.filter_duplicates {
|
||||||
|
let addr_bytes = addr.as_le_bytes()?;
|
||||||
|
|
||||||
|
let mut seen_adv_cache = seen_adv_clone.lock().unwrap();
|
||||||
|
let expiry_duration = time::Duration::from_secs(cli.dedup_expiry_secs);
|
||||||
|
|
||||||
|
let advs_from_addr = seen_adv_cache
|
||||||
|
.entry(addr_bytes)
|
||||||
|
.or_insert_with(collections::HashMap::new);
|
||||||
|
// we expect cache hits to be the norm, so we do a separate lookup to avoid cloning
|
||||||
|
// on every lookup with entry()
|
||||||
|
let show = if let Some(prev) = advs_from_addr.get_mut(&data_units) {
|
||||||
|
let expired = prev.elapsed() > expiry_duration;
|
||||||
|
*prev = time::Instant::now();
|
||||||
|
expired
|
||||||
|
} else {
|
||||||
|
advs_from_addr.insert(data_units.clone(), time::Instant::now());
|
||||||
|
true
|
||||||
|
};
|
||||||
|
|
||||||
|
// clean out anything we haven't seen in a while
|
||||||
|
advs_from_addr.retain(|_, instant| instant.elapsed() <= expiry_duration);
|
||||||
|
|
||||||
|
show
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
|
||||||
|
if !show_adv {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let addr_style = if adv.is_connectable()? {
|
||||||
|
Style::new().yellow()
|
||||||
|
} else {
|
||||||
|
Style::new().red()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (type_style, qualifier) = match adv.address()?.address_type()? {
|
||||||
|
AddressType::PublicIdentity | AddressType::PublicDevice => (Style::new().cyan(), ""),
|
||||||
|
_ => {
|
||||||
|
if addr.is_static()? {
|
||||||
|
(Style::new().green(), "(static)")
|
||||||
|
} else if addr.is_resolvable()? {
|
||||||
|
(Style::new().magenta(), "(resolvable)")
|
||||||
|
} else {
|
||||||
|
(Style::new().default_color(), "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
">>> {} [{:?}] {qualifier}:\n RSSI: {}",
|
||||||
|
addr.as_hex()?.style(addr_style),
|
||||||
|
addr.address_type()?.style(type_style),
|
||||||
|
rssi,
|
||||||
|
);
|
||||||
|
|
||||||
|
data_units.into_iter().for_each(|(code, data)| {
|
||||||
|
let matching = CommonDataType::for_type_code(code).collect::<Vec<_>>();
|
||||||
|
let code_str = if matching.is_empty() {
|
||||||
|
format!("0x{}", hex::encode_upper([code.into()]))
|
||||||
|
} else {
|
||||||
|
matching
|
||||||
|
.iter()
|
||||||
|
.map(|t| format!("{}", t))
|
||||||
|
.join(" / ")
|
||||||
|
.blue()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// use the first matching type's formatted data, if any
|
||||||
|
let data_str = matching
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| {
|
||||||
|
t.format_data(&data).map(|formatted| {
|
||||||
|
format!(
|
||||||
|
"{} {}",
|
||||||
|
formatted,
|
||||||
|
format!("(raw: 0x{})", hex::encode_upper(&data)).dimmed()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.next()
|
||||||
|
.unwrap_or_else(|| format!("0x{}", hex::encode_upper(&data)));
|
||||||
|
|
||||||
|
println!(" [{}]: {}", code_str, data_str)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
device.power_on().await?;
|
||||||
|
// do our own dedup
|
||||||
|
device.start_scanning(false).await?;
|
||||||
|
|
||||||
|
// wait until user kills the process
|
||||||
|
tokio::signal::ctrl_c().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
/// Bumble transport spec.
|
||||||
|
///
|
||||||
|
/// <https://google.github.io/bumble/transports/index.html>
|
||||||
|
#[arg(long)]
|
||||||
|
transport: String,
|
||||||
|
|
||||||
|
/// Filter duplicate advertisements
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
filter_duplicates: bool,
|
||||||
|
|
||||||
|
/// How long before a deduplicated advertisement that hasn't been seen in a while is considered
|
||||||
|
/// fresh again, in seconds
|
||||||
|
#[arg(long, default_value_t = 10, requires = "filter_duplicates")]
|
||||||
|
dedup_expiry_secs: u64,
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Rust version of the Python `usb_probe.py`.
|
||||||
|
//!
|
||||||
|
//! This tool lists all the USB devices, with details about each device.
|
||||||
|
//! For each device, the different possible Bumble transport strings that can
|
||||||
|
//! refer to it are listed. If the device is known to be a Bluetooth HCI device,
|
||||||
|
//! its identifier is printed in reverse colors, and the transport names in cyan color.
|
||||||
|
//! For other devices, regardless of their type, the transport names are printed
|
||||||
|
//! in red. Whether that device is actually a Bluetooth device or not depends on
|
||||||
|
//! whether it is a Bluetooth device that uses a non-standard Class, or some other
|
||||||
|
//! type of device (there's no way to tell).
|
||||||
|
|
||||||
|
use clap::Parser as _;
|
||||||
|
use itertools::Itertools as _;
|
||||||
|
use owo_colors::{OwoColorize, Style};
|
||||||
|
use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
|
||||||
|
const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
|
||||||
|
const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
|
||||||
|
const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let mut bt_dev_count = 0;
|
||||||
|
let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
|
||||||
|
for device in rusb::devices()?.iter() {
|
||||||
|
let device_desc = device.device_descriptor().unwrap();
|
||||||
|
|
||||||
|
let class_info = ClassInfo::from(&device_desc);
|
||||||
|
let handle = device.open()?;
|
||||||
|
let timeout = Duration::from_secs(1);
|
||||||
|
// some devices don't have languages
|
||||||
|
let lang = handle
|
||||||
|
.read_languages(timeout)
|
||||||
|
.ok()
|
||||||
|
.and_then(|langs| langs.into_iter().next());
|
||||||
|
let serial = lang.and_then(|l| {
|
||||||
|
handle
|
||||||
|
.read_serial_number_string(l, &device_desc, timeout)
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
let mfg = lang.and_then(|l| {
|
||||||
|
handle
|
||||||
|
.read_manufacturer_string(l, &device_desc, timeout)
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
let product = lang.and_then(|l| handle.read_product_string(l, &device_desc, timeout).ok());
|
||||||
|
|
||||||
|
let is_hci = is_bluetooth_hci(&device, &device_desc)?;
|
||||||
|
let addr_style = if is_hci {
|
||||||
|
bt_dev_count += 1;
|
||||||
|
Style::new().black().on_yellow()
|
||||||
|
} else {
|
||||||
|
Style::new().yellow().on_black()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut transport_names = Vec::new();
|
||||||
|
let basic_transport_name = format!(
|
||||||
|
"usb:{:04X}:{:04X}",
|
||||||
|
device_desc.vendor_id(),
|
||||||
|
device_desc.product_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
if is_hci {
|
||||||
|
transport_names.push(format!("usb:{}", bt_dev_count - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_id = (device_desc.vendor_id(), device_desc.product_id());
|
||||||
|
if !device_serials_by_id.contains_key(&device_id) {
|
||||||
|
transport_names.push(basic_transport_name.clone());
|
||||||
|
} else {
|
||||||
|
transport_names.push(format!(
|
||||||
|
"{}#{}",
|
||||||
|
basic_transport_name,
|
||||||
|
device_serials_by_id
|
||||||
|
.get(&device_id)
|
||||||
|
.map(|serials| serials.len())
|
||||||
|
.unwrap_or(0)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(s) = &serial {
|
||||||
|
if !device_serials_by_id
|
||||||
|
.get(&device_id)
|
||||||
|
.map(|serials| serials.contains(s))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
transport_names.push(format!("{}/{}", basic_transport_name, s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(
|
||||||
|
"ID {:04X}:{:04X}",
|
||||||
|
device_desc.vendor_id(),
|
||||||
|
device_desc.product_id()
|
||||||
|
)
|
||||||
|
.style(addr_style)
|
||||||
|
);
|
||||||
|
if !transport_names.is_empty() {
|
||||||
|
let style = if is_hci {
|
||||||
|
Style::new().cyan()
|
||||||
|
} else {
|
||||||
|
Style::new().red()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{:26}{}",
|
||||||
|
" Bumble Transport Names:".blue(),
|
||||||
|
transport_names.iter().map(|n| n.style(style)).join(" or ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{:26}{:03}/{:03}",
|
||||||
|
" Bus/Device:".green(),
|
||||||
|
device.bus_number(),
|
||||||
|
device.address()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{:26}{}",
|
||||||
|
" Class:".green(),
|
||||||
|
class_info.formatted_class_name()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{:26}{}",
|
||||||
|
" Subclass/Protocol:".green(),
|
||||||
|
class_info.formatted_subclass_protocol()
|
||||||
|
);
|
||||||
|
if let Some(s) = serial {
|
||||||
|
println!("{:26}{}", " Serial:".green(), s);
|
||||||
|
device_serials_by_id
|
||||||
|
.entry(device_id)
|
||||||
|
.or_insert(HashSet::new())
|
||||||
|
.insert(s);
|
||||||
|
}
|
||||||
|
if let Some(m) = mfg {
|
||||||
|
println!("{:26}{}", " Manufacturer:".green(), m);
|
||||||
|
}
|
||||||
|
if let Some(p) = product {
|
||||||
|
println!("{:26}{}", " Product:".green(), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.verbose {
|
||||||
|
print_device_details(&device, &device_desc)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_bluetooth_hci<T: UsbContext>(
|
||||||
|
device: &Device<T>,
|
||||||
|
device_desc: &DeviceDescriptor,
|
||||||
|
) -> rusb::Result<bool> {
|
||||||
|
if device_desc.class_code() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER
|
||||||
|
&& device_desc.sub_class_code() == USB_DEVICE_SUBCLASS_RF_CONTROLLER
|
||||||
|
&& device_desc.protocol_code() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
|
||||||
|
{
|
||||||
|
Ok(true)
|
||||||
|
} else if device_desc.class_code() == USB_DEVICE_CLASS_DEVICE {
|
||||||
|
for i in 0..device_desc.num_configurations() {
|
||||||
|
for interface in device.config_descriptor(i)?.interfaces() {
|
||||||
|
for d in interface.descriptors() {
|
||||||
|
if d.class_code() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER
|
||||||
|
&& d.sub_class_code() == USB_DEVICE_SUBCLASS_RF_CONTROLLER
|
||||||
|
&& d.protocol_code() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_device_details<T: UsbContext>(
|
||||||
|
device: &Device<T>,
|
||||||
|
device_desc: &DeviceDescriptor,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
for i in 0..device_desc.num_configurations() {
|
||||||
|
println!(" Configuration {}", i + 1);
|
||||||
|
for interface in device.config_descriptor(i)?.interfaces() {
|
||||||
|
let interface_descriptors: Vec<_> = interface.descriptors().collect();
|
||||||
|
for d in &interface_descriptors {
|
||||||
|
let class_info =
|
||||||
|
ClassInfo::new(d.class_code(), d.sub_class_code(), d.protocol_code());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" Interface: {}{} ({}, {})",
|
||||||
|
interface.number(),
|
||||||
|
if interface_descriptors.len() > 1 {
|
||||||
|
format!("/{}", d.setting_number())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
},
|
||||||
|
class_info.formatted_class_name(),
|
||||||
|
class_info.formatted_subclass_protocol()
|
||||||
|
);
|
||||||
|
|
||||||
|
for e in d.endpoint_descriptors() {
|
||||||
|
println!(
|
||||||
|
" Endpoint {:#04X}: {} {}",
|
||||||
|
e.address(),
|
||||||
|
match e.transfer_type() {
|
||||||
|
TransferType::Control => "CONTROL",
|
||||||
|
TransferType::Isochronous => "ISOCHRONOUS",
|
||||||
|
TransferType::Bulk => "BULK",
|
||||||
|
TransferType::Interrupt => "INTERRUPT",
|
||||||
|
},
|
||||||
|
match e.direction() {
|
||||||
|
Direction::In => "IN",
|
||||||
|
Direction::Out => "OUT",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClassInfo {
|
||||||
|
class: u8,
|
||||||
|
sub_class: u8,
|
||||||
|
protocol: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClassInfo {
|
||||||
|
fn new(class: u8, sub_class: u8, protocol: u8) -> Self {
|
||||||
|
Self {
|
||||||
|
class,
|
||||||
|
sub_class,
|
||||||
|
protocol,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class_name(&self) -> Option<&str> {
|
||||||
|
match self.class {
|
||||||
|
0x00 => Some("Device"),
|
||||||
|
0x01 => Some("Audio"),
|
||||||
|
0x02 => Some("Communications and CDC Control"),
|
||||||
|
0x03 => Some("Human Interface Device"),
|
||||||
|
0x05 => Some("Physical"),
|
||||||
|
0x06 => Some("Still Imaging"),
|
||||||
|
0x07 => Some("Printer"),
|
||||||
|
0x08 => Some("Mass Storage"),
|
||||||
|
0x09 => Some("Hub"),
|
||||||
|
0x0A => Some("CDC Data"),
|
||||||
|
0x0B => Some("Smart Card"),
|
||||||
|
0x0D => Some("Content Security"),
|
||||||
|
0x0E => Some("Video"),
|
||||||
|
0x0F => Some("Personal Healthcare"),
|
||||||
|
0x10 => Some("Audio/Video"),
|
||||||
|
0x11 => Some("Billboard"),
|
||||||
|
0x12 => Some("USB Type-C Bridge"),
|
||||||
|
0x3C => Some("I3C"),
|
||||||
|
0xDC => Some("Diagnostic"),
|
||||||
|
USB_DEVICE_CLASS_WIRELESS_CONTROLLER => Some("Wireless Controller"),
|
||||||
|
0xEF => Some("Miscellaneous"),
|
||||||
|
0xFE => Some("Application Specific"),
|
||||||
|
0xFF => Some("Vendor Specific"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protocol_name(&self) -> Option<&str> {
|
||||||
|
match self.class {
|
||||||
|
USB_DEVICE_CLASS_WIRELESS_CONTROLLER => match self.sub_class {
|
||||||
|
0x01 => match self.protocol {
|
||||||
|
0x01 => Some("Bluetooth"),
|
||||||
|
0x02 => Some("UWB"),
|
||||||
|
0x03 => Some("Remote NDIS"),
|
||||||
|
0x04 => Some("Bluetooth AMP"),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn formatted_class_name(&self) -> String {
|
||||||
|
self.class_name()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("{:#04X}", self.class))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn formatted_subclass_protocol(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}{}",
|
||||||
|
self.sub_class,
|
||||||
|
self.protocol,
|
||||||
|
self.protocol_name()
|
||||||
|
.map(|s| format!(" [{}]", s))
|
||||||
|
.unwrap_or_else(String::new)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&DeviceDescriptor> for ClassInfo {
|
||||||
|
fn from(value: &DeviceDescriptor) -> Self {
|
||||||
|
Self::new(
|
||||||
|
value.class_code(),
|
||||||
|
value.sub_class_code(),
|
||||||
|
value.protocol_code(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
/// Show additional info for each USB device
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
verbose: bool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::main]
|
||||||
|
async fn main() -> pyo3::PyResult<()> {
|
||||||
|
pyo3_asyncio::testing::main().await
|
||||||
|
}
|
||||||
|
|
||||||
|
mod wrapper;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use bumble::{wrapper, wrapper::transport::Transport};
|
||||||
|
use nix::sys::stat::Mode;
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::test]
|
||||||
|
async fn fifo_transport_can_open() -> PyResult<()> {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut fifo = dir.path().to_path_buf();
|
||||||
|
fifo.push("bumble-transport-fifo");
|
||||||
|
nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap();
|
||||||
|
|
||||||
|
let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?;
|
||||||
|
|
||||||
|
t.close().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3_asyncio::tokio::test]
|
||||||
|
async fn company_ids() -> PyResult<()> {
|
||||||
|
assert!(wrapper::assigned_numbers::COMPANY_IDS.len() > 2000);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+446
@@ -0,0 +1,446 @@
|
|||||||
|
//! BLE advertisements.
|
||||||
|
|
||||||
|
use crate::wrapper::assigned_numbers::{COMPANY_IDS, SERVICE_IDS};
|
||||||
|
use crate::wrapper::core::{Uuid128, Uuid16, Uuid32};
|
||||||
|
use itertools::Itertools;
|
||||||
|
use nom::{combinator, multi, number};
|
||||||
|
use std::fmt;
|
||||||
|
use strum::IntoEnumIterator;
|
||||||
|
|
||||||
|
/// The numeric code for a common data type.
|
||||||
|
///
|
||||||
|
/// For known types, see [CommonDataType], or use this type directly for non-assigned codes.
|
||||||
|
#[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)]
|
||||||
|
pub struct CommonDataTypeCode(u8);
|
||||||
|
|
||||||
|
impl From<CommonDataType> for CommonDataTypeCode {
|
||||||
|
fn from(value: CommonDataType) -> Self {
|
||||||
|
let byte = match value {
|
||||||
|
CommonDataType::Flags => 0x01,
|
||||||
|
CommonDataType::IncompleteListOf16BitServiceClassUuids => 0x02,
|
||||||
|
CommonDataType::CompleteListOf16BitServiceClassUuids => 0x03,
|
||||||
|
CommonDataType::IncompleteListOf32BitServiceClassUuids => 0x04,
|
||||||
|
CommonDataType::CompleteListOf32BitServiceClassUuids => 0x05,
|
||||||
|
CommonDataType::IncompleteListOf128BitServiceClassUuids => 0x06,
|
||||||
|
CommonDataType::CompleteListOf128BitServiceClassUuids => 0x07,
|
||||||
|
CommonDataType::ShortenedLocalName => 0x08,
|
||||||
|
CommonDataType::CompleteLocalName => 0x09,
|
||||||
|
CommonDataType::TxPowerLevel => 0x0A,
|
||||||
|
CommonDataType::ClassOfDevice => 0x0D,
|
||||||
|
CommonDataType::SimplePairingHashC192 => 0x0E,
|
||||||
|
CommonDataType::SimplePairingRandomizerR192 => 0x0F,
|
||||||
|
// These two both really have type code 0x10! D:
|
||||||
|
CommonDataType::DeviceId => 0x10,
|
||||||
|
CommonDataType::SecurityManagerTkValue => 0x10,
|
||||||
|
CommonDataType::SecurityManagerOutOfBandFlags => 0x11,
|
||||||
|
CommonDataType::PeripheralConnectionIntervalRange => 0x12,
|
||||||
|
CommonDataType::ListOf16BitServiceSolicitationUuids => 0x14,
|
||||||
|
CommonDataType::ListOf128BitServiceSolicitationUuids => 0x15,
|
||||||
|
CommonDataType::ServiceData16BitUuid => 0x16,
|
||||||
|
CommonDataType::PublicTargetAddress => 0x17,
|
||||||
|
CommonDataType::RandomTargetAddress => 0x18,
|
||||||
|
CommonDataType::Appearance => 0x19,
|
||||||
|
CommonDataType::AdvertisingInterval => 0x1A,
|
||||||
|
CommonDataType::LeBluetoothDeviceAddress => 0x1B,
|
||||||
|
CommonDataType::LeRole => 0x1C,
|
||||||
|
CommonDataType::SimplePairingHashC256 => 0x1D,
|
||||||
|
CommonDataType::SimplePairingRandomizerR256 => 0x1E,
|
||||||
|
CommonDataType::ListOf32BitServiceSolicitationUuids => 0x1F,
|
||||||
|
CommonDataType::ServiceData32BitUuid => 0x20,
|
||||||
|
CommonDataType::ServiceData128BitUuid => 0x21,
|
||||||
|
CommonDataType::LeSecureConnectionsConfirmationValue => 0x22,
|
||||||
|
CommonDataType::LeSecureConnectionsRandomValue => 0x23,
|
||||||
|
CommonDataType::Uri => 0x24,
|
||||||
|
CommonDataType::IndoorPositioning => 0x25,
|
||||||
|
CommonDataType::TransportDiscoveryData => 0x26,
|
||||||
|
CommonDataType::LeSupportedFeatures => 0x27,
|
||||||
|
CommonDataType::ChannelMapUpdateIndication => 0x28,
|
||||||
|
CommonDataType::PbAdv => 0x29,
|
||||||
|
CommonDataType::MeshMessage => 0x2A,
|
||||||
|
CommonDataType::MeshBeacon => 0x2B,
|
||||||
|
CommonDataType::BigInfo => 0x2C,
|
||||||
|
CommonDataType::BroadcastCode => 0x2D,
|
||||||
|
CommonDataType::ResolvableSetIdentifier => 0x2E,
|
||||||
|
CommonDataType::AdvertisingIntervalLong => 0x2F,
|
||||||
|
CommonDataType::ThreeDInformationData => 0x3D,
|
||||||
|
CommonDataType::ManufacturerSpecificData => 0xFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self(byte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u8> for CommonDataTypeCode {
|
||||||
|
fn from(value: u8) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CommonDataTypeCode> for u8 {
|
||||||
|
fn from(value: CommonDataTypeCode) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data types for assigned type codes.
|
||||||
|
///
|
||||||
|
/// See Bluetooth Assigned Numbers § 2.3
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumIter)]
|
||||||
|
#[allow(missing_docs)]
|
||||||
|
pub enum CommonDataType {
|
||||||
|
Flags,
|
||||||
|
IncompleteListOf16BitServiceClassUuids,
|
||||||
|
CompleteListOf16BitServiceClassUuids,
|
||||||
|
IncompleteListOf32BitServiceClassUuids,
|
||||||
|
CompleteListOf32BitServiceClassUuids,
|
||||||
|
IncompleteListOf128BitServiceClassUuids,
|
||||||
|
CompleteListOf128BitServiceClassUuids,
|
||||||
|
ShortenedLocalName,
|
||||||
|
CompleteLocalName,
|
||||||
|
TxPowerLevel,
|
||||||
|
ClassOfDevice,
|
||||||
|
SimplePairingHashC192,
|
||||||
|
SimplePairingRandomizerR192,
|
||||||
|
DeviceId,
|
||||||
|
SecurityManagerTkValue,
|
||||||
|
SecurityManagerOutOfBandFlags,
|
||||||
|
PeripheralConnectionIntervalRange,
|
||||||
|
ListOf16BitServiceSolicitationUuids,
|
||||||
|
ListOf128BitServiceSolicitationUuids,
|
||||||
|
ServiceData16BitUuid,
|
||||||
|
PublicTargetAddress,
|
||||||
|
RandomTargetAddress,
|
||||||
|
Appearance,
|
||||||
|
AdvertisingInterval,
|
||||||
|
LeBluetoothDeviceAddress,
|
||||||
|
LeRole,
|
||||||
|
SimplePairingHashC256,
|
||||||
|
SimplePairingRandomizerR256,
|
||||||
|
ListOf32BitServiceSolicitationUuids,
|
||||||
|
ServiceData32BitUuid,
|
||||||
|
ServiceData128BitUuid,
|
||||||
|
LeSecureConnectionsConfirmationValue,
|
||||||
|
LeSecureConnectionsRandomValue,
|
||||||
|
Uri,
|
||||||
|
IndoorPositioning,
|
||||||
|
TransportDiscoveryData,
|
||||||
|
LeSupportedFeatures,
|
||||||
|
ChannelMapUpdateIndication,
|
||||||
|
PbAdv,
|
||||||
|
MeshMessage,
|
||||||
|
MeshBeacon,
|
||||||
|
BigInfo,
|
||||||
|
BroadcastCode,
|
||||||
|
ResolvableSetIdentifier,
|
||||||
|
AdvertisingIntervalLong,
|
||||||
|
ThreeDInformationData,
|
||||||
|
ManufacturerSpecificData,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonDataType {
|
||||||
|
/// Iterate over the zero, one, or more matching types for the provided code.
|
||||||
|
///
|
||||||
|
/// `0x10` maps to both Device Id and Security Manager TK Value, so multiple matching types
|
||||||
|
/// may exist for a single code.
|
||||||
|
pub fn for_type_code(code: CommonDataTypeCode) -> impl Iterator<Item = CommonDataType> {
|
||||||
|
Self::iter().filter(move |t| CommonDataTypeCode::from(*t) == code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply type-specific human-oriented formatting to data, if any is applicable
|
||||||
|
pub fn format_data(&self, data: &[u8]) -> Option<String> {
|
||||||
|
match self {
|
||||||
|
Self::Flags => Some(Flags::matching(data).map(|f| format!("{:?}", f)).join(",")),
|
||||||
|
Self::CompleteListOf16BitServiceClassUuids
|
||||||
|
| Self::IncompleteListOf16BitServiceClassUuids
|
||||||
|
| Self::ListOf16BitServiceSolicitationUuids => {
|
||||||
|
combinator::complete(multi::many0(Uuid16::parse_le))(data)
|
||||||
|
.map(|(_res, uuids)| {
|
||||||
|
uuids
|
||||||
|
.into_iter()
|
||||||
|
.map(|uuid| {
|
||||||
|
SERVICE_IDS
|
||||||
|
.get(&uuid)
|
||||||
|
.map(|name| format!("{:?} ({name})", uuid))
|
||||||
|
.unwrap_or_else(|| format!("{:?}", uuid))
|
||||||
|
})
|
||||||
|
.join(", ")
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
Self::CompleteListOf32BitServiceClassUuids
|
||||||
|
| Self::IncompleteListOf32BitServiceClassUuids
|
||||||
|
| Self::ListOf32BitServiceSolicitationUuids => {
|
||||||
|
combinator::complete(multi::many0(Uuid32::parse))(data)
|
||||||
|
.map(|(_res, uuids)| uuids.into_iter().map(|u| format!("{:?}", u)).join(", "))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
Self::CompleteListOf128BitServiceClassUuids
|
||||||
|
| Self::IncompleteListOf128BitServiceClassUuids
|
||||||
|
| Self::ListOf128BitServiceSolicitationUuids => {
|
||||||
|
combinator::complete(multi::many0(Uuid128::parse_le))(data)
|
||||||
|
.map(|(_res, uuids)| uuids.into_iter().map(|u| format!("{:?}", u)).join(", "))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
Self::ServiceData16BitUuid => Uuid16::parse_le(data)
|
||||||
|
.map(|(rem, uuid)| {
|
||||||
|
format!(
|
||||||
|
"service={:?}, data={}",
|
||||||
|
SERVICE_IDS
|
||||||
|
.get(&uuid)
|
||||||
|
.map(|name| format!("{:?} ({name})", uuid))
|
||||||
|
.unwrap_or_else(|| format!("{:?}", uuid)),
|
||||||
|
hex::encode_upper(rem)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.ok(),
|
||||||
|
Self::ServiceData32BitUuid => Uuid32::parse(data)
|
||||||
|
.map(|(rem, uuid)| format!("service={:?}, data={}", uuid, hex::encode_upper(rem)))
|
||||||
|
.ok(),
|
||||||
|
Self::ServiceData128BitUuid => Uuid128::parse_le(data)
|
||||||
|
.map(|(rem, uuid)| format!("service={:?}, data={}", uuid, hex::encode_upper(rem)))
|
||||||
|
.ok(),
|
||||||
|
Self::ShortenedLocalName | Self::CompleteLocalName => {
|
||||||
|
std::str::from_utf8(data).ok().map(|s| format!("\"{}\"", s))
|
||||||
|
}
|
||||||
|
Self::TxPowerLevel => {
|
||||||
|
let (_, tx) =
|
||||||
|
combinator::complete(number::complete::i8::<_, nom::error::Error<_>>)(data)
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
Some(tx.to_string())
|
||||||
|
}
|
||||||
|
Self::ManufacturerSpecificData => {
|
||||||
|
let (rem, id) = Uuid16::parse_le(data).ok()?;
|
||||||
|
Some(format!(
|
||||||
|
"company={}, data=0x{}",
|
||||||
|
COMPANY_IDS
|
||||||
|
.get(&id)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("{:?}", id)),
|
||||||
|
hex::encode_upper(rem)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CommonDataType {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
CommonDataType::Flags => write!(f, "Flags"),
|
||||||
|
CommonDataType::IncompleteListOf16BitServiceClassUuids => {
|
||||||
|
write!(f, "Incomplete List of 16-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::CompleteListOf16BitServiceClassUuids => {
|
||||||
|
write!(f, "Complete List of 16-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::IncompleteListOf32BitServiceClassUuids => {
|
||||||
|
write!(f, "Incomplete List of 32-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::CompleteListOf32BitServiceClassUuids => {
|
||||||
|
write!(f, "Complete List of 32-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::ListOf16BitServiceSolicitationUuids => {
|
||||||
|
write!(f, "List of 16-bit Service Solicitation UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::ListOf32BitServiceSolicitationUuids => {
|
||||||
|
write!(f, "List of 32-bit Service Solicitation UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::ListOf128BitServiceSolicitationUuids => {
|
||||||
|
write!(f, "List of 128-bit Service Solicitation UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::IncompleteListOf128BitServiceClassUuids => {
|
||||||
|
write!(f, "Incomplete List of 128-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::CompleteListOf128BitServiceClassUuids => {
|
||||||
|
write!(f, "Complete List of 128-bit Service Class UUIDs")
|
||||||
|
}
|
||||||
|
CommonDataType::ShortenedLocalName => write!(f, "Shortened Local Name"),
|
||||||
|
CommonDataType::CompleteLocalName => write!(f, "Complete Local Name"),
|
||||||
|
CommonDataType::TxPowerLevel => write!(f, "TX Power Level"),
|
||||||
|
CommonDataType::ClassOfDevice => write!(f, "Class of Device"),
|
||||||
|
CommonDataType::SimplePairingHashC192 => {
|
||||||
|
write!(f, "Simple Pairing Hash C-192")
|
||||||
|
}
|
||||||
|
CommonDataType::SimplePairingHashC256 => {
|
||||||
|
write!(f, "Simple Pairing Hash C 256")
|
||||||
|
}
|
||||||
|
CommonDataType::SimplePairingRandomizerR192 => {
|
||||||
|
write!(f, "Simple Pairing Randomizer R-192")
|
||||||
|
}
|
||||||
|
CommonDataType::SimplePairingRandomizerR256 => {
|
||||||
|
write!(f, "Simple Pairing Randomizer R 256")
|
||||||
|
}
|
||||||
|
CommonDataType::DeviceId => write!(f, "Device Id"),
|
||||||
|
CommonDataType::SecurityManagerTkValue => {
|
||||||
|
write!(f, "Security Manager TK Value")
|
||||||
|
}
|
||||||
|
CommonDataType::SecurityManagerOutOfBandFlags => {
|
||||||
|
write!(f, "Security Manager Out of Band Flags")
|
||||||
|
}
|
||||||
|
CommonDataType::PeripheralConnectionIntervalRange => {
|
||||||
|
write!(f, "Peripheral Connection Interval Range")
|
||||||
|
}
|
||||||
|
CommonDataType::ServiceData16BitUuid => {
|
||||||
|
write!(f, "Service Data 16-bit UUID")
|
||||||
|
}
|
||||||
|
CommonDataType::ServiceData32BitUuid => {
|
||||||
|
write!(f, "Service Data 32-bit UUID")
|
||||||
|
}
|
||||||
|
CommonDataType::ServiceData128BitUuid => {
|
||||||
|
write!(f, "Service Data 128-bit UUID")
|
||||||
|
}
|
||||||
|
CommonDataType::PublicTargetAddress => write!(f, "Public Target Address"),
|
||||||
|
CommonDataType::RandomTargetAddress => write!(f, "Random Target Address"),
|
||||||
|
CommonDataType::Appearance => write!(f, "Appearance"),
|
||||||
|
CommonDataType::AdvertisingInterval => write!(f, "Advertising Interval"),
|
||||||
|
CommonDataType::LeBluetoothDeviceAddress => {
|
||||||
|
write!(f, "LE Bluetooth Device Address")
|
||||||
|
}
|
||||||
|
CommonDataType::LeRole => write!(f, "LE Role"),
|
||||||
|
CommonDataType::LeSecureConnectionsConfirmationValue => {
|
||||||
|
write!(f, "LE Secure Connections Confirmation Value")
|
||||||
|
}
|
||||||
|
CommonDataType::LeSecureConnectionsRandomValue => {
|
||||||
|
write!(f, "LE Secure Connections Random Value")
|
||||||
|
}
|
||||||
|
CommonDataType::LeSupportedFeatures => write!(f, "LE Supported Features"),
|
||||||
|
CommonDataType::Uri => write!(f, "URI"),
|
||||||
|
CommonDataType::IndoorPositioning => write!(f, "Indoor Positioning"),
|
||||||
|
CommonDataType::TransportDiscoveryData => {
|
||||||
|
write!(f, "Transport Discovery Data")
|
||||||
|
}
|
||||||
|
CommonDataType::ChannelMapUpdateIndication => {
|
||||||
|
write!(f, "Channel Map Update Indication")
|
||||||
|
}
|
||||||
|
CommonDataType::PbAdv => write!(f, "PB-ADV"),
|
||||||
|
CommonDataType::MeshMessage => write!(f, "Mesh Message"),
|
||||||
|
CommonDataType::MeshBeacon => write!(f, "Mesh Beacon"),
|
||||||
|
CommonDataType::BigInfo => write!(f, "BIGIInfo"),
|
||||||
|
CommonDataType::BroadcastCode => write!(f, "Broadcast Code"),
|
||||||
|
CommonDataType::ResolvableSetIdentifier => {
|
||||||
|
write!(f, "Resolvable Set Identifier")
|
||||||
|
}
|
||||||
|
CommonDataType::AdvertisingIntervalLong => {
|
||||||
|
write!(f, "Advertising Interval Long")
|
||||||
|
}
|
||||||
|
CommonDataType::ThreeDInformationData => write!(f, "3D Information Data"),
|
||||||
|
CommonDataType::ManufacturerSpecificData => {
|
||||||
|
write!(f, "Manufacturer Specific Data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulates advertisement data to broadcast on a [crate::wrapper::device::Device].
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AdvertisementDataBuilder {
|
||||||
|
encoded_data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdvertisementDataBuilder {
|
||||||
|
/// Returns a new, empty instance.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
encoded_data: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append advertising data to the builder.
|
||||||
|
///
|
||||||
|
/// Returns an error if the data cannot be appended.
|
||||||
|
pub fn append(
|
||||||
|
&mut self,
|
||||||
|
type_code: impl Into<CommonDataTypeCode>,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<(), AdvertisementDataBuilderError> {
|
||||||
|
self.encoded_data.push(
|
||||||
|
data.len()
|
||||||
|
.try_into()
|
||||||
|
.ok()
|
||||||
|
.and_then(|len: u8| len.checked_add(1))
|
||||||
|
.ok_or(AdvertisementDataBuilderError::DataTooLong)?,
|
||||||
|
);
|
||||||
|
self.encoded_data.push(type_code.into().0);
|
||||||
|
self.encoded_data.extend_from_slice(data);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn into_bytes(self) -> Vec<u8> {
|
||||||
|
self.encoded_data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur when building advertisement data with [AdvertisementDataBuilder].
|
||||||
|
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum AdvertisementDataBuilderError {
|
||||||
|
/// The provided adv data is too long to be encoded
|
||||||
|
#[error("Data too long")]
|
||||||
|
DataTooLong,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, strum_macros::EnumIter)]
|
||||||
|
#[allow(missing_docs)]
|
||||||
|
/// Features in the Flags AD
|
||||||
|
pub enum Flags {
|
||||||
|
LeLimited,
|
||||||
|
LeDiscoverable,
|
||||||
|
NoBrEdr,
|
||||||
|
BrEdrController,
|
||||||
|
BrEdrHost,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Flags {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.short_name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flags {
|
||||||
|
/// Iterates over the flags that are present in the provided `flags` bytes.
|
||||||
|
pub fn matching(flags: &[u8]) -> impl Iterator<Item = Self> + '_ {
|
||||||
|
// The encoding is not clear from the spec: do we look at the first byte? or the last?
|
||||||
|
// In practice it's only one byte.
|
||||||
|
let first_byte = flags.first().unwrap_or(&0_u8);
|
||||||
|
|
||||||
|
Self::iter().filter(move |f| {
|
||||||
|
let mask = match f {
|
||||||
|
Flags::LeLimited => 0x01_u8,
|
||||||
|
Flags::LeDiscoverable => 0x02,
|
||||||
|
Flags::NoBrEdr => 0x04,
|
||||||
|
Flags::BrEdrController => 0x08,
|
||||||
|
Flags::BrEdrHost => 0x10,
|
||||||
|
};
|
||||||
|
|
||||||
|
mask & first_byte > 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An abbreviated form of the flag name.
|
||||||
|
///
|
||||||
|
/// See [Flags::name] for the full name.
|
||||||
|
pub fn short_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Flags::LeLimited => "LE Limited",
|
||||||
|
Flags::LeDiscoverable => "LE General",
|
||||||
|
Flags::NoBrEdr => "No BR/EDR",
|
||||||
|
Flags::BrEdrController => "BR/EDR C",
|
||||||
|
Flags::BrEdrHost => "BR/EDR H",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The human-readable name of the flag.
|
||||||
|
///
|
||||||
|
/// See [Flags::short_name] for a shorter string for use if compactness is important.
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Flags::LeLimited => "LE Limited Discoverable Mode",
|
||||||
|
Flags::LeDiscoverable => "LE General Discoverable Mode",
|
||||||
|
Flags::NoBrEdr => "BR/EDR Not Supported",
|
||||||
|
Flags::BrEdrController => "Simultaneous LE and BR/EDR (Controller)",
|
||||||
|
Flags::BrEdrHost => "Simultaneous LE and BR/EDR (Host)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Rust API for [Bumble](https://github.com/google/bumble).
|
||||||
|
//!
|
||||||
|
//! Bumble is a userspace Bluetooth stack that works with more or less anything that uses HCI. This
|
||||||
|
//! could be physical Bluetooth USB dongles, netsim, HCI proxied over a network from some device
|
||||||
|
//! elsewhere, etc.
|
||||||
|
//!
|
||||||
|
//! It also does not restrict what you can do with Bluetooth the way that OS Bluetooth APIs
|
||||||
|
//! typically do, making it good for prototyping, experimentation, test tools, etc.
|
||||||
|
//!
|
||||||
|
//! Bumble is primarily written in Python. Rust types that wrap the Python API, which is currently
|
||||||
|
//! the bulk of the code, are in the [wrapper] module.
|
||||||
|
|
||||||
|
#![deny(missing_docs, unsafe_code)]
|
||||||
|
|
||||||
|
pub mod wrapper;
|
||||||
|
|
||||||
|
pub mod adv;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Assigned numbers from the Bluetooth spec.
|
||||||
|
|
||||||
|
use crate::wrapper::core::Uuid16;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use pyo3::{
|
||||||
|
intern,
|
||||||
|
types::{PyDict, PyModule},
|
||||||
|
PyResult, Python,
|
||||||
|
};
|
||||||
|
use std::collections;
|
||||||
|
|
||||||
|
mod services;
|
||||||
|
|
||||||
|
pub use services::SERVICE_IDS;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
/// Assigned company IDs
|
||||||
|
pub static ref COMPANY_IDS: collections::HashMap<Uuid16, String> = load_company_ids()
|
||||||
|
.expect("Could not load company ids -- are Bumble's Python sources available?");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_company_ids() -> PyResult<collections::HashMap<Uuid16, String>> {
|
||||||
|
// this takes about 4ms on a fast machine -- slower than constructing in rust, but not slow
|
||||||
|
// enough to worry about
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.company_ids"))?
|
||||||
|
.getattr(intern!(py, "COMPANY_IDENTIFIERS"))?
|
||||||
|
.downcast::<PyDict>()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
Ok((
|
||||||
|
Uuid16::from_be_bytes(k.extract::<u16>()?.to_be_bytes()),
|
||||||
|
v.str()?.to_str()?.to_string(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect::<PyResult<collections::HashMap<_, _>>>()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Assigned service IDs
|
||||||
|
|
||||||
|
use crate::wrapper::core::Uuid16;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use std::collections;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
/// Assigned service IDs
|
||||||
|
pub static ref SERVICE_IDS: collections::HashMap<Uuid16, &'static str> = [
|
||||||
|
(0x1800_u16, "Generic Access"),
|
||||||
|
(0x1801, "Generic Attribute"),
|
||||||
|
(0x1802, "Immediate Alert"),
|
||||||
|
(0x1803, "Link Loss"),
|
||||||
|
(0x1804, "TX Power"),
|
||||||
|
(0x1805, "Current Time"),
|
||||||
|
(0x1806, "Reference Time Update"),
|
||||||
|
(0x1807, "Next DST Change"),
|
||||||
|
(0x1808, "Glucose"),
|
||||||
|
(0x1809, "Health Thermometer"),
|
||||||
|
(0x180A, "Device Information"),
|
||||||
|
(0x180D, "Heart Rate"),
|
||||||
|
(0x180E, "Phone Alert Status"),
|
||||||
|
(0x180F, "Battery"),
|
||||||
|
(0x1810, "Blood Pressure"),
|
||||||
|
(0x1811, "Alert Notification"),
|
||||||
|
(0x1812, "Human Interface Device"),
|
||||||
|
(0x1813, "Scan Parameters"),
|
||||||
|
(0x1814, "Running Speed and Cadence"),
|
||||||
|
(0x1815, "Automation IO"),
|
||||||
|
(0x1816, "Cycling Speed and Cadence"),
|
||||||
|
(0x1818, "Cycling Power"),
|
||||||
|
(0x1819, "Location and Navigation"),
|
||||||
|
(0x181A, "Environmental Sensing"),
|
||||||
|
(0x181B, "Body Composition"),
|
||||||
|
(0x181C, "User Data"),
|
||||||
|
(0x181D, "Weight Scale"),
|
||||||
|
(0x181E, "Bond Management"),
|
||||||
|
(0x181F, "Continuous Glucose Monitoring"),
|
||||||
|
(0x1820, "Internet Protocol Support"),
|
||||||
|
(0x1821, "Indoor Positioning"),
|
||||||
|
(0x1822, "Pulse Oximeter"),
|
||||||
|
(0x1823, "HTTP Proxy"),
|
||||||
|
(0x1824, "Transport Discovery"),
|
||||||
|
(0x1825, "Object Transfer"),
|
||||||
|
(0x1826, "Fitness Machine"),
|
||||||
|
(0x1827, "Mesh Provisioning"),
|
||||||
|
(0x1828, "Mesh Proxy"),
|
||||||
|
(0x1829, "Reconnection Configuration"),
|
||||||
|
(0x183A, "Insulin Delivery"),
|
||||||
|
(0x183B, "Binary Sensor"),
|
||||||
|
(0x183C, "Emergency Configuration"),
|
||||||
|
(0x183E, "Physical Activity Monitor"),
|
||||||
|
(0x1843, "Audio Input Control"),
|
||||||
|
(0x1844, "Volume Control"),
|
||||||
|
(0x1845, "Volume Offset Control"),
|
||||||
|
(0x1846, "Coordinated Set Identification Service"),
|
||||||
|
(0x1847, "Device Time"),
|
||||||
|
(0x1848, "Media Control Service"),
|
||||||
|
(0x1849, "Generic Media Control Service"),
|
||||||
|
(0x184A, "Constant Tone Extension"),
|
||||||
|
(0x184B, "Telephone Bearer Service"),
|
||||||
|
(0x184C, "Generic Telephone Bearer Service"),
|
||||||
|
(0x184D, "Microphone Control"),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(num, name)| (Uuid16::from_le_bytes(num.to_le_bytes()), name))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Core types
|
||||||
|
|
||||||
|
use crate::adv::CommonDataTypeCode;
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use nom::{bytes, combinator};
|
||||||
|
use pyo3::{intern, PyObject, PyResult, Python};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref BASE_UUID: [u8; 16] = hex::decode("0000000000001000800000805F9B34FB")
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A type code and data pair from an advertisement
|
||||||
|
pub type AdvertisementDataUnit = (CommonDataTypeCode, Vec<u8>);
|
||||||
|
|
||||||
|
/// Contents of an advertisement
|
||||||
|
pub struct AdvertisingData(pub(crate) PyObject);
|
||||||
|
|
||||||
|
impl AdvertisingData {
|
||||||
|
/// Data units in the advertisement contents
|
||||||
|
pub fn data_units(&self) -> PyResult<Vec<AdvertisementDataUnit>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let list = self.0.getattr(py, intern!(py, "ad_structures"))?;
|
||||||
|
|
||||||
|
list.as_ref(py)
|
||||||
|
.iter()?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|tuple| {
|
||||||
|
let type_code = tuple
|
||||||
|
.call_method1(intern!(py, "__getitem__"), (0,))?
|
||||||
|
.extract::<u8>()?
|
||||||
|
.into();
|
||||||
|
let data = tuple
|
||||||
|
.call_method1(intern!(py, "__getitem__"), (1,))?
|
||||||
|
.extract::<Vec<u8>>()?;
|
||||||
|
Ok((type_code, data))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 16-bit UUID
|
||||||
|
#[derive(PartialEq, Eq, Hash)]
|
||||||
|
pub struct Uuid16 {
|
||||||
|
/// Big-endian bytes
|
||||||
|
uuid: [u8; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Uuid16 {
|
||||||
|
/// Construct a UUID from little-endian bytes
|
||||||
|
pub fn from_le_bytes(mut bytes: [u8; 2]) -> Self {
|
||||||
|
bytes.reverse();
|
||||||
|
Self::from_be_bytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a UUID from big-endian bytes
|
||||||
|
pub fn from_be_bytes(bytes: [u8; 2]) -> Self {
|
||||||
|
Self { uuid: bytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UUID in big-endian bytes form
|
||||||
|
pub fn as_be_bytes(&self) -> [u8; 2] {
|
||||||
|
self.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The UUID in little-endian bytes form
|
||||||
|
pub fn as_le_bytes(&self) -> [u8; 2] {
|
||||||
|
let mut uuid = self.uuid;
|
||||||
|
uuid.reverse();
|
||||||
|
uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_le(input: &[u8]) -> nom::IResult<&[u8], Self> {
|
||||||
|
combinator::map_res(bytes::complete::take(2_usize), |b: &[u8]| {
|
||||||
|
b.try_into().map(|mut uuid: [u8; 2]| {
|
||||||
|
uuid.reverse();
|
||||||
|
Self { uuid }
|
||||||
|
})
|
||||||
|
})(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Uuid16 {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "UUID-16:{}", hex::encode_upper(self.uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 32-bit UUID
|
||||||
|
#[derive(PartialEq, Eq, Hash)]
|
||||||
|
pub struct Uuid32 {
|
||||||
|
/// Big-endian bytes
|
||||||
|
uuid: [u8; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Uuid32 {
|
||||||
|
/// The UUID in big-endian bytes form
|
||||||
|
pub fn as_bytes(&self) -> [u8; 4] {
|
||||||
|
self.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse(input: &[u8]) -> nom::IResult<&[u8], Self> {
|
||||||
|
combinator::map_res(bytes::complete::take(4_usize), |b: &[u8]| {
|
||||||
|
b.try_into().map(|mut uuid: [u8; 4]| {
|
||||||
|
uuid.reverse();
|
||||||
|
Self { uuid }
|
||||||
|
})
|
||||||
|
})(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Uuid32 {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "UUID-32:{}", hex::encode_upper(self.uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Uuid16> for Uuid32 {
|
||||||
|
fn from(value: Uuid16) -> Self {
|
||||||
|
let mut uuid = [0; 4];
|
||||||
|
uuid[2..].copy_from_slice(&value.uuid);
|
||||||
|
Self { uuid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 128-bit UUID
|
||||||
|
#[derive(PartialEq, Eq, Hash)]
|
||||||
|
pub struct Uuid128 {
|
||||||
|
/// Big-endian bytes
|
||||||
|
uuid: [u8; 16],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Uuid128 {
|
||||||
|
/// The UUID in big-endian bytes form
|
||||||
|
pub fn as_bytes(&self) -> [u8; 16] {
|
||||||
|
self.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_le(input: &[u8]) -> nom::IResult<&[u8], Self> {
|
||||||
|
combinator::map_res(bytes::complete::take(16_usize), |b: &[u8]| {
|
||||||
|
b.try_into().map(|mut uuid: [u8; 16]| {
|
||||||
|
uuid.reverse();
|
||||||
|
Self { uuid }
|
||||||
|
})
|
||||||
|
})(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Uuid128 {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}-{}-{}-{}-{}",
|
||||||
|
hex::encode_upper(&self.uuid[..4]),
|
||||||
|
hex::encode_upper(&self.uuid[4..6]),
|
||||||
|
hex::encode_upper(&self.uuid[6..8]),
|
||||||
|
hex::encode_upper(&self.uuid[8..10]),
|
||||||
|
hex::encode_upper(&self.uuid[10..])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Uuid16> for Uuid128 {
|
||||||
|
fn from(value: Uuid16) -> Self {
|
||||||
|
let mut uuid = *BASE_UUID;
|
||||||
|
uuid[2..4].copy_from_slice(&value.uuid);
|
||||||
|
Self { uuid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Uuid32> for Uuid128 {
|
||||||
|
fn from(value: Uuid32) -> Self {
|
||||||
|
let mut uuid = *BASE_UUID;
|
||||||
|
uuid[..4].copy_from_slice(&value.uuid);
|
||||||
|
Self { uuid }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Devices and connections to them
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
adv::AdvertisementDataBuilder,
|
||||||
|
wrapper::{
|
||||||
|
core::AdvertisingData,
|
||||||
|
gatt_client::{ProfileServiceProxy, ServiceProxy},
|
||||||
|
hci::Address,
|
||||||
|
transport::{Sink, Source},
|
||||||
|
ClosureCallback,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use pyo3::types::PyDict;
|
||||||
|
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
|
||||||
|
use std::path;
|
||||||
|
|
||||||
|
/// A device that can send/receive HCI frames.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Device(PyObject);
|
||||||
|
|
||||||
|
impl Device {
|
||||||
|
/// Create a Device per the provided file configured to communicate with a controller through an HCI source/sink
|
||||||
|
pub fn from_config_file_with_hci(
|
||||||
|
device_config: &path::Path,
|
||||||
|
source: Source,
|
||||||
|
sink: Sink,
|
||||||
|
) -> PyResult<Self> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.device"))?
|
||||||
|
.getattr(intern!(py, "Device"))?
|
||||||
|
.call_method1(
|
||||||
|
intern!(py, "from_config_file_with_hci"),
|
||||||
|
(device_config, source.0, sink.0),
|
||||||
|
)
|
||||||
|
.map(|any| Self(any.into()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a Device configured to communicate with a controller through an HCI source/sink
|
||||||
|
pub fn with_hci(name: &str, address: &str, source: Source, sink: Sink) -> PyResult<Self> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.device"))?
|
||||||
|
.getattr(intern!(py, "Device"))?
|
||||||
|
.call_method1(intern!(py, "with_hci"), (name, address, source.0, sink.0))
|
||||||
|
.map(|any| Self(any.into()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn the device on
|
||||||
|
pub async fn power_on(&self) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method0(py, intern!(py, "power_on"))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a peer
|
||||||
|
pub async fn connect(&self, peer_addr: &str) -> PyResult<Connection> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method1(py, intern!(py, "connect"), (peer_addr,))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(Connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start scanning
|
||||||
|
pub async fn start_scanning(&self, filter_duplicates: bool) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let kwargs = PyDict::new(py);
|
||||||
|
kwargs.set_item("filter_duplicates", filter_duplicates)?;
|
||||||
|
self.0
|
||||||
|
.call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a callback to be called for each advertisement
|
||||||
|
pub fn on_advertisement(
|
||||||
|
&mut self,
|
||||||
|
callback: impl Fn(Python, Advertisement) -> PyResult<()> + Send + 'static,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
|
||||||
|
callback(py, Advertisement(args.get_item(0)?.into()))
|
||||||
|
});
|
||||||
|
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method1(py, intern!(py, "add_listener"), ("advertisement", boxed))
|
||||||
|
})
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the advertisement data to be used when [Device::start_advertising] is called.
|
||||||
|
pub fn set_advertising_data(&mut self, adv_data: AdvertisementDataBuilder) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0.setattr(
|
||||||
|
py,
|
||||||
|
intern!(py, "advertising_data"),
|
||||||
|
adv_data.into_bytes().as_slice(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start advertising the data set with [Device.set_advertisement].
|
||||||
|
pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let kwargs = PyDict::new(py);
|
||||||
|
kwargs.set_item("auto_restart", auto_restart)?;
|
||||||
|
|
||||||
|
self.0
|
||||||
|
.call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop advertising.
|
||||||
|
pub async fn stop_advertising(&mut self) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method0(py, intern!(py, "stop_advertising"))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connection to a remote device.
|
||||||
|
pub struct Connection(PyObject);
|
||||||
|
|
||||||
|
/// The other end of a connection
|
||||||
|
pub struct Peer(PyObject);
|
||||||
|
|
||||||
|
impl Peer {
|
||||||
|
/// Wrap a [Connection] in a Peer
|
||||||
|
pub fn new(conn: Connection) -> PyResult<Self> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.device"))?
|
||||||
|
.getattr(intern!(py, "Peer"))?
|
||||||
|
.call1((conn.0,))
|
||||||
|
.map(|obj| Self(obj.into()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populates the peer's cache of services.
|
||||||
|
///
|
||||||
|
/// Returns the discovered services.
|
||||||
|
pub async fn discover_services(&mut self) -> PyResult<Vec<ServiceProxy>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method0(py, intern!(py, "discover_services"))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.and_then(|list| {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
list.as_ref(py)
|
||||||
|
.iter()?
|
||||||
|
.map(|r| r.map(|h| ServiceProxy(h.to_object(py))))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a snapshot of the Services currently in the peer's cache
|
||||||
|
pub fn services(&self) -> PyResult<Vec<ServiceProxy>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.getattr(py, intern!(py, "services"))?
|
||||||
|
.as_ref(py)
|
||||||
|
.iter()?
|
||||||
|
.map(|r| r.map(|h| ServiceProxy(h.to_object(py))))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a [ProfileServiceProxy] for the specified type.
|
||||||
|
/// [Peer::discover_services] or some other means of populating the Peer's service cache must be
|
||||||
|
/// called first, or the required service won't be found.
|
||||||
|
pub fn create_service_proxy<P: ProfileServiceProxy>(&self) -> PyResult<Option<P>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let module = py.import(P::PROXY_CLASS_MODULE)?;
|
||||||
|
let class = module.getattr(P::PROXY_CLASS_NAME)?;
|
||||||
|
self.0
|
||||||
|
.call_method1(py, intern!(py, "create_service_proxy"), (class,))
|
||||||
|
.map(|obj| {
|
||||||
|
if obj.is_none(py) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(P::wrap(obj))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A BLE advertisement
|
||||||
|
pub struct Advertisement(PyObject);
|
||||||
|
|
||||||
|
impl Advertisement {
|
||||||
|
/// Address that sent the advertisement
|
||||||
|
pub fn address(&self) -> PyResult<Address> {
|
||||||
|
Python::with_gil(|py| self.0.getattr(py, intern!(py, "address")).map(Address))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the advertisement is connectable
|
||||||
|
pub fn is_connectable(&self) -> PyResult<bool> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.getattr(py, intern!(py, "is_connectable"))?
|
||||||
|
.extract::<bool>(py)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RSSI of the advertisement
|
||||||
|
pub fn rssi(&self) -> PyResult<i8> {
|
||||||
|
Python::with_gil(|py| self.0.getattr(py, intern!(py, "rssi"))?.extract::<i8>(py))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Data in the advertisement
|
||||||
|
pub fn data(&self) -> PyResult<AdvertisingData> {
|
||||||
|
Python::with_gil(|py| self.0.getattr(py, intern!(py, "data")).map(AdvertisingData))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! GATT client support
|
||||||
|
|
||||||
|
use crate::wrapper::ClosureCallback;
|
||||||
|
use pyo3::types::PyTuple;
|
||||||
|
use pyo3::{intern, PyObject, PyResult, Python};
|
||||||
|
|
||||||
|
/// A GATT service on a remote device
|
||||||
|
pub struct ServiceProxy(pub(crate) PyObject);
|
||||||
|
|
||||||
|
impl ServiceProxy {
|
||||||
|
/// Discover the characteristics in this service.
|
||||||
|
///
|
||||||
|
/// Populates an internal cache of characteristics in this service.
|
||||||
|
pub async fn discover_characteristics(&mut self) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method0(py, intern!(py, "discover_characteristics"))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GATT characteristic on a remote device
|
||||||
|
pub struct CharacteristicProxy(pub(crate) PyObject);
|
||||||
|
|
||||||
|
impl CharacteristicProxy {
|
||||||
|
/// Subscribe to changes to the characteristic, executing `callback` for each new value
|
||||||
|
pub async fn subscribe(
|
||||||
|
&mut self,
|
||||||
|
callback: impl Fn(Python, &PyTuple) -> PyResult<()> + Send + 'static,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
let boxed = ClosureCallback::new(move |py, args, _kwargs| callback(py, args));
|
||||||
|
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method1(py, intern!(py, "subscribe"), (boxed,))
|
||||||
|
.and_then(|obj| pyo3_asyncio::tokio::into_future(obj.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current value of the characteristic
|
||||||
|
pub async fn read_value(&self) -> PyResult<PyObject> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method0(py, intern!(py, "read_value"))
|
||||||
|
.and_then(|obj| pyo3_asyncio::tokio::into_future(obj.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Equivalent to the Python `ProfileServiceProxy`.
|
||||||
|
pub trait ProfileServiceProxy {
|
||||||
|
/// The module containing the proxy class
|
||||||
|
const PROXY_CLASS_MODULE: &'static str;
|
||||||
|
/// The module class name
|
||||||
|
const PROXY_CLASS_NAME: &'static str;
|
||||||
|
|
||||||
|
/// Wrap a PyObject in the Rust wrapper type
|
||||||
|
fn wrap(obj: PyObject) -> Self;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! HCI
|
||||||
|
|
||||||
|
use itertools::Itertools as _;
|
||||||
|
use pyo3::{exceptions::PyException, intern, types::PyModule, PyErr, PyObject, PyResult, Python};
|
||||||
|
|
||||||
|
/// A Bluetooth address
|
||||||
|
pub struct Address(pub(crate) PyObject);
|
||||||
|
|
||||||
|
impl Address {
|
||||||
|
/// The type of address
|
||||||
|
pub fn address_type(&self) -> PyResult<AddressType> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let addr_type = self
|
||||||
|
.0
|
||||||
|
.getattr(py, intern!(py, "address_type"))?
|
||||||
|
.extract::<u32>(py)?;
|
||||||
|
|
||||||
|
let module = PyModule::import(py, intern!(py, "bumble.hci"))?;
|
||||||
|
let klass = module.getattr(intern!(py, "Address"))?;
|
||||||
|
|
||||||
|
if addr_type
|
||||||
|
== klass
|
||||||
|
.getattr(intern!(py, "PUBLIC_DEVICE_ADDRESS"))?
|
||||||
|
.extract::<u32>()?
|
||||||
|
{
|
||||||
|
Ok(AddressType::PublicDevice)
|
||||||
|
} else if addr_type
|
||||||
|
== klass
|
||||||
|
.getattr(intern!(py, "RANDOM_DEVICE_ADDRESS"))?
|
||||||
|
.extract::<u32>()?
|
||||||
|
{
|
||||||
|
Ok(AddressType::RandomDevice)
|
||||||
|
} else if addr_type
|
||||||
|
== klass
|
||||||
|
.getattr(intern!(py, "PUBLIC_IDENTITY_ADDRESS"))?
|
||||||
|
.extract::<u32>()?
|
||||||
|
{
|
||||||
|
Ok(AddressType::PublicIdentity)
|
||||||
|
} else if addr_type
|
||||||
|
== klass
|
||||||
|
.getattr(intern!(py, "RANDOM_IDENTITY_ADDRESS"))?
|
||||||
|
.extract::<u32>()?
|
||||||
|
{
|
||||||
|
Ok(AddressType::RandomIdentity)
|
||||||
|
} else {
|
||||||
|
Err(PyErr::new::<PyException, _>("Invalid address type"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the address is static
|
||||||
|
pub fn is_static(&self) -> PyResult<bool> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.getattr(py, intern!(py, "is_static"))?
|
||||||
|
.extract::<bool>(py)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the address is resolvable
|
||||||
|
pub fn is_resolvable(&self) -> PyResult<bool> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.getattr(py, intern!(py, "is_resolvable"))?
|
||||||
|
.extract::<bool>(py)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Address bytes in _little-endian_ format
|
||||||
|
pub fn as_le_bytes(&self) -> PyResult<Vec<u8>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method0(py, intern!(py, "to_bytes"))?
|
||||||
|
.extract::<Vec<u8>>(py)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Address bytes as big-endian colon-separated hex
|
||||||
|
pub fn as_hex(&self) -> PyResult<String> {
|
||||||
|
self.as_le_bytes().map(|bytes| {
|
||||||
|
bytes
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.map(|byte| hex::encode_upper([byte]))
|
||||||
|
.join(":")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BT address types
|
||||||
|
#[allow(missing_docs)]
|
||||||
|
#[derive(PartialEq, Eq, Debug)]
|
||||||
|
pub enum AddressType {
|
||||||
|
PublicDevice,
|
||||||
|
RandomDevice,
|
||||||
|
PublicIdentity,
|
||||||
|
RandomIdentity,
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! Bumble & Python logging
|
||||||
|
|
||||||
|
use pyo3::types::PyDict;
|
||||||
|
use pyo3::{intern, types::PyModule, PyResult, Python};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
/// Returns the uppercased contents of the `BUMBLE_LOGLEVEL` env var, or `default` if it is not present or not UTF-8.
|
||||||
|
///
|
||||||
|
/// The result could be passed to [py_logging_basic_config] to configure Python's logging
|
||||||
|
/// accordingly.
|
||||||
|
pub fn bumble_env_logging_level(default: impl Into<String>) -> String {
|
||||||
|
env::var("BUMBLE_LOGLEVEL")
|
||||||
|
.unwrap_or_else(|_| default.into())
|
||||||
|
.to_ascii_uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call `logging.basicConfig` with the provided logging level
|
||||||
|
pub fn py_logging_basic_config(log_level: impl Into<String>) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
let kwargs = PyDict::new(py);
|
||||||
|
kwargs.set_item("level", log_level.into())?;
|
||||||
|
|
||||||
|
PyModule::import(py, intern!(py, "logging"))?
|
||||||
|
.call_method(intern!(py, "basicConfig"), (), Some(kwargs))
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Types that wrap the Python API.
|
||||||
|
//!
|
||||||
|
//! Because mutability, aliasing, etc is all hidden behind Python, the normal Rust rules about
|
||||||
|
//! only one mutable reference to one piece of memory, etc, may not hold since using `&mut self`
|
||||||
|
//! instead of `&self` is only guided by inspection of the Python source, not the compiler.
|
||||||
|
//!
|
||||||
|
//! The modules are generally structured to mirror the Python equivalents.
|
||||||
|
|
||||||
|
// Re-exported to make it easy for users to depend on the same `PyObject`, etc
|
||||||
|
pub use pyo3;
|
||||||
|
use pyo3::{
|
||||||
|
prelude::*,
|
||||||
|
types::{PyDict, PyTuple},
|
||||||
|
};
|
||||||
|
pub use pyo3_asyncio;
|
||||||
|
|
||||||
|
pub mod assigned_numbers;
|
||||||
|
pub mod core;
|
||||||
|
pub mod device;
|
||||||
|
pub mod gatt_client;
|
||||||
|
pub mod hci;
|
||||||
|
pub mod logging;
|
||||||
|
pub mod profile;
|
||||||
|
pub mod transport;
|
||||||
|
|
||||||
|
/// Convenience extensions to [PyObject]
|
||||||
|
pub trait PyObjectExt {
|
||||||
|
/// Get a GIL-bound reference
|
||||||
|
fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny;
|
||||||
|
|
||||||
|
/// Extract any [FromPyObject] implementation from this value
|
||||||
|
fn extract_with_gil<T>(&self) -> PyResult<T>
|
||||||
|
where
|
||||||
|
T: for<'a> FromPyObject<'a>,
|
||||||
|
{
|
||||||
|
Python::with_gil(|py| self.gil_ref(py).extract::<T>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PyObjectExt for PyObject {
|
||||||
|
fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny {
|
||||||
|
self.as_ref(py)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper to make Rust closures ([Fn] implementations) callable from Python.
|
||||||
|
///
|
||||||
|
/// The Python callable form returns a Python `None`.
|
||||||
|
#[pyclass(name = "SubscribeCallback")]
|
||||||
|
pub(crate) struct ClosureCallback {
|
||||||
|
// can't use generics in a pyclass, so have to box
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
callback: Box<dyn Fn(Python, &PyTuple, Option<&PyDict>) -> PyResult<()> + Send + 'static>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClosureCallback {
|
||||||
|
/// Create a new callback around the provided closure
|
||||||
|
pub fn new(
|
||||||
|
callback: impl Fn(Python, &PyTuple, Option<&PyDict>) -> PyResult<()> + Send + 'static,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
callback: Box::new(callback),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl ClosureCallback {
|
||||||
|
#[pyo3(signature = (*args, **kwargs))]
|
||||||
|
fn __call__(
|
||||||
|
&self,
|
||||||
|
py: Python<'_>,
|
||||||
|
args: &PyTuple,
|
||||||
|
kwargs: Option<&PyDict>,
|
||||||
|
) -> PyResult<Py<PyAny>> {
|
||||||
|
(self.callback)(py, args, kwargs).map(|_| py.None())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! GATT profiles
|
||||||
|
|
||||||
|
use crate::wrapper::gatt_client::{CharacteristicProxy, ProfileServiceProxy};
|
||||||
|
use pyo3::{intern, PyObject, PyResult, Python};
|
||||||
|
|
||||||
|
/// Exposes the battery GATT service
|
||||||
|
pub struct BatteryServiceProxy(PyObject);
|
||||||
|
|
||||||
|
impl BatteryServiceProxy {
|
||||||
|
/// Get the battery level, if available
|
||||||
|
pub fn battery_level(&self) -> PyResult<Option<CharacteristicProxy>> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.getattr(py, intern!(py, "battery_level"))
|
||||||
|
.map(|level| {
|
||||||
|
if level.is_none(py) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(CharacteristicProxy(level))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileServiceProxy for BatteryServiceProxy {
|
||||||
|
const PROXY_CLASS_MODULE: &'static str = "bumble.profiles.battery_service";
|
||||||
|
const PROXY_CLASS_NAME: &'static str = "BatteryServiceProxy";
|
||||||
|
|
||||||
|
fn wrap(obj: PyObject) -> Self {
|
||||||
|
Self(obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Copyright 2023 Google LLC
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! HCI packet transport
|
||||||
|
|
||||||
|
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python};
|
||||||
|
|
||||||
|
/// A source/sink pair for HCI packet I/O.
|
||||||
|
///
|
||||||
|
/// See <https://google.github.io/bumble/transports/index.html>.
|
||||||
|
pub struct Transport(PyObject);
|
||||||
|
|
||||||
|
impl Transport {
|
||||||
|
/// Open a new Transport for the provided spec, e.g. `"usb:0"` or `"android-netsim"`.
|
||||||
|
pub async fn open(transport_spec: impl Into<String>) -> PyResult<Self> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
PyModule::import(py, intern!(py, "bumble.transport"))?
|
||||||
|
.call_method1(intern!(py, "open_transport"), (transport_spec.into(),))
|
||||||
|
.and_then(pyo3_asyncio::tokio::into_future)
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the transport.
|
||||||
|
pub async fn close(&mut self) -> PyResult<()> {
|
||||||
|
Python::with_gil(|py| {
|
||||||
|
self.0
|
||||||
|
.call_method0(py, intern!(py, "close"))
|
||||||
|
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||||
|
})?
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the source half of the transport.
|
||||||
|
pub fn source(&self) -> PyResult<Source> {
|
||||||
|
Python::with_gil(|py| self.0.getattr(py, intern!(py, "source"))).map(Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sink half of the transport.
|
||||||
|
pub fn sink(&self) -> PyResult<Sink> {
|
||||||
|
Python::with_gil(|py| self.0.getattr(py, intern!(py, "sink"))).map(Sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Transport {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// can't await in a Drop impl, but we can at least spawn a task to do it
|
||||||
|
let obj = self.0.clone();
|
||||||
|
tokio::spawn(async move { Self(obj).close().await });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source side of a [Transport].
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Source(pub(crate) PyObject);
|
||||||
|
|
||||||
|
/// The sink side of a [Transport].
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Sink(pub(crate) PyObject);
|
||||||
+15
-11
@@ -803,14 +803,14 @@ async def test_mtu_exchange():
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def test_char_property_to_string():
|
def test_char_property_to_string():
|
||||||
# single
|
# single
|
||||||
assert str(Characteristic.Properties(0x01)) == "Properties.BROADCAST"
|
assert str(Characteristic.Properties(0x01)) == "BROADCAST"
|
||||||
assert str(Characteristic.Properties.BROADCAST) == "Properties.BROADCAST"
|
assert str(Characteristic.Properties.BROADCAST) == "BROADCAST"
|
||||||
|
|
||||||
# double
|
# double
|
||||||
assert str(Characteristic.Properties(0x03)) == "Properties.READ|BROADCAST"
|
assert str(Characteristic.Properties(0x03)) == "BROADCAST|READ"
|
||||||
assert (
|
assert (
|
||||||
str(Characteristic.Properties.BROADCAST | Characteristic.Properties.READ)
|
str(Characteristic.Properties.BROADCAST | Characteristic.Properties.READ)
|
||||||
== "Properties.READ|BROADCAST"
|
== "BROADCAST|READ"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -831,6 +831,10 @@ def test_characteristic_property_from_string():
|
|||||||
Characteristic.Properties.from_string("READ,BROADCAST")
|
Characteristic.Properties.from_string("READ,BROADCAST")
|
||||||
== Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
|
== Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
|
||||||
)
|
)
|
||||||
|
assert (
|
||||||
|
Characteristic.Properties.from_string("BROADCAST|READ")
|
||||||
|
== Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -841,7 +845,7 @@ def test_characteristic_property_from_string_assert():
|
|||||||
assert (
|
assert (
|
||||||
str(e_info.value)
|
str(e_info.value)
|
||||||
== """Characteristic.Properties::from_string() error:
|
== """Characteristic.Properties::from_string() error:
|
||||||
Expected a string containing any of the keys, separated by commas: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES
|
Expected a string containing any of the keys, separated by , or |: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES
|
||||||
Got: BROADCAST,HELLO"""
|
Got: BROADCAST,HELLO"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -866,13 +870,13 @@ async def test_server_string():
|
|||||||
assert (
|
assert (
|
||||||
str(server.gatt_server)
|
str(server.gatt_server)
|
||||||
== """Service(handle=0x0001, end=0x0005, uuid=UUID-16:1800 (Generic Access))
|
== """Service(handle=0x0001, end=0x0005, uuid=UUID-16:1800 (Generic Access))
|
||||||
CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
|
CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), READ)
|
||||||
Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
|
Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), READ)
|
||||||
CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
|
CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
|
||||||
Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
|
Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
|
||||||
Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
|
Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
|
||||||
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
|
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
|
||||||
Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
|
Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
|
||||||
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
|
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from bumble.hci import (
|
|||||||
HCI_LE_Set_Advertising_Parameters_Command,
|
HCI_LE_Set_Advertising_Parameters_Command,
|
||||||
HCI_LE_Set_Default_PHY_Command,
|
HCI_LE_Set_Default_PHY_Command,
|
||||||
HCI_LE_Set_Event_Mask_Command,
|
HCI_LE_Set_Event_Mask_Command,
|
||||||
|
HCI_LE_Set_Extended_Advertising_Enable_Command,
|
||||||
HCI_LE_Set_Extended_Scan_Parameters_Command,
|
HCI_LE_Set_Extended_Scan_Parameters_Command,
|
||||||
HCI_LE_Set_Random_Address_Command,
|
HCI_LE_Set_Random_Address_Command,
|
||||||
HCI_LE_Set_Scan_Enable_Command,
|
HCI_LE_Set_Scan_Enable_Command,
|
||||||
@@ -422,6 +423,25 @@ def test_HCI_LE_Set_Extended_Scan_Parameters_Command():
|
|||||||
basic_check(command)
|
basic_check(command)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def test_HCI_LE_Set_Extended_Advertising_Enable_Command():
|
||||||
|
command = HCI_Packet.from_bytes(
|
||||||
|
bytes.fromhex('0139200e010301050008020600090307000a')
|
||||||
|
)
|
||||||
|
assert command.enable == 1
|
||||||
|
assert command.advertising_handles == [1, 2, 3]
|
||||||
|
assert command.durations == [5, 6, 7]
|
||||||
|
assert command.max_extended_advertising_events == [8, 9, 10]
|
||||||
|
|
||||||
|
command = HCI_LE_Set_Extended_Advertising_Enable_Command(
|
||||||
|
enable=1,
|
||||||
|
advertising_handles=[1, 2, 3],
|
||||||
|
durations=[5, 6, 7],
|
||||||
|
max_extended_advertising_events=[8, 9, 10],
|
||||||
|
)
|
||||||
|
basic_check(command)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def test_address():
|
def test_address():
|
||||||
a = Address('C4:F2:17:1A:1D:BB')
|
a = Address('C4:F2:17:1A:1D:BB')
|
||||||
@@ -478,6 +498,7 @@ def run_test_commands():
|
|||||||
test_HCI_LE_Read_Remote_Features_Command()
|
test_HCI_LE_Read_Remote_Features_Command()
|
||||||
test_HCI_LE_Set_Default_PHY_Command()
|
test_HCI_LE_Set_Default_PHY_Command()
|
||||||
test_HCI_LE_Set_Extended_Scan_Parameters_Command()
|
test_HCI_LE_Set_Extended_Scan_Parameters_Command()
|
||||||
|
test_HCI_LE_Set_Extended_Advertising_Enable_Command()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from bumble.controller import Controller
|
from bumble.controller import Controller
|
||||||
from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE
|
from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE
|
||||||
from bumble.link import LocalLink
|
from bumble.link import LocalLink
|
||||||
@@ -34,6 +36,8 @@ from bumble.smp import (
|
|||||||
SMP_CONFIRM_VALUE_FAILED_ERROR,
|
SMP_CONFIRM_VALUE_FAILED_ERROR,
|
||||||
)
|
)
|
||||||
from bumble.core import ProtocolError
|
from bumble.core import ProtocolError
|
||||||
|
from bumble.hci import HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE
|
||||||
|
from bumble.keys import PairingKeys
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -473,6 +477,85 @@ async def test_self_smp_wrong_pin():
|
|||||||
assert not paired
|
assert not paired
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_self_smp_over_classic():
|
||||||
|
# Create two devices, each with a controller, attached to the same link
|
||||||
|
two_devices = TwoDevices()
|
||||||
|
|
||||||
|
# Attach listeners
|
||||||
|
two_devices.devices[0].on(
|
||||||
|
'connection', lambda connection: two_devices.on_connection(0, connection)
|
||||||
|
)
|
||||||
|
two_devices.devices[1].on(
|
||||||
|
'connection', lambda connection: two_devices.on_connection(1, connection)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable Classic connections
|
||||||
|
two_devices.devices[0].classic_enabled = True
|
||||||
|
two_devices.devices[1].classic_enabled = True
|
||||||
|
|
||||||
|
# Start
|
||||||
|
await two_devices.devices[0].power_on()
|
||||||
|
await two_devices.devices[1].power_on()
|
||||||
|
|
||||||
|
# Connect the two devices
|
||||||
|
await asyncio.gather(
|
||||||
|
two_devices.devices[0].connect(
|
||||||
|
two_devices.devices[1].public_address, transport=BT_BR_EDR_TRANSPORT
|
||||||
|
),
|
||||||
|
two_devices.devices[1].accept(two_devices.devices[0].public_address),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check the post conditions
|
||||||
|
assert two_devices.connections[0] is not None
|
||||||
|
assert two_devices.connections[1] is not None
|
||||||
|
|
||||||
|
# Mock connection
|
||||||
|
# TODO: Implement Classic SSP and encryption in link relayer
|
||||||
|
LINK_KEY = bytes.fromhex('287ad379dca402530a39f1f43047b835')
|
||||||
|
two_devices.devices[0].on_link_key(
|
||||||
|
two_devices.devices[1].public_address,
|
||||||
|
LINK_KEY,
|
||||||
|
HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
|
||||||
|
)
|
||||||
|
two_devices.devices[1].on_link_key(
|
||||||
|
two_devices.devices[0].public_address,
|
||||||
|
LINK_KEY,
|
||||||
|
HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
|
||||||
|
)
|
||||||
|
two_devices.connections[0].encryption = 1
|
||||||
|
two_devices.connections[1].encryption = 1
|
||||||
|
|
||||||
|
paired = [
|
||||||
|
asyncio.get_event_loop().create_future(),
|
||||||
|
asyncio.get_event_loop().create_future(),
|
||||||
|
]
|
||||||
|
|
||||||
|
def on_pairing(which: int, keys: PairingKeys):
|
||||||
|
paired[which].set_result(keys)
|
||||||
|
|
||||||
|
two_devices.connections[0].on('pairing', lambda keys: on_pairing(0, keys))
|
||||||
|
two_devices.connections[1].on('pairing', lambda keys: on_pairing(1, keys))
|
||||||
|
|
||||||
|
# Mock SMP
|
||||||
|
with patch('bumble.smp.Session', spec=True) as MockSmpSession:
|
||||||
|
MockSmpSession.send_pairing_confirm_command = MagicMock()
|
||||||
|
MockSmpSession.send_pairing_dhkey_check_command = MagicMock()
|
||||||
|
MockSmpSession.send_public_key_command = MagicMock()
|
||||||
|
MockSmpSession.send_pairing_random_command = MagicMock()
|
||||||
|
|
||||||
|
# Start CTKD
|
||||||
|
await two_devices.connections[0].pair()
|
||||||
|
await asyncio.gather(*paired)
|
||||||
|
|
||||||
|
# Phase 2 commands should not be invoked
|
||||||
|
MockSmpSession.send_pairing_confirm_command.assert_not_called()
|
||||||
|
MockSmpSession.send_pairing_dhkey_check_command.assert_not_called()
|
||||||
|
MockSmpSession.send_public_key_command.assert_not_called()
|
||||||
|
MockSmpSession.send_pairing_random_command.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def run_test_self():
|
async def run_test_self():
|
||||||
await test_self_connection()
|
await test_self_connection()
|
||||||
@@ -481,6 +564,7 @@ async def run_test_self():
|
|||||||
await test_self_smp()
|
await test_self_smp()
|
||||||
await test_self_smp_reject()
|
await test_self_smp_reject()
|
||||||
await test_self_smp_wrong_pin()
|
await test_self_smp_wrong_pin()
|
||||||
|
await test_self_smp_over_classic()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user