forked from auracaster/bumble_mirror
Compare commits
103 Commits
v0.0.140
...
gbg/pairin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c2f512180 | ||
|
|
859aea5a99 | ||
|
|
c53e1d2480 | ||
|
|
620c135ac4 | ||
|
|
fca73a49a3 | ||
|
|
cf70db84a1 | ||
|
|
7731c41f80 | ||
|
|
278341cbc0 | ||
|
|
fb49a87494 | ||
|
|
eba82b9d9a | ||
|
|
677fc77d3c | ||
|
|
e026de295f | ||
|
|
52c15705e9 | ||
|
|
45ca0ef071 | ||
|
|
e0af954baa | ||
|
|
044597de66 | ||
|
|
fb68fa6a33 | ||
|
|
b6fe7460ac | ||
|
|
5c59b6ca6d | ||
|
|
dcd66743f6 | ||
|
|
423a5a95d8 | ||
|
|
6f1f185642 | ||
|
|
8e881fdb18 | ||
|
|
4907022398 | ||
|
|
e93f71c035 | ||
|
|
94ff80563b | ||
|
|
552deab8a7 | ||
|
|
a72beb1b06 | ||
|
|
7e62d4a81a | ||
|
|
a50181e6b8 | ||
|
|
9e1358536b | ||
|
|
21d8a0d577 | ||
|
|
a8e61673d0 | ||
|
|
bd25cf27df | ||
|
|
fdf2da7023 | ||
|
|
dfb6734324 | ||
|
|
51ae6a5969 | ||
|
|
4fc13585cc | ||
|
|
c5e5397ed8 | ||
|
|
4c6320f98a | ||
|
|
cc0d56ad14 | ||
|
|
0019fa8e79 | ||
|
|
7ae1bf8959 | ||
|
|
9541cb6db0 | ||
|
|
1cd13dfc19 | ||
|
|
d4346c3c9b | ||
|
|
afe8765508 | ||
|
|
41d1772cb5 | ||
|
|
6e9078d60e | ||
|
|
d5c7d0db57 | ||
|
|
b70ebdef73 | ||
|
|
3af027e234 | ||
|
|
6e719ca9fd | ||
|
|
1a580d1c1e | ||
|
|
aee7348687 | ||
|
|
864889ccab | ||
|
|
fda00dcb28 | ||
|
|
77e5618ce7 | ||
|
|
6fa857ad13 | ||
|
|
bc29f327ef | ||
|
|
1894b96de4 | ||
|
|
c4fb63d35c | ||
|
|
33ae047765 | ||
|
|
1efa2e9d44 | ||
|
|
aa9af61cbe | ||
|
|
dc3ac3060e | ||
|
|
c34c5fdf17 | ||
|
|
e77723a5f9 | ||
|
|
fe8cf51432 | ||
|
|
97a0e115ae | ||
|
|
46e7aac77c | ||
|
|
08a6f4fa49 | ||
|
|
ca063eda0b | ||
|
|
c97ba4319f | ||
|
|
a5275ade29 | ||
|
|
e7b39c4188 | ||
|
|
0594eaef09 | ||
|
|
05200284d2 | ||
|
|
d21da78aa3 | ||
|
|
fbc7cf02a3 | ||
|
|
a8beb6b1ff | ||
|
|
2d44de611f | ||
|
|
9874bb3b37 | ||
|
|
6645ad47ee | ||
|
|
ad27de7717 | ||
|
|
e6fc63b2d8 | ||
|
|
1321c7da81 | ||
|
|
5a1b03fd91 | ||
|
|
de47721753 | ||
|
|
83a76a75d3 | ||
|
|
d5b5ef8313 | ||
|
|
856a8d53cd | ||
|
|
177c273a57 | ||
|
|
24a863983d | ||
|
|
b7ef09d4a3 | ||
|
|
b5b6cd13b8 | ||
|
|
ef781bc374 | ||
|
|
00978c1d63 | ||
|
|
b731f6f556 | ||
|
|
ed261886e1 | ||
|
|
5e18094c31 | ||
|
|
9a9b4e5bf1 | ||
|
|
895f1618d8 |
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -71,5 +71,10 @@
|
||||
"editor.rulers": [88]
|
||||
},
|
||||
"python.formatting.provider": "black",
|
||||
"pylint.importStrategy": "useBundled"
|
||||
"pylint.importStrategy": "useBundled",
|
||||
"python.testing.pytestArgs": [
|
||||
"."
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
||||
|
||||
1209
apps/bench.py
Normal file
1209
apps/bench.py
Normal file
File diff suppressed because it is too large
Load Diff
192
apps/console.py
192
apps/console.py
@@ -24,10 +24,11 @@ import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from collections import OrderedDict
|
||||
|
||||
import click
|
||||
import colors
|
||||
from prettytable import PrettyTable
|
||||
|
||||
from prompt_toolkit import Application
|
||||
from prompt_toolkit.history import FileHistory
|
||||
@@ -53,11 +54,13 @@ from prompt_toolkit.layout import (
|
||||
|
||||
from bumble import __version__
|
||||
import bumble.core
|
||||
from bumble import colors
|
||||
from bumble.core import UUID, AdvertisingData, BT_LE_TRANSPORT
|
||||
from bumble.device import ConnectionParametersPreferences, Device, Connection, Peer
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
|
||||
from bumble.gatt_client import CharacteristicProxy
|
||||
from bumble.hci import (
|
||||
HCI_Constant,
|
||||
HCI_LE_1M_PHY,
|
||||
@@ -119,9 +122,12 @@ def parse_phys(phys):
|
||||
# Console App
|
||||
# -----------------------------------------------------------------------------
|
||||
class ConsoleApp:
|
||||
connected_peer: Optional[Peer]
|
||||
|
||||
def __init__(self):
|
||||
self.known_addresses = set()
|
||||
self.known_attributes = []
|
||||
self.known_remote_attributes = []
|
||||
self.known_local_attributes = []
|
||||
self.device = None
|
||||
self.connected_peer = None
|
||||
self.top_tab = 'device'
|
||||
@@ -158,6 +164,7 @@ class ConsoleApp:
|
||||
'device': None,
|
||||
'local-services': None,
|
||||
'remote-services': None,
|
||||
'local-values': None,
|
||||
},
|
||||
'filter': {
|
||||
'address': None,
|
||||
@@ -168,10 +175,11 @@ class ConsoleApp:
|
||||
'disconnect': None,
|
||||
'discover': {'services': None, 'attributes': None},
|
||||
'request-mtu': None,
|
||||
'read': LiveCompleter(self.known_attributes),
|
||||
'write': LiveCompleter(self.known_attributes),
|
||||
'subscribe': LiveCompleter(self.known_attributes),
|
||||
'unsubscribe': LiveCompleter(self.known_attributes),
|
||||
'read': LiveCompleter(self.known_remote_attributes),
|
||||
'write': LiveCompleter(self.known_remote_attributes),
|
||||
'local-write': LiveCompleter(self.known_local_attributes),
|
||||
'subscribe': LiveCompleter(self.known_remote_attributes),
|
||||
'unsubscribe': LiveCompleter(self.known_remote_attributes),
|
||||
'set-phy': {'1m': None, '2m': None, 'coded': None},
|
||||
'set-default-phy': None,
|
||||
'quit': None,
|
||||
@@ -203,6 +211,7 @@ class ConsoleApp:
|
||||
self.log_text = FormattedTextControl(
|
||||
get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1))
|
||||
)
|
||||
self.local_values_text = FormattedTextControl()
|
||||
self.log_height = Dimension(min=7, weight=4)
|
||||
self.log_max_lines = 100
|
||||
self.log_lines = []
|
||||
@@ -218,7 +227,11 @@ class ConsoleApp:
|
||||
filter=Condition(lambda: self.top_tab == 'local-services'),
|
||||
),
|
||||
ConditionalContainer(
|
||||
Frame(Window(self.remote_services_text), title='Remove Services'),
|
||||
Frame(Window(self.local_values_text), title='Local Values'),
|
||||
filter=Condition(lambda: self.top_tab == 'local-values'),
|
||||
),
|
||||
ConditionalContainer(
|
||||
Frame(Window(self.remote_services_text), title='Remote Services'),
|
||||
filter=Condition(lambda: self.top_tab == 'remote-services'),
|
||||
),
|
||||
ConditionalContainer(
|
||||
@@ -362,17 +375,19 @@ class ConsoleApp:
|
||||
|
||||
def show_remote_services(self, services):
|
||||
lines = []
|
||||
del self.known_attributes[:]
|
||||
del self.known_remote_attributes[:]
|
||||
for service in services:
|
||||
lines.append(("ansicyan", f"{service}\n"))
|
||||
|
||||
for characteristic in service.characteristics:
|
||||
lines.append(('ansimagenta', f' {characteristic} + \n'))
|
||||
self.known_attributes.append(
|
||||
self.known_remote_attributes.append(
|
||||
f'{service.uuid.to_hex_str()}.{characteristic.uuid.to_hex_str()}'
|
||||
)
|
||||
self.known_attributes.append(f'*.{characteristic.uuid.to_hex_str()}')
|
||||
self.known_attributes.append(f'#{characteristic.handle:X}')
|
||||
self.known_remote_attributes.append(
|
||||
f'*.{characteristic.uuid.to_hex_str()}'
|
||||
)
|
||||
self.known_remote_attributes.append(f'#{characteristic.handle:X}')
|
||||
for descriptor in characteristic.descriptors:
|
||||
lines.append(("ansigreen", f" {descriptor}\n"))
|
||||
|
||||
@@ -381,12 +396,31 @@ class ConsoleApp:
|
||||
|
||||
def show_local_services(self, attributes):
|
||||
lines = []
|
||||
del self.known_local_attributes[:]
|
||||
for attribute in attributes:
|
||||
if isinstance(attribute, Service):
|
||||
# Save the most recent service for use later
|
||||
service = attribute
|
||||
lines.append(("ansicyan", f"{attribute}\n"))
|
||||
elif isinstance(attribute, (Characteristic, CharacteristicDeclaration)):
|
||||
elif isinstance(attribute, Characteristic):
|
||||
# CharacteristicDeclaration includes all info from Characteristic
|
||||
# no need to print it twice
|
||||
continue
|
||||
elif isinstance(attribute, CharacteristicDeclaration):
|
||||
# Save the most recent characteristic declaration for use later
|
||||
characteristic_declaration = attribute
|
||||
self.known_local_attributes.append(
|
||||
f'{service.uuid.to_hex_str()}.{attribute.characteristic.uuid.to_hex_str()}'
|
||||
)
|
||||
self.known_local_attributes.append(
|
||||
f'#{attribute.characteristic.handle:X}'
|
||||
)
|
||||
lines.append(("ansimagenta", f" {attribute}\n"))
|
||||
elif isinstance(attribute, Descriptor):
|
||||
self.known_local_attributes.append(
|
||||
f'{service.uuid.to_hex_str()}.{characteristic_declaration.characteristic.uuid.to_hex_str()}.{attribute.type.to_hex_str()}'
|
||||
)
|
||||
self.known_local_attributes.append(f'#{attribute.handle:X}')
|
||||
lines.append(("ansigreen", f" {attribute}\n"))
|
||||
else:
|
||||
lines.append(("ansiyellow", f"{attribute}\n"))
|
||||
@@ -490,7 +524,9 @@ class ConsoleApp:
|
||||
|
||||
self.show_attributes(attributes)
|
||||
|
||||
def find_characteristic(self, param):
|
||||
def find_remote_characteristic(self, param) -> Optional[CharacteristicProxy]:
|
||||
if not self.connected_peer:
|
||||
return None
|
||||
parts = param.split('.')
|
||||
if len(parts) == 2:
|
||||
service_uuid = UUID(parts[0]) if parts[0] != '*' else None
|
||||
@@ -510,6 +546,38 @@ class ConsoleApp:
|
||||
|
||||
return None
|
||||
|
||||
def find_local_attribute(
|
||||
self, param
|
||||
) -> Optional[Union[Characteristic, Descriptor]]:
|
||||
parts = param.split('.')
|
||||
if len(parts) == 3:
|
||||
service_uuid = UUID(parts[0])
|
||||
characteristic_uuid = UUID(parts[1])
|
||||
descriptor_uuid = UUID(parts[2])
|
||||
return self.device.gatt_server.get_descriptor_attribute(
|
||||
service_uuid, characteristic_uuid, descriptor_uuid
|
||||
)
|
||||
if len(parts) == 2:
|
||||
service_uuid = UUID(parts[0])
|
||||
characteristic_uuid = UUID(parts[1])
|
||||
characteristic_attributes = (
|
||||
self.device.gatt_server.get_characteristic_attributes(
|
||||
service_uuid, characteristic_uuid
|
||||
)
|
||||
)
|
||||
if characteristic_attributes:
|
||||
return characteristic_attributes[1]
|
||||
return None
|
||||
elif len(parts) == 1:
|
||||
if parts[0].startswith('#'):
|
||||
attribute_handle = int(f'{parts[0][1:]}', 16)
|
||||
attribute = self.device.gatt_server.get_attribute(attribute_handle)
|
||||
if isinstance(attribute, (Characteristic, Descriptor)):
|
||||
return attribute
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
async def rssi_monitor_loop(self):
|
||||
while True:
|
||||
if self.monitor_rssi and self.connected_peer:
|
||||
@@ -668,10 +736,70 @@ class ConsoleApp:
|
||||
'device',
|
||||
'local-services',
|
||||
'remote-services',
|
||||
'local-values',
|
||||
}:
|
||||
self.top_tab = params[0]
|
||||
self.ui.invalidate()
|
||||
|
||||
while self.top_tab == 'local-values':
|
||||
await self.do_show_local_values()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def do_show_local_values(self):
|
||||
prettytable = PrettyTable()
|
||||
field_names = ["Service", "Characteristic", "Descriptor"]
|
||||
|
||||
# if there's no connections, add a column just for value
|
||||
if not self.device.connections:
|
||||
field_names.append("Value")
|
||||
|
||||
# if there are connections, add a column for each connection's value
|
||||
for connection in self.device.connections.values():
|
||||
field_names.append(f"Connection {connection.handle}")
|
||||
|
||||
for attribute in self.device.gatt_server.attributes:
|
||||
if isinstance(attribute, Characteristic):
|
||||
service = self.device.gatt_server.get_attribute_group(
|
||||
attribute.handle, Service
|
||||
)
|
||||
if not service:
|
||||
continue
|
||||
values = [
|
||||
attribute.read_value(connection)
|
||||
for connection in self.device.connections.values()
|
||||
]
|
||||
if not values:
|
||||
values = [attribute.read_value(None)]
|
||||
prettytable.add_row([f"{service.uuid}", attribute.uuid, ""] + values)
|
||||
|
||||
elif isinstance(attribute, Descriptor):
|
||||
service = self.device.gatt_server.get_attribute_group(
|
||||
attribute.handle, Service
|
||||
)
|
||||
if not service:
|
||||
continue
|
||||
characteristic = self.device.gatt_server.get_attribute_group(
|
||||
attribute.handle, Characteristic
|
||||
)
|
||||
if not characteristic:
|
||||
continue
|
||||
values = [
|
||||
attribute.read_value(connection)
|
||||
for connection in self.device.connections.values()
|
||||
]
|
||||
if not values:
|
||||
values = [attribute.read_value(None)]
|
||||
|
||||
# TODO: future optimization: convert CCCD value to human readable string
|
||||
|
||||
prettytable.add_row(
|
||||
[service.uuid, characteristic.uuid, attribute.type] + values
|
||||
)
|
||||
|
||||
prettytable.field_names = field_names
|
||||
self.local_values_text.text = prettytable.get_string()
|
||||
self.ui.invalidate()
|
||||
|
||||
async def do_get_phy(self, _):
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
@@ -714,7 +842,7 @@ class ConsoleApp:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
|
||||
characteristic = self.find_characteristic(params[0])
|
||||
characteristic = self.find_remote_characteristic(params[0])
|
||||
if characteristic is None:
|
||||
self.show_error('no such characteristic')
|
||||
return
|
||||
@@ -739,15 +867,43 @@ class ConsoleApp:
|
||||
except ValueError:
|
||||
value = str.encode(params[1]) # must be a string
|
||||
|
||||
characteristic = self.find_characteristic(params[0])
|
||||
characteristic = self.find_remote_characteristic(params[0])
|
||||
if characteristic is None:
|
||||
self.show_error('no such characteristic')
|
||||
return
|
||||
|
||||
# use write with response if supported
|
||||
with_response = characteristic.properties & Characteristic.WRITE
|
||||
with_response = characteristic.properties & Characteristic.Properties.WRITE
|
||||
await characteristic.write_value(value, with_response=with_response)
|
||||
|
||||
async def do_local_write(self, params):
|
||||
if len(params) != 2:
|
||||
self.show_error(
|
||||
'invalid syntax', 'expected local-write <attribute> <value>'
|
||||
)
|
||||
return
|
||||
|
||||
if params[1].upper().startswith("0X"):
|
||||
value = bytes.fromhex(params[1][2:]) # parse as hex string
|
||||
else:
|
||||
try:
|
||||
value = int(params[1]).to_bytes(2, "little") # try as 2 byte integer
|
||||
except ValueError:
|
||||
value = str.encode(params[1]) # must be a string
|
||||
|
||||
attribute = self.find_local_attribute(params[0])
|
||||
if not attribute:
|
||||
self.show_error('invalid syntax', 'unable to find attribute')
|
||||
return
|
||||
|
||||
# send data to any subscribers
|
||||
if isinstance(attribute, Characteristic):
|
||||
attribute.write_value(None, value)
|
||||
if attribute.has_properties([Characteristic.NOTIFY]):
|
||||
await self.device.gatt_server.notify_subscribers(attribute)
|
||||
if attribute.has_properties([Characteristic.INDICATE]):
|
||||
await self.device.gatt_server.indicate_subscribers(attribute)
|
||||
|
||||
async def do_subscribe(self, params):
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
@@ -757,7 +913,7 @@ class ConsoleApp:
|
||||
self.show_error('invalid syntax', 'expected subscribe <attribute>')
|
||||
return
|
||||
|
||||
characteristic = self.find_characteristic(params[0])
|
||||
characteristic = self.find_remote_characteristic(params[0])
|
||||
if characteristic is None:
|
||||
self.show_error('no such characteristic')
|
||||
return
|
||||
@@ -777,7 +933,7 @@ class ConsoleApp:
|
||||
self.show_error('invalid syntax', 'expected subscribe <attribute>')
|
||||
return
|
||||
|
||||
characteristic = self.find_characteristic(params[0])
|
||||
characteristic = self.find_remote_characteristic(params[0])
|
||||
if characteristic is None:
|
||||
self.show_error('no such characteristic')
|
||||
return
|
||||
|
||||
@@ -19,9 +19,9 @@ import asyncio
|
||||
import os
|
||||
import logging
|
||||
import click
|
||||
from colors import color
|
||||
from bumble.company_ids import COMPANY_IDENTIFIERS
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.core import name_or_number
|
||||
from bumble.hci import (
|
||||
map_null_terminated_utf8_string,
|
||||
@@ -30,6 +30,8 @@ from bumble.hci import (
|
||||
HCI_VERSION_NAMES,
|
||||
LMP_VERSION_NAMES,
|
||||
HCI_Command,
|
||||
HCI_Command_Complete_Event,
|
||||
HCI_Command_Status_Event,
|
||||
HCI_READ_BD_ADDR_COMMAND,
|
||||
HCI_Read_BD_ADDR_Command,
|
||||
HCI_READ_LOCAL_NAME_COMMAND,
|
||||
@@ -45,11 +47,20 @@ from bumble.host import Host
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def command_succeeded(response):
|
||||
if isinstance(response, HCI_Command_Status_Event):
|
||||
return response.status == HCI_SUCCESS
|
||||
if isinstance(response, HCI_Command_Complete_Event):
|
||||
return response.return_parameters.status == HCI_SUCCESS
|
||||
return False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def get_classic_info(host):
|
||||
if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
|
||||
response = await host.send_command(HCI_Read_BD_ADDR_Command())
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
if command_succeeded(response):
|
||||
print()
|
||||
print(
|
||||
color('Classic Address:', 'yellow'), response.return_parameters.bd_addr
|
||||
@@ -57,7 +68,7 @@ async def get_classic_info(host):
|
||||
|
||||
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
|
||||
response = await host.send_command(HCI_Read_Local_Name_Command())
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
if command_succeeded(response):
|
||||
print()
|
||||
print(
|
||||
color('Local Name:', 'yellow'),
|
||||
@@ -73,7 +84,7 @@ async def get_le_info(host):
|
||||
response = await host.send_command(
|
||||
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command()
|
||||
)
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
if command_succeeded(response):
|
||||
print(
|
||||
color('LE Number Of Supported Advertising Sets:', 'yellow'),
|
||||
response.return_parameters.num_supported_advertising_sets,
|
||||
@@ -84,7 +95,7 @@ async def get_le_info(host):
|
||||
response = await host.send_command(
|
||||
HCI_LE_Read_Maximum_Advertising_Data_Length_Command()
|
||||
)
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
if command_succeeded(response):
|
||||
print(
|
||||
color('LE Maximum Advertising Data Length:', 'yellow'),
|
||||
response.return_parameters.max_advertising_data_length,
|
||||
@@ -93,7 +104,7 @@ async def get_le_info(host):
|
||||
|
||||
if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND):
|
||||
response = await host.send_command(HCI_LE_Read_Maximum_Data_Length_Command())
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
if command_succeeded(response):
|
||||
print(
|
||||
color('Maximum Data Length:', 'yellow'),
|
||||
(
|
||||
|
||||
@@ -19,9 +19,9 @@ import asyncio
|
||||
import os
|
||||
import logging
|
||||
import click
|
||||
from colors import color
|
||||
|
||||
import bumble.core
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.gatt import show_services
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
@@ -20,8 +20,8 @@ import os
|
||||
import struct
|
||||
import logging
|
||||
import click
|
||||
from colors import color
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.gatt import Service, Characteristic, CharacteristicValue
|
||||
@@ -230,13 +230,13 @@ class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
|
||||
)
|
||||
self.tx_characteristic = Characteristic(
|
||||
GG_GATTLINK_TX_CHARACTERISTIC_UUID,
|
||||
Characteristic.NOTIFY,
|
||||
Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
)
|
||||
self.tx_characteristic.on('subscription', self.on_tx_subscription)
|
||||
self.psm_characteristic = Characteristic(
|
||||
GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID,
|
||||
Characteristic.READ | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
bytes([psm, 0]),
|
||||
)
|
||||
@@ -339,8 +339,7 @@ async def run(
|
||||
|
||||
# Create a UDP to TX bridge (receive from TX, send to UDP)
|
||||
bridge.tx_socket, _ = await loop.create_datagram_endpoint(
|
||||
# pylint: disable-next=unnecessary-lambda
|
||||
lambda: asyncio.DatagramProtocol(),
|
||||
asyncio.DatagramProtocol,
|
||||
remote_addr=(send_host, send_port),
|
||||
)
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import click
|
||||
from colors import color
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.device import Device
|
||||
from bumble.utils import FlowControlAsyncPipe
|
||||
|
||||
@@ -23,9 +23,10 @@ import argparse
|
||||
import uuid
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
from colors import color
|
||||
import websockets
|
||||
|
||||
from bumble.colors import color
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
135
apps/pair.py
135
apps/pair.py
@@ -19,9 +19,9 @@ import asyncio
|
||||
import os
|
||||
import logging
|
||||
import click
|
||||
import aioconsole
|
||||
from colors import color
|
||||
from prompt_toolkit.shortcuts import PromptSession
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.smp import PairingDelegate, PairingConfig
|
||||
@@ -42,9 +42,23 @@ from bumble.att import (
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Waiter:
|
||||
instance = None
|
||||
|
||||
def __init__(self):
|
||||
self.done = asyncio.get_running_loop().create_future()
|
||||
|
||||
def terminate(self):
|
||||
self.done.set_result(None)
|
||||
|
||||
async def wait_until_terminated(self):
|
||||
return await self.done
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Delegate(PairingDelegate):
|
||||
def __init__(self, mode, connection, capability_string, prompt):
|
||||
def __init__(self, mode, connection, capability_string, do_prompt):
|
||||
super().__init__(
|
||||
{
|
||||
'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY,
|
||||
@@ -58,7 +72,18 @@ class Delegate(PairingDelegate):
|
||||
self.mode = mode
|
||||
self.peer = Peer(connection)
|
||||
self.peer_name = None
|
||||
self.prompt = prompt
|
||||
self.do_prompt = do_prompt
|
||||
|
||||
def print(self, message):
|
||||
print(color(message, 'yellow'))
|
||||
|
||||
async def prompt(self, message):
|
||||
# Wait a bit to allow some of the log lines to print before we prompt
|
||||
await asyncio.sleep(1)
|
||||
|
||||
session = PromptSession(message)
|
||||
response = await session.prompt_async()
|
||||
return response.lower().strip()
|
||||
|
||||
async def update_peer_name(self):
|
||||
if self.peer_name is not None:
|
||||
@@ -73,19 +98,15 @@ class Delegate(PairingDelegate):
|
||||
self.peer_name = '[?]'
|
||||
|
||||
async def accept(self):
|
||||
if self.prompt:
|
||||
if self.do_prompt:
|
||||
await self.update_peer_name()
|
||||
|
||||
# Wait a bit to allow some of the log lines to print before we prompt
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Prompt for acceptance
|
||||
print(color('###-----------------------------------', 'yellow'))
|
||||
print(color(f'### Pairing request from {self.peer_name}', 'yellow'))
|
||||
print(color('###-----------------------------------', 'yellow'))
|
||||
self.print('###-----------------------------------')
|
||||
self.print(f'### Pairing request from {self.peer_name}')
|
||||
self.print('###-----------------------------------')
|
||||
while True:
|
||||
response = await aioconsole.ainput(color('>>> Accept? ', 'yellow'))
|
||||
response = response.lower().strip()
|
||||
response = await self.prompt('>>> Accept? ')
|
||||
|
||||
if response == 'yes':
|
||||
return True
|
||||
@@ -96,23 +117,17 @@ class Delegate(PairingDelegate):
|
||||
# Accept silently
|
||||
return True
|
||||
|
||||
async def compare_numbers(self, number, digits=6):
|
||||
async def compare_numbers(self, number, digits):
|
||||
await self.update_peer_name()
|
||||
|
||||
# Wait a bit to allow some of the log lines to print before we prompt
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Prompt for a numeric comparison
|
||||
print(color('###-----------------------------------', 'yellow'))
|
||||
print(color(f'### Pairing with {self.peer_name}', 'yellow'))
|
||||
print(color('###-----------------------------------', 'yellow'))
|
||||
self.print('###-----------------------------------')
|
||||
self.print(f'### Pairing with {self.peer_name}')
|
||||
self.print('###-----------------------------------')
|
||||
while True:
|
||||
response = await aioconsole.ainput(
|
||||
color(
|
||||
f'>>> Does the other device display {number:0{digits}}? ', 'yellow'
|
||||
)
|
||||
response = await self.prompt(
|
||||
f'>>> Does the other device display {number:0{digits}}? '
|
||||
)
|
||||
response = response.lower().strip()
|
||||
|
||||
if response == 'yes':
|
||||
return True
|
||||
@@ -123,30 +138,24 @@ class Delegate(PairingDelegate):
|
||||
async def get_number(self):
|
||||
await self.update_peer_name()
|
||||
|
||||
# Wait a bit to allow some of the log lines to print before we prompt
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Prompt for a PIN
|
||||
while True:
|
||||
try:
|
||||
print(color('###-----------------------------------', 'yellow'))
|
||||
print(color(f'### Pairing with {self.peer_name}', 'yellow'))
|
||||
print(color('###-----------------------------------', 'yellow'))
|
||||
return int(await aioconsole.ainput(color('>>> Enter PIN: ', 'yellow')))
|
||||
self.print('###-----------------------------------')
|
||||
self.print(f'### Pairing with {self.peer_name}')
|
||||
self.print('###-----------------------------------')
|
||||
return int(await self.prompt('>>> Enter PIN: '))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def display_number(self, number, digits=6):
|
||||
async def display_number(self, number, digits):
|
||||
await self.update_peer_name()
|
||||
|
||||
# Wait a bit to allow some of the log lines to print before we prompt
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Display a PIN code
|
||||
print(color('###-----------------------------------', 'yellow'))
|
||||
print(color(f'### Pairing with {self.peer_name}', 'yellow'))
|
||||
print(color(f'### PIN: {number:0{digits}}', 'yellow'))
|
||||
print(color('###-----------------------------------', 'yellow'))
|
||||
self.print('###-----------------------------------')
|
||||
self.print(f'### Pairing with {self.peer_name}')
|
||||
self.print(f'### PIN: {number:0{digits}}')
|
||||
self.print('###-----------------------------------')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -238,6 +247,7 @@ def on_pairing(keys):
|
||||
print(color('*** Paired!', 'cyan'))
|
||||
keys.print(prefix=color('*** ', 'cyan'))
|
||||
print(color('***-----------------------------------', 'cyan'))
|
||||
Waiter.instance.terminate()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -245,6 +255,7 @@ def on_pairing_failure(reason):
|
||||
print(color('***-----------------------------------', 'red'))
|
||||
print(color(f'*** Pairing failed: {smp_error_name(reason)}', 'red'))
|
||||
print(color('***-----------------------------------', 'red'))
|
||||
Waiter.instance.terminate()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -253,6 +264,7 @@ async def pair(
|
||||
sc,
|
||||
mitm,
|
||||
bond,
|
||||
ctkd,
|
||||
io,
|
||||
prompt,
|
||||
request,
|
||||
@@ -262,6 +274,8 @@ async def pair(
|
||||
hci_transport,
|
||||
address_or_name,
|
||||
):
|
||||
Waiter.instance = Waiter()
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
@@ -289,7 +303,8 @@ async def pair(
|
||||
[
|
||||
Characteristic(
|
||||
'552957FB-CF1F-4A31-9535-E78847E1A714',
|
||||
Characteristic.READ | Characteristic.WRITE,
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
CharacteristicValue(
|
||||
read=read_with_error, write=write_with_error
|
||||
@@ -303,6 +318,7 @@ async def pair(
|
||||
if mode == 'classic':
|
||||
device.classic_enabled = True
|
||||
device.le_enabled = False
|
||||
device.classic_smp_enabled = ctkd
|
||||
|
||||
# Get things going
|
||||
await device.power_on()
|
||||
@@ -332,7 +348,19 @@ async def pair(
|
||||
# Advertise so that peers can find us and connect
|
||||
await device.start_advertising(auto_restart=True)
|
||||
|
||||
await hci_source.wait_for_termination()
|
||||
# Run until the user asks to exit
|
||||
await Waiter.instance.wait_until_terminated()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class LogHandler(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setFormatter(logging.Formatter('%(levelname)s:%(name)s:%(message)s'))
|
||||
|
||||
def emit(self, record):
|
||||
message = self.format(record)
|
||||
print(message)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -353,6 +381,13 @@ async def pair(
|
||||
@click.option(
|
||||
'--bond', type=bool, default=True, help='Enable bonding', show_default=True
|
||||
)
|
||||
@click.option(
|
||||
'--ctkd',
|
||||
type=bool,
|
||||
default=True,
|
||||
help='Enable CTKD',
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
'--io',
|
||||
type=click.Choice(
|
||||
@@ -366,7 +401,11 @@ async def pair(
|
||||
'--request', is_flag=True, help='Request that the connecting peer initiate pairing'
|
||||
)
|
||||
@click.option('--print-keys', is_flag=True, help='Print the bond keys before pairing')
|
||||
@click.option('--keystore-file', help='File in which to store the pairing keys')
|
||||
@click.option(
|
||||
'--keystore-file',
|
||||
metavar='<filename>',
|
||||
help='File in which to store the pairing keys',
|
||||
)
|
||||
@click.argument('device-config')
|
||||
@click.argument('hci_transport')
|
||||
@click.argument('address-or-name', required=False)
|
||||
@@ -375,6 +414,7 @@ def main(
|
||||
sc,
|
||||
mitm,
|
||||
bond,
|
||||
ctkd,
|
||||
io,
|
||||
prompt,
|
||||
request,
|
||||
@@ -384,13 +424,20 @@ def main(
|
||||
hci_transport,
|
||||
address_or_name,
|
||||
):
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
# Setup logging
|
||||
log_handler = LogHandler()
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(log_handler)
|
||||
root_logger.setLevel(os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
|
||||
# Pair
|
||||
asyncio.run(
|
||||
pair(
|
||||
mode,
|
||||
sc,
|
||||
mitm,
|
||||
bond,
|
||||
ctkd,
|
||||
io,
|
||||
prompt,
|
||||
request,
|
||||
|
||||
@@ -19,8 +19,8 @@ import asyncio
|
||||
import os
|
||||
import logging
|
||||
import click
|
||||
from colors import color
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.keys import JsonKeyStore
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import struct
|
||||
import click
|
||||
from colors import color
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble import hci
|
||||
from bumble.transport.common import PacketReader
|
||||
from bumble.helpers import PacketTracer
|
||||
|
||||
@@ -30,8 +30,8 @@ import os
|
||||
import logging
|
||||
import click
|
||||
import usb1
|
||||
from colors import color
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.transport.usb import load_libusb
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
import struct
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
import bitstruct
|
||||
|
||||
from .company_ids import COMPANY_IDENTIFIERS
|
||||
from .sdp import (
|
||||
@@ -258,7 +257,6 @@ class SbcMediaCodecInformation(
|
||||
A2DP spec - 4.3.2 Codec Specific Information Elements
|
||||
'''
|
||||
|
||||
BIT_FIELDS = 'u4u4u4u2u2u8u8'
|
||||
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
|
||||
CHANNEL_MODE_BITS = {
|
||||
SBC_MONO_CHANNEL_MODE: 1 << 3,
|
||||
@@ -274,9 +272,22 @@ class SbcMediaCodecInformation(
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data):
|
||||
def from_bytes(data: bytes) -> 'SbcMediaCodecInformation':
|
||||
sampling_frequency = (data[0] >> 4) & 0x0F
|
||||
channel_mode = (data[0] >> 0) & 0x0F
|
||||
block_length = (data[1] >> 4) & 0x0F
|
||||
subbands = (data[1] >> 2) & 0x03
|
||||
allocation_method = (data[1] >> 0) & 0x03
|
||||
minimum_bitpool_value = (data[2] >> 0) & 0xFF
|
||||
maximum_bitpool_value = (data[3] >> 0) & 0xFF
|
||||
return SbcMediaCodecInformation(
|
||||
*bitstruct.unpack(SbcMediaCodecInformation.BIT_FIELDS, data)
|
||||
sampling_frequency,
|
||||
channel_mode,
|
||||
block_length,
|
||||
subbands,
|
||||
allocation_method,
|
||||
minimum_bitpool_value,
|
||||
maximum_bitpool_value,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -325,8 +336,17 @@ class SbcMediaCodecInformation(
|
||||
maximum_bitpool_value=maximum_bitpool_value,
|
||||
)
|
||||
|
||||
def __bytes__(self):
|
||||
return bitstruct.pack(self.BIT_FIELDS, *self)
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes(
|
||||
[
|
||||
(self.sampling_frequency << 4) | self.channel_mode,
|
||||
(self.block_length << 4)
|
||||
| (self.subbands << 2)
|
||||
| self.allocation_method,
|
||||
self.minimum_bitpool_value,
|
||||
self.maximum_bitpool_value,
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
|
||||
@@ -350,14 +370,13 @@ class SbcMediaCodecInformation(
|
||||
class AacMediaCodecInformation(
|
||||
namedtuple(
|
||||
'AacMediaCodecInformation',
|
||||
['object_type', 'sampling_frequency', 'channels', 'vbr', 'bitrate'],
|
||||
['object_type', 'sampling_frequency', 'channels', 'rfa', 'vbr', 'bitrate'],
|
||||
)
|
||||
):
|
||||
'''
|
||||
A2DP spec - 4.5.2 Codec Specific Information Elements
|
||||
'''
|
||||
|
||||
BIT_FIELDS = 'u8u12u2p2u1u23'
|
||||
OBJECT_TYPE_BITS = {
|
||||
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
|
||||
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
|
||||
@@ -381,9 +400,15 @@ class AacMediaCodecInformation(
|
||||
CHANNELS_BITS = {1: 1 << 1, 2: 1}
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data):
|
||||
def from_bytes(data: bytes) -> 'AacMediaCodecInformation':
|
||||
object_type = data[0]
|
||||
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
|
||||
channels = (data[2] >> 2) & 0x03
|
||||
rfa = 0
|
||||
vbr = (data[3] >> 7) & 0x01
|
||||
bitrate = ((data[3] & 0x7F) << 16) | (data[4] << 8) | data[5]
|
||||
return AacMediaCodecInformation(
|
||||
*bitstruct.unpack(AacMediaCodecInformation.BIT_FIELDS, data)
|
||||
object_type, sampling_frequency, channels, rfa, vbr, bitrate
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -394,6 +419,7 @@ class AacMediaCodecInformation(
|
||||
object_type=cls.OBJECT_TYPE_BITS[object_type],
|
||||
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
|
||||
channels=cls.CHANNELS_BITS[channels],
|
||||
rfa=0,
|
||||
vbr=vbr,
|
||||
bitrate=bitrate,
|
||||
)
|
||||
@@ -410,8 +436,17 @@ class AacMediaCodecInformation(
|
||||
bitrate=bitrate,
|
||||
)
|
||||
|
||||
def __bytes__(self):
|
||||
return bitstruct.pack(self.BIT_FIELDS, *self)
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes(
|
||||
[
|
||||
self.object_type & 0xFF,
|
||||
(self.sampling_frequency >> 4) & 0xFF,
|
||||
(((self.sampling_frequency & 0x0F) << 4) | (self.channels << 2)) & 0xFF,
|
||||
((self.vbr << 7) | ((self.bitrate >> 16) & 0x7F)) & 0xFF,
|
||||
((self.bitrate >> 8) & 0xFF) & 0xFF,
|
||||
self.bitrate & 0xFF,
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
object_types = [
|
||||
|
||||
@@ -23,14 +23,17 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import functools
|
||||
import struct
|
||||
from colors import color
|
||||
from pyee import EventEmitter
|
||||
from typing import Dict, Type
|
||||
from typing import Dict, Type, TYPE_CHECKING
|
||||
|
||||
from bumble.core import UUID, name_or_number
|
||||
from bumble.hci import HCI_Object, key_with_value
|
||||
from bumble.core import UUID, name_or_number, get_dict_key_by_value, ProtocolError
|
||||
from bumble.hci import HCI_Object, key_with_value, HCI_Constant
|
||||
from bumble.colors import color
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.device import Connection
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -182,13 +185,18 @@ UUID_2_FIELD_SPEC = lambda x, y: UUID.parse_uuid_2(x, y) # noqa: E731
|
||||
# -----------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# -----------------------------------------------------------------------------
|
||||
class ATT_Error(Exception):
|
||||
def __init__(self, error_code, att_handle=0x0000):
|
||||
self.error_code = error_code
|
||||
class ATT_Error(ProtocolError):
|
||||
def __init__(self, error_code, att_handle=0x0000, message=''):
|
||||
super().__init__(
|
||||
error_code,
|
||||
error_namespace='att',
|
||||
error_name=ATT_PDU.error_name(error_code),
|
||||
)
|
||||
self.att_handle = att_handle
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return f'ATT_Error({ATT_PDU.error_name(self.error_code)})'
|
||||
return f'ATT_Error(error={self.error_name}, handle={self.att_handle:04X}): {self.message}'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -723,11 +731,38 @@ class Attribute(EventEmitter):
|
||||
READ_REQUIRES_AUTHORIZATION = 0x40
|
||||
WRITE_REQUIRES_AUTHORIZATION = 0x80
|
||||
|
||||
PERMISSION_NAMES = {
|
||||
READABLE: 'READABLE',
|
||||
WRITEABLE: 'WRITEABLE',
|
||||
READ_REQUIRES_ENCRYPTION: 'READ_REQUIRES_ENCRYPTION',
|
||||
WRITE_REQUIRES_ENCRYPTION: 'WRITE_REQUIRES_ENCRYPTION',
|
||||
READ_REQUIRES_AUTHENTICATION: 'READ_REQUIRES_AUTHENTICATION',
|
||||
WRITE_REQUIRES_AUTHENTICATION: 'WRITE_REQUIRES_AUTHENTICATION',
|
||||
READ_REQUIRES_AUTHORIZATION: 'READ_REQUIRES_AUTHORIZATION',
|
||||
WRITE_REQUIRES_AUTHORIZATION: 'WRITE_REQUIRES_AUTHORIZATION',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def string_to_permissions(permissions_str: str):
|
||||
try:
|
||||
return functools.reduce(
|
||||
lambda x, y: x | get_dict_key_by_value(Attribute.PERMISSION_NAMES, y),
|
||||
permissions_str.split(","),
|
||||
0,
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise TypeError(
|
||||
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}"
|
||||
) from exc
|
||||
|
||||
def __init__(self, attribute_type, permissions, value=b''):
|
||||
EventEmitter.__init__(self)
|
||||
self.handle = 0
|
||||
self.end_group_handle = 0
|
||||
self.permissions = permissions
|
||||
if isinstance(permissions, str):
|
||||
self.permissions = self.string_to_permissions(permissions)
|
||||
else:
|
||||
self.permissions = permissions
|
||||
|
||||
# Convert the type to a UUID object if it isn't already
|
||||
if isinstance(attribute_type, str):
|
||||
@@ -749,7 +784,25 @@ class Attribute(EventEmitter):
|
||||
def decode_value(self, value_bytes):
|
||||
return value_bytes
|
||||
|
||||
def read_value(self, connection):
|
||||
def read_value(self, connection: Connection):
|
||||
if (
|
||||
self.permissions & self.READ_REQUIRES_ENCRYPTION
|
||||
) and not connection.encryption:
|
||||
raise ATT_Error(
|
||||
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
|
||||
)
|
||||
if (
|
||||
self.permissions & self.READ_REQUIRES_AUTHENTICATION
|
||||
) and not connection.authenticated:
|
||||
raise ATT_Error(
|
||||
error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
|
||||
)
|
||||
if self.permissions & self.READ_REQUIRES_AUTHORIZATION:
|
||||
# TODO: handle authorization better
|
||||
raise ATT_Error(
|
||||
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
|
||||
)
|
||||
|
||||
if read := getattr(self.value, 'read', None):
|
||||
try:
|
||||
value = read(connection) # pylint: disable=not-callable
|
||||
@@ -762,7 +815,25 @@ class Attribute(EventEmitter):
|
||||
|
||||
return self.encode_value(value)
|
||||
|
||||
def write_value(self, connection, value_bytes):
|
||||
def write_value(self, connection: Connection, value_bytes):
|
||||
if (
|
||||
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
|
||||
) and not connection.encryption:
|
||||
raise ATT_Error(
|
||||
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
|
||||
)
|
||||
if (
|
||||
self.permissions & self.WRITE_REQUIRES_AUTHENTICATION
|
||||
) and not connection.authenticated:
|
||||
raise ATT_Error(
|
||||
error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
|
||||
)
|
||||
if self.permissions & self.WRITE_REQUIRES_AUTHORIZATION:
|
||||
# TODO: handle authorization better
|
||||
raise ATT_Error(
|
||||
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
|
||||
)
|
||||
|
||||
value = self.decode_value(value_bytes)
|
||||
|
||||
if write := getattr(self.value, 'write', None):
|
||||
|
||||
@@ -20,7 +20,6 @@ import asyncio
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
from colors import color
|
||||
from pyee import EventEmitter
|
||||
from typing import Dict, Type
|
||||
|
||||
@@ -40,6 +39,7 @@ from .a2dp import (
|
||||
VendorSpecificMediaCodecInformation,
|
||||
)
|
||||
from . import sdp
|
||||
from .colors import color
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
|
||||
103
bumble/colors.py
Normal file
103
bumble/colors.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# Copyright (c) 2012 Giorgos Verigakis <verigak@gmail.com>
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software for any
|
||||
# purpose with or without fee is hereby granted, provided that the above
|
||||
# copyright notice and this permission notice appear in all copies.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
from functools import partial
|
||||
from typing import List, Optional, Union
|
||||
|
||||
|
||||
# ANSI color names. There is also a "default"
|
||||
COLORS = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
|
||||
|
||||
# ANSI style names
|
||||
STYLES = (
|
||||
'none',
|
||||
'bold',
|
||||
'faint',
|
||||
'italic',
|
||||
'underline',
|
||||
'blink',
|
||||
'blink2',
|
||||
'negative',
|
||||
'concealed',
|
||||
'crossed',
|
||||
)
|
||||
|
||||
|
||||
ColorSpec = Union[str, int]
|
||||
|
||||
|
||||
def _join(*values: ColorSpec) -> str:
|
||||
return ';'.join(str(v) for v in values)
|
||||
|
||||
|
||||
def _color_code(spec: ColorSpec, base: int) -> str:
|
||||
if isinstance(spec, str):
|
||||
spec = spec.strip().lower()
|
||||
|
||||
if spec == 'default':
|
||||
return _join(base + 9)
|
||||
elif spec in COLORS:
|
||||
return _join(base + COLORS.index(spec))
|
||||
elif isinstance(spec, int) and 0 <= spec <= 255:
|
||||
return _join(base + 8, 5, spec)
|
||||
else:
|
||||
raise ValueError('Invalid color spec "%s"' % spec)
|
||||
|
||||
|
||||
def color(
|
||||
s: str,
|
||||
fg: Optional[ColorSpec] = None,
|
||||
bg: Optional[ColorSpec] = None,
|
||||
style: Optional[str] = None,
|
||||
) -> str:
|
||||
codes: List[ColorSpec] = []
|
||||
|
||||
if fg:
|
||||
codes.append(_color_code(fg, 30))
|
||||
if bg:
|
||||
codes.append(_color_code(bg, 40))
|
||||
if style:
|
||||
for style_part in style.split('+'):
|
||||
if style_part in STYLES:
|
||||
codes.append(STYLES.index(style_part))
|
||||
else:
|
||||
raise ValueError('Invalid style "%s"' % style_part)
|
||||
|
||||
if codes:
|
||||
return '\x1b[{0}m{1}\x1b[0m'.format(_join(*codes), s)
|
||||
else:
|
||||
return s
|
||||
|
||||
|
||||
# Foreground color shortcuts
|
||||
black = partial(color, fg='black')
|
||||
red = partial(color, fg='red')
|
||||
green = partial(color, fg='green')
|
||||
yellow = partial(color, fg='yellow')
|
||||
blue = partial(color, fg='blue')
|
||||
magenta = partial(color, fg='magenta')
|
||||
cyan = partial(color, fg='cyan')
|
||||
white = partial(color, fg='white')
|
||||
|
||||
# Style shortcuts
|
||||
bold = partial(color, style='bold')
|
||||
none = partial(color, style='none')
|
||||
faint = partial(color, style='faint')
|
||||
italic = partial(color, style='italic')
|
||||
underline = partial(color, style='underline')
|
||||
blink = partial(color, style='blink')
|
||||
blink2 = partial(color, style='blink2')
|
||||
negative = partial(color, style='negative')
|
||||
concealed = partial(color, style='concealed')
|
||||
crossed = partial(color, style='crossed')
|
||||
@@ -20,8 +20,13 @@ import asyncio
|
||||
import itertools
|
||||
import random
|
||||
import struct
|
||||
from colors import color
|
||||
from bumble.core import BT_CENTRAL_ROLE, BT_PERIPHERAL_ROLE
|
||||
from bumble.colors import color
|
||||
from bumble.core import (
|
||||
BT_CENTRAL_ROLE,
|
||||
BT_PERIPHERAL_ROLE,
|
||||
BT_LE_TRANSPORT,
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
)
|
||||
|
||||
from bumble.hci import (
|
||||
HCI_ACL_DATA_PACKET,
|
||||
@@ -29,17 +34,21 @@ from bumble.hci import (
|
||||
HCI_COMMAND_PACKET,
|
||||
HCI_COMMAND_STATUS_PENDING,
|
||||
HCI_CONNECTION_TIMEOUT_ERROR,
|
||||
HCI_CONTROLLER_BUSY_ERROR,
|
||||
HCI_EVENT_PACKET,
|
||||
HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR,
|
||||
HCI_LE_1M_PHY,
|
||||
HCI_SUCCESS,
|
||||
HCI_UNKNOWN_HCI_COMMAND_ERROR,
|
||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||
HCI_VERSION_BLUETOOTH_CORE_5_0,
|
||||
Address,
|
||||
HCI_AclDataPacket,
|
||||
HCI_AclDataPacketAssembler,
|
||||
HCI_Command_Complete_Event,
|
||||
HCI_Command_Status_Event,
|
||||
HCI_Connection_Complete_Event,
|
||||
HCI_Connection_Request_Event,
|
||||
HCI_Disconnection_Complete_Event,
|
||||
HCI_Encryption_Change_Event,
|
||||
HCI_LE_Advertising_Report_Event,
|
||||
@@ -47,7 +56,9 @@ from bumble.hci import (
|
||||
HCI_LE_Read_Remote_Features_Complete_Event,
|
||||
HCI_Number_Of_Completed_Packets_Event,
|
||||
HCI_Packet,
|
||||
HCI_Role_Change_Event,
|
||||
)
|
||||
from typing import Optional, Union, Dict
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -65,13 +76,14 @@ class DataObject:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Connection:
|
||||
def __init__(self, controller, handle, role, peer_address, link):
|
||||
def __init__(self, controller, handle, role, peer_address, link, transport):
|
||||
self.controller = controller
|
||||
self.handle = handle
|
||||
self.role = role
|
||||
self.peer_address = peer_address
|
||||
self.link = link
|
||||
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||
self.transport = transport
|
||||
|
||||
def on_hci_acl_data_packet(self, packet):
|
||||
self.assembler.feed_packet(packet)
|
||||
@@ -82,23 +94,33 @@ class Connection:
|
||||
def on_acl_pdu(self, data):
|
||||
if self.link:
|
||||
self.link.send_acl_data(
|
||||
self.controller.random_address, self.peer_address, data
|
||||
self.controller, self.peer_address, self.transport, data
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Controller:
|
||||
def __init__(self, name, host_source=None, host_sink=None, link=None):
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
host_source=None,
|
||||
host_sink=None,
|
||||
link=None,
|
||||
public_address: Optional[Union[bytes, str, Address]] = None,
|
||||
):
|
||||
self.name = name
|
||||
self.hci_sink = None
|
||||
self.link = link
|
||||
|
||||
self.central_connections = (
|
||||
{}
|
||||
) # Connections where this controller is the central
|
||||
self.peripheral_connections = (
|
||||
{}
|
||||
) # Connections where this controller is the peripheral
|
||||
self.central_connections: Dict[
|
||||
Address, Connection
|
||||
] = {} # Connections where this controller is the central
|
||||
self.peripheral_connections: Dict[
|
||||
Address, Connection
|
||||
] = {} # Connections where this controller is the peripheral
|
||||
self.classic_connections: Dict[
|
||||
Address, Connection
|
||||
] = {} # Connections in BR/EDR
|
||||
|
||||
self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
|
||||
self.hci_revision = 0
|
||||
@@ -148,7 +170,14 @@ class Controller:
|
||||
self.advertising_timer_handle = None
|
||||
|
||||
self._random_address = Address('00:00:00:00:00:00')
|
||||
self._public_address = None
|
||||
if isinstance(public_address, Address):
|
||||
self._public_address = public_address
|
||||
elif public_address is not None:
|
||||
self._public_address = Address(
|
||||
public_address, Address.PUBLIC_DEVICE_ADDRESS
|
||||
)
|
||||
else:
|
||||
self._public_address = Address('00:00:00:00:00:00')
|
||||
|
||||
# Set the source and sink interfaces
|
||||
if host_source:
|
||||
@@ -271,7 +300,9 @@ class Controller:
|
||||
handle = 0
|
||||
max_handle = 0
|
||||
for connection in itertools.chain(
|
||||
self.central_connections.values(), self.peripheral_connections.values()
|
||||
self.central_connections.values(),
|
||||
self.peripheral_connections.values(),
|
||||
self.classic_connections.values(),
|
||||
):
|
||||
max_handle = max(max_handle, connection.handle)
|
||||
if connection.handle == handle:
|
||||
@@ -279,14 +310,19 @@ class Controller:
|
||||
handle = max_handle + 1
|
||||
return handle
|
||||
|
||||
def find_connection_by_address(self, address):
|
||||
def find_le_connection_by_address(self, address):
|
||||
return self.central_connections.get(address) or self.peripheral_connections.get(
|
||||
address
|
||||
)
|
||||
|
||||
def find_classic_connection_by_address(self, address):
|
||||
return self.classic_connections.get(address)
|
||||
|
||||
def find_connection_by_handle(self, handle):
|
||||
for connection in itertools.chain(
|
||||
self.central_connections.values(), self.peripheral_connections.values()
|
||||
self.central_connections.values(),
|
||||
self.peripheral_connections.values(),
|
||||
self.classic_connections.values(),
|
||||
):
|
||||
if connection.handle == handle:
|
||||
return connection
|
||||
@@ -298,6 +334,12 @@ class Controller:
|
||||
return connection
|
||||
return None
|
||||
|
||||
def find_classic_connection_by_handle(self, handle):
|
||||
for connection in self.classic_connections.values():
|
||||
if connection.handle == handle:
|
||||
return connection
|
||||
return None
|
||||
|
||||
def on_link_central_connected(self, central_address):
|
||||
'''
|
||||
Called when an incoming connection occurs from a central on the link
|
||||
@@ -310,7 +352,12 @@ class Controller:
|
||||
if connection is None:
|
||||
connection_handle = self.allocate_connection_handle()
|
||||
connection = Connection(
|
||||
self, connection_handle, BT_PERIPHERAL_ROLE, peer_address, self.link
|
||||
self,
|
||||
connection_handle,
|
||||
BT_PERIPHERAL_ROLE,
|
||||
peer_address,
|
||||
self.link,
|
||||
BT_LE_TRANSPORT,
|
||||
)
|
||||
self.peripheral_connections[peer_address] = connection
|
||||
logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}')
|
||||
@@ -364,7 +411,12 @@ class Controller:
|
||||
if connection is None:
|
||||
connection_handle = self.allocate_connection_handle()
|
||||
connection = Connection(
|
||||
self, connection_handle, BT_CENTRAL_ROLE, peer_address, self.link
|
||||
self,
|
||||
connection_handle,
|
||||
BT_CENTRAL_ROLE,
|
||||
peer_address,
|
||||
self.link,
|
||||
BT_LE_TRANSPORT,
|
||||
)
|
||||
self.central_connections[peer_address] = connection
|
||||
logger.debug(
|
||||
@@ -432,16 +484,19 @@ class Controller:
|
||||
|
||||
def on_link_encrypted(self, peer_address, _rand, _ediv, _ltk):
|
||||
# For now, just setup the encryption without asking the host
|
||||
if connection := self.find_connection_by_address(peer_address):
|
||||
if connection := self.find_le_connection_by_address(peer_address):
|
||||
self.send_hci_packet(
|
||||
HCI_Encryption_Change_Event(
|
||||
status=0, connection_handle=connection.handle, encryption_enabled=1
|
||||
)
|
||||
)
|
||||
|
||||
def on_link_acl_data(self, sender_address, data):
|
||||
def on_link_acl_data(self, sender_address, transport, data):
|
||||
# Look for the connection to which this data belongs
|
||||
connection = self.find_connection_by_address(sender_address)
|
||||
if transport == BT_LE_TRANSPORT:
|
||||
connection = self.find_le_connection_by_address(sender_address)
|
||||
else:
|
||||
connection = self.find_classic_connection_by_address(sender_address)
|
||||
if connection is None:
|
||||
logger.warning(f'!!! no connection for {sender_address}')
|
||||
return
|
||||
@@ -478,6 +533,87 @@ class Controller:
|
||||
)
|
||||
self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
|
||||
|
||||
############################################################
|
||||
# Classic link connections
|
||||
############################################################
|
||||
|
||||
def on_classic_connection_request(self, peer_address, link_type):
|
||||
self.send_hci_packet(
|
||||
HCI_Connection_Request_Event(
|
||||
bd_addr=peer_address,
|
||||
class_of_device=0,
|
||||
link_type=link_type,
|
||||
)
|
||||
)
|
||||
|
||||
def on_classic_connection_complete(self, peer_address, status):
|
||||
if status == HCI_SUCCESS:
|
||||
# Allocate (or reuse) a connection handle
|
||||
peer_address = peer_address
|
||||
connection = self.classic_connections.get(peer_address)
|
||||
if connection is None:
|
||||
connection_handle = self.allocate_connection_handle()
|
||||
connection = Connection(
|
||||
controller=self,
|
||||
handle=connection_handle,
|
||||
# Role doesn't matter in Classic because they are managed by HCI_Role_Change and HCI_Role_Discovery
|
||||
role=BT_CENTRAL_ROLE,
|
||||
peer_address=peer_address,
|
||||
link=self.link,
|
||||
transport=BT_BR_EDR_TRANSPORT,
|
||||
)
|
||||
self.classic_connections[peer_address] = connection
|
||||
logger.debug(
|
||||
f'New CLASSIC connection handle: 0x{connection_handle:04X}'
|
||||
)
|
||||
else:
|
||||
connection_handle = connection.handle
|
||||
self.send_hci_packet(
|
||||
HCI_Connection_Complete_Event(
|
||||
status=status,
|
||||
connection_handle=connection_handle,
|
||||
bd_addr=peer_address,
|
||||
encryption_enabled=False,
|
||||
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||
)
|
||||
)
|
||||
else:
|
||||
connection = None
|
||||
self.send_hci_packet(
|
||||
HCI_Connection_Complete_Event(
|
||||
status=status,
|
||||
connection_handle=0,
|
||||
bd_addr=peer_address,
|
||||
encryption_enabled=False,
|
||||
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||
)
|
||||
)
|
||||
|
||||
def on_classic_disconnected(self, peer_address, reason):
|
||||
# Send a disconnection complete event
|
||||
if connection := self.classic_connections.get(peer_address):
|
||||
self.send_hci_packet(
|
||||
HCI_Disconnection_Complete_Event(
|
||||
status=HCI_SUCCESS,
|
||||
connection_handle=connection.handle,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
|
||||
# Remove the connection
|
||||
del self.classic_connections[peer_address]
|
||||
else:
|
||||
logger.warning(f'!!! No classic connection found for {peer_address}')
|
||||
|
||||
def on_classic_role_change(self, peer_address, new_role):
|
||||
self.send_hci_packet(
|
||||
HCI_Role_Change_Event(
|
||||
status=HCI_SUCCESS,
|
||||
bd_addr=peer_address,
|
||||
new_role=new_role,
|
||||
)
|
||||
)
|
||||
|
||||
############################################################
|
||||
# Advertising support
|
||||
############################################################
|
||||
@@ -521,7 +657,31 @@ class Controller:
|
||||
See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command
|
||||
'''
|
||||
|
||||
# TODO: classic mode not supported yet
|
||||
if self.link is None:
|
||||
return
|
||||
logger.debug(f'Connection request to {command.bd_addr}')
|
||||
|
||||
# Check that we don't already have a pending connection
|
||||
if self.link.get_pending_connection():
|
||||
self.send_hci_packet(
|
||||
HCI_Command_Status_Event(
|
||||
status=HCI_CONTROLLER_BUSY_ERROR,
|
||||
num_hci_command_packets=1,
|
||||
command_opcode=command.op_code,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self.link.classic_connect(self, command.bd_addr)
|
||||
|
||||
# Say that the connection is pending
|
||||
self.send_hci_packet(
|
||||
HCI_Command_Status_Event(
|
||||
status=HCI_COMMAND_STATUS_PENDING,
|
||||
num_hci_command_packets=1,
|
||||
command_opcode=command.op_code,
|
||||
)
|
||||
)
|
||||
|
||||
def on_hci_disconnect_command(self, command):
|
||||
'''
|
||||
@@ -537,19 +697,57 @@ class Controller:
|
||||
)
|
||||
|
||||
# Notify the link of the disconnection
|
||||
if not (
|
||||
connection := self.find_central_connection_by_handle(
|
||||
command.connection_handle
|
||||
)
|
||||
):
|
||||
logger.warning('connection not found')
|
||||
return
|
||||
handle = command.connection_handle
|
||||
if connection := self.find_central_connection_by_handle(handle):
|
||||
if self.link:
|
||||
self.link.disconnect(
|
||||
self.random_address, connection.peer_address, command
|
||||
)
|
||||
else:
|
||||
# Remove the connection
|
||||
del self.central_connections[connection.peer_address]
|
||||
elif connection := self.find_classic_connection_by_handle(handle):
|
||||
if self.link:
|
||||
self.link.classic_disconnect(
|
||||
self,
|
||||
connection.peer_address,
|
||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||
)
|
||||
else:
|
||||
# Remove the connection
|
||||
del self.classic_connections[connection.peer_address]
|
||||
|
||||
if self.link:
|
||||
self.link.disconnect(self.random_address, connection.peer_address, command)
|
||||
else:
|
||||
# Remove the connection
|
||||
del self.central_connections[connection.peer_address]
|
||||
def on_hci_accept_connection_request_command(self, command):
|
||||
'''
|
||||
See Bluetooth spec Vol 2, Part E - 7.1.8 Accept Connection Request command
|
||||
'''
|
||||
|
||||
if self.link is None:
|
||||
return
|
||||
self.send_hci_packet(
|
||||
HCI_Command_Status_Event(
|
||||
status=HCI_SUCCESS,
|
||||
num_hci_command_packets=1,
|
||||
command_opcode=command.op_code,
|
||||
)
|
||||
)
|
||||
self.link.classic_accept_connection(self, command.bd_addr, command.role)
|
||||
|
||||
def on_hci_switch_role_command(self, command):
|
||||
'''
|
||||
See Bluetooth spec Vol 2, Part E - 7.2.8 Switch Role command
|
||||
'''
|
||||
|
||||
if self.link is None:
|
||||
return
|
||||
self.send_hci_packet(
|
||||
HCI_Command_Status_Event(
|
||||
status=HCI_SUCCESS,
|
||||
num_hci_command_packets=1,
|
||||
command_opcode=command.op_code,
|
||||
)
|
||||
)
|
||||
self.link.classic_switch_role(self, command.bd_addr, command.role)
|
||||
|
||||
def on_hci_set_event_mask_command(self, command):
|
||||
'''
|
||||
@@ -627,6 +825,12 @@ class Controller:
|
||||
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
|
||||
return bytes([ret])
|
||||
|
||||
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
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
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
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import struct
|
||||
from typing import List, Optional, Tuple, Union, cast
|
||||
|
||||
from .company_ids import COMPANY_IDENTIFIERS
|
||||
|
||||
@@ -143,10 +144,13 @@ class ConnectionError(BaseError): # pylint: disable=redefined-builtin
|
||||
class UUID:
|
||||
'''
|
||||
See Bluetooth spec Vol 3, Part B - 2.5.1 UUID
|
||||
|
||||
Note that this class expects and works in little-endian byte-order throughout.
|
||||
The exception is when interacting with strings, which are in big-endian byte-order.
|
||||
'''
|
||||
|
||||
BASE_UUID = bytes.fromhex('00001000800000805F9B34FB')
|
||||
UUIDS: list[UUID] = [] # Registry of all instances created
|
||||
BASE_UUID = bytes.fromhex('00001000800000805F9B34FB')[::-1] # little-endian
|
||||
UUIDS: List[UUID] = [] # Registry of all instances created
|
||||
|
||||
def __init__(self, uuid_str_or_int, name=None):
|
||||
if isinstance(uuid_str_or_int, int):
|
||||
@@ -181,7 +185,7 @@ class UUID:
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, uuid_bytes, name=None):
|
||||
def from_bytes(cls, uuid_bytes: bytes, name: Optional[str] = None) -> UUID:
|
||||
if len(uuid_bytes) in (2, 4, 16):
|
||||
self = cls.__new__(cls)
|
||||
self.uuid_bytes = uuid_bytes
|
||||
@@ -208,13 +212,20 @@ class UUID:
|
||||
return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2])
|
||||
|
||||
def to_bytes(self, force_128=False):
|
||||
if len(self.uuid_bytes) == 16 or not force_128:
|
||||
'''
|
||||
Serialize UUID in little-endian byte-order
|
||||
'''
|
||||
if not force_128:
|
||||
return self.uuid_bytes
|
||||
|
||||
if len(self.uuid_bytes) == 4:
|
||||
return self.uuid_bytes + UUID.BASE_UUID
|
||||
|
||||
return self.uuid_bytes + bytes([0, 0]) + UUID.BASE_UUID
|
||||
if len(self.uuid_bytes) == 2:
|
||||
return self.BASE_UUID + self.uuid_bytes + bytes([0, 0])
|
||||
elif len(self.uuid_bytes) == 4:
|
||||
return self.BASE_UUID + self.uuid_bytes
|
||||
elif len(self.uuid_bytes) == 16:
|
||||
return self.uuid_bytes
|
||||
else:
|
||||
assert False, "unreachable"
|
||||
|
||||
def to_pdu_bytes(self):
|
||||
'''
|
||||
@@ -225,7 +236,7 @@ class UUID:
|
||||
'''
|
||||
return self.to_bytes(force_128=(len(self.uuid_bytes) == 4))
|
||||
|
||||
def to_hex_str(self):
|
||||
def to_hex_str(self) -> str:
|
||||
if len(self.uuid_bytes) == 2 or len(self.uuid_bytes) == 4:
|
||||
return bytes(reversed(self.uuid_bytes)).hex().upper()
|
||||
|
||||
@@ -607,6 +618,11 @@ class DeviceClass:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Advertising Data
|
||||
# -----------------------------------------------------------------------------
|
||||
AdvertisingObject = Union[
|
||||
List[UUID], Tuple[UUID, bytes], bytes, str, int, Tuple[int, int], Tuple[int, bytes]
|
||||
]
|
||||
|
||||
|
||||
class AdvertisingData:
|
||||
# fmt: off
|
||||
# pylint: disable=line-too-long
|
||||
@@ -722,10 +738,12 @@ class AdvertisingData:
|
||||
BR_EDR_CONTROLLER_FLAG = 0x08
|
||||
BR_EDR_HOST_FLAG = 0x10
|
||||
|
||||
ad_structures: List[Tuple[int, bytes]]
|
||||
|
||||
# fmt: on
|
||||
# pylint: enable=line-too-long
|
||||
|
||||
def __init__(self, ad_structures=None):
|
||||
def __init__(self, ad_structures: Optional[List[Tuple[int, bytes]]] = None) -> None:
|
||||
if ad_structures is None:
|
||||
ad_structures = []
|
||||
self.ad_structures = ad_structures[:]
|
||||
@@ -752,7 +770,7 @@ class AdvertisingData:
|
||||
return ','.join(bit_flags_to_strings(flags, flag_names))
|
||||
|
||||
@staticmethod
|
||||
def uuid_list_to_objects(ad_data, uuid_size):
|
||||
def uuid_list_to_objects(ad_data: bytes, uuid_size: int) -> List[UUID]:
|
||||
uuids = []
|
||||
offset = 0
|
||||
while (uuid_size * (offset + 1)) <= len(ad_data):
|
||||
@@ -829,7 +847,7 @@ class AdvertisingData:
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
@staticmethod
|
||||
def ad_data_to_object(ad_type, ad_data):
|
||||
def ad_data_to_object(ad_type: int, ad_data: bytes) -> AdvertisingObject:
|
||||
if ad_type in (
|
||||
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
@@ -868,22 +886,22 @@ class AdvertisingData:
|
||||
return ad_data.decode("utf-8")
|
||||
|
||||
if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS):
|
||||
return ad_data[0]
|
||||
return cast(int, struct.unpack('B', ad_data)[0])
|
||||
|
||||
if ad_type in (
|
||||
AdvertisingData.APPEARANCE,
|
||||
AdvertisingData.ADVERTISING_INTERVAL,
|
||||
):
|
||||
return struct.unpack('<H', ad_data)[0]
|
||||
return cast(int, struct.unpack('<H', ad_data)[0])
|
||||
|
||||
if ad_type == AdvertisingData.CLASS_OF_DEVICE:
|
||||
return struct.unpack('<I', bytes([*ad_data, 0]))[0]
|
||||
return cast(int, struct.unpack('<I', bytes([*ad_data, 0]))[0])
|
||||
|
||||
if ad_type == AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE:
|
||||
return struct.unpack('<HH', ad_data)
|
||||
return cast(Tuple[int, int], struct.unpack('<HH', ad_data))
|
||||
|
||||
if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
|
||||
return (struct.unpack_from('<H', ad_data, 0)[0], ad_data[2:])
|
||||
return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:])
|
||||
|
||||
return ad_data
|
||||
|
||||
@@ -898,26 +916,27 @@ class AdvertisingData:
|
||||
self.ad_structures.append((ad_type, ad_data))
|
||||
offset += length
|
||||
|
||||
def get(self, type_id, return_all=False, raw=False):
|
||||
def get_all(self, type_id: int, raw: bool = False) -> List[AdvertisingObject]:
|
||||
'''
|
||||
Get Advertising Data Structure(s) with a given type
|
||||
|
||||
If return_all is True, returns a (possibly empty) list of matches,
|
||||
else returns the first entry, or None if no structure matches.
|
||||
Returns a (possibly empty) list of matches.
|
||||
'''
|
||||
|
||||
def process_ad_data(ad_data):
|
||||
def process_ad_data(ad_data: bytes) -> AdvertisingObject:
|
||||
return ad_data if raw else self.ad_data_to_object(type_id, ad_data)
|
||||
|
||||
if return_all:
|
||||
return [
|
||||
process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id
|
||||
]
|
||||
return [process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id]
|
||||
|
||||
return next(
|
||||
(process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id),
|
||||
None,
|
||||
)
|
||||
def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingObject]:
|
||||
'''
|
||||
Get Advertising Data Structure(s) with a given type
|
||||
|
||||
Returns the first entry, or None if no structure matches.
|
||||
'''
|
||||
|
||||
all = self.get_all(type_id, raw=raw)
|
||||
return all[0] if all else None
|
||||
|
||||
def __bytes__(self):
|
||||
return b''.join(
|
||||
|
||||
416
bumble/decoder.py
Normal file
416
bumble/decoder.py
Normal file
@@ -0,0 +1,416 @@
|
||||
# Copyright 2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
# fmt: off
|
||||
|
||||
WL = [-60, -30, 58, 172, 334, 538, 1198, 3042]
|
||||
RL42 = [0, 7, 6, 5, 4, 3, 2, 1, 7, 6, 5, 4, 3, 2, 1, 0]
|
||||
ILB = [
|
||||
2048,
|
||||
2093,
|
||||
2139,
|
||||
2186,
|
||||
2233,
|
||||
2282,
|
||||
2332,
|
||||
2383,
|
||||
2435,
|
||||
2489,
|
||||
2543,
|
||||
2599,
|
||||
2656,
|
||||
2714,
|
||||
2774,
|
||||
2834,
|
||||
2896,
|
||||
2960,
|
||||
3025,
|
||||
3091,
|
||||
3158,
|
||||
3228,
|
||||
3298,
|
||||
3371,
|
||||
3444,
|
||||
3520,
|
||||
3597,
|
||||
3676,
|
||||
3756,
|
||||
3838,
|
||||
3922,
|
||||
4008,
|
||||
]
|
||||
WH = [0, -214, 798]
|
||||
RH2 = [2, 1, 2, 1]
|
||||
# Values in QM2/QM4/QM6 left shift three bits than original g722 specification.
|
||||
QM2 = [-7408, -1616, 7408, 1616]
|
||||
QM4 = [
|
||||
0,
|
||||
-20456,
|
||||
-12896,
|
||||
-8968,
|
||||
-6288,
|
||||
-4240,
|
||||
-2584,
|
||||
-1200,
|
||||
20456,
|
||||
12896,
|
||||
8968,
|
||||
6288,
|
||||
4240,
|
||||
2584,
|
||||
1200,
|
||||
0,
|
||||
]
|
||||
QM6 = [
|
||||
-136,
|
||||
-136,
|
||||
-136,
|
||||
-136,
|
||||
-24808,
|
||||
-21904,
|
||||
-19008,
|
||||
-16704,
|
||||
-14984,
|
||||
-13512,
|
||||
-12280,
|
||||
-11192,
|
||||
-10232,
|
||||
-9360,
|
||||
-8576,
|
||||
-7856,
|
||||
-7192,
|
||||
-6576,
|
||||
-6000,
|
||||
-5456,
|
||||
-4944,
|
||||
-4464,
|
||||
-4008,
|
||||
-3576,
|
||||
-3168,
|
||||
-2776,
|
||||
-2400,
|
||||
-2032,
|
||||
-1688,
|
||||
-1360,
|
||||
-1040,
|
||||
-728,
|
||||
24808,
|
||||
21904,
|
||||
19008,
|
||||
16704,
|
||||
14984,
|
||||
13512,
|
||||
12280,
|
||||
11192,
|
||||
10232,
|
||||
9360,
|
||||
8576,
|
||||
7856,
|
||||
7192,
|
||||
6576,
|
||||
6000,
|
||||
5456,
|
||||
4944,
|
||||
4464,
|
||||
4008,
|
||||
3576,
|
||||
3168,
|
||||
2776,
|
||||
2400,
|
||||
2032,
|
||||
1688,
|
||||
1360,
|
||||
1040,
|
||||
728,
|
||||
432,
|
||||
136,
|
||||
-432,
|
||||
-136,
|
||||
]
|
||||
QMF_COEFFS = [3, -11, 12, 32, -210, 951, 3876, -805, 362, -156, 53, -11]
|
||||
|
||||
# fmt: on
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
class G722Decoder(object):
|
||||
"""G.722 decoder with bitrate 64kbit/s.
|
||||
|
||||
For the Blocks in the sub-band decoders, please refer to the G.722
|
||||
specification for the required information. G722 specification:
|
||||
https://www.itu.int/rec/T-REC-G.722-201209-I
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._x = [0] * 24
|
||||
self._band = [Band(), Band()]
|
||||
# The initial value in BLOCK 3L
|
||||
self._band[0].det = 32
|
||||
# The initial value in BLOCK 3H
|
||||
self._band[1].det = 8
|
||||
|
||||
def decode_frame(self, encoded_data) -> bytearray:
|
||||
result_array = bytearray(len(encoded_data) * 4)
|
||||
self.g722_decode(result_array, encoded_data)
|
||||
return result_array
|
||||
|
||||
def g722_decode(self, result_array, encoded_data) -> int:
|
||||
"""Decode the data frame using g722 decoder."""
|
||||
result_length = 0
|
||||
|
||||
for code in encoded_data:
|
||||
higher_bits = (code >> 6) & 0x03
|
||||
lower_bits = code & 0x3F
|
||||
|
||||
rlow = self.lower_sub_band_decoder(lower_bits)
|
||||
rhigh = self.higher_sub_band_decoder(higher_bits)
|
||||
|
||||
# Apply the receive QMF
|
||||
self._x[:22] = self._x[2:]
|
||||
self._x[22] = rlow + rhigh
|
||||
self._x[23] = rlow - rhigh
|
||||
|
||||
xout2 = sum(self._x[2 * i] * QMF_COEFFS[i] for i in range(12))
|
||||
xout1 = sum(self._x[2 * i + 1] * QMF_COEFFS[11 - i] for i in range(12))
|
||||
|
||||
result_length = self.update_decoded_result(
|
||||
xout1, result_length, result_array
|
||||
)
|
||||
result_length = self.update_decoded_result(
|
||||
xout2, result_length, result_array
|
||||
)
|
||||
|
||||
return result_length
|
||||
|
||||
def update_decoded_result(self, xout, byte_length, byte_array) -> int:
|
||||
result = (int)(xout >> 11)
|
||||
bytes_result = result.to_bytes(2, 'little', signed=True)
|
||||
byte_array[byte_length] = bytes_result[0]
|
||||
byte_array[byte_length + 1] = bytes_result[1]
|
||||
return byte_length + 2
|
||||
|
||||
def lower_sub_band_decoder(self, lower_bits) -> int:
|
||||
"""Lower sub-band decoder for last six bits."""
|
||||
|
||||
# Block 5L
|
||||
# INVQBL
|
||||
wd1 = lower_bits
|
||||
wd2 = QM6[wd1]
|
||||
wd1 >>= 2
|
||||
wd2 = (self._band[0].det * wd2) >> 15
|
||||
# RECONS
|
||||
rlow = self._band[0].s + wd2
|
||||
|
||||
# Block 6L
|
||||
# LIMIT
|
||||
if rlow > 16383:
|
||||
rlow = 16383
|
||||
elif rlow < -16384:
|
||||
rlow = -16384
|
||||
|
||||
# Block 2L
|
||||
# INVQAL
|
||||
wd2 = QM4[wd1]
|
||||
dlowt = (self._band[0].det * wd2) >> 15
|
||||
|
||||
# Block 3L
|
||||
# LOGSCL
|
||||
wd2 = RL42[wd1]
|
||||
wd1 = (self._band[0].nb * 127) >> 7
|
||||
wd1 += WL[wd2]
|
||||
|
||||
if wd1 < 0:
|
||||
wd1 = 0
|
||||
elif wd1 > 18432:
|
||||
wd1 = 18432
|
||||
|
||||
self._band[0].nb = wd1
|
||||
|
||||
# SCALEL
|
||||
wd1 = (self._band[0].nb >> 6) & 31
|
||||
wd2 = 8 - (self._band[0].nb >> 11)
|
||||
|
||||
if wd2 < 0:
|
||||
wd3 = ILB[wd1] << -wd2
|
||||
else:
|
||||
wd3 = ILB[wd1] >> wd2
|
||||
|
||||
self._band[0].det = wd3 << 2
|
||||
|
||||
# Block 4L
|
||||
self._band[0].block4(dlowt)
|
||||
|
||||
return rlow
|
||||
|
||||
def higher_sub_band_decoder(self, higher_bits) -> int:
|
||||
"""Higher sub-band decoder for first two bits."""
|
||||
|
||||
# Block 2H
|
||||
# INVQAH
|
||||
wd2 = QM2[higher_bits]
|
||||
dhigh = (self._band[1].det * wd2) >> 15
|
||||
|
||||
# Block 5H
|
||||
# RECONS
|
||||
rhigh = dhigh + self._band[1].s
|
||||
|
||||
# Block 6H
|
||||
# LIMIT
|
||||
if rhigh > 16383:
|
||||
rhigh = 16383
|
||||
elif rhigh < -16384:
|
||||
rhigh = -16384
|
||||
|
||||
# Block 3H
|
||||
# LOGSCH
|
||||
wd2 = RH2[higher_bits]
|
||||
wd1 = (self._band[1].nb * 127) >> 7
|
||||
wd1 += WH[wd2]
|
||||
|
||||
if wd1 < 0:
|
||||
wd1 = 0
|
||||
elif wd1 > 22528:
|
||||
wd1 = 22528
|
||||
self._band[1].nb = wd1
|
||||
|
||||
# SCALEH
|
||||
wd1 = (self._band[1].nb >> 6) & 31
|
||||
wd2 = 10 - (self._band[1].nb >> 11)
|
||||
|
||||
if wd2 < 0:
|
||||
wd3 = ILB[wd1] << -wd2
|
||||
else:
|
||||
wd3 = ILB[wd1] >> wd2
|
||||
self._band[1].det = wd3 << 2
|
||||
|
||||
# Block 4H
|
||||
self._band[1].block4(dhigh)
|
||||
|
||||
return rhigh
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Band(object):
|
||||
"""Structure for G722 decode proccessing."""
|
||||
|
||||
s: int = 0
|
||||
nb: int = 0
|
||||
det: int = 0
|
||||
|
||||
def __init__(self):
|
||||
self._sp = 0
|
||||
self._sz = 0
|
||||
self._r = [0] * 3
|
||||
self._a = [0] * 3
|
||||
self._ap = [0] * 3
|
||||
self._p = [0] * 3
|
||||
self._d = [0] * 7
|
||||
self._b = [0] * 7
|
||||
self._bp = [0] * 7
|
||||
self._sg = [0] * 7
|
||||
|
||||
def saturate(self, amp: int) -> int:
|
||||
if amp > 32767:
|
||||
return 32767
|
||||
elif amp < -32768:
|
||||
return -32768
|
||||
else:
|
||||
return amp
|
||||
|
||||
def block4(self, d: int) -> None:
|
||||
"""Block4 for both lower and higher sub-band decoder."""
|
||||
wd1 = 0
|
||||
wd2 = 0
|
||||
wd3 = 0
|
||||
|
||||
# RECONS
|
||||
self._d[0] = d
|
||||
self._r[0] = self.saturate(self.s + d)
|
||||
|
||||
# PARREC
|
||||
self._p[0] = self.saturate(self._sz + d)
|
||||
|
||||
# UPPOL2
|
||||
for i in range(3):
|
||||
self._sg[i] = (self._p[i]) >> 15
|
||||
wd1 = self.saturate((self._a[1]) << 2)
|
||||
wd2 = -wd1 if self._sg[0] == self._sg[1] else wd1
|
||||
|
||||
if wd2 > 32767:
|
||||
wd2 = 32767
|
||||
|
||||
wd3 = 128 if self._sg[0] == self._sg[2] else -128
|
||||
wd3 += wd2 >> 7
|
||||
wd3 += (self._a[2] * 32512) >> 15
|
||||
|
||||
if wd3 > 12288:
|
||||
wd3 = 12288
|
||||
elif wd3 < -12288:
|
||||
wd3 = -12288
|
||||
self._ap[2] = wd3
|
||||
|
||||
# UPPOL1
|
||||
self._sg[0] = (self._p[0]) >> 15
|
||||
self._sg[1] = (self._p[1]) >> 15
|
||||
wd1 = 192 if self._sg[0] == self._sg[1] else -192
|
||||
wd2 = (self._a[1] * 32640) >> 15
|
||||
|
||||
self._ap[1] = self.saturate(wd1 + wd2)
|
||||
wd3 = self.saturate(15360 - self._ap[2])
|
||||
|
||||
if self._ap[1] > wd3:
|
||||
self._ap[1] = wd3
|
||||
elif self._ap[1] < -wd3:
|
||||
self._ap[1] = -wd3
|
||||
|
||||
# UPZERO
|
||||
wd1 = 0 if d == 0 else 128
|
||||
self._sg[0] = d >> 15
|
||||
for i in range(1, 7):
|
||||
self._sg[i] = (self._d[i]) >> 15
|
||||
wd2 = wd1 if self._sg[i] == self._sg[0] else -wd1
|
||||
wd3 = (self._b[i] * 32640) >> 15
|
||||
self._bp[i] = self.saturate(wd2 + wd3)
|
||||
|
||||
# DELAYA
|
||||
for i in range(6, 0, -1):
|
||||
self._d[i] = self._d[i - 1]
|
||||
self._b[i] = self._bp[i]
|
||||
|
||||
for i in range(2, 0, -1):
|
||||
self._r[i] = self._r[i - 1]
|
||||
self._p[i] = self._p[i - 1]
|
||||
self._a[i] = self._ap[i]
|
||||
|
||||
# FILTEP
|
||||
self._sp = 0
|
||||
for i in range(1, 3):
|
||||
wd1 = self.saturate(self._r[i] + self._r[i])
|
||||
self._sp += (self._a[i] * wd1) >> 15
|
||||
self._sp = self.saturate(self._sp)
|
||||
|
||||
# FILTEZ
|
||||
self._sz = 0
|
||||
for i in range(6, 0, -1):
|
||||
wd1 = self.saturate(self._d[i] + self._d[i])
|
||||
self._sz += (self._b[i] * wd1) >> 15
|
||||
self._sz = self.saturate(self._sz)
|
||||
|
||||
# PREDIC
|
||||
self.s = self.saturate(self._sp + self._sz)
|
||||
484
bumble/device.py
484
bumble/device.py
File diff suppressed because it is too large
Load Diff
@@ -41,14 +41,14 @@ class GenericAccessService(Service):
|
||||
def __init__(self, device_name, appearance=(0, 0)):
|
||||
device_name_characteristic = Characteristic(
|
||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
device_name.encode('utf-8')[:248],
|
||||
)
|
||||
|
||||
appearance_characteristic = Characteristic(
|
||||
GATT_APPEARANCE_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
struct.pack('<H', (appearance[0] << 6) | appearance[1]),
|
||||
)
|
||||
|
||||
110
bumble/gatt.py
110
bumble/gatt.py
@@ -28,9 +28,9 @@ import enum
|
||||
import functools
|
||||
import logging
|
||||
import struct
|
||||
from typing import Sequence
|
||||
from colors import color
|
||||
from typing import Optional, Sequence, List
|
||||
|
||||
from .colors import color
|
||||
from .core import UUID, get_dict_key_by_value
|
||||
from .att import Attribute
|
||||
|
||||
@@ -204,6 +204,8 @@ class Service(Attribute):
|
||||
See Vol 3, Part G - 3.1 SERVICE DEFINITION
|
||||
'''
|
||||
|
||||
uuid: UUID
|
||||
|
||||
def __init__(self, uuid, characteristics: list[Characteristic], primary=True):
|
||||
# Convert the uuid to a UUID object if it isn't already
|
||||
if isinstance(uuid, str):
|
||||
@@ -221,7 +223,7 @@ class Service(Attribute):
|
||||
self.characteristics = characteristics[:]
|
||||
self.primary = primary
|
||||
|
||||
def get_advertising_data(self):
|
||||
def get_advertising_data(self) -> Optional[bytes]:
|
||||
"""
|
||||
Get Service specific advertising data
|
||||
Defined by each Service, default value is empty
|
||||
@@ -257,63 +259,68 @@ class Characteristic(Attribute):
|
||||
See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION
|
||||
'''
|
||||
|
||||
# Property flags
|
||||
BROADCAST = 0x01
|
||||
READ = 0x02
|
||||
WRITE_WITHOUT_RESPONSE = 0x04
|
||||
WRITE = 0x08
|
||||
NOTIFY = 0x10
|
||||
INDICATE = 0x20
|
||||
AUTHENTICATED_SIGNED_WRITES = 0x40
|
||||
EXTENDED_PROPERTIES = 0x80
|
||||
uuid: UUID
|
||||
properties: Characteristic.Properties
|
||||
|
||||
PROPERTY_NAMES = {
|
||||
BROADCAST: 'BROADCAST',
|
||||
READ: 'READ',
|
||||
WRITE_WITHOUT_RESPONSE: 'WRITE_WITHOUT_RESPONSE',
|
||||
WRITE: 'WRITE',
|
||||
NOTIFY: 'NOTIFY',
|
||||
INDICATE: 'INDICATE',
|
||||
AUTHENTICATED_SIGNED_WRITES: 'AUTHENTICATED_SIGNED_WRITES',
|
||||
EXTENDED_PROPERTIES: 'EXTENDED_PROPERTIES',
|
||||
}
|
||||
class Properties(enum.IntFlag):
|
||||
"""Property flags"""
|
||||
|
||||
@staticmethod
|
||||
def property_name(property_int):
|
||||
return Characteristic.PROPERTY_NAMES.get(property_int, '')
|
||||
BROADCAST = 0x01
|
||||
READ = 0x02
|
||||
WRITE_WITHOUT_RESPONSE = 0x04
|
||||
WRITE = 0x08
|
||||
NOTIFY = 0x10
|
||||
INDICATE = 0x20
|
||||
AUTHENTICATED_SIGNED_WRITES = 0x40
|
||||
EXTENDED_PROPERTIES = 0x80
|
||||
|
||||
@staticmethod
|
||||
def properties_as_string(properties):
|
||||
return ','.join(
|
||||
[
|
||||
Characteristic.property_name(p)
|
||||
for p in Characteristic.PROPERTY_NAMES
|
||||
if properties & p
|
||||
]
|
||||
)
|
||||
@staticmethod
|
||||
def from_string(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)
|
||||
|
||||
@staticmethod
|
||||
def string_to_properties(properties_str: str):
|
||||
return functools.reduce(
|
||||
lambda x, y: x | get_dict_key_by_value(Characteristic.PROPERTY_NAMES, y),
|
||||
properties_str.split(","),
|
||||
0,
|
||||
)
|
||||
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:
|
||||
return functools.reduce(
|
||||
lambda x, y: x | string_to_property(y),
|
||||
properties_str.split(","),
|
||||
Characteristic.Properties(0),
|
||||
)
|
||||
except 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}"
|
||||
)
|
||||
|
||||
# For backwards compatibility these are defined here
|
||||
# For new code, please use Characteristic.Properties.X
|
||||
BROADCAST = Properties.BROADCAST
|
||||
READ = Properties.READ
|
||||
WRITE_WITHOUT_RESPONSE = Properties.WRITE_WITHOUT_RESPONSE
|
||||
WRITE = Properties.WRITE
|
||||
NOTIFY = Properties.NOTIFY
|
||||
INDICATE = Properties.INDICATE
|
||||
AUTHENTICATED_SIGNED_WRITES = Properties.AUTHENTICATED_SIGNED_WRITES
|
||||
EXTENDED_PROPERTIES = Properties.EXTENDED_PROPERTIES
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uuid,
|
||||
properties,
|
||||
properties: Characteristic.Properties,
|
||||
permissions,
|
||||
value=b'',
|
||||
descriptors: Sequence[Descriptor] = (),
|
||||
):
|
||||
super().__init__(uuid, permissions, value)
|
||||
self.uuid = self.type
|
||||
if isinstance(properties, str):
|
||||
self.properties = Characteristic.string_to_properties(properties)
|
||||
else:
|
||||
self.properties = properties
|
||||
self.properties = properties
|
||||
self.descriptors = descriptors
|
||||
|
||||
def get_descriptor(self, descriptor_type):
|
||||
@@ -323,12 +330,15 @@ class Characteristic(Attribute):
|
||||
|
||||
return None
|
||||
|
||||
def has_properties(self, properties: Characteristic.Properties) -> bool:
|
||||
return self.properties & properties == properties
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f'Characteristic(handle=0x{self.handle:04X}, '
|
||||
f'end=0x{self.end_group_handle:04X}, '
|
||||
f'uuid={self.uuid}, '
|
||||
f'properties={Characteristic.properties_as_string(self.properties)})'
|
||||
f'{self.properties!s})'
|
||||
)
|
||||
|
||||
|
||||
@@ -338,6 +348,8 @@ class CharacteristicDeclaration(Attribute):
|
||||
See Vol 3, Part G - 3.3.1 CHARACTERISTIC DECLARATION
|
||||
'''
|
||||
|
||||
characteristic: Characteristic
|
||||
|
||||
def __init__(self, characteristic, value_handle):
|
||||
declaration_bytes = (
|
||||
struct.pack('<BH', characteristic.properties, value_handle)
|
||||
@@ -353,8 +365,8 @@ class CharacteristicDeclaration(Attribute):
|
||||
return (
|
||||
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
|
||||
f'value_handle=0x{self.value_handle:04X}, '
|
||||
f'uuid={self.characteristic.uuid}, properties='
|
||||
f'{Characteristic.properties_as_string(self.characteristic.properties)})'
|
||||
f'uuid={self.characteristic.uuid}, '
|
||||
f'{self.characteristic.properties!s})'
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,13 +23,15 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
from typing import List, Optional, Dict, Any, Callable
|
||||
|
||||
from colors import color
|
||||
from pyee import EventEmitter
|
||||
|
||||
from .colors import color
|
||||
from .hci import HCI_Constant
|
||||
from .att import (
|
||||
ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||
@@ -50,6 +52,7 @@ from .att import (
|
||||
ATT_Read_Request,
|
||||
ATT_Write_Command,
|
||||
ATT_Write_Request,
|
||||
ATT_Error,
|
||||
)
|
||||
from . import core
|
||||
from .core import UUID, InvalidStateError, ProtocolError
|
||||
@@ -73,6 +76,8 @@ logger = logging.getLogger(__name__)
|
||||
# Proxies
|
||||
# -----------------------------------------------------------------------------
|
||||
class AttributeProxy(EventEmitter):
|
||||
client: Client
|
||||
|
||||
def __init__(self, client, handle, end_group_handle, attribute_type):
|
||||
EventEmitter.__init__(self)
|
||||
self.client = client
|
||||
@@ -101,6 +106,9 @@ class AttributeProxy(EventEmitter):
|
||||
|
||||
|
||||
class ServiceProxy(AttributeProxy):
|
||||
uuid: UUID
|
||||
characteristics: List[CharacteristicProxy]
|
||||
|
||||
@staticmethod
|
||||
def from_client(service_class, client, service_uuid):
|
||||
# The service and its characteristics are considered to have already been
|
||||
@@ -130,10 +138,21 @@ class ServiceProxy(AttributeProxy):
|
||||
|
||||
|
||||
class CharacteristicProxy(AttributeProxy):
|
||||
def __init__(self, client, handle, end_group_handle, uuid, properties):
|
||||
properties: Characteristic.Properties
|
||||
descriptors: List[DescriptorProxy]
|
||||
subscribers: Dict[Any, Callable]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client,
|
||||
handle,
|
||||
end_group_handle,
|
||||
uuid,
|
||||
properties: int,
|
||||
):
|
||||
super().__init__(client, handle, end_group_handle, uuid)
|
||||
self.uuid = uuid
|
||||
self.properties = properties
|
||||
self.properties = Characteristic.Properties(properties)
|
||||
self.descriptors = []
|
||||
self.descriptors_discovered = False
|
||||
self.subscribers = {} # Map from subscriber to proxy subscriber
|
||||
@@ -175,7 +194,7 @@ class CharacteristicProxy(AttributeProxy):
|
||||
return (
|
||||
f'Characteristic(handle=0x{self.handle:04X}, '
|
||||
f'uuid={self.uuid}, '
|
||||
f'properties={Characteristic.properties_as_string(self.properties)})'
|
||||
f'{self.properties!s})'
|
||||
)
|
||||
|
||||
|
||||
@@ -201,6 +220,8 @@ class ProfileServiceProxy:
|
||||
# GATT Client
|
||||
# -----------------------------------------------------------------------------
|
||||
class Client:
|
||||
services: List[ServiceProxy]
|
||||
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
self.mtu_exchange_done = False
|
||||
@@ -306,7 +327,7 @@ class Client:
|
||||
if not already_known:
|
||||
self.services.append(service)
|
||||
|
||||
async def discover_services(self, uuids=None):
|
||||
async def discover_services(self, uuids=None) -> List[ServiceProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.4.1 Discover All Primary Services
|
||||
'''
|
||||
@@ -332,8 +353,10 @@ class Client:
|
||||
'!!! unexpected error while discovering services: '
|
||||
f'{HCI_Constant.error_name(response.error_code)}'
|
||||
)
|
||||
# TODO raise appropriate exception
|
||||
return
|
||||
raise ATT_Error(
|
||||
error_code=response.error_code,
|
||||
message='Unexpected error while discovering services',
|
||||
)
|
||||
break
|
||||
|
||||
for (
|
||||
@@ -349,7 +372,7 @@ class Client:
|
||||
logger.warning(
|
||||
f'bogus handle values: {attribute_handle} {end_group_handle}'
|
||||
)
|
||||
return
|
||||
return []
|
||||
|
||||
# Create a service proxy for this service
|
||||
service = ServiceProxy(
|
||||
@@ -452,7 +475,9 @@ class Client:
|
||||
# TODO
|
||||
return []
|
||||
|
||||
async def discover_characteristics(self, uuids, service):
|
||||
async def discover_characteristics(
|
||||
self, uuids, service: Optional[ServiceProxy]
|
||||
) -> List[CharacteristicProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.6.1 Discover All Characteristics of a Service and 4.6.2
|
||||
Discover Characteristics by UUID
|
||||
@@ -465,12 +490,12 @@ class Client:
|
||||
services = [service] if service else self.services
|
||||
|
||||
# Perform characteristic discovery for each service
|
||||
discovered_characteristics = []
|
||||
discovered_characteristics: List[CharacteristicProxy] = []
|
||||
for service in services:
|
||||
starting_handle = service.handle
|
||||
ending_handle = service.end_group_handle
|
||||
|
||||
characteristics = []
|
||||
characteristics: List[CharacteristicProxy] = []
|
||||
while starting_handle <= ending_handle:
|
||||
response = await self.send_request(
|
||||
ATT_Read_By_Type_Request(
|
||||
@@ -491,8 +516,10 @@ class Client:
|
||||
'!!! unexpected error while discovering characteristics: '
|
||||
f'{HCI_Constant.error_name(response.error_code)}'
|
||||
)
|
||||
# TODO raise appropriate exception
|
||||
return
|
||||
raise ATT_Error(
|
||||
error_code=response.error_code,
|
||||
message='Unexpected error while discovering characteristics',
|
||||
)
|
||||
break
|
||||
|
||||
# Stop if for some reason the list was empty
|
||||
@@ -535,8 +562,11 @@ class Client:
|
||||
return discovered_characteristics
|
||||
|
||||
async def discover_descriptors(
|
||||
self, characteristic=None, start_handle=None, end_handle=None
|
||||
):
|
||||
self,
|
||||
characteristic: Optional[CharacteristicProxy] = None,
|
||||
start_handle=None,
|
||||
end_handle=None,
|
||||
) -> List[DescriptorProxy]:
|
||||
'''
|
||||
See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors
|
||||
'''
|
||||
@@ -549,7 +579,7 @@ class Client:
|
||||
else:
|
||||
return []
|
||||
|
||||
descriptors = []
|
||||
descriptors: List[DescriptorProxy] = []
|
||||
while starting_handle <= ending_handle:
|
||||
response = await self.send_request(
|
||||
ATT_Find_Information_Request(
|
||||
@@ -656,8 +686,8 @@ class Client:
|
||||
return
|
||||
|
||||
if (
|
||||
characteristic.properties & Characteristic.NOTIFY
|
||||
and characteristic.properties & Characteristic.INDICATE
|
||||
characteristic.properties & Characteristic.Properties.NOTIFY
|
||||
and characteristic.properties & Characteristic.Properties.INDICATE
|
||||
):
|
||||
if prefer_notify:
|
||||
bits = ClientCharacteristicConfigurationBits.NOTIFICATION
|
||||
@@ -665,10 +695,10 @@ class Client:
|
||||
else:
|
||||
bits = ClientCharacteristicConfigurationBits.INDICATION
|
||||
subscribers = self.indication_subscribers
|
||||
elif characteristic.properties & Characteristic.NOTIFY:
|
||||
elif characteristic.properties & Characteristic.Properties.NOTIFY:
|
||||
bits = ClientCharacteristicConfigurationBits.NOTIFICATION
|
||||
subscribers = self.notification_subscribers
|
||||
elif characteristic.properties & Characteristic.INDICATE:
|
||||
elif characteristic.properties & Characteristic.Properties.INDICATE:
|
||||
bits = ClientCharacteristicConfigurationBits.INDICATION
|
||||
subscribers = self.indication_subscribers
|
||||
else:
|
||||
|
||||
@@ -27,10 +27,10 @@ import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
import struct
|
||||
from typing import Tuple, Optional
|
||||
from typing import List, Tuple, Optional, TypeVar, Type
|
||||
from pyee import EventEmitter
|
||||
from colors import color
|
||||
|
||||
from .colors import color
|
||||
from .core import UUID
|
||||
from .att import (
|
||||
ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||
@@ -61,7 +61,6 @@ from .att import (
|
||||
from .gatt import (
|
||||
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
||||
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||
GATT_INCLUDE_ATTRIBUTE_TYPE,
|
||||
GATT_MAX_ATTRIBUTE_VALUE_SIZE,
|
||||
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
||||
GATT_REQUEST_TIMEOUT,
|
||||
@@ -90,6 +89,8 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517
|
||||
# GATT Server
|
||||
# -----------------------------------------------------------------------------
|
||||
class Server(EventEmitter):
|
||||
attributes: List[Attribute]
|
||||
|
||||
def __init__(self, device):
|
||||
super().__init__()
|
||||
self.device = device
|
||||
@@ -134,12 +135,28 @@ class Server(EventEmitter):
|
||||
return attribute
|
||||
return None
|
||||
|
||||
AttributeGroupType = TypeVar('AttributeGroupType', Service, Characteristic)
|
||||
|
||||
def get_attribute_group(
|
||||
self, handle: int, group_type: Type[AttributeGroupType]
|
||||
) -> Optional[AttributeGroupType]:
|
||||
return next(
|
||||
(
|
||||
attribute
|
||||
for attribute in self.attributes
|
||||
if isinstance(attribute, group_type)
|
||||
and attribute.handle <= handle <= attribute.end_group_handle
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def get_service_attribute(self, service_uuid: UUID) -> Optional[Service]:
|
||||
return next(
|
||||
(
|
||||
attribute
|
||||
for attribute in self.attributes
|
||||
if attribute.type == GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE
|
||||
and isinstance(attribute, Service)
|
||||
and attribute.uuid == service_uuid
|
||||
),
|
||||
None,
|
||||
@@ -226,7 +243,10 @@ class Server(EventEmitter):
|
||||
# unless there is one already
|
||||
if (
|
||||
characteristic.properties
|
||||
& (Characteristic.NOTIFY | Characteristic.INDICATE)
|
||||
& (
|
||||
Characteristic.Properties.NOTIFY
|
||||
| Characteristic.Properties.INDICATE
|
||||
)
|
||||
and characteristic.get_descriptor(
|
||||
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR
|
||||
)
|
||||
@@ -540,8 +560,6 @@ class Server(EventEmitter):
|
||||
if attribute.handle >= request.starting_handle
|
||||
and attribute.handle <= request.ending_handle
|
||||
):
|
||||
# TODO: check permissions
|
||||
|
||||
this_uuid_size = len(attribute.type.to_pdu_bytes())
|
||||
|
||||
if attributes:
|
||||
@@ -635,6 +653,13 @@ class Server(EventEmitter):
|
||||
'''
|
||||
|
||||
pdu_space_available = connection.att_mtu - 2
|
||||
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
attribute_handle_in_error=request.starting_handle,
|
||||
error_code=ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||
)
|
||||
|
||||
attributes = []
|
||||
for attribute in (
|
||||
attribute
|
||||
@@ -644,10 +669,21 @@ class Server(EventEmitter):
|
||||
and attribute.handle <= request.ending_handle
|
||||
and pdu_space_available
|
||||
):
|
||||
# TODO: check permissions
|
||||
|
||||
try:
|
||||
attribute_value = attribute.read_value(connection)
|
||||
except ATT_Error as error:
|
||||
# If the first attribute is unreadable, return an error
|
||||
# Otherwise return attributes up to this point
|
||||
if not attributes:
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
attribute_handle_in_error=attribute.handle,
|
||||
error_code=error.error_code,
|
||||
)
|
||||
break
|
||||
|
||||
# Check the attribute value size
|
||||
attribute_value = attribute.read_value(connection)
|
||||
max_attribute_size = min(connection.att_mtu - 4, 253)
|
||||
if len(attribute_value) > max_attribute_size:
|
||||
# We need to truncate
|
||||
@@ -673,11 +709,7 @@ class Server(EventEmitter):
|
||||
length=entry_size, attribute_data_list=b''.join(attribute_data_list)
|
||||
)
|
||||
else:
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
attribute_handle_in_error=request.starting_handle,
|
||||
error_code=ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||
)
|
||||
logging.debug(f"not found {request}")
|
||||
|
||||
self.send_response(connection, response)
|
||||
|
||||
@@ -687,10 +719,17 @@ class Server(EventEmitter):
|
||||
'''
|
||||
|
||||
if attribute := self.get_attribute(request.attribute_handle):
|
||||
# TODO: check permissions
|
||||
value = attribute.read_value(connection)
|
||||
value_size = min(connection.att_mtu - 1, len(value))
|
||||
response = ATT_Read_Response(attribute_value=value[:value_size])
|
||||
try:
|
||||
value = attribute.read_value(connection)
|
||||
except ATT_Error as error:
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
attribute_handle_in_error=request.attribute_handle,
|
||||
error_code=error.error_code,
|
||||
)
|
||||
else:
|
||||
value_size = min(connection.att_mtu - 1, len(value))
|
||||
response = ATT_Read_Response(attribute_value=value[:value_size])
|
||||
else:
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
@@ -705,29 +744,36 @@ class Server(EventEmitter):
|
||||
'''
|
||||
|
||||
if attribute := self.get_attribute(request.attribute_handle):
|
||||
# TODO: check permissions
|
||||
value = attribute.read_value(connection)
|
||||
if request.value_offset > len(value):
|
||||
try:
|
||||
value = attribute.read_value(connection)
|
||||
except ATT_Error as error:
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
attribute_handle_in_error=request.attribute_handle,
|
||||
error_code=ATT_INVALID_OFFSET_ERROR,
|
||||
)
|
||||
elif len(value) <= connection.att_mtu - 1:
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
attribute_handle_in_error=request.attribute_handle,
|
||||
error_code=ATT_ATTRIBUTE_NOT_LONG_ERROR,
|
||||
error_code=error.error_code,
|
||||
)
|
||||
else:
|
||||
part_size = min(
|
||||
connection.att_mtu - 1, len(value) - request.value_offset
|
||||
)
|
||||
response = ATT_Read_Blob_Response(
|
||||
part_attribute_value=value[
|
||||
request.value_offset : request.value_offset + part_size
|
||||
]
|
||||
)
|
||||
if request.value_offset > len(value):
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
attribute_handle_in_error=request.attribute_handle,
|
||||
error_code=ATT_INVALID_OFFSET_ERROR,
|
||||
)
|
||||
elif len(value) <= connection.att_mtu - 1:
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
attribute_handle_in_error=request.attribute_handle,
|
||||
error_code=ATT_ATTRIBUTE_NOT_LONG_ERROR,
|
||||
)
|
||||
else:
|
||||
part_size = min(
|
||||
connection.att_mtu - 1, len(value) - request.value_offset
|
||||
)
|
||||
response = ATT_Read_Blob_Response(
|
||||
part_attribute_value=value[
|
||||
request.value_offset : request.value_offset + part_size
|
||||
]
|
||||
)
|
||||
else:
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
@@ -743,7 +789,6 @@ class Server(EventEmitter):
|
||||
if request.attribute_group_type not in (
|
||||
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
||||
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
||||
GATT_INCLUDE_ATTRIBUTE_TYPE,
|
||||
):
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error=request.op_code,
|
||||
@@ -763,8 +808,10 @@ class Server(EventEmitter):
|
||||
and attribute.handle <= request.ending_handle
|
||||
and pdu_space_available
|
||||
):
|
||||
# Check the attribute value size
|
||||
# No need to catch permission errors here, since these attributes
|
||||
# must all be world-readable
|
||||
attribute_value = attribute.read_value(connection)
|
||||
# Check the attribute value size
|
||||
max_attribute_size = min(connection.att_mtu - 6, 251)
|
||||
if len(attribute_value) > max_attribute_size:
|
||||
# We need to truncate
|
||||
|
||||
@@ -20,9 +20,9 @@ import struct
|
||||
import collections
|
||||
import logging
|
||||
import functools
|
||||
from colors import color
|
||||
from typing import Dict, Type
|
||||
from typing import Dict, Type, Union
|
||||
|
||||
from .colors import color
|
||||
from .core import (
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
AdvertisingData,
|
||||
@@ -1421,7 +1421,11 @@ class HCI_Constant:
|
||||
# -----------------------------------------------------------------------------
|
||||
class HCI_Error(ProtocolError):
|
||||
def __init__(self, error_code):
|
||||
super().__init__(error_code, 'hci', HCI_Constant.error_name(error_code))
|
||||
super().__init__(
|
||||
error_code,
|
||||
error_namespace='hci',
|
||||
error_name=HCI_Constant.error_name(error_code),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1487,7 +1491,7 @@ class HCI_Object:
|
||||
elif field_type == -2:
|
||||
# 16-bit signed
|
||||
field_value = struct.unpack_from('<h', data, offset)[0]
|
||||
offset += 1
|
||||
offset += 2
|
||||
elif field_type == 3:
|
||||
# 24-bit unsigned
|
||||
padded = data[offset : offset + 3] + bytes([0])
|
||||
@@ -1729,7 +1733,9 @@ class Address:
|
||||
address_type = data[offset - 1]
|
||||
return Address.parse_address_with_type(data, offset, address_type)
|
||||
|
||||
def __init__(self, address, address_type=RANDOM_DEVICE_ADDRESS):
|
||||
def __init__(
|
||||
self, address: Union[bytes, str], address_type: int = RANDOM_DEVICE_ADDRESS
|
||||
):
|
||||
'''
|
||||
Initialize an instance. `address` may be a byte array in little-endian
|
||||
format, or a hex string in big-endian format (with optional ':'
|
||||
@@ -1844,6 +1850,8 @@ class HCI_Packet:
|
||||
Abstract Base class for HCI packets
|
||||
'''
|
||||
|
||||
hci_packet_type: int
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(packet):
|
||||
packet_type = packet[0]
|
||||
@@ -1862,6 +1870,9 @@ class HCI_Packet:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
raise NotImplementedError
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@@ -1873,6 +1884,9 @@ class HCI_CustomPacket(HCI_Packet):
|
||||
self.hci_packet_type = payload[0]
|
||||
self.payload = payload
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
return self.payload
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HCI_Command(HCI_Packet):
|
||||
@@ -2083,6 +2097,24 @@ class HCI_Link_Key_Request_Negative_Reply_Command(HCI_Command):
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[
|
||||
('bd_addr', Address.parse_address),
|
||||
('pin_code_length', 1),
|
||||
('pin_code', 16),
|
||||
],
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('bd_addr', Address.parse_address),
|
||||
],
|
||||
)
|
||||
class HCI_PIN_Code_Request_Reply_Command(HCI_Command):
|
||||
'''
|
||||
See Bluetooth spec @ 7.1.12 PIN Code Request Reply Command
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
fields=[('bd_addr', Address.parse_address)],
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
from colors import color
|
||||
|
||||
from .colors import color
|
||||
from .att import ATT_CID, ATT_PDU
|
||||
from .smp import SMP_CID, SMP_Command
|
||||
from .core import name_or_number
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import collections
|
||||
from colors import color
|
||||
|
||||
from .colors import color
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -20,11 +20,14 @@ import collections
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from colors import color
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.l2cap import L2CAP_PDU
|
||||
from bumble.snoop import Snooper
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .hci import (
|
||||
Address,
|
||||
HCI_ACL_DATA_PACKET,
|
||||
HCI_COMMAND_COMPLETE_EVENT,
|
||||
HCI_COMMAND_PACKET,
|
||||
@@ -53,7 +56,6 @@ from .hci import (
|
||||
HCI_LE_Write_Suggested_Default_Data_Length_Command,
|
||||
HCI_Link_Key_Request_Negative_Reply_Command,
|
||||
HCI_Link_Key_Request_Reply_Command,
|
||||
HCI_PIN_Code_Request_Negative_Reply_Command,
|
||||
HCI_Packet,
|
||||
HCI_Read_Buffer_Size_Command,
|
||||
HCI_Read_Local_Supported_Commands_Command,
|
||||
@@ -92,10 +94,9 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Connection:
|
||||
def __init__(self, host, handle, role, peer_address, transport):
|
||||
def __init__(self, host, handle, peer_address, transport):
|
||||
self.host = host
|
||||
self.handle = handle
|
||||
self.role = role
|
||||
self.peer_address = peer_address
|
||||
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||
self.transport = transport
|
||||
@@ -134,6 +135,7 @@ class Host(AbortableEventEmitter):
|
||||
self.long_term_key_provider = None
|
||||
self.link_key_provider = None
|
||||
self.pairing_io_capability_provider = None # Classic only
|
||||
self.snooper = None
|
||||
|
||||
# Connect to the source and sink if specified
|
||||
if controller_source:
|
||||
@@ -141,7 +143,25 @@ class Host(AbortableEventEmitter):
|
||||
if controller_sink:
|
||||
self.set_packet_sink(controller_sink)
|
||||
|
||||
async def flush(self):
|
||||
def find_connection_by_bd_addr(
|
||||
self,
|
||||
bd_addr: Address,
|
||||
transport: Optional[int] = None,
|
||||
check_address_type: bool = False,
|
||||
) -> Optional[Connection]:
|
||||
for connection in self.connections.values():
|
||||
if connection.peer_address.to_bytes() == bd_addr.to_bytes():
|
||||
if (
|
||||
check_address_type
|
||||
and connection.peer_address.address_type != bd_addr.address_type
|
||||
):
|
||||
continue
|
||||
if transport is None or connection.transport == transport:
|
||||
return connection
|
||||
|
||||
return None
|
||||
|
||||
async def flush(self) -> None:
|
||||
# Make sure no command is pending
|
||||
await self.command_semaphore.acquire()
|
||||
|
||||
@@ -274,6 +294,9 @@ class Host(AbortableEventEmitter):
|
||||
self.hci_sink = sink
|
||||
|
||||
def send_hci_packet(self, packet):
|
||||
if self.snooper:
|
||||
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
||||
|
||||
self.hci_sink.on_packet(packet.to_bytes())
|
||||
|
||||
async def send_command(self, command, check_result=False):
|
||||
@@ -420,6 +443,9 @@ class Host(AbortableEventEmitter):
|
||||
def on_hci_packet(self, packet):
|
||||
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
|
||||
|
||||
if self.snooper:
|
||||
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
||||
|
||||
# If the packet is a command, invoke the handler for this packet
|
||||
if packet.hci_packet_type == HCI_COMMAND_PACKET:
|
||||
self.on_hci_command_packet(packet)
|
||||
@@ -507,7 +533,7 @@ class Host(AbortableEventEmitter):
|
||||
if event.status == HCI_SUCCESS:
|
||||
# Create/update the connection
|
||||
logger.debug(
|
||||
f'### CONNECTION: [0x{event.connection_handle:04X}] '
|
||||
f'### LE CONNECTION: [0x{event.connection_handle:04X}] '
|
||||
f'{event.peer_address} as {HCI_Constant.role_name(event.role)}'
|
||||
)
|
||||
|
||||
@@ -516,7 +542,6 @@ class Host(AbortableEventEmitter):
|
||||
connection = Connection(
|
||||
self,
|
||||
event.connection_handle,
|
||||
event.role,
|
||||
event.peer_address,
|
||||
BT_LE_TRANSPORT,
|
||||
)
|
||||
@@ -533,7 +558,6 @@ class Host(AbortableEventEmitter):
|
||||
event.connection_handle,
|
||||
BT_LE_TRANSPORT,
|
||||
event.peer_address,
|
||||
None,
|
||||
event.role,
|
||||
connection_parameters,
|
||||
)
|
||||
@@ -562,7 +586,6 @@ class Host(AbortableEventEmitter):
|
||||
connection = Connection(
|
||||
self,
|
||||
event.connection_handle,
|
||||
BT_CENTRAL_ROLE,
|
||||
event.bd_addr,
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
)
|
||||
@@ -575,7 +598,6 @@ class Host(AbortableEventEmitter):
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
event.bd_addr,
|
||||
None,
|
||||
BT_CENTRAL_ROLE,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
@@ -595,8 +617,7 @@ class Host(AbortableEventEmitter):
|
||||
if event.status == HCI_SUCCESS:
|
||||
logger.debug(
|
||||
f'### DISCONNECTION: [0x{event.connection_handle:04X}] '
|
||||
f'{connection.peer_address} as '
|
||||
f'{HCI_Constant.role_name(connection.role)}, '
|
||||
f'{connection.peer_address} '
|
||||
f'reason={event.reason}'
|
||||
)
|
||||
del self.connections[event.connection_handle]
|
||||
@@ -660,7 +681,7 @@ class Host(AbortableEventEmitter):
|
||||
connection_handle=event.connection_handle,
|
||||
interval_min=event.interval_min,
|
||||
interval_max=event.interval_max,
|
||||
latency=event.latency,
|
||||
max_latency=event.max_latency,
|
||||
timeout=event.timeout,
|
||||
min_ce_length=0,
|
||||
max_ce_length=0,
|
||||
@@ -712,12 +733,13 @@ class Host(AbortableEventEmitter):
|
||||
f'role change for {event.bd_addr}: '
|
||||
f'{HCI_Constant.role_name(event.new_role)}'
|
||||
)
|
||||
# TODO: lookup the connection and update the role
|
||||
self.emit('role_change', event.bd_addr, event.new_role)
|
||||
else:
|
||||
logger.debug(
|
||||
f'role change for {event.bd_addr} failed: '
|
||||
f'{HCI_Constant.error_name(event.status)}'
|
||||
)
|
||||
self.emit('role_change_failure', event.bd_addr, event.status)
|
||||
|
||||
def on_hci_le_data_length_change_event(self, event):
|
||||
self.emit(
|
||||
@@ -787,11 +809,7 @@ class Host(AbortableEventEmitter):
|
||||
)
|
||||
|
||||
def on_hci_pin_code_request_event(self, event):
|
||||
# For now, just refuse all requests
|
||||
# TODO: delegate the decision
|
||||
self.send_command_sync(
|
||||
HCI_PIN_Code_Request_Negative_Reply_Command(bd_addr=event.bd_addr)
|
||||
)
|
||||
self.emit('pin_code_request', event.bd_addr)
|
||||
|
||||
def on_hci_link_key_request_event(self, event):
|
||||
async def send_link_key():
|
||||
|
||||
@@ -20,14 +20,19 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from colors import color
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from .colors import color
|
||||
from .hci import Address
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .device import Device
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -129,7 +134,7 @@ class PairingKeys:
|
||||
for (key_property, key_value) in value.items():
|
||||
print(f'{prefix} {color(key_property, "green")}: {key_value}')
|
||||
else:
|
||||
print(f'{prefix}{color(property, "cyan")}: {value}')
|
||||
print(f'{prefix}{color(container_property, "cyan")}: {value}')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -172,13 +177,13 @@ class KeyStore:
|
||||
separator = '\n'
|
||||
|
||||
@staticmethod
|
||||
def create_for_device(device_config):
|
||||
if device_config.keystore is None:
|
||||
def create_for_device(device: Device) -> Optional[KeyStore]:
|
||||
if device.config.keystore is None:
|
||||
return None
|
||||
|
||||
keystore_type = device_config.keystore.split(':', 1)[0]
|
||||
keystore_type = device.config.keystore.split(':', 1)[0]
|
||||
if keystore_type == 'JsonKeyStore':
|
||||
return JsonKeyStore.from_device_config(device_config)
|
||||
return JsonKeyStore.from_device(device)
|
||||
|
||||
return None
|
||||
|
||||
@@ -203,7 +208,9 @@ class JsonKeyStore(KeyStore):
|
||||
self.directory_name = os.path.join(
|
||||
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
|
||||
)
|
||||
json_filename = f'{self.namespace}.json'.lower().replace(':', '-')
|
||||
json_filename = (
|
||||
f'{self.namespace}.json'.lower().replace(':', '-').replace('/p', '-p')
|
||||
)
|
||||
self.filename = os.path.join(self.directory_name, json_filename)
|
||||
else:
|
||||
self.filename = filename
|
||||
@@ -212,11 +219,21 @@ class JsonKeyStore(KeyStore):
|
||||
logger.debug(f'JSON keystore: {self.filename}')
|
||||
|
||||
@staticmethod
|
||||
def from_device_config(device_config):
|
||||
params = device_config.keystore.split(':', 1)[1:]
|
||||
namespace = str(device_config.address)
|
||||
def from_device(device: Device) -> Optional[JsonKeyStore]:
|
||||
if not device.config.keystore:
|
||||
return None
|
||||
|
||||
params = device.config.keystore.split(':', 1)[1:]
|
||||
|
||||
# Use a namespace based on the device address
|
||||
if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
|
||||
namespace = str(device.public_address)
|
||||
elif device.random_address != Address.ANY_RANDOM:
|
||||
namespace = str(device.random_address)
|
||||
else:
|
||||
namespace = JsonKeyStore.DEFAULT_NAMESPACE
|
||||
if params:
|
||||
filename = params[1]
|
||||
filename = params[0]
|
||||
else:
|
||||
filename = None
|
||||
|
||||
@@ -242,7 +259,7 @@ class JsonKeyStore(KeyStore):
|
||||
# Atomically replace the previous file
|
||||
os.rename(temp_filename, self.filename)
|
||||
|
||||
async def delete(self, name):
|
||||
async def delete(self, name: str) -> None:
|
||||
db = await self.load()
|
||||
|
||||
namespace = db.get(self.namespace)
|
||||
@@ -256,7 +273,7 @@ class JsonKeyStore(KeyStore):
|
||||
db = await self.load()
|
||||
|
||||
namespace = db.setdefault(self.namespace, {})
|
||||
namespace[name] = keys.to_dict()
|
||||
namespace.setdefault(name, {}).update(keys.to_dict())
|
||||
|
||||
await self.save(db)
|
||||
|
||||
@@ -278,7 +295,7 @@ class JsonKeyStore(KeyStore):
|
||||
|
||||
await self.save(db)
|
||||
|
||||
async def get(self, name):
|
||||
async def get(self, name: str) -> Optional[PairingKeys]:
|
||||
db = await self.load()
|
||||
|
||||
namespace = db.get(self.namespace)
|
||||
|
||||
@@ -21,10 +21,10 @@ import logging
|
||||
import struct
|
||||
|
||||
from collections import deque
|
||||
from colors import color
|
||||
from pyee import EventEmitter
|
||||
from typing import Dict, Type
|
||||
|
||||
from .colors import color
|
||||
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
||||
from .hci import (
|
||||
HCI_LE_Connection_Update_Command,
|
||||
@@ -796,6 +796,11 @@ class Channel(EventEmitter):
|
||||
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||
return await self.disconnection_result
|
||||
|
||||
def abort(self):
|
||||
if self.state == self.OPEN:
|
||||
self.change_state(self.CLOSED)
|
||||
self.emit('close')
|
||||
|
||||
def send_configure_request(self):
|
||||
options = L2CAP_Control_Frame.encode_configuration_options(
|
||||
[
|
||||
@@ -1105,6 +1110,10 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||
return await self.disconnection_result
|
||||
|
||||
def abort(self):
|
||||
if self.state == self.CONNECTED:
|
||||
self.change_state(self.DISCONNECTED)
|
||||
|
||||
def on_pdu(self, pdu):
|
||||
if self.sink is None:
|
||||
logger.warning('received pdu without a sink')
|
||||
@@ -1492,8 +1501,12 @@ class ChannelManager:
|
||||
def on_disconnection(self, connection_handle, _reason):
|
||||
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
|
||||
if connection_handle in self.channels:
|
||||
for _, channel in self.channels[connection_handle].items():
|
||||
channel.abort()
|
||||
del self.channels[connection_handle]
|
||||
if connection_handle in self.le_coc_channels:
|
||||
for _, channel in self.le_coc_channels[connection_handle].items():
|
||||
channel.abort()
|
||||
del self.le_coc_channels[connection_handle]
|
||||
if connection_handle in self.identifiers:
|
||||
del self.identifiers[connection_handle]
|
||||
|
||||
130
bumble/link.py
130
bumble/link.py
@@ -19,14 +19,15 @@ import logging
|
||||
import asyncio
|
||||
from functools import partial
|
||||
|
||||
from colors import color
|
||||
import websockets
|
||||
|
||||
from bumble.core import BT_PERIPHERAL_ROLE, BT_BR_EDR_TRANSPORT, BT_LE_TRANSPORT
|
||||
from bumble.colors import color
|
||||
from bumble.hci import (
|
||||
Address,
|
||||
HCI_SUCCESS,
|
||||
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
|
||||
HCI_CONNECTION_TIMEOUT_ERROR,
|
||||
HCI_PAGE_TIMEOUT_ERROR,
|
||||
HCI_Connection_Complete_Event,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -59,6 +60,11 @@ class LocalLink:
|
||||
def __init__(self):
|
||||
self.controllers = set()
|
||||
self.pending_connection = None
|
||||
self.pending_classic_connection = None
|
||||
|
||||
############################################################
|
||||
# Common utils
|
||||
############################################################
|
||||
|
||||
def add_controller(self, controller):
|
||||
logger.debug(f'new controller: {controller}')
|
||||
@@ -73,22 +79,39 @@ class LocalLink:
|
||||
return controller
|
||||
return None
|
||||
|
||||
def on_address_changed(self, controller):
|
||||
pass
|
||||
def find_classic_controller(self, address):
|
||||
for controller in self.controllers:
|
||||
if controller.public_address == address:
|
||||
return controller
|
||||
return None
|
||||
|
||||
def get_pending_connection(self):
|
||||
return self.pending_connection
|
||||
|
||||
############################################################
|
||||
# LE handlers
|
||||
############################################################
|
||||
|
||||
def on_address_changed(self, controller):
|
||||
pass
|
||||
|
||||
def send_advertising_data(self, sender_address, data):
|
||||
# Send the advertising data to all controllers, except the sender
|
||||
for controller in self.controllers:
|
||||
if controller.random_address != sender_address:
|
||||
controller.on_link_advertising_data(sender_address, data)
|
||||
|
||||
def send_acl_data(self, sender_address, destination_address, data):
|
||||
def send_acl_data(self, sender_controller, destination_address, transport, data):
|
||||
# Send the data to the first controller with a matching address
|
||||
if controller := self.find_controller(destination_address):
|
||||
controller.on_link_acl_data(sender_address, data)
|
||||
if transport == BT_LE_TRANSPORT:
|
||||
destination_controller = self.find_controller(destination_address)
|
||||
source_address = sender_controller.random_address
|
||||
elif transport == BT_BR_EDR_TRANSPORT:
|
||||
destination_controller = self.find_classic_controller(destination_address)
|
||||
source_address = sender_controller.public_address
|
||||
|
||||
if destination_controller is not None:
|
||||
destination_controller.on_link_acl_data(source_address, transport, data)
|
||||
|
||||
def on_connection_complete(self):
|
||||
# Check that we expect this call
|
||||
@@ -165,6 +188,89 @@ class LocalLink:
|
||||
if peripheral_controller := self.find_controller(peripheral_address):
|
||||
peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk)
|
||||
|
||||
############################################################
|
||||
# Classic handlers
|
||||
############################################################
|
||||
|
||||
def classic_connect(self, initiator_controller, responder_address):
|
||||
logger.debug(
|
||||
f'[Classic] {initiator_controller.public_address} connects to {responder_address}'
|
||||
)
|
||||
responder_controller = self.find_classic_controller(responder_address)
|
||||
if responder_controller is None:
|
||||
initiator_controller.on_classic_connection_complete(
|
||||
responder_address, HCI_PAGE_TIMEOUT_ERROR
|
||||
)
|
||||
return
|
||||
self.pending_classic_connection = (initiator_controller, responder_controller)
|
||||
|
||||
responder_controller.on_classic_connection_request(
|
||||
initiator_controller.public_address,
|
||||
HCI_Connection_Complete_Event.ACL_LINK_TYPE,
|
||||
)
|
||||
|
||||
def classic_accept_connection(
|
||||
self, responder_controller, initiator_address, responder_role
|
||||
):
|
||||
logger.debug(
|
||||
f'[Classic] {responder_controller.public_address} accepts to connect {initiator_address}'
|
||||
)
|
||||
initiator_controller = self.find_classic_controller(initiator_address)
|
||||
if initiator_controller is None:
|
||||
responder_controller.on_classic_connection_complete(
|
||||
responder_controller.public_address, HCI_PAGE_TIMEOUT_ERROR
|
||||
)
|
||||
return
|
||||
|
||||
async def task():
|
||||
if responder_role != BT_PERIPHERAL_ROLE:
|
||||
initiator_controller.on_classic_role_change(
|
||||
responder_controller.public_address, int(not (responder_role))
|
||||
)
|
||||
initiator_controller.on_classic_connection_complete(
|
||||
responder_controller.public_address, HCI_SUCCESS
|
||||
)
|
||||
|
||||
asyncio.create_task(task())
|
||||
responder_controller.on_classic_role_change(
|
||||
initiator_controller.public_address, responder_role
|
||||
)
|
||||
responder_controller.on_classic_connection_complete(
|
||||
initiator_controller.public_address, HCI_SUCCESS
|
||||
)
|
||||
self.pending_classic_connection = None
|
||||
|
||||
def classic_disconnect(self, initiator_controller, responder_address, reason):
|
||||
logger.debug(
|
||||
f'[Classic] {initiator_controller.public_address} disconnects {responder_address}'
|
||||
)
|
||||
responder_controller = self.find_classic_controller(responder_address)
|
||||
|
||||
async def task():
|
||||
initiator_controller.on_classic_disconnected(responder_address, reason)
|
||||
|
||||
asyncio.create_task(task())
|
||||
responder_controller.on_classic_disconnected(
|
||||
initiator_controller.public_address, reason
|
||||
)
|
||||
|
||||
def classic_switch_role(
|
||||
self, initiator_controller, responder_address, initiator_new_role
|
||||
):
|
||||
responder_controller = self.find_classic_controller(responder_address)
|
||||
if responder_controller is None:
|
||||
return
|
||||
|
||||
async def task():
|
||||
initiator_controller.on_classic_role_change(
|
||||
responder_address, initiator_new_role
|
||||
)
|
||||
|
||||
asyncio.create_task(task())
|
||||
responder_controller.on_classic_role_change(
|
||||
initiator_controller.public_address, int(not (initiator_new_role))
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class RemoteLink:
|
||||
@@ -202,6 +308,9 @@ class RemoteLink:
|
||||
def get_pending_connection(self):
|
||||
return self.pending_connection
|
||||
|
||||
def get_pending_classic_connection(self):
|
||||
return self.pending_classic_connection
|
||||
|
||||
async def wait_until_connected(self):
|
||||
await self.websocket
|
||||
|
||||
@@ -220,6 +329,8 @@ class RemoteLink:
|
||||
)
|
||||
|
||||
async def run_connection(self):
|
||||
import websockets # lazy import
|
||||
|
||||
# Connect to the relay
|
||||
logger.debug(f'connecting to {self.uri}')
|
||||
# pylint: disable-next=no-member
|
||||
@@ -366,7 +477,8 @@ class RemoteLink:
|
||||
async def send_acl_data_to_relay(self, peer_address, data):
|
||||
await self.send_targeted_message(peer_address, f'acl:{data.hex()}')
|
||||
|
||||
def send_acl_data(self, _, peer_address, data):
|
||||
def send_acl_data(self, _, peer_address, _transport, data):
|
||||
# TODO: handle different transport
|
||||
self.execute(partial(self.send_acl_data_to_relay, peer_address, data))
|
||||
|
||||
async def send_connection_request_to_relay(self, peer_address):
|
||||
|
||||
@@ -20,6 +20,7 @@ import struct
|
||||
import logging
|
||||
from typing import List
|
||||
from ..core import AdvertisingData
|
||||
from ..device import Device, Connection
|
||||
from ..gatt import (
|
||||
GATT_ASHA_SERVICE,
|
||||
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
|
||||
@@ -31,7 +32,7 @@ from ..gatt import (
|
||||
Characteristic,
|
||||
CharacteristicValue,
|
||||
)
|
||||
from ..device import Device
|
||||
from ..utils import AsyncRunner
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -55,16 +56,16 @@ class AshaService(TemplateService):
|
||||
self.hisyncid = hisyncid
|
||||
self.capability = capability # Device Capabilities [Left, Monaural]
|
||||
self.device = device
|
||||
self.emitted_data_name = 'ASHA_data_' + str(self.capability)
|
||||
self.audio_out_data = b''
|
||||
self.psm = psm # a non-zero psm is mainly for testing purpose
|
||||
|
||||
# Handler for volume control
|
||||
def on_volume_write(_connection, value):
|
||||
def on_volume_write(connection, value):
|
||||
logger.info(f'--- VOLUME Write:{value[0]}')
|
||||
self.emit('volume', connection, value[0])
|
||||
|
||||
# Handler for audio control commands
|
||||
def on_audio_control_point_write(_connection, value):
|
||||
def on_audio_control_point_write(connection: Connection, value):
|
||||
logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}')
|
||||
opcode = value[0]
|
||||
if opcode == AshaService.OPCODE_START:
|
||||
@@ -76,18 +77,33 @@ class AshaService(TemplateService):
|
||||
f'volume={value[3]}, '
|
||||
f'otherstate={value[4]}'
|
||||
)
|
||||
self.emit(
|
||||
'start',
|
||||
connection,
|
||||
{
|
||||
'codec': value[1],
|
||||
'audiotype': value[2],
|
||||
'volume': value[3],
|
||||
'otherstate': value[4],
|
||||
},
|
||||
)
|
||||
elif opcode == AshaService.OPCODE_STOP:
|
||||
logger.info('### STOP')
|
||||
self.emit('stop', connection)
|
||||
elif opcode == AshaService.OPCODE_STATUS:
|
||||
logger.info(f'### STATUS: connected={value[1]}')
|
||||
|
||||
# TODO Respond with a status
|
||||
# asyncio.create_task(device.notify_subscribers(audio_status_characteristic,
|
||||
# force=True))
|
||||
# OPCODE_STATUS does not need audio status point update
|
||||
if opcode != AshaService.OPCODE_STATUS:
|
||||
AsyncRunner.spawn(
|
||||
device.notify_subscribers(
|
||||
self.audio_status_characteristic, force=True
|
||||
)
|
||||
)
|
||||
|
||||
self.read_only_properties_characteristic = Characteristic(
|
||||
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
bytes(
|
||||
[
|
||||
@@ -104,19 +120,20 @@ class AshaService(TemplateService):
|
||||
|
||||
self.audio_control_point_characteristic = Characteristic(
|
||||
GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
|
||||
Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.Properties.WRITE
|
||||
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.WRITEABLE,
|
||||
CharacteristicValue(write=on_audio_control_point_write),
|
||||
)
|
||||
self.audio_status_characteristic = Characteristic(
|
||||
GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC,
|
||||
Characteristic.READ | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
bytes([0]),
|
||||
)
|
||||
self.volume_characteristic = Characteristic(
|
||||
GATT_ASHA_VOLUME_CHARACTERISTIC,
|
||||
Characteristic.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.WRITEABLE,
|
||||
CharacteristicValue(write=on_volume_write),
|
||||
)
|
||||
@@ -126,7 +143,7 @@ class AshaService(TemplateService):
|
||||
def on_data(data):
|
||||
logging.debug(f'<<< data received:{data}')
|
||||
|
||||
self.emit(self.emitted_data_name, data)
|
||||
self.emit('data', channel.connection, data)
|
||||
self.audio_out_data += data
|
||||
|
||||
channel.sink = on_data
|
||||
@@ -135,7 +152,7 @@ class AshaService(TemplateService):
|
||||
self.psm = self.device.register_l2cap_channel_server(self.psm, on_coc, 8)
|
||||
self.le_psm_out_characteristic = Characteristic(
|
||||
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
struct.pack('<H', self.psm),
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ class BatteryService(TemplateService):
|
||||
self.battery_level_characteristic = PackedCharacteristicAdapter(
|
||||
Characteristic(
|
||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
||||
Characteristic.READ | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
CharacteristicValue(read=read_battery_level),
|
||||
),
|
||||
|
||||
@@ -63,7 +63,9 @@ class DeviceInformationService(TemplateService):
|
||||
# TODO: pnp_id
|
||||
):
|
||||
characteristics = [
|
||||
Characteristic(uuid, Characteristic.READ, Characteristic.READABLE, field)
|
||||
Characteristic(
|
||||
uuid, Characteristic.Properties.READ, Characteristic.READABLE, field
|
||||
)
|
||||
for (field, uuid) in (
|
||||
(manufacturer_name, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),
|
||||
(model_number, GATT_MODEL_NUMBER_STRING_CHARACTERISTIC),
|
||||
@@ -79,7 +81,7 @@ class DeviceInformationService(TemplateService):
|
||||
characteristics.append(
|
||||
Characteristic(
|
||||
GATT_SYSTEM_ID_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
self.pack_system_id(*system_id),
|
||||
)
|
||||
@@ -89,7 +91,7 @@ class DeviceInformationService(TemplateService):
|
||||
characteristics.append(
|
||||
Characteristic(
|
||||
GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
ieee_regulatory_certification_data_list,
|
||||
)
|
||||
|
||||
@@ -152,7 +152,7 @@ class HeartRateService(TemplateService):
|
||||
self.heart_rate_measurement_characteristic = DelegatedCharacteristicAdapter(
|
||||
Characteristic(
|
||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
||||
Characteristic.NOTIFY,
|
||||
Characteristic.Properties.NOTIFY,
|
||||
0,
|
||||
CharacteristicValue(read=read_heart_rate_measurement),
|
||||
),
|
||||
@@ -164,7 +164,7 @@ class HeartRateService(TemplateService):
|
||||
if body_sensor_location is not None:
|
||||
self.body_sensor_location_characteristic = Characteristic(
|
||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
bytes([int(body_sensor_location)]),
|
||||
)
|
||||
@@ -182,7 +182,7 @@ class HeartRateService(TemplateService):
|
||||
self.heart_rate_control_point_characteristic = PackedCharacteristicAdapter(
|
||||
Characteristic(
|
||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
|
||||
Characteristic.WRITE,
|
||||
Characteristic.Properties.WRITE,
|
||||
Characteristic.WRITEABLE,
|
||||
CharacteristicValue(write=write_heart_rate_control_point_value),
|
||||
),
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from colors import color
|
||||
from pyee import EventEmitter
|
||||
|
||||
from . import core
|
||||
from .colors import color
|
||||
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -439,7 +439,7 @@ class DLC(EventEmitter):
|
||||
|
||||
logger.debug(
|
||||
f'<<< Credits [{self.dlci}]: '
|
||||
f'received {credits}, total={self.tx_credits}'
|
||||
f'received {received_credits}, total={self.tx_credits}'
|
||||
)
|
||||
data = data[1:]
|
||||
|
||||
@@ -852,17 +852,27 @@ class Server(EventEmitter):
|
||||
# Register ourselves with the L2CAP channel manager
|
||||
device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
|
||||
|
||||
def listen(self, acceptor):
|
||||
# Find a free channel number
|
||||
for channel in range(
|
||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START, RFCOMM_DYNAMIC_CHANNEL_NUMBER_END + 1
|
||||
):
|
||||
if channel not in self.acceptors:
|
||||
self.acceptors[channel] = acceptor
|
||||
return channel
|
||||
def listen(self, acceptor, channel=0):
|
||||
if channel:
|
||||
if channel in self.acceptors:
|
||||
# Busy
|
||||
return 0
|
||||
else:
|
||||
# Find a free channel number
|
||||
for candidate in range(
|
||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START,
|
||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END + 1,
|
||||
):
|
||||
if candidate not in self.acceptors:
|
||||
channel = candidate
|
||||
break
|
||||
|
||||
# All channels used...
|
||||
return 0
|
||||
if channel == 0:
|
||||
# All channels used...
|
||||
return 0
|
||||
|
||||
self.acceptors[channel] = acceptor
|
||||
return channel
|
||||
|
||||
def on_connection(self, l2cap_channel):
|
||||
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
|
||||
|
||||
@@ -18,11 +18,10 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import struct
|
||||
from colors import color
|
||||
import colors
|
||||
from typing import Dict, Type
|
||||
from typing import Dict, List, Type
|
||||
|
||||
from . import core
|
||||
from .colors import color
|
||||
from .core import InvalidStateError
|
||||
from .hci import HCI_Object, name_or_number, key_with_value
|
||||
|
||||
@@ -183,63 +182,63 @@ class DataElement:
|
||||
raise ValueError('integer types must have a value size specified')
|
||||
|
||||
@staticmethod
|
||||
def nil():
|
||||
def nil() -> DataElement:
|
||||
return DataElement(DataElement.NIL, None)
|
||||
|
||||
@staticmethod
|
||||
def unsigned_integer(value, value_size):
|
||||
def unsigned_integer(value: int, value_size: int) -> DataElement:
|
||||
return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size)
|
||||
|
||||
@staticmethod
|
||||
def unsigned_integer_8(value):
|
||||
def unsigned_integer_8(value: int) -> DataElement:
|
||||
return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size=1)
|
||||
|
||||
@staticmethod
|
||||
def unsigned_integer_16(value):
|
||||
def unsigned_integer_16(value: int) -> DataElement:
|
||||
return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size=2)
|
||||
|
||||
@staticmethod
|
||||
def unsigned_integer_32(value):
|
||||
def unsigned_integer_32(value: int) -> DataElement:
|
||||
return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size=4)
|
||||
|
||||
@staticmethod
|
||||
def signed_integer(value, value_size):
|
||||
def signed_integer(value: int, value_size: int) -> DataElement:
|
||||
return DataElement(DataElement.SIGNED_INTEGER, value, value_size)
|
||||
|
||||
@staticmethod
|
||||
def signed_integer_8(value):
|
||||
def signed_integer_8(value: int) -> DataElement:
|
||||
return DataElement(DataElement.SIGNED_INTEGER, value, value_size=1)
|
||||
|
||||
@staticmethod
|
||||
def signed_integer_16(value):
|
||||
def signed_integer_16(value: int) -> DataElement:
|
||||
return DataElement(DataElement.SIGNED_INTEGER, value, value_size=2)
|
||||
|
||||
@staticmethod
|
||||
def signed_integer_32(value):
|
||||
def signed_integer_32(value: int) -> DataElement:
|
||||
return DataElement(DataElement.SIGNED_INTEGER, value, value_size=4)
|
||||
|
||||
@staticmethod
|
||||
def uuid(value):
|
||||
def uuid(value: core.UUID) -> DataElement:
|
||||
return DataElement(DataElement.UUID, value)
|
||||
|
||||
@staticmethod
|
||||
def text_string(value):
|
||||
def text_string(value: str) -> DataElement:
|
||||
return DataElement(DataElement.TEXT_STRING, value)
|
||||
|
||||
@staticmethod
|
||||
def boolean(value):
|
||||
def boolean(value: bool) -> DataElement:
|
||||
return DataElement(DataElement.BOOLEAN, value)
|
||||
|
||||
@staticmethod
|
||||
def sequence(value):
|
||||
def sequence(value: List[DataElement]) -> DataElement:
|
||||
return DataElement(DataElement.SEQUENCE, value)
|
||||
|
||||
@staticmethod
|
||||
def alternative(value):
|
||||
def alternative(value: List[DataElement]) -> DataElement:
|
||||
return DataElement(DataElement.ALTERNATIVE, value)
|
||||
|
||||
@staticmethod
|
||||
def url(value):
|
||||
def url(value: str) -> DataElement:
|
||||
return DataElement(DataElement.URL, value)
|
||||
|
||||
@staticmethod
|
||||
@@ -458,7 +457,7 @@ class DataElement:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ServiceAttribute:
|
||||
def __init__(self, attribute_id, value):
|
||||
def __init__(self, attribute_id: int, value: DataElement) -> None:
|
||||
self.id = attribute_id
|
||||
self.value = value
|
||||
|
||||
@@ -506,7 +505,7 @@ class ServiceAttribute:
|
||||
def to_string(self, with_colors=False):
|
||||
if with_colors:
|
||||
return (
|
||||
f'Attribute(id={colors.color(self.id_name(self.id),"magenta")},'
|
||||
f'Attribute(id={color(self.id_name(self.id),"magenta")},'
|
||||
f'value={self.value})'
|
||||
)
|
||||
|
||||
|
||||
112
bumble/smp.py
112
bumble/smp.py
@@ -26,11 +26,11 @@ from __future__ import annotations
|
||||
import logging
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Dict, Type
|
||||
from typing import Dict, Optional, Type
|
||||
|
||||
from pyee import EventEmitter
|
||||
from colors import color
|
||||
|
||||
from .colors import color
|
||||
from .hci import Address, HCI_LE_Enable_Encryption_Command, HCI_Object, key_with_value
|
||||
from .core import (
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
@@ -498,33 +498,45 @@ class PairingDelegate:
|
||||
DISPLAY_OUTPUT_ONLY = SMP_DISPLAY_ONLY_IO_CAPABILITY
|
||||
DISPLAY_OUTPUT_AND_YES_NO_INPUT = SMP_DISPLAY_YES_NO_IO_CAPABILITY
|
||||
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY
|
||||
DEFAULT_KEY_DISTRIBUTION = (
|
||||
DEFAULT_KEY_DISTRIBUTION: int = (
|
||||
SMP_ENC_KEY_DISTRIBUTION_FLAG | SMP_ID_KEY_DISTRIBUTION_FLAG
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
io_capability=NO_OUTPUT_NO_INPUT,
|
||||
local_initiator_key_distribution=DEFAULT_KEY_DISTRIBUTION,
|
||||
local_responder_key_distribution=DEFAULT_KEY_DISTRIBUTION,
|
||||
):
|
||||
io_capability: int = NO_OUTPUT_NO_INPUT,
|
||||
local_initiator_key_distribution: int = DEFAULT_KEY_DISTRIBUTION,
|
||||
local_responder_key_distribution: int = DEFAULT_KEY_DISTRIBUTION,
|
||||
) -> None:
|
||||
self.io_capability = io_capability
|
||||
self.local_initiator_key_distribution = local_initiator_key_distribution
|
||||
self.local_responder_key_distribution = local_responder_key_distribution
|
||||
|
||||
async def accept(self):
|
||||
async def accept(self) -> bool:
|
||||
return True
|
||||
|
||||
async def confirm(self):
|
||||
async def confirm(self) -> bool:
|
||||
return True
|
||||
|
||||
async def compare_numbers(self, _number, _digits=6):
|
||||
# pylint: disable-next=unused-argument
|
||||
async def compare_numbers(self, number: int, digits: int) -> bool:
|
||||
return True
|
||||
|
||||
async def get_number(self):
|
||||
async def get_number(self) -> Optional[int]:
|
||||
'''
|
||||
Returns an optional number as an answer to a passkey request.
|
||||
Returning `None` will result in a negative reply.
|
||||
'''
|
||||
return 0
|
||||
|
||||
async def display_number(self, _number, _digits=6):
|
||||
async def get_string(self, max_length) -> Optional[str]:
|
||||
'''
|
||||
Returns a string whose utf-8 encoding is up to max_length bytes.
|
||||
'''
|
||||
return None
|
||||
|
||||
# pylint: disable-next=unused-argument
|
||||
async def display_number(self, number: int, digits: int) -> None:
|
||||
pass
|
||||
|
||||
async def key_distribution_response(
|
||||
@@ -538,7 +550,13 @@ class PairingDelegate:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PairingConfig:
|
||||
def __init__(self, sc=True, mitm=True, bonding=True, delegate=None):
|
||||
def __init__(
|
||||
self,
|
||||
sc: bool = True,
|
||||
mitm: bool = False,
|
||||
bonding: bool = True,
|
||||
delegate: Optional[PairingDelegate] = None,
|
||||
) -> None:
|
||||
self.sc = sc
|
||||
self.mitm = mitm
|
||||
self.bonding = bonding
|
||||
@@ -655,7 +673,8 @@ class Session:
|
||||
self.peer_expected_distributions = []
|
||||
self.dh_key = None
|
||||
self.confirm_value = None
|
||||
self.passkey = 0
|
||||
self.passkey = None
|
||||
self.passkey_ready = asyncio.Event()
|
||||
self.passkey_step = 0
|
||||
self.passkey_display = False
|
||||
self.pairing_method = 0
|
||||
@@ -833,6 +852,7 @@ class Session:
|
||||
# Generate random Passkey/PIN code
|
||||
self.passkey = secrets.randbelow(1000000)
|
||||
logger.debug(f'Pairing PIN CODE: {self.passkey:06}')
|
||||
self.passkey_ready.set()
|
||||
|
||||
# The value of TK is computed from the PIN code
|
||||
if not self.sc:
|
||||
@@ -853,6 +873,8 @@ class Session:
|
||||
self.tk = passkey.to_bytes(16, byteorder='little')
|
||||
logger.debug(f'TK from passkey = {self.tk.hex()}')
|
||||
|
||||
self.passkey_ready.set()
|
||||
|
||||
if next_steps is not None:
|
||||
next_steps()
|
||||
|
||||
@@ -904,17 +926,29 @@ class Session:
|
||||
logger.debug(f'generated random: {self.r.hex()}')
|
||||
|
||||
if self.sc:
|
||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
||||
z = 0
|
||||
elif self.pairing_method == self.PASSKEY:
|
||||
z = 0x80 + ((self.passkey >> self.passkey_step) & 1)
|
||||
else:
|
||||
return
|
||||
|
||||
if self.is_initiator:
|
||||
confirm_value = crypto.f4(self.pka, self.pkb, self.r, bytes([z]))
|
||||
else:
|
||||
confirm_value = crypto.f4(self.pkb, self.pka, self.r, bytes([z]))
|
||||
async def next_steps():
|
||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
||||
z = 0
|
||||
elif self.pairing_method == self.PASSKEY:
|
||||
# We need a passkey
|
||||
await self.passkey_ready.wait()
|
||||
|
||||
z = 0x80 + ((self.passkey >> self.passkey_step) & 1)
|
||||
else:
|
||||
return
|
||||
|
||||
if self.is_initiator:
|
||||
confirm_value = crypto.f4(self.pka, self.pkb, self.r, bytes([z]))
|
||||
else:
|
||||
confirm_value = crypto.f4(self.pkb, self.pka, self.r, bytes([z]))
|
||||
|
||||
self.send_command(
|
||||
SMP_Pairing_Confirm_Command(confirm_value=confirm_value)
|
||||
)
|
||||
|
||||
# Perform the next steps asynchronously in case we need to wait for input
|
||||
self.connection.abort_on('disconnection', next_steps())
|
||||
else:
|
||||
confirm_value = crypto.c1(
|
||||
self.tk,
|
||||
@@ -927,7 +961,7 @@ class Session:
|
||||
self.ra,
|
||||
)
|
||||
|
||||
self.send_command(SMP_Pairing_Confirm_Command(confirm_value=confirm_value))
|
||||
self.send_command(SMP_Pairing_Confirm_Command(confirm_value=confirm_value))
|
||||
|
||||
def send_pairing_random_command(self):
|
||||
self.send_command(SMP_Pairing_Random_Command(random_value=self.r))
|
||||
@@ -1358,8 +1392,8 @@ class Session:
|
||||
|
||||
# Start phase 2
|
||||
if self.sc:
|
||||
if self.pairing_method == self.PASSKEY and self.passkey_display:
|
||||
self.display_passkey()
|
||||
if self.pairing_method == self.PASSKEY:
|
||||
self.display_or_input_passkey()
|
||||
|
||||
self.send_public_key_command()
|
||||
else:
|
||||
@@ -1420,18 +1454,22 @@ class Session:
|
||||
else:
|
||||
srand = self.r
|
||||
mrand = command.random_value
|
||||
stk = crypto.s1(self.tk, srand, mrand)
|
||||
logger.debug(f'STK = {stk.hex()}')
|
||||
self.stk = crypto.s1(self.tk, srand, mrand)
|
||||
logger.debug(f'STK = {self.stk.hex()}')
|
||||
|
||||
# Generate LTK
|
||||
self.ltk = crypto.r()
|
||||
|
||||
if self.is_initiator:
|
||||
self.start_encryption(stk)
|
||||
self.start_encryption(self.stk)
|
||||
else:
|
||||
self.send_pairing_random_command()
|
||||
|
||||
def on_smp_pairing_random_command_secure_connections(self, command):
|
||||
if self.pairing_method == self.PASSKEY and self.passkey is None:
|
||||
logger.warning('no passkey entered, ignoring command')
|
||||
return
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
if self.is_initiator:
|
||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
||||
@@ -1559,17 +1597,13 @@ class Session:
|
||||
logger.debug(f'DH key: {self.dh_key.hex()}')
|
||||
|
||||
if self.is_initiator:
|
||||
if self.pairing_method == self.PASSKEY:
|
||||
if self.passkey_display:
|
||||
self.send_pairing_confirm_command()
|
||||
else:
|
||||
self.input_passkey(self.send_pairing_confirm_command)
|
||||
self.send_pairing_confirm_command()
|
||||
else:
|
||||
# Send our public key back to the initiator
|
||||
if self.pairing_method == self.PASSKEY:
|
||||
self.display_or_input_passkey(self.send_public_key_command)
|
||||
else:
|
||||
self.send_public_key_command()
|
||||
self.display_or_input_passkey()
|
||||
|
||||
# Send our public key back to the initiator
|
||||
self.send_public_key_command()
|
||||
|
||||
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
|
||||
# We can now send the confirmation value
|
||||
|
||||
170
bumble/snoop.py
Normal file
170
bumble/snoop.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# Copyright 2021-2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from contextlib import contextmanager
|
||||
from enum import IntEnum
|
||||
import logging
|
||||
import struct
|
||||
import datetime
|
||||
from typing import BinaryIO, Generator
|
||||
import os
|
||||
|
||||
from bumble.hci import HCI_COMMAND_PACKET, HCI_EVENT_PACKET
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
class Snooper:
|
||||
"""
|
||||
Base class for snooper implementations.
|
||||
|
||||
A snooper is an object that will be provided with HCI packets as they are
|
||||
exchanged between a host and a controller.
|
||||
"""
|
||||
|
||||
class Direction(IntEnum):
|
||||
HOST_TO_CONTROLLER = 0
|
||||
CONTROLLER_TO_HOST = 1
|
||||
|
||||
class DataLinkType(IntEnum):
|
||||
H1 = 1001
|
||||
H4 = 1002
|
||||
HCI_BSCP = 1003
|
||||
H5 = 1004
|
||||
|
||||
def snoop(self, hci_packet: bytes, direction: Direction) -> None:
|
||||
"""Snoop on an HCI packet."""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class BtSnooper(Snooper):
|
||||
"""
|
||||
Snooper that saves HCI packets using the BTSnoop format, based on RFC 1761.
|
||||
"""
|
||||
|
||||
IDENTIFICATION_PATTERN = b'btsnoop\0'
|
||||
TIMESTAMP_ANCHOR = datetime.datetime(2000, 1, 1)
|
||||
TIMESTAMP_DELTA = 0x00E03AB44A676000
|
||||
ONE_MS = datetime.timedelta(microseconds=1)
|
||||
|
||||
def __init__(self, output: BinaryIO):
|
||||
self.output = output
|
||||
|
||||
# Write the header
|
||||
self.output.write(
|
||||
self.IDENTIFICATION_PATTERN + struct.pack('>LL', 1, self.DataLinkType.H4)
|
||||
)
|
||||
|
||||
def snoop(self, hci_packet: bytes, direction: Snooper.Direction) -> None:
|
||||
flags = int(direction)
|
||||
packet_type = hci_packet[0]
|
||||
if packet_type in (HCI_EVENT_PACKET, HCI_COMMAND_PACKET):
|
||||
flags |= 0x10
|
||||
|
||||
# Compute the current timestamp
|
||||
timestamp = (
|
||||
int((datetime.datetime.utcnow() - self.TIMESTAMP_ANCHOR) / self.ONE_MS)
|
||||
+ self.TIMESTAMP_DELTA
|
||||
)
|
||||
|
||||
# Emit the record
|
||||
self.output.write(
|
||||
struct.pack(
|
||||
'>IIIIQ',
|
||||
len(hci_packet), # Original Length
|
||||
len(hci_packet), # Included Length
|
||||
flags, # Packet Flags
|
||||
0, # Cumulative Drops
|
||||
timestamp, # Timestamp
|
||||
)
|
||||
+ hci_packet
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
_SNOOPER_INSTANCE_COUNT = 0
|
||||
|
||||
|
||||
@contextmanager
|
||||
def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
||||
"""
|
||||
Create a snooper given a specification string.
|
||||
|
||||
The general syntax for the specification string is:
|
||||
<snooper-type>:<type-specific-arguments>
|
||||
|
||||
Supported snooper types are:
|
||||
|
||||
btsnoop
|
||||
The syntax for the type-specific arguments for this type is:
|
||||
<io-type>:<io-type-specific-arguments>
|
||||
|
||||
Supported I/O types are:
|
||||
|
||||
file
|
||||
The type-specific arguments for this I/O type is a string that is converted
|
||||
to a file path using the python `str.format()` string formatting. The log
|
||||
records will be written to that file if it can be opened/created.
|
||||
The keyword args that may be referenced by the string pattern are:
|
||||
now: the value of `datetime.now()`
|
||||
utcnow: the value of `datetime.utcnow()`
|
||||
pid: the current process ID.
|
||||
instance: the instance ID in the current process.
|
||||
|
||||
Examples:
|
||||
btsnoop:file:my_btsnoop.log
|
||||
btsnoop:file:/tmp/bumble_{now:%Y-%m-%d-%H:%M:%S}_{pid}.log
|
||||
|
||||
"""
|
||||
if ':' not in spec:
|
||||
raise ValueError('snooper type prefix missing')
|
||||
|
||||
snooper_type, snooper_args = spec.split(':', maxsplit=1)
|
||||
|
||||
if snooper_type == 'btsnoop':
|
||||
if ':' not in snooper_args:
|
||||
raise ValueError('I/O type for btsnoop snooper type missing')
|
||||
|
||||
io_type, io_name = snooper_args.split(':', maxsplit=1)
|
||||
if io_type == 'file':
|
||||
# Process the file name string pattern.
|
||||
global _SNOOPER_INSTANCE_COUNT
|
||||
file_path = io_name.format(
|
||||
now=datetime.datetime.now(),
|
||||
utcnow=datetime.datetime.utcnow(),
|
||||
pid=os.getpid(),
|
||||
instance=_SNOOPER_INSTANCE_COUNT,
|
||||
)
|
||||
|
||||
# Open the file
|
||||
logger.debug(f'Snoop file: {file_path}')
|
||||
with open(file_path, 'wb') as snoop_file:
|
||||
_SNOOPER_INSTANCE_COUNT += 1
|
||||
yield BtSnooper(snoop_file)
|
||||
_SNOOPER_INSTANCE_COUNT -= 1
|
||||
return
|
||||
|
||||
raise ValueError(f'I/O type {io_type} not supported')
|
||||
|
||||
raise ValueError(f'snooper type {snooper_type} not found')
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
# Copyright 2021-2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -15,11 +15,13 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .common import Transport, AsyncPipeSink
|
||||
from ..link import RemoteLink
|
||||
from .common import Transport, AsyncPipeSink, SnoopingTransport
|
||||
from ..controller import Controller
|
||||
from ..snoop import create_snooper
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -28,13 +30,52 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_transport(name):
|
||||
'''
|
||||
def _wrap_transport(transport: Transport) -> Transport:
|
||||
"""
|
||||
Automatically wrap a Transport instance when a wrapping class can be inferred
|
||||
from the environment.
|
||||
If no wrapping class is applicable, the transport argument is returned as-is.
|
||||
"""
|
||||
|
||||
# If BUMBLE_SNOOPER is set, try to automatically create a snooper.
|
||||
if snooper_spec := os.getenv('BUMBLE_SNOOPER'):
|
||||
try:
|
||||
return SnoopingTransport.create_with(
|
||||
transport, create_snooper(snooper_spec)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f'Exception while creating snooper: {exc}')
|
||||
|
||||
return transport
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_transport(name: str) -> Transport:
|
||||
"""
|
||||
Open a transport by name.
|
||||
The name must be <type>:<parameters>
|
||||
Where <parameters> depend on the type (and may be empty for some types).
|
||||
The supported types are: serial,udp,tcp,pty,usb
|
||||
'''
|
||||
The supported types are:
|
||||
* serial
|
||||
* udp
|
||||
* tcp-client
|
||||
* tcp-server
|
||||
* ws-client
|
||||
* ws-server
|
||||
* pty
|
||||
* file
|
||||
* vhci
|
||||
* hci-socket
|
||||
* usb
|
||||
* pyusb
|
||||
* android-emulator
|
||||
"""
|
||||
|
||||
return _wrap_transport(await _open_transport(name))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def _open_transport(name: str) -> Transport:
|
||||
# pylint: disable=import-outside-toplevel
|
||||
# pylint: disable=too-many-return-statements
|
||||
|
||||
@@ -108,8 +149,21 @@ async def open_transport(name):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def open_transport_or_link(name):
|
||||
async def open_transport_or_link(name: str) -> Transport:
|
||||
"""
|
||||
Open a transport or a link relay.
|
||||
|
||||
Args:
|
||||
name:
|
||||
Name of the transport or link relay to open.
|
||||
When the name starts with "link-relay:", open a link relay (see RemoteLink
|
||||
for details on what the arguments are).
|
||||
For other namespaces, see `open_transport`.
|
||||
|
||||
"""
|
||||
if name.startswith('link-relay:'):
|
||||
from ..link import RemoteLink # lazy import
|
||||
|
||||
link = RemoteLink(name[11:])
|
||||
await link.wait_until_connected()
|
||||
controller = Controller('remote', link=link)
|
||||
@@ -118,6 +172,6 @@ async def open_transport_or_link(name):
|
||||
async def close(self):
|
||||
link.close()
|
||||
|
||||
return LinkTransport(controller, AsyncPipeSink(controller))
|
||||
return _wrap_transport(LinkTransport(controller, AsyncPipeSink(controller)))
|
||||
|
||||
return await open_transport(name)
|
||||
|
||||
@@ -15,12 +15,16 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import contextlib
|
||||
import struct
|
||||
import asyncio
|
||||
import logging
|
||||
from colors import color
|
||||
from typing import ContextManager
|
||||
|
||||
from .. import hci
|
||||
from ..colors import color
|
||||
from ..snoop import Snooper
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -246,6 +250,20 @@ class StreamPacketSink:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Transport:
|
||||
"""
|
||||
Base class for all transports.
|
||||
|
||||
A Transport represents a source and a sink together.
|
||||
An instance must be closed by calling close() when no longer used. Instances
|
||||
implement the ContextManager protocol so that they may be used in a `async with`
|
||||
statement.
|
||||
An instance is iterable. The iterator yields, in order, its source and sink, so
|
||||
that it may be used with a convenient call syntax like:
|
||||
|
||||
async with create_transport() as (source, sink):
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self, source, sink):
|
||||
self.source = source
|
||||
self.sink = sink
|
||||
@@ -259,7 +277,7 @@ class Transport:
|
||||
def __iter__(self):
|
||||
return iter((self.source, self.sink))
|
||||
|
||||
async def close(self):
|
||||
async def close(self) -> None:
|
||||
self.source.close()
|
||||
self.sink.close()
|
||||
|
||||
@@ -335,3 +353,60 @@ class PumpedTransport(Transport):
|
||||
async def close(self):
|
||||
await super().close()
|
||||
await self.close_function()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class SnoopingTransport(Transport):
|
||||
"""Transport wrapper that snoops on packets to/from a wrapped transport."""
|
||||
|
||||
@staticmethod
|
||||
def create_with(
|
||||
transport: Transport, snooper: ContextManager[Snooper]
|
||||
) -> SnoopingTransport:
|
||||
"""
|
||||
Create an instance given a snooper that works as as context manager.
|
||||
|
||||
The returned instance will exit the snooper context when it is closed.
|
||||
"""
|
||||
with contextlib.ExitStack() as exit_stack:
|
||||
return SnoopingTransport(
|
||||
transport, exit_stack.enter_context(snooper), exit_stack.pop_all().close
|
||||
)
|
||||
raise RuntimeError('unexpected code path') # Satisfy the type checker
|
||||
|
||||
class Source:
|
||||
def __init__(self, source, snooper):
|
||||
self.source = source
|
||||
self.snooper = snooper
|
||||
self.sink = None
|
||||
|
||||
def set_packet_sink(self, sink):
|
||||
self.sink = sink
|
||||
self.source.set_packet_sink(self)
|
||||
|
||||
def on_packet(self, packet):
|
||||
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
|
||||
if self.sink:
|
||||
self.sink.on_packet(packet)
|
||||
|
||||
class Sink:
|
||||
def __init__(self, sink, snooper):
|
||||
self.sink = sink
|
||||
self.snooper = snooper
|
||||
|
||||
def on_packet(self, packet):
|
||||
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
|
||||
if self.sink:
|
||||
self.sink.on_packet(packet)
|
||||
|
||||
def __init__(self, transport, snooper, close_snooper=None):
|
||||
super().__init__(
|
||||
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
|
||||
)
|
||||
self.transport = transport
|
||||
self.close_snooper = close_snooper
|
||||
|
||||
async def close(self):
|
||||
await self.transport.close()
|
||||
if self.close_snooper:
|
||||
self.close_snooper()
|
||||
|
||||
@@ -22,10 +22,10 @@ import time
|
||||
|
||||
import usb.core
|
||||
import usb.util
|
||||
from colors import color
|
||||
|
||||
from .common import Transport, ParserSource
|
||||
from .. import hci
|
||||
from ..colors import color
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -23,10 +23,10 @@ import ctypes
|
||||
import platform
|
||||
|
||||
import usb1
|
||||
from colors import color
|
||||
|
||||
from .common import Transport, ParserSource
|
||||
from .. import hci
|
||||
from ..colors import color
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -20,11 +20,11 @@ import logging
|
||||
import traceback
|
||||
import collections
|
||||
import sys
|
||||
from typing import Awaitable
|
||||
from typing import Awaitable, Set, TypeVar
|
||||
from functools import wraps
|
||||
from colors import color
|
||||
from pyee import EventEmitter
|
||||
|
||||
from .colors import color
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -65,8 +65,11 @@ def composite_listener(cls):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
_T = TypeVar('_T')
|
||||
|
||||
|
||||
class AbortableEventEmitter(EventEmitter):
|
||||
def abort_on(self, event: str, awaitable: Awaitable):
|
||||
def abort_on(self, event: str, awaitable: Awaitable[_T]) -> Awaitable[_T]:
|
||||
"""
|
||||
Set a coroutine or future to abort when an event occur.
|
||||
"""
|
||||
@@ -75,6 +78,8 @@ class AbortableEventEmitter(EventEmitter):
|
||||
return future
|
||||
|
||||
def on_event(*_):
|
||||
if future.done():
|
||||
return
|
||||
msg = f'abort: {event} event occurred.'
|
||||
if isinstance(future, asyncio.Task):
|
||||
# python < 3.9 does not support passing a message on `Task.cancel`
|
||||
@@ -152,6 +157,9 @@ class AsyncRunner:
|
||||
# Shared default queue
|
||||
default_queue = WorkQueue()
|
||||
|
||||
# Shared set of running tasks
|
||||
running_tasks: Set[Awaitable] = set()
|
||||
|
||||
@staticmethod
|
||||
def run_in_task(queue=None):
|
||||
"""
|
||||
@@ -182,6 +190,19 @@ class AsyncRunner:
|
||||
|
||||
return decorator
|
||||
|
||||
@staticmethod
|
||||
def spawn(coroutine):
|
||||
"""
|
||||
Spawn a task to run a coroutine in a "fire and forget" mode.
|
||||
|
||||
Using this method instead of just calling `asyncio.create_task(coroutine)`
|
||||
is necessary when you don't keep a reference to the task, because `asyncio`
|
||||
only keeps weak references to alive tasks.
|
||||
"""
|
||||
task = asyncio.create_task(coroutine)
|
||||
AsyncRunner.running_tasks.add(task)
|
||||
task.add_done_callback(AsyncRunner.running_tasks.remove)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class FlowControlAsyncPipe:
|
||||
|
||||
@@ -43,7 +43,7 @@ nav:
|
||||
- Apps & Tools:
|
||||
- Overview: apps_and_tools/index.md
|
||||
- Console: apps_and_tools/console.md
|
||||
- Link Relay: apps_and_tools/link_relay.md
|
||||
- Bench: apps_and_tools/bench.md
|
||||
- HCI Bridge: apps_and_tools/hci_bridge.md
|
||||
- Golden Gate Bridge: apps_and_tools/gg_bridge.md
|
||||
- Show: apps_and_tools/show.md
|
||||
@@ -51,6 +51,7 @@ nav:
|
||||
- Pair: apps_and_tools/pair.md
|
||||
- Unbond: apps_and_tools/unbond.md
|
||||
- USB Probe: apps_and_tools/usb_probe.md
|
||||
- Link Relay: apps_and_tools/link_relay.md
|
||||
- Hardware:
|
||||
- Overview: hardware/index.md
|
||||
- Platforms:
|
||||
@@ -62,7 +63,7 @@ nav:
|
||||
- Examples:
|
||||
- Overview: examples/index.md
|
||||
|
||||
copyright: Copyright 2021-2022 Google LLC
|
||||
copyright: Copyright 2021-2023 Google LLC
|
||||
|
||||
theme:
|
||||
name: 'material'
|
||||
|
||||
158
docs/mkdocs/src/apps_and_tools/bench.md
Normal file
158
docs/mkdocs/src/apps_and_tools/bench.md
Normal file
@@ -0,0 +1,158 @@
|
||||
BENCH TOOL
|
||||
==========
|
||||
|
||||
The "bench" tool implements a number of different ways of measuring the
|
||||
throughput and/or latency between two devices.
|
||||
|
||||
# General Usage
|
||||
|
||||
```
|
||||
Usage: bench.py [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
Options:
|
||||
--device-config FILENAME Device configuration file
|
||||
--role [sender|receiver|ping|pong]
|
||||
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
|
||||
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
|
||||
-s, --packet-size SIZE Packet size (server role) [8<=x<=4096]
|
||||
-c, --packet-count COUNT Packet count (server role)
|
||||
-sd, --start-delay SECONDS Start delay (server role)
|
||||
--help Show this message and exit.
|
||||
|
||||
Commands:
|
||||
central Run as a central (initiates the connection)
|
||||
peripheral Run as a peripheral (waits for a connection)
|
||||
```
|
||||
|
||||
## Options for the ``central`` Command
|
||||
```
|
||||
Usage: bumble-bench central [OPTIONS] TRANSPORT
|
||||
|
||||
Run as a central (initiates the connection)
|
||||
|
||||
Options:
|
||||
--peripheral ADDRESS_OR_NAME Address or name to connect to
|
||||
--connection-interval, --ci CONNECTION_INTERVAL
|
||||
Connection interval (in ms)
|
||||
--phy [1m|2m|coded] PHY to use
|
||||
--help Show this message and exit.
|
||||
```
|
||||
|
||||
|
||||
To test once device against another, one of the two devices must be running
|
||||
the ``peripheral`` command and the other the ``central`` command. The device
|
||||
running the ``peripheral`` command will accept connections from the device
|
||||
running the ``central`` command.
|
||||
When using Bluetooth LE (all modes except for ``rfcomm-server`` and ``rfcomm-client``utils),
|
||||
the default addresses configured in the tool should be sufficient. But when using
|
||||
Bluetooth Classic, the address of the Peripheral must be specified on the Central
|
||||
using the ``--peripheral`` option. The address will be printed by the Peripheral when
|
||||
it starts.
|
||||
|
||||
Independently of whether the device is the Central or Peripheral, each device selects a
|
||||
``mode`` and and ``role`` to run as. The ``mode`` and ``role`` of the Central and Peripheral
|
||||
must be compatible.
|
||||
|
||||
Device 1 mode | Device 2 mode
|
||||
------------------|------------------
|
||||
``gatt-client`` | ``gatt-server``
|
||||
``l2cap-client`` | ``l2cap-server``
|
||||
``rfcomm-client`` | ``rfcomm-server``
|
||||
|
||||
Device 1 role | Device 2 role
|
||||
--------------|--------------
|
||||
``sender`` | ``receiver``
|
||||
``ping`` | ``pong``
|
||||
|
||||
|
||||
# Examples
|
||||
|
||||
In the following examples, we have two USB Bluetooth controllers, one on `usb:0` and
|
||||
the other on `usb:1`, and two consoles/terminals. We will run a command in each.
|
||||
|
||||
!!! example "GATT Throughput"
|
||||
Using the default mode and role for the Central and Peripheral.
|
||||
|
||||
In the first console/terminal:
|
||||
```
|
||||
$ bumble-bench peripheral usb:0
|
||||
```
|
||||
|
||||
In the second console/terminal:
|
||||
```
|
||||
$ bumble-bench central usb:1
|
||||
```
|
||||
|
||||
In this default configuration, the Central runs a Sender, as a GATT client,
|
||||
connecting to the Peripheral running a Receiver, as a GATT server.
|
||||
|
||||
!!! example "L2CAP Throughput"
|
||||
In the first console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode l2cap-server peripheral usb:0
|
||||
```
|
||||
|
||||
In the second console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode l2cap-client central usb:1
|
||||
```
|
||||
|
||||
!!! example "RFComm Throughput"
|
||||
In the first console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode rfcomm-server peripheral usb:0
|
||||
```
|
||||
|
||||
NOTE: the BT address of the Peripheral will be printed out, use it with the
|
||||
``--peripheral`` option for the Central.
|
||||
|
||||
In this example, we use a larger packet size and packet count than the default.
|
||||
|
||||
In the second console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode rfcomm-client --packet-size 2000 --packet-count 100 central --peripheral 00:16:A4:5A:40:F2 usb:1
|
||||
```
|
||||
|
||||
!!! example "Ping/Pong Latency"
|
||||
In the first console/terminal:
|
||||
```
|
||||
$ bumble-bench --role pong peripheral usb:0
|
||||
```
|
||||
|
||||
In the second console/terminal:
|
||||
```
|
||||
$ bumble-bench --role ping central usb:1
|
||||
```
|
||||
|
||||
!!! example "Reversed modes with GATT and custom connection interval"
|
||||
In the first console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode gatt-client peripheral usb:0
|
||||
```
|
||||
|
||||
In the second console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode gatt-server central --ci 10 usb:1
|
||||
```
|
||||
|
||||
!!! example "Reversed modes with L2CAP and custom PHY"
|
||||
In the first console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode l2cap-client peripheral usb:0
|
||||
```
|
||||
|
||||
In the second console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode l2cap-server central --phy 2m usb:1
|
||||
```
|
||||
|
||||
!!! example "Reversed roles with L2CAP"
|
||||
In the first console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode l2cap-client --role sender peripheral usb:0
|
||||
```
|
||||
|
||||
In the second console/terminal:
|
||||
```
|
||||
$ bumble-bench --mode l2cap-server --role receiver central usb:1
|
||||
```
|
||||
@@ -5,6 +5,7 @@ Included in the project are a few apps and tools, built on top of the core libra
|
||||
These include:
|
||||
|
||||
* [Console](console.md) - an interactive text-based console
|
||||
* [Bench](bench.md) - Speed and Latency benchmarking between two devices (LE and Classic)
|
||||
* [Pair](pair.md) - Pair/bond two devices (LE and Classic)
|
||||
* [Unbond](unbond.md) - Remove a previously established bond
|
||||
* [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets
|
||||
|
||||
@@ -8,8 +8,7 @@ The project initially only supported BLE (Bluetooth Low Energy), but support for
|
||||
eventually added. Support for BLE is therefore currently somewhat more advanced than for Classic.
|
||||
|
||||
!!! warning
|
||||
This project is still very much experimental and in an alpha state where a lot of things are still missing or broken, and what's there changes frequently.
|
||||
Also, there are still a few hardcoded values/parameters in some of the examples and apps which need to be changed (those will eventually be command line arguments, as appropriate)
|
||||
This project is still in an early state of development where some things are still missing or broken, and what's implemented may change and evolve frequently.
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
@@ -19,7 +19,7 @@ import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport
|
||||
from bumble.profiles.battery_service import BatteryServiceProxy
|
||||
|
||||
@@ -19,7 +19,7 @@ import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.profiles.device_information_service import DeviceInformationServiceProxy
|
||||
from bumble.transport import open_transport
|
||||
|
||||
@@ -19,7 +19,7 @@ import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport
|
||||
from bumble.profiles.heart_rate_service import HeartRateServiceProxy
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"name": "Bumble Hands-Free",
|
||||
"class_of_device": 2360324
|
||||
"class_of_device": 2360324,
|
||||
"keystore": "JsonKeyStore",
|
||||
"le_enabled": false
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import logging
|
||||
import struct
|
||||
import json
|
||||
import websockets
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.device import Device, Connection, Peer
|
||||
@@ -209,7 +209,7 @@ async def keyboard_host(device, peer_address):
|
||||
return
|
||||
for i, characteristic in enumerate(report_characteristics):
|
||||
print(color('REPORT:', 'yellow'), characteristic)
|
||||
if characteristic.properties & Characteristic.NOTIFY:
|
||||
if characteristic.properties & Characteristic.Properties.NOTIFY:
|
||||
await peer.discover_descriptors(characteristic)
|
||||
report_reference_descriptor = characteristic.get_descriptor(
|
||||
GATT_REPORT_REFERENCE_DESCRIPTOR
|
||||
@@ -241,7 +241,9 @@ async def keyboard_device(device, command):
|
||||
# Create an 'input report' characteristic to send keyboard reports to the host
|
||||
input_report_characteristic = Characteristic(
|
||||
GATT_REPORT_CHARACTERISTIC,
|
||||
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE
|
||||
| Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[
|
||||
@@ -256,8 +258,8 @@ async def keyboard_device(device, command):
|
||||
# Create an 'output report' characteristic to receive keyboard reports from the host
|
||||
output_report_characteristic = Characteristic(
|
||||
GATT_REPORT_CHARACTERISTIC,
|
||||
Characteristic.READ
|
||||
| Characteristic.WRITE
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE
|
||||
| Characteristic.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([0]),
|
||||
@@ -278,7 +280,7 @@ async def keyboard_device(device, command):
|
||||
[
|
||||
Characteristic(
|
||||
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
'Bumble',
|
||||
)
|
||||
@@ -289,13 +291,13 @@ async def keyboard_device(device, command):
|
||||
[
|
||||
Characteristic(
|
||||
GATT_PROTOCOL_MODE_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
bytes([HID_REPORT_PROTOCOL]),
|
||||
),
|
||||
Characteristic(
|
||||
GATT_HID_INFORMATION_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
# bcdHID=1.1, bCountryCode=0x00,
|
||||
# Flags=RemoteWake|NormallyConnectable
|
||||
@@ -309,7 +311,7 @@ async def keyboard_device(device, command):
|
||||
),
|
||||
Characteristic(
|
||||
GATT_REPORT_MAP_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
HID_KEYBOARD_REPORT_MAP,
|
||||
),
|
||||
@@ -322,7 +324,7 @@ async def keyboard_device(device, command):
|
||||
[
|
||||
Characteristic(
|
||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
bytes([100]),
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.core import (
|
||||
|
||||
@@ -20,7 +20,7 @@ import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.core import BT_BR_EDR_TRANSPORT
|
||||
|
||||
@@ -101,7 +101,7 @@ async def main():
|
||||
# Add the ASHA service to the GATT server
|
||||
read_only_properties_characteristic = Characteristic(
|
||||
ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
bytes(
|
||||
[
|
||||
@@ -127,13 +127,13 @@ async def main():
|
||||
)
|
||||
audio_control_point_characteristic = Characteristic(
|
||||
ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
|
||||
Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.Properties.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.WRITEABLE,
|
||||
CharacteristicValue(write=on_audio_control_point_write),
|
||||
)
|
||||
audio_status_characteristic = Characteristic(
|
||||
ASHA_AUDIO_STATUS_CHARACTERISTIC,
|
||||
Characteristic.READ | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
bytes([0]),
|
||||
)
|
||||
@@ -145,7 +145,7 @@ async def main():
|
||||
)
|
||||
le_psm_out_characteristic = Characteristic(
|
||||
ASHA_LE_PSM_OUT_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
struct.pack('<H', psm),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
@@ -19,7 +19,7 @@ import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
@@ -80,7 +80,7 @@ async def main():
|
||||
)
|
||||
manufacturer_name_characteristic = Characteristic(
|
||||
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
"Fitbit",
|
||||
[descriptor],
|
||||
|
||||
@@ -19,7 +19,7 @@ import logging
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.controller import Controller
|
||||
|
||||
51
examples/run_device_with_snooper.py
Normal file
51
examples/run_device_with_snooper.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# Copyright 2021-2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from bumble.colors import color
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.snoop import BtSnooper
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main():
|
||||
if len(sys.argv) != 3:
|
||||
print('Usage: run_device_with_snooper.py <transport-spec> <snoop-file>')
|
||||
print('example: run_device_with_snooper.py usb:0 btsnoop.log')
|
||||
return
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(sys.argv[1]) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
|
||||
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
|
||||
|
||||
with open(sys.argv[2], "wb") as snoop_file:
|
||||
device.host.snooper = BtSnooper(snoop_file)
|
||||
await device.power_on()
|
||||
await device.start_scanning()
|
||||
|
||||
await hci_source.wait_for_termination()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
asyncio.run(main())
|
||||
@@ -19,7 +19,7 @@ import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
|
||||
from bumble.core import ProtocolError
|
||||
from bumble.device import Device, Peer
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
|
||||
from bumble.core import ProtocolError
|
||||
from bumble.controller import Controller
|
||||
@@ -70,7 +70,7 @@ async def main():
|
||||
)
|
||||
manufacturer_name_characteristic = Characteristic(
|
||||
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
"Fitbit",
|
||||
[descriptor],
|
||||
|
||||
@@ -96,7 +96,7 @@ async def main():
|
||||
)
|
||||
manufacturer_name_characteristic = Characteristic(
|
||||
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
'Fitbit',
|
||||
[descriptor],
|
||||
@@ -109,13 +109,13 @@ async def main():
|
||||
[
|
||||
Characteristic(
|
||||
'D901B45B-4916-412E-ACCA-376ECB603B2C',
|
||||
Characteristic.READ | Characteristic.WRITE,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.WRITE,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
CharacteristicValue(read=my_custom_read, write=my_custom_write),
|
||||
),
|
||||
Characteristic(
|
||||
'552957FB-CF1F-4A31-9535-E78847E1A714',
|
||||
Characteristic.READ | Characteristic.WRITE,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.WRITE,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
CharacteristicValue(
|
||||
read=my_custom_read_with_error, write=my_custom_write_with_error
|
||||
@@ -123,7 +123,7 @@ async def main():
|
||||
),
|
||||
Characteristic(
|
||||
'486F64C6-4B5F-4B3B-8AFF-EDE134A8446A',
|
||||
Characteristic.READ | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
'hello',
|
||||
),
|
||||
|
||||
@@ -20,7 +20,7 @@ import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
|
||||
import bumble.core
|
||||
from bumble.device import Device
|
||||
|
||||
@@ -74,19 +74,21 @@ async def main():
|
||||
# Add a few entries to the device's GATT server
|
||||
characteristic1 = Characteristic(
|
||||
'486F64C6-4B5F-4B3B-8AFF-EDE134A8446A',
|
||||
Characteristic.READ | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
bytes([0x40]),
|
||||
)
|
||||
characteristic2 = Characteristic(
|
||||
'8EBDEBAE-0017-418E-8D3B-3A3809492165',
|
||||
Characteristic.READ | Characteristic.INDICATE,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.INDICATE,
|
||||
Characteristic.READABLE,
|
||||
bytes([0x41]),
|
||||
)
|
||||
characteristic3 = Characteristic(
|
||||
'8EBDEBAE-0017-418E-8D3B-3A3809492165',
|
||||
Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE,
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.NOTIFY
|
||||
| Characteristic.Properties.INDICATE,
|
||||
Characteristic.READABLE,
|
||||
bytes([0x42]),
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
|
||||
import bumble.core
|
||||
from bumble.device import Device
|
||||
|
||||
@@ -19,7 +19,7 @@ import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from colors import color
|
||||
from bumble.colors import color
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
@@ -64,11 +64,11 @@ module = "aioconsole.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "bitstruct.*"
|
||||
module = "colors.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "colors.*"
|
||||
module = "construct.*"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
|
||||
11
setup.cfg
11
setup.cfg
@@ -30,10 +30,7 @@ package_dir =
|
||||
bumble.apps = apps
|
||||
include-package-data = True
|
||||
install_requires =
|
||||
aioconsole >= 0.4.1
|
||||
ansicolors >= 1.1
|
||||
appdirs >= 1.4
|
||||
bitstruct >= 8.12
|
||||
click >= 7.1.2; platform_system!='Emscripten'
|
||||
cryptography == 35; platform_system!='Emscripten'
|
||||
grpcio >= 1.46; platform_system!='Emscripten'
|
||||
@@ -46,6 +43,7 @@ install_requires =
|
||||
pyserial >= 3.5; platform_system!='Emscripten'
|
||||
pyusb >= 1.2; platform_system!='Emscripten'
|
||||
websockets >= 8.1; platform_system!='Emscripten'
|
||||
prettytable >= 3.6.0
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
@@ -60,6 +58,7 @@ console_scripts =
|
||||
bumble-unbond = bumble.apps.unbond:main
|
||||
bumble-usb-probe = bumble.apps.usb_probe:main
|
||||
bumble-link-relay = bumble.apps.link_relay.link_relay:main
|
||||
bumble-bench = bumble.apps.bench:main
|
||||
|
||||
[options.package_data]
|
||||
* = py.typed, *.pyi
|
||||
@@ -73,11 +72,11 @@ test =
|
||||
pytest-html >= 3.2.0
|
||||
coverage >= 6.4
|
||||
development =
|
||||
black >= 22.10
|
||||
black == 22.10
|
||||
invoke >= 1.7.3
|
||||
mypy >= 0.991
|
||||
mypy == 1.1.1
|
||||
nox >= 2022
|
||||
pylint >= 2.15.8
|
||||
pylint == 2.15.8
|
||||
types-appdirs >= 1.4.3
|
||||
types-invoke >= 1.7.3
|
||||
types-protobuf >= 4.21.0
|
||||
|
||||
@@ -21,6 +21,7 @@ import os
|
||||
import pytest
|
||||
|
||||
from bumble.controller import Controller
|
||||
from bumble.core import BT_BR_EDR_TRANSPORT
|
||||
from bumble.link import LocalLink
|
||||
from bumble.device import Device
|
||||
from bumble.host import Host
|
||||
@@ -58,18 +59,19 @@ class TwoDevices:
|
||||
def __init__(self):
|
||||
self.connections = [None, None]
|
||||
|
||||
addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
|
||||
self.link = LocalLink()
|
||||
self.controllers = [
|
||||
Controller('C1', link=self.link),
|
||||
Controller('C2', link=self.link),
|
||||
Controller('C1', link=self.link, public_address=addresses[0]),
|
||||
Controller('C2', link=self.link, public_address=addresses[1]),
|
||||
]
|
||||
self.devices = [
|
||||
Device(
|
||||
address='F0:F1:F2:F3:F4:F5',
|
||||
address=addresses[0],
|
||||
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
|
||||
),
|
||||
Device(
|
||||
address='F5:F4:F3:F2:F1:F0',
|
||||
address=addresses[1],
|
||||
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
|
||||
),
|
||||
]
|
||||
@@ -79,6 +81,9 @@ class TwoDevices:
|
||||
def on_connection(self, which, connection):
|
||||
self.connections[which] = connection
|
||||
|
||||
def on_paired(self, which, keys):
|
||||
self.paired[which] = keys
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
@@ -94,12 +99,21 @@ async def test_self_connection():
|
||||
'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 two_devices.devices[0].connect(two_devices.devices[1].random_address)
|
||||
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
|
||||
@@ -152,6 +166,9 @@ def sink_codec_capabilities():
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_sink_1():
|
||||
two_devices = TwoDevices()
|
||||
# Enable Classic connections
|
||||
two_devices.devices[0].classic_enabled = True
|
||||
two_devices.devices[1].classic_enabled = True
|
||||
await two_devices.devices[0].power_on()
|
||||
await two_devices.devices[1].power_on()
|
||||
|
||||
@@ -171,9 +188,16 @@ async def test_source_sink_1():
|
||||
listener = Listener(Listener.create_registrar(two_devices.devices[1]))
|
||||
listener.on('connection', on_avdtp_connection)
|
||||
|
||||
connection = await two_devices.devices[0].connect(
|
||||
two_devices.devices[1].random_address
|
||||
)
|
||||
async def make_connection():
|
||||
connections = await asyncio.gather(
|
||||
two_devices.devices[0].connect(
|
||||
two_devices.devices[1].public_address, BT_BR_EDR_TRANSPORT
|
||||
),
|
||||
two_devices.devices[1].accept(two_devices.devices[0].public_address),
|
||||
)
|
||||
return connections[0]
|
||||
|
||||
connection = await make_connection()
|
||||
client = await Protocol.connect(connection)
|
||||
endpoints = await client.discover_remote_endpoints()
|
||||
assert len(endpoints) == 1
|
||||
|
||||
@@ -25,10 +25,8 @@ def test_ad_data():
|
||||
assert data == ad_bytes
|
||||
assert ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None
|
||||
assert ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123])
|
||||
assert ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True, raw=True) == []
|
||||
assert ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [
|
||||
bytes([123])
|
||||
]
|
||||
assert ad.get_all(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) == []
|
||||
assert ad.get_all(AdvertisingData.TX_POWER_LEVEL, raw=True) == [bytes([123])]
|
||||
|
||||
data2 = bytes([2, AdvertisingData.TX_POWER_LEVEL, 234])
|
||||
ad.append(data2)
|
||||
@@ -36,8 +34,8 @@ def test_ad_data():
|
||||
assert ad_bytes == data + data2
|
||||
assert ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None
|
||||
assert ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123])
|
||||
assert ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True, raw=True) == []
|
||||
assert ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [
|
||||
assert ad.get_all(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) == []
|
||||
assert ad.get_all(AdvertisingData.TX_POWER_LEVEL, raw=True) == [
|
||||
bytes([123]),
|
||||
bytes([234]),
|
||||
]
|
||||
|
||||
47
tests/decoder_test.py
Normal file
47
tests/decoder_test.py
Normal file
@@ -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
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import hashlib
|
||||
import os
|
||||
from bumble.decoder import G722Decoder
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_decode_file():
|
||||
decoder = G722Decoder()
|
||||
output_bytes = bytearray()
|
||||
|
||||
with open(
|
||||
os.path.join(os.path.dirname(__file__), 'g722_sample.g722'), 'rb'
|
||||
) as file:
|
||||
file_content = file.read()
|
||||
frame_length = 80
|
||||
data_length = int(len(file_content) / frame_length)
|
||||
|
||||
for i in range(0, data_length):
|
||||
decoded_data = decoder.decode_frame(
|
||||
file_content[i * frame_length : i * frame_length + frame_length]
|
||||
)
|
||||
output_bytes.extend(decoded_data)
|
||||
|
||||
result = hashlib.md5(output_bytes).hexdigest()
|
||||
assert result == 'b58e0cdd012d12f5633fc796c3b0fbd4'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
test_decode_file()
|
||||
@@ -197,7 +197,7 @@ async def test_device_connect_parallel():
|
||||
d1.host.set_packet_sink(Sink(d1_flow()))
|
||||
d2.host.set_packet_sink(Sink(d2_flow()))
|
||||
|
||||
[c01, c02, a10, a20, a01] = await asyncio.gather(
|
||||
[c01, c02, a10, a20] = await asyncio.gather(
|
||||
*[
|
||||
asyncio.create_task(
|
||||
d0.connect(d1.public_address, transport=BT_BR_EDR_TRANSPORT)
|
||||
@@ -207,7 +207,6 @@ async def test_device_connect_parallel():
|
||||
),
|
||||
asyncio.create_task(d1.accept(peer_address=d0.public_address)),
|
||||
asyncio.create_task(d2.accept()),
|
||||
asyncio.create_task(d0.accept(peer_address=d1.public_address)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -215,11 +214,9 @@ async def test_device_connect_parallel():
|
||||
assert type(c02) == Connection
|
||||
assert type(a10) == Connection
|
||||
assert type(a20) == Connection
|
||||
assert type(a01) == Connection
|
||||
|
||||
assert c01.handle == a10.handle and c01.handle == 0x100
|
||||
assert c02.handle == a20.handle and c02.handle == 0x101
|
||||
assert a01 == c01
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
1
tests/g722_sample.g722
Normal file
1
tests/g722_sample.g722
Normal file
File diff suppressed because one or more lines are too long
@@ -23,6 +23,7 @@ import pytest
|
||||
|
||||
from bumble.controller import Controller
|
||||
from bumble.gatt_client import CharacteristicProxy
|
||||
from bumble.gatt_server import Server
|
||||
from bumble.link import LocalLink
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.host import Host
|
||||
@@ -37,10 +38,12 @@ from bumble.gatt import (
|
||||
Service,
|
||||
Characteristic,
|
||||
CharacteristicValue,
|
||||
Descriptor,
|
||||
)
|
||||
from bumble.transport import AsyncPipeSink
|
||||
from bumble.core import UUID
|
||||
from bumble.att import (
|
||||
Attribute,
|
||||
ATT_EXCHANGE_MTU_REQUEST,
|
||||
ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||
ATT_PDU,
|
||||
@@ -112,7 +115,7 @@ async def test_characteristic_encoding():
|
||||
|
||||
c = Foo(
|
||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
123,
|
||||
)
|
||||
@@ -141,7 +144,9 @@ async def test_characteristic_encoding():
|
||||
|
||||
characteristic = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE
|
||||
| Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([123]),
|
||||
)
|
||||
@@ -237,7 +242,9 @@ async def test_attribute_getters():
|
||||
characteristic_uuid = UUID('FDB159DB-036C-49E3-B3DB-6325AC750806')
|
||||
characteristic = Characteristic(
|
||||
characteristic_uuid,
|
||||
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE
|
||||
| Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([123]),
|
||||
)
|
||||
@@ -282,7 +289,7 @@ def test_CharacteristicAdapter():
|
||||
v = bytes([1, 2, 3])
|
||||
c = Characteristic(
|
||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
v,
|
||||
)
|
||||
@@ -418,7 +425,7 @@ async def test_read_write():
|
||||
|
||||
characteristic1 = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
Characteristic.READ | Characteristic.WRITE,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.WRITE,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
)
|
||||
|
||||
@@ -435,7 +442,7 @@ async def test_read_write():
|
||||
|
||||
characteristic2 = Characteristic(
|
||||
'66DE9057-C848-4ACA-B993-D675644EBB85',
|
||||
Characteristic.READ | Characteristic.WRITE,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.WRITE,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
CharacteristicValue(
|
||||
read=on_characteristic2_read, write=on_characteristic2_write
|
||||
@@ -498,7 +505,7 @@ async def test_read_write2():
|
||||
v = bytes([0x11, 0x22, 0x33, 0x44])
|
||||
characteristic1 = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
Characteristic.READ | Characteristic.WRITE,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.WRITE,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
value=v,
|
||||
)
|
||||
@@ -542,7 +549,7 @@ async def test_subscribe_notify():
|
||||
|
||||
characteristic1 = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
Characteristic.READ | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE,
|
||||
bytes([1, 2, 3]),
|
||||
)
|
||||
@@ -558,7 +565,7 @@ async def test_subscribe_notify():
|
||||
|
||||
characteristic2 = Characteristic(
|
||||
'66DE9057-C848-4ACA-B993-D675644EBB85',
|
||||
Characteristic.READ | Characteristic.INDICATE,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.INDICATE,
|
||||
Characteristic.READABLE,
|
||||
bytes([4, 5, 6]),
|
||||
)
|
||||
@@ -574,7 +581,9 @@ async def test_subscribe_notify():
|
||||
|
||||
characteristic3 = Characteristic(
|
||||
'AB5E639C-40C1-4238-B9CB-AF41F8B806E4',
|
||||
Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE,
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.NOTIFY
|
||||
| Characteristic.Properties.INDICATE,
|
||||
Characteristic.READABLE,
|
||||
bytes([7, 8, 9]),
|
||||
)
|
||||
@@ -794,32 +803,46 @@ async def test_mtu_exchange():
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_char_property_to_string():
|
||||
# single
|
||||
assert Characteristic.property_name(0x01) == "BROADCAST"
|
||||
assert Characteristic.property_name(Characteristic.BROADCAST) == "BROADCAST"
|
||||
assert str(Characteristic.Properties(0x01)) == "Properties.BROADCAST"
|
||||
assert str(Characteristic.Properties.BROADCAST) == "Properties.BROADCAST"
|
||||
|
||||
# double
|
||||
assert Characteristic.properties_as_string(0x03) == "BROADCAST,READ"
|
||||
assert str(Characteristic.Properties(0x03)) == "Properties.READ|BROADCAST"
|
||||
assert (
|
||||
Characteristic.properties_as_string(
|
||||
Characteristic.BROADCAST | Characteristic.READ
|
||||
)
|
||||
== "BROADCAST,READ"
|
||||
str(Characteristic.Properties.BROADCAST | Characteristic.Properties.READ)
|
||||
== "Properties.READ|BROADCAST"
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_char_property_string_to_type():
|
||||
def test_characteristic_property_from_string():
|
||||
# single
|
||||
assert Characteristic.string_to_properties("BROADCAST") == Characteristic.BROADCAST
|
||||
assert (
|
||||
Characteristic.Properties.from_string("BROADCAST")
|
||||
== Characteristic.Properties.BROADCAST
|
||||
)
|
||||
|
||||
# double
|
||||
assert (
|
||||
Characteristic.string_to_properties("BROADCAST,READ")
|
||||
== Characteristic.BROADCAST | Characteristic.READ
|
||||
Characteristic.Properties.from_string("BROADCAST,READ")
|
||||
== Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
|
||||
)
|
||||
assert (
|
||||
Characteristic.string_to_properties("READ,BROADCAST")
|
||||
== Characteristic.BROADCAST | Characteristic.READ
|
||||
Characteristic.Properties.from_string("READ,BROADCAST")
|
||||
== Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_characteristic_property_from_string_assert():
|
||||
with pytest.raises(TypeError) as e_info:
|
||||
Characteristic.Properties.from_string("BROADCAST,HELLO")
|
||||
|
||||
assert (
|
||||
str(e_info.value)
|
||||
== """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
|
||||
Got: BROADCAST,HELLO"""
|
||||
)
|
||||
|
||||
|
||||
@@ -830,7 +853,9 @@ async def test_server_string():
|
||||
|
||||
characteristic = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE
|
||||
| Characteristic.Properties.NOTIFY,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([123]),
|
||||
)
|
||||
@@ -841,13 +866,13 @@ async def test_server_string():
|
||||
assert (
|
||||
str(server.gatt_server)
|
||||
== """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)
|
||||
Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), properties=READ)
|
||||
CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), properties=READ)
|
||||
Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), properties=READ)
|
||||
CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
|
||||
Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
|
||||
CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
|
||||
Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
|
||||
Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
|
||||
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, properties=READ,WRITE,NOTIFY)
|
||||
Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, properties=READ,WRITE,NOTIFY)
|
||||
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
|
||||
Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
|
||||
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
|
||||
)
|
||||
|
||||
@@ -861,6 +886,139 @@ async def async_main():
|
||||
await test_mtu_exchange()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_attribute_string_to_permissions():
|
||||
assert Attribute.string_to_permissions('READABLE') == 1
|
||||
assert Attribute.string_to_permissions('WRITEABLE') == 2
|
||||
assert Attribute.string_to_permissions('READABLE,WRITEABLE') == 3
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_characteristic_permissions():
|
||||
characteristic = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE
|
||||
| Characteristic.Properties.NOTIFY,
|
||||
'READABLE,WRITEABLE',
|
||||
)
|
||||
assert characteristic.permissions == 3
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_characteristic_has_properties():
|
||||
characteristic = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE
|
||||
| Characteristic.Properties.NOTIFY,
|
||||
'READABLE,WRITEABLE',
|
||||
)
|
||||
assert characteristic.has_properties(Characteristic.Properties.READ)
|
||||
assert characteristic.has_properties(
|
||||
Characteristic.Properties.READ | Characteristic.Properties.WRITE
|
||||
)
|
||||
assert not characteristic.has_properties(
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE
|
||||
| Characteristic.Properties.INDICATE
|
||||
)
|
||||
assert not characteristic.has_properties(Characteristic.Properties.INDICATE)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_descriptor_permissions():
|
||||
descriptor = Descriptor('2902', 'READABLE,WRITEABLE')
|
||||
assert descriptor.permissions == 3
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_get_attribute_group():
|
||||
device = Device()
|
||||
|
||||
# add some services / characteristics to the gatt server
|
||||
characteristic1 = Characteristic(
|
||||
'1111',
|
||||
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([123]),
|
||||
)
|
||||
characteristic2 = Characteristic(
|
||||
'2222',
|
||||
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([123]),
|
||||
)
|
||||
services = [Service('1212', [characteristic1]), Service('3233', [characteristic2])]
|
||||
device.gatt_server.add_services(services)
|
||||
|
||||
# get the handles from gatt server
|
||||
characteristic_attributes1 = device.gatt_server.get_characteristic_attributes(
|
||||
UUID('1212'), UUID('1111')
|
||||
)
|
||||
assert characteristic_attributes1 is not None
|
||||
characteristic_attributes2 = device.gatt_server.get_characteristic_attributes(
|
||||
UUID('3233'), UUID('2222')
|
||||
)
|
||||
assert characteristic_attributes2 is not None
|
||||
descriptor1 = device.gatt_server.get_descriptor_attribute(
|
||||
UUID('1212'), UUID('1111'), UUID('2902')
|
||||
)
|
||||
assert descriptor1 is not None
|
||||
descriptor2 = device.gatt_server.get_descriptor_attribute(
|
||||
UUID('3233'), UUID('2222'), UUID('2902')
|
||||
)
|
||||
assert descriptor2 is not None
|
||||
|
||||
# confirm the handles map back to the service
|
||||
assert (
|
||||
UUID('1212')
|
||||
== device.gatt_server.get_attribute_group(
|
||||
characteristic_attributes1[0].handle, Service
|
||||
).uuid
|
||||
)
|
||||
assert (
|
||||
UUID('1212')
|
||||
== device.gatt_server.get_attribute_group(
|
||||
characteristic_attributes1[1].handle, Service
|
||||
).uuid
|
||||
)
|
||||
assert (
|
||||
UUID('1212')
|
||||
== device.gatt_server.get_attribute_group(descriptor1.handle, Service).uuid
|
||||
)
|
||||
assert (
|
||||
UUID('3233')
|
||||
== device.gatt_server.get_attribute_group(
|
||||
characteristic_attributes2[0].handle, Service
|
||||
).uuid
|
||||
)
|
||||
assert (
|
||||
UUID('3233')
|
||||
== device.gatt_server.get_attribute_group(
|
||||
characteristic_attributes2[1].handle, Service
|
||||
).uuid
|
||||
)
|
||||
assert (
|
||||
UUID('3233')
|
||||
== device.gatt_server.get_attribute_group(descriptor2.handle, Service).uuid
|
||||
)
|
||||
|
||||
# confirm the handles map back to the characteristic
|
||||
assert (
|
||||
UUID('1111')
|
||||
== device.gatt_server.get_attribute_group(
|
||||
descriptor1.handle, Characteristic
|
||||
).uuid
|
||||
)
|
||||
assert (
|
||||
UUID('2222')
|
||||
== device.gatt_server.get_attribute_group(
|
||||
descriptor2.handle, Characteristic
|
||||
).uuid
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
|
||||
@@ -52,6 +52,7 @@ from bumble.hci import (
|
||||
HCI_LE_Set_Scan_Parameters_Command,
|
||||
HCI_Number_Of_Completed_Packets_Event,
|
||||
HCI_Packet,
|
||||
HCI_PIN_Code_Request_Reply_Command,
|
||||
HCI_Read_Local_Supported_Commands_Command,
|
||||
HCI_Read_Local_Supported_Features_Command,
|
||||
HCI_Read_Local_Version_Information_Command,
|
||||
@@ -213,6 +214,23 @@ def test_HCI_Command():
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_HCI_PIN_Code_Request_Reply_Command():
|
||||
pin_code = b'1234'
|
||||
pin_code_length = len(pin_code)
|
||||
# here to make the test pass, we need to
|
||||
# pad pin_code, as HCI_Object.format_fields
|
||||
# does not do it for us
|
||||
padded_pin_code = pin_code + bytes(16 - pin_code_length)
|
||||
command = HCI_PIN_Code_Request_Reply_Command(
|
||||
bd_addr=Address(
|
||||
'00:11:22:33:44:55', address_type=Address.PUBLIC_DEVICE_ADDRESS
|
||||
),
|
||||
pin_code_length=pin_code_length,
|
||||
pin_code=padded_pin_code,
|
||||
)
|
||||
basic_check(command)
|
||||
|
||||
|
||||
def test_HCI_Reset_Command():
|
||||
command = HCI_Reset_Command()
|
||||
basic_check(command)
|
||||
@@ -440,6 +458,7 @@ def run_test_events():
|
||||
def run_test_commands():
|
||||
test_HCI_Command()
|
||||
test_HCI_Reset_Command()
|
||||
test_HCI_PIN_Code_Request_Reply_Command()
|
||||
test_HCI_Read_Local_Version_Information_Command()
|
||||
test_HCI_Read_Local_Supported_Commands_Command()
|
||||
test_HCI_Read_Local_Supported_Features_Command()
|
||||
|
||||
@@ -22,6 +22,7 @@ import os
|
||||
import pytest
|
||||
|
||||
from bumble.controller import Controller
|
||||
from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE
|
||||
from bumble.link import LocalLink
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.host import Host
|
||||
@@ -32,7 +33,6 @@ from bumble.smp import (
|
||||
PairingDelegate,
|
||||
SMP_PAIRING_NOT_SUPPORTED_ERROR,
|
||||
SMP_CONFIRM_VALUE_FAILED_ERROR,
|
||||
SMP_ID_KEY_DISTRIBUTION_FLAG,
|
||||
)
|
||||
from bumble.core import ProtocolError
|
||||
|
||||
@@ -48,18 +48,19 @@ class TwoDevices:
|
||||
def __init__(self):
|
||||
self.connections = [None, None]
|
||||
|
||||
addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
|
||||
self.link = LocalLink()
|
||||
self.controllers = [
|
||||
Controller('C1', link=self.link),
|
||||
Controller('C2', link=self.link),
|
||||
Controller('C1', link=self.link, public_address=addresses[0]),
|
||||
Controller('C2', link=self.link, public_address=addresses[1]),
|
||||
]
|
||||
self.devices = [
|
||||
Device(
|
||||
address='F0:F1:F2:F3:F4:F5',
|
||||
address=addresses[0],
|
||||
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
|
||||
),
|
||||
Device(
|
||||
address='F5:F4:F3:F2:F1:F0',
|
||||
address=addresses[1],
|
||||
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
|
||||
),
|
||||
]
|
||||
@@ -99,6 +100,60 @@ async def test_self_connection():
|
||||
assert two_devices.connections[1] is not None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'responder_role,',
|
||||
(BT_CENTRAL_ROLE, BT_PERIPHERAL_ROLE),
|
||||
)
|
||||
async def test_self_classic_connection(responder_role):
|
||||
# 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, responder_role
|
||||
),
|
||||
)
|
||||
|
||||
# Check the post conditions
|
||||
assert two_devices.connections[0] is not None
|
||||
assert two_devices.connections[1] is not None
|
||||
|
||||
# Check the role
|
||||
assert two_devices.connections[0].role != responder_role
|
||||
assert two_devices.connections[1].role == responder_role
|
||||
|
||||
# Role switch
|
||||
await two_devices.connections[0].switch_role(responder_role)
|
||||
|
||||
# Check the role
|
||||
assert two_devices.connections[0].role == responder_role
|
||||
assert two_devices.connections[1].role != responder_role
|
||||
|
||||
await two_devices.connections[0].disconnect()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_self_gatt():
|
||||
@@ -108,25 +163,28 @@ async def test_self_gatt():
|
||||
# Add some GATT characteristics to device 1
|
||||
c1 = Characteristic(
|
||||
'3A143AD7-D4A7-436B-97D6-5B62C315E833',
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
bytes([1, 2, 3]),
|
||||
)
|
||||
c2 = Characteristic(
|
||||
'9557CCE2-DB37-46EB-94C4-50AE5B9CB0F8',
|
||||
Characteristic.READ | Characteristic.WRITE,
|
||||
Characteristic.Properties.READ | Characteristic.Properties.WRITE,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([4, 5, 6]),
|
||||
)
|
||||
c3 = Characteristic(
|
||||
'84FC1A2E-C52D-4A2D-B8C3-8855BAB86638',
|
||||
Characteristic.READ | Characteristic.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([7, 8, 9]),
|
||||
)
|
||||
c4 = Characteristic(
|
||||
'84FC1A2E-C52D-4A2D-B8C3-8855BAB86638',
|
||||
Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE,
|
||||
Characteristic.Properties.READ
|
||||
| Characteristic.Properties.NOTIFY
|
||||
| Characteristic.Properties.INDICATE,
|
||||
Characteristic.READABLE,
|
||||
bytes([1, 1, 1]),
|
||||
)
|
||||
@@ -179,7 +237,7 @@ async def test_self_gatt_long_read():
|
||||
characteristics = [
|
||||
Characteristic(
|
||||
f'3A143AD7-D4A7-436B-97D6-5B62C315{i:04X}',
|
||||
Characteristic.READ,
|
||||
Characteristic.Properties.READ,
|
||||
Characteristic.READABLE,
|
||||
bytes([x & 255 for x in range(i)]),
|
||||
)
|
||||
@@ -273,9 +331,15 @@ KEY_DIST = range(16)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'io_cap, sc, mitm, key_dist', itertools.product(IO_CAP, SC, MITM, KEY_DIST)
|
||||
'io_caps, sc, mitm, key_dist',
|
||||
itertools.chain(
|
||||
itertools.product([IO_CAP], SC, MITM, [15]),
|
||||
itertools.product(
|
||||
[[PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT]], SC, MITM, KEY_DIST
|
||||
),
|
||||
),
|
||||
)
|
||||
async def test_self_smp(io_cap, sc, mitm, key_dist):
|
||||
async def test_self_smp(io_caps, sc, mitm, key_dist):
|
||||
class Delegate(PairingDelegate):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -296,6 +360,7 @@ async def test_self_smp(io_cap, sc, mitm, key_dist):
|
||||
self.peer_delegate = None
|
||||
self.number = asyncio.get_running_loop().create_future()
|
||||
|
||||
# pylint: disable-next=unused-argument
|
||||
async def compare_numbers(self, number, digits):
|
||||
if self.peer_delegate is None:
|
||||
logger.warning(f'[{self.name}] no peer delegate')
|
||||
@@ -331,8 +396,9 @@ async def test_self_smp(io_cap, sc, mitm, key_dist):
|
||||
|
||||
pairing_config_sets = [('Initiator', [None]), ('Responder', [None])]
|
||||
for pairing_config_set in pairing_config_sets:
|
||||
delegate = Delegate(pairing_config_set[0], io_cap, key_dist, key_dist)
|
||||
pairing_config_set[1].append(PairingConfig(sc, mitm, True, delegate))
|
||||
for io_cap in io_caps:
|
||||
delegate = Delegate(pairing_config_set[0], io_cap, key_dist, key_dist)
|
||||
pairing_config_set[1].append(PairingConfig(sc, mitm, True, delegate))
|
||||
|
||||
for pairing_config1 in pairing_config_sets[0][1]:
|
||||
for pairing_config2 in pairing_config_sets[1][1]:
|
||||
@@ -381,7 +447,7 @@ async def test_self_smp_wrong_pin():
|
||||
async def compare_numbers(self, number, digits):
|
||||
return False
|
||||
|
||||
wrong_pin_pairing_config = PairingConfig(delegate=WrongPinDelegate())
|
||||
wrong_pin_pairing_config = PairingConfig(mitm=True, delegate=WrongPinDelegate())
|
||||
paired = False
|
||||
try:
|
||||
await _test_self_smp_with_configs(
|
||||
|
||||
@@ -72,5 +72,6 @@ def test_parser_extensions():
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
test_parser()
|
||||
test_parser_extensions()
|
||||
if __name__ == '__main__':
|
||||
test_parser()
|
||||
test_parser_extensions()
|
||||
|
||||
Reference in New Issue
Block a user