forked from auracaster/bumble_mirror
Compare commits
68 Commits
gbg/standa
...
v0.0.127
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78b36d2049 | ||
|
|
3e0cad1456 | ||
|
|
68d9fbc159 | ||
|
|
a916b7a21a | ||
|
|
6ff52df8bd | ||
|
|
7fa2eb7658 | ||
|
|
86618e52ef | ||
|
|
fbb46dd736 | ||
|
|
d1e119f176 | ||
|
|
2fc7a0bf04 | ||
|
|
d6c4644b23 | ||
|
|
073757d5dd | ||
|
|
20dedbd923 | ||
|
|
df1962e8da | ||
|
|
0edd6b731f | ||
|
|
d2227f017f | ||
|
|
a2f18cffc9 | ||
|
|
db5e52f1df | ||
|
|
d7da5a9379 | ||
|
|
80569bc9f3 | ||
|
|
daa05b8996 | ||
|
|
624e860762 | ||
|
|
159cbf7774 | ||
|
|
d188041694 | ||
|
|
99cba19d7c | ||
|
|
84d70ad4f3 | ||
|
|
996a9e28f4 | ||
|
|
27cb4c586b | ||
|
|
1f78243ea6 | ||
|
|
216ce2abd0 | ||
|
|
431445e6a2 | ||
|
|
d7cc546248 | ||
|
|
29fd19f40d | ||
|
|
14dfc1a501 | ||
|
|
938282e961 | ||
|
|
900c15b151 | ||
|
|
9ea93be723 | ||
|
|
894ab023c7 | ||
|
|
7bbb37b2da | ||
|
|
3fa5d320de | ||
|
|
16d684c199 | ||
|
|
c28aa2ebb6 | ||
|
|
28586382f4 | ||
|
|
76f08977c4 | ||
|
|
15cbf52da4 | ||
|
|
f4f84dffef | ||
|
|
6dfb07d7b9 | ||
|
|
d7ce62beaa | ||
|
|
0e2a184edb | ||
|
|
e6ee5ae996 | ||
|
|
f1836e659f | ||
|
|
99218d3abf | ||
|
|
b5ba0bef63 | ||
|
|
9cd1890faa | ||
|
|
472702a9d9 | ||
|
|
b38740e5b7 | ||
|
|
3040df3179 | ||
|
|
c66b357de6 | ||
|
|
0ffed3deff | ||
|
|
2f949a1182 | ||
|
|
4e2fae5145 | ||
|
|
2b58364c51 | ||
|
|
e3bf7c4b53 | ||
|
|
009ecfce96 | ||
|
|
d6075df356 | ||
|
|
ebd0a0c8ca | ||
|
|
b64fa65921 | ||
|
|
7d87c3cc3a |
11
.github/workflows/python-build-test.yml
vendored
11
.github/workflows/python-build-test.yml
vendored
@@ -16,7 +16,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Check out from Git
|
||||
uses: actions/checkout@v3
|
||||
- name: Get history and tags for SCM versioning to work
|
||||
run: |
|
||||
git fetch --prune --unshallow
|
||||
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
@@ -24,11 +29,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install ".[test,development,documentation]"
|
||||
python -m pip install ".[build,test,development,documentation]"
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pytest
|
||||
- name: Build
|
||||
run: |
|
||||
inv build
|
||||
inv mkdocs
|
||||
inv build.mkdocs
|
||||
|
||||
37
.github/workflows/python-publish.yml
vendored
Normal file
37
.github/workflows/python-publish.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out from Git
|
||||
uses: actions/checkout@v3
|
||||
- name: Get history and tags for SCM versioning to work
|
||||
run: |
|
||||
git fetch --prune --unshallow
|
||||
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install build
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
- name: Publish package to PyPI
|
||||
if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
33
README.md
33
README.md
@@ -9,7 +9,7 @@
|
||||
Bluetooth Stack for Apps, Emulation, Test and Experimentation
|
||||
=============================================================
|
||||
|
||||
<img src="docs/mkdocs/src/images/logo_framed.png" alt="drawing" width="200" height="200"/>
|
||||
<img src="docs/mkdocs/src/images/logo_framed.png" alt="Logo" width="200" height="200"/>
|
||||
|
||||
Bumble is a full-featured Bluetooth stack written entirely in Python. It supports most of the common Bluetooth Low Energy (BLE) and Bluetooth Classic (BR/EDR) protocols and profiles, including GAP, L2CAP, ATT, GATT, SMP, SDP, RFCOMM, HFP, HID and A2DP. The stack can be used with physical radios via HCI over USB, UART, or the Linux VHCI, as well as virtual radios, including the virtual Bluetooth support of the Android emulator.
|
||||
|
||||
@@ -21,6 +21,37 @@ or see the documentation source under `docs/mkdocs/src`, or build the static HTM
|
||||
mkdocs build -f docs/mkdocs/mkdocs.yml
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Getting Started
|
||||
|
||||
For a quick start to using Bumble, see the [Getting Started](docs/mkdocs/src/getting_started.md) guide.
|
||||
|
||||
### Dependencies
|
||||
|
||||
To install package dependencies needed to run the bumble examples execute the following commands:
|
||||
|
||||
```
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install ".[test,development,documentation]"
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
Refer to the [Examples Documentation](examples/README.md) for details on the included example scripts and how to run them.
|
||||
|
||||
The complete [list of Examples](/docs/mkdocs/src/examples/index.md), and what they are designed to do is here.
|
||||
|
||||
There are also a set of [Apps and Tools](docs/mkdocs/src/apps_and_tools/index.md) that show the utility of Bumble.
|
||||
|
||||
### Using Bumble With a USB Dongle
|
||||
|
||||
Bumble is easiest to use with a dedicated USB dongle.
|
||||
This is because internal Bluetooth interfaces tend to be locked down by the operating system.
|
||||
You can use the [usb_probe](/docs/mkdocs/src/apps_and_tools/usb_probe.md) tool (all platforms) or `lsusb` (Linux or macOS) to list the available USB devices on your system.
|
||||
|
||||
See the [USB Transport](/docs/mkdocs/src/transports/usb.md) page for details on how to refer to USB devices.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [Apache 2.0](LICENSE) License.
|
||||
|
||||
104
apps/console.py
104
apps/console.py
@@ -32,6 +32,7 @@ from bumble.core import UUID, AdvertisingData
|
||||
from bumble.device import Device, Connection, Peer
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.gatt import Characteristic
|
||||
|
||||
from prompt_toolkit import Application
|
||||
from prompt_toolkit.history import FileHistory
|
||||
@@ -121,6 +122,8 @@ class ConsoleApp:
|
||||
},
|
||||
'read': LiveCompleter(self.known_attributes),
|
||||
'write': LiveCompleter(self.known_attributes),
|
||||
'subscribe': LiveCompleter(self.known_attributes),
|
||||
'unsubscribe': LiveCompleter(self.known_attributes),
|
||||
'quit': None,
|
||||
'exit': None
|
||||
})
|
||||
@@ -330,9 +333,27 @@ class ConsoleApp:
|
||||
|
||||
await self.show_attributes(attributes)
|
||||
|
||||
def find_characteristic(self, param):
|
||||
parts = param.split('.')
|
||||
if len(parts) == 2:
|
||||
service_uuid = UUID(parts[0]) if parts[0] != '*' else None
|
||||
characteristic_uuid = UUID(parts[1])
|
||||
for service in self.connected_peer.services:
|
||||
if service_uuid is None or service.uuid == service_uuid:
|
||||
for characteristic in service.characteristics:
|
||||
if characteristic.uuid == characteristic_uuid:
|
||||
return characteristic
|
||||
elif len(parts) == 1:
|
||||
if parts[0].startswith('#'):
|
||||
attribute_handle = int(f'{parts[0][1:]}', 16)
|
||||
for service in self.connected_peer.services:
|
||||
for characteristic in service.characteristics:
|
||||
if characteristic.handle == attribute_handle:
|
||||
return characteristic
|
||||
|
||||
async def command(self, command):
|
||||
try:
|
||||
(keyword, *params) = command.strip().split(' ', 1)
|
||||
(keyword, *params) = command.strip().split(' ')
|
||||
keyword = keyword.replace('-', '_').lower()
|
||||
handler = getattr(self, f'do_{keyword}', None)
|
||||
if handler:
|
||||
@@ -441,26 +462,73 @@ class ConsoleApp:
|
||||
self.show_error('invalid syntax', 'expected read <attribute>')
|
||||
return
|
||||
|
||||
parts = params[0].split('.')
|
||||
if len(parts) == 2:
|
||||
service_uuid = UUID(parts[0]) if parts[0] != '*' else None
|
||||
characteristic_uuid = UUID(parts[1])
|
||||
for service in self.connected_peer.services:
|
||||
if service_uuid is None or service.uuid == service_uuid:
|
||||
for characteristic in service.characteristics:
|
||||
if characteristic.uuid == characteristic_uuid:
|
||||
value = await self.connected_peer.read_value(characteristic)
|
||||
self.append_to_output(f'VALUE: {value}')
|
||||
return
|
||||
characteristic = self.find_characteristic(params[0])
|
||||
if characteristic is None:
|
||||
self.show_error('no such characteristic')
|
||||
elif len(parts) == 1:
|
||||
if parts[0].startswith('#'):
|
||||
attribute_handle = int(f'{parts[0][1:]}', 16)
|
||||
value = await self.connected_peer.read_value(attribute_handle)
|
||||
self.append_to_output(f'VALUE: {value}')
|
||||
return
|
||||
return
|
||||
|
||||
value = await characteristic.read_value()
|
||||
self.append_to_output(f'VALUE: 0x{value.hex()}')
|
||||
|
||||
async def do_write(self, params):
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
|
||||
if len(params) != 2:
|
||||
self.show_error('invalid syntax', 'expected 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]) # try as integer
|
||||
except ValueError:
|
||||
value = str.encode(params[1]) # must be a string
|
||||
|
||||
characteristic = self.find_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
|
||||
await characteristic.write_value(value, with_response=with_response)
|
||||
|
||||
async def do_subscribe(self, params):
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
|
||||
if len(params) != 1:
|
||||
self.show_error('invalid syntax', 'expected subscribe <attribute>')
|
||||
return
|
||||
|
||||
characteristic = self.find_characteristic(params[0])
|
||||
if characteristic is None:
|
||||
self.show_error('no such characteristic')
|
||||
return
|
||||
|
||||
await characteristic.subscribe(
|
||||
lambda value: self.append_to_output(f"{characteristic} VALUE: 0x{value.hex()}"),
|
||||
)
|
||||
|
||||
async def do_unsubscribe(self, params):
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
|
||||
if len(params) != 1:
|
||||
self.show_error('invalid syntax', 'expected subscribe <attribute>')
|
||||
return
|
||||
|
||||
characteristic = self.find_characteristic(params[0])
|
||||
if characteristic is None:
|
||||
self.show_error('no such characteristic')
|
||||
return
|
||||
|
||||
await characteristic.unsubscribe()
|
||||
|
||||
async def do_exit(self, params):
|
||||
self.ui.exit()
|
||||
|
||||
105
apps/controller_info.py
Normal file
105
apps/controller_info.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import click
|
||||
from colors import color
|
||||
from bumble.company_ids import COMPANY_IDENTIFIERS
|
||||
|
||||
from bumble.core import name_or_number
|
||||
from bumble.hci import (
|
||||
map_null_terminated_utf8_string,
|
||||
HCI_LE_SUPPORTED_FEATURES_NAMES,
|
||||
HCI_SUCCESS,
|
||||
HCI_VERSION_NAMES,
|
||||
LMP_VERSION_NAMES,
|
||||
HCI_Command,
|
||||
HCI_Read_BD_ADDR_Command,
|
||||
HCI_READ_BD_ADDR_COMMAND,
|
||||
HCI_Read_Local_Name_Command,
|
||||
HCI_READ_LOCAL_NAME_COMMAND
|
||||
)
|
||||
from bumble.host import Host
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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:
|
||||
print()
|
||||
print(color('Classic Address:', 'yellow'), response.return_parameters.bd_addr)
|
||||
|
||||
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:
|
||||
print()
|
||||
print(color('Local Name:', 'yellow'), map_null_terminated_utf8_string(response.return_parameters.local_name))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def get_le_info(host):
|
||||
print()
|
||||
print(color('LE Features:', 'yellow'))
|
||||
for feature in host.supported_le_features:
|
||||
print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def async_main(transport):
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
|
||||
host = Host(hci_source, hci_sink)
|
||||
await host.reset()
|
||||
|
||||
# Print version
|
||||
print(color('Version:', 'yellow'))
|
||||
print(color(' Manufacturer: ', 'green'), name_or_number(COMPANY_IDENTIFIERS, host.local_version.company_identifier))
|
||||
print(color(' HCI Version: ', 'green'), name_or_number(HCI_VERSION_NAMES, host.local_version.hci_version))
|
||||
print(color(' HCI Subversion:', 'green'), host.local_version.hci_subversion)
|
||||
print(color(' LMP Version: ', 'green'), name_or_number(LMP_VERSION_NAMES, host.local_version.lmp_version))
|
||||
print(color(' LMP Subversion:', 'green'), host.local_version.lmp_subversion)
|
||||
|
||||
# Get the Classic info
|
||||
await get_classic_info(host)
|
||||
|
||||
# Get the LE info
|
||||
await get_le_info(host)
|
||||
|
||||
# Print the list of commands supported by the controller
|
||||
print()
|
||||
print(color('Supported Commands:', 'yellow'))
|
||||
for command in host.supported_commands:
|
||||
print(' ', HCI_Command.command_name(command))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@click.command()
|
||||
@click.argument('transport')
|
||||
def main(transport):
|
||||
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
asyncio.run(async_main(transport))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -90,7 +90,7 @@ class SnoopPacketReader:
|
||||
@click.command()
|
||||
@click.option('--format', type=click.Choice(['h4', 'snoop']), default='h4', help='Format of the input file')
|
||||
@click.argument('filename')
|
||||
def show(format, filename):
|
||||
def main(format, filename):
|
||||
input = open(filename, 'rb')
|
||||
if format == 'h4':
|
||||
packet_reader = PacketReader(input)
|
||||
@@ -117,4 +117,4 @@ def show(format, filename):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
show()
|
||||
main()
|
||||
|
||||
239
apps/usb_probe.py
Normal file
239
apps/usb_probe.py
Normal file
@@ -0,0 +1,239 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# This tool lists all the USB devices, with details about each device.
|
||||
# For each device, the different possible Bumble transport strings that can
|
||||
# refer to it are listed. If the device is known to be a Bluetooth HCI device,
|
||||
# its identifier is printed in reverse colors, and the transport names in cyan color.
|
||||
# For other devices, regardless of their type, the transport names are printed
|
||||
# in red. Whether that device is actually a Bluetooth device or not depends on
|
||||
# whether it is a Bluetooth device that uses a non-standard Class, or some other
|
||||
# type of device (there's no way to tell).
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import os
|
||||
import logging
|
||||
import sys
|
||||
import click
|
||||
import usb1
|
||||
from colors import color
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
USB_DEVICE_CLASS_DEVICE = 0x00
|
||||
USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0
|
||||
USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01
|
||||
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
|
||||
|
||||
USB_DEVICE_CLASSES = {
|
||||
0x00: 'Device',
|
||||
0x01: 'Audio',
|
||||
0x02: 'Communications and CDC Control',
|
||||
0x03: 'Human Interface Device',
|
||||
0x05: 'Physical',
|
||||
0x06: 'Still Imaging',
|
||||
0x07: 'Printer',
|
||||
0x08: 'Mass Storage',
|
||||
0x09: 'Hub',
|
||||
0x0A: 'CDC Data',
|
||||
0x0B: 'Smart Card',
|
||||
0x0D: 'Content Security',
|
||||
0x0E: 'Video',
|
||||
0x0F: 'Personal Healthcare',
|
||||
0x10: 'Audio/Video',
|
||||
0x11: 'Billboard',
|
||||
0x12: 'USB Type-C Bridge',
|
||||
0x3C: 'I3C',
|
||||
0xDC: 'Diagnostic',
|
||||
USB_DEVICE_CLASS_WIRELESS_CONTROLLER: (
|
||||
'Wireless Controller',
|
||||
{
|
||||
0x01: {
|
||||
0x01: 'Bluetooth',
|
||||
0x02: 'UWB',
|
||||
0x03: 'Remote NDIS',
|
||||
0x04: 'Bluetooth AMP'
|
||||
}
|
||||
}
|
||||
),
|
||||
0xEF: 'Miscellaneous',
|
||||
0xFE: 'Application Specific',
|
||||
0xFF: 'Vendor Specific'
|
||||
}
|
||||
|
||||
USB_ENDPOINT_IN = 0x80
|
||||
USB_ENDPOINT_TYPES = ['CONTROL', 'ISOCHRONOUS', 'BULK', 'INTERRUPT']
|
||||
|
||||
USB_BT_HCI_CLASS_TUPLE = (
|
||||
USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
|
||||
USB_DEVICE_SUBCLASS_RF_CONTROLLER,
|
||||
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def show_device_details(device):
|
||||
for configuration in device:
|
||||
print(f' Configuration {configuration.getConfigurationValue()}')
|
||||
for interface in configuration:
|
||||
for setting in interface:
|
||||
alternateSetting = setting.getAlternateSetting()
|
||||
suffix = f'/{alternateSetting}' if interface.getNumSettings() > 1 else ''
|
||||
(class_string, subclass_string) = get_class_info(
|
||||
setting.getClass(),
|
||||
setting.getSubClass(),
|
||||
setting.getProtocol()
|
||||
)
|
||||
details = f'({class_string}, {subclass_string})'
|
||||
print(f' Interface: {setting.getNumber()}{suffix} {details}')
|
||||
for endpoint in setting:
|
||||
endpoint_type = USB_ENDPOINT_TYPES[endpoint.getAttributes() & 3]
|
||||
endpoint_direction = 'OUT' if (endpoint.getAddress() & USB_ENDPOINT_IN == 0) else 'IN'
|
||||
print(f' Endpoint 0x{endpoint.getAddress():02X}: {endpoint_type} {endpoint_direction}')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def get_class_info(cls, subclass, protocol):
|
||||
class_info = USB_DEVICE_CLASSES.get(cls)
|
||||
protocol_string = ''
|
||||
if class_info is None:
|
||||
class_string = f'0x{cls:02X}'
|
||||
else:
|
||||
if type(class_info) is tuple:
|
||||
class_string = class_info[0]
|
||||
subclass_info = class_info[1].get(subclass)
|
||||
if subclass_info:
|
||||
protocol_string = subclass_info.get(protocol)
|
||||
if protocol_string is not None:
|
||||
protocol_string = f' [{protocol_string}]'
|
||||
|
||||
else:
|
||||
class_string = class_info
|
||||
|
||||
subclass_string = f'{subclass}/{protocol}{protocol_string}'
|
||||
|
||||
return (class_string, subclass_string)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def is_bluetooth_hci(device):
|
||||
# Check if the device class indicates a match
|
||||
if (device.getDeviceClass(), device.getDeviceSubClass(), device.getDeviceProtocol()) == USB_BT_HCI_CLASS_TUPLE:
|
||||
return True
|
||||
|
||||
# If the device class is 'Device', look for a matching interface
|
||||
if device.getDeviceClass() == USB_DEVICE_CLASS_DEVICE:
|
||||
for configuration in device:
|
||||
for interface in configuration:
|
||||
for setting in interface:
|
||||
if (setting.getClass(), setting.getSubClass(), setting.getProtocol()) == USB_BT_HCI_CLASS_TUPLE:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@click.command()
|
||||
@click.option('--verbose', is_flag=True, default=False, help='Print more details')
|
||||
def main(verbose):
|
||||
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
||||
|
||||
with usb1.USBContext() as context:
|
||||
bluetooth_device_count = 0
|
||||
devices = {}
|
||||
|
||||
for device in context.getDeviceIterator(skip_on_error=True):
|
||||
device_class = device.getDeviceClass()
|
||||
device_subclass = device.getDeviceSubClass()
|
||||
device_protocol = device.getDeviceProtocol()
|
||||
|
||||
device_id = (device.getVendorID(), device.getProductID())
|
||||
|
||||
(device_class_string, device_subclass_string) = get_class_info(
|
||||
device_class,
|
||||
device_subclass,
|
||||
device_protocol
|
||||
)
|
||||
|
||||
try:
|
||||
device_serial_number = device.getSerialNumber()
|
||||
except usb1.USBError:
|
||||
device_serial_number = None
|
||||
|
||||
try:
|
||||
device_manufacturer = device.getManufacturer()
|
||||
except usb1.USBError:
|
||||
device_manufacturer = None
|
||||
|
||||
try:
|
||||
device_product = device.getProduct()
|
||||
except usb1.USBError:
|
||||
device_product = None
|
||||
|
||||
device_is_bluetooth_hci = is_bluetooth_hci(device)
|
||||
if device_is_bluetooth_hci:
|
||||
bluetooth_device_count += 1
|
||||
fg_color = 'black'
|
||||
bg_color = 'yellow'
|
||||
else:
|
||||
fg_color = 'yellow'
|
||||
bg_color = 'black'
|
||||
|
||||
# Compute the different ways this can be referenced as a Bumble transport
|
||||
bumble_transport_names = []
|
||||
basic_transport_name = f'usb:{device.getVendorID():04X}:{device.getProductID():04X}'
|
||||
|
||||
if device_is_bluetooth_hci:
|
||||
bumble_transport_names.append(f'usb:{bluetooth_device_count - 1}')
|
||||
|
||||
if device_id not in devices:
|
||||
bumble_transport_names.append(basic_transport_name)
|
||||
else:
|
||||
bumble_transport_names.append(f'{basic_transport_name}#{len(devices[device_id])}')
|
||||
|
||||
if device_serial_number is not None:
|
||||
if device_id not in devices or device_serial_number not in devices[device_id]:
|
||||
bumble_transport_names.append(f'{basic_transport_name}/{device_serial_number}')
|
||||
|
||||
# Print the results
|
||||
print(color(f'ID {device.getVendorID():04X}:{device.getProductID():04X}', fg=fg_color, bg=bg_color))
|
||||
if bumble_transport_names:
|
||||
print(color(' Bumble Transport Names:', 'blue'), ' or '.join(color(x, 'cyan' if device_is_bluetooth_hci else 'red') for x in bumble_transport_names))
|
||||
print(color(' Bus/Device: ', 'green'), f'{device.getBusNumber():03}/{device.getDeviceAddress():03}')
|
||||
print(color(' Class: ', 'green'), device_class_string)
|
||||
print(color(' Subclass/Protocol: ', 'green'), device_subclass_string)
|
||||
if device_serial_number is not None:
|
||||
print(color(' Serial: ', 'green'), device_serial_number)
|
||||
if device_manufacturer is not None:
|
||||
print(color(' Manufacturer: ', 'green'), device_manufacturer)
|
||||
if device_product is not None:
|
||||
print(color(' Product: ', 'green'), device_product)
|
||||
|
||||
if verbose:
|
||||
show_device_details(device)
|
||||
|
||||
print()
|
||||
|
||||
devices.setdefault(device_id, []).append(device_serial_number)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -700,16 +700,26 @@ class Attribute(EventEmitter):
|
||||
else:
|
||||
self.value = value
|
||||
|
||||
def encode_value(self, value):
|
||||
return value
|
||||
|
||||
def decode_value(self, value_bytes):
|
||||
return value_bytes
|
||||
|
||||
def read_value(self, connection):
|
||||
if read := getattr(self.value, 'read', None):
|
||||
try:
|
||||
return read(connection)
|
||||
value = read(connection)
|
||||
except ATT_Error as error:
|
||||
raise ATT_Error(error_code=error.error_code, att_handle=self.handle)
|
||||
else:
|
||||
return self.value
|
||||
value = self.value
|
||||
|
||||
return self.encode_value(value)
|
||||
|
||||
def write_value(self, connection, value_bytes):
|
||||
value = self.decode_value(value_bytes)
|
||||
|
||||
def write_value(self, connection, value):
|
||||
if write := getattr(self.value, 'write', None):
|
||||
try:
|
||||
write(connection, value)
|
||||
@@ -721,7 +731,11 @@ class Attribute(EventEmitter):
|
||||
self.emit('write', connection, value)
|
||||
|
||||
def __repr__(self):
|
||||
if len(self.value) > 0:
|
||||
if type(self.value) is bytes:
|
||||
value_str = self.value.hex()
|
||||
else:
|
||||
value_str = str(self.value)
|
||||
if value_str:
|
||||
value_string = f', value={self.value.hex()}'
|
||||
else:
|
||||
value_string = ''
|
||||
|
||||
@@ -77,7 +77,7 @@ class Controller:
|
||||
self.le_features = bytes.fromhex('ff49010000000000')
|
||||
self.le_states = bytes.fromhex('ffff3fffff030000')
|
||||
self.avertising_channel_tx_power = 0
|
||||
self.white_list_size = 8
|
||||
self.filter_accept_list_size = 8
|
||||
self.resolving_list_size = 8
|
||||
self.supported_max_tx_octets = 27
|
||||
self.supported_max_tx_time = 10000 # microseconds
|
||||
@@ -731,27 +731,27 @@ class Controller:
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_read_white_list_size_command(self, command):
|
||||
def on_hci_le_read_filter_accept_list_size_command(self, command):
|
||||
'''
|
||||
See Bluetooth spec Vol 2, Part E - 7.8.14 LE Read White List Size Command
|
||||
See Bluetooth spec Vol 2, Part E - 7.8.14 LE Read Filter Accept List Size Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS, self.white_list_size])
|
||||
return bytes([HCI_SUCCESS, self.filter_accept_list_size])
|
||||
|
||||
def on_hci_le_clear_white_list_command(self, command):
|
||||
def on_hci_le_clear_filter_accept_list_command(self, command):
|
||||
'''
|
||||
See Bluetooth spec Vol 2, Part E - 7.8.15 LE Clear White List Command
|
||||
See Bluetooth spec Vol 2, Part E - 7.8.15 LE Clear Filter Accept List Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_add_device_to_white_list_command(self, command):
|
||||
def on_hci_le_add_device_to_filter_accept_list_command(self, command):
|
||||
'''
|
||||
See Bluetooth spec Vol 2, Part E - 7.8.16 LE Add Device To White List Command
|
||||
See Bluetooth spec Vol 2, Part E - 7.8.16 LE Add Device To Filter Accept List Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_remove_device_from_white_list_command(self, command):
|
||||
def on_hci_le_remove_device_from_filter_accept_list_command(self, command):
|
||||
'''
|
||||
See Bluetooth spec Vol 2, Part E - 7.8.17 LE Remove Device From White List Command
|
||||
See Bluetooth spec Vol 2, Part E - 7.8.17 LE Remove Device From Filter Accept List Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
@@ -780,9 +780,9 @@ class Controller:
|
||||
'''
|
||||
return bytes([HCI_SUCCESS]) + struct.pack('Q', random.randint(0, 1 << 64))
|
||||
|
||||
def on_hci_le_start_encryption_command(self, command):
|
||||
def on_hci_le_enable_encryption_command(self, command):
|
||||
'''
|
||||
See Bluetooth spec Vol 2, Part E - 7.8.24 LE Start Encryption Command
|
||||
See Bluetooth spec Vol 2, Part E - 7.8.24 LE Enable Encryption Command
|
||||
'''
|
||||
|
||||
# Check the parameters
|
||||
|
||||
@@ -227,3 +227,17 @@ def g2(u, v, x, y):
|
||||
aes_cmac(bytes(reversed(u)) + bytes(reversed(v)) + bytes(reversed(y)), bytes(reversed(x)))[-4:],
|
||||
byteorder='big'
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def h6(w, key_id):
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.10 Link key conversion function h6
|
||||
'''
|
||||
return aes_cmac(key_id, w)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def h7(salt, w):
|
||||
'''
|
||||
See Bluetooth spec, Vol 3, Part H - 2.2.11 Link key conversion function h7
|
||||
'''
|
||||
return aes_cmac(w, salt)
|
||||
|
||||
147
bumble/device.py
147
bumble/device.py
@@ -18,6 +18,7 @@
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager, AsyncExitStack
|
||||
|
||||
from .hci import *
|
||||
from .host import Host
|
||||
@@ -122,6 +123,9 @@ class Peer:
|
||||
async def subscribe(self, characteristic, subscriber=None):
|
||||
return await self.gatt_client.subscribe(characteristic, subscriber)
|
||||
|
||||
async def unsubscribe(self, characteristic, subscriber=None):
|
||||
return await self.gatt_client.unsubscribe(characteristic, subscriber)
|
||||
|
||||
async def read_value(self, attribute):
|
||||
return await self.gatt_client.read_value(attribute)
|
||||
|
||||
@@ -148,10 +152,24 @@ class Peer:
|
||||
await service.discover_characteristics()
|
||||
return self.create_service_proxy(proxy_class)
|
||||
|
||||
async def sustain(self, timeout=None):
|
||||
await self.connection.sustain(timeout)
|
||||
|
||||
# [Classic only]
|
||||
async def request_name(self):
|
||||
return await self.connection.request_remote_name()
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.discover_services()
|
||||
for service in self.services:
|
||||
await self.discover_characteristics()
|
||||
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
pass
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.connection.peer_address} as {self.connection.role_name}'
|
||||
|
||||
@@ -232,6 +250,21 @@ class Connection(CompositeEventEmitter):
|
||||
async def encrypt(self):
|
||||
return await self.device.encrypt(self)
|
||||
|
||||
async def sustain(self, timeout=None):
|
||||
""" Idles the current task waiting for a disconnect or timeout """
|
||||
|
||||
abort = asyncio.get_running_loop().create_future()
|
||||
self.on('disconnection', abort.set_result)
|
||||
self.on('disconnection_failure', abort.set_exception)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(abort, timeout)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
self.remove_listener('disconnection', abort.set_result)
|
||||
self.remove_listener('disconnection_failure', abort.set_exception)
|
||||
|
||||
async def update_parameters(
|
||||
self,
|
||||
conn_interval_min,
|
||||
@@ -251,6 +284,18 @@ class Connection(CompositeEventEmitter):
|
||||
async def request_remote_name(self):
|
||||
return await self.device.request_remote_name(self)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
if exc_type is None:
|
||||
try:
|
||||
await self.disconnect()
|
||||
except HCI_StatusError as e:
|
||||
# Invalid parameter means the connection is no longer valid
|
||||
if e.error_code != HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR:
|
||||
raise
|
||||
|
||||
def __str__(self):
|
||||
return f'Connection(handle=0x{self.handle:04X}, role={self.role_name}, address={self.peer_address})'
|
||||
|
||||
@@ -265,6 +310,13 @@ class DeviceConfiguration:
|
||||
self.scan_response_data = DEVICE_DEFAULT_SCAN_RESPONSE_DATA
|
||||
self.advertising_interval_min = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
||||
self.advertising_interval_max = DEVICE_DEFAULT_ADVERTISING_INTERVAL
|
||||
self.le_enabled = True
|
||||
# LE host enable 2nd parameter
|
||||
self.le_simultaneous_enabled = True
|
||||
self.classic_sc_enabled = True
|
||||
self.classic_ssp_enabled = True
|
||||
self.connectable = True
|
||||
self.discoverable = True
|
||||
self.advertising_data = bytes(
|
||||
AdvertisingData([(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))])
|
||||
)
|
||||
@@ -278,7 +330,13 @@ class DeviceConfiguration:
|
||||
self.class_of_device = config.get('class_of_device', self.class_of_device)
|
||||
self.advertising_interval_min = config.get('advertising_interval', self.advertising_interval_min)
|
||||
self.advertising_interval_max = self.advertising_interval_min
|
||||
self.keystore = config.get('keystore')
|
||||
self.keystore = config.get('keystore')
|
||||
self.le_enabled = config.get('le_enabled', self.le_enabled)
|
||||
self.le_simultaneous_enabled = config.get('le_simultaneous_enabled', self.le_simultaneous_enabled)
|
||||
self.classic_sc_enabled = config.get('classic_sc_enabled', self.classic_sc_enabled)
|
||||
self.classic_ssp_enabled = config.get('classic_ssp_enabled', self.classic_ssp_enabled)
|
||||
self.connectable = config.get('connectable', self.connectable)
|
||||
self.discoverable = config.get('discoverable', self.discoverable)
|
||||
|
||||
# Load or synthesize an IRK
|
||||
irk = config.get('irk')
|
||||
@@ -305,6 +363,7 @@ class DeviceConfiguration:
|
||||
# within a class requires unnecessarily complicated acrobatics)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Decorator that converts the first argument from a connection handle to a connection
|
||||
def with_connection_from_handle(function):
|
||||
@functools.wraps(function)
|
||||
@@ -391,17 +450,15 @@ class Device(CompositeEventEmitter):
|
||||
self.command_timeout = 10 # seconds
|
||||
self.gatt_server = gatt_server.Server(self)
|
||||
self.sdp_server = sdp.Server(self)
|
||||
self.l2cap_channel_manager = l2cap.ChannelManager()
|
||||
self.l2cap_channel_manager = l2cap.ChannelManager(
|
||||
[l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS])
|
||||
self.advertisement_data = {}
|
||||
self.scanning = False
|
||||
self.discovering = False
|
||||
self.connecting = False
|
||||
self.disconnecting = False
|
||||
self.connections = {} # Connections, by connection handle
|
||||
self.le_enabled = True
|
||||
self.classic_enabled = False
|
||||
self.discoverable = False
|
||||
self.connectable = False
|
||||
self.inquiry_response = None
|
||||
self.address_resolver = None
|
||||
|
||||
@@ -418,6 +475,12 @@ class Device(CompositeEventEmitter):
|
||||
self.advertising_interval_max = config.advertising_interval_max
|
||||
self.keystore = keys.KeyStore.create_for_device(config)
|
||||
self.irk = config.irk
|
||||
self.le_enabled = config.le_enabled
|
||||
self.le_simultaneous_enabled = config.le_simultaneous_enabled
|
||||
self.classic_ssp_enabled = config.classic_ssp_enabled
|
||||
self.classic_sc_enabled = config.classic_sc_enabled
|
||||
self.discoverable = config.discoverable
|
||||
self.connectable = config.connectable
|
||||
|
||||
# If a name is passed, override the name from the config
|
||||
if name:
|
||||
@@ -432,6 +495,10 @@ class Device(CompositeEventEmitter):
|
||||
# Setup SMP
|
||||
# TODO: allow using a public address
|
||||
self.smp_manager = smp.Manager(self, self.random_address)
|
||||
self.l2cap_channel_manager.register_fixed_channel(
|
||||
smp.SMP_CID, self.on_smp_pdu)
|
||||
self.l2cap_channel_manager.register_fixed_channel(
|
||||
smp.SMP_BR_CID, self.on_smp_pdu)
|
||||
|
||||
# Register the SDP server with the L2CAP Channel Manager
|
||||
self.sdp_server.register(self.l2cap_channel_manager)
|
||||
@@ -439,6 +506,7 @@ class Device(CompositeEventEmitter):
|
||||
# Add a GAP Service if requested
|
||||
if generic_access_service:
|
||||
self.gatt_server.add_service(GenericAccessService(self.name))
|
||||
self.l2cap_channel_manager.register_fixed_channel(ATT_CID, self.on_gatt_pdu)
|
||||
|
||||
# Forward some events
|
||||
setup_event_forwarding(self.gatt_server, self, 'characteristic_subscription')
|
||||
@@ -483,10 +551,11 @@ class Device(CompositeEventEmitter):
|
||||
if connection := self.connections.get(connection_handle):
|
||||
return connection
|
||||
|
||||
def find_connection_by_bd_addr(self, bd_addr):
|
||||
def find_connection_by_bd_addr(self, bd_addr, transport=None):
|
||||
for connection in self.connections.values():
|
||||
if connection.peer_address == bd_addr:
|
||||
return connection
|
||||
if connection.peer_address.get_bytes() == bd_addr.get_bytes():
|
||||
if transport is None or connection.transport == transport:
|
||||
return connection
|
||||
|
||||
def register_l2cap_server(self, psm, server):
|
||||
self.l2cap_channel_manager.register_server(psm, server)
|
||||
@@ -515,6 +584,12 @@ class Device(CompositeEventEmitter):
|
||||
logger.debug(color(f'BD_ADDR: {response.return_parameters.bd_addr}', 'yellow'))
|
||||
self.public_address = response.return_parameters.bd_addr
|
||||
|
||||
if self.host.supports_command(HCI_WRITE_LE_HOST_SUPPORT_COMMAND):
|
||||
await self.send_command(HCI_Write_LE_Host_Support_Command(
|
||||
le_supported_host = int(self.le_enabled),
|
||||
simultaneous_le_host = int(self.le_simultaneous_enabled),
|
||||
))
|
||||
|
||||
if self.le_enabled:
|
||||
# Set the controller address
|
||||
await self.send_command(HCI_LE_Set_Random_Address_Command(
|
||||
@@ -552,8 +627,15 @@ class Device(CompositeEventEmitter):
|
||||
HCI_Write_Class_Of_Device_Command(class_of_device = self.class_of_device)
|
||||
)
|
||||
await self.send_command(
|
||||
HCI_Write_Simple_Pairing_Mode_Command(simple_pairing_mode = 0x01)
|
||||
HCI_Write_Simple_Pairing_Mode_Command(
|
||||
simple_pairing_mode=int(self.classic_ssp_enabled))
|
||||
)
|
||||
await self.send_command(
|
||||
HCI_Write_Secure_Connections_Host_Support_Command(
|
||||
secure_connections_host_support=int(self.classic_sc_enabled))
|
||||
)
|
||||
await self.set_connectable(self.connectable)
|
||||
await self.set_discoverable(self.discoverable)
|
||||
|
||||
# Let the SMP manager know about the address
|
||||
# TODO: allow using a public address
|
||||
@@ -681,7 +763,7 @@ class Device(CompositeEventEmitter):
|
||||
))
|
||||
if response.status != HCI_Command_Status_Event.PENDING:
|
||||
self.discovering = False
|
||||
raise RuntimeError(f'HCI_Inquiry command failed: {HCI_Constant.status_name(response.status)} ({response.status})')
|
||||
raise HCI_StatusError(response)
|
||||
|
||||
self.discovering = True
|
||||
|
||||
@@ -762,7 +844,7 @@ class Device(CompositeEventEmitter):
|
||||
try:
|
||||
peer_address = Address(peer_address)
|
||||
except ValueError:
|
||||
# If the address is not parssable, assume it is a name instead
|
||||
# If the address is not parsable, assume it is a name instead
|
||||
logger.debug('looking for peer by name')
|
||||
peer_address = await self.find_peer_by_name(peer_address, transport)
|
||||
|
||||
@@ -801,16 +883,25 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
try:
|
||||
if result.status != HCI_Command_Status_Event.PENDING:
|
||||
raise RuntimeError(f'HCI_LE_Create_Connection_Command failed: {HCI_Constant.status_name(result.status)} ({result.status})')
|
||||
raise HCI_StatusError(result)
|
||||
|
||||
# Wait for the connection process to complete
|
||||
self.connecting = True
|
||||
return await pending_connection
|
||||
|
||||
finally:
|
||||
self.remove_listener('connection', pending_connection.set_result)
|
||||
self.remove_listener('connection_failure', pending_connection.set_exception)
|
||||
self.connecting = False
|
||||
|
||||
@asynccontextmanager
|
||||
async def connect_as_gatt(self, peer_address):
|
||||
async with AsyncExitStack() as stack:
|
||||
connection = await stack.enter_async_context(await self.connect(peer_address))
|
||||
peer = await stack.enter_async_context(Peer(connection))
|
||||
|
||||
yield peer
|
||||
|
||||
@property
|
||||
def is_connecting(self):
|
||||
return self.connecting
|
||||
@@ -835,7 +926,7 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
try:
|
||||
if result.status != HCI_Command_Status_Event.PENDING:
|
||||
raise RuntimeError(f'HCI_Disconnect_Command failed: {HCI_Constant.status_name(result.status)} ({result.status})')
|
||||
raise HCI_StatusError(result)
|
||||
|
||||
# Wait for the disconnection process to complete
|
||||
self.disconnecting = True
|
||||
@@ -987,7 +1078,7 @@ class Device(CompositeEventEmitter):
|
||||
)
|
||||
if result.status != HCI_COMMAND_STATUS_PENDING:
|
||||
logger.warn(f'HCI_Authentication_Requested_Command failed: {HCI_Constant.error_name(result.status)}')
|
||||
raise HCI_Error(result.status)
|
||||
raise HCI_StatusError(result)
|
||||
|
||||
# Wait for the authentication to complete
|
||||
await pending_authentication
|
||||
@@ -1034,7 +1125,7 @@ class Device(CompositeEventEmitter):
|
||||
raise InvalidStateError('only centrals can start encryption')
|
||||
|
||||
result = await self.send_command(
|
||||
HCI_LE_Start_Encryption_Command(
|
||||
HCI_LE_Enable_Encryption_Command(
|
||||
connection_handle = connection.handle,
|
||||
random_number = rand,
|
||||
encrypted_diversifier = ediv,
|
||||
@@ -1043,8 +1134,8 @@ class Device(CompositeEventEmitter):
|
||||
)
|
||||
|
||||
if result.status != HCI_COMMAND_STATUS_PENDING:
|
||||
logger.warn(f'HCI_LE_Start_Encryption_Command failed: {HCI_Constant.error_name(result.status)}')
|
||||
raise HCI_Error(result.status)
|
||||
logger.warn(f'HCI_LE_Enable_Encryption_Command failed: {HCI_Constant.error_name(result.status)}')
|
||||
raise HCI_StatusError(result)
|
||||
else:
|
||||
result = await self.send_command(
|
||||
HCI_Set_Connection_Encryption_Command(
|
||||
@@ -1055,7 +1146,7 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
if result.status != HCI_COMMAND_STATUS_PENDING:
|
||||
logger.warn(f'HCI_Set_Connection_Encryption_Command failed: {HCI_Constant.error_name(result.status)}')
|
||||
raise HCI_Error(result.status)
|
||||
raise HCI_StatusError(result)
|
||||
|
||||
# Wait for the result
|
||||
await pending_encryption
|
||||
@@ -1089,7 +1180,7 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
if result.status != HCI_COMMAND_STATUS_PENDING:
|
||||
logger.warn(f'HCI_Set_Connection_Encryption_Command failed: {HCI_Constant.error_name(result.status)}')
|
||||
raise HCI_Error(result.status)
|
||||
raise HCI_StatusError(result)
|
||||
|
||||
# Wait for the result
|
||||
return await pending_name
|
||||
@@ -1119,17 +1210,17 @@ class Device(CompositeEventEmitter):
|
||||
def add_services(self, services):
|
||||
self.gatt_server.add_services(services)
|
||||
|
||||
async def notify_subscriber(self, connection, attribute, force=False):
|
||||
await self.gatt_server.notify_subscriber(connection, attribute, force)
|
||||
async def notify_subscriber(self, connection, attribute, value=None, force=False):
|
||||
await self.gatt_server.notify_subscriber(connection, attribute, value, force)
|
||||
|
||||
async def notify_subscribers(self, attribute, force=False):
|
||||
await self.gatt_server.notify_subscribers(attribute, force)
|
||||
async def notify_subscribers(self, attribute, value=None, force=False):
|
||||
await self.gatt_server.notify_subscribers(attribute, value, force)
|
||||
|
||||
async def indicate_subscriber(self, connection, attribute, force=False):
|
||||
await self.gatt_server.indicate_subscriber(connection, attribute, force)
|
||||
async def indicate_subscriber(self, connection, attribute, value=None, force=False):
|
||||
await self.gatt_server.indicate_subscriber(connection, attribute, value, force)
|
||||
|
||||
async def indicate_subscribers(self, attribute):
|
||||
await self.gatt_server.indicate_subscribers(attribute)
|
||||
async def indicate_subscribers(self, attribute, value=None, force=False):
|
||||
await self.gatt_server.indicate_subscribers(attribute, value, force)
|
||||
|
||||
@host_event_handler
|
||||
def on_connection(self, connection_handle, transport, peer_address, peer_resolvable_address, role, connection_parameters):
|
||||
@@ -1419,7 +1510,6 @@ class Device(CompositeEventEmitter):
|
||||
def on_pairing_failure(self, connection, reason):
|
||||
connection.emit('pairing_failure', reason)
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
def on_gatt_pdu(self, connection, pdu):
|
||||
# Parse the L2CAP payload into an ATT PDU object
|
||||
@@ -1438,7 +1528,6 @@ class Device(CompositeEventEmitter):
|
||||
return
|
||||
connection.gatt_server.on_gatt_pdu(connection, att_pdu)
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
def on_smp_pdu(self, connection, pdu):
|
||||
self.smp_manager.on_smp_pdu(connection, pdu)
|
||||
|
||||
@@ -134,13 +134,21 @@ GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC = UUID.from_16_bits(0x2A2
|
||||
GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC = UUID.from_16_bits(0x2A2A, 'IEEE 11073-20601 Regulatory Certification Data List')
|
||||
GATT_PNP_ID_CHARACTERISTIC = UUID.from_16_bits(0x2A50, 'PnP ID')
|
||||
|
||||
# Human Interface Device
|
||||
# Human Interface Device Service
|
||||
GATT_HID_INFORMATION_CHARACTERISTIC = UUID.from_16_bits(0x2A4A, 'HID Information')
|
||||
GATT_REPORT_MAP_CHARACTERISTIC = UUID.from_16_bits(0x2A4B, 'Report Map')
|
||||
GATT_HID_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2A4C, 'HID Control Point')
|
||||
GATT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A4D, 'Report')
|
||||
GATT_PROTOCOL_MODE_CHARACTERISTIC = UUID.from_16_bits(0x2A4E, 'Protocol Mode')
|
||||
|
||||
# Heart Rate Service
|
||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC = UUID.from_16_bits(0x2A37, 'Heart Rate Measurement')
|
||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2A38, 'Body Sensor Location')
|
||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2A39, 'Heart Rate Control Point')
|
||||
|
||||
# Battery Service
|
||||
GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level')
|
||||
|
||||
# Misc
|
||||
GATT_DEVICE_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2A00, 'Device Name')
|
||||
GATT_APPEARANCE_CHARACTERISTIC = UUID.from_16_bits(0x2A01, 'Appearance')
|
||||
@@ -150,7 +158,6 @@ GATT_PERIPHERAL_PREFERRED_CONNECTION_PARAMETERS_CHARACTERISTIC = UUID.from_16_bi
|
||||
GATT_SERVICE_CHANGED_CHARACTERISTIC = UUID.from_16_bits(0x2A05, 'Service Changed')
|
||||
GATT_ALERT_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A06, 'Alert Level')
|
||||
GATT_TX_POWER_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A07, 'Tx Power Level')
|
||||
GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level')
|
||||
GATT_BOOT_KEYBOARD_INPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A22, 'Boot Keyboard Input Report')
|
||||
GATT_CURRENT_TIME_CHARACTERISTIC = UUID.from_16_bits(0x2A2B, 'Current Time')
|
||||
GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report')
|
||||
@@ -296,6 +303,7 @@ class CharacteristicAdapter:
|
||||
'''
|
||||
def __init__(self, characteristic):
|
||||
self.wrapped_characteristic = characteristic
|
||||
self.subscribers = {} # Map from subscriber to proxy subscriber
|
||||
|
||||
if (
|
||||
asyncio.iscoroutinefunction(characteristic.read_value) and
|
||||
@@ -310,9 +318,25 @@ class CharacteristicAdapter:
|
||||
if hasattr(self.wrapped_characteristic, 'subscribe'):
|
||||
self.subscribe = self.wrapped_subscribe
|
||||
|
||||
if hasattr(self.wrapped_characteristic, 'unsubscribe'):
|
||||
self.unsubscribe = self.wrapped_unsubscribe
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.wrapped_characteristic, name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name in {
|
||||
'wrapped_characteristic',
|
||||
'subscribers',
|
||||
'read_value',
|
||||
'write_value',
|
||||
'subscribe',
|
||||
'unsubscribe'
|
||||
}:
|
||||
super().__setattr__(name, value)
|
||||
else:
|
||||
setattr(self.wrapped_characteristic, name, value)
|
||||
|
||||
def read_encoded_value(self, connection):
|
||||
return self.encode_value(self.wrapped_characteristic.read_value(connection))
|
||||
|
||||
@@ -322,8 +346,11 @@ class CharacteristicAdapter:
|
||||
async def read_decoded_value(self):
|
||||
return self.decode_value(await self.wrapped_characteristic.read_value())
|
||||
|
||||
async def write_decoded_value(self, value):
|
||||
return await self.wrapped_characteristic.write_value(self.encode_value(value))
|
||||
async def write_decoded_value(self, value, with_response=False):
|
||||
return await self.wrapped_characteristic.write_value(
|
||||
self.encode_value(value),
|
||||
with_response
|
||||
)
|
||||
|
||||
def encode_value(self, value):
|
||||
return value
|
||||
@@ -332,23 +359,47 @@ class CharacteristicAdapter:
|
||||
return value
|
||||
|
||||
def wrapped_subscribe(self, subscriber=None):
|
||||
return self.wrapped_characteristic.subscribe(
|
||||
None if subscriber is None else lambda value: subscriber(self.decode_value(value))
|
||||
)
|
||||
if subscriber is not None:
|
||||
if subscriber in self.subscribers:
|
||||
# We already have a proxy subscriber
|
||||
subscriber = self.subscribers[subscriber]
|
||||
else:
|
||||
# Create and register a proxy that will decode the value
|
||||
original_subscriber = subscriber
|
||||
|
||||
def on_change(value):
|
||||
original_subscriber(self.decode_value(value))
|
||||
self.subscribers[subscriber] = on_change
|
||||
subscriber = on_change
|
||||
|
||||
return self.wrapped_characteristic.subscribe(subscriber)
|
||||
|
||||
def wrapped_unsubscribe(self, subscriber=None):
|
||||
if subscriber in self.subscribers:
|
||||
subscriber = self.subscribers.pop(subscriber)
|
||||
|
||||
return self.wrapped_characteristic.unsubscribe(subscriber)
|
||||
|
||||
def __str__(self):
|
||||
wrapped = str(self.wrapped_characteristic)
|
||||
return f'{self.__class__.__name__}({wrapped})'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class DelegatedCharacteristicAdapter(CharacteristicAdapter):
|
||||
def __init__(self, characteristic, encode, decode):
|
||||
'''
|
||||
Adapter that converts bytes values using an encode and a decode function.
|
||||
'''
|
||||
def __init__(self, characteristic, encode=None, decode=None):
|
||||
super().__init__(characteristic)
|
||||
self.encode = encode
|
||||
self.decode = decode
|
||||
|
||||
def encode_value(self, value):
|
||||
return self.encode(value)
|
||||
return self.encode(value) if self.encode else value
|
||||
|
||||
def decode_value(self, value):
|
||||
return self.decode(value)
|
||||
return self.decode(value) if self.decode else value
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -58,10 +58,16 @@ class AttributeProxy(EventEmitter):
|
||||
self.type = attribute_type
|
||||
|
||||
async def read_value(self, no_long_read=False):
|
||||
return await self.client.read_value(self.handle, no_long_read)
|
||||
return self.decode_value(await self.client.read_value(self.handle, no_long_read))
|
||||
|
||||
async def write_value(self, value, with_response=False):
|
||||
return await self.client.write_value(self.handle, value, with_response)
|
||||
return await self.client.write_value(self.handle, self.encode_value(value), with_response)
|
||||
|
||||
def encode_value(self, value):
|
||||
return value
|
||||
|
||||
def decode_value(self, value_bytes):
|
||||
return value_bytes
|
||||
|
||||
def __str__(self):
|
||||
return f'Attribute(handle=0x{self.handle:04X}, type={self.uuid})'
|
||||
@@ -98,6 +104,7 @@ class CharacteristicProxy(AttributeProxy):
|
||||
self.properties = properties
|
||||
self.descriptors = []
|
||||
self.descriptors_discovered = False
|
||||
self.subscribers = {} # Map from subscriber to proxy subscriber
|
||||
|
||||
def get_descriptor(self, descriptor_type):
|
||||
for descriptor in self.descriptors:
|
||||
@@ -108,8 +115,27 @@ class CharacteristicProxy(AttributeProxy):
|
||||
return await self.client.discover_descriptors(self)
|
||||
|
||||
async def subscribe(self, subscriber=None):
|
||||
if subscriber is not None:
|
||||
if subscriber in self.subscribers:
|
||||
# We already have a proxy subscriber
|
||||
subscriber = self.subscribers[subscriber]
|
||||
else:
|
||||
# Create and register a proxy that will decode the value
|
||||
original_subscriber = subscriber
|
||||
|
||||
def on_change(value):
|
||||
original_subscriber(self.decode_value(value))
|
||||
self.subscribers[subscriber] = on_change
|
||||
subscriber = on_change
|
||||
|
||||
return await self.client.subscribe(self, subscriber)
|
||||
|
||||
async def unsubscribe(self, subscriber=None):
|
||||
if subscriber in self.subscribers:
|
||||
subscriber = self.subscribers.pop(subscriber)
|
||||
|
||||
return await self.client.unsubscribe(self, subscriber)
|
||||
|
||||
def __str__(self):
|
||||
return f'Characteristic(handle=0x{self.handle:04X}, uuid={self.uuid}, properties={Characteristic.properties_as_string(self.properties)})'
|
||||
|
||||
@@ -137,7 +163,6 @@ class ProfileServiceProxy:
|
||||
class Client:
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
self.mtu = ATT_DEFAULT_MTU
|
||||
self.mtu_exchange_done = False
|
||||
self.request_semaphore = asyncio.Semaphore(1)
|
||||
self.pending_request = None
|
||||
@@ -159,8 +184,8 @@ class Client:
|
||||
# Wait until we can send (only one pending command at a time for the connection)
|
||||
response = None
|
||||
async with self.request_semaphore:
|
||||
assert(self.pending_request is None)
|
||||
assert(self.pending_response is None)
|
||||
assert self.pending_request is None
|
||||
assert self.pending_response is None
|
||||
|
||||
# Create a future value to hold the eventual response
|
||||
self.pending_response = asyncio.get_running_loop().create_future()
|
||||
@@ -191,7 +216,7 @@ class Client:
|
||||
|
||||
# We can only send one request per connection
|
||||
if self.mtu_exchange_done:
|
||||
return
|
||||
return self.connection.att_mtu
|
||||
|
||||
# Send the request
|
||||
self.mtu_exchange_done = True
|
||||
@@ -204,8 +229,10 @@ class Client:
|
||||
response
|
||||
)
|
||||
|
||||
self.mtu = max(ATT_DEFAULT_MTU, response.server_rx_mtu)
|
||||
return self.mtu
|
||||
# Compute the final MTU
|
||||
self.connection.att_mtu = min(mtu, response.server_rx_mtu)
|
||||
|
||||
return self.connection.att_mtu
|
||||
|
||||
def get_services_by_uuid(self, uuid):
|
||||
return [service for service in self.services if service.uuid == uuid]
|
||||
@@ -544,10 +571,42 @@ class Client:
|
||||
for subscriber_set in subscriber_sets:
|
||||
if subscriber is not None:
|
||||
subscriber_set.add(subscriber)
|
||||
subscriber_set.add(lambda value: characteristic.emit('update', self.connection, value))
|
||||
# Add the characteristic as a subscriber, which will result in the characteristic
|
||||
# emitting an 'update' event when a notification or indication is received
|
||||
subscriber_set.add(characteristic)
|
||||
|
||||
await self.write_value(cccd, struct.pack('<H', bits), with_response=True)
|
||||
|
||||
async def unsubscribe(self, characteristic, subscriber=None):
|
||||
# If we haven't already discovered the descriptors for this characteristic, do it now
|
||||
if not characteristic.descriptors_discovered:
|
||||
await self.discover_descriptors(characteristic)
|
||||
|
||||
# Look for the CCCD descriptor
|
||||
cccd = characteristic.get_descriptor(GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR)
|
||||
if not cccd:
|
||||
logger.warning('unsubscribing from characteristic with no CCCD descriptor')
|
||||
return
|
||||
|
||||
if subscriber is not None:
|
||||
# Remove matching subscriber from subscriber sets
|
||||
for subscriber_set in (self.notification_subscribers, self.indication_subscribers):
|
||||
subscribers = subscriber_set.get(characteristic.handle, [])
|
||||
if subscriber in subscribers:
|
||||
subscribers.remove(subscriber)
|
||||
|
||||
# Cleanup if we removed the last one
|
||||
if not subscribers:
|
||||
subscriber_set.remove(characteristic.handle)
|
||||
else:
|
||||
# Remove all subscribers for this attribute from the sets!
|
||||
self.notification_subscribers.pop(characteristic.handle, None)
|
||||
self.indication_subscribers.pop(characteristic.handle, None)
|
||||
|
||||
if not self.notification_subscribers and not self.indication_subscribers:
|
||||
# No more subscribers left
|
||||
await self.write_value(cccd, b'\x00\x00', with_response=True)
|
||||
|
||||
async def read_value(self, attribute, no_long_read=False):
|
||||
'''
|
||||
See Vol 3, Part G - 4.8.1 Read Characteristic Value
|
||||
@@ -571,7 +630,7 @@ class Client:
|
||||
# If the value is the max size for the MTU, try to read more unless the caller
|
||||
# specifically asked not to do that
|
||||
attribute_value = response.attribute_value
|
||||
if not no_long_read and len(attribute_value) == self.mtu - 1:
|
||||
if not no_long_read and len(attribute_value) == self.connection.att_mtu - 1:
|
||||
logger.debug('using READ BLOB to get the rest of the value')
|
||||
offset = len(attribute_value)
|
||||
while True:
|
||||
@@ -593,7 +652,7 @@ class Client:
|
||||
part = response.part_attribute_value
|
||||
attribute_value += part
|
||||
|
||||
if len(part) < self.mtu - 1:
|
||||
if len(part) < self.connection.att_mtu - 1:
|
||||
break
|
||||
|
||||
offset += len(part)
|
||||
@@ -714,7 +773,10 @@ class Client:
|
||||
if not subscribers:
|
||||
logger.warning('!!! received notification with no subscriber')
|
||||
for subscriber in subscribers:
|
||||
subscriber(notification.attribute_value)
|
||||
if callable(subscriber):
|
||||
subscriber(notification.attribute_value)
|
||||
else:
|
||||
subscriber.emit('update', notification.attribute_value)
|
||||
|
||||
def on_att_handle_value_indication(self, indication):
|
||||
# Call all subscribers
|
||||
@@ -722,7 +784,10 @@ class Client:
|
||||
if not subscribers:
|
||||
logger.warning('!!! received indication with no subscriber')
|
||||
for subscriber in subscribers:
|
||||
subscriber(indication.attribute_value)
|
||||
if callable(subscriber):
|
||||
subscriber(indication.attribute_value)
|
||||
else:
|
||||
subscriber.emit('update', indication.attribute_value)
|
||||
|
||||
# Confirm that we received the indication
|
||||
self.send_confirmation(ATT_Handle_Value_Confirmation())
|
||||
|
||||
@@ -40,6 +40,12 @@ from .gatt import *
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
GATT_SERVER_DEFAULT_MAX_MTU = 517
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# GATT Server
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -49,9 +55,8 @@ class Server(EventEmitter):
|
||||
self.device = device
|
||||
self.attributes = [] # Attributes, ordered by increasing handle values
|
||||
self.attributes_by_handle = {} # Map for fast attribute access by handle
|
||||
self.max_mtu = 23 # FIXME: 517 # The max MTU we're willing to negotiate
|
||||
self.max_mtu = GATT_SERVER_DEFAULT_MAX_MTU # The max MTU we're willing to negotiate
|
||||
self.subscribers = {} # Map of subscriber states by connection handle and attribute handle
|
||||
self.mtus = {} # Map of ATT MTU values by connection handle
|
||||
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
|
||||
self.pending_confirmations = defaultdict(lambda: None)
|
||||
|
||||
@@ -169,7 +174,7 @@ class Server(EventEmitter):
|
||||
logger.debug(f'GATT Response from server: [0x{connection.handle:04X}] {response}')
|
||||
self.send_gatt_pdu(connection.handle, response.to_bytes())
|
||||
|
||||
async def notify_subscriber(self, connection, attribute, force=False):
|
||||
async def notify_subscriber(self, connection, attribute, value=None, force=False):
|
||||
# Check if there's a subscriber
|
||||
if not force:
|
||||
subscribers = self.subscribers.get(connection.handle)
|
||||
@@ -184,13 +189,12 @@ class Server(EventEmitter):
|
||||
logger.debug(f'not notifying, cccd={cccd.hex()}')
|
||||
return
|
||||
|
||||
# Get the value
|
||||
value = attribute.read_value(connection)
|
||||
# Get or encode the value
|
||||
value = attribute.read_value(connection) if value is None else attribute.encode_value(value)
|
||||
|
||||
# Truncate if needed
|
||||
mtu = self.get_mtu(connection)
|
||||
if len(value) > mtu - 3:
|
||||
value = value[:mtu - 3]
|
||||
if len(value) > connection.att_mtu - 3:
|
||||
value = value[:connection.att_mtu - 3]
|
||||
|
||||
# Notify
|
||||
notification = ATT_Handle_Value_Notification(
|
||||
@@ -198,27 +202,9 @@ class Server(EventEmitter):
|
||||
attribute_value = value
|
||||
)
|
||||
logger.debug(f'GATT Notify from server: [0x{connection.handle:04X}] {notification}')
|
||||
self.send_gatt_pdu(connection.handle, notification.to_bytes())
|
||||
self.send_gatt_pdu(connection.handle, bytes(notification))
|
||||
|
||||
async def notify_subscribers(self, attribute, force=False):
|
||||
# Get all the connections for which there's at least one subscription
|
||||
connections = [
|
||||
connection for connection in [
|
||||
self.device.lookup_connection(connection_handle)
|
||||
for (connection_handle, subscribers) in self.subscribers.items()
|
||||
if force or subscribers.get(attribute.handle)
|
||||
]
|
||||
if connection is not None
|
||||
]
|
||||
|
||||
# Notify for each connection
|
||||
if connections:
|
||||
await asyncio.wait([
|
||||
self.notify_subscriber(connection, attribute, force)
|
||||
for connection in connections
|
||||
])
|
||||
|
||||
async def indicate_subscriber(self, connection, attribute, force=False):
|
||||
async def indicate_subscriber(self, connection, attribute, value=None, force=False):
|
||||
# Check if there's a subscriber
|
||||
if not force:
|
||||
subscribers = self.subscribers.get(connection.handle)
|
||||
@@ -233,13 +219,12 @@ class Server(EventEmitter):
|
||||
logger.debug(f'not indicating, cccd={cccd.hex()}')
|
||||
return
|
||||
|
||||
# Get the value
|
||||
value = attribute.read_value(connection)
|
||||
# Get or encode the value
|
||||
value = attribute.read_value(connection) if value is None else attribute.encode_value(value)
|
||||
|
||||
# Truncate if needed
|
||||
mtu = self.get_mtu(connection)
|
||||
if len(value) > mtu - 3:
|
||||
value = value[:mtu - 3]
|
||||
if len(value) > connection.att_mtu - 3:
|
||||
value = value[:connection.att_mtu - 3]
|
||||
|
||||
# Indicate
|
||||
indication = ATT_Handle_Value_Indication(
|
||||
@@ -264,27 +249,32 @@ class Server(EventEmitter):
|
||||
finally:
|
||||
self.pending_confirmations[connection.handle] = None
|
||||
|
||||
async def indicate_subscribers(self, attribute):
|
||||
async def notify_or_indicate_subscribers(self, indicate, attribute, value=None, force=False):
|
||||
# Get all the connections for which there's at least one subscription
|
||||
connections = [
|
||||
connection for connection in [
|
||||
self.device.lookup_connection(connection_handle)
|
||||
for (connection_handle, subscribers) in self.subscribers.items()
|
||||
if subscribers.get(attribute.handle)
|
||||
if force or subscribers.get(attribute.handle)
|
||||
]
|
||||
if connection is not None
|
||||
]
|
||||
|
||||
# Indicate for each connection
|
||||
# Indicate or notify for each connection
|
||||
if connections:
|
||||
coroutine = self.indicate_subscriber if indicate else self.notify_subscriber
|
||||
await asyncio.wait([
|
||||
self.indicate_subscriber(connection, attribute)
|
||||
asyncio.create_task(coroutine(connection, attribute, value, force))
|
||||
for connection in connections
|
||||
])
|
||||
|
||||
async def notify_subscribers(self, attribute, value=None, force=False):
|
||||
return await self.notify_or_indicate_subscribers(False, attribute, value, force)
|
||||
|
||||
async def indicate_subscribers(self, attribute, value=None, force=False):
|
||||
return await self.notify_or_indicate_subscribers(True, attribute, value, force)
|
||||
|
||||
def on_disconnection(self, connection):
|
||||
if connection.handle in self.mtus:
|
||||
del self.mtus[connection.handle]
|
||||
if connection.handle in self.subscribers:
|
||||
del self.subscribers[connection.handle]
|
||||
if connection.handle in self.indication_semaphores:
|
||||
@@ -325,9 +315,6 @@ class Server(EventEmitter):
|
||||
# Just ignore
|
||||
logger.warning(f'{color("--- Ignoring GATT Request from [0x{connection.handle:04X}]:", "red")} {att_pdu}')
|
||||
|
||||
def get_mtu(self, connection):
|
||||
return self.mtus.get(connection.handle, ATT_DEFAULT_MTU)
|
||||
|
||||
#######################################################
|
||||
# ATT handlers
|
||||
#######################################################
|
||||
@@ -347,12 +334,16 @@ class Server(EventEmitter):
|
||||
'''
|
||||
See Bluetooth spec Vol 3, Part F - 3.4.2.1 Exchange MTU Request
|
||||
'''
|
||||
mtu = max(ATT_DEFAULT_MTU, min(self.max_mtu, request.client_rx_mtu))
|
||||
self.mtus[connection.handle] = mtu
|
||||
self.send_response(connection, ATT_Exchange_MTU_Response(server_rx_mtu = mtu))
|
||||
self.send_response(connection, ATT_Exchange_MTU_Response(server_rx_mtu = self.max_mtu))
|
||||
|
||||
# Notify the device
|
||||
self.device.on_connection_att_mtu_update(connection.handle, mtu)
|
||||
# Compute the final MTU
|
||||
if request.client_rx_mtu >= ATT_DEFAULT_MTU:
|
||||
mtu = min(self.max_mtu, request.client_rx_mtu)
|
||||
|
||||
# Notify the device
|
||||
self.device.on_connection_att_mtu_update(connection.handle, mtu)
|
||||
else:
|
||||
logger.warning('invalid client_rx_mtu received, MTU not changed')
|
||||
|
||||
def on_att_find_information_request(self, connection, request):
|
||||
'''
|
||||
@@ -369,7 +360,7 @@ class Server(EventEmitter):
|
||||
return
|
||||
|
||||
# Build list of returned attributes
|
||||
pdu_space_available = self.get_mtu(connection) - 2
|
||||
pdu_space_available = connection.att_mtu - 2
|
||||
attributes = []
|
||||
uuid_size = 0
|
||||
for attribute in (
|
||||
@@ -420,7 +411,7 @@ class Server(EventEmitter):
|
||||
'''
|
||||
|
||||
# Build list of returned attributes
|
||||
pdu_space_available = self.get_mtu(connection) - 2
|
||||
pdu_space_available = connection.att_mtu - 2
|
||||
attributes = []
|
||||
for attribute in (
|
||||
attribute for attribute in self.attributes if
|
||||
@@ -468,8 +459,7 @@ class Server(EventEmitter):
|
||||
See Bluetooth spec Vol 3, Part F - 3.4.4.1 Read By Type Request
|
||||
'''
|
||||
|
||||
mtu = self.get_mtu(connection)
|
||||
pdu_space_available = mtu - 2
|
||||
pdu_space_available = connection.att_mtu - 2
|
||||
attributes = []
|
||||
for attribute in (
|
||||
attribute for attribute in self.attributes if
|
||||
@@ -482,7 +472,7 @@ class Server(EventEmitter):
|
||||
|
||||
# Check the attribute value size
|
||||
attribute_value = attribute.read_value(connection)
|
||||
max_attribute_size = min(mtu - 4, 253)
|
||||
max_attribute_size = min(connection.att_mtu - 4, 253)
|
||||
if len(attribute_value) > max_attribute_size:
|
||||
# We need to truncate
|
||||
attribute_value = attribute_value[:max_attribute_size]
|
||||
@@ -522,7 +512,7 @@ class Server(EventEmitter):
|
||||
if attribute := self.get_attribute(request.attribute_handle):
|
||||
# TODO: check permissions
|
||||
value = attribute.read_value(connection)
|
||||
value_size = min(self.get_mtu(connection) - 1, len(value))
|
||||
value_size = min(connection.att_mtu - 1, len(value))
|
||||
response = ATT_Read_Response(
|
||||
attribute_value = value[:value_size]
|
||||
)
|
||||
@@ -541,22 +531,21 @@ class Server(EventEmitter):
|
||||
|
||||
if attribute := self.get_attribute(request.attribute_handle):
|
||||
# TODO: check permissions
|
||||
mtu = self.get_mtu(connection)
|
||||
value = attribute.read_value(connection)
|
||||
if request.value_offset > len(value):
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error = request.op_code,
|
||||
request_opcode_in_error = request.op_code,
|
||||
attribute_handle_in_error = request.attribute_handle,
|
||||
error_code = ATT_INVALID_OFFSET_ERROR
|
||||
)
|
||||
elif len(value) <= mtu - 1:
|
||||
elif len(value) <= connection.att_mtu - 1:
|
||||
response = ATT_Error_Response(
|
||||
request_opcode_in_error = request.op_code,
|
||||
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(mtu - 1, len(value) - request.value_offset)
|
||||
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]
|
||||
)
|
||||
@@ -585,8 +574,7 @@ class Server(EventEmitter):
|
||||
self.send_response(connection, response)
|
||||
return
|
||||
|
||||
mtu = self.get_mtu(connection)
|
||||
pdu_space_available = mtu - 2
|
||||
pdu_space_available = connection.att_mtu - 2
|
||||
attributes = []
|
||||
for attribute in (
|
||||
attribute for attribute in self.attributes if
|
||||
@@ -597,7 +585,7 @@ class Server(EventEmitter):
|
||||
):
|
||||
# Check the attribute value size
|
||||
attribute_value = attribute.read_value(connection)
|
||||
max_attribute_size = min(mtu - 6, 251)
|
||||
max_attribute_size = min(connection.att_mtu - 6, 251)
|
||||
if len(attribute_value) > max_attribute_size:
|
||||
# We need to truncate
|
||||
attribute_value = attribute_value[:max_attribute_size]
|
||||
|
||||
1786
bumble/hci.py
1786
bumble/hci.py
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,8 @@
|
||||
import logging
|
||||
from colors import color
|
||||
|
||||
from bumble.smp import SMP_CID, SMP_Command
|
||||
|
||||
from .core import name_or_number
|
||||
from .gatt import ATT_PDU, ATT_CID
|
||||
from .l2cap import (
|
||||
@@ -73,6 +75,9 @@ class PacketTracer:
|
||||
if l2cap_pdu.cid == ATT_CID:
|
||||
att_pdu = ATT_PDU.from_bytes(l2cap_pdu.payload)
|
||||
self.analyzer.emit(att_pdu)
|
||||
elif l2cap_pdu.cid == SMP_CID:
|
||||
smp_command = SMP_Command.from_bytes(l2cap_pdu.payload)
|
||||
self.analyzer.emit(smp_command)
|
||||
elif l2cap_pdu.cid == L2CAP_SIGNALING_CID or l2cap_pdu.cid == L2CAP_LE_SIGNALING_CID:
|
||||
control_frame = L2CAP_Control_Frame.from_bytes(l2cap_pdu.payload)
|
||||
self.analyzer.emit(control_frame)
|
||||
|
||||
133
bumble/host.py
133
bumble/host.py
@@ -44,25 +44,20 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Connection:
|
||||
def __init__(self, host, handle, role, peer_address):
|
||||
def __init__(self, host, handle, role, 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
|
||||
|
||||
def on_hci_acl_data_packet(self, packet):
|
||||
self.assembler.feed_packet(packet)
|
||||
|
||||
def on_acl_pdu(self, pdu):
|
||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||
|
||||
if l2cap_pdu.cid == ATT_CID:
|
||||
self.host.on_gatt_pdu(self, l2cap_pdu.payload)
|
||||
elif l2cap_pdu.cid == SMP_CID:
|
||||
self.host.on_smp_pdu(self, l2cap_pdu.payload)
|
||||
else:
|
||||
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
||||
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -81,7 +76,9 @@ class Host(EventEmitter):
|
||||
self.hc_total_num_acl_data_packets = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS
|
||||
self.acl_packet_queue = collections.deque()
|
||||
self.acl_packets_in_flight = 0
|
||||
self.local_version = HCI_VERSION_BLUETOOTH_CORE_4_0
|
||||
self.local_supported_commands = bytes(64)
|
||||
self.local_le_features = 0
|
||||
self.command_semaphore = asyncio.Semaphore(1)
|
||||
self.long_term_key_provider = None
|
||||
self.link_key_provider = None
|
||||
@@ -98,34 +95,64 @@ class Host(EventEmitter):
|
||||
self.ready = True
|
||||
|
||||
response = await self.send_command(HCI_Read_Local_Supported_Commands_Command())
|
||||
if response.return_parameters.status != HCI_SUCCESS:
|
||||
raise ProtocolError(response.return_parameters.status, 'hci')
|
||||
self.local_supported_commands = response.return_parameters.supported_commands
|
||||
|
||||
await self.send_command(HCI_Set_Event_Mask_Command(event_mask = bytes.fromhex('FFFFFFFFFFFFFFFF')))
|
||||
await self.send_command(HCI_LE_Set_Event_Mask_Command(le_event_mask = bytes.fromhex('FFFFF00000000000')))
|
||||
await self.send_command(HCI_Read_Local_Version_Information_Command())
|
||||
await self.send_command(HCI_Write_LE_Host_Support_Command(le_supported_host = 1, simultaneous_le_host = 0))
|
||||
|
||||
response = await self.send_command(HCI_LE_Read_Buffer_Size_Command())
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
self.hc_le_acl_data_packet_length = response.return_parameters.hc_le_acl_data_packet_length
|
||||
self.hc_total_num_le_acl_data_packets = response.return_parameters.hc_total_num_le_acl_data_packets
|
||||
logger.debug(f'HCI LE ACL flow control: hc_le_acl_data_packet_length={response.return_parameters.hc_le_acl_data_packet_length}, hc_total_num_le_acl_data_packets={response.return_parameters.hc_total_num_le_acl_data_packets}')
|
||||
self.local_supported_commands = response.return_parameters.supported_commands
|
||||
else:
|
||||
logger.warn(f'HCI_LE_Read_Buffer_Size_Command failed: {response.return_parameters.status}')
|
||||
if response.return_parameters.hc_le_acl_data_packet_length == 0 or response.return_parameters.hc_total_num_le_acl_data_packets == 0:
|
||||
# Read the non-LE-specific values
|
||||
logger.warn(f'HCI_Read_Local_Supported_Commands_Command failed: {response.return_parameters.status}')
|
||||
|
||||
if self.supports_command(HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
||||
response = await self.send_command(HCI_LE_Read_Local_Supported_Features_Command())
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
self.local_le_features = struct.unpack('<Q', response.return_parameters.le_features)[0]
|
||||
else:
|
||||
logger.warn(f'HCI_LE_Read_Supported_Features_Command failed: {response.return_parameters.status}')
|
||||
|
||||
if self.supports_command(HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
|
||||
response = await self.send_command(HCI_Read_Local_Version_Information_Command())
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
self.local_version = response.return_parameters
|
||||
else:
|
||||
logger.warn(f'HCI_Read_Local_Version_Information_Command failed: {response.return_parameters.status}')
|
||||
|
||||
await self.send_command(HCI_Set_Event_Mask_Command(event_mask = bytes.fromhex('FFFFFFFFFFFFFF3F')))
|
||||
|
||||
if self.local_version.hci_version <= HCI_VERSION_BLUETOOTH_CORE_4_0:
|
||||
# Some older controllers don't like event masks with bits they don't understand
|
||||
le_event_mask = bytes.fromhex('1F00000000000000')
|
||||
else:
|
||||
le_event_mask = bytes.fromhex('FFFFF00000000000')
|
||||
await self.send_command(HCI_LE_Set_Event_Mask_Command(le_event_mask = le_event_mask))
|
||||
|
||||
if self.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
|
||||
response = await self.send_command(HCI_Read_Buffer_Size_Command())
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
self.hc_acl_data_packet_length = response.return_parameters.hc_le_acl_data_packet_length
|
||||
self.hc_le_acl_data_packet_length = self.hc_le_acl_data_packet_length or self.hc_acl_data_packet_length
|
||||
self.hc_total_num_acl_data_packets = response.return_parameters.hc_total_num_le_acl_data_packets
|
||||
self.hc_total_num_le_acl_data_packets = self.hc_total_num_le_acl_data_packets or self.hc_total_num_acl_data_packets
|
||||
logger.debug(f'HCI LE ACL flow control: hc_le_acl_data_packet_length={self.hc_le_acl_data_packet_length}, hc_total_num_le_acl_data_packets={self.hc_total_num_le_acl_data_packets}')
|
||||
self.hc_acl_data_packet_length = response.return_parameters.hc_acl_data_packet_length
|
||||
self.hc_total_num_acl_data_packets = response.return_parameters.hc_total_num_acl_data_packets
|
||||
else:
|
||||
logger.warn(f'HCI_Read_Buffer_Size_Command failed: {response.return_parameters.status}')
|
||||
|
||||
if self.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||
response = await self.send_command(HCI_LE_Read_Buffer_Size_Command())
|
||||
if response.return_parameters.status == HCI_SUCCESS:
|
||||
self.hc_le_acl_data_packet_length = response.return_parameters.hc_le_acl_data_packet_length
|
||||
self.hc_total_num_le_acl_data_packets = response.return_parameters.hc_total_num_le_acl_data_packets
|
||||
else:
|
||||
logger.warn(f'HCI_LE_Read_Buffer_Size_Command failed: {response.return_parameters.status}')
|
||||
|
||||
if response.return_parameters.hc_le_acl_data_packet_length == 0 or response.return_parameters.hc_total_num_le_acl_data_packets == 0:
|
||||
# LE and Classic share the same values
|
||||
self.hc_le_acl_data_packet_length = self.hc_acl_data_packet_length
|
||||
self.hc_total_num_le_acl_data_packets = self.hc_total_num_acl_data_packets
|
||||
|
||||
logger.debug(
|
||||
f'HCI ACL flow control: hc_acl_data_packet_length={self.hc_acl_data_packet_length},'
|
||||
f'hc_total_num_acl_data_packets={self.hc_total_num_acl_data_packets}'
|
||||
)
|
||||
logger.debug(
|
||||
f'HCI LE ACL flow control: hc_le_acl_data_packet_length={self.hc_le_acl_data_packet_length},'
|
||||
f'hc_total_num_le_acl_data_packets={self.hc_total_num_le_acl_data_packets}'
|
||||
)
|
||||
|
||||
self.reset_done = True
|
||||
|
||||
@property
|
||||
@@ -149,8 +176,8 @@ class Host(EventEmitter):
|
||||
|
||||
# Wait until we can send (only one pending command at a time)
|
||||
async with self.command_semaphore:
|
||||
assert(self.pending_command is None)
|
||||
assert(self.pending_response is None)
|
||||
assert self.pending_command is None
|
||||
assert self.pending_response is None
|
||||
|
||||
# Create a future value to hold the eventual response
|
||||
self.pending_response = asyncio.get_running_loop().create_future()
|
||||
@@ -183,6 +210,7 @@ class Host(EventEmitter):
|
||||
offset = 0
|
||||
pb_flag = 0
|
||||
while bytes_remaining:
|
||||
# TODO: support different LE/Classic lengths
|
||||
data_total_length = min(bytes_remaining, self.hc_le_acl_data_packet_length)
|
||||
acl_packet = HCI_AclDataPacket(
|
||||
connection_handle = connection_handle,
|
||||
@@ -205,12 +233,43 @@ class Host(EventEmitter):
|
||||
logger.debug(f'{self.acl_packets_in_flight} ACL packets in flight, {len(self.acl_packet_queue)} in queue')
|
||||
|
||||
def check_acl_packet_queue(self):
|
||||
# Send all we can
|
||||
# Send all we can (TODO: support different LE/Classic limits)
|
||||
while len(self.acl_packet_queue) > 0 and self.acl_packets_in_flight < self.hc_total_num_le_acl_data_packets:
|
||||
packet = self.acl_packet_queue.pop()
|
||||
self.send_hci_packet(packet)
|
||||
self.acl_packets_in_flight += 1
|
||||
|
||||
def supports_command(self, command):
|
||||
# Find the support flag position for this command
|
||||
for (octet, flags) in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS):
|
||||
for (flag_position, value) in enumerate(flags):
|
||||
if value == command:
|
||||
# Check if the flag is set
|
||||
if octet < len(self.local_supported_commands) and flag_position < 8:
|
||||
return (self.local_supported_commands[octet] & (1 << flag_position)) != 0
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def supported_commands(self):
|
||||
commands = []
|
||||
for (octet, flags) in enumerate(self.local_supported_commands):
|
||||
if octet < len(HCI_SUPPORTED_COMMANDS_FLAGS):
|
||||
for flag in range(8):
|
||||
if flags & (1 << flag) != 0:
|
||||
command = HCI_SUPPORTED_COMMANDS_FLAGS[octet][flag]
|
||||
if command is not None:
|
||||
commands.append(command)
|
||||
|
||||
return commands
|
||||
|
||||
def supports_le_feature(self, feature):
|
||||
return (self.local_le_features & (1 << feature)) != 0
|
||||
|
||||
@property
|
||||
def supported_le_features(self):
|
||||
return [feature for feature in range(64) if self.local_le_features & (1 << feature)]
|
||||
|
||||
# Packet Sink protocol (packets coming from the controller via HCI)
|
||||
def on_packet(self, packet):
|
||||
hci_packet = HCI_Packet.from_bytes(packet)
|
||||
@@ -249,12 +308,6 @@ class Host(EventEmitter):
|
||||
if connection := self.connections.get(packet.connection_handle):
|
||||
connection.on_hci_acl_data_packet(packet)
|
||||
|
||||
def on_gatt_pdu(self, connection, pdu):
|
||||
self.emit('gatt_pdu', connection.handle, pdu)
|
||||
|
||||
def on_smp_pdu(self, connection, pdu):
|
||||
self.emit('smp_pdu', connection.handle, pdu)
|
||||
|
||||
def on_l2cap_pdu(self, connection, cid, pdu):
|
||||
self.emit('l2cap_pdu', connection.handle, cid, pdu)
|
||||
|
||||
@@ -312,7 +365,7 @@ class Host(EventEmitter):
|
||||
|
||||
connection = self.connections.get(event.connection_handle)
|
||||
if connection is None:
|
||||
connection = Connection(self, event.connection_handle, event.role, event.peer_address)
|
||||
connection = Connection(self, event.connection_handle, event.role, event.peer_address, BT_LE_TRANSPORT)
|
||||
self.connections[event.connection_handle] = connection
|
||||
|
||||
# Notify the client
|
||||
@@ -347,7 +400,7 @@ class Host(EventEmitter):
|
||||
|
||||
connection = self.connections.get(event.connection_handle)
|
||||
if connection is None:
|
||||
connection = Connection(self, event.connection_handle, BT_CENTRAL_ROLE, event.bd_addr)
|
||||
connection = Connection(self, event.connection_handle, BT_CENTRAL_ROLE, event.bd_addr, BT_BR_EDR_TRANSPORT)
|
||||
self.connections[event.connection_handle] = connection
|
||||
|
||||
# Notify the client
|
||||
|
||||
@@ -20,11 +20,11 @@ import logging
|
||||
import struct
|
||||
|
||||
from colors import color
|
||||
from pyee import EventEmitter
|
||||
|
||||
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
||||
from .hci import (HCI_LE_Connection_Update_Command, HCI_Object, key_with_value,
|
||||
name_or_number)
|
||||
from .utils import EventEmitter
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -414,6 +414,18 @@ class L2CAP_Information_Request(L2CAP_Control_Frame):
|
||||
EXTENDED_FEATURES_SUPPORTED = 0x0002
|
||||
FIXED_CHANNELS_SUPPORTED = 0x0003
|
||||
|
||||
EXTENDED_FEATURE_FLOW_MODE_CONTROL = 0x0001
|
||||
EXTENDED_FEATURE_RETRANSMISSION_MODE = 0x0002
|
||||
EXTENDED_FEATURE_BIDIRECTIONAL_QOS = 0x0004
|
||||
EXTENDED_FEATURE_ENHANCED_RETRANSMISSION_MODE = 0x0008
|
||||
EXTENDED_FEATURE_STREAMING_MODE = 0x0010
|
||||
EXTENDED_FEATURE_FCS_OPTION = 0x0020
|
||||
EXTENDED_FEATURE_EXTENDED_FLOW_SPEC = 0x0040
|
||||
EXTENDED_FEATURE_FIXED_CHANNELS = 0x0080
|
||||
EXTENDED_FEATURE_EXTENDED_WINDOW_SIZE = 0x0100
|
||||
EXTENDED_FEATURE_UNICAST_CONNECTIONLESS_DATA = 0x0200
|
||||
EXTENDED_FEATURE_ENHANCED_CREDIT_BASE_FLOW_CONTROL = 0x0400
|
||||
|
||||
INFO_TYPE_NAMES = {
|
||||
CONNECTIONLESS_MTU: 'CONNECTIONLESS_MTU',
|
||||
EXTENDED_FEATURES_SUPPORTED: 'EXTENDED_FEATURES_SUPPORTED',
|
||||
@@ -817,11 +829,16 @@ class Channel(EventEmitter):
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ChannelManager:
|
||||
def __init__(self):
|
||||
self.host = None
|
||||
self.channels = {} # Channels, mapped by connection and cid
|
||||
self.identifiers = {} # Incrementing identifier values by connection
|
||||
self.servers = {} # Servers accepting connections, by PSM
|
||||
def __init__(self, extended_features=None, connectionless_mtu=1024):
|
||||
self.host = None
|
||||
self.channels = {} # Channels, mapped by connection and cid
|
||||
# Fixed channel handlers, mapped by cid
|
||||
self.fixed_channels = {
|
||||
L2CAP_SIGNALING_CID: None, L2CAP_LE_SIGNALING_CID: None}
|
||||
self.identifiers = {} # Incrementing identifier values by connection
|
||||
self.servers = {} # Servers accepting connections, by PSM
|
||||
self.extended_features = [] if extended_features is None else extended_features
|
||||
self.connectionless_mtu = connectionless_mtu
|
||||
|
||||
def find_channel(self, connection_handle, cid):
|
||||
if connection_channels := self.channels.get(connection_handle):
|
||||
@@ -840,6 +857,13 @@ class ChannelManager:
|
||||
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
||||
self.identifiers[connection.handle] = identifier
|
||||
return identifier
|
||||
|
||||
def register_fixed_channel(self, cid, handler):
|
||||
self.fixed_channels[cid] = handler
|
||||
|
||||
def deregister_fixed_channel(self, cid):
|
||||
if cid in self.fixed_channels:
|
||||
del self.fixed_channels[cid]
|
||||
|
||||
def register_server(self, psm, server):
|
||||
self.servers[psm] = server
|
||||
@@ -855,6 +879,8 @@ class ChannelManager:
|
||||
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
|
||||
|
||||
self.on_control_frame(connection, cid, control_frame)
|
||||
elif cid in self.fixed_channels:
|
||||
self.fixed_channels[cid](connection.handle, pdu)
|
||||
else:
|
||||
if (channel := self.find_channel(connection.handle, cid)) is None:
|
||||
logger.warn(color(f'channel not found for 0x{connection.handle:04X}:{cid}', 'red'))
|
||||
@@ -999,13 +1025,13 @@ class ChannelManager:
|
||||
def on_l2cap_information_request(self, connection, cid, request):
|
||||
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
|
||||
result = L2CAP_Information_Response.SUCCESS
|
||||
data = struct.pack('<H', 1024) # TODO: don't use a fixed value
|
||||
data = self.connectionless_mtu.to_bytes(2, 'little')
|
||||
elif request.info_type == L2CAP_Information_Request.EXTENDED_FEATURES_SUPPORTED:
|
||||
result = L2CAP_Information_Response.SUCCESS
|
||||
data = bytes.fromhex('00000000') # TODO: don't use a fixed value
|
||||
data = sum(self.extended_features).to_bytes(4, 'little')
|
||||
elif request.info_type == L2CAP_Information_Request.FIXED_CHANNELS_SUPPORTED:
|
||||
result = L2CAP_Information_Response.SUCCESS
|
||||
data = bytes.fromhex('FFFFFFFFFFFFFFFF') # TODO: don't use a fixed value
|
||||
data = sum(1 << cid for cid in self.fixed_channels).to_bytes(8, 'little')
|
||||
else:
|
||||
result = L2CAP_Information_Request.NO_SUPPORTED
|
||||
|
||||
|
||||
221
bumble/profiles/heart_rate_service.py
Normal file
221
bumble/profiles/heart_rate_service.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from enum import IntEnum
|
||||
import struct
|
||||
|
||||
from ..gatt_client import ProfileServiceProxy
|
||||
from ..att import ATT_Error
|
||||
from ..gatt import (
|
||||
GATT_HEART_RATE_SERVICE,
|
||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
|
||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
|
||||
TemplateService,
|
||||
Characteristic,
|
||||
CharacteristicValue,
|
||||
DelegatedCharacteristicAdapter,
|
||||
PackedCharacteristicAdapter
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HeartRateService(TemplateService):
|
||||
UUID = GATT_HEART_RATE_SERVICE
|
||||
HEART_RATE_CONTROL_POINT_FORMAT = 'B'
|
||||
CONTROL_POINT_NOT_SUPPORTED = 0x80
|
||||
RESET_ENERGY_EXPENDED = 0x01
|
||||
|
||||
class BodySensorLocation(IntEnum):
|
||||
OTHER = 0,
|
||||
CHEST = 1,
|
||||
WRIST = 2,
|
||||
FINGER = 3,
|
||||
HAND = 4,
|
||||
EAR_LOBE = 5,
|
||||
FOOT = 6
|
||||
|
||||
class HeartRateMeasurement:
|
||||
def __init__(
|
||||
self,
|
||||
heart_rate,
|
||||
sensor_contact_detected=None,
|
||||
energy_expended=None,
|
||||
rr_intervals=None
|
||||
):
|
||||
if heart_rate < 0 or heart_rate > 0xFFFF:
|
||||
raise ValueError('heart_rate out of range')
|
||||
|
||||
if energy_expended is not None and (energy_expended < 0 or energy_expended > 0xFFFF):
|
||||
raise ValueError('energy_expended out of range')
|
||||
|
||||
if rr_intervals:
|
||||
for rr_interval in rr_intervals:
|
||||
if rr_interval < 0 or rr_interval * 1024 > 0xFFFF:
|
||||
raise ValueError('rr_intervals out of range')
|
||||
|
||||
self.heart_rate = heart_rate
|
||||
self.sensor_contact_detected = sensor_contact_detected
|
||||
self.energy_expended = energy_expended
|
||||
self.rr_intervals = rr_intervals
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data):
|
||||
flags = data[0]
|
||||
offset = 1
|
||||
|
||||
if flags & 1:
|
||||
hr = struct.unpack_from('<H', data, offset)[0]
|
||||
offset += 2
|
||||
else:
|
||||
hr = struct.unpack_from('B', data, offset)[0]
|
||||
offset += 1
|
||||
|
||||
if flags & (1 << 2):
|
||||
sensor_contact_detected = (flags & (1 << 1) != 0)
|
||||
else:
|
||||
sensor_contact_detected = None
|
||||
|
||||
if flags & (1 << 3):
|
||||
energy_expended = struct.unpack_from('<H', data, offset)[0]
|
||||
offset += 2
|
||||
else:
|
||||
energy_expended = None
|
||||
|
||||
if flags & (1 << 4):
|
||||
rr_intervals = tuple(
|
||||
struct.unpack_from('<H', data, offset + i * 2)[0] / 1024
|
||||
for i in range((len(data) - offset) // 2)
|
||||
)
|
||||
else:
|
||||
rr_intervals = ()
|
||||
|
||||
return cls(hr, sensor_contact_detected, energy_expended, rr_intervals)
|
||||
|
||||
def __bytes__(self):
|
||||
if self.heart_rate < 256:
|
||||
flags = 0
|
||||
data = struct.pack('B', self.heart_rate)
|
||||
else:
|
||||
flags = 1
|
||||
data = struct.pack('<H', self.heart_rate)
|
||||
|
||||
if self.sensor_contact_detected is not None:
|
||||
flags |= ((1 if self.sensor_contact_detected else 0) << 1) | (1 << 2)
|
||||
|
||||
if self.energy_expended is not None:
|
||||
flags |= (1 << 3)
|
||||
data += struct.pack('<H', self.energy_expended)
|
||||
|
||||
if self.rr_intervals:
|
||||
flags |= (1 << 4)
|
||||
data += b''.join([
|
||||
struct.pack('<H', int(rr_interval * 1024))
|
||||
for rr_interval in self.rr_intervals
|
||||
])
|
||||
|
||||
return bytes([flags]) + data
|
||||
|
||||
def __str__(self):
|
||||
return f'HeartRateMeasurement(heart_rate={self.heart_rate},'\
|
||||
f' sensor_contact_detected={self.sensor_contact_detected},'\
|
||||
f' energy_expended={self.energy_expended},'\
|
||||
f' rr_intervals={self.rr_intervals})'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
read_heart_rate_measurement,
|
||||
body_sensor_location=None,
|
||||
reset_energy_expended=None
|
||||
):
|
||||
self.heart_rate_measurement_characteristic = DelegatedCharacteristicAdapter(
|
||||
Characteristic(
|
||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
||||
Characteristic.NOTIFY,
|
||||
0,
|
||||
CharacteristicValue(read=read_heart_rate_measurement)
|
||||
),
|
||||
encode=lambda value: bytes(value)
|
||||
)
|
||||
characteristics = [self.heart_rate_measurement_characteristic]
|
||||
|
||||
if body_sensor_location is not None:
|
||||
self.body_sensor_location_characteristic = Characteristic(
|
||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
|
||||
Characteristic.READ,
|
||||
Characteristic.READABLE,
|
||||
bytes([int(body_sensor_location)])
|
||||
)
|
||||
characteristics.append(self.body_sensor_location_characteristic)
|
||||
|
||||
if reset_energy_expended:
|
||||
def write_heart_rate_control_point_value(connection, value):
|
||||
if value == self.RESET_ENERGY_EXPENDED:
|
||||
if reset_energy_expended is not None:
|
||||
reset_energy_expended(connection)
|
||||
else:
|
||||
raise ATT_Error(self.CONTROL_POINT_NOT_SUPPORTED)
|
||||
|
||||
self.heart_rate_control_point_characteristic = PackedCharacteristicAdapter(
|
||||
Characteristic(
|
||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
|
||||
Characteristic.WRITE,
|
||||
Characteristic.WRITEABLE,
|
||||
CharacteristicValue(write=write_heart_rate_control_point_value)
|
||||
),
|
||||
format=HeartRateService.HEART_RATE_CONTROL_POINT_FORMAT
|
||||
)
|
||||
characteristics.append(self.heart_rate_control_point_characteristic)
|
||||
|
||||
super().__init__(characteristics)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HeartRateServiceProxy(ProfileServiceProxy):
|
||||
SERVICE_CLASS = HeartRateService
|
||||
|
||||
def __init__(self, service_proxy):
|
||||
self.service_proxy = service_proxy
|
||||
|
||||
if characteristics := service_proxy.get_characteristics_by_uuid(GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC):
|
||||
self.heart_rate_measurement = DelegatedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
decode=HeartRateService.HeartRateMeasurement.from_bytes
|
||||
)
|
||||
else:
|
||||
self.heart_rate_measurement = None
|
||||
|
||||
if characteristics := service_proxy.get_characteristics_by_uuid(GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC):
|
||||
self.body_sensor_location = DelegatedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
decode=lambda value: HeartRateService.BodySensorLocation(value[0])
|
||||
)
|
||||
else:
|
||||
self.body_sensor_location = None
|
||||
|
||||
if characteristics := service_proxy.get_characteristics_by_uuid(GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC):
|
||||
self.heart_rate_control_point = PackedCharacteristicAdapter(
|
||||
characteristics[0],
|
||||
format=HeartRateService.HEART_RATE_CONTROL_POINT_FORMAT
|
||||
)
|
||||
else:
|
||||
self.heart_rate_control_point = None
|
||||
|
||||
async def reset_energy_expended(self):
|
||||
if self.heart_rate_control_point is not None:
|
||||
return await self.heart_rate_control_point.write_value(HeartRateService.RESET_ENERGY_EXPENDED)
|
||||
@@ -17,9 +17,10 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import asyncio
|
||||
from colors import color
|
||||
|
||||
from .utils import EventEmitter
|
||||
from colors import color
|
||||
from pyee import EventEmitter
|
||||
|
||||
from .core import InvalidStateError, ProtocolError, ConnectionError
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
148
bumble/smp.py
148
bumble/smp.py
@@ -44,6 +44,7 @@ logger = logging.getLogger(__name__)
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
SMP_CID = 0x06
|
||||
SMP_BR_CID = 0x07
|
||||
|
||||
SMP_PAIRING_REQUEST_COMMAND = 0x01
|
||||
SMP_PAIRING_RESPONSE_COMMAND = 0x02
|
||||
@@ -150,6 +151,10 @@ SMP_SC_AUTHREQ = 0b00001000
|
||||
SMP_KEYPRESS_AUTHREQ = 0b00010000
|
||||
SMP_CT2_AUTHREQ = 0b00100000
|
||||
|
||||
# Crypto salt
|
||||
SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('00000000000000000000000000000000746D7031')
|
||||
SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('00000000000000000000000000000000746D7032')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utils
|
||||
@@ -457,9 +462,17 @@ 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 = (SMP_ENC_KEY_DISTRIBUTION_FLAG | SMP_ID_KEY_DISTRIBUTION_FLAG)
|
||||
|
||||
def __init__(self, io_capability = NO_OUTPUT_NO_INPUT):
|
||||
def __init__(
|
||||
self,
|
||||
io_capability=NO_OUTPUT_NO_INPUT,
|
||||
local_initiator_key_distribution=DEFAULT_KEY_DISTRIBUTION,
|
||||
local_responder_key_distribution=DEFAULT_KEY_DISTRIBUTION
|
||||
):
|
||||
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):
|
||||
return True
|
||||
@@ -473,6 +486,14 @@ class PairingDelegate:
|
||||
async def display_number(self, number, digits=6):
|
||||
pass
|
||||
|
||||
async def key_distribution_response(self, peer_initiator_key_distribution, peer_responder_key_distribution):
|
||||
return (
|
||||
(peer_initiator_key_distribution &
|
||||
self.local_initiator_key_distribution),
|
||||
(peer_responder_key_distribution &
|
||||
self.local_responder_key_distribution)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PairingConfig:
|
||||
@@ -559,6 +580,7 @@ class Session:
|
||||
self.ltk = None
|
||||
self.ltk_ediv = 0
|
||||
self.ltk_rand = bytes(8)
|
||||
self.link_key = None
|
||||
self.initiator_key_distribution = 0
|
||||
self.responder_key_distribution = 0
|
||||
self.peer_random_value = None
|
||||
@@ -579,6 +601,7 @@ class Session:
|
||||
self.pairing_config = pairing_config
|
||||
self.wait_before_continuing = None
|
||||
self.completed = False
|
||||
self.ctkd_task = None
|
||||
|
||||
# Decide if we're the initiator or the responder
|
||||
self.is_initiator = (connection.role == BT_CENTRAL_ROLE)
|
||||
@@ -596,11 +619,8 @@ class Session:
|
||||
self.pairing_result = None
|
||||
|
||||
# Key Distribution (default values before negotiation)
|
||||
self.initiator_key_distribution = (
|
||||
SMP_ENC_KEY_DISTRIBUTION_FLAG |
|
||||
SMP_ID_KEY_DISTRIBUTION_FLAG # |SMP_SIGN_KEY_DISTRIBUTION_FLAG
|
||||
)
|
||||
self.responder_key_distribution = self.initiator_key_distribution
|
||||
self.initiator_key_distribution = pairing_config.delegate.local_initiator_key_distribution
|
||||
self.responder_key_distribution = pairing_config.delegate.local_responder_key_distribution
|
||||
|
||||
# Authentication Requirements Flags - Vol 3, Part H, Figure 3.3
|
||||
self.bonding = pairing_config.bonding
|
||||
@@ -852,7 +872,7 @@ class Session:
|
||||
# distribute the long term and/or other keys over an encrypted connection
|
||||
asyncio.create_task(
|
||||
self.manager.device.host.send_command(
|
||||
HCI_LE_Start_Encryption_Command(
|
||||
HCI_LE_Enable_Encryption_Command(
|
||||
connection_handle = self.connection.handle,
|
||||
random_number = bytes(8),
|
||||
encrypted_diversifier = 0,
|
||||
@@ -861,61 +881,84 @@ class Session:
|
||||
)
|
||||
)
|
||||
|
||||
async def derive_ltk(self):
|
||||
link_key = await self.manager.device.get_link_key(self.connection.peer_address)
|
||||
assert link_key is not None
|
||||
ilk = crypto.h7(
|
||||
salt=SMP_CTKD_H7_BRLE_SALT,
|
||||
w=link_key) if self.ct2 else crypto.h6(link_key, b'tmp2')
|
||||
self.ltk = crypto.h6(ilk, b'brle')
|
||||
|
||||
def distribute_keys(self):
|
||||
# Distribute the keys as required
|
||||
if self.is_initiator:
|
||||
if not self.sc:
|
||||
# CTKD: Derive LTK from LinkKey
|
||||
if self.connection.transport == BT_BR_EDR_TRANSPORT and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG:
|
||||
self.ctkd_task = asyncio.create_task(self.derive_ltk())
|
||||
elif not self.sc:
|
||||
# Distribute the LTK, EDIV and RAND
|
||||
if self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG:
|
||||
self.send_command(SMP_Encryption_Information_Command(long_term_key=self.ltk))
|
||||
self.send_command(SMP_Master_Identification_Command(ediv=self.ltk_ediv, rand=self.ltk_rand))
|
||||
|
||||
# Distribute IRK
|
||||
# Distribute IRK & BD ADDR
|
||||
if self.initiator_key_distribution & SMP_ID_KEY_DISTRIBUTION_FLAG:
|
||||
self.send_command(
|
||||
SMP_Identity_Information_Command(identity_resolving_key=self.manager.device.irk)
|
||||
)
|
||||
|
||||
# Distribute BD ADDR
|
||||
self.send_command(SMP_Identity_Address_Information_Command(
|
||||
addr_type = self.manager.address.address_type,
|
||||
bd_addr = self.manager.address
|
||||
))
|
||||
self.send_command(SMP_Identity_Address_Information_Command(
|
||||
addr_type = self.manager.address.address_type,
|
||||
bd_addr = self.manager.address
|
||||
))
|
||||
|
||||
# Distribute CSRK
|
||||
csrk = bytes(16) # FIXME: testing
|
||||
if self.initiator_key_distribution & SMP_SIGN_KEY_DISTRIBUTION_FLAG:
|
||||
self.send_command(SMP_Signing_Information_Command(signature_key=csrk))
|
||||
|
||||
# CTKD, calculate BR/EDR link key
|
||||
if self.initiator_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
||||
ilk = crypto.h7(
|
||||
salt=SMP_CTKD_H7_LEBR_SALT,
|
||||
w=self.ltk) if self.ct2 else crypto.h6(self.ltk, b'tmp1')
|
||||
self.link_key = crypto.h6(ilk, b'lebr')
|
||||
|
||||
else:
|
||||
# Distribute the LTK
|
||||
if not self.sc:
|
||||
# CTKD: Derive LTK from LinkKey
|
||||
if self.connection.transport == BT_BR_EDR_TRANSPORT and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG:
|
||||
self.ctkd_task = asyncio.create_task(self.derive_ltk())
|
||||
# Distribute the LTK, EDIV and RAND
|
||||
elif not self.sc:
|
||||
if self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG:
|
||||
self.send_command(SMP_Encryption_Information_Command(long_term_key=self.ltk))
|
||||
self.send_command(SMP_Master_Identification_Command(ediv=self.ltk_ediv, rand=self.ltk_rand))
|
||||
|
||||
# Distribute EDIV and RAND
|
||||
self.send_command(SMP_Master_Identification_Command(ediv=self.ltk_ediv, rand=self.ltk_rand))
|
||||
|
||||
# Distribute IRK
|
||||
# Distribute IRK & BD ADDR
|
||||
if self.responder_key_distribution & SMP_ID_KEY_DISTRIBUTION_FLAG:
|
||||
self.send_command(
|
||||
SMP_Identity_Information_Command(identity_resolving_key=self.manager.device.irk)
|
||||
)
|
||||
|
||||
# Distribute BD ADDR
|
||||
self.send_command(SMP_Identity_Address_Information_Command(
|
||||
addr_type = self.manager.address.address_type,
|
||||
bd_addr = self.manager.address
|
||||
))
|
||||
self.send_command(SMP_Identity_Address_Information_Command(
|
||||
addr_type = self.manager.address.address_type,
|
||||
bd_addr = self.manager.address
|
||||
))
|
||||
|
||||
# Distribute CSRK
|
||||
csrk = bytes(16) # FIXME: testing
|
||||
if self.responder_key_distribution & SMP_SIGN_KEY_DISTRIBUTION_FLAG:
|
||||
self.send_command(SMP_Signing_Information_Command(signature_key=csrk))
|
||||
|
||||
# CTKD, calculate BR/EDR link key
|
||||
if self.responder_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
|
||||
ilk = crypto.h7(
|
||||
salt=SMP_CTKD_H7_LEBR_SALT,
|
||||
w=self.ltk) if self.ct2 else crypto.h6(self.ltk, b'tmp1')
|
||||
self.link_key = crypto.h6(ilk, b'lebr')
|
||||
|
||||
def compute_peer_expected_distributions(self, key_distribution_flags):
|
||||
# Set our expectations for what to wait for in the key distribution phase
|
||||
self.peer_expected_distributions = []
|
||||
if not self.sc:
|
||||
if not self.sc and self.connection.transport == BT_LE_TRANSPORT:
|
||||
if (key_distribution_flags & SMP_ENC_KEY_DISTRIBUTION_FLAG != 0):
|
||||
self.peer_expected_distributions.append(SMP_Encryption_Information_Command)
|
||||
self.peer_expected_distributions.append(SMP_Master_Identification_Command)
|
||||
@@ -938,14 +981,9 @@ class Session:
|
||||
self.peer_expected_distributions.remove(command_class)
|
||||
logger.debug(f'remaining distributions: {[c.__name__ for c in self.peer_expected_distributions]}')
|
||||
if not self.peer_expected_distributions:
|
||||
# The initiator can now send its keys
|
||||
if self.is_initiator:
|
||||
self.distribute_keys()
|
||||
|
||||
# Nothing left to expect, we're done
|
||||
self.on_pairing()
|
||||
self.on_peer_key_distribution_complete()
|
||||
else:
|
||||
logger.warn(color('!!! unexpected key distribution command', 'red'))
|
||||
logger.warn(color(f'!!! unexpected key distribution command: {command_class.__name__}', 'red'))
|
||||
self.send_pairing_failed(SMP_UNSPECIFIED_REASON_ERROR)
|
||||
|
||||
async def pair(self):
|
||||
@@ -964,17 +1002,28 @@ class Session:
|
||||
self.connection.remove_listener('connection_encryption_key_refresh', self.on_connection_encryption_key_refresh)
|
||||
self.manager.on_session_end(self)
|
||||
|
||||
def on_peer_key_distribution_complete(self):
|
||||
# The initiator can now send its keys
|
||||
if self.is_initiator:
|
||||
self.distribute_keys()
|
||||
|
||||
asyncio.create_task(self.on_pairing())
|
||||
|
||||
def on_connection_encryption_change(self):
|
||||
if self.connection.is_encrypted:
|
||||
if self.is_responder:
|
||||
# The responder distributes its keys first, the initiator later
|
||||
self.distribute_keys()
|
||||
|
||||
# If we're not expecting key distributions from the peer, we're done
|
||||
if not self.peer_expected_distributions:
|
||||
self.on_peer_key_distribution_complete()
|
||||
|
||||
def on_connection_encryption_key_refresh(self):
|
||||
# Do as if the connection had just been encrypted
|
||||
self.on_connection_encryption_change()
|
||||
|
||||
def on_pairing(self):
|
||||
async def on_pairing(self):
|
||||
logger.debug('pairing complete')
|
||||
|
||||
if self.completed:
|
||||
@@ -991,11 +1040,16 @@ class Session:
|
||||
else:
|
||||
peer_address = self.connection.peer_address
|
||||
|
||||
# Wait for link key fetch and key derivation
|
||||
if self.ctkd_task is not None:
|
||||
await self.ctkd_task
|
||||
self.ctkd_task = None
|
||||
|
||||
# Create an object to hold the keys
|
||||
keys = PairingKeys()
|
||||
keys.address_type = peer_address.address_type
|
||||
authenticated = self.pairing_method != self.JUST_WORKS
|
||||
if self.sc:
|
||||
if self.sc or self.connection.transport == BT_BR_EDR_TRANSPORT:
|
||||
keys.ltk = PairingKeys.Key(
|
||||
value = self.ltk,
|
||||
authenticated = authenticated
|
||||
@@ -1029,7 +1083,11 @@ class Session:
|
||||
value = self.peer_signature_key,
|
||||
authenticated = authenticated
|
||||
)
|
||||
|
||||
if self.link_key is not None:
|
||||
keys.link_key = PairingKeys.Key(
|
||||
value = self.link_key,
|
||||
authenticated = authenticated
|
||||
)
|
||||
self.manager.on_pairing(self, peer_address, keys)
|
||||
|
||||
def on_pairing_failure(self, reason):
|
||||
@@ -1076,6 +1134,7 @@ class Session:
|
||||
# Bonding and SC require both sides to request/support it
|
||||
self.bonding = self.bonding and (command.auth_req & SMP_BONDING_AUTHREQ != 0)
|
||||
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
|
||||
self.ct2 = self.ct2 and (command.auth_req & SMP_CT2_AUTHREQ != 0)
|
||||
|
||||
# Check for OOB
|
||||
if command.oob_data_flag != 0:
|
||||
@@ -1091,8 +1150,8 @@ class Session:
|
||||
logger.debug(f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}')
|
||||
|
||||
# Key distribution
|
||||
self.initiator_key_distribution &= command.initiator_key_distribution
|
||||
self.responder_key_distribution &= command.responder_key_distribution
|
||||
self.initiator_key_distribution, self.responder_key_distribution = await self.pairing_config.delegate.key_distribution_response(
|
||||
command.initiator_key_distribution, command.responder_key_distribution)
|
||||
self.compute_peer_expected_distributions(self.initiator_key_distribution)
|
||||
|
||||
# The pairing is now starting
|
||||
@@ -1106,6 +1165,12 @@ class Session:
|
||||
# Respond
|
||||
self.send_pairing_response_command()
|
||||
|
||||
# Vol 3, Part C, 5.2.2.1.3
|
||||
# CTKD over BR/EDR should happen after the connection has been encrypted,
|
||||
# so when receiving pairing requests, responder should start distributing keys
|
||||
if self.connection.transport == BT_BR_EDR_TRANSPORT and self.connection.is_encrypted and self.is_responder and accepted:
|
||||
self.distribute_keys()
|
||||
|
||||
def on_smp_pairing_response_command(self, command):
|
||||
if self.is_responder:
|
||||
logger.warn(color('received pairing response as a responder', 'red'))
|
||||
@@ -1431,7 +1496,8 @@ class Manager(EventEmitter):
|
||||
|
||||
def send_command(self, connection, command):
|
||||
logger.debug(f'>>> Sending SMP Command on connection [0x{connection.handle:04X}] {connection.peer_address}: {command}')
|
||||
connection.send_l2cap_pdu(SMP_CID, command.to_bytes())
|
||||
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
|
||||
connection.send_l2cap_pdu(cid, command.to_bytes())
|
||||
|
||||
def on_smp_pdu(self, connection, pdu):
|
||||
# Look for a session with this connection, and create one if none exists
|
||||
|
||||
@@ -36,34 +36,51 @@ logger = logging.getLogger(__name__)
|
||||
async def open_usb_transport(spec):
|
||||
'''
|
||||
Open a USB transport.
|
||||
The parameter string has this syntax:
|
||||
either <index> or <vendor>:<product>[/<serial-number>]
|
||||
The moniker string has this syntax:
|
||||
either <index> or
|
||||
<vendor>:<product> or
|
||||
<vendor>:<product>/<serial-number>] or
|
||||
<vendor>:<product>#<index>
|
||||
With <index> as the 0-based index to select amongst all the devices that appear
|
||||
to be supporting Bluetooth HCI (0 being the first one), or
|
||||
Where <vendor> and <product> are the vendor ID and product ID in hexadecimal. The
|
||||
/<serial-number> suffix max be specified when more than one device with the same
|
||||
vendor and product identifiers are present.
|
||||
/<serial-number> suffix or #<index> suffix max be specified when more than one device with
|
||||
the same vendor and product identifiers are present.
|
||||
|
||||
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
|
||||
the first USB interface of the device will be used, regardless of the interface class/subclass.
|
||||
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
|
||||
|
||||
Examples:
|
||||
0 --> the first BT USB dongle
|
||||
04b4:f901 --> the BT USB dongle with vendor=04b4 and product=f901
|
||||
04b4:f901#2 --> the third USB device with vendor=04b4 and product=f901
|
||||
04b4:f901/00E04C239987 --> the BT USB dongle with vendor=04b4 and product=f901 and serial number 00E04C239987
|
||||
usb:0B05:17CB! --> the BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
|
||||
'''
|
||||
|
||||
USB_RECIPIENT_DEVICE = 0x00
|
||||
USB_REQUEST_TYPE_CLASS = 0x01 << 5
|
||||
USB_ENDPOINT_EVENTS_IN = 0x81
|
||||
USB_ENDPOINT_ACL_IN = 0x82
|
||||
USB_ENDPOINT_ACL_OUT = 0x02
|
||||
USB_DEVICE_CLASS_DEVICE = 0x00
|
||||
USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0
|
||||
USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01
|
||||
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
|
||||
USB_ENDPOINT_TRANSFER_TYPE_BULK = 0x02
|
||||
USB_ENDPOINT_TRANSFER_TYPE_INTERRUPT = 0x03
|
||||
USB_ENDPOINT_IN = 0x80
|
||||
|
||||
USB_BT_HCI_CLASS_TUPLE = (
|
||||
USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
|
||||
USB_DEVICE_SUBCLASS_RF_CONTROLLER,
|
||||
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
|
||||
)
|
||||
|
||||
READ_SIZE = 1024
|
||||
|
||||
class UsbPacketSink:
|
||||
def __init__(self, device):
|
||||
def __init__(self, device, acl_out):
|
||||
self.device = device
|
||||
self.acl_out = acl_out
|
||||
self.transfer = device.getTransfer()
|
||||
self.packets = collections.deque() # Queue of packets waiting to be sent
|
||||
self.loop = asyncio.get_running_loop()
|
||||
@@ -112,7 +129,7 @@ async def open_usb_transport(spec):
|
||||
packet_type = packet[0]
|
||||
if packet_type == hci.HCI_ACL_DATA_PACKET:
|
||||
self.transfer.setBulk(
|
||||
USB_ENDPOINT_ACL_OUT,
|
||||
self.acl_out,
|
||||
packet[1:],
|
||||
callback=self.on_packet_sent
|
||||
)
|
||||
@@ -148,10 +165,12 @@ async def open_usb_transport(spec):
|
||||
logger.debug('OUT transfer likely already completed')
|
||||
|
||||
class UsbPacketSource(asyncio.Protocol, ParserSource):
|
||||
def __init__(self, context, device):
|
||||
def __init__(self, context, device, acl_in, events_in):
|
||||
super().__init__()
|
||||
self.context = context
|
||||
self.device = device
|
||||
self.acl_in = acl_in
|
||||
self.events_in = events_in
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.queue = asyncio.Queue()
|
||||
self.closed = False
|
||||
@@ -168,7 +187,7 @@ async def open_usb_transport(spec):
|
||||
# Set up transfer objects for input
|
||||
self.events_in_transfer = device.getTransfer()
|
||||
self.events_in_transfer.setInterrupt(
|
||||
USB_ENDPOINT_EVENTS_IN,
|
||||
self.events_in,
|
||||
READ_SIZE,
|
||||
callback=self.on_packet_received,
|
||||
user_data=hci.HCI_EVENT_PACKET
|
||||
@@ -177,7 +196,7 @@ async def open_usb_transport(spec):
|
||||
|
||||
self.acl_in_transfer = device.getTransfer()
|
||||
self.acl_in_transfer.setBulk(
|
||||
USB_ENDPOINT_ACL_IN,
|
||||
self.acl_in,
|
||||
READ_SIZE,
|
||||
callback=self.on_packet_received,
|
||||
user_data=hci.HCI_ACL_DATA_PACKET
|
||||
@@ -190,7 +209,7 @@ async def open_usb_transport(spec):
|
||||
def on_packet_received(self, transfer):
|
||||
packet_type = transfer.getUserData()
|
||||
status = transfer.getStatus()
|
||||
# logger.debug(f'<<< USB IN transfer callback: status={status} packet_type={packet_type}')
|
||||
# logger.debug(f'<<< USB IN transfer callback: status={status} packet_type={packet_type} length={transfer.getActualLength()}')
|
||||
|
||||
if status == usb1.TRANSFER_COMPLETED:
|
||||
packet = bytes([packet_type]) + transfer.getBuffer()[:transfer.getActualLength()]
|
||||
@@ -244,7 +263,7 @@ async def open_usb_transport(spec):
|
||||
await self.event_loop_done
|
||||
|
||||
class UsbTransport(Transport):
|
||||
def __init__(self, context, device, interface, source, sink):
|
||||
def __init__(self, context, device, interface, setting, source, sink):
|
||||
super().__init__(source, sink)
|
||||
self.context = context
|
||||
self.device = device
|
||||
@@ -253,6 +272,10 @@ async def open_usb_transport(spec):
|
||||
# Get exclusive access
|
||||
device.claimInterface(interface)
|
||||
|
||||
# Set the alternate setting if not the default
|
||||
if setting != 0:
|
||||
device.setInterfaceAltSetting(interface, setting)
|
||||
|
||||
# The source and sink can now start
|
||||
source.start()
|
||||
sink.start()
|
||||
@@ -269,29 +292,60 @@ async def open_usb_transport(spec):
|
||||
context.open()
|
||||
try:
|
||||
found = None
|
||||
|
||||
if spec.endswith('!'):
|
||||
spec = spec[:-1]
|
||||
forced_mode = True
|
||||
else:
|
||||
forced_mode = False
|
||||
|
||||
if ':' in spec:
|
||||
vendor_id, product_id = spec.split(':')
|
||||
serial_number = None
|
||||
device_index = 0
|
||||
if '/' in product_id:
|
||||
product_id, serial_number = product_id.split('/')
|
||||
for device in context.getDeviceIterator(skip_on_error=True):
|
||||
if (
|
||||
device.getVendorID() == int(vendor_id, 16) and
|
||||
device.getProductID() == int(product_id, 16) and
|
||||
device.getSerialNumber() == serial_number
|
||||
):
|
||||
elif '#' in product_id:
|
||||
product_id, device_index_str = product_id.split('#')
|
||||
device_index = int(device_index_str)
|
||||
|
||||
for device in context.getDeviceIterator(skip_on_error=True):
|
||||
try:
|
||||
device_serial_number = device.getSerialNumber()
|
||||
except usb1.USBError:
|
||||
device_serial_number = None
|
||||
if (
|
||||
device.getVendorID() == int(vendor_id, 16) and
|
||||
device.getProductID() == int(product_id, 16) and
|
||||
(serial_number is None or serial_number == device_serial_number)
|
||||
):
|
||||
if device_index == 0:
|
||||
found = device
|
||||
break
|
||||
device.close()
|
||||
else:
|
||||
found = context.getByVendorIDAndProductID(int(vendor_id, 16), int(product_id, 16), skip_on_error=True)
|
||||
device_index -= 1
|
||||
device.close()
|
||||
else:
|
||||
# Look for a compatible device by index
|
||||
def device_is_bluetooth_hci(device):
|
||||
# Check if the device class indicates a match
|
||||
if (device.getDeviceClass(), device.getDeviceSubClass(), device.getDeviceProtocol()) == \
|
||||
USB_BT_HCI_CLASS_TUPLE:
|
||||
return True
|
||||
|
||||
# If the device class is 'Device', look for a matching interface
|
||||
if device.getDeviceClass() == USB_DEVICE_CLASS_DEVICE:
|
||||
for configuration in device:
|
||||
for interface in configuration:
|
||||
for setting in interface:
|
||||
if (setting.getClass(), setting.getSubClass(), setting.getProtocol()) == \
|
||||
USB_BT_HCI_CLASS_TUPLE:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
device_index = int(spec)
|
||||
for device in context.getDeviceIterator(skip_on_error=True):
|
||||
if (
|
||||
device.getDeviceClass() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER and
|
||||
device.getDeviceSubClass() == USB_DEVICE_SUBCLASS_RF_CONTROLLER and
|
||||
device.getDeviceProtocol() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
|
||||
):
|
||||
if device_is_bluetooth_hci(device):
|
||||
if device_index == 0:
|
||||
found = device
|
||||
break
|
||||
@@ -303,22 +357,63 @@ async def open_usb_transport(spec):
|
||||
raise ValueError('device not found')
|
||||
|
||||
logger.debug(f'USB Device: {found}')
|
||||
|
||||
# Look for the first interface with the right class and endpoints
|
||||
def find_endpoints(device):
|
||||
for (configuration_index, configuration) in enumerate(device):
|
||||
interface = None
|
||||
for interface in configuration:
|
||||
setting = None
|
||||
for setting in interface:
|
||||
if (
|
||||
not forced_mode and
|
||||
(setting.getClass(), setting.getSubClass(), setting.getProtocol()) != USB_BT_HCI_CLASS_TUPLE
|
||||
):
|
||||
continue
|
||||
|
||||
events_in = None
|
||||
acl_in = None
|
||||
acl_out = None
|
||||
for endpoint in setting:
|
||||
attributes = endpoint.getAttributes()
|
||||
address = endpoint.getAddress()
|
||||
if attributes & 0x03 == USB_ENDPOINT_TRANSFER_TYPE_BULK:
|
||||
if address & USB_ENDPOINT_IN and acl_in is None:
|
||||
acl_in = address
|
||||
elif acl_out is None:
|
||||
acl_out = address
|
||||
elif attributes & 0x03 == USB_ENDPOINT_TRANSFER_TYPE_INTERRUPT:
|
||||
if address & USB_ENDPOINT_IN and events_in is None:
|
||||
events_in = address
|
||||
|
||||
# Return if we found all 3 endpoints
|
||||
if acl_in is not None and acl_out is not None and events_in is not None:
|
||||
return (
|
||||
configuration_index + 1,
|
||||
setting.getNumber(),
|
||||
setting.getAlternateSetting(),
|
||||
acl_in,
|
||||
acl_out,
|
||||
events_in
|
||||
)
|
||||
else:
|
||||
logger.debug(f'skipping configuration {configuration_index + 1} / interface {setting.getNumber()}')
|
||||
|
||||
endpoints = find_endpoints(found)
|
||||
if endpoints is None:
|
||||
raise ValueError('no compatible interface found for device')
|
||||
(configuration, interface, setting, acl_in, acl_out, events_in) = endpoints
|
||||
logger.debug(
|
||||
f'selected endpoints: configuration={configuration}, '
|
||||
f'interface={interface}, '
|
||||
f'setting={setting}, '
|
||||
f'acl_in=0x{acl_in:02X}, '
|
||||
f'acl_out=0x{acl_out:02X}, '
|
||||
f'events_in=0x{events_in:02X}, '
|
||||
)
|
||||
|
||||
device = found.open()
|
||||
|
||||
# Set the configuration if needed
|
||||
try:
|
||||
configuration = device.getConfiguration()
|
||||
logger.debug(f'current configuration = {configuration}')
|
||||
except usb1.USBError:
|
||||
try:
|
||||
logger.debug('setting configuration 1')
|
||||
device.setConfiguration(1)
|
||||
except usb1.USBError:
|
||||
logger.debug('failed to set configuration 1')
|
||||
|
||||
# Use the first interface
|
||||
interface = 0
|
||||
|
||||
# Detach the kernel driver if supported and needed
|
||||
if usb1.hasCapability(usb1.CAP_SUPPORTS_DETACH_KERNEL_DRIVER):
|
||||
try:
|
||||
@@ -328,9 +423,23 @@ async def open_usb_transport(spec):
|
||||
except usb1.USBError:
|
||||
pass
|
||||
|
||||
source = UsbPacketSource(context, device)
|
||||
sink = UsbPacketSink(device)
|
||||
return UsbTransport(context, device, interface, source, sink)
|
||||
# Set the configuration if needed
|
||||
try:
|
||||
current_configuration = device.getConfiguration()
|
||||
logger.debug(f'current configuration = {current_configuration}')
|
||||
except usb1.USBError:
|
||||
current_configuration = 0
|
||||
|
||||
if current_configuration != configuration:
|
||||
try:
|
||||
logger.debug(f'setting configuration {configuration}')
|
||||
device.setConfiguration(configuration)
|
||||
except usb1.USBError:
|
||||
logger.warning('failed to set configuration')
|
||||
|
||||
source = UsbPacketSource(context, device, acl_in, events_in)
|
||||
sink = UsbPacketSink(device, acl_out)
|
||||
return UsbTransport(context, device, interface, setting, source, sink)
|
||||
except usb1.USBError as error:
|
||||
logger.warning(color(f'!!! failed to open USB device: {error}', 'red'))
|
||||
context.close()
|
||||
|
||||
@@ -45,6 +45,10 @@ nav:
|
||||
- HCI Bridge: apps_and_tools/hci_bridge.md
|
||||
- Golden Gate Bridge: apps_and_tools/gg_bridge.md
|
||||
- Show: apps_and_tools/show.md
|
||||
- GATT Dump: apps_and_tools/gatt_dump.md
|
||||
- Pair: apps_and_tools/pair.md
|
||||
- Unbond: apps_and_tools/unbond.md
|
||||
- USB Probe: apps_and_tools/usb_probe.md
|
||||
- Hardware:
|
||||
- Overview: hardware/index.md
|
||||
- Platforms:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# This requirements file is for python3
|
||||
mkdocs == 1.2.3
|
||||
mkdocs-material == 7.1.7
|
||||
mkdocs-material-extensions == 1.0.1
|
||||
pymdown-extensions == 8.2
|
||||
mkdocstrings == 0.15.1
|
||||
mkdocs == 1.4.0
|
||||
mkdocs-material == 8.5.6
|
||||
mkdocs-material-extensions == 1.0.3
|
||||
pymdown-extensions == 9.6
|
||||
mkdocstrings-python == 0.7.1
|
||||
@@ -7,10 +7,12 @@ The Console app is an interactive text user interface that offers a number of fu
|
||||
|
||||
* scanning
|
||||
* advertising
|
||||
* connecting to devices
|
||||
* connecting to and disconnecting from devices
|
||||
* changing connection parameters
|
||||
* enabling encryption
|
||||
* discovering GATT services and characteristics
|
||||
* read & write GATT characteristics
|
||||
* reading and writing GATT characteristics
|
||||
* subscribing to and unsubscribing from GATT characteristics
|
||||
|
||||
The console user interface has 3 main panes:
|
||||
|
||||
|
||||
50
docs/mkdocs/src/apps_and_tools/usb_probe.md
Normal file
50
docs/mkdocs/src/apps_and_tools/usb_probe.md
Normal file
@@ -0,0 +1,50 @@
|
||||
USB PROBE TOOL
|
||||
==============
|
||||
|
||||
This tool lists all the USB devices, with details about each device.
|
||||
For each device, the different possible Bumble transport strings that can
|
||||
refer to it are listed.
|
||||
If the device is known to be a Bluetooth HCI device, its identifier is printed
|
||||
in reverse colors, and the transport names in cyan color.
|
||||
For other devices, regardless of their type, the transport names are printed
|
||||
in red. Whether that device is actually a Bluetooth device or not depends on
|
||||
whether it is a Bluetooth device that uses a non-standard Class, or some other
|
||||
type of device (there's no way to tell).
|
||||
|
||||
## Usage
|
||||
|
||||
This command line tool may be invoked with no arguments, or with `--verbose`
|
||||
for extra details.
|
||||
When installed from PyPI, run as
|
||||
```
|
||||
$ bumble-usb-probe
|
||||
```
|
||||
|
||||
or, for extra details, with the `--verbose` argument
|
||||
```
|
||||
$ bumble-usb-probe --v
|
||||
```
|
||||
|
||||
When running from the source distribution:
|
||||
```
|
||||
$ python3 apps/usb-probe.py
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
$ python3 apps/usb-probe.py --verbose
|
||||
```
|
||||
|
||||
!!! example
|
||||
```
|
||||
$ python3 apps/usb_probe.py
|
||||
|
||||
ID 0A12:0001
|
||||
Bumble Transport Names: usb:0 or usb:0A12:0001
|
||||
Bus/Device: 020/034
|
||||
Class: Wireless Controller
|
||||
Subclass/Protocol: 1/1 [Bluetooth]
|
||||
Manufacturer: None
|
||||
Product: USB2.0-BT
|
||||
```
|
||||
@@ -1,48 +1,86 @@
|
||||
:material-linux: LINUX PLATFORM
|
||||
===============================
|
||||
|
||||
In addition to all the standard functionality available from the project by running the python tools and/or writing your own apps by leveraging the API, it is also possible on Linux hosts to interface the Bumble stack with the native BlueZ stack, and with Bluetooth controllers.
|
||||
Using Bumble With Physical Bluetooth Controllers
|
||||
------------------------------------------------
|
||||
|
||||
Using Bumble With BlueZ
|
||||
-----------------------
|
||||
A Bumble application can interface with a local Bluetooth controller on a Linux host.
|
||||
The 3 main types of physical Bluetooth controllers are:
|
||||
|
||||
A Bumble virtual controller can be attached to the BlueZ stack.
|
||||
Attaching a controller to BlueZ can be done by either simulating a UART HCI interface, or by using the VHCI driver interface if available.
|
||||
In both cases, the controller can run locally on the Linux host, or remotely on a different host, with a bridge between the remote controller and the local BlueZ host, which may be useful when the BlueZ stack is running on an embedded system, or a host on which running the Bumble controller is not convenient.
|
||||
* Bluetooth USB Dongle
|
||||
* HCI over UART (via a serial port)
|
||||
* Kernel-managed Bluetooth HCI (HCI Sockets)
|
||||
|
||||
### Using VHCI
|
||||
!!! tip "Conflicts with the kernel and BlueZ"
|
||||
If your use a USB dongle that is recognized by your kernel as a supported Bluetooth device, it is
|
||||
likely that the kernel driver will claim that USB device and attach it to the BlueZ stack.
|
||||
If you want to claim ownership of it to use with Bumble, you will need to set the state of the corresponding HCI interface as `DOWN`.
|
||||
HCI interfaces are numbered, starting from 0 (i.e `hci0`, `hci1`, ...).
|
||||
|
||||
With the [VHCI transport](../transports/vhci.md) you can attach a Bumble virtual controller to the BlueZ stack. Once attached, the controller will appear just like any other controller, and thus can be used with the standard BlueZ tools.
|
||||
|
||||
!!! example "Attaching a virtual controller"
|
||||
With the example app `run_controller.py`:
|
||||
For example, to bring `hci0` down:
|
||||
```
|
||||
PYTHONPATH=. python3 examples/run_controller.py F6:F7:F8:F9:FA:FB examples/device1.json vhci
|
||||
```
|
||||
|
||||
You should see a 'Virtual Bus' controller. For example:
|
||||
```
|
||||
$ hciconfig
|
||||
hci0: Type: Primary Bus: Virtual
|
||||
BD Address: F6:F7:F8:F9:FA:FB ACL MTU: 27:64 SCO MTU: 0:0
|
||||
UP RUNNING
|
||||
RX bytes:0 acl:0 sco:0 events:43 errors:0
|
||||
TX bytes:274 acl:0 sco:0 commands:43 errors:0
|
||||
$ sudo hciconfig hci0 down
|
||||
```
|
||||
|
||||
And scanning for devices should show the virtual 'Bumble' device that's running as part of the `run_controller.py` example app:
|
||||
You can use the `hciconfig` command with no arguments to get a list of HCI interfaces seen by
|
||||
the kernel.
|
||||
|
||||
Also, if `bluetoothd` is running on your system, it will likely re-claim the interface after you
|
||||
close it, so you may need to bring the interface back `UP` before using it again, or to disable
|
||||
`bluetoothd` altogether (see the section further below about BlueZ and `bluetoothd`).
|
||||
|
||||
### Using a USB Dongle
|
||||
|
||||
See the [USB Transport page](../transports/usb.md) for general information on how to use HCI USB controllers.
|
||||
|
||||
!!! tip "USB Permissions"
|
||||
By default, when running as a regular user, you won't have the permission to use
|
||||
arbitrary USB devices.
|
||||
You can change the permissions for a specific USB device based on its bus number and
|
||||
device number (you can use `lsusb` to find the Bus and Device numbers for your Bluetooth
|
||||
dongle).
|
||||
|
||||
Example:
|
||||
```
|
||||
pi@raspberrypi:~ $ sudo hcitool -i hci2 lescan
|
||||
LE Scan ...
|
||||
F0:F1:F2:F3:F4:F5 Bumble
|
||||
$ sudo chmod o+w /dev/bus/usb/001/004
|
||||
```
|
||||
This will change the permissions for Device 4 on Bus 1.
|
||||
|
||||
Note that the USB Bus number and Device number may change depending on where you plug the USB
|
||||
dongle and what other USB devices and hubs are also plugged in.
|
||||
|
||||
If you need to make the permission changes permanent across reboots, you can create a `udev`
|
||||
rule for your specific Bluetooth dongle. Visit [this Arch Linux Wiki page](https://wiki.archlinux.org/title/udev) for a
|
||||
good overview of how you may do that.
|
||||
|
||||
### Using HCI over UART
|
||||
|
||||
See the [Serial Transport page](../transports/serial.md) for general information on how to use HCI over a UART (serial port).
|
||||
|
||||
### Using HCI Sockets
|
||||
|
||||
HCI sockets provide a way to send/receive HCI packets to/from a Bluetooth controller managed by the kernel.
|
||||
The HCI device referenced by an `hci-socket` transport (`hciX`, where `X` is an integer, with `hci0` being the first controller device, and so on) must be in the `DOWN` state before it can be opened as a transport.
|
||||
You can bring a HCI controller `UP` or `DOWN` with `hciconfig`.
|
||||
See the [HCI Socket Transport page](../transports/hci_socket.md) for details on the `hci-socket` tansport syntax.
|
||||
|
||||
The HCI device referenced by an `hci-socket` transport (`hci<X>`, where `<X>` is an integer, with `hci0` being the first controller device, and so on) must be in the `DOWN` state before it can be opened as a transport.
|
||||
You can bring a HCI controller `UP` or `DOWN` with `hciconfig hci<X> up` and `hciconfig hci<X> up`.
|
||||
|
||||
!!! tip "HCI Socket Permissions"
|
||||
By default, when running as a regular user, you won't have the permission to use
|
||||
an HCI socket to a Bluetooth controller (you may see an exception like `PermissionError: [Errno 1] Operation not permitted`).
|
||||
|
||||
If you want to run without using `sudo`, you need to manage the capabilities by adding the appropriate entries in `/etc/security/capability.conf` to grant a user or group the `cap_net_admin` capability.
|
||||
See [this manpage](https://manpages.ubuntu.com/manpages/bionic/man5/capability.conf.5.html) for details.
|
||||
|
||||
Alternatively, if you are just experimenting temporarily, the `capsh` command may be useful in order
|
||||
to execute a single command with enhanced permissions, as in this example:
|
||||
|
||||
|
||||
```
|
||||
$ sudo capsh --caps="cap_net_admin+eip cap_setpcap,cap_setuid,cap_setgid+ep" --keep=1 --user=$USER --addamb=cap_net_admin -- -c "<path/to/executable> <executable-args>"
|
||||
```
|
||||
Where `<path/to/executable>` is the path to your `python3` executable or to one of the Bumble bundled command-line applications.
|
||||
|
||||
!!! tip "List all available controllers"
|
||||
The command
|
||||
```
|
||||
@@ -72,29 +110,16 @@ You can bring a HCI controller `UP` or `DOWN` with `hciconfig`.
|
||||
```
|
||||
$ hciconfig hci0 down
|
||||
```
|
||||
(or `hciX` with `X` being the index of the controller device you want to use), but a simpler solution is to just stop the `bluetoothd` daemon, with a command like:
|
||||
(or `hci<X>` with `<X>` being the index of the controller device you want to use), but a simpler solution is to just stop the `bluetoothd` daemon, with a command like:
|
||||
```
|
||||
$ sudo systemctl stop bluetooth.service
|
||||
```
|
||||
You can always re-start the daemon with
|
||||
```
|
||||
$ sudo systemctl start bluetooth.service
|
||||
```
|
||||
|
||||
### Using a Simulated UART HCI
|
||||
|
||||
### Bridge to a Remote Controller
|
||||
|
||||
|
||||
Using Bumble With Bluetooth Controllers
|
||||
---------------------------------------
|
||||
|
||||
A Bumble application can interface with a local Bluetooth controller.
|
||||
If your Bluetooth controller is a standard HCI USB controller, see the [USB Transport page](../transports/usb.md) for details on how to use HCI USB controllers.
|
||||
If your Bluetooth controller is a standard HCI UART controller, see the [Serial Transport page](../transports/serial.md).
|
||||
Alternatively, a Bumble Host object can communicate with one of the platform's controllers via an HCI Socket.
|
||||
|
||||
`<details to be filled in>`
|
||||
Bumble on the Raspberry Pi
|
||||
--------------------------
|
||||
|
||||
### Raspberry Pi 4 :fontawesome-brands-raspberry-pi:
|
||||
|
||||
@@ -102,9 +127,10 @@ You can use the Bluetooth controller either via the kernel, or directly to the d
|
||||
|
||||
#### Via The Kernel
|
||||
|
||||
Use an HCI Socket transport
|
||||
Use an HCI Socket transport (see section above)
|
||||
|
||||
#### Directly
|
||||
|
||||
In order to use the Bluetooth controller directly on a Raspberry Pi 4 board, you need to ensure that it isn't being used by the BlueZ stack (which it probably is by default).
|
||||
|
||||
```
|
||||
@@ -136,3 +162,47 @@ should detach the controller from the stack, after which you can use the HCI UAR
|
||||
python3 run_scanner.py serial:/dev/serial1,3000000
|
||||
```
|
||||
|
||||
|
||||
Using Bumble With BlueZ
|
||||
-----------------------
|
||||
|
||||
In addition to all the standard functionality available from the project by running the python tools and/or writing your own apps by leveraging the API, it is also possible on Linux hosts to interface the Bumble stack with the native BlueZ stack, and with Bluetooth controllers.
|
||||
|
||||
A Bumble virtual controller can be attached to the BlueZ stack.
|
||||
Attaching a controller to BlueZ can be done by either simulating a UART HCI interface, or by using the VHCI driver interface if available.
|
||||
In both cases, the controller can run locally on the Linux host, or remotely on a different host, with a bridge between the remote controller and the local BlueZ host, which may be useful when the BlueZ stack is running on an embedded system, or a host on which running the Bumble controller is not convenient.
|
||||
|
||||
### Using VHCI
|
||||
|
||||
With the [VHCI transport](../transports/vhci.md) you can attach a Bumble virtual controller to the BlueZ stack. Once attached, the controller will appear just like any other controller, and thus can be used with the standard BlueZ tools.
|
||||
|
||||
!!! example "Attaching a virtual controller"
|
||||
With the example app `run_controller.py`:
|
||||
```
|
||||
python3 examples/run_controller.py F6:F7:F8:F9:FA:FB examples/device1.json vhci
|
||||
```
|
||||
|
||||
You should see a 'Virtual Bus' controller. For example:
|
||||
```
|
||||
$ hciconfig
|
||||
hci0: Type: Primary Bus: Virtual
|
||||
BD Address: F6:F7:F8:F9:FA:FB ACL MTU: 27:64 SCO MTU: 0:0
|
||||
UP RUNNING
|
||||
RX bytes:0 acl:0 sco:0 events:43 errors:0
|
||||
TX bytes:274 acl:0 sco:0 commands:43 errors:0
|
||||
```
|
||||
|
||||
And scanning for devices should show the virtual 'Bumble' device that's running as part of the `run_controller.py` example app:
|
||||
```
|
||||
pi@raspberrypi:~ $ sudo hcitool -i hci2 lescan
|
||||
LE Scan ...
|
||||
F0:F1:F2:F3:F4:F5 Bumble
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
### Using a Simulated UART HCI
|
||||
|
||||
### Bridge to a Remote Controller
|
||||
|
||||
|
||||
|
||||
@@ -4,16 +4,65 @@ USB TRANSPORT
|
||||
The USB transport interfaces with a local Bluetooth USB dongle.
|
||||
|
||||
## Moniker
|
||||
The moniker for a USB transport is either `usb:<index>` or `usb:<vendor>:<product>`
|
||||
with `<index>` as the 0-based index to select amongst all the devices that appear to be supporting Bluetooth HCI (0 being the first one), or where `<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
|
||||
The moniker for a USB transport is either:
|
||||
|
||||
!!! example
|
||||
* `usb:<index>`
|
||||
* `usb:<vendor>:<product>`
|
||||
* `usb:<vendor>:<product>/<serial-number>`
|
||||
* `usb:<vendor>:<product>#<index>`
|
||||
|
||||
with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one.
|
||||
In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol.
|
||||
In the `usb:<vendor>:<product>#<index>` form, matching devices are the ones with the specified `<vendor>` and `<product>` identification.
|
||||
|
||||
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
|
||||
|
||||
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
|
||||
the first USB interface of the device will be used, regardless of the interface class/subclass.
|
||||
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
|
||||
|
||||
!!! examples
|
||||
`usb:04b4:f901`
|
||||
Use the USB dongle with `vendor` equal to `04b4` and `product` equal to `f901`
|
||||
The USB dongle with `<vendor>` equal to `04b4` and `<product>` equal to `f901`
|
||||
|
||||
`usb:0`
|
||||
Use the first Bluetooth dongle
|
||||
The first Bluetooth HCI dongle that's declared as such by Class/Subclass/Protocol
|
||||
|
||||
`usb:04b4:f901/0016A45B05D8`
|
||||
The USB dongle with `<vendor>` equal to `04b4`, `<product>` equal to `f901` and `<serial>` equal to `0016A45B05D8`
|
||||
|
||||
`usb:04b4:f901/#1`
|
||||
The second USB dongle with `<vendor>` equal to `04b4` and `<product>` equal to `f901`
|
||||
|
||||
`usb:0B05:17CB!`
|
||||
The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
|
||||
|
||||
|
||||
## Alternative
|
||||
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
|
||||
Using the transport prefix `pyusb:` instead of `usb:` selects the implementation based on [PyUSB](https://pypi.org/project/pyusb/), using the synchronous API of `libusb`, whereas the default implementation is based on [libusb1](https://pypi.org/project/libusb1/), using the asynchronous API of `libusb`. In order to use the alternative PyUSB-based implementation, you need to ensure that you have installed that python module, as it isn't installed by default as a dependency of Bumble.
|
||||
|
||||
## Listing Available USB Devices
|
||||
|
||||
### With `usb_probe`
|
||||
You can use the [`usb_probe`](../apps_and_tools/usb_probe.md) tool to list all the USB devices attached to your host computer.
|
||||
The tool will also show the `usb:XXX` transport name(s) you can use to reference each device.
|
||||
|
||||
|
||||
### With `lsusb`
|
||||
On Linux and macOS, the `lsusb` tool serves a similar purpose to Bumble's own `usb_probe` tool (without the Bumble specifics)
|
||||
|
||||
#### Installing lsusb
|
||||
|
||||
On Mac: `brew install lsusb`
|
||||
On Linux: `sudo apt-get install usbutils`
|
||||
|
||||
#### Using lsusb
|
||||
|
||||
```
|
||||
$ lsusb
|
||||
Bus 004 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
|
||||
Bus 003 Device 014: ID 0b05:17cb ASUSTek Computer, Inc. Broadcom BCM20702A0 Bluetooth
|
||||
```
|
||||
|
||||
The device id for the Bluetooth interface in this case is `0b05:17cb`.
|
||||
@@ -43,28 +43,24 @@ async def main():
|
||||
# Connect to the peer
|
||||
target_address = sys.argv[2]
|
||||
print(f'=== Connecting to {target_address}...')
|
||||
connection = await device.connect(target_address)
|
||||
print(f'=== Connected to {connection}')
|
||||
async with device.connect_as_gatt(target_address) as peer:
|
||||
print(f'=== Connected to {peer}')
|
||||
battery_service = peer.create_service_proxy(BatteryServiceProxy)
|
||||
|
||||
# Discover the Battery Service
|
||||
peer = Peer(connection)
|
||||
print('=== Discovering Battery Service')
|
||||
battery_service = await peer.discover_and_create_service_proxy(BatteryServiceProxy)
|
||||
# Check that the service was found
|
||||
if not battery_service:
|
||||
print('!!! Service not found')
|
||||
return
|
||||
|
||||
# Check that the service was found
|
||||
if not battery_service:
|
||||
print('!!! Service not found')
|
||||
return
|
||||
# Subscribe to and read the battery level
|
||||
if battery_service.battery_level:
|
||||
await battery_service.battery_level.subscribe(
|
||||
lambda value: print(f'{color("Battery Level Update:", "green")} {value}')
|
||||
)
|
||||
value = await battery_service.battery_level.read_value()
|
||||
print(f'{color("Initial Battery Level:", "green")} {value}')
|
||||
|
||||
# Subscribe to and read the battery level
|
||||
if battery_service.battery_level:
|
||||
await battery_service.battery_level.subscribe(
|
||||
lambda value: print(f'{color("Battery Level Update:", "green")} {value}')
|
||||
)
|
||||
value = await battery_service.battery_level.read_value()
|
||||
print(f'{color("Initial Battery Level:", "green")} {value}')
|
||||
|
||||
await hci_source.wait_for_termination()
|
||||
await peer.sustain()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -38,7 +38,7 @@ async def main():
|
||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||
|
||||
# Add a Device Information Service and Battery Service to the GATT sever
|
||||
# Add a Battery Service to the GATT sever
|
||||
battery_service = BatteryService(lambda _: random.randint(0, 100))
|
||||
device.add_service(battery_service)
|
||||
|
||||
|
||||
72
examples/heart_rate_client.py
Normal file
72
examples/heart_rate_client.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from colors import color
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.transport import open_transport
|
||||
from bumble.profiles.heart_rate_service import HeartRateServiceProxy
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main():
|
||||
if len(sys.argv) != 3:
|
||||
print('Usage: heart_rate_client.py <transport-spec> <bluetooth-address>')
|
||||
print('example: heart_rate_client.py usb:0 E1:CA:72:48:C4:E8')
|
||||
return
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport(sys.argv[1]) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
|
||||
# Create and start a device
|
||||
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
|
||||
await device.power_on()
|
||||
|
||||
# Connect to the peer
|
||||
target_address = sys.argv[2]
|
||||
print(f'=== Connecting to {target_address}...')
|
||||
async with device.connect_as_gatt(target_address) as peer:
|
||||
print(f'=== Connected to {peer}')
|
||||
|
||||
heart_rate_service = peer.create_service_proxy(HeartRateServiceProxy)
|
||||
|
||||
# Check that the service was found
|
||||
if not heart_rate_service:
|
||||
print('!!! Service not found')
|
||||
return
|
||||
|
||||
# Read the body sensor location
|
||||
if heart_rate_service.body_sensor_location:
|
||||
location = await heart_rate_service.body_sensor_location.read_value()
|
||||
print(color('Sensor Location:', 'green'), location)
|
||||
|
||||
# Subscribe to the heart rate measurement
|
||||
if heart_rate_service.heart_rate_measurement:
|
||||
await heart_rate_service.heart_rate_measurement.subscribe(
|
||||
lambda value: print(f'{color("Heart Rate Measurement:", "green")} {value}')
|
||||
)
|
||||
|
||||
await peer.sustain()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
asyncio.run(main())
|
||||
95
examples/heart_rate_server.py
Normal file
95
examples/heart_rate_server.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import sys
|
||||
import time
|
||||
import math
|
||||
import random
|
||||
import struct
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.profiles.device_information_service import DeviceInformationService
|
||||
from bumble.profiles.heart_rate_service import HeartRateService
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main():
|
||||
if len(sys.argv) != 3:
|
||||
print('Usage: python heart_rate_server.py <device-config> <transport-spec>')
|
||||
print('example: python heart_rate_server.py device1.json usb:0')
|
||||
return
|
||||
|
||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||
|
||||
# Keep track of accumulated expended energy
|
||||
energy_start_time = time.time()
|
||||
|
||||
def reset_energy_expended():
|
||||
nonlocal energy_start_time
|
||||
energy_start_time = time.time()
|
||||
|
||||
# Add a Device Information Service and Heart Rate Service to the GATT sever
|
||||
device_information_service = DeviceInformationService(
|
||||
manufacturer_name = 'ACME',
|
||||
model_number = 'HR-102',
|
||||
serial_number = '7654321',
|
||||
hardware_revision = '1.1.3',
|
||||
software_revision = '2.5.6',
|
||||
system_id = (0x123456, 0x8877665544)
|
||||
)
|
||||
|
||||
heart_rate_service = HeartRateService(
|
||||
read_heart_rate_measurement = lambda _: HeartRateService.HeartRateMeasurement(
|
||||
heart_rate = 100 + int(50 * math.sin(time.time() * math.pi / 60)),
|
||||
sensor_contact_detected = random.choice((True, False, None)),
|
||||
energy_expended = random.choice((int((time.time() - energy_start_time) * 100), None)),
|
||||
rr_intervals = random.choice(((random.randint(900, 1100) / 1000, random.randint(900, 1100) / 1000), None))
|
||||
),
|
||||
body_sensor_location=HeartRateService.BodySensorLocation.WRIST,
|
||||
reset_energy_expended=lambda _: reset_energy_expended()
|
||||
)
|
||||
|
||||
device.add_services([device_information_service, heart_rate_service])
|
||||
|
||||
# Set the advertising data
|
||||
device.advertising_data = bytes(
|
||||
AdvertisingData([
|
||||
(AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble Heart', 'utf-8')),
|
||||
(AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, bytes(heart_rate_service.uuid)),
|
||||
(AdvertisingData.APPEARANCE, struct.pack('<H', 0x0340))
|
||||
])
|
||||
)
|
||||
|
||||
# Go!
|
||||
await device.power_on()
|
||||
await device.start_advertising(auto_restart=True)
|
||||
|
||||
# Notify every 3 seconds
|
||||
while True:
|
||||
await asyncio.sleep(3.0)
|
||||
await device.notify_subscribers(heart_rate_service.heart_rate_measurement_characteristic)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
asyncio.run(main())
|
||||
13
setup.cfg
13
setup.cfg
@@ -16,13 +16,15 @@
|
||||
name = bumble
|
||||
use_scm_version = True
|
||||
description = Bluetooth Stack for Apps, Emulation, Test and Experimentation
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
author = Google
|
||||
author_email = tbd@tbd.com
|
||||
url = https://github.com/google/bumble
|
||||
|
||||
[options]
|
||||
python_requires = >=3.8
|
||||
packages = bumble, bumble.transport, bumble.apps, bumble.apps.link_relay
|
||||
packages = bumble, bumble.transport, bumble.profiles, bumble.apps, bumble.apps.link_relay
|
||||
package_dir =
|
||||
bumble = bumble
|
||||
bumble.apps = apps
|
||||
@@ -46,23 +48,26 @@ install_requires =
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
bumble-console = bumble.apps.console:main
|
||||
bumble-controller-info = bumble.apps.controller_info:main
|
||||
bumble-gatt-dump = bumble.apps.gatt_dump:main
|
||||
bumble-hci-bridge = bumble.apps.hci_bridge:main
|
||||
bumble-pair = bumble.apps.pair:main
|
||||
bumble-scan = bumble.apps.scan:main
|
||||
bumble-show = bumble.apps.show:main
|
||||
bumble-unbond = bumble.apps.unbond:main
|
||||
bumble-usb-probe = bumble.apps.usb_probe:main
|
||||
bumble-link-relay = bumble.apps.link_relay.link_relay:main
|
||||
|
||||
[options.extras_require]
|
||||
build =
|
||||
build >= 0.7
|
||||
test =
|
||||
pytest >= 6.2
|
||||
pytest-asyncio >= 0.17
|
||||
development =
|
||||
invoke >= 1.4
|
||||
build >= 0.7
|
||||
nox >= 2022
|
||||
documentation =
|
||||
mkdocs >= 1.2.3
|
||||
mkdocs-material >= 8.1.9
|
||||
mkdocs >= 1.4.0
|
||||
mkdocs-material >= 8.5.6
|
||||
mkdocstrings[python] >= 0.19.0
|
||||
|
||||
47
tasks.py
47
tasks.py
@@ -23,35 +23,52 @@ ROOT_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
ns = Collection()
|
||||
|
||||
# Building
|
||||
build_tasks = Collection()
|
||||
ns.add_collection(build_tasks, name='build')
|
||||
|
||||
ns.add_collection(build_tasks, name="build")
|
||||
|
||||
@task
|
||||
def build(ctx):
|
||||
ctx.run('python -m build')
|
||||
def build(ctx, install=False):
|
||||
if install:
|
||||
ctx.run('python -m pip install .[build]')
|
||||
|
||||
build_tasks.add_task(build, default=True, name='build')
|
||||
ctx.run("python -m build")
|
||||
|
||||
build_tasks.add_task(build, default=True)
|
||||
|
||||
@task
|
||||
def release_build(ctx):
|
||||
build(ctx, install=True)
|
||||
|
||||
build_tasks.add_task(release_build, name="release")
|
||||
|
||||
@task
|
||||
def mkdocs(ctx):
|
||||
ctx.run("mkdocs build -f docs/mkdocs/mkdocs.yml")
|
||||
|
||||
build_tasks.add_task(mkdocs, name="mkdocs")
|
||||
|
||||
# Testing
|
||||
test_tasks = Collection()
|
||||
ns.add_collection(test_tasks, name='test')
|
||||
ns.add_collection(test_tasks, name="test")
|
||||
|
||||
@task
|
||||
def test(ctx, filter=None, junit=False):
|
||||
def test(ctx, filter=None, junit=False, install=False):
|
||||
# Install the package before running the tests
|
||||
if install:
|
||||
ctx.run("python -m pip install .[test]")
|
||||
|
||||
args = ""
|
||||
if junit:
|
||||
args += "--junit-xml test-results.xml"
|
||||
if filter is not None:
|
||||
args += " -k '{}'".format(filter)
|
||||
ctx.run('python -m pytest {} {}'
|
||||
.format(os.path.join(ROOT_DIR, "tests"), args))
|
||||
|
||||
test_tasks.add_task(test, name='test', default=True)
|
||||
ctx.run("python -m pytest {} {}".format(os.path.join(ROOT_DIR, "tests"), args))
|
||||
|
||||
test_tasks.add_task(test, default=True)
|
||||
|
||||
@task
|
||||
def mkdocs(ctx):
|
||||
ctx.run('mkdocs build -f docs/mkdocs/mkdocs.yml')
|
||||
def release_test(ctx):
|
||||
test(ctx, install=True)
|
||||
|
||||
|
||||
ns.add_task(mkdocs)
|
||||
test_tasks.add_task(release_test, name="release")
|
||||
|
||||
@@ -22,6 +22,7 @@ import struct
|
||||
import pytest
|
||||
|
||||
from bumble.controller import Controller
|
||||
from bumble.gatt_client import CharacteristicProxy
|
||||
from bumble.link import LocalLink
|
||||
from bumble.device import Device, Peer
|
||||
from bumble.host import Host
|
||||
@@ -53,29 +54,29 @@ def basic_check(x):
|
||||
parsed = ATT_PDU.from_bytes(pdu)
|
||||
x_str = str(x)
|
||||
parsed_str = str(parsed)
|
||||
assert(x_str == parsed_str)
|
||||
assert x_str == parsed_str
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_UUID():
|
||||
u = UUID.from_16_bits(0x7788)
|
||||
assert(str(u) == 'UUID-16:7788')
|
||||
assert str(u) == 'UUID-16:7788'
|
||||
u = UUID.from_32_bits(0x11223344)
|
||||
assert(str(u) == 'UUID-32:11223344')
|
||||
assert str(u) == 'UUID-32:11223344'
|
||||
u = UUID('61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
|
||||
assert(str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
|
||||
assert str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
||||
v = UUID(str(u))
|
||||
assert(str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
|
||||
assert str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
||||
w = UUID.from_bytes(v.to_bytes())
|
||||
assert(str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
|
||||
assert str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
|
||||
|
||||
u1 = UUID.from_16_bits(0x1234)
|
||||
b1 = u1.to_bytes(force_128 = True)
|
||||
u2 = UUID.from_bytes(b1)
|
||||
assert(u1 == u2)
|
||||
assert u1 == u2
|
||||
|
||||
u3 = UUID.from_16_bits(0x180a)
|
||||
assert(str(u3) == 'UUID-16:180A (Device Information)')
|
||||
assert str(u3) == 'UUID-16:180A (Device Information)'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -98,6 +99,133 @@ def test_ATT_Read_By_Group_Type_Request():
|
||||
basic_check(pdu)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_characteristic_encoding():
|
||||
class Foo(Characteristic):
|
||||
def encode_value(self, value):
|
||||
return bytes([value])
|
||||
|
||||
def decode_value(self, value_bytes):
|
||||
return value_bytes[0]
|
||||
|
||||
c = Foo(GATT_BATTERY_LEVEL_CHARACTERISTIC, Characteristic.READ, Characteristic.READABLE, 123)
|
||||
x = c.read_value(None)
|
||||
assert x == bytes([123])
|
||||
c.write_value(None, bytes([122]))
|
||||
assert c.value == 122
|
||||
|
||||
class FooProxy(CharacteristicProxy):
|
||||
def __init__(self, characteristic):
|
||||
super().__init__(
|
||||
characteristic.client,
|
||||
characteristic.handle,
|
||||
characteristic.end_group_handle,
|
||||
characteristic.uuid,
|
||||
characteristic.properties
|
||||
)
|
||||
|
||||
def encode_value(self, value):
|
||||
return bytes([value])
|
||||
|
||||
def decode_value(self, value_bytes):
|
||||
return value_bytes[0]
|
||||
|
||||
[client, server] = LinkedDevices().devices[:2]
|
||||
|
||||
characteristic = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([123])
|
||||
)
|
||||
|
||||
service = Service(
|
||||
'3A657F47-D34F-46B3-B1EC-698E29B6B829',
|
||||
[characteristic]
|
||||
)
|
||||
server.add_service(service)
|
||||
|
||||
await client.power_on()
|
||||
await server.power_on()
|
||||
connection = await client.connect(server.random_address)
|
||||
peer = Peer(connection)
|
||||
|
||||
await peer.discover_services()
|
||||
await peer.discover_characteristics()
|
||||
c = peer.get_characteristics_by_uuid(characteristic.uuid)
|
||||
assert len(c) == 1
|
||||
c = c[0]
|
||||
cp = FooProxy(c)
|
||||
|
||||
v = await cp.read_value()
|
||||
assert v == 123
|
||||
await cp.write_value(124)
|
||||
await async_barrier()
|
||||
assert characteristic.value == bytes([124])
|
||||
|
||||
v = await cp.read_value()
|
||||
assert v == 124
|
||||
await cp.write_value(125, with_response=True)
|
||||
await async_barrier()
|
||||
assert characteristic.value == bytes([125])
|
||||
|
||||
cd = DelegatedCharacteristicAdapter(c, encode=lambda x: bytes([x // 2]))
|
||||
await cd.write_value(100, with_response=True)
|
||||
await async_barrier()
|
||||
assert characteristic.value == bytes([50])
|
||||
|
||||
last_change = None
|
||||
|
||||
def on_change(value):
|
||||
nonlocal last_change
|
||||
last_change = value
|
||||
|
||||
await c.subscribe(on_change)
|
||||
await server.notify_subscribers(characteristic)
|
||||
await async_barrier()
|
||||
assert last_change == characteristic.value
|
||||
last_change = None
|
||||
|
||||
await server.notify_subscribers(characteristic, value=bytes([125]))
|
||||
await async_barrier()
|
||||
assert last_change == bytes([125])
|
||||
last_change = None
|
||||
|
||||
await c.unsubscribe(on_change)
|
||||
await server.notify_subscribers(characteristic)
|
||||
await async_barrier()
|
||||
assert last_change is None
|
||||
|
||||
await cp.subscribe(on_change)
|
||||
await server.notify_subscribers(characteristic)
|
||||
await async_barrier()
|
||||
assert last_change == characteristic.value[0]
|
||||
last_change = None
|
||||
|
||||
await server.notify_subscribers(characteristic, value=bytes([126]))
|
||||
await async_barrier()
|
||||
assert last_change == 126
|
||||
last_change = None
|
||||
|
||||
await cp.unsubscribe(on_change)
|
||||
await server.notify_subscribers(characteristic)
|
||||
await async_barrier()
|
||||
assert last_change is None
|
||||
|
||||
cd = DelegatedCharacteristicAdapter(c, decode=lambda x: x[0])
|
||||
await cd.subscribe(on_change)
|
||||
await server.notify_subscribers(characteristic)
|
||||
await async_barrier()
|
||||
assert last_change == characteristic.value[0]
|
||||
last_change = None
|
||||
|
||||
await cd.unsubscribe(on_change)
|
||||
await server.notify_subscribers(characteristic)
|
||||
await async_barrier()
|
||||
assert last_change is None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_CharacteristicAdapter():
|
||||
# Check that the CharacteristicAdapter base class is transparent
|
||||
@@ -106,21 +234,21 @@ def test_CharacteristicAdapter():
|
||||
a = CharacteristicAdapter(c)
|
||||
|
||||
value = a.read_value(None)
|
||||
assert(value == v)
|
||||
assert value == v
|
||||
|
||||
v = bytes([3, 4, 5])
|
||||
a.write_value(None, v)
|
||||
assert(c.value == v)
|
||||
assert c.value == v
|
||||
|
||||
# Simple delegated adapter
|
||||
a = DelegatedCharacteristicAdapter(c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x)))
|
||||
|
||||
value = a.read_value(None)
|
||||
assert(value == bytes(reversed(v)))
|
||||
assert value == bytes(reversed(v))
|
||||
|
||||
v = bytes([3, 4, 5])
|
||||
a.write_value(None, v)
|
||||
assert(a.value == bytes(reversed(v)))
|
||||
assert a.value == bytes(reversed(v))
|
||||
|
||||
# Packed adapter with single element format
|
||||
v = 1234
|
||||
@@ -129,10 +257,10 @@ def test_CharacteristicAdapter():
|
||||
a = PackedCharacteristicAdapter(c, '>H')
|
||||
|
||||
value = a.read_value(None)
|
||||
assert(value == pv)
|
||||
assert value == pv
|
||||
c.value = None
|
||||
a.write_value(None, pv)
|
||||
assert(a.value == v)
|
||||
assert a.value == v
|
||||
|
||||
# Packed adapter with multi-element format
|
||||
v1 = 1234
|
||||
@@ -142,10 +270,10 @@ def test_CharacteristicAdapter():
|
||||
a = PackedCharacteristicAdapter(c, '>HH')
|
||||
|
||||
value = a.read_value(None)
|
||||
assert(value == pv)
|
||||
assert value == pv
|
||||
c.value = None
|
||||
a.write_value(None, pv)
|
||||
assert(a.value == (v1, v2))
|
||||
assert a.value == (v1, v2)
|
||||
|
||||
# Mapped adapter
|
||||
v1 = 1234
|
||||
@@ -156,10 +284,10 @@ def test_CharacteristicAdapter():
|
||||
a = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2'))
|
||||
|
||||
value = a.read_value(None)
|
||||
assert(value == pv)
|
||||
assert value == pv
|
||||
c.value = None
|
||||
a.write_value(None, pv)
|
||||
assert(a.value == mapped)
|
||||
assert a.value == mapped
|
||||
|
||||
# UTF-8 adapter
|
||||
v = 'Hello π'
|
||||
@@ -168,10 +296,10 @@ def test_CharacteristicAdapter():
|
||||
a = UTF8CharacteristicAdapter(c)
|
||||
|
||||
value = a.read_value(None)
|
||||
assert(value == ev)
|
||||
assert value == ev
|
||||
c.value = None
|
||||
a.write_value(None, ev)
|
||||
assert(a.value == v)
|
||||
assert a.value == v
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -179,24 +307,25 @@ def test_CharacteristicValue():
|
||||
b = bytes([1, 2, 3])
|
||||
c = CharacteristicValue(read=lambda _: b)
|
||||
x = c.read(None)
|
||||
assert(x == b)
|
||||
assert x == b
|
||||
|
||||
result = []
|
||||
c = CharacteristicValue(write=lambda connection, value: result.append((connection, value)))
|
||||
z = object()
|
||||
c.write(z, b)
|
||||
assert(result == [(z, b)])
|
||||
assert result == [(z, b)]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class TwoDevices:
|
||||
class LinkedDevices:
|
||||
def __init__(self):
|
||||
self.connections = [None, None]
|
||||
self.connections = [None, None, None]
|
||||
|
||||
self.link = LocalLink()
|
||||
self.controllers = [
|
||||
Controller('C1', link = self.link),
|
||||
Controller('C2', link = self.link)
|
||||
Controller('C2', link = self.link),
|
||||
Controller('C3', link = self.link)
|
||||
]
|
||||
self.devices = [
|
||||
Device(
|
||||
@@ -204,12 +333,16 @@ class TwoDevices:
|
||||
host = Host(self.controllers[0], AsyncPipeSink(self.controllers[0]))
|
||||
),
|
||||
Device(
|
||||
address = 'F5:F4:F3:F2:F1:F0',
|
||||
address = 'F1:F2:F3:F4:F5:F6',
|
||||
host = Host(self.controllers[1], AsyncPipeSink(self.controllers[1]))
|
||||
),
|
||||
Device(
|
||||
address = 'F2:F3:F4:F5:F6:F7',
|
||||
host = Host(self.controllers[2], AsyncPipeSink(self.controllers[2]))
|
||||
)
|
||||
]
|
||||
|
||||
self.paired = [None, None]
|
||||
self.paired = [None, None, None]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -222,7 +355,7 @@ async def async_barrier():
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_write():
|
||||
[client, server] = TwoDevices().devices
|
||||
[client, server] = LinkedDevices().devices[:2]
|
||||
|
||||
characteristic1 = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
@@ -265,41 +398,41 @@ async def test_read_write():
|
||||
await peer.discover_services()
|
||||
await peer.discover_characteristics()
|
||||
c = peer.get_characteristics_by_uuid(characteristic1.uuid)
|
||||
assert(len(c) == 1)
|
||||
assert len(c) == 1
|
||||
c1 = c[0]
|
||||
c = peer.get_characteristics_by_uuid(characteristic2.uuid)
|
||||
assert(len(c) == 1)
|
||||
assert len(c) == 1
|
||||
c2 = c[0]
|
||||
|
||||
v1 = await peer.read_value(c1)
|
||||
assert(v1 == b'')
|
||||
assert v1 == b''
|
||||
b = bytes([1, 2, 3])
|
||||
await peer.write_value(c1, b)
|
||||
await async_barrier()
|
||||
assert(characteristic1.value == b)
|
||||
assert characteristic1.value == b
|
||||
v1 = await peer.read_value(c1)
|
||||
assert(v1 == b)
|
||||
assert(type(characteristic1._last_value) is tuple)
|
||||
assert(len(characteristic1._last_value) == 2)
|
||||
assert(str(characteristic1._last_value[0].peer_address) == str(client.random_address))
|
||||
assert(characteristic1._last_value[1] == b)
|
||||
assert v1 == b
|
||||
assert type(characteristic1._last_value is tuple)
|
||||
assert len(characteristic1._last_value) == 2
|
||||
assert str(characteristic1._last_value[0].peer_address) == str(client.random_address)
|
||||
assert characteristic1._last_value[1] == b
|
||||
bb = bytes([3, 4, 5, 6])
|
||||
characteristic1.value = bb
|
||||
v1 = await peer.read_value(c1)
|
||||
assert(v1 == bb)
|
||||
assert v1 == bb
|
||||
|
||||
await peer.write_value(c2, b)
|
||||
await async_barrier()
|
||||
assert(type(characteristic2._last_value) is tuple)
|
||||
assert(len(characteristic2._last_value) == 2)
|
||||
assert(str(characteristic2._last_value[0].peer_address) == str(client.random_address))
|
||||
assert(characteristic2._last_value[1] == b)
|
||||
assert type(characteristic2._last_value is tuple)
|
||||
assert len(characteristic2._last_value) == 2
|
||||
assert str(characteristic2._last_value[0].peer_address) == str(client.random_address)
|
||||
assert characteristic2._last_value[1] == b
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_write2():
|
||||
[client, server] = TwoDevices().devices
|
||||
[client, server] = LinkedDevices().devices[:2]
|
||||
|
||||
v = bytes([0x11, 0x22, 0x33, 0x44])
|
||||
characteristic1 = Characteristic(
|
||||
@@ -324,32 +457,32 @@ async def test_read_write2():
|
||||
|
||||
await peer.discover_services()
|
||||
c = peer.get_services_by_uuid(service1.uuid)
|
||||
assert(len(c) == 1)
|
||||
assert len(c) == 1
|
||||
s = c[0]
|
||||
await s.discover_characteristics()
|
||||
c = s.get_characteristics_by_uuid(characteristic1.uuid)
|
||||
assert(len(c) == 1)
|
||||
assert len(c) == 1
|
||||
c1 = c[0]
|
||||
|
||||
v1 = await c1.read_value()
|
||||
assert(v1 == v)
|
||||
assert v1 == v
|
||||
|
||||
a1 = PackedCharacteristicAdapter(c1, '>I')
|
||||
v1 = await a1.read_value()
|
||||
assert(v1 == struct.unpack('>I', v)[0])
|
||||
assert v1 == struct.unpack('>I', v)[0]
|
||||
|
||||
b = bytes([0x55, 0x66, 0x77, 0x88])
|
||||
await a1.write_value(struct.unpack('>I', b)[0])
|
||||
await async_barrier()
|
||||
assert(characteristic1.value == b)
|
||||
assert characteristic1.value == b
|
||||
v1 = await a1.read_value()
|
||||
assert(v1 == struct.unpack('>I', b)[0])
|
||||
assert v1 == struct.unpack('>I', b)[0]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_notify():
|
||||
[client, server] = TwoDevices().devices
|
||||
[client, server] = LinkedDevices().devices[:2]
|
||||
|
||||
characteristic1 = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
@@ -410,68 +543,144 @@ async def test_subscribe_notify():
|
||||
await peer.discover_services()
|
||||
await peer.discover_characteristics()
|
||||
c = peer.get_characteristics_by_uuid(characteristic1.uuid)
|
||||
assert(len(c) == 1)
|
||||
assert len(c) == 1
|
||||
c1 = c[0]
|
||||
c = peer.get_characteristics_by_uuid(characteristic2.uuid)
|
||||
assert(len(c) == 1)
|
||||
assert len(c) == 1
|
||||
c2 = c[0]
|
||||
c = peer.get_characteristics_by_uuid(characteristic3.uuid)
|
||||
assert(len(c) == 1)
|
||||
assert len(c) == 1
|
||||
c3 = c[0]
|
||||
|
||||
c1._called = False
|
||||
c1._last_update = None
|
||||
|
||||
def on_c1_update(connection, value):
|
||||
c1._last_update = (connection, value)
|
||||
def on_c1_update(value):
|
||||
c1._called = True
|
||||
c1._last_update = value
|
||||
|
||||
c1.on('update', on_c1_update)
|
||||
await peer.subscribe(c1)
|
||||
await async_barrier()
|
||||
assert(server._last_subscription[1] == characteristic1)
|
||||
assert(server._last_subscription[2])
|
||||
assert(not server._last_subscription[3])
|
||||
assert(characteristic1._last_subscription[1])
|
||||
assert(not characteristic1._last_subscription[2])
|
||||
assert server._last_subscription[1] == characteristic1
|
||||
assert server._last_subscription[2]
|
||||
assert not server._last_subscription[3]
|
||||
assert characteristic1._last_subscription[1]
|
||||
assert not characteristic1._last_subscription[2]
|
||||
await server.indicate_subscribers(characteristic1)
|
||||
await async_barrier()
|
||||
assert(c1._last_update is None)
|
||||
assert not c1._called
|
||||
await server.notify_subscribers(characteristic1)
|
||||
await async_barrier()
|
||||
assert(c1._last_update is not None)
|
||||
assert(c1._last_update[1] == characteristic1.value)
|
||||
assert c1._called
|
||||
assert c1._last_update == characteristic1.value
|
||||
|
||||
c1._called = False
|
||||
c1._last_update = None
|
||||
c1_value = characteristic1.value
|
||||
await server.notify_subscribers(characteristic1, bytes([0, 1, 2]))
|
||||
await async_barrier()
|
||||
assert c1._called
|
||||
assert c1._last_update == bytes([0, 1, 2])
|
||||
assert characteristic1.value == c1_value
|
||||
|
||||
c1._called = False
|
||||
await peer.unsubscribe(c1)
|
||||
await server.notify_subscribers(characteristic1)
|
||||
assert not c1._called
|
||||
|
||||
c2._called = False
|
||||
c2._last_update = None
|
||||
|
||||
def on_c2_update(value):
|
||||
c2._last_update = (connection, value)
|
||||
c2._called = True
|
||||
c2._last_update = value
|
||||
|
||||
await peer.subscribe(c2, on_c2_update)
|
||||
await async_barrier()
|
||||
await server.notify_subscriber(characteristic2._last_subscription[0], characteristic2)
|
||||
await async_barrier()
|
||||
assert(c2._last_update is None)
|
||||
assert not c2._called
|
||||
await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
|
||||
await async_barrier()
|
||||
assert(c2._last_update is not None)
|
||||
assert(c2._last_update[1] == characteristic2.value)
|
||||
assert c2._called
|
||||
assert c2._last_update == characteristic2.value
|
||||
|
||||
c3._last_update = None
|
||||
c2._called = False
|
||||
await peer.unsubscribe(c2, on_c2_update)
|
||||
await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
|
||||
await async_barrier()
|
||||
assert not c2._called
|
||||
|
||||
def on_c3_update(connection, value):
|
||||
c3._last_update = (connection, value)
|
||||
def on_c3_update(value):
|
||||
c3._called = True
|
||||
c3._last_update = value
|
||||
|
||||
def on_c3_update_2(value):
|
||||
c3._called_2 = True
|
||||
c3._last_update_2 = value
|
||||
|
||||
c3.on('update', on_c3_update)
|
||||
await peer.subscribe(c3)
|
||||
await peer.subscribe(c3, on_c3_update_2)
|
||||
await async_barrier()
|
||||
await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
|
||||
await async_barrier()
|
||||
assert(c3._last_update is not None)
|
||||
assert(c3._last_update[1] == characteristic3.value)
|
||||
assert c3._called
|
||||
assert c3._last_update == characteristic3.value
|
||||
assert c3._called_2
|
||||
assert c3._last_update_2 == characteristic3.value
|
||||
characteristic3.value = bytes([1, 2, 3])
|
||||
await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
|
||||
await async_barrier()
|
||||
assert(c3._last_update is not None)
|
||||
assert(c3._last_update[1] == characteristic3.value)
|
||||
assert c3._called
|
||||
assert c3._last_update == characteristic3.value
|
||||
assert c3._called_2
|
||||
assert c3._last_update_2 == characteristic3.value
|
||||
|
||||
c3._called = False
|
||||
c3._called_2 = False
|
||||
await peer.unsubscribe(c3)
|
||||
await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
|
||||
await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
|
||||
await async_barrier()
|
||||
assert not c3._called
|
||||
assert not c3._called_2
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_mtu_exchange():
|
||||
[d1, d2, d3] = LinkedDevices().devices[:3]
|
||||
|
||||
d3.gatt_server.max_mtu = 100
|
||||
|
||||
d3_connections = []
|
||||
@d3.on('connection')
|
||||
def on_d3_connection(connection):
|
||||
d3_connections.append(connection)
|
||||
|
||||
await d1.power_on()
|
||||
await d2.power_on()
|
||||
await d3.power_on()
|
||||
|
||||
d1_connection = await d1.connect(d3.random_address)
|
||||
assert len(d3_connections) == 1
|
||||
assert d3_connections[0] is not None
|
||||
|
||||
d2_connection = await d2.connect(d3.random_address)
|
||||
assert len(d3_connections) == 2
|
||||
assert d3_connections[1] is not None
|
||||
|
||||
d1_peer = Peer(d1_connection)
|
||||
d2_peer = Peer(d2_connection)
|
||||
|
||||
d1_client_mtu = await d1_peer.request_mtu(220)
|
||||
assert d1_client_mtu == 100
|
||||
assert d1_connection.att_mtu == 100
|
||||
|
||||
d2_client_mtu = await d2_peer.request_mtu(50)
|
||||
assert d2_client_mtu == 50
|
||||
assert d2_connection.att_mtu == 50
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -479,6 +688,9 @@ async def async_main():
|
||||
await test_read_write()
|
||||
await test_read_write2()
|
||||
await test_subscribe_notify()
|
||||
await test_characteristic_encoding()
|
||||
await test_mtu_exchange()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -294,8 +294,8 @@ def test_HCI_LE_Create_Connection_Command():
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_HCI_LE_Add_Device_To_White_List_Command():
|
||||
command = HCI_LE_Add_Device_To_White_List_Command(
|
||||
def test_HCI_LE_Add_Device_To_Filter_Accept_List_Command():
|
||||
command = HCI_LE_Add_Device_To_Filter_Accept_List_Command(
|
||||
address_type = 1,
|
||||
address = Address('00:11:22:33:44:55')
|
||||
)
|
||||
@@ -303,8 +303,8 @@ def test_HCI_LE_Add_Device_To_White_List_Command():
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_HCI_LE_Remove_Device_From_White_List_Command():
|
||||
command = HCI_LE_Remove_Device_From_White_List_Command(
|
||||
def test_HCI_LE_Remove_Device_From_Filter_Accept_List_Command():
|
||||
command = HCI_LE_Remove_Device_From_Filter_Accept_List_Command(
|
||||
address_type = 1,
|
||||
address = Address('00:11:22:33:44:55')
|
||||
)
|
||||
@@ -343,6 +343,23 @@ def test_HCI_LE_Set_Default_PHY_Command():
|
||||
basic_check(command)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_HCI_LE_Set_Extended_Scan_Parameters_Command():
|
||||
command = HCI_LE_Set_Extended_Scan_Parameters_Command(
|
||||
own_address_type=Address.RANDOM_DEVICE_ADDRESS,
|
||||
scanning_filter_policy=HCI_LE_Set_Extended_Scan_Parameters_Command.BASIC_FILTERED_POLICY,
|
||||
scanning_phys=(1 << HCI_LE_Set_Extended_Scan_Parameters_Command.LE_1M_PHY | 1 << HCI_LE_Set_Extended_Scan_Parameters_Command.LE_CODED_PHY | 1 << 4),
|
||||
scan_types=[
|
||||
HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING,
|
||||
HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING,
|
||||
HCI_LE_Set_Extended_Scan_Parameters_Command.PASSIVE_SCANNING
|
||||
],
|
||||
scan_intervals=[1, 2, 3],
|
||||
scan_windows=[4, 5, 6]
|
||||
)
|
||||
basic_check(command)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_address():
|
||||
a = Address('C4:F2:17:1A:1D:BB')
|
||||
@@ -391,11 +408,12 @@ def run_test_commands():
|
||||
test_HCI_LE_Set_Scan_Parameters_Command()
|
||||
test_HCI_LE_Set_Scan_Enable_Command()
|
||||
test_HCI_LE_Create_Connection_Command()
|
||||
test_HCI_LE_Add_Device_To_White_List_Command()
|
||||
test_HCI_LE_Remove_Device_From_White_List_Command()
|
||||
test_HCI_LE_Add_Device_To_Filter_Accept_List_Command()
|
||||
test_HCI_LE_Remove_Device_From_Filter_Accept_List_Command()
|
||||
test_HCI_LE_Connection_Update_Command()
|
||||
test_HCI_LE_Read_Remote_Features_Command()
|
||||
test_HCI_LE_Set_Default_PHY_Command()
|
||||
test_HCI_LE_Set_Extended_Scan_Parameters_Command()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pytest
|
||||
@@ -30,7 +31,8 @@ from bumble.smp import (
|
||||
PairingConfig,
|
||||
PairingDelegate,
|
||||
SMP_PAIRING_NOT_SUPPORTED_ERROR,
|
||||
SMP_CONFIRM_VALUE_FAILED_ERROR
|
||||
SMP_CONFIRM_VALUE_FAILED_ERROR,
|
||||
SMP_ID_KEY_DISTRIBUTION_FLAG,
|
||||
)
|
||||
from bumble.core import ProtocolError
|
||||
|
||||
@@ -161,6 +163,44 @@ async def test_self_gatt():
|
||||
assert(result == c1.value)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_self_gatt_long_read():
|
||||
# Create two devices, each with a controller, attached to the same link
|
||||
two_devices = TwoDevices()
|
||||
|
||||
# Add some GATT characteristics to device 1
|
||||
characteristics = [
|
||||
Characteristic(
|
||||
f'3A143AD7-D4A7-436B-97D6-5B62C315{i:04X}',
|
||||
Characteristic.READ,
|
||||
Characteristic.READABLE,
|
||||
bytes([x & 255 for x in range(i)])
|
||||
)
|
||||
for i in range(0, 513)
|
||||
]
|
||||
|
||||
service = Service('8140E247-04F0-42C1-BC34-534C344DAFCA', characteristics)
|
||||
two_devices.devices[1].add_service(service)
|
||||
|
||||
# Start
|
||||
await two_devices.devices[0].power_on()
|
||||
await two_devices.devices[1].power_on()
|
||||
|
||||
# Connect the two devices
|
||||
connection = await two_devices.devices[0].connect(two_devices.devices[1].random_address)
|
||||
peer = Peer(connection)
|
||||
|
||||
result = await peer.discover_service(service.uuid)
|
||||
assert(len(result) == 1)
|
||||
found_service = result[0]
|
||||
found_characteristics = await found_service.discover_characteristics()
|
||||
assert(len(found_characteristics) == 513)
|
||||
for (i, characteristic) in enumerate(found_characteristics):
|
||||
value = await characteristic.read_value()
|
||||
assert(value == characteristics[i].value)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def _test_self_smp_with_configs(pairing_config1, pairing_config2):
|
||||
# Create two devices, each with a controller, attached to the same link
|
||||
@@ -196,11 +236,27 @@ async def _test_self_smp_with_configs(pairing_config1, pairing_config2):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
IO_CAP = [
|
||||
PairingDelegate.NO_OUTPUT_NO_INPUT,
|
||||
PairingDelegate.KEYBOARD_INPUT_ONLY,
|
||||
PairingDelegate.DISPLAY_OUTPUT_ONLY,
|
||||
PairingDelegate.DISPLAY_OUTPUT_AND_YES_NO_INPUT,
|
||||
PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT
|
||||
]
|
||||
SC = [False, True]
|
||||
MITM = [False, True]
|
||||
# Key distribution is a 4-bit bitmask
|
||||
KEY_DIST = range(16)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_self_smp():
|
||||
@pytest.mark.parametrize('io_cap, sc, mitm, key_dist',
|
||||
itertools.product(IO_CAP, SC, MITM, KEY_DIST)
|
||||
)
|
||||
async def test_self_smp(io_cap, sc, mitm, key_dist):
|
||||
class Delegate(PairingDelegate):
|
||||
def __init__(self, name, io_capability):
|
||||
super().__init__(io_capability)
|
||||
def __init__(self, name, io_capability, local_initiator_key_distribution, local_responder_key_distribution):
|
||||
super().__init__(io_capability, local_initiator_key_distribution,
|
||||
local_responder_key_distribution)
|
||||
self.name = name
|
||||
self.reset()
|
||||
|
||||
@@ -240,17 +296,8 @@ async def test_self_smp():
|
||||
|
||||
pairing_config_sets = [('Initiator', [None]), ('Responder', [None])]
|
||||
for pairing_config_set in pairing_config_sets:
|
||||
for io_capability in [
|
||||
PairingDelegate.NO_OUTPUT_NO_INPUT,
|
||||
PairingDelegate.KEYBOARD_INPUT_ONLY,
|
||||
PairingDelegate.DISPLAY_OUTPUT_ONLY,
|
||||
PairingDelegate.DISPLAY_OUTPUT_AND_YES_NO_INPUT,
|
||||
PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT
|
||||
]:
|
||||
for sc in [False, True]:
|
||||
for mitm in [False, True]:
|
||||
delegate = Delegate(pairing_config_set[0], io_capability)
|
||||
pairing_config_set[1].append(PairingConfig(sc, mitm, True, delegate))
|
||||
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]:
|
||||
@@ -262,9 +309,11 @@ async def test_self_smp():
|
||||
if pairing_config1 and pairing_config2:
|
||||
pairing_config1.delegate.peer_delegate = pairing_config2.delegate
|
||||
pairing_config2.delegate.peer_delegate = pairing_config1.delegate
|
||||
|
||||
await _test_self_smp_with_configs(pairing_config1, pairing_config2)
|
||||
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_self_smp_reject():
|
||||
@@ -311,6 +360,7 @@ async def test_self_smp_wrong_pin():
|
||||
async def run_test_self():
|
||||
await test_self_connection()
|
||||
await test_self_gatt()
|
||||
await test_self_gatt_long_read()
|
||||
await test_self_smp()
|
||||
await test_self_smp_reject()
|
||||
await test_self_smp_wrong_pin()
|
||||
|
||||
@@ -176,6 +176,20 @@ def test_g2():
|
||||
assert(value == 0x2f9ed5ba)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_h6():
|
||||
KEY = bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')
|
||||
KEY_ID = bytes.fromhex('6c656272')
|
||||
assert(h6(KEY, KEY_ID) == bytes.fromhex('2d9ae102 e76dc91c e8d3a9e2 80b16399'))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_h7():
|
||||
KEY = bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')
|
||||
SALT = bytes.fromhex('00000000 00000000 00000000 746D7031')
|
||||
assert(h7(SALT, KEY) == bytes.fromhex('fb173597 c6a3c0ec d2998c2a 75a57011'))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_ah():
|
||||
irk = bytes(reversed(bytes.fromhex('ec0234a3 57c8ad05 341010a6 0a397d9b')))
|
||||
@@ -195,4 +209,6 @@ if __name__ == '__main__':
|
||||
test_f5()
|
||||
test_f6()
|
||||
test_g2()
|
||||
test_h6()
|
||||
test_h7()
|
||||
test_ah()
|
||||
|
||||
Reference in New Issue
Block a user