mirror of
https://github.com/google/bumble.git
synced 2026-05-08 03:58:01 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84d70ad4f3 | ||
|
|
996a9e28f4 | ||
|
|
27cb4c586b | ||
|
|
1f78243ea6 | ||
|
|
216ce2abd0 | ||
|
|
431445e6a2 | ||
|
|
d7cc546248 | ||
|
|
29fd19f40d | ||
|
|
14dfc1a501 | ||
|
|
938282e961 | ||
|
|
900c15b151 | ||
|
|
9ea93be723 | ||
|
|
894ab023c7 |
4
.github/workflows/python-build-test.yml
vendored
4
.github/workflows/python-build-test.yml
vendored
@@ -29,11 +29,11 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
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
|
- name: Test with pytest
|
||||||
run: |
|
run: |
|
||||||
pytest
|
pytest
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
inv build
|
inv build
|
||||||
inv mkdocs
|
inv build.mkdocs
|
||||||
|
|||||||
10
.github/workflows/python-publish.yml
vendored
10
.github/workflows/python-publish.yml
vendored
@@ -1,11 +1,9 @@
|
|||||||
name: Upload Python Package
|
name: Upload Python Package
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
release:
|
||||||
branches: [ main ]
|
types: [published]
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -32,7 +30,7 @@ jobs:
|
|||||||
- name: Build package
|
- name: Build package
|
||||||
run: python -m build
|
run: python -m build
|
||||||
- name: Publish package to PyPI
|
- name: Publish package to PyPI
|
||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags')
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
with:
|
with:
|
||||||
user: __token__
|
user: __token__
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -9,7 +9,7 @@
|
|||||||
Bluetooth Stack for Apps, Emulation, Test and Experimentation
|
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.
|
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.
|
||||||
|
|
||||||
@@ -38,12 +38,20 @@ python -m pip install ".[test,development,documentation]"
|
|||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
Refer to the [Example Documentation](examples/README.md) for details on the included example scripts and how to run them.
|
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.
|
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.
|
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
|
## License
|
||||||
|
|
||||||
Licensed under the [Apache 2.0](LICENSE) License.
|
Licensed under the [Apache 2.0](LICENSE) License.
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ class ConsoleApp:
|
|||||||
},
|
},
|
||||||
'read': LiveCompleter(self.known_attributes),
|
'read': LiveCompleter(self.known_attributes),
|
||||||
'write': LiveCompleter(self.known_attributes),
|
'write': LiveCompleter(self.known_attributes),
|
||||||
|
'subscribe': LiveCompleter(self.known_attributes),
|
||||||
|
'unsubscribe': LiveCompleter(self.known_attributes),
|
||||||
'quit': None,
|
'quit': None,
|
||||||
'exit': None
|
'exit': None
|
||||||
})
|
})
|
||||||
@@ -331,7 +333,7 @@ class ConsoleApp:
|
|||||||
|
|
||||||
await self.show_attributes(attributes)
|
await self.show_attributes(attributes)
|
||||||
|
|
||||||
def find_attribute(self, param):
|
def find_characteristic(self, param):
|
||||||
parts = param.split('.')
|
parts = param.split('.')
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
service_uuid = UUID(parts[0]) if parts[0] != '*' else None
|
service_uuid = UUID(parts[0]) if parts[0] != '*' else None
|
||||||
@@ -344,7 +346,10 @@ class ConsoleApp:
|
|||||||
elif len(parts) == 1:
|
elif len(parts) == 1:
|
||||||
if parts[0].startswith('#'):
|
if parts[0].startswith('#'):
|
||||||
attribute_handle = int(f'{parts[0][1:]}', 16)
|
attribute_handle = int(f'{parts[0][1:]}', 16)
|
||||||
return attribute_handle
|
for service in self.connected_peer.services:
|
||||||
|
for characteristic in service.characteristics:
|
||||||
|
if characteristic.handle == attribute_handle:
|
||||||
|
return characteristic
|
||||||
|
|
||||||
async def command(self, command):
|
async def command(self, command):
|
||||||
try:
|
try:
|
||||||
@@ -457,13 +462,13 @@ class ConsoleApp:
|
|||||||
self.show_error('invalid syntax', 'expected read <attribute>')
|
self.show_error('invalid syntax', 'expected read <attribute>')
|
||||||
return
|
return
|
||||||
|
|
||||||
attribute = self.find_attribute(params[0])
|
characteristic = self.find_characteristic(params[0])
|
||||||
if attribute is None:
|
if characteristic is None:
|
||||||
self.show_error('no such characteristic')
|
self.show_error('no such characteristic')
|
||||||
return
|
return
|
||||||
|
|
||||||
value = await self.connected_peer.read_value(attribute)
|
value = await characteristic.read_value()
|
||||||
self.append_to_output(f'VALUE: {value}')
|
self.append_to_output(f'VALUE: 0x{value.hex()}')
|
||||||
|
|
||||||
async def do_write(self, params):
|
async def do_write(self, params):
|
||||||
if not self.connected_peer:
|
if not self.connected_peer:
|
||||||
@@ -482,21 +487,48 @@ class ConsoleApp:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
value = str.encode(params[1]) # must be a string
|
value = str.encode(params[1]) # must be a string
|
||||||
|
|
||||||
attribute = self.find_attribute(params[0])
|
characteristic = self.find_characteristic(params[0])
|
||||||
if attribute is None:
|
if characteristic is None:
|
||||||
self.show_error('no such characteristic')
|
self.show_error('no such characteristic')
|
||||||
return
|
return
|
||||||
|
|
||||||
# use write with response if supported
|
# use write with response if supported
|
||||||
with_response = (
|
with_response = characteristic.properties & Characteristic.WRITE
|
||||||
(attribute.properties & Characteristic.WRITE)
|
await characteristic.write_value(value, with_response=with_response)
|
||||||
if hasattr(attribute, "properties")
|
|
||||||
else False
|
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()}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.connected_peer.write_value(
|
async def do_unsubscribe(self, params):
|
||||||
attribute, value, with_response=with_response
|
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):
|
async def do_exit(self, params):
|
||||||
self.ui.exit()
|
self.ui.exit()
|
||||||
|
|||||||
157
apps/usb_probe.py
Normal file
157
apps/usb_probe.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# 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 usb1
|
||||||
|
from colors import color
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
def main():
|
||||||
|
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_is_bluetooth_hci = (
|
||||||
|
device_class == USB_DEVICE_CLASS_WIRELESS_CONTROLLER and
|
||||||
|
device_subclass == USB_DEVICE_SUBCLASS_RF_CONTROLLER and
|
||||||
|
device_protocol == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
|
||||||
|
)
|
||||||
|
|
||||||
|
device_class_details = ''
|
||||||
|
device_class_info = USB_DEVICE_CLASSES.get(device_class)
|
||||||
|
if device_class_info is not None:
|
||||||
|
if type(device_class_info) is tuple:
|
||||||
|
device_class = device_class_info[0]
|
||||||
|
device_subclass_info = device_class_info[1].get(device_subclass)
|
||||||
|
if device_subclass_info:
|
||||||
|
device_class_details = f' [{device_subclass_info.get(device_protocol)}]'
|
||||||
|
else:
|
||||||
|
device_class = device_class_info
|
||||||
|
|
||||||
|
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}')
|
||||||
|
|
||||||
|
serial_number_collision = False
|
||||||
|
if device_id in devices:
|
||||||
|
for device_serial in devices[device_id]:
|
||||||
|
if device_serial == device.getSerialNumber():
|
||||||
|
serial_number_collision = True
|
||||||
|
|
||||||
|
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.getSerialNumber() and not serial_number_collision:
|
||||||
|
bumble_transport_names.append(f'{basic_transport_name}/{device.getSerialNumber()}')
|
||||||
|
|
||||||
|
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}')
|
||||||
|
if device.getSerialNumber():
|
||||||
|
print(color(' Serial: ', 'green'), device.getSerialNumber())
|
||||||
|
print(color(' Class: ', 'green'), device_class)
|
||||||
|
print(color(' Subclass/Protocol: ', 'green'), f'{device_subclass}/{device_protocol}{device_class_details}')
|
||||||
|
print(color(' Manufacturer: ', 'green'), device.getManufacturer())
|
||||||
|
print(color(' Product: ', 'green'), device.getProduct())
|
||||||
|
print()
|
||||||
|
|
||||||
|
devices.setdefault(device_id, []).append(device.getSerialNumber())
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -123,6 +123,9 @@ class Peer:
|
|||||||
async def subscribe(self, characteristic, subscriber=None):
|
async def subscribe(self, characteristic, subscriber=None):
|
||||||
return await self.gatt_client.subscribe(characteristic, subscriber)
|
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):
|
async def read_value(self, attribute):
|
||||||
return await self.gatt_client.read_value(attribute)
|
return await self.gatt_client.read_value(attribute)
|
||||||
|
|
||||||
@@ -312,6 +315,8 @@ class DeviceConfiguration:
|
|||||||
self.le_simultaneous_enabled = True
|
self.le_simultaneous_enabled = True
|
||||||
self.classic_sc_enabled = True
|
self.classic_sc_enabled = True
|
||||||
self.classic_ssp_enabled = True
|
self.classic_ssp_enabled = True
|
||||||
|
self.connectable = True
|
||||||
|
self.discoverable = True
|
||||||
self.advertising_data = bytes(
|
self.advertising_data = bytes(
|
||||||
AdvertisingData([(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))])
|
AdvertisingData([(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))])
|
||||||
)
|
)
|
||||||
@@ -330,6 +335,8 @@ class DeviceConfiguration:
|
|||||||
self.le_simultaneous_enabled = config.get('le_simultaneous_enabled', self.le_simultaneous_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_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.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
|
# Load or synthesize an IRK
|
||||||
irk = config.get('irk')
|
irk = config.get('irk')
|
||||||
@@ -443,7 +450,8 @@ class Device(CompositeEventEmitter):
|
|||||||
self.command_timeout = 10 # seconds
|
self.command_timeout = 10 # seconds
|
||||||
self.gatt_server = gatt_server.Server(self)
|
self.gatt_server = gatt_server.Server(self)
|
||||||
self.sdp_server = sdp.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.advertisement_data = {}
|
||||||
self.scanning = False
|
self.scanning = False
|
||||||
self.discovering = False
|
self.discovering = False
|
||||||
@@ -451,8 +459,6 @@ class Device(CompositeEventEmitter):
|
|||||||
self.disconnecting = False
|
self.disconnecting = False
|
||||||
self.connections = {} # Connections, by connection handle
|
self.connections = {} # Connections, by connection handle
|
||||||
self.classic_enabled = False
|
self.classic_enabled = False
|
||||||
self.discoverable = False
|
|
||||||
self.connectable = False
|
|
||||||
self.inquiry_response = None
|
self.inquiry_response = None
|
||||||
self.address_resolver = None
|
self.address_resolver = None
|
||||||
|
|
||||||
@@ -473,6 +479,8 @@ class Device(CompositeEventEmitter):
|
|||||||
self.le_simultaneous_enabled = config.le_simultaneous_enabled
|
self.le_simultaneous_enabled = config.le_simultaneous_enabled
|
||||||
self.classic_ssp_enabled = config.classic_ssp_enabled
|
self.classic_ssp_enabled = config.classic_ssp_enabled
|
||||||
self.classic_sc_enabled = config.classic_sc_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 a name is passed, override the name from the config
|
||||||
if name:
|
if name:
|
||||||
@@ -487,6 +495,8 @@ class Device(CompositeEventEmitter):
|
|||||||
# Setup SMP
|
# Setup SMP
|
||||||
# TODO: allow using a public address
|
# TODO: allow using a public address
|
||||||
self.smp_manager = smp.Manager(self, self.random_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
|
# Register the SDP server with the L2CAP Channel Manager
|
||||||
self.sdp_server.register(self.l2cap_channel_manager)
|
self.sdp_server.register(self.l2cap_channel_manager)
|
||||||
@@ -494,6 +504,7 @@ class Device(CompositeEventEmitter):
|
|||||||
# Add a GAP Service if requested
|
# Add a GAP Service if requested
|
||||||
if generic_access_service:
|
if generic_access_service:
|
||||||
self.gatt_server.add_service(GenericAccessService(self.name))
|
self.gatt_server.add_service(GenericAccessService(self.name))
|
||||||
|
self.l2cap_channel_manager.register_fixed_channel(ATT_CID, self.on_gatt_pdu)
|
||||||
|
|
||||||
# Forward some events
|
# Forward some events
|
||||||
setup_event_forwarding(self.gatt_server, self, 'characteristic_subscription')
|
setup_event_forwarding(self.gatt_server, self, 'characteristic_subscription')
|
||||||
@@ -571,11 +582,12 @@ class Device(CompositeEventEmitter):
|
|||||||
logger.debug(color(f'BD_ADDR: {response.return_parameters.bd_addr}', 'yellow'))
|
logger.debug(color(f'BD_ADDR: {response.return_parameters.bd_addr}', 'yellow'))
|
||||||
self.public_address = response.return_parameters.bd_addr
|
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),
|
||||||
|
))
|
||||||
|
|
||||||
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:
|
if self.le_enabled:
|
||||||
# Set the controller address
|
# Set the controller address
|
||||||
await self.send_command(HCI_LE_Set_Random_Address_Command(
|
await self.send_command(HCI_LE_Set_Random_Address_Command(
|
||||||
@@ -620,6 +632,8 @@ class Device(CompositeEventEmitter):
|
|||||||
HCI_Write_Secure_Connections_Host_Support_Command(
|
HCI_Write_Secure_Connections_Host_Support_Command(
|
||||||
secure_connections_host_support=int(self.classic_sc_enabled))
|
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
|
# Let the SMP manager know about the address
|
||||||
# TODO: allow using a public address
|
# TODO: allow using a public address
|
||||||
@@ -1494,7 +1508,6 @@ class Device(CompositeEventEmitter):
|
|||||||
def on_pairing_failure(self, connection, reason):
|
def on_pairing_failure(self, connection, reason):
|
||||||
connection.emit('pairing_failure', reason)
|
connection.emit('pairing_failure', reason)
|
||||||
|
|
||||||
@host_event_handler
|
|
||||||
@with_connection_from_handle
|
@with_connection_from_handle
|
||||||
def on_gatt_pdu(self, connection, pdu):
|
def on_gatt_pdu(self, connection, pdu):
|
||||||
# Parse the L2CAP payload into an ATT PDU object
|
# Parse the L2CAP payload into an ATT PDU object
|
||||||
@@ -1513,7 +1526,6 @@ class Device(CompositeEventEmitter):
|
|||||||
return
|
return
|
||||||
connection.gatt_server.on_gatt_pdu(connection, att_pdu)
|
connection.gatt_server.on_gatt_pdu(connection, att_pdu)
|
||||||
|
|
||||||
@host_event_handler
|
|
||||||
@with_connection_from_handle
|
@with_connection_from_handle
|
||||||
def on_smp_pdu(self, connection, pdu):
|
def on_smp_pdu(self, connection, pdu):
|
||||||
self.smp_manager.on_smp_pdu(connection, pdu)
|
self.smp_manager.on_smp_pdu(connection, pdu)
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ class CharacteristicProxy(AttributeProxy):
|
|||||||
async def subscribe(self, subscriber=None):
|
async def subscribe(self, subscriber=None):
|
||||||
return await self.client.subscribe(self, subscriber)
|
return await self.client.subscribe(self, subscriber)
|
||||||
|
|
||||||
|
async def unsubscribe(self, subscriber=None):
|
||||||
|
return await self.client.unsubscribe(self, subscriber)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'Characteristic(handle=0x{self.handle:04X}, uuid={self.uuid}, properties={Characteristic.properties_as_string(self.properties)})'
|
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:
|
for subscriber_set in subscriber_sets:
|
||||||
if subscriber is not None:
|
if subscriber is not None:
|
||||||
subscriber_set.add(subscriber)
|
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)
|
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):
|
async def read_value(self, attribute, no_long_read=False):
|
||||||
'''
|
'''
|
||||||
See Vol 3, Part G - 4.8.1 Read Characteristic Value
|
See Vol 3, Part G - 4.8.1 Read Characteristic Value
|
||||||
@@ -714,7 +743,10 @@ class Client:
|
|||||||
if not subscribers:
|
if not subscribers:
|
||||||
logger.warning('!!! received notification with no subscriber')
|
logger.warning('!!! received notification with no subscriber')
|
||||||
for subscriber in subscribers:
|
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):
|
def on_att_handle_value_indication(self, indication):
|
||||||
# Call all subscribers
|
# Call all subscribers
|
||||||
@@ -722,7 +754,10 @@ class Client:
|
|||||||
if not subscribers:
|
if not subscribers:
|
||||||
logger.warning('!!! received indication with no subscriber')
|
logger.warning('!!! received indication with no subscriber')
|
||||||
for subscriber in subscribers:
|
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
|
# Confirm that we received the indication
|
||||||
self.send_confirmation(ATT_Handle_Value_Confirmation())
|
self.send_confirmation(ATT_Handle_Value_Confirmation())
|
||||||
|
|||||||
@@ -56,13 +56,7 @@ class Connection:
|
|||||||
|
|
||||||
def on_acl_pdu(self, pdu):
|
def on_acl_pdu(self, pdu):
|
||||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||||
|
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -81,7 +75,7 @@ class Host(EventEmitter):
|
|||||||
self.hc_total_num_acl_data_packets = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS
|
self.hc_total_num_acl_data_packets = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS
|
||||||
self.acl_packet_queue = collections.deque()
|
self.acl_packet_queue = collections.deque()
|
||||||
self.acl_packets_in_flight = 0
|
self.acl_packets_in_flight = 0
|
||||||
self.local_version = None
|
self.local_version = HCI_VERSION_BLUETOOTH_CORE_4_0
|
||||||
self.local_supported_commands = bytes(64)
|
self.local_supported_commands = bytes(64)
|
||||||
self.local_le_features = 0
|
self.local_le_features = 0
|
||||||
self.command_semaphore = asyncio.Semaphore(1)
|
self.command_semaphore = asyncio.Semaphore(1)
|
||||||
@@ -99,17 +93,18 @@ class Host(EventEmitter):
|
|||||||
await self.send_command(HCI_Reset_Command())
|
await self.send_command(HCI_Reset_Command())
|
||||||
self.ready = True
|
self.ready = True
|
||||||
|
|
||||||
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')))
|
|
||||||
|
|
||||||
response = await self.send_command(HCI_Read_Local_Supported_Commands_Command())
|
response = await self.send_command(HCI_Read_Local_Supported_Commands_Command())
|
||||||
if response.return_parameters.status == HCI_SUCCESS:
|
if response.return_parameters.status == HCI_SUCCESS:
|
||||||
self.local_supported_commands = response.return_parameters.supported_commands
|
self.local_supported_commands = response.return_parameters.supported_commands
|
||||||
else:
|
else:
|
||||||
logger.warn(f'HCI_Read_Local_Supported_Commands_Command failed: {response.return_parameters.status}')
|
logger.warn(f'HCI_Read_Local_Supported_Commands_Command failed: {response.return_parameters.status}')
|
||||||
|
|
||||||
if self.supports_command(HCI_WRITE_LE_HOST_SUPPORT_COMMAND):
|
if self.supports_command(HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_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_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):
|
if self.supports_command(HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
|
||||||
response = await self.send_command(HCI_Read_Local_Version_Information_Command())
|
response = await self.send_command(HCI_Read_Local_Version_Information_Command())
|
||||||
@@ -118,32 +113,44 @@ class Host(EventEmitter):
|
|||||||
else:
|
else:
|
||||||
logger.warn(f'HCI_Read_Local_Version_Information_Command failed: {response.return_parameters.status}')
|
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_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):
|
if self.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||||
response = await self.send_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:
|
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_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
|
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:
|
else:
|
||||||
logger.warn(f'HCI_LE_Read_Buffer_Size_Command failed: {response.return_parameters.status}')
|
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):
|
if response.return_parameters.hc_le_acl_data_packet_length == 0 or response.return_parameters.hc_total_num_le_acl_data_packets == 0:
|
||||||
response = await self.send_command(HCI_LE_Read_Local_Supported_Features_Command())
|
# LE and Classic share the same values
|
||||||
if response.return_parameters.status == HCI_SUCCESS:
|
self.hc_le_acl_data_packet_length = self.hc_acl_data_packet_length
|
||||||
self.local_le_features = struct.unpack('<Q', response.return_parameters.le_features)[0]
|
self.hc_total_num_le_acl_data_packets = self.hc_total_num_acl_data_packets
|
||||||
else:
|
|
||||||
logger.warn(f'HCI_LE_Read_Supported_Features_Command failed: {response.return_parameters.status}')
|
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
|
self.reset_done = True
|
||||||
|
|
||||||
@@ -168,8 +175,8 @@ class Host(EventEmitter):
|
|||||||
|
|
||||||
# Wait until we can send (only one pending command at a time)
|
# Wait until we can send (only one pending command at a time)
|
||||||
async with self.command_semaphore:
|
async with self.command_semaphore:
|
||||||
assert(self.pending_command is None)
|
assert self.pending_command is None
|
||||||
assert(self.pending_response is None)
|
assert self.pending_response is None
|
||||||
|
|
||||||
# Create a future value to hold the eventual response
|
# Create a future value to hold the eventual response
|
||||||
self.pending_response = asyncio.get_running_loop().create_future()
|
self.pending_response = asyncio.get_running_loop().create_future()
|
||||||
@@ -202,6 +209,7 @@ class Host(EventEmitter):
|
|||||||
offset = 0
|
offset = 0
|
||||||
pb_flag = 0
|
pb_flag = 0
|
||||||
while bytes_remaining:
|
while bytes_remaining:
|
||||||
|
# TODO: support different LE/Classic lengths
|
||||||
data_total_length = min(bytes_remaining, self.hc_le_acl_data_packet_length)
|
data_total_length = min(bytes_remaining, self.hc_le_acl_data_packet_length)
|
||||||
acl_packet = HCI_AclDataPacket(
|
acl_packet = HCI_AclDataPacket(
|
||||||
connection_handle = connection_handle,
|
connection_handle = connection_handle,
|
||||||
@@ -224,7 +232,7 @@ class Host(EventEmitter):
|
|||||||
logger.debug(f'{self.acl_packets_in_flight} ACL packets in flight, {len(self.acl_packet_queue)} in queue')
|
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):
|
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:
|
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()
|
packet = self.acl_packet_queue.pop()
|
||||||
self.send_hci_packet(packet)
|
self.send_hci_packet(packet)
|
||||||
@@ -299,12 +307,6 @@ class Host(EventEmitter):
|
|||||||
if connection := self.connections.get(packet.connection_handle):
|
if connection := self.connections.get(packet.connection_handle):
|
||||||
connection.on_hci_acl_data_packet(packet)
|
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):
|
def on_l2cap_pdu(self, connection, cid, pdu):
|
||||||
self.emit('l2cap_pdu', connection.handle, cid, pdu)
|
self.emit('l2cap_pdu', connection.handle, cid, pdu)
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ import logging
|
|||||||
import struct
|
import struct
|
||||||
|
|
||||||
from colors import color
|
from colors import color
|
||||||
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
|
||||||
from .hci import (HCI_LE_Connection_Update_Command, HCI_Object, key_with_value,
|
from .hci import (HCI_LE_Connection_Update_Command, HCI_Object, key_with_value,
|
||||||
name_or_number)
|
name_or_number)
|
||||||
from .utils import EventEmitter
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -414,6 +414,18 @@ class L2CAP_Information_Request(L2CAP_Control_Frame):
|
|||||||
EXTENDED_FEATURES_SUPPORTED = 0x0002
|
EXTENDED_FEATURES_SUPPORTED = 0x0002
|
||||||
FIXED_CHANNELS_SUPPORTED = 0x0003
|
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 = {
|
INFO_TYPE_NAMES = {
|
||||||
CONNECTIONLESS_MTU: 'CONNECTIONLESS_MTU',
|
CONNECTIONLESS_MTU: 'CONNECTIONLESS_MTU',
|
||||||
EXTENDED_FEATURES_SUPPORTED: 'EXTENDED_FEATURES_SUPPORTED',
|
EXTENDED_FEATURES_SUPPORTED: 'EXTENDED_FEATURES_SUPPORTED',
|
||||||
@@ -817,11 +829,16 @@ class Channel(EventEmitter):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class ChannelManager:
|
class ChannelManager:
|
||||||
def __init__(self):
|
def __init__(self, extended_features=None, connectionless_mtu=1024):
|
||||||
self.host = None
|
self.host = None
|
||||||
self.channels = {} # Channels, mapped by connection and cid
|
self.channels = {} # Channels, mapped by connection and cid
|
||||||
self.identifiers = {} # Incrementing identifier values by connection
|
# Fixed channel handlers, mapped by cid
|
||||||
self.servers = {} # Servers accepting connections, by PSM
|
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):
|
def find_channel(self, connection_handle, cid):
|
||||||
if connection_channels := self.channels.get(connection_handle):
|
if connection_channels := self.channels.get(connection_handle):
|
||||||
@@ -840,6 +857,13 @@ class ChannelManager:
|
|||||||
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
|
||||||
self.identifiers[connection.handle] = identifier
|
self.identifiers[connection.handle] = identifier
|
||||||
return identifier
|
return identifier
|
||||||
|
|
||||||
|
def register_fixed_channel(self, cid, handler):
|
||||||
|
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):
|
def register_server(self, psm, server):
|
||||||
self.servers[psm] = server
|
self.servers[psm] = server
|
||||||
@@ -855,6 +879,8 @@ class ChannelManager:
|
|||||||
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
|
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
|
||||||
|
|
||||||
self.on_control_frame(connection, cid, control_frame)
|
self.on_control_frame(connection, cid, control_frame)
|
||||||
|
elif cid in self.fixed_channels:
|
||||||
|
self.fixed_channels[cid](connection.handle, pdu)
|
||||||
else:
|
else:
|
||||||
if (channel := self.find_channel(connection.handle, cid)) is None:
|
if (channel := self.find_channel(connection.handle, cid)) is None:
|
||||||
logger.warn(color(f'channel not found for 0x{connection.handle:04X}:{cid}', 'red'))
|
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):
|
def on_l2cap_information_request(self, connection, cid, request):
|
||||||
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
|
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
|
||||||
result = L2CAP_Information_Response.SUCCESS
|
result = L2CAP_Information_Response.SUCCESS
|
||||||
data = 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:
|
elif request.info_type == L2CAP_Information_Request.EXTENDED_FEATURES_SUPPORTED:
|
||||||
result = L2CAP_Information_Response.SUCCESS
|
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:
|
elif request.info_type == L2CAP_Information_Request.FIXED_CHANNELS_SUPPORTED:
|
||||||
result = L2CAP_Information_Response.SUCCESS
|
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:
|
else:
|
||||||
result = L2CAP_Information_Request.NO_SUPPORTED
|
result = L2CAP_Information_Request.NO_SUPPORTED
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,10 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from colors import color
|
|
||||||
|
|
||||||
from .utils import EventEmitter
|
from colors import color
|
||||||
|
from pyee import EventEmitter
|
||||||
|
|
||||||
from .core import InvalidStateError, ProtocolError, ConnectionError
|
from .core import InvalidStateError, ProtocolError, ConnectionError
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -37,16 +37,20 @@ async def open_usb_transport(spec):
|
|||||||
'''
|
'''
|
||||||
Open a USB transport.
|
Open a USB transport.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
either <index> or <vendor>:<product>[/<serial-number>]
|
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
|
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
|
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
|
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
|
/<serial-number> suffix or #<index> suffix max be specified when more than one device with
|
||||||
vendor and product identifiers are present.
|
the same vendor and product identifiers are present.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
0 --> the first BT USB dongle
|
0 --> the first BT USB dongle
|
||||||
04b4:f901 --> the BT USB dongle with vendor=04b4 and product=f901
|
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
|
04b4:f901/00E04C239987 --> the BT USB dongle with vendor=04b4 and product=f901 and serial number 00E04C239987
|
||||||
'''
|
'''
|
||||||
|
|
||||||
@@ -190,7 +194,7 @@ async def open_usb_transport(spec):
|
|||||||
def on_packet_received(self, transfer):
|
def on_packet_received(self, transfer):
|
||||||
packet_type = transfer.getUserData()
|
packet_type = transfer.getUserData()
|
||||||
status = transfer.getStatus()
|
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:
|
if status == usb1.TRANSFER_COMPLETED:
|
||||||
packet = bytes([packet_type]) + transfer.getBuffer()[:transfer.getActualLength()]
|
packet = bytes([packet_type]) + transfer.getBuffer()[:transfer.getActualLength()]
|
||||||
@@ -271,19 +275,25 @@ async def open_usb_transport(spec):
|
|||||||
found = None
|
found = None
|
||||||
if ':' in spec:
|
if ':' in spec:
|
||||||
vendor_id, product_id = spec.split(':')
|
vendor_id, product_id = spec.split(':')
|
||||||
|
serial_number = None
|
||||||
|
device_index = 0
|
||||||
if '/' in product_id:
|
if '/' in product_id:
|
||||||
product_id, serial_number = product_id.split('/')
|
product_id, serial_number = product_id.split('/')
|
||||||
for device in context.getDeviceIterator(skip_on_error=True):
|
elif '#' in product_id:
|
||||||
if (
|
product_id, device_index_str = product_id.split('#')
|
||||||
device.getVendorID() == int(vendor_id, 16) and
|
device_index = int(device_index_str)
|
||||||
device.getProductID() == int(product_id, 16) and
|
|
||||||
device.getSerialNumber() == serial_number
|
for device in context.getDeviceIterator(skip_on_error=True):
|
||||||
):
|
if (
|
||||||
|
device.getVendorID() == int(vendor_id, 16) and
|
||||||
|
device.getProductID() == int(product_id, 16) and
|
||||||
|
(serial_number is None or device.getSerialNumber() == serial_number)
|
||||||
|
):
|
||||||
|
if device_index == 0:
|
||||||
found = device
|
found = device
|
||||||
break
|
break
|
||||||
device.close()
|
device_index -= 1
|
||||||
else:
|
device.close()
|
||||||
found = context.getByVendorIDAndProductID(int(vendor_id, 16), int(product_id, 16), skip_on_error=True)
|
|
||||||
else:
|
else:
|
||||||
device_index = int(spec)
|
device_index = int(spec)
|
||||||
for device in context.getDeviceIterator(skip_on_error=True):
|
for device in context.getDeviceIterator(skip_on_error=True):
|
||||||
@@ -305,17 +315,6 @@ async def open_usb_transport(spec):
|
|||||||
logger.debug(f'USB Device: {found}')
|
logger.debug(f'USB Device: {found}')
|
||||||
device = found.open()
|
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
|
# Use the first interface
|
||||||
interface = 0
|
interface = 0
|
||||||
|
|
||||||
@@ -328,6 +327,20 @@ async def open_usb_transport(spec):
|
|||||||
except usb1.USBError:
|
except usb1.USBError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Set the configuration if needed
|
||||||
|
try:
|
||||||
|
configuration = device.getConfiguration()
|
||||||
|
logger.debug(f'current configuration = {configuration}')
|
||||||
|
except usb1.USBError:
|
||||||
|
configuration = 0
|
||||||
|
|
||||||
|
if configuration != 1:
|
||||||
|
try:
|
||||||
|
logger.debug('setting configuration 1')
|
||||||
|
device.setConfiguration(1)
|
||||||
|
except usb1.USBError:
|
||||||
|
logger.warning('failed to set configuration 1')
|
||||||
|
|
||||||
source = UsbPacketSource(context, device)
|
source = UsbPacketSource(context, device)
|
||||||
sink = UsbPacketSink(device)
|
sink = UsbPacketSink(device)
|
||||||
return UsbTransport(context, device, interface, source, sink)
|
return UsbTransport(context, device, interface, source, sink)
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ The Console app is an interactive text user interface that offers a number of fu
|
|||||||
|
|
||||||
* scanning
|
* scanning
|
||||||
* advertising
|
* advertising
|
||||||
* connecting to devices
|
* connecting to and disconnecting from devices
|
||||||
* changing connection parameters
|
* changing connection parameters
|
||||||
|
* enabling encryption
|
||||||
* discovering GATT services and characteristics
|
* 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:
|
The console user interface has 3 main panes:
|
||||||
|
|
||||||
|
|||||||
38
docs/mkdocs/src/apps_and_tools/usb_probe.md
Normal file
38
docs/mkdocs/src/apps_and_tools/usb_probe.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 takes no arguments.
|
||||||
|
When installed from PyPI, run as
|
||||||
|
```
|
||||||
|
$ bumble-usb-probe
|
||||||
|
```
|
||||||
|
|
||||||
|
When running from the source distribution:
|
||||||
|
```
|
||||||
|
$ python3 apps/usb-probe.py
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! 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
|
||||||
|
```
|
||||||
@@ -4,16 +4,56 @@ USB TRANSPORT
|
|||||||
The USB transport interfaces with a local Bluetooth USB dongle.
|
The USB transport interfaces with a local Bluetooth USB dongle.
|
||||||
|
|
||||||
## Moniker
|
## Moniker
|
||||||
The moniker for a USB transport is either `usb:<index>` or `usb:<vendor>:<product>`
|
The moniker for a USB transport is either:
|
||||||
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.
|
* `usb:<index>`
|
||||||
|
* `usb:<vendor>:<product>`
|
||||||
|
* `usb:<vendor>:<product>/<serial-number>`
|
||||||
|
* `usb:<vendor>:<product>#<index>`
|
||||||
|
|
||||||
!!! example
|
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.
|
||||||
|
|
||||||
|
!!! examples
|
||||||
`usb:04b4:f901`
|
`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`
|
`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`
|
||||||
|
|
||||||
## Alternative
|
## Alternative
|
||||||
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
|
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.
|
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`.
|
||||||
@@ -24,7 +24,7 @@ url = https://github.com/google/bumble
|
|||||||
|
|
||||||
[options]
|
[options]
|
||||||
python_requires = >=3.8
|
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 =
|
package_dir =
|
||||||
bumble = bumble
|
bumble = bumble
|
||||||
bumble.apps = apps
|
bumble.apps = apps
|
||||||
@@ -54,15 +54,17 @@ console_scripts =
|
|||||||
bumble-scan = bumble.apps.scan:main
|
bumble-scan = bumble.apps.scan:main
|
||||||
bumble-show = bumble.apps.show:main
|
bumble-show = bumble.apps.show:main
|
||||||
bumble-unbond = bumble.apps.unbond: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
|
bumble-link-relay = bumble.apps.link_relay.link_relay:main
|
||||||
|
|
||||||
[options.extras_require]
|
[options.extras_require]
|
||||||
|
build =
|
||||||
|
build >= 0.7
|
||||||
test =
|
test =
|
||||||
pytest >= 6.2
|
pytest >= 6.2
|
||||||
pytest-asyncio >= 0.17
|
pytest-asyncio >= 0.17
|
||||||
development =
|
development =
|
||||||
invoke >= 1.4
|
invoke >= 1.4
|
||||||
build >= 0.7
|
|
||||||
nox >= 2022
|
nox >= 2022
|
||||||
documentation =
|
documentation =
|
||||||
mkdocs >= 1.2.3
|
mkdocs >= 1.2.3
|
||||||
|
|||||||
47
tasks.py
47
tasks.py
@@ -23,35 +23,52 @@ ROOT_DIR = os.path.dirname(os.path.realpath(__file__))
|
|||||||
|
|
||||||
ns = Collection()
|
ns = Collection()
|
||||||
|
|
||||||
|
# Building
|
||||||
build_tasks = Collection()
|
build_tasks = Collection()
|
||||||
ns.add_collection(build_tasks, name='build')
|
ns.add_collection(build_tasks, name="build")
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def build(ctx):
|
def build(ctx, install=False):
|
||||||
ctx.run('python -m build')
|
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()
|
test_tasks = Collection()
|
||||||
ns.add_collection(test_tasks, name='test')
|
ns.add_collection(test_tasks, name="test")
|
||||||
|
|
||||||
@task
|
@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 = ""
|
args = ""
|
||||||
if junit:
|
if junit:
|
||||||
args += "--junit-xml test-results.xml"
|
args += "--junit-xml test-results.xml"
|
||||||
if filter is not None:
|
if filter is not None:
|
||||||
args += " -k '{}'".format(filter)
|
args += " -k '{}'".format(filter)
|
||||||
ctx.run('python -m pytest {} {}'
|
ctx.run("python -m pytest {} {}".format(os.path.join(ROOT_DIR, "tests"), args))
|
||||||
.format(os.path.join(ROOT_DIR, "tests"), args))
|
|
||||||
|
|
||||||
test_tasks.add_task(test, name='test', default=True)
|
|
||||||
|
|
||||||
|
test_tasks.add_task(test, default=True)
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def mkdocs(ctx):
|
def release_test(ctx):
|
||||||
ctx.run('mkdocs build -f docs/mkdocs/mkdocs.yml')
|
test(ctx, install=True)
|
||||||
|
|
||||||
|
test_tasks.add_task(release_test, name="release")
|
||||||
ns.add_task(mkdocs)
|
|
||||||
|
|||||||
@@ -419,10 +419,12 @@ async def test_subscribe_notify():
|
|||||||
assert(len(c) == 1)
|
assert(len(c) == 1)
|
||||||
c3 = c[0]
|
c3 = c[0]
|
||||||
|
|
||||||
|
c1._called = False
|
||||||
c1._last_update = None
|
c1._last_update = None
|
||||||
|
|
||||||
def on_c1_update(connection, value):
|
def on_c1_update(value):
|
||||||
c1._last_update = (connection, value)
|
c1._called = True
|
||||||
|
c1._last_update = value
|
||||||
|
|
||||||
c1.on('update', on_c1_update)
|
c1.on('update', on_c1_update)
|
||||||
await peer.subscribe(c1)
|
await peer.subscribe(c1)
|
||||||
@@ -434,44 +436,73 @@ async def test_subscribe_notify():
|
|||||||
assert(not characteristic1._last_subscription[2])
|
assert(not characteristic1._last_subscription[2])
|
||||||
await server.indicate_subscribers(characteristic1)
|
await server.indicate_subscribers(characteristic1)
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
assert(c1._last_update is None)
|
assert(not c1._called)
|
||||||
await server.notify_subscribers(characteristic1)
|
await server.notify_subscribers(characteristic1)
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
assert(c1._last_update is not None)
|
assert(c1._called)
|
||||||
assert(c1._last_update[1] == characteristic1.value)
|
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
|
c2._last_update = None
|
||||||
|
|
||||||
def on_c2_update(value):
|
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 peer.subscribe(c2, on_c2_update)
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
await server.notify_subscriber(characteristic2._last_subscription[0], characteristic2)
|
await server.notify_subscriber(characteristic2._last_subscription[0], characteristic2)
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
assert(c2._last_update is None)
|
assert(not c2._called)
|
||||||
await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
|
await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
assert(c2._last_update is not None)
|
assert(c2._called)
|
||||||
assert(c2._last_update[1] == characteristic2.value)
|
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):
|
def on_c3_update(value):
|
||||||
c3._last_update = (connection, 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)
|
c3.on('update', on_c3_update)
|
||||||
await peer.subscribe(c3)
|
await peer.subscribe(c3, on_c3_update_2)
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
|
await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
assert(c3._last_update is not None)
|
assert(c3._called)
|
||||||
assert(c3._last_update[1] == characteristic3.value)
|
assert(c3._last_update == characteristic3.value)
|
||||||
|
assert(c3._called_2)
|
||||||
|
assert(c3._last_update_2 == characteristic3.value)
|
||||||
characteristic3.value = bytes([1, 2, 3])
|
characteristic3.value = bytes([1, 2, 3])
|
||||||
await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
|
await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
|
||||||
await async_barrier()
|
await async_barrier()
|
||||||
assert(c3._last_update is not None)
|
assert(c3._called)
|
||||||
assert(c3._last_update[1] == characteristic3.value)
|
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)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user