Compare commits

...

33 Commits

Author SHA1 Message Date
zxzxwu
996a9e28f4 Handle L2CAP info dynamically (#28)
* Add feature and MTU fields in L2CAP manager constructor
* Add register/unregister API for fixed channels
2022-08-18 08:25:59 -07:00
zxzxwu
27cb4c586b Delegate Classic connectable and discoverable (#27)
For remote-initiated test cases, we need the device to be
scan-configurable.
2022-08-17 14:20:32 -07:00
Gilles Boccon-Gibod
1f78243ea6 add test.release task to facilitate CI integration (#26) 2022-08-16 13:37:26 -07:00
Ray
216ce2abd0 Add release tasks (#6)
Added two tasks to tasks.py, release and release_tests.

Applied black formatter

authored-by: Raymundo Ramirez Mata <raymundora@google.com>
2022-08-16 11:50:30 -07:00
Gilles Boccon-Gibod
431445e6a2 fix imports (#25) 2022-08-16 11:29:56 -07:00
Michael Mogenson
d7cc546248 Update supported commands in console.py docs (#24)
Co-authored-by: Michael Mogenson <mogenson@google.com>
2022-08-12 14:23:21 -07:00
Gilles Boccon-Gibod
29fd19f40d gbg/fix subscribe lambda (#23)
* don't use a lambda as a subscriber

* update tests to look at side effects instead of internals
2022-08-12 14:22:31 -07:00
Michael Mogenson
14dfc1a501 Add subscribe and unsubscribe commands to console.py (#22)
Subscribe command will enable notify or indicate events from the
characteristic, depending on supported characteristic properties, and
print received values to the output window.

Unsubscribe will stop notify or indicate events.

Rename find_attribute() to find_characteristic() and return a
characteristic for a set of UUIDS, a characteristic for an attribute
handle, or None.

Print read and received values has a hex string.

Add an unsubscribe implementation to gatt_client.py. Reset the CCCD bits
to 0x0000. Remove a matching subsciber, if one is provided. Otherwise
remove all subscribers for a characteristic, since no more notify or
indicates events will be comming.

authored-by: Michael Mogenson <mogenson@google.com>
2022-08-12 11:49:01 -07:00
Gilles Boccon-Gibod
938282e961 Update python-publish.yml 2022-08-04 14:40:40 -07:00
Gilles Boccon-Gibod
900c15b151 Update python-publish.yml
trigger on published release
2022-08-04 14:30:25 -07:00
Gilles Boccon-Gibod
9ea93be723 add missing package entry (#21) 2022-08-04 14:27:21 -07:00
Gilles Boccon-Gibod
894ab023c7 Update python-publish.yml
don't run on PRs
2022-08-04 10:50:28 -07:00
Gilles Boccon-Gibod
7bbb37b2da Merge pull request #20 from google/gbg/test-gatt-long-read
add long read self test
2022-08-04 10:33:27 -07:00
Gilles Boccon-Gibod
3fa5d320de add long read self test 2022-08-03 16:19:04 -07:00
Gilles Boccon-Gibod
16d684c199 Merge pull request #19 from google/gbg/pypi-publish
add long description
2022-08-03 16:11:51 -07:00
Gilles Boccon-Gibod
c28aa2ebb6 add long description 2022-08-01 18:18:32 -07:00
Gilles Boccon-Gibod
28586382f4 don't publish to test PyPI
publishing to PyPI doesn't work with SCM versioning
2022-08-01 18:16:46 -07:00
Gilles Boccon-Gibod
76f08977c4 support SCM versioning 2022-08-01 17:30:00 -07:00
Gilles Boccon-Gibod
15cbf52da4 Update python-build-test.yml
Get history and tags for SCM versioning to work
2022-08-01 17:27:11 -07:00
Gilles Boccon-Gibod
f4f84dffef Update python-publish.yml
add action to fetch tags in order for SCM versioning to work
2022-08-01 17:21:19 -07:00
Gilles Boccon-Gibod
6dfb07d7b9 Create python-publish.yml 2022-08-01 16:35:32 -07:00
Gilles Boccon-Gibod
d7ce62beaa Merge pull request #18 from turon/docs/quick_start
[docs] Add some getting started information to the top-level README.
2022-07-31 12:00:36 -07:00
Gilles Boccon-Gibod
0e2a184edb Merge pull request #17 from mogenson/console_py_do_write
Implement 'write' command for console.py
2022-07-30 16:02:47 -07:00
Martin Turon
e6ee5ae996 [docs] Add references to some of the docs to the top-level for discoverability. 2022-07-30 14:18:08 -07:00
Martin Turon
f1836e659f [docs] Add some getting started information to the top-level README. 2022-07-30 14:13:55 -07:00
Michael Mogenson
99218d3abf Implement 'write' command for console.py
Syntax is `write <attribute> <value>`. Supports a value of type string,
hexadecimal string, or integer.

Ex:
- `write 180D.2A38 hello`
- `write 180D.2A38 0xbeef`
- `write 180D.2A38 123`

Write with response method is used if supported by characteristic,
otherwise write without response.

Add a find_attribute() method to consolidate common logic of finding a
characteristic or attribute handle in `do_read()` and `do_write()`.

Tested with run_gatt_server.py example to verify sent data.
2022-07-29 19:45:24 -04:00
Gilles Boccon-Gibod
b5ba0bef63 Merge pull request #16 from google/jdm/connection-context-manager
Adding in Device.connected_to context manager and Peer.sustain
2022-07-27 17:25:06 -07:00
Jayson Messenger
9cd1890faa Adding in context manager for Connection and Peer classes
* Connection implements async context manager to disconnect when
  context is left
    * The Connection only calls disconnect if the context manager exits
      without an exception
* Peer implements async context manager to discover when entering the
  context
* Device.connect_as_gatt implements an async context manager to nest the
  connection and peer context managers
* Added HCI_StatusError that can be raised when a HCI Command Status
  event is received that doesn't show "PENDING" as status
* Added Connection.sustain to wait for a timeout or disconnect
* Peer.sustain also maps to Connectin.sustain
* Updated battery_client.py to use .connect_as_gatt and .sustain
* Updated heart_rate_client.py to use .connect_as_gatt and .sustain
2022-07-27 14:03:12 -04:00
Gilles Boccon-Gibod
472702a9d9 Merge pull request #12 from google/gbg/more-hci-types
more hci types
2022-07-26 18:00:21 -07:00
Gilles Boccon-Gibod
b38740e5b7 Merge pull request #15 from google/gbg/hr-profile
add support for the heart rate service
2022-07-26 13:17:22 -07:00
Gilles Boccon-Gibod
009ecfce96 use list comprehension 2022-07-19 19:53:18 -07:00
Gilles Boccon-Gibod
d6075df356 add tool 2022-07-19 19:53:18 -07:00
Gilles Boccon-Gibod
ebd0a0c8ca more complete set of HCI types and constants 2022-07-19 19:53:18 -07:00
23 changed files with 1940 additions and 722 deletions

View File

@@ -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
View 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 }}

View File

@@ -21,6 +21,29 @@ 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 [Example 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.
## License
Licensed under the [Apache 2.0](LICENSE) License.

View File

@@ -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
View 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()

View File

@@ -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

View File

@@ -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})'
@@ -270,6 +315,8 @@ class DeviceConfiguration:
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'))])
)
@@ -288,6 +335,8 @@ class DeviceConfiguration:
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')
@@ -314,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)
@@ -400,7 +450,8 @@ 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
@@ -408,8 +459,6 @@ class Device(CompositeEventEmitter):
self.disconnecting = False
self.connections = {} # Connections, by connection handle
self.classic_enabled = False
self.discoverable = False
self.connectable = False
self.inquiry_response = None
self.address_resolver = None
@@ -430,6 +479,8 @@ class Device(CompositeEventEmitter):
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:
@@ -444,6 +495,8 @@ 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)
# Register the SDP server with the L2CAP Channel Manager
self.sdp_server.register(self.l2cap_channel_manager)
@@ -451,6 +504,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')
@@ -577,6 +631,8 @@ class Device(CompositeEventEmitter):
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
@@ -704,7 +760,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
@@ -785,7 +841,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)
@@ -824,16 +880,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
@@ -858,7 +923,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
@@ -1010,7 +1075,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
@@ -1057,7 +1122,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,
@@ -1066,8 +1131,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(
@@ -1078,7 +1143,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
@@ -1112,7 +1177,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
@@ -1442,7 +1507,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
@@ -1461,7 +1525,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)

View File

@@ -320,6 +320,12 @@ class CharacteristicAdapter:
def __getattr__(self, name):
return getattr(self.wrapped_characteristic, name)
def __setattr__(self, name, value):
if name in {'wrapped_characteristic', 'read_value', 'write_value', 'subscribe'}:
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))
@@ -343,6 +349,10 @@ class CharacteristicAdapter:
None if subscriber is None else lambda value: subscriber(self.decode_value(value))
)
def __str__(self):
wrapped = str(self.wrapped_characteristic)
return f'{self.__class__.__name__}({wrapped})'
# -----------------------------------------------------------------------------
class DelegatedCharacteristicAdapter(CharacteristicAdapter):

View File

@@ -110,6 +110,9 @@ class CharacteristicProxy(AttributeProxy):
async def subscribe(self, subscriber=None):
return await self.client.subscribe(self, subscriber)
async def unsubscribe(self, subscriber=None):
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)})'
@@ -544,10 +547,36 @@ 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)
else:
# Remove all subscribers for this attribute from the sets!
self.notification_subscribers.pop(characteristic.handle, None)
self.indication_subscribers.pop(characteristic.handle, None)
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
@@ -714,7 +743,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 +754,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())

View File

@@ -545,13 +545,13 @@ class Server(EventEmitter):
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:
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
)

File diff suppressed because it is too large Load Diff

View File

@@ -56,13 +56,7 @@ class Connection:
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 +75,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 = None
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
@@ -97,34 +93,51 @@ class Host(EventEmitter):
await self.send_command(HCI_Reset_Command())
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())
response = await self.send_command(HCI_Read_Local_Supported_Commands_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
response = await self.send_command(HCI_Read_Buffer_Size_Command())
logger.warn(f'HCI_Read_Local_Supported_Commands_Command failed: {response.return_parameters.status}')
if self.supports_command(HCI_WRITE_LE_HOST_SUPPORT_COMMAND):
await self.send_command(HCI_Write_LE_Host_Support_Command(le_supported_host = 1, simultaneous_le_host = 0))
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.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.local_version = response.return_parameters
else:
logger.warn(f'HCI_Read_Buffer_Size_Command failed: {response.return_parameters.status}')
logger.warn(f'HCI_Read_Local_Version_Information_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
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}')
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
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}')
else:
logger.warn(f'HCI_Read_Buffer_Size_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}')
self.reset_done = True
@@ -211,6 +224,37 @@ class Host(EventEmitter):
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 +293,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)

View File

@@ -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

View File

@@ -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
# -----------------------------------------------------------------------------

View File

@@ -868,7 +868,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,

View File

@@ -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:

View File

@@ -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_service_and_create_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()
# -----------------------------------------------------------------------------

View File

@@ -43,31 +43,28 @@ 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}')
# Discover the Heart Rate Service
peer = Peer(connection)
print('=== Discovering Heart Rate Service')
heart_rate_service = await peer.discover_service_and_create_proxy(HeartRateServiceProxy)
heart_rate_service = peer.create_service_proxy(HeartRateServiceProxy)
# Check that the service was found
if not heart_rate_service:
print('!!! Service not found')
return
# 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)
# 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}')
)
# 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 hci_source.wait_for_termination()
await peer.sustain()
# -----------------------------------------------------------------------------

View File

@@ -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
@@ -55,12 +57,13 @@ console_scripts =
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

View File

@@ -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")

View File

@@ -419,10 +419,12 @@ async def test_subscribe_notify():
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)
@@ -434,44 +436,73 @@ async def test_subscribe_notify():
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
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)
# -----------------------------------------------------------------------------

View File

@@ -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()
# -----------------------------------------------------------------------------

View File

@@ -163,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
@@ -274,7 +312,7 @@ async def test_self_smp(io_cap, sc, mitm, key_dist):
pairing_config2.delegate.peer_delegate = pairing_config1.delegate
await _test_self_smp_with_configs(pairing_config1, pairing_config2)
# -----------------------------------------------------------------------------
@@ -323,6 +361,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()