initial import

This commit is contained in:
Gilles Boccon-Gibod
2022-05-16 19:42:31 -07:00
commit 6ac91f7dec
185 changed files with 32064 additions and 0 deletions

29
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,29 @@
# How to Contribute
We'd love to accept your patches and contributions to this project. There are
just a few small guidelines you need to follow.
## Contributor License Agreement
Contributions to this project must be accompanied by a Contributor License
Agreement (CLA). You (or your employer) retain the copyright to your
contribution; this simply gives us permission to use and redistribute your
contributions as part of the project. Head over to
<https://cla.developers.google.com/> to see your current agreements on file or
to sign a new one.
You generally only need to submit a CLA once, so if you've already submitted one
(even if it was for a different project), you probably don't need to do it
again.
## Code Reviews
All submissions, including submissions by project members, require review. We
use GitHub pull requests for this purpose. Consult
[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
information on using pull requests.
## Community Guidelines
This project follows
[Google's Open Source Community Guidelines](https://opensource.google/conduct/).

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

31
README.md Normal file
View File

@@ -0,0 +1,31 @@
_ _ _
| | | | | |
| |__ _ _ ____ | |__ | | _____
| _ \| | | | \| _ \| || ___ |
| |_) ) |_| | | | | |_) ) || ____|
|____/|____/|_|_|_|____/ \_)_____)
Bluetooth Stack for Apps, Emulation, Test and Experimentation
=============================================================
<img src="docs/mkdocs/src/images/logo_framed.png" alt="drawing" width="200" height="200"/>
## Documentation
See the documentation under `docs/mkdocs/src`, or build the static HTML site from the markdown text with:
```
mkdocs build -f docs/mkdocs/mkdocs.yml
```
## License
Licensed under the [Apache 2.0](LICENSE) License.
## Disclaimer
This is not an official Google product.
This library is in alpha and will be going through a lot of breaking changes. While releases will be stable enough for prototyping, experimentation and research, we do not recommend using it in any production environment yet.
Expect bugs and sharp edges.
Please help by trying it out, reporting bugs, and letting us know what you think!

51
apps/README.md Normal file
View File

@@ -0,0 +1,51 @@
Bumble Apps
===========
NOTE:
To run python scripts from this directory when the Bumble package isn't installed in your environment,
put .. in your PYTHONPATH: `export PYTHONPATH=..`
Apps
----
## `show.py`
Parse a file with HCI packets and print the details of each packet in a human readable form
## `link_relay.py`
Simple WebSocket relay for virtual RemoteLink instances to communicate with each other through.
## `hci_bridge.py`
This app acts as a simple bridge between two HCI transports, with a host on one side and
a controller on the other. All the HCI packets bridged between the two are printed on the console
for logging. This bridge also has the ability to short-circuit some HCI packets (respond to them
with a fixed response instead of bridging them to the other side), which may be useful when used with
a host that send custom HCI commands that the controller may not understand.
### Usage
```
python hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list]
```
### Examples
#### UDP to HCI UART
```
python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078
```
#### PTY to Link Relay
```
python hci_bridge.py serial:emulated_uart_pty,1000000 link-relay:ws://127.0.0.1:10723/test
```
In this example, an emulator that exposes a PTY as an interface to its HCI UART is running as
a Bluetooth host, and we are connecting it to a virtual controller attached to a link relay
(through which the communication with other virtual controllers will be mediated).
NOTE: this assumes you're running a Link Relay on port `10723`.
## `console.py`
A simple text-based-ui interactive Bluetooth device with GATT client capabilities.

0
apps/__init__.py Normal file
View File

601
apps/console.py Normal file
View File

@@ -0,0 +1,601 @@
# 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.
# -----------------------------------------------------------------------------
# Bumble Tool
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
from bumble.hci import HCI_Constant
import os
import os.path
import logging
import click
from collections import OrderedDict
import colors
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 prompt_toolkit import Application
from prompt_toolkit.history import FileHistory
from prompt_toolkit.completion import Completer, Completion, NestedCompleter
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.styles import Style
from prompt_toolkit.filters import Condition
from prompt_toolkit.widgets import TextArea, Frame
from prompt_toolkit.widgets.toolbars import FormattedTextToolbar
from prompt_toolkit.layout import (
Layout,
HSplit,
Window,
CompletionsMenu,
Float,
FormattedTextControl,
FloatContainer,
ConditionalContainer
)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
BUMBLE_USER_DIR = os.path.expanduser('~/.bumble')
DEFAULT_PROMPT_HEIGHT = 20
DEFAULT_RSSI_BAR_WIDTH = 20
DISPLAY_MIN_RSSI = -100
DISPLAY_MAX_RSSI = -30
# -----------------------------------------------------------------------------
# Globals
# -----------------------------------------------------------------------------
App = None
# -----------------------------------------------------------------------------
# Console App
# -----------------------------------------------------------------------------
class ConsoleApp:
def __init__(self):
self.known_addresses = set()
self.known_attributes = []
self.device = None
self.connected_peer = None
self.top_tab = 'scan'
style = Style.from_dict({
'output-field': 'bg:#000044 #ffffff',
'input-field': 'bg:#000000 #ffffff',
'line': '#004400',
'error': 'fg:ansired'
})
class LiveCompleter(Completer):
def __init__(self, words):
self.words = words
def get_completions(self, document, complete_event):
prefix = document.text_before_cursor.upper()
for word in [x for x in self.words if x.upper().startswith(prefix)]:
yield Completion(word, start_position=-len(prefix))
def make_completer():
return NestedCompleter.from_nested_dict({
'scan': {
'on': None,
'off': None
},
'advertise': {
'on': None,
'off': None
},
'show': {
'scan': None,
'services': None,
'attributes': None,
'log': None
},
'connect': LiveCompleter(self.known_addresses),
'update-parameters': None,
'encrypt': None,
'disconnect': None,
'discover': {
'services': None,
'attributes': None
},
'read': LiveCompleter(self.known_attributes),
'write': LiveCompleter(self.known_attributes),
'quit': None,
'exit': None
})
self.input_field = TextArea(
height=1,
prompt="> ",
multiline=False,
wrap_lines=False,
completer=make_completer(),
history=FileHistory(os.path.join(BUMBLE_USER_DIR, 'history'))
)
self.input_field.accept_handler = self.accept_input
self.output_height = 7
self.output_lines = []
self.output = FormattedTextControl()
self.scan_results_text = FormattedTextControl()
self.services_text = FormattedTextControl()
self.attributes_text = FormattedTextControl()
self.log_text = FormattedTextControl()
self.log_height = 20
self.log_lines = []
container = HSplit([
ConditionalContainer(
Frame(Window(self.scan_results_text), title='Scan Results'),
filter=Condition(lambda: self.top_tab == 'scan')
),
ConditionalContainer(
Frame(Window(self.services_text), title='Services'),
filter=Condition(lambda: self.top_tab == 'services')
),
ConditionalContainer(
Frame(Window(self.attributes_text), title='Attributes'),
filter=Condition(lambda: self.top_tab == 'attributes')
),
ConditionalContainer(
Frame(Window(self.log_text), title='Log'),
filter=Condition(lambda: self.top_tab == 'log')
),
Frame(Window(self.output), height=self.output_height),
# HorizontalLine(),
FormattedTextToolbar(text=self.get_status_bar_text, style='reverse'),
self.input_field
])
container = FloatContainer(
container,
floats=[
Float(
xcursor=True,
ycursor=True,
content=CompletionsMenu(max_height=16, scroll_offset=1),
),
],
)
layout = Layout(container, focused_element=self.input_field)
kb = KeyBindings()
@kb.add("c-c")
@kb.add("c-q")
def _(event):
event.app.exit()
self.ui = Application(
layout=layout,
style=style,
key_bindings=kb,
full_screen=True
)
async def run_async(self, device_config, transport):
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
if device_config:
self.device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
else:
self.device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
self.device.listener = DeviceListener(self)
await self.device.power_on()
# Run the UI
await self.ui.run_async()
def add_known_address(self, address):
self.known_addresses.add(address)
def accept_input(self, buff):
if len(self.input_field.text) == 0:
return
self.append_to_output([('', '* '), ('ansicyan', self.input_field.text)], False)
self.ui.create_background_task(self.command(self.input_field.text))
def get_status_bar_text(self):
scanning = "ON" if self.device and self.device.is_scanning else "OFF"
connection_state = 'NONE'
encryption_state = ''
if self.device:
if self.device.is_connecting:
connection_state = 'CONNECTING'
elif self.connected_peer:
connection = self.connected_peer.connection
connection_parameters = f'{connection.parameters.connection_interval}/{connection.parameters.connection_latency}/{connection.parameters.supervision_timeout}'
connection_state = f'{connection.peer_address} {connection_parameters} {connection.data_length}'
encryption_state = 'ENCRYPTED' if connection.is_encrypted else 'NOT ENCRYPTED'
return [
('ansigreen', f' SCAN: {scanning} '),
('', ' '),
('ansiblue', f' CONNECTION: {connection_state} '),
('', ' '),
('ansimagenta', f' {encryption_state} ')
]
def show_error(self, title, details = None):
appended = [('class:error', title)]
if details:
appended.append(('', f' {details}'))
self.append_to_output(appended)
def show_scan_results(self, scan_results):
max_lines = 40 # TEMP
lines = []
keys = list(scan_results.keys())[:max_lines]
for key in keys:
lines.append(scan_results[key].to_display_string())
self.scan_results_text.text = ANSI('\n'.join(lines))
self.ui.invalidate()
def show_services(self, services):
lines = []
del self.known_attributes[:]
for service in services:
lines.append(('ansicyan', str(service) + '\n'))
for characteristic in service.characteristics:
lines.append(('ansimagenta', ' ' + str(characteristic) + '\n'))
self.known_attributes.append(f'{service.uuid.to_hex_str()}.{characteristic.uuid.to_hex_str()}')
self.known_attributes.append(f'*.{characteristic.uuid.to_hex_str()}')
self.known_attributes.append(f'#{characteristic.handle:X}')
for descriptor in characteristic.descriptors:
lines.append(('ansigreen', ' ' + str(descriptor) + '\n'))
self.services_text.text = lines
self.ui.invalidate()
async def show_attributes(self, attributes):
lines = []
for attribute in attributes:
lines.append(('ansicyan', f'{attribute}\n'))
self.attributes_text.text = lines
self.ui.invalidate()
def append_to_output(self, line, invalidate=True):
if type(line) is str:
line = [('', line)]
self.output_lines = self.output_lines[-(self.output_height - 3):]
self.output_lines.append(line)
formatted_text = []
for line in self.output_lines:
formatted_text += line
formatted_text.append(('', '\n'))
self.output.text = formatted_text
if invalidate:
self.ui.invalidate()
def append_to_log(self, lines, invalidate=True):
self.log_lines.extend(lines.split('\n'))
self.log_lines = self.log_lines[-(self.log_height - 3):]
self.log_text.text = ANSI('\n'.join(self.log_lines))
if invalidate:
self.ui.invalidate()
async def discover_services(self):
if not self.connected_peer:
self.show_error('not connected')
return
# Discover all services, characteristics and descriptors
self.append_to_output('discovering services...')
await self.connected_peer.discover_services()
self.append_to_output(f'found {len(self.connected_peer.services)} services, discovering charateristics...')
await self.connected_peer.discover_characteristics()
self.append_to_output('found characteristics, discovering descriptors...')
for service in self.connected_peer.services:
for characteristic in service.characteristics:
await self.connected_peer.discover_descriptors(characteristic)
self.append_to_output('discovery completed')
self.show_services(self.connected_peer.services)
async def discover_attributes(self):
if not self.connected_peer:
self.show_error('not connected')
return
# Discover all attributes
self.append_to_output('discovering attributes...')
attributes = await self.connected_peer.discover_attributes()
self.append_to_output(f'discovered {len(attributes)} attributes...')
await self.show_attributes(attributes)
async def command(self, command):
try:
(keyword, *params) = command.strip().split(' ', 1)
keyword = keyword.replace('-', '_').lower()
handler = getattr(self, f'do_{keyword}', None)
if handler:
await handler(params)
self.ui.invalidate()
else:
self.show_error('unknown command', keyword)
except Exception as error:
self.show_error(str(error))
async def do_scan(self, params):
if len(params) == 0:
# Toggle scanning
if self.device.is_scanning:
await self.device.stop_scanning()
else:
await self.device.start_scanning()
elif params[0] == 'on':
await self.device.start_scanning()
self.top_tab = 'scan'
elif params[0] == 'off':
await self.device.stop_scanning()
else:
self.show_error('unsupported arguments for scan command')
async def do_connect(self, params):
if len(params) != 1:
self.show_error('invalid syntax', 'expected connect <address>')
return
self.append_to_output('connecting...')
await self.device.connect(params[0])
self.top_tab = 'services'
async def do_disconnect(self, params):
if not self.connected_peer:
self.show_error('not connected')
return
await self.connected_peer.connection.disconnect()
async def do_update_parameters(self, params):
if len(params) != 1 or len(params[0].split('/')) != 3:
self.show_error('invalid syntax', 'expected update-parameters <interval-min>-<interval-max>/<latency>/<supervision>')
return
if not self.connected_peer:
self.show_error('not connected')
return
connection_intervals, connection_latency, supervision_timeout = params[0].split('/')
connection_interval_min, connection_interval_max = [int(x) for x in connection_intervals.split('-')]
connection_latency = int(connection_latency)
supervision_timeout = int(supervision_timeout)
await self.connected_peer.connection.update_parameters(
connection_interval_min,
connection_interval_max,
connection_latency,
supervision_timeout
)
async def do_encrypt(self, params):
if not self.connected_peer:
self.show_error('not connected')
return
await self.connected_peer.connection.encrypt()
async def do_advertise(self, params):
if len(params) == 0:
# Toggle advertising
if self.device.is_advertising:
await self.device.stop_advertising()
else:
await self.device.start_advertising()
elif params[0] == 'on':
await self.device.start_advertising()
elif params[0] == 'off':
await self.device.stop_advertising()
else:
self.show_error('unsupported arguments for advertise command')
async def do_show(self, params):
if params:
if params[0] in {'scan', 'services', 'attributes', 'log'}:
self.top_tab = params[0]
self.ui.invalidate()
async def do_discover(self, params):
if not params:
self.show_error('invalid syntax', 'expected discover services|attributes')
return
discovery_type = params[0]
if discovery_type == 'services':
await self.discover_services()
elif discovery_type == 'attributes':
await self.discover_attributes()
async def do_read(self, params):
if not self.connected_peer:
self.show_error('not connected')
return
if len(params) != 1:
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
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
else:
self.show_error('no such characteristic')
async def do_exit(self, params):
self.ui.exit()
async def do_quit(self, params):
self.ui.exit()
# -----------------------------------------------------------------------------
# Device and Connection Listener
# -----------------------------------------------------------------------------
class DeviceListener(Device.Listener, Connection.Listener):
def __init__(self, app):
self.app = app
self.scan_results = OrderedDict()
@AsyncRunner.run_in_task()
async def on_connection(self, connection):
self.app.connected_peer = Peer(connection)
self.app.append_to_output(f'connected to {self.app.connected_peer}')
connection.listener = self
def on_disconnection(self, reason):
self.app.append_to_output(f'disconnected from {self.app.connected_peer}, reason: {HCI_Constant.error_name(reason)}')
self.app.connected_peer = None
def on_connection_parameters_update(self):
self.app.append_to_output(f'connection parameters update: {self.app.connected_peer.connection.parameters}')
def on_connection_phy_update(self):
self.app.append_to_output(f'connection phy update: {self.app.connected_peer.connection.phy}')
def on_connection_att_mtu_update(self):
self.app.append_to_output(f'connection att mtu update: {self.app.connected_peer.connection.att_mtu}')
def on_connection_encryption_change(self):
self.app.append_to_output(f'connection encryption change: {"encrypted" if self.app.connected_peer.connection.is_encrypted else "not encrypted"}')
def on_connection_data_length_change(self):
self.app.append_to_output(f'connection data length change: {self.app.connected_peer.connection.data_length}')
def on_advertisement(self, address, ad_data, rssi, connectable):
entry_key = f'{address}/{address.address_type}'
entry = self.scan_results.get(entry_key)
if entry:
entry.ad_data = ad_data
entry.rssi = rssi
entry.connectable = connectable
else:
self.app.add_known_address(str(address))
self.scan_results[entry_key] = ScanResult(address, address.address_type, ad_data, rssi, connectable)
self.app.show_scan_results(self.scan_results)
# -----------------------------------------------------------------------------
# Scanning
# -----------------------------------------------------------------------------
class ScanResult:
def __init__(self, address, address_type, ad_data, rssi, connectable):
self.address = address
self.address_type = address_type
self.ad_data = ad_data
self.rssi = rssi
self.connectable = connectable
def to_display_string(self):
address_type_string = ('P', 'R', 'PI', 'RI')[self.address_type]
address_color = colors.yellow if self.connectable else colors.red
if address_type_string.startswith('P'):
type_color = colors.green
else:
type_color = colors.cyan
name = self.ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME)
if name is None:
name = self.ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME)
if name:
# Convert to string
try:
name = name.decode()
except UnicodeDecodeError:
name = name.hex()
else:
name = ''
# RSSI bar
blocks = ['', '', '', '', '', '', '', '']
bar_width = (self.rssi - DISPLAY_MIN_RSSI) / (DISPLAY_MAX_RSSI - DISPLAY_MIN_RSSI)
bar_width = min(max(bar_width, 0), 1)
bar_ticks = int(bar_width * DEFAULT_RSSI_BAR_WIDTH * 8)
bar_blocks = ('' * int(bar_ticks / 8)) + blocks[bar_ticks % 8]
bar_string = f'{self.rssi} {bar_blocks}'
bar_padding = ' ' * (DEFAULT_RSSI_BAR_WIDTH + 5 - len(bar_string))
return f'{address_color(str(self.address))} [{type_color(address_type_string)}] {bar_string} {bar_padding} {name}'
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
class LogHandler(logging.Handler):
def __init__(self, app):
super().__init__()
self.app = app
def emit(self, record):
message = self.format(record)
self.app.append_to_log(message)
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
@click.command()
@click.option('--device-config', help='Device configuration file')
@click.argument('transport')
def main(device_config, transport):
# Ensure that the BUMBLE_USER_DIR directory exists
if not os.path.isdir(BUMBLE_USER_DIR):
os.mkdir(BUMBLE_USER_DIR)
# Create an instane of the app
app = ConsoleApp()
# Setup logging
# logging.basicConfig(level = 'FATAL')
# logging.basicConfig(level = 'DEBUG')
root_logger = logging.getLogger()
root_logger.addHandler(LogHandler(app))
root_logger.setLevel(logging.DEBUG)
# Run until the user exits
asyncio.run(app.run_async(device_config, transport))
# -----------------------------------------------------------------------------
if __name__ == "__main__":
main()

63
apps/controllers.py Normal file
View File

@@ -0,0 +1,63 @@
# 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 logging
import asyncio
import sys
import os
from bumble.controller import Controller
from bumble.link import LocalLink
from bumble.transport import open_transport_or_link
# -----------------------------------------------------------------------------
async def async_main():
if len(sys.argv) != 3:
print('Usage: controllers.py <hci-transport-1> <hci-transport-2> [<hci-transport-3> ...]')
print('example: python controllers.py pty:ble1 pty:ble2')
return
# Create a loccal link to attach the controllers to
link = LocalLink()
# Create a transport and controller for all requested names
transports = []
controllers = []
for index, transport_name in enumerate(sys.argv[1:]):
transport = await open_transport_or_link(transport_name)
transports.append(transport)
controller = Controller(f'C{index}', host_source = transport.source, host_sink = transport.sink, link = link)
controllers.append(controller)
# Wait until the user interrupts
await asyncio.get_running_loop().create_future()
# Cleanup
for transport in transports:
transport.close()
# -----------------------------------------------------------------------------
def main():
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(async_main())
# -----------------------------------------------------------------------------
if __name__ == '__main__':
main()

108
apps/gatt_dump.py Normal file
View File

@@ -0,0 +1,108 @@
# 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.core import ProtocolError, TimeoutError
from bumble.device import Device, Peer
from bumble.gatt import show_services
from bumble.transport import open_transport_or_link
# -----------------------------------------------------------------------------
async def dump_gatt_db(peer, done):
# Discover all services
print(color('### Discovering Services and Characteristics', 'magenta'))
await peer.discover_services()
await peer.discover_characteristics()
for service in peer.services:
for characteristic in service.characteristics:
await peer.discover_descriptors(characteristic)
print(color('=== Services ===', 'yellow'))
show_services(peer.services)
print()
# Discover all attributes
print(color('=== All Attributes ===', 'yellow'))
attributes = await peer.discover_attributes()
for attribute in attributes:
print(attribute)
try:
value = await peer.read_value(attribute)
print(color(f'{value.hex()}', 'green'))
except ProtocolError as error:
print(color(error, 'red'))
except TimeoutError:
print(color('read timeout', 'red'))
if done is not None:
done.set_result(None)
# -----------------------------------------------------------------------------
async def async_main(device_config, encrypt, transport, address_or_name):
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
# Create a device
if device_config:
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
else:
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
await device.power_on()
if address_or_name:
# Connect to the target peer
connection = await device.connect(address_or_name)
# Encrypt the connection if required
if encrypt:
await connection.encrypt()
await dump_gatt_db(Peer(connection), None)
else:
# Wait for a connection
done = asyncio.get_running_loop().create_future()
device.on('connection', lambda connection: asyncio.create_task(dump_gatt_db(Peer(connection), done)))
await device.start_advertising(auto_restart=True)
print(color('### Waiting for connection...', 'blue'))
await done
# -----------------------------------------------------------------------------
@click.command()
@click.option('--device-config', help='Device configuration', type=click.Path())
@click.option('--encrypt', help='Encrypt the connection', is_flag=True, default=False)
@click.argument('transport')
@click.argument('address-or-name', required=False)
def main(device_config, encrypt, transport, address_or_name):
"""
Dump the GATT database on a remote device. If ADDRESS_OR_NAME is not specified,
wait for an incoming connection.
"""
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(async_main(device_config, encrypt, transport, address_or_name))
# -----------------------------------------------------------------------------
if __name__ == '__main__':
main()

211
apps/gg_bridge.py Normal file
View File

@@ -0,0 +1,211 @@
# 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.device import Device, Peer
from bumble.core import AdvertisingData
from bumble.gatt import Service, Characteristic
from bumble.utils import AsyncRunner
from bumble.transport import open_transport_or_link
from bumble.hci import HCI_Constant
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
GG_GATTLINK_SERVICE_UUID = 'ABBAFF00-E56A-484C-B832-8B17CF6CBFE8'
GG_GATTLINK_RX_CHARACTERISTIC_UUID = 'ABBAFF01-E56A-484C-B832-8B17CF6CBFE8'
GG_GATTLINK_TX_CHARACTERISTIC_UUID = 'ABBAFF02-E56A-484C-B832-8B17CF6CBFE8'
GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID = 'ABBAFF03-E56A-484C-B832-8B17CF6CBFE8'
GG_PREFERRED_MTU = 256
# -----------------------------------------------------------------------------
class GattlinkHubBridge(Device.Listener):
def __init__(self):
self.peer = None
self.rx_socket = None
self.tx_socket = None
self.rx_characteristic = None
self.tx_characteristic = None
@AsyncRunner.run_in_task()
async def on_connection(self, connection):
print(f'=== Connected to {connection}')
self.peer = Peer(connection)
# Request a larger MTU than the default
server_mtu = await self.peer.request_mtu(GG_PREFERRED_MTU)
print(f'### Server MTU = {server_mtu}')
# Discover all services
print(color('=== Discovering services', 'yellow'))
await self.peer.discover_service(GG_GATTLINK_SERVICE_UUID)
print(color('=== Services discovered', 'yellow'), self.peer.services)
for service in self.peer.services:
print(service)
services = self.peer.get_services_by_uuid(GG_GATTLINK_SERVICE_UUID)
if not services:
print(color('!!! Gattlink service not found', 'red'))
return
# Use the first Gattlink (there should only be one anyway)
gattlink_service = services[0]
# Discover all the characteristics for the service
characteristics = await self.peer.discover_characteristics(service = gattlink_service)
print(color('=== Characteristics discovered', 'yellow'))
for characteristic in characteristics:
if characteristic.uuid == GG_GATTLINK_RX_CHARACTERISTIC_UUID:
self.rx_characteristic = characteristic
elif characteristic.uuid == GG_GATTLINK_TX_CHARACTERISTIC_UUID:
self.tx_characteristic = characteristic
print('RX:', self.rx_characteristic)
print('TX:', self.tx_characteristic)
# Subscribe to TX
if self.tx_characteristic:
await self.peer.subscribe(self.tx_characteristic, self.on_tx_received)
print(color('=== Subscribed to Gattlink TX', 'yellow'))
else:
print(color('!!! Gattlink TX not found', 'red'))
def on_connection_failure(self, error):
print(color(f'!!! Connection failed: {error}'))
def on_disconnection(self, reason):
print(color(f'!!! Disconnected from {self.peer}, reason={HCI_Constant.error_name(reason)}', 'red'))
self.tx_characteristic = None
self.rx_characteristic = None
self.peer = None
# Called by the GATT client when a notification is received
def on_tx_received(self, value):
print(color('>>> TX:', 'magenta'), value.hex())
if self.tx_socket:
self.tx_socket.sendto(value)
# Called by asyncio when the UDP socket is created
def connection_made(self, transport):
pass
# Called by asyncio when a UDP datagram is received
def datagram_received(self, data, address):
print(color('<<< RX:', 'magenta'), data.hex())
# TODO: use a queue instead of creating a task everytime
if self.peer and self.rx_characteristic:
asyncio.create_task(self.peer.write_value(self.rx_characteristic, data))
# -----------------------------------------------------------------------------
class GattlinkNodeBridge(Device.Listener):
def __init__(self):
self.peer = None
self.rx_socket = None
self.tx_socket = None
# Called by asyncio when the UDP socket is created
def connection_made(self, transport):
pass
# Called by asyncio when a UDP datagram is received
def datagram_received(self, data, address):
print(color('<<< RX:', 'magenta'), data.hex())
# TODO: use a queue instead of creating a task everytime
if self.peer and self.rx_characteristic:
asyncio.create_task(self.peer.write_value(self.rx_characteristic, data))
# -----------------------------------------------------------------------------
async def run(hci_transport, device_address, send_host, send_port, receive_host, receive_port):
print('<<< connecting to HCI...')
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
print('<<< connected')
# Instantiate a bridge object
bridge = GattlinkNodeBridge()
# Create a UDP to RX bridge (receive from UDP, send to RX)
loop = asyncio.get_running_loop()
await loop.create_datagram_endpoint(
lambda: bridge,
local_addr=(receive_host, receive_port)
)
# Create a UDP to TX bridge (receive from TX, send to UDP)
bridge.tx_socket, _ = await loop.create_datagram_endpoint(
lambda: asyncio.DatagramProtocol(),
remote_addr=(send_host, send_port)
)
# Create a device to manage the host, with a custom listener
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
device.listener = bridge
await device.power_on()
# Connect to the peer
# print(f'=== Connecting to {device_address}...')
# await device.connect(device_address)
# TODO move to class
gattlink_service = Service(
GG_GATTLINK_SERVICE_UUID,
[
Characteristic(
GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID,
Characteristic.READ,
Characteristic.READABLE,
bytes([193, 0])
)
]
)
device.add_services([gattlink_service])
device.advertising_data = bytes(
AdvertisingData([
(AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble GG', 'utf-8')),
(AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS, bytes(reversed(bytes.fromhex('ABBAFF00E56A484CB8328B17CF6CBFE8'))))
])
)
await device.start_advertising()
# Wait until the source terminates
await hci_source.wait_for_termination()
@click.command()
@click.argument('hci_transport')
@click.argument('device_address')
@click.option('-sh', '--send-host', type=str, default='127.0.0.1', help='UDP host to send to')
@click.option('-sp', '--send-port', type=int, default=9001, help='UDP port to send to')
@click.option('-rh', '--receive-host', type=str, default='127.0.0.1', help='UDP host to receive on')
@click.option('-rp', '--receive-port', type=int, default=9000, help='UDP port to receive on')
def main(hci_transport, device_address, send_host, send_port, receive_host, receive_port):
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(run(hci_transport, device_address, send_host, send_port, receive_host, receive_port))
# -----------------------------------------------------------------------------
if __name__ == '__main__':
main()

89
apps/hci_bridge.py Normal file
View File

@@ -0,0 +1,89 @@
# 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 logging
import asyncio
import os
import sys
from bumble import hci, transport
from bumble.bridge import HCI_Bridge
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
async def async_main():
if len(sys.argv) < 3:
print('Usage: hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list]')
print('example: python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078')
return
print('>>> connecting to HCI...')
async with await transport.open_transport_or_link(sys.argv[1]) as (hci_host_source, hci_host_sink):
print('>>> connected')
print('>>> connecting to HCI...')
async with await transport.open_transport_or_link(sys.argv[2]) as (hci_controller_source, hci_controller_sink):
print('>>> connected')
command_short_circuits = []
if len(sys.argv) >= 4:
for op_code_str in sys.argv[3].split(','):
if ':' in op_code_str:
ogf, ocf = op_code_str.split(':')
command_short_circuits.append(hci.hci_command_op_code(int(ogf, 16), int(ocf, 16)))
else:
command_short_circuits.append(int(op_code_str, 16))
def host_to_controller_filter(hci_packet):
if hci_packet.hci_packet_type == hci.HCI_COMMAND_PACKET and hci_packet.op_code in command_short_circuits:
# Respond with a success response
logger.debug('short-circuiting packet')
response = hci.HCI_Command_Complete_Event(
num_hci_command_packets = 1,
command_opcode = hci_packet.op_code,
return_parameters = bytes([hci.HCI_SUCCESS])
)
# Return a packet with 'respond to sender' set to True
return (response.to_bytes(), True)
_ = HCI_Bridge(
hci_host_source,
hci_host_sink,
hci_controller_source,
hci_controller_sink,
host_to_controller_filter,
None
)
await asyncio.get_running_loop().create_future()
# -----------------------------------------------------------------------------
def main():
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(async_main())
# -----------------------------------------------------------------------------
if __name__ == '__main__':
main()

View File

View File

@@ -0,0 +1,276 @@
# Copyright 2021-2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ----------------------------------------------------------------------------
# Imports
# ----------------------------------------------------------------------------
import sys
import websockets
import logging
import json
import asyncio
import argparse
import uuid
import os
from urllib.parse import urlparse
from colors import color
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------
DEFAULT_RELAY_PORT = 10723
# ----------------------------------------------------------------------------
# Utils
# ----------------------------------------------------------------------------
def error_to_json(error):
return json.dumps({'error': error})
def error_to_result(error):
return f'result:{error_to_json(error)}'
async def broadcast_message(message, connections):
# Send to all the connections
tasks = [connection.send_message(message) for connection in connections]
if tasks:
await asyncio.gather(*tasks)
# ----------------------------------------------------------------------------
# Connection class
# ----------------------------------------------------------------------------
class Connection:
"""
A Connection represents a client connected to the relay over a websocket
"""
def __init__(self, room, websocket):
self.room = room
self.websocket = websocket
self.address = str(uuid.uuid4())
async def send_message(self, message):
try:
logger.debug(color(f'->{self.address}: {message}', 'yellow'))
return await self.websocket.send(message)
except websockets.exceptions.WebSocketException as error:
logger.info(f'! client "{self}" disconnected: {error}')
await self.cleanup()
async def send_error(self, error):
return await self.send_message(f'result:{error_to_json(error)}')
async def receive_message(self):
try:
message = await self.websocket.recv()
logger.debug(color(f'<-{self.address}: {message}', 'blue'))
return message
except websockets.exceptions.WebSocketException as error:
logger.info(color(f'! client "{self}" disconnected: {error}', 'red'))
await self.cleanup()
async def cleanup(self):
if self.room:
await self.room.remove_connection(self)
def set_address(self, address):
logger.info(f'Connection address changed: {self.address} -> {address}')
self.address = address
def __str__(self):
return f'Connection(address="{self.address}", client={self.websocket.remote_address[0]}:{self.websocket.remote_address[1]})'
# ----------------------------------------------------------------------------
# Room class
# ----------------------------------------------------------------------------
class Room:
"""
A Room is a collection of bridged connections
"""
def __init__(self, relay, name):
self.relay = relay
self.name = name
self.observers = []
self.connections = []
async def add_connection(self, connection):
logger.info(f'New participant in {self.name}: {connection}')
self.connections.append(connection)
await self.broadcast_message(connection, f'joined:{connection.address}')
async def remove_connection(self, connection):
if connection in self.connections:
self.connections.remove(connection)
await self.broadcast_message(connection, f'left:{connection.address}')
def find_connections_by_address(self, address):
return [c for c in self.connections if c.address == address]
async def bridge_connection(self, connection):
while True:
# Wait for a message
message = await connection.receive_message()
# Skip empty messages
if message is None:
return
# Parse the message to decide how to handle it
if message.startswith('@'):
# This is a targetted message
await self.on_targetted_message(connection, message)
elif message.startswith('/'):
# This is an RPC request
await self.on_rpc_request(connection, message)
else:
await connection.send_message(f'result:{error_to_json("error: invalid message")}')
async def broadcast_message(self, sender, message):
'''
Send to all connections in the room except back to the sender
'''
await broadcast_message(message, [c for c in self.connections if c != sender])
async def on_rpc_request(self, connection, message):
command, *params = message.split(' ', 1)
if handler := getattr(self, f'on_{command[1:].lower().replace("-","_")}_command', None):
try:
result = await handler(connection, params)
except Exception as error:
result = error_to_result(error)
else:
result = error_to_result('unknown command')
await connection.send_message(result or 'result:{}')
async def on_targetted_message(self, connection, message):
target, *payload = message.split(' ', 1)
if not payload:
return error_to_json('missing arguments')
payload = payload[0]
target = target[1:]
# Determine what targets to send to
if target == '*':
# Send to all connections in the room except the connection from which the message was received
connections = [c for c in self.connections if c != connection]
else:
connections = self.find_connections_by_address(target)
if not connections:
# Unicast with no recipient, let the sender know
await connection.send_message(f'unreachable:{target}')
# Send to targets
await broadcast_message(f'message:{connection.address}/{payload}', connections)
async def on_set_address_command(self, connection, params):
if not params:
return error_to_result('missing address')
current_address = connection.address
new_address = params[0]
connection.set_address(new_address)
await self.broadcast_message(connection, f'address-changed:from={current_address},to={new_address}')
# ----------------------------------------------------------------------------
class Relay:
"""
A relay accepts connections with the following url: ws://<hostname>/<room>.
Participants in a room can communicate with each other
"""
def __init__(self, port):
self.port = port
self.rooms = {}
self.observers = []
def start(self):
logger.info(f'Starting Relay on port {self.port}')
return websockets.serve(self.serve, '0.0.0.0', self.port, ping_interval=None)
async def serve_as_controller(connection):
pass
async def serve(self, websocket, path):
logger.debug(f'New connection with path {path}')
# Parse the path
parsed = urlparse(path)
# Check if this is a controller client
if parsed.path == '/':
return await self.serve_as_controller(Connection('', websocket))
# Find or create a room for this connection
room_name = parsed.path[1:].split('/')[0]
if room_name not in self.rooms:
self.rooms[room_name] = Room(self, room_name)
room = self.rooms[room_name]
# Add the connection to the room
connection = Connection(room, websocket)
await room.add_connection(connection)
# Bridge until the connection is closed
await room.bridge_connection(connection)
# ----------------------------------------------------------------------------
def main():
# Check the Python version
if sys.version_info < (3, 6, 1):
print('ERROR: Python 3.6.1 or higher is required')
sys.exit(1)
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
# Parse arguments
arg_parser = argparse.ArgumentParser(description='Bumble Link Relay')
arg_parser.add_argument('--log-level', default='INFO', help='logger level')
arg_parser.add_argument('--log-config', help='logger config file (YAML)')
arg_parser.add_argument('--port',
type = int,
default = DEFAULT_RELAY_PORT,
help = 'Port to listen on')
args = arg_parser.parse_args()
# Setup logger
if args.log_config:
from logging import config
config.fileConfig(args.log_config)
else:
logging.basicConfig(level = getattr(logging, args.log_level.upper()))
# Start a relay
relay = Relay(args.port)
asyncio.get_event_loop().run_until_complete(relay.start())
asyncio.get_event_loop().run_forever()
# ----------------------------------------------------------------------------
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,21 @@
[loggers]
keys=root
[handlers]
keys=stream_handler
[formatters]
keys=formatter
[logger_root]
level=DEBUG
handlers=stream_handler
[handler_stream_handler]
class=StreamHandler
level=DEBUG
formatter=formatter
args=(sys.stderr,)
[formatter_formatter]
format=%(asctime)s %(name)-12s %(levelname)-8s %(message)s

312
apps/pair.py Normal file
View File

@@ -0,0 +1,312 @@
# 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
import aioconsole
from colors import color
from bumble.device import Device, Peer
from bumble.transport import open_transport_or_link
from bumble.smp import PairingDelegate, PairingConfig
from bumble.smp import error_name as smp_error_name
from bumble.keys import JsonKeyStore
from bumble.core import ProtocolError
from bumble.gatt import (
GATT_DEVICE_NAME_CHARACTERISTIC,
GATT_GENERIC_ACCESS_SERVICE,
Service,
Characteristic,
CharacteristicValue
)
from bumble.att import (
ATT_Error,
ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
ATT_INSUFFICIENT_ENCRYPTION_ERROR
)
# -----------------------------------------------------------------------------
class Delegate(PairingDelegate):
def __init__(self, connection, capability_string, prompt):
super().__init__({
'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY,
'display': PairingDelegate.DISPLAY_OUTPUT_ONLY,
'display+keyboard': PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
'display+yes/no': PairingDelegate.DISPLAY_OUTPUT_AND_YES_NO_INPUT,
'none': PairingDelegate.NO_OUTPUT_NO_INPUT
}[capability_string.lower()])
self.peer = Peer(connection)
self.peer_name = None
self.prompt = prompt
async def update_peer_name(self):
if self.peer_name is not None:
# We already asked the peer
return
# Try to get the peer's name
if self.peer:
peer_name = await get_peer_name(self.peer)
self.peer_name = f'{peer_name or ""} [{self.peer.connection.peer_address}]'
else:
self.peer_name = '[?]'
async def accept(self):
if self.prompt:
await self.update_peer_name()
# Wait a bit to allow some of the log lines to print before we prompt
await asyncio.sleep(1)
# Prompt for acceptance
print(color('###-----------------------------------', 'yellow'))
print(color(f'### Pairing request from {self.peer_name}', 'yellow'))
print(color('###-----------------------------------', 'yellow'))
while True:
response = await aioconsole.ainput(color('>>> Accept? ', 'yellow'))
response = response.lower().strip()
if response == 'yes':
return True
elif response == 'no':
return False
else:
# Accept silently
return True
async def compare_numbers(self, number):
await self.update_peer_name()
# Wait a bit to allow some of the log lines to print before we prompt
await asyncio.sleep(1)
# Prompt for a numeric comparison
print(color('###-----------------------------------', 'yellow'))
print(color(f'### Pairing with {self.peer_name}', 'yellow'))
print(color('###-----------------------------------', 'yellow'))
while True:
response = await aioconsole.ainput(color(f'>>> Does the other device display {number:06}? ', 'yellow'))
response = response.lower().strip()
if response == 'yes':
return True
elif response == 'no':
return False
async def get_number(self):
await self.update_peer_name()
# Wait a bit to allow some of the log lines to print before we prompt
await asyncio.sleep(1)
# Prompt for a PIN
while True:
try:
print(color('###-----------------------------------', 'yellow'))
print(color(f'### Pairing with {self.peer_name}', 'yellow'))
print(color('###-----------------------------------', 'yellow'))
return int(await aioconsole.ainput(color('>>> Enter PIN: ', 'yellow')))
except ValueError:
pass
async def display_number(self, number):
await self.update_peer_name()
# Wait a bit to allow some of the log lines to print before we prompt
await asyncio.sleep(1)
# Display a PIN code
print(color('###-----------------------------------', 'yellow'))
print(color(f'### Pairing with {self.peer_name}', 'yellow'))
print(color(f'### PIN: {number:06}', 'yellow'))
print(color('###-----------------------------------', 'yellow'))
# -----------------------------------------------------------------------------
async def get_peer_name(peer):
services = await peer.discover_service(GATT_GENERIC_ACCESS_SERVICE)
if not services:
return None
values = await peer.read_characteristics_by_uuid(GATT_DEVICE_NAME_CHARACTERISTIC, services[0])
if values:
return values[0].decode('utf-8')
# -----------------------------------------------------------------------------
AUTHENTICATION_ERROR_RETURNED = [False, False]
def read_with_error(connection):
if not connection.is_encrypted:
raise ATT_Error(ATT_INSUFFICIENT_ENCRYPTION_ERROR)
if AUTHENTICATION_ERROR_RETURNED[0]:
return bytes([1])
else:
AUTHENTICATION_ERROR_RETURNED[0] = True
raise ATT_Error(ATT_INSUFFICIENT_AUTHENTICATION_ERROR)
def write_with_error(connection, value):
if not connection.is_encrypted:
raise ATT_Error(ATT_INSUFFICIENT_ENCRYPTION_ERROR)
if not AUTHENTICATION_ERROR_RETURNED[1]:
AUTHENTICATION_ERROR_RETURNED[1] = True
raise ATT_Error(ATT_INSUFFICIENT_AUTHENTICATION_ERROR)
# -----------------------------------------------------------------------------
def on_connection(connection, request):
print(color(f'<<< Connection: {connection}', 'green'))
# Listen for pairing events
connection.on('pairing_start', on_pairing_start)
connection.on('pairing', on_pairing)
connection.on('pairing_failure', on_pairing_failure)
# Listen for encryption changes
connection.on(
'connection_encryption_change',
lambda: on_connection_encryption_change(connection)
)
# Request pairing if needed
if request:
print(color('>>> Requesting pairing', 'green'))
connection.request_pairing()
# -----------------------------------------------------------------------------
def on_connection_encryption_change(connection):
print(color('@@@-----------------------------------', 'blue'))
print(color(f'@@@ Connection is {"" if connection.is_encrypted else "not"}encrypted', 'blue'))
print(color('@@@-----------------------------------', 'blue'))
# -----------------------------------------------------------------------------
def on_pairing_start():
print(color('***-----------------------------------', 'magenta'))
print(color('*** Pairing starting', 'magenta'))
print(color('***-----------------------------------', 'magenta'))
# -----------------------------------------------------------------------------
def on_pairing(keys):
print(color('***-----------------------------------', 'cyan'))
print(color('*** Paired!', 'cyan'))
keys.print(prefix=color('*** ', 'cyan'))
print(color('***-----------------------------------', 'cyan'))
# -----------------------------------------------------------------------------
def on_pairing_failure(reason):
print(color('***-----------------------------------', 'red'))
print(color(f'*** Pairing failed: {smp_error_name(reason)}', 'red'))
print(color('***-----------------------------------', 'red'))
# -----------------------------------------------------------------------------
async def pair(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, device_config, transport, address_or_name):
print('<<< connecting to HCI...')
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
print('<<< connected')
# Create a device to manage the host
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
# Set a custom keystore if specified on the command line
if keystore_file:
device.keystore = JsonKeyStore(namespace=None, filename=keystore_file)
# Print the existing keys before pairing
if print_keys and device.keystore:
print(color('@@@-----------------------------------', 'blue'))
print(color('@@@ Pairing Keys:', 'blue'))
await device.keystore.print(prefix=color('@@@ ', 'blue'))
print(color('@@@-----------------------------------', 'blue'))
# Expose a GATT characteristic that can be used to trigger pairing by
# responding with an authentication error when read
device.add_service(
Service(
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
[
Characteristic(
'552957FB-CF1F-4A31-9535-E78847E1A714',
Characteristic.READ | Characteristic.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue(read=read_with_error, write=write_with_error)
)
]
)
)
# Get things going
await device.power_on()
# Set up a pairing config factory
device.pairing_config_factory = lambda connection: PairingConfig(
sc,
mitm,
bond,
Delegate(connection, io, prompt)
)
# Connect to a peer or wait for a connection
device.on('connection', lambda connection: on_connection(connection, request))
if address_or_name is not None:
print(color(f'=== Connecting to {address_or_name}...', 'green'))
connection = await device.connect(address_or_name)
if not request:
try:
await connection.pair()
return
except ProtocolError:
pass
else:
# Advertise so that peers can find us and connect
await device.start_advertising(auto_restart=True)
await hci_source.wait_for_termination()
# -----------------------------------------------------------------------------
@click.command()
@click.option('--sc', type=bool, default=True, help='Use the Secure Connections protocol', show_default=True)
@click.option('--mitm', type=bool, default=True, help='Request MITM protection', show_default=True)
@click.option('--bond', type=bool, default=True, help='Enable bonding', show_default=True)
@click.option('--io', type=click.Choice(['keyboard', 'display', 'display+keyboard', 'display+yes/no', 'none']), default='display+keyboard', show_default=True)
@click.option('--prompt', is_flag=True, help='Prompt to accept/reject pairing request')
@click.option('--request', is_flag=True, help='Request that the connecting peer initiate pairing')
@click.option('--print-keys', is_flag=True, help='Print the bond keys before pairing')
@click.option('--keystore-file', help='File in which to store the pairing keys')
@click.argument('device-config')
@click.argument('transport')
@click.argument('address-or-name', required=False)
def main(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, device_config, transport, address_or_name):
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(pair(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, device_config, transport, address_or_name))
# -----------------------------------------------------------------------------
if __name__ == '__main__':
main()

157
apps/scan.py Normal file
View 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.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
import os
import logging
import click
from colors import color
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.keys import JsonKeyStore
from bumble.smp import AddressResolver
from bumble.hci import HCI_LE_Advertising_Report_Event
from bumble.core import AdvertisingData
# -----------------------------------------------------------------------------
def make_rssi_bar(rssi):
DISPLAY_MIN_RSSI = -105
DISPLAY_MAX_RSSI = -30
DEFAULT_RSSI_BAR_WIDTH = 30
blocks = ['', '', '', '', '', '', '', '']
bar_width = (rssi - DISPLAY_MIN_RSSI) / (DISPLAY_MAX_RSSI - DISPLAY_MIN_RSSI)
bar_width = min(max(bar_width, 0), 1)
bar_ticks = int(bar_width * DEFAULT_RSSI_BAR_WIDTH * 8)
return ('' * int(bar_ticks / 8)) + blocks[bar_ticks % 8]
# -----------------------------------------------------------------------------
class AdvertisementPrinter:
def __init__(self, min_rssi, resolver):
self.min_rssi = min_rssi
self.resolver = resolver
def print_advertisement(self, address, address_color, ad_data, rssi):
if self.min_rssi is not None and rssi < self.min_rssi:
return
address_qualifier = ''
resolution_qualifier = ''
if self.resolver and address.is_resolvable:
resolved = self.resolver.resolve(address)
if resolved is not None:
resolution_qualifier = f'(resolved from {address})'
address = resolved
address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[address.address_type]
if address.is_public:
type_color = 'cyan'
else:
if address.is_static:
type_color = 'green'
address_qualifier = '(static)'
elif address.is_resolvable:
type_color = 'magenta'
address_qualifier = '(resolvable)'
else:
type_color = 'blue'
address_qualifier = '(non-resolvable)'
rssi_bar = make_rssi_bar(rssi)
separator = '\n '
print(f'>>> {color(address, address_color)} [{color(address_type_string, type_color)}]{address_qualifier}{resolution_qualifier}:{separator}RSSI:{rssi:4} {rssi_bar}{separator}{ad_data.to_string(separator)}\n')
def on_advertisement(self, address, ad_data, rssi, connectable):
address_color = 'yellow' if connectable else 'red'
self.print_advertisement(address, address_color, ad_data, rssi)
def on_advertising_report(self, address, ad_data, rssi, event_type):
print(f'{color("EVENT", "green")}: {HCI_LE_Advertising_Report_Event.event_type_name(event_type)}')
ad_data = AdvertisingData.from_bytes(ad_data)
self.print_advertisement(address, 'yellow', ad_data, rssi)
# -----------------------------------------------------------------------------
async def scan(
min_rssi,
passive,
scan_interval,
scan_window,
filter_duplicates,
raw,
keystore_file,
device_config,
transport
):
print('<<< connecting to HCI...')
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
print('<<< connected')
if device_config:
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
else:
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
if keystore_file:
keystore = JsonKeyStore(namespace=None, filename=keystore_file)
device.keystore = keystore
else:
resolver = None
if device.keystore:
resolving_keys = await device.keystore.get_resolving_keys()
resolver = AddressResolver(resolving_keys)
printer = AdvertisementPrinter(min_rssi, resolver)
if raw:
device.host.on('advertising_report', printer.on_advertising_report)
else:
device.on('advertisement', printer.on_advertisement)
await device.power_on()
await device.start_scanning(
active=(not passive),
scan_interval=scan_interval,
scan_window=scan_window,
filter_duplicates=filter_duplicates
)
await hci_source.wait_for_termination()
# -----------------------------------------------------------------------------
@click.command()
@click.option('--min-rssi', type=int, help='Minimum RSSI value')
@click.option('--passive', is_flag=True, default=False, help='Perform passive scanning')
@click.option('--scan-interval', type=int, default=60, help='Scan interval')
@click.option('--scan-window', type=int, default=60, help='Scan window')
@click.option('--filter-duplicates', type=bool, default=True, help='Filter duplicates at the controller level')
@click.option('--raw', is_flag=True, default=False, help='Listen for raw advertising reports instead of processed ones')
@click.option('--keystore-file', help='Keystore file to use when resolving addresses')
@click.option('--device-config', help='Device config file for the scanning device')
@click.argument('transport')
def main(min_rssi, passive, scan_interval, scan_window, filter_duplicates, raw, keystore_file, device_config, transport):
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
asyncio.run(scan(min_rssi, passive, scan_interval, scan_window, filter_duplicates, raw, keystore_file, device_config, transport))
# -----------------------------------------------------------------------------
if __name__ == '__main__':
main()

120
apps/show.py Normal file
View File

@@ -0,0 +1,120 @@
# 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 struct
import click
from colors import color
from bumble import hci
from bumble.transport import PacketReader
from bumble.helpers import PacketTracer
# -----------------------------------------------------------------------------
class SnoopPacketReader:
'''
Reader that reads HCI packets from a "snoop" file (based on RFC 1761, but not exactly the same...)
'''
DATALINK_H1 = 1001
DATALINK_H4 = 1002
DATALINK_BSCP = 1003
DATALINK_H5 = 1004
def __init__(self, source):
self.source = source
# Read the header
identification_pattern = source.read(8)
if identification_pattern.hex().lower() != '6274736e6f6f7000':
raise ValueError('not a valid snoop file, unexpected identification pattern')
(self.version_number, self.data_link_type) = struct.unpack('>II', source.read(8))
if self.data_link_type != self.DATALINK_H4 and self.data_link_type != self.DATALINK_H1:
raise ValueError(f'datalink type {self.data_link_type} not supported')
def next_packet(self):
# Read the record header
header = self.source.read(24)
if len(header) < 24:
return (0, None)
(
original_length,
included_length,
packet_flags,
cumulative_drops,
timestamp_seconds,
timestamp_microsecond
) = struct.unpack('>IIIIII', header)
# Abort on truncated packets
if original_length != included_length:
return (0, None)
if self.data_link_type == self.DATALINK_H1:
# The packet is un-encapsulated, look at the flags to figure out its type
if packet_flags & 1:
# Controller -> Host
if packet_flags & 2:
packet_type = hci.HCI_EVENT_PACKET
else:
packet_type = hci.HCI_ACL_DATA_PACKET
else:
# Host -> Controller
if packet_flags & 2:
packet_type = hci.HCI_COMMAND_PACKET
else:
packet_type = hci.HCI_ACL_DATA_PACKET
return (packet_flags & 1, bytes([packet_type]) + self.source.read(included_length))
else:
return (packet_flags & 1, self.source.read(included_length))
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
@click.command()
@click.option('--format', type=click.Choice(['h4', 'snoop']), default='h4', help='Format of the input file')
@click.argument('filename')
def show(format, filename):
input = open(filename, 'rb')
if format == 'h4':
packet_reader = PacketReader(input)
def read_next_packet():
(0, packet_reader.next_packet())
else:
packet_reader = SnoopPacketReader(input)
read_next_packet = packet_reader.next_packet
tracer = PacketTracer(emit_message=print)
while True:
try:
(direction, packet) = read_next_packet()
if packet is None:
break
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
except Exception as error:
print(color(f'!!! {error}', 'red'))
pass
# -----------------------------------------------------------------------------
if __name__ == '__main__':
show()

63
apps/unbond.py Normal file
View File

@@ -0,0 +1,63 @@
# 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 bumble.device import Device
from bumble.keys import JsonKeyStore
# -----------------------------------------------------------------------------
async def unbond(keystore_file, device_config, address):
# Create a device to manage the host
device = Device.from_config_file(device_config)
# Get all entries in the keystore
if keystore_file:
keystore = JsonKeyStore(None, keystore_file)
else:
keystore = device.keystore
if keystore is None:
print('no keystore')
return
if address is None:
await keystore.print()
else:
try:
await keystore.delete(address)
except KeyError:
print('!!! pairing not found')
# -----------------------------------------------------------------------------
@click.command()
@click.option('--keystore-file', help='File in which to store the pairing keys')
@click.argument('device-config')
@click.argument('address', required=False)
def main(keystore_file, device_config, address):
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(unbond(keystore_file, device_config, address))
# -----------------------------------------------------------------------------
if __name__ == '__main__':
main()

0
bumble/__init__.py Normal file
View File

554
bumble/a2dp.py Normal file
View File

@@ -0,0 +1,554 @@
# 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 struct
import bitstruct
import logging
from collections import namedtuple
from colors import color
from .company_ids import COMPANY_IDENTIFIERS
from .sdp import (
DataElement,
ServiceAttribute,
SDP_PUBLIC_BROWSE_ROOT,
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID
)
from .core import (
BT_L2CAP_PROTOCOL_ID,
BT_AUDIO_SOURCE_SERVICE,
BT_AUDIO_SINK_SERVICE,
BT_AVDTP_PROTOCOL_ID,
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
name_or_number
)
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
A2DP_SBC_CODEC_TYPE = 0x00
A2DP_MPEG_1_2_AUDIO_CODEC_TYPE = 0x01
A2DP_MPEG_2_4_AAC_CODEC_TYPE = 0x02
A2DP_ATRAC_FAMILY_CODEC_TYPE = 0x03
A2DP_NON_A2DP_CODEC_TYPE = 0xFF
A2DP_CODEC_TYPE_NAMES = {
A2DP_SBC_CODEC_TYPE: 'A2DP_SBC_CODEC_TYPE',
A2DP_MPEG_1_2_AUDIO_CODEC_TYPE: 'A2DP_MPEG_1_2_AUDIO_CODEC_TYPE',
A2DP_MPEG_2_4_AAC_CODEC_TYPE: 'A2DP_MPEG_2_4_AAC_CODEC_TYPE',
A2DP_ATRAC_FAMILY_CODEC_TYPE: 'A2DP_ATRAC_FAMILY_CODEC_TYPE',
A2DP_NON_A2DP_CODEC_TYPE: 'A2DP_NON_A2DP_CODEC_TYPE'
}
SBC_SYNC_WORD = 0x9C
SBC_SAMPLING_FREQUENCIES = [
16000,
22050,
44100,
48000
]
SBC_MONO_CHANNEL_MODE = 0x00
SBC_DUAL_CHANNEL_MODE = 0x01
SBC_STEREO_CHANNEL_MODE = 0x02
SBC_JOINT_STEREO_CHANNEL_MODE = 0x03
SBC_CHANNEL_MODE_NAMES = {
SBC_MONO_CHANNEL_MODE: 'SBC_MONO_CHANNEL_MODE',
SBC_DUAL_CHANNEL_MODE: 'SBC_DUAL_CHANNEL_MODE',
SBC_STEREO_CHANNEL_MODE: 'SBC_STEREO_CHANNEL_MODE',
SBC_JOINT_STEREO_CHANNEL_MODE: 'SBC_JOINT_STEREO_CHANNEL_MODE'
}
SBC_BLOCK_LENGTHS = [4, 8, 12, 16]
SBC_SUBBANDS = [4, 8]
SBC_SNR_ALLOCATION_METHOD = 0x00
SBC_LOUDNESS_ALLOCATION_METHOD = 0x01
SBC_ALLOCATION_METHOD_NAMES = {
SBC_SNR_ALLOCATION_METHOD: 'SBC_SNR_ALLOCATION_METHOD',
SBC_LOUDNESS_ALLOCATION_METHOD: 'SBC_LOUDNESS_ALLOCATION_METHOD'
}
MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [
8000,
11025,
12000,
16000,
22050,
24000,
32000,
44100,
48000,
64000,
88200,
96000
]
MPEG_2_AAC_LC_OBJECT_TYPE = 0x00
MPEG_4_AAC_LC_OBJECT_TYPE = 0x01
MPEG_4_AAC_LTP_OBJECT_TYPE = 0x02
MPEG_4_AAC_SCALABLE_OBJECT_TYPE = 0x03
MPEG_2_4_OBJECT_TYPE_NAMES = {
MPEG_2_AAC_LC_OBJECT_TYPE: 'MPEG_2_AAC_LC_OBJECT_TYPE',
MPEG_4_AAC_LC_OBJECT_TYPE: 'MPEG_4_AAC_LC_OBJECT_TYPE',
MPEG_4_AAC_LTP_OBJECT_TYPE: 'MPEG_4_AAC_LTP_OBJECT_TYPE',
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 'MPEG_4_AAC_SCALABLE_OBJECT_TYPE'
}
# -----------------------------------------------------------------------------
def flags_to_list(flags, values):
result = []
for i in range(len(values)):
if flags & (1 << (len(values) - i - 1)):
result.append(values[i])
return result
# -----------------------------------------------------------------------------
def make_audio_source_service_sdp_records(service_record_handle, version=(1, 3)):
from .avdtp import AVDTP_PSM
version_int = version[0] << 8 | version[1]
return [
ServiceAttribute(SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID, DataElement.unsigned_integer_32(service_record_handle)),
ServiceAttribute(SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID, DataElement.sequence([
DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)
])),
ServiceAttribute(SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, DataElement.sequence([
DataElement.uuid(BT_AUDIO_SOURCE_SERVICE)
])),
ServiceAttribute(SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, DataElement.sequence([
DataElement.sequence([
DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
DataElement.unsigned_integer_16(AVDTP_PSM)
]),
DataElement.sequence([
DataElement.uuid(BT_AVDTP_PROTOCOL_ID),
DataElement.unsigned_integer_16(version_int)
])
])),
ServiceAttribute(SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID, DataElement.sequence([
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
DataElement.unsigned_integer_16(version_int)
])),
]
# -----------------------------------------------------------------------------
def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
from .avdtp import AVDTP_PSM
version_int = version[0] << 8 | version[1]
return [
ServiceAttribute(SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID, DataElement.unsigned_integer_32(service_record_handle)),
ServiceAttribute(SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID, DataElement.sequence([
DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)
])),
ServiceAttribute(SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, DataElement.sequence([
DataElement.uuid(BT_AUDIO_SINK_SERVICE)
])),
ServiceAttribute(SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, DataElement.sequence([
DataElement.sequence([
DataElement.uuid(BT_L2CAP_PROTOCOL_ID),
DataElement.unsigned_integer_16(AVDTP_PSM)
]),
DataElement.sequence([
DataElement.uuid(BT_AVDTP_PROTOCOL_ID),
DataElement.unsigned_integer_16(version_int)
])
])),
ServiceAttribute(SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID, DataElement.sequence([
DataElement.uuid(BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE),
DataElement.unsigned_integer_16(version_int)
])),
]
# -----------------------------------------------------------------------------
class SbcMediaCodecInformation(
namedtuple(
'SbcMediaCodecInformation',
[
'sampling_frequency',
'channel_mode',
'block_length',
'subbands',
'allocation_method',
'minimum_bitpool_value',
'maximum_bitpool_value'
]
)
):
'''
A2DP spec - 4.3.2 Codec Specific Information Elements
'''
BIT_FIELDS = 'u4u4u4u2u2u8u8'
SAMPLING_FREQUENCY_BITS = {
16000: 1 << 3,
32000: 1 << 2,
44100: 1 << 1,
48000: 1
}
CHANNEL_MODE_BITS = {
SBC_MONO_CHANNEL_MODE: 1 << 3,
SBC_DUAL_CHANNEL_MODE: 1 << 2,
SBC_STEREO_CHANNEL_MODE: 1 << 1,
SBC_JOINT_STEREO_CHANNEL_MODE: 1
}
BLOCK_LENGTH_BITS = {
4: 1 << 3,
8: 1 << 2,
12: 1 << 1,
16: 1
}
SUBBANDS_BITS = {
4: 1 << 1,
8: 1
}
ALLOCATION_METHOD_BITS = {
SBC_SNR_ALLOCATION_METHOD: 1 << 1,
SBC_LOUDNESS_ALLOCATION_METHOD: 1
}
@staticmethod
def from_bytes(data):
return SbcMediaCodecInformation(*bitstruct.unpack(SbcMediaCodecInformation.BIT_FIELDS, data))
@classmethod
def from_discrete_values(
cls,
sampling_frequency,
channel_mode,
block_length,
subbands,
allocation_method,
minimum_bitpool_value,
maximum_bitpool_value
):
return SbcMediaCodecInformation(
sampling_frequency = cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
channel_mode = cls.CHANNEL_MODE_BITS[channel_mode],
block_length = cls.BLOCK_LENGTH_BITS[block_length],
subbands = cls.SUBBANDS_BITS[subbands],
allocation_method = cls.ALLOCATION_METHOD_BITS[allocation_method],
minimum_bitpool_value = minimum_bitpool_value,
maximum_bitpool_value = maximum_bitpool_value
)
@classmethod
def from_lists(
cls,
sampling_frequencies,
channel_modes,
block_lengths,
subbands,
allocation_methods,
minimum_bitpool_value,
maximum_bitpool_value
):
return SbcMediaCodecInformation(
sampling_frequency = sum(cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies),
channel_mode = sum(cls.CHANNEL_MODE_BITS[x] for x in channel_modes),
block_length = sum(cls.BLOCK_LENGTH_BITS[x] for x in block_lengths),
subbands = sum(cls.SUBBANDS_BITS[x] for x in subbands),
allocation_method = sum(cls.ALLOCATION_METHOD_BITS[x] for x in allocation_methods),
minimum_bitpool_value = minimum_bitpool_value,
maximum_bitpool_value = maximum_bitpool_value
)
def __bytes__(self):
return bitstruct.pack(self.BIT_FIELDS, *self)
def __str__(self):
channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
allocation_methods = ['SNR', 'Loudness']
return '\n'.join([
'SbcMediaCodecInformation(',
f' sampling_frequency: {",".join([str(x) for x in flags_to_list(self.sampling_frequency, SBC_SAMPLING_FREQUENCIES)])}',
f' channel_mode: {",".join([str(x) for x in flags_to_list(self.channel_mode, channel_modes)])}',
f' block_length: {",".join([str(x) for x in flags_to_list(self.block_length, SBC_BLOCK_LENGTHS)])}',
f' subbands: {",".join([str(x) for x in flags_to_list(self.subbands, SBC_SUBBANDS)])}',
f' allocation_method: {",".join([str(x) for x in flags_to_list(self.allocation_method, allocation_methods)])}',
f' minimum_bitpool_value: {self.minimum_bitpool_value}',
f' maximum_bitpool_value: {self.maximum_bitpool_value}'
')'
])
# -----------------------------------------------------------------------------
class AacMediaCodecInformation(
namedtuple(
'AacMediaCodecInformation',
[
'object_type',
'sampling_frequency',
'channels',
'vbr',
'bitrate'
]
)
):
'''
A2DP spec - 4.5.2 Codec Specific Information Elements
'''
BIT_FIELDS = 'u8u12u2p2u1u23'
OBJECT_TYPE_BITS = {
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
MPEG_4_AAC_LTP_OBJECT_TYPE: 1 << 5,
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 1 << 4
}
SAMPLING_FREQUENCY_BITS = {
8000: 1 << 11,
11025: 1 << 10,
12000: 1 << 9,
16000: 1 << 8,
22050: 1 << 7,
24000: 1 << 6,
32000: 1 << 5,
44100: 1 << 4,
48000: 1 << 3,
64000: 1 << 2,
88200: 1 << 1,
96000: 1
}
CHANNELS_BITS = {
1: 1 << 1,
2: 1
}
@staticmethod
def from_bytes(data):
return AacMediaCodecInformation(*bitstruct.unpack(AacMediaCodecInformation.BIT_FIELDS, data))
@classmethod
def from_discrete_values(
cls,
object_type,
sampling_frequency,
channels,
vbr,
bitrate
):
return AacMediaCodecInformation(
object_type = cls.OBJECT_TYPE_BITS[object_type],
sampling_frequency = cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
channels = cls.CHANNELS_BITS[channels],
vbr = vbr,
bitrate = bitrate
)
@classmethod
def from_lists(
cls,
object_types,
sampling_frequencies,
channels,
vbr,
bitrate
):
return AacMediaCodecInformation(
object_type = sum(cls.OBJECT_TYPE_BITS[x] for x in object_types),
sampling_frequency = sum(cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies),
channels = sum(cls.CHANNELS_BITS[x] for x in channels),
vbr = vbr,
bitrate = bitrate
)
def __bytes__(self):
return bitstruct.pack(self.BIT_FIELDS, *self)
def __str__(self):
object_types = ['MPEG_2_AAC_LC', 'MPEG_4_AAC_LC', 'MPEG_4_AAC_LTP', 'MPEG_4_AAC_SCALABLE', '[4]', '[5]', '[6]', '[7]']
channels = [1, 2]
return '\n'.join([
'AacMediaCodecInformation(',
f' object_type: {",".join([str(x) for x in flags_to_list(self.object_type, object_types)])}',
f' sampling_frequency: {",".join([str(x) for x in flags_to_list(self.sampling_frequency, MPEG_2_4_AAC_SAMPLING_FREQUENCIES)])}',
f' channels: {",".join([str(x) for x in flags_to_list(self.channels, channels)])}',
f' vbr: {self.vbr}',
f' bitrate: {self.bitrate}'
')'
])
# -----------------------------------------------------------------------------
class VendorSpecificMediaCodecInformation:
'''
A2DP spec - 4.7.2 Codec Specific Information Elements
'''
@staticmethod
def from_bytes(data):
(vendor_id, codec_id) = struct.unpack_from('<IH', data, 0)
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
def __init__(self, vendor_id, codec_id, value):
self.vendor_id = vendor_id
self.codec_id = codec_id
self.value = value
def __bytes__(self):
return struct.pack('<IH', self.vendor_id, self.codec_id, self.value)
def __str__(self):
return '\n'.join([
'VendorSpecificMediaCodecInformation(',
f' vendor_id: {self.vendor_id:08X} ({name_or_number(COMPANY_IDENTIFIERS, self.vendor_id & 0xFFFF)})',
f' codec_id: {self.codec_id:04X}',
f' value: {self.value.hex()}'
')'
])
# -----------------------------------------------------------------------------
class SbcFrame:
def __init__(
self,
sampling_frequency,
block_count,
channel_mode,
subband_count,
payload
):
self.sampling_frequency = sampling_frequency
self.block_count = block_count
self.channel_mode = channel_mode
self.subband_count = subband_count
self.payload = payload
@property
def sample_count(self):
return self.subband_count * self.block_count
@property
def bitrate(self):
return 8 * ((len(self.payload) * self.sampling_frequency) // self.sample_count)
@property
def duration(self):
return self.sample_count / self.sampling_frequency
def __str__(self):
return f'SBC(sf={self.sampling_frequency},cm={self.channel_mode},br={self.bitrate},sc={self.sample_count},size={len(self.payload)})'
# -----------------------------------------------------------------------------
class SbcParser:
def __init__(self, read):
self.read = read
@property
def frames(self):
async def generate_frames():
while True:
# Read 4 bytes of header
header = await self.read(4)
if len(header) != 4:
return
# Check the sync word
if header[0] != SBC_SYNC_WORD:
logger.debug('invalid sync word')
return
# Extract some of the header fields
sampling_frequency = SBC_SAMPLING_FREQUENCIES[(header[1] >> 6) & 3]
blocks = 4 * (1 + ((header[1] >> 4) & 3))
channel_mode = (header[1] >> 2) & 3
channels = 1 if channel_mode == SBC_MONO_CHANNEL_MODE else 2
subbands = 8 if ((header[1]) & 1) else 4
bitpool = header[2]
# Compute the frame length
frame_length = 4 + (4 * subbands * channels) // 8
if channel_mode in (SBC_MONO_CHANNEL_MODE, SBC_DUAL_CHANNEL_MODE):
frame_length += (blocks * channels * bitpool) // 8
else:
frame_length += ((1 if channel_mode == SBC_JOINT_STEREO_CHANNEL_MODE else 0) * subbands + blocks * bitpool) // 8
# Read the rest of the frame
payload = header + await self.read(frame_length - 4)
# Emit the next frame
yield SbcFrame(sampling_frequency, blocks, channel_mode, subbands, payload)
return generate_frames()
# -----------------------------------------------------------------------------
class SbcPacketSource:
def __init__(self, read, mtu, codec_capabilities):
self.read = read
self.mtu = mtu
self.codec_capabilities = codec_capabilities
@property
def packets(self):
async def generate_packets():
from .avdtp import MediaPacket # Import here to avoid a circular reference
sequence_number = 0
timestamp = 0
frames = []
frames_size = 0
max_rtp_payload = self.mtu - 12 - 1
# NOTE: this doesn't support frame fragments
sbc_parser = SbcParser(self.read)
async for frame in sbc_parser.frames:
print(frame)
if frames_size + len(frame.payload) > max_rtp_payload or len(frames) == 16:
# Need to flush what has been accumulated so far
# Emit a packet
sbc_payload = bytes([len(frames)]) + b''.join([frame.payload for frame in frames])
packet = MediaPacket(2, 0, 0, 0, sequence_number, timestamp, 0, [], 96, sbc_payload)
packet.timestamp_seconds = timestamp / frame.sampling_frequency
yield packet
# Prepare for next packets
sequence_number += 1
timestamp += sum([frame.sample_count for frame in frames])
frames = [frame]
frames_size = len(frame.payload)
else:
# Accumulate
frames.append(frame)
frames_size += len(frame.payload)
return generate_packets()

728
bumble/att.py Normal file
View File

@@ -0,0 +1,728 @@
# 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.
# -----------------------------------------------------------------------------
# ATT - Attribute Protocol
#
# See Bluetooth spec @ Vol 3, Part F
#
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from colors import color
from pyee import EventEmitter
from .core import *
from .hci import *
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
ATT_CID = 0x04
ATT_ERROR_RESPONSE = 0x01
ATT_EXCHANGE_MTU_REQUEST = 0x02
ATT_EXCHANGE_MTU_RESPONSE = 0x03
ATT_FIND_INFORMATION_REQUEST = 0x04
ATT_FIND_INFORMATION_RESPONSE = 0x05
ATT_FIND_BY_TYPE_VALUE_REQUEST = 0x06
ATT_FIND_BY_TYPE_VALUE_RESPONSE = 0x07
ATT_READ_BY_TYPE_REQUEST = 0x08
ATT_READ_BY_TYPE_RESPONSE = 0x09
ATT_READ_REQUEST = 0x0A
ATT_READ_RESPONSE = 0x0B
ATT_READ_BLOB_REQUEST = 0x0C
ATT_READ_BLOB_RESPONSE = 0x0D
ATT_READ_MULTIPLE_REQUEST = 0x0E
ATT_READ_MULTIPLE_RESPONSE = 0x0F
ATT_READ_BY_GROUP_TYPE_REQUEST = 0x10
ATT_READ_BY_GROUP_TYPE_RESPONSE = 0x11
ATT_WRITE_REQUEST = 0x12
ATT_WRITE_RESPONSE = 0x13
ATT_WRITE_COMMAND = 0x52
ATT_SIGNED_WRITE_COMMAND = 0xD2
ATT_PREPARE_WRITE_REQUEST = 0x16
ATT_PREPARE_WRITE_RESPONSE = 0x17
ATT_EXECUTE_WRITE_REQUEST = 0x18
ATT_EXECUTE_WRITE_RESPONSE = 0x19
ATT_HANDLE_VALUE_NOTIFICATION = 0x1B
ATT_HANDLE_VALUE_INDICATION = 0x1D
ATT_HANDLE_VALUE_CONFIRMATION = 0x1E
ATT_PDU_NAMES = {
ATT_ERROR_RESPONSE: 'ATT_ERROR_RESPONSE',
ATT_EXCHANGE_MTU_REQUEST: 'ATT_EXCHANGE_MTU_REQUEST',
ATT_EXCHANGE_MTU_RESPONSE: 'ATT_EXCHANGE_MTU_RESPONSE',
ATT_FIND_INFORMATION_REQUEST: 'ATT_FIND_INFORMATION_REQUEST',
ATT_FIND_INFORMATION_RESPONSE: 'ATT_FIND_INFORMATION_RESPONSE',
ATT_FIND_BY_TYPE_VALUE_REQUEST: 'ATT_FIND_BY_TYPE_VALUE_REQUEST',
ATT_FIND_BY_TYPE_VALUE_RESPONSE: 'ATT_FIND_BY_TYPE_VALUE_RESPONSE',
ATT_READ_BY_TYPE_REQUEST: 'ATT_READ_BY_TYPE_REQUEST',
ATT_READ_BY_TYPE_RESPONSE: 'ATT_READ_BY_TYPE_RESPONSE',
ATT_READ_REQUEST: 'ATT_READ_REQUEST',
ATT_READ_RESPONSE: 'ATT_READ_RESPONSE',
ATT_READ_BLOB_REQUEST: 'ATT_READ_BLOB_REQUEST',
ATT_READ_BLOB_RESPONSE: 'ATT_READ_BLOB_RESPONSE',
ATT_READ_MULTIPLE_REQUEST: 'ATT_READ_MULTIPLE_REQUEST',
ATT_READ_MULTIPLE_RESPONSE: 'ATT_READ_MULTIPLE_RESPONSE',
ATT_READ_BY_GROUP_TYPE_REQUEST: 'ATT_READ_BY_GROUP_TYPE_REQUEST',
ATT_READ_BY_GROUP_TYPE_RESPONSE: 'ATT_READ_BY_GROUP_TYPE_RESPONSE',
ATT_WRITE_REQUEST: 'ATT_WRITE_REQUEST',
ATT_WRITE_RESPONSE: 'ATT_WRITE_RESPONSE',
ATT_WRITE_COMMAND: 'ATT_WRITE_COMMAND',
ATT_SIGNED_WRITE_COMMAND: 'ATT_SIGNED_WRITE_COMMAND',
ATT_PREPARE_WRITE_REQUEST: 'ATT_PREPARE_WRITE_REQUEST',
ATT_PREPARE_WRITE_RESPONSE: 'ATT_PREPARE_WRITE_RESPONSE',
ATT_EXECUTE_WRITE_REQUEST: 'ATT_EXECUTE_WRITE_REQUEST',
ATT_EXECUTE_WRITE_RESPONSE: 'ATT_EXECUTE_WRITE_RESPONSE',
ATT_HANDLE_VALUE_NOTIFICATION: 'ATT_HANDLE_VALUE_NOTIFICATION',
ATT_HANDLE_VALUE_INDICATION: 'ATT_HANDLE_VALUE_INDICATION',
ATT_HANDLE_VALUE_CONFIRMATION: 'ATT_HANDLE_VALUE_CONFIRMATION'
}
ATT_REQUESTS = [
ATT_EXCHANGE_MTU_REQUEST,
ATT_FIND_INFORMATION_REQUEST,
ATT_FIND_BY_TYPE_VALUE_REQUEST,
ATT_READ_BY_TYPE_REQUEST,
ATT_READ_REQUEST,
ATT_READ_BLOB_REQUEST,
ATT_READ_MULTIPLE_REQUEST,
ATT_READ_BY_GROUP_TYPE_REQUEST,
ATT_WRITE_REQUEST,
ATT_PREPARE_WRITE_REQUEST,
ATT_EXECUTE_WRITE_REQUEST
]
ATT_RESPONSES = [
ATT_ERROR_RESPONSE,
ATT_EXCHANGE_MTU_RESPONSE,
ATT_FIND_INFORMATION_RESPONSE,
ATT_FIND_BY_TYPE_VALUE_RESPONSE,
ATT_READ_BY_TYPE_RESPONSE,
ATT_READ_RESPONSE,
ATT_READ_BLOB_RESPONSE,
ATT_READ_MULTIPLE_RESPONSE,
ATT_READ_BY_GROUP_TYPE_RESPONSE,
ATT_WRITE_RESPONSE,
ATT_PREPARE_WRITE_RESPONSE,
ATT_EXECUTE_WRITE_RESPONSE
]
ATT_INVALID_HANDLE_ERROR = 0x01
ATT_READ_NOT_PERMITTED_ERROR = 0x02
ATT_WRITE_NOT_PERMITTED_ERROR = 0x03
ATT_INVALID_PDU_ERROR = 0x04
ATT_INSUFFICIENT_AUTHENTICATION_ERROR = 0x05
ATT_REQUEST_NOT_SUPPORTED_ERROR = 0x06
ATT_INVALID_OFFSET_ERROR = 0x07
ATT_INSUFFICIENT_AUTHORIZATION_ERROR = 0x08
ATT_PREPARE_QUEUE_FULL_ERROR = 0x09
ATT_ATTRIBUTE_NOT_FOUND_ERROR = 0x0A
ATT_ATTRIBUTE_NOT_LONG_ERROR = 0x0B
ATT_INSUFFICIENT_ENCRYPTION_KEY_SIZE_ERROR = 0x0C
ATT_INVALID_ATTRIBUTE_LENGTH_ERROR = 0x0D
ATT_UNLIKELY_ERROR_ERROR = 0x0E
ATT_INSUFFICIENT_ENCRYPTION_ERROR = 0x0F
ATT_UNSUPPORTED_GROUP_TYPE_ERROR = 0x10
ATT_INSUFFICIENT_RESOURCES_ERROR = 0x11
ATT_ERROR_NAMES = {
ATT_INVALID_HANDLE_ERROR: 'ATT_INVALID_HANDLE_ERROR',
ATT_READ_NOT_PERMITTED_ERROR: 'ATT_READ_NOT_PERMITTED_ERROR',
ATT_WRITE_NOT_PERMITTED_ERROR: 'ATT_WRITE_NOT_PERMITTED_ERROR',
ATT_INVALID_PDU_ERROR: 'ATT_INVALID_PDU_ERROR',
ATT_INSUFFICIENT_AUTHENTICATION_ERROR: 'ATT_INSUFFICIENT_AUTHENTICATION_ERROR',
ATT_REQUEST_NOT_SUPPORTED_ERROR: 'ATT_REQUEST_NOT_SUPPORTED_ERROR',
ATT_INVALID_OFFSET_ERROR: 'ATT_INVALID_OFFSET_ERROR',
ATT_INSUFFICIENT_AUTHORIZATION_ERROR: 'ATT_INSUFFICIENT_AUTHORIZATION_ERROR',
ATT_PREPARE_QUEUE_FULL_ERROR: 'ATT_PREPARE_QUEUE_FULL_ERROR',
ATT_ATTRIBUTE_NOT_FOUND_ERROR: 'ATT_ATTRIBUTE_NOT_FOUND_ERROR',
ATT_ATTRIBUTE_NOT_LONG_ERROR: 'ATT_ATTRIBUTE_NOT_LONG_ERROR',
ATT_INSUFFICIENT_ENCRYPTION_KEY_SIZE_ERROR: 'ATT_INSUFFICIENT_ENCRYPTION_KEY_SIZE_ERROR',
ATT_INVALID_ATTRIBUTE_LENGTH_ERROR: 'ATT_INVALID_ATTRIBUTE_LENGTH_ERROR',
ATT_UNLIKELY_ERROR_ERROR: 'ATT_UNLIKELY_ERROR_ERROR',
ATT_INSUFFICIENT_ENCRYPTION_ERROR: 'ATT_INSUFFICIENT_ENCRYPTION_ERROR',
ATT_UNSUPPORTED_GROUP_TYPE_ERROR: 'ATT_UNSUPPORTED_GROUP_TYPE_ERROR',
ATT_INSUFFICIENT_RESOURCES_ERROR: 'ATT_INSUFFICIENT_RESOURCES_ERROR'
}
ATT_DEFAULT_MTU = 23
HANDLE_FIELD_SPEC = {'size': 2, 'mapper': lambda x: f'0x{x:04X}'}
UUID_2_16_FIELD_SPEC = lambda x, y: UUID.parse_uuid(x, y) # noqa: E731
UUID_2_FIELD_SPEC = lambda x, y: UUID.parse_uuid_2(x, y) # noqa: E731
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
def key_with_value(dictionary, target_value):
for key, value in dictionary.items():
if value == target_value:
return key
return None
# -----------------------------------------------------------------------------
# Exceptions
# -----------------------------------------------------------------------------
class ATT_Error(Exception):
def __init__(self, error_code, att_handle=0x0000):
self.error_code = error_code
self.att_handle = att_handle
def __str__(self):
return f'ATT_Error({ATT_PDU.error_name(self.error_code)})'
# -----------------------------------------------------------------------------
# Attribute Protocol
# -----------------------------------------------------------------------------
class ATT_PDU:
'''
See Bluetooth spec @ Vol 3, Part F - 3.3 ATTRIBUTE PDU
'''
pdu_classes = {}
op_code = 0
@staticmethod
def from_bytes(pdu):
op_code = pdu[0]
cls = ATT_PDU.pdu_classes.get(op_code)
if cls is None:
instance = ATT_PDU(pdu)
instance.name = ATT_PDU.pdu_name(op_code)
instance.op_code = op_code
return instance
self = cls.__new__(cls)
ATT_PDU.__init__(self, pdu)
if hasattr(self, 'fields'):
self.init_from_bytes(pdu, 1)
return self
@staticmethod
def pdu_name(op_code):
return name_or_number(ATT_PDU_NAMES, op_code, 2)
@staticmethod
def error_name(error_code):
return name_or_number(ATT_ERROR_NAMES, error_code, 2)
@staticmethod
def subclass(fields):
def inner(cls):
cls.name = cls.__name__.upper()
cls.op_code = key_with_value(ATT_PDU_NAMES, cls.name)
if cls.op_code is None:
raise KeyError(f'PDU name {cls.name} not found in ATT_PDU_NAMES')
cls.fields = fields
# Register a factory for this class
ATT_PDU.pdu_classes[cls.op_code] = cls
return cls
return inner
def __init__(self, pdu=None, **kwargs):
if hasattr(self, 'fields') and kwargs:
HCI_Object.init_from_fields(self, self.fields, kwargs)
if pdu is None:
pdu = bytes([self.op_code]) + HCI_Object.dict_to_bytes(kwargs, self.fields)
self.pdu = pdu
def init_from_bytes(self, pdu, offset):
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
def to_bytes(self):
return self.pdu
@property
def is_command(self):
return ((self.op_code >> 6) & 1) == 1
@property
def has_authentication_signature(self):
return ((self.op_code >> 7) & 1) == 1
def __bytes__(self):
return self.to_bytes()
def __str__(self):
result = color(self.name, 'yellow')
if fields := getattr(self, 'fields', None):
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
else:
if len(self.pdu) > 1:
result += f': {self.pdu.hex()}'
return result
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('request_opcode_in_error', {'size': 1, 'mapper': ATT_PDU.pdu_name}),
('attribute_handle_in_error', HANDLE_FIELD_SPEC),
('error_code', {'size': 1, 'mapper': ATT_PDU.error_name})
])
class ATT_Error_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.1.1 Error Response
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('client_rx_mtu', 2)
])
class ATT_Exchange_MTU_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.2.1 Exchange MTU Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('server_rx_mtu', 2)
])
class ATT_Exchange_MTU_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.2.2 Exchange MTU Response
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('starting_handle', HANDLE_FIELD_SPEC),
('ending_handle', HANDLE_FIELD_SPEC)
])
class ATT_Find_Information_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.3.1 Find Information Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('format', 1),
('information_data', '*')
])
class ATT_Find_Information_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.3.2 Find Information Response
'''
def parse_information_data(self):
self.information = []
offset = 0
uuid_size = 2 if self.format == 1 else 16
while offset + uuid_size <= len(self.information_data):
handle = struct.unpack_from('<H', self.information_data, offset)[0]
uuid = self.information_data[2 + offset:2 + offset + uuid_size]
self.information.append((handle, uuid))
offset += 2 + uuid_size
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parse_information_data()
def init_from_bytes(self, pdu, offset):
super().init_from_bytes(pdu, offset)
self.parse_information_data()
def __str__(self):
result = color(self.name, 'yellow')
result += ':\n' + HCI_Object.format_fields(self.__dict__, [
('format', 1),
('information', {'mapper': lambda x: ', '.join([f'0x{handle:04X}:{uuid.hex()}' for handle, uuid in x])})
], ' ')
return result
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('starting_handle', HANDLE_FIELD_SPEC),
('ending_handle', HANDLE_FIELD_SPEC),
('attribute_type', UUID_2_FIELD_SPEC),
('attribute_value', '*')
])
class ATT_Find_By_Type_Value_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.3.3 Find By Type Value Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('handles_information_list', '*')
])
class ATT_Find_By_Type_Value_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.3.4 Find By Type Value Response
'''
def parse_handles_information_list(self):
self.handles_information = []
offset = 0
while offset + 4 <= len(self.handles_information_list):
found_attribute_handle, group_end_handle = struct.unpack_from('<HH', self.handles_information_list, offset)
self.handles_information.append((found_attribute_handle, group_end_handle))
offset += 4
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parse_handles_information_list()
def init_from_bytes(self, pdu, offset):
super().init_from_bytes(pdu, offset)
self.parse_handles_information_list()
def __str__(self):
result = color(self.name, 'yellow')
result += ':\n' + HCI_Object.format_fields(self.__dict__, [
('handles_information', {'mapper': lambda x: ', '.join([f'0x{handle1:04X}-0x{handle2:04X}' for handle1, handle2 in x])})
], ' ')
return result
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('starting_handle', HANDLE_FIELD_SPEC),
('ending_handle', HANDLE_FIELD_SPEC),
('attribute_type', UUID_2_16_FIELD_SPEC)
])
class ATT_Read_By_Type_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.4.1 Read By Type Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('length', 1),
('attribute_data_list', '*')
])
class ATT_Read_By_Type_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.4.2 Read By Type Response
'''
def parse_attribute_data_list(self):
self.attributes = []
offset = 0
while self.length != 0 and offset + self.length <= len(self.attribute_data_list):
attribute_handle, = struct.unpack_from('<H', self.attribute_data_list, offset)
attribute_value = self.attribute_data_list[offset + 2:offset + self.length]
self.attributes.append((attribute_handle, attribute_value))
offset += self.length
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parse_attribute_data_list()
def init_from_bytes(self, pdu, offset):
super().init_from_bytes(pdu, offset)
self.parse_attribute_data_list()
def __str__(self):
result = color(self.name, 'yellow')
result += ':\n' + HCI_Object.format_fields(self.__dict__, [
('length', 1),
('attributes', {'mapper': lambda x: ', '.join([f'0x{handle:04X}:{value.hex()}' for handle, value in x])})
], ' ')
return result
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('attribute_handle', HANDLE_FIELD_SPEC)
])
class ATT_Read_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.4.3 Read Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('attribute_value', '*')
])
class ATT_Read_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.4.4 Read Response
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('attribute_handle', HANDLE_FIELD_SPEC),
('value_offset', 2)
])
class ATT_Read_Blob_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.4.5 Read Blob Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('part_attribute_value', '*')
])
class ATT_Read_Blob_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.4.6 Read Blob Response
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('set_of_handles', '*')
])
class ATT_Read_Multiple_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.4.7 Read Multiple Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('set_of_values', '*')
])
class ATT_Read_Multiple_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.4.8 Read Multiple Response
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('starting_handle', HANDLE_FIELD_SPEC),
('ending_handle', HANDLE_FIELD_SPEC),
('attribute_group_type', UUID_2_16_FIELD_SPEC)
])
class ATT_Read_By_Group_Type_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.4.9 Read by Group Type Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('length', 1),
('attribute_data_list', '*')
])
class ATT_Read_By_Group_Type_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.4.10 Read by Group Type Response
'''
def parse_attribute_data_list(self):
self.attributes = []
offset = 0
while self.length != 0 and offset + self.length <= len(self.attribute_data_list):
attribute_handle, end_group_handle = struct.unpack_from('<HH', self.attribute_data_list, offset)
attribute_value = self.attribute_data_list[offset + 4:offset + self.length]
self.attributes.append((attribute_handle, end_group_handle, attribute_value))
offset += self.length
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parse_attribute_data_list()
def init_from_bytes(self, pdu, offset):
super().init_from_bytes(pdu, offset)
self.parse_attribute_data_list()
def __str__(self):
result = color(self.name, 'yellow')
result += ':\n' + HCI_Object.format_fields(self.__dict__, [
('length', 1),
('attributes', {'mapper': lambda x: ', '.join([f'0x{handle:04X}-0x{end:04X}:{value.hex()}' for handle, end, value in x])})
], ' ')
return result
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('attribute_handle', HANDLE_FIELD_SPEC),
('attribute_value', '*')
])
class ATT_Write_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.5.1 Write Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([])
class ATT_Write_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.5.2 Write Response
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('attribute_handle', HANDLE_FIELD_SPEC),
('attribute_value', '*')
])
class ATT_Write_Command(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.5.3 Write Command
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('attribute_handle', HANDLE_FIELD_SPEC),
('attribute_value', '*')
# ('authentication_signature', 'TODO')
])
class ATT_Signed_Write_Command(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.5.4 Signed Write Command
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('attribute_handle', HANDLE_FIELD_SPEC),
('value_offset', 2),
('part_attribute_value', '*')
])
class ATT_Prepare_Write_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.6.1 Prepare Write Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('attribute_handle', HANDLE_FIELD_SPEC),
('value_offset', 2),
('part_attribute_value', '*')
])
class ATT_Prepare_Write_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.6.2 Prepare Write Response
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([])
class ATT_Execute_Write_Request(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.6.3 Execute Write Request
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([])
class ATT_Execute_Write_Response(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.6.4 Execute Write Response
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('attribute_handle', HANDLE_FIELD_SPEC),
('attribute_value', '*')
])
class ATT_Handle_Value_Notification(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.7.1 Handle Value Notification
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([
('attribute_handle', HANDLE_FIELD_SPEC),
('attribute_value', '*')
])
class ATT_Handle_Value_Indication(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.7.2 Handle Value Indication
'''
# -----------------------------------------------------------------------------
@ATT_PDU.subclass([])
class ATT_Handle_Value_Confirmation(ATT_PDU):
'''
See Bluetooth spec @ Vol 3, Part F - 3.4.7.3 Handle Value Confirmation
'''
# -----------------------------------------------------------------------------
class Attribute(EventEmitter):
# Permission flags
READABLE = 0x01
WRITEABLE = 0x02
READ_REQUIRES_ENCRYPTION = 0x04
WRITE_REQUIRES_ENCRYPTION = 0x08
READ_REQUIRES_AUTHENTICATION = 0x10
WRITE_REQUIRES_AUTHENTICATION = 0x20
READ_REQUIRES_AUTHORIZATION = 0x40
WRITE_REQUIRES_AUTHORIZATION = 0x80
def __init__(self, attribute_type, permissions, value = b''):
EventEmitter.__init__(self)
self.handle = 0
self.permissions = permissions
# Convert the type to a UUID
if type(attribute_type) is bytes:
self.type = UUID.from_bytes(attribute_type)
else:
self.type = attribute_type
# Convert the value to a byte array
if type(value) is str:
self.value = bytes(value, 'utf-8')
else:
self.value = value
def read_value(self, connection):
if type(self.value) is bytes:
return self.value
else:
if read := getattr(self.value, 'read', None):
try:
return read(connection)
except ATT_Error as error:
raise ATT_Error(error_code=error.error_code, att_handle=self.handle)
else:
return bytes(self.value)
def write_value(self, connection, value):
if write := getattr(self.value, 'write', None):
try:
write(connection, value)
except ATT_Error as error:
raise ATT_Error(error_code=error.error_code, att_handle=self.handle)
else:
self.value = value
self.emit('write', connection, value)
def __repr__(self):
if len(self.value) > 0:
value_string = f', value={self.value.hex()}'
else:
value_string = ''
return f'Attribute(handle=0x{self.handle:04X}, type={self.type}, permissions={self.permissions}{value_string})'

1921
bumble/avdtp.py Normal file

File diff suppressed because it is too large Load Diff

82
bumble/bridge.py Normal file
View File

@@ -0,0 +1,82 @@
# 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 logging
from .hci import HCI_Packet
from .helpers import PacketTracer
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
class HCI_Bridge:
class Forwarder:
def __init__(self, hci_sink, sender_hci_sink, packet_filter, trace):
self.hci_sink = hci_sink
self.sender_hci_sink = sender_hci_sink
self.packet_filter = packet_filter
self.trace = trace
def on_packet(self, packet):
# Convert the packet bytes to an object
hci_packet = HCI_Packet.from_bytes(packet)
# Filter the packet
if self.packet_filter is not None:
filtered = self.packet_filter(hci_packet)
if filtered is not None:
packet, respond_to_sender = filtered
hci_packet = HCI_Packet.from_bytes(packet)
if respond_to_sender:
self.sender_hci_sink.on_packet(packet)
return
# Analyze the packet
self.trace(hci_packet)
# Bridge the packet
self.hci_sink.on_packet(packet)
def __init__(
self,
hci_host_source,
hci_host_sink,
hci_controller_source,
hci_controller_sink,
host_to_controller_filter = None,
controller_to_host_filter = None
):
tracer = PacketTracer(emit_message=logger.info)
host_to_controller_forwarder = HCI_Bridge.Forwarder(
hci_controller_sink,
hci_host_sink,
host_to_controller_filter,
lambda packet: tracer.trace(packet, 0)
)
hci_host_source.set_packet_sink(host_to_controller_forwarder)
controller_to_host_forwarder = HCI_Bridge.Forwarder(
hci_host_sink,
hci_controller_sink,
controller_to_host_filter,
lambda packet: tracer.trace(packet, 1)
)
hci_controller_source.set_packet_sink(controller_to_host_forwarder)

2708
bumble/company_ids.py Normal file

File diff suppressed because it is too large Load Diff

895
bumble/controller.py Normal file
View File

@@ -0,0 +1,895 @@
# 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 logging
import asyncio
import itertools
import random
from .hci import *
from .l2cap import *
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
class DataObject:
pass
# -----------------------------------------------------------------------------
class Connection:
def __init__(self, controller, handle, role, peer_address, link):
self.controller = controller
self.handle = handle
self.role = role
self.peer_address = peer_address
self.link = link
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
def on_hci_acl_data_packet(self, packet):
self.assembler.feed_packet(packet)
self.controller.send_hci_packet(HCI_Number_Of_Completed_Packets_Event([(self.handle, 1)]))
def on_acl_pdu(self, data):
if self.link:
self.link.send_acl_data(self.controller.random_address, self.peer_address, data)
# -----------------------------------------------------------------------------
class Controller:
def __init__(self, name, host_source = None, host_sink = None, link = None):
self.name = name
self.hci_sink = None
self.link = link
self.central_connections = {} # Connections where this controller is the central
self.peripheral_connections = {} # Connections where this controller is the peripheral
self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
self.hci_revision = 0
self.lmp_version = HCI_VERSION_BLUETOOTH_CORE_5_0
self.lmp_subversion = 0
self.lmp_features = bytes.fromhex('0000000060000000') # BR/EDR Not Supported, LE Supported (Controller)
self.manufacturer_name = 0xFFFF
self.hc_le_data_packet_length = 27
self.hc_total_num_le_data_packets = 64
self.supported_commands = bytes.fromhex('2000800000c000000000e40000002822000000000000040000f7ffff7f00000030f0f9ff01008004000000000000000000000000000000000000000000000000')
self.le_features = bytes.fromhex('ff49010000000000')
self.le_states = bytes.fromhex('ffff3fffff030000')
self.avertising_channel_tx_power = 0
self.white_list_size = 8
self.resolving_list_size = 8
self.supported_max_tx_octets = 27
self.supported_max_tx_time = 10000 # microseconds
self.supported_max_rx_octets = 27
self.supported_max_rx_time = 10000 # microseconds
self.suggested_max_tx_octets = 27
self.suggested_max_tx_time = 0x0148 # microseconds
self.default_phy = bytes([0, 0, 0])
self.le_scan_type = 0
self.le_scan_interval = 0x10
self.le_scan_window = 0x10
self.le_scan_enable = 0
self.le_scan_own_address_type = Address.RANDOM_DEVICE_ADDRESS
self.le_scanning_filter_policy = 0
self.le_scan_response_data = None
self.le_address_resolution = False
self.le_rpa_timeout = 0
self.sync_flow_control = False
self.local_name = 'Bumble'
self.advertising_interval = 2000 # Fixed for now
self.advertising_data = None
self.advertising_timer_handle = None
self._random_address = Address('00:00:00:00:00:00')
self._public_address = None
# Set the source and sink interfaces
if host_source:
host_source.set_packet_sink(self)
self.host = host_sink
# Add this controller to the link if specified
if link:
link.add_controller(self)
@property
def host(self):
return self.hci_sink
@host.setter
def host(self, host):
'''
Sets the host (sink) for this controller, and set this controller as the controller (sink) for the host
'''
self.set_packet_sink(host)
if host:
host.controller = self
def set_packet_sink(self, sink):
'''
Method from the Packet Source interface
'''
self.hci_sink = sink
@property
def public_address(self):
return self._public_address
@public_address.setter
def public_address(self, address):
if type(address) is str:
address = Address(address)
self._public_address = address
@property
def random_address(self):
return self._random_address
@random_address.setter
def random_address(self, address):
if type(address) is str:
address = Address(address)
self._random_address = address
logger.debug(f'new random address: {address}')
if self.link:
self.link.on_address_changed(self)
# Packet Sink protocol (packets coming from the host via HCI)
def on_packet(self, packet):
self.on_hci_packet(HCI_Packet.from_bytes(packet))
def on_hci_packet(self, packet):
logger.debug(f'{color("<<<", "blue")} [{self.name}] {color("HOST -> CONTROLLER", "blue")}: {packet}')
# If the packet is a command, invoke the handler for this packet
if packet.hci_packet_type == HCI_COMMAND_PACKET:
self.on_hci_command_packet(packet)
elif packet.hci_packet_type == HCI_EVENT_PACKET:
self.on_hci_event_packet(packet)
elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
self.on_hci_acl_data_packet(packet)
else:
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
def on_hci_command_packet(self, command):
handler_name = f'on_{command.name.lower()}'
handler = getattr(self, handler_name, self.on_hci_command)
result = handler(command)
if type(result) is bytes:
self.send_hci_packet(HCI_Command_Complete_Event(
num_hci_command_packets = 1,
command_opcode = command.op_code,
return_parameters = result
))
def on_hci_event_packet(self, event):
logger.warning('!!! unexpected event packet')
def on_hci_acl_data_packet(self, packet):
# Look for the connection to which this data belongs
connection = self.find_connection_by_handle(packet.connection_handle)
if connection is None:
logger.warning(f'!!! no connection for handle 0x{packet.connection_handle:04X}')
return
# Pass the packet to the connection
connection.on_hci_acl_data_packet(packet)
def send_hci_packet(self, packet):
logger.debug(f'{color(">>>", "green")} [{self.name}] {color("CONTROLLER -> HOST", "green")}: {packet}')
if self.host:
self.host.on_packet(packet.to_bytes())
# This method allow the controller to emulate the same API as a transport source
async def wait_for_termination(self):
# For now, just wait forever
await asyncio.get_running_loop().create_future()
############################################################
# Link connections
############################################################
def allocate_connection_handle(self):
handle = 0
max_handle = 0
for connection in itertools.chain(
self.central_connections.values(),
self.peripheral_connections.values()
):
max_handle = max(max_handle, connection.handle)
if connection.handle == handle:
# Already used, continue searching after the current max
handle = max_handle + 1
return handle
def find_connection_by_address(self, address):
return self.central_connections.get(address) or self.peripheral_connections.get(address)
def find_connection_by_handle(self, handle):
for connection in itertools.chain(
self.central_connections.values(),
self.peripheral_connections.values()
):
if connection.handle == handle:
return connection
return None
def find_central_connection_by_handle(self, handle):
for connection in self.central_connections.values():
if connection.handle == handle:
return connection
return None
def on_link_central_connected(self, central_address):
'''
Called when an incoming connection occurs from a central on the link
'''
# Allocate (or reuse) a connection handle
peer_address = central_address
peer_address_type = central_address.address_type
connection = self.peripheral_connections.get(peer_address)
if connection is None:
connection_handle = self.allocate_connection_handle()
connection = Connection(self, connection_handle, BT_PERIPHERAL_ROLE, peer_address, self.link)
self.peripheral_connections[peer_address] = connection
logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}')
# Then say that the connection has completed
self.send_hci_packet(HCI_LE_Connection_Complete_Event(
status = HCI_SUCCESS,
connection_handle = connection.handle,
role = connection.role,
peer_address_type = peer_address_type,
peer_address = peer_address,
conn_interval = 10, # FIXME
conn_latency = 0, # FIXME
supervision_timeout = 10, # FIXME
master_clock_accuracy = 7 # FIXME
))
def on_link_central_disconnected(self, peer_address, reason):
'''
Called when an active disconnection occurs from a peer
'''
# Send a disconnection complete event
if connection := self.peripheral_connections.get(peer_address):
self.send_hci_packet(HCI_Disconnection_Complete_Event(
status = HCI_SUCCESS,
connection_handle = connection.handle,
reason = reason
))
# Remove the connection
del self.peripheral_connections[peer_address]
else:
logger.warn(f'!!! No peripheral connection found for {peer_address}')
def on_link_peripheral_connection_complete(self, le_create_connection_command, status):
'''
Called by the link when a connection has been made or has failed to be made
'''
if status == HCI_SUCCESS:
# Allocate (or reuse) a connection handle
peer_address = le_create_connection_command.peer_address
connection = self.central_connections.get(peer_address)
if connection is None:
connection_handle = self.allocate_connection_handle()
connection = Connection(
self,
connection_handle,
BT_CENTRAL_ROLE,
peer_address,
self.link
)
self.central_connections[peer_address] = connection
logger.debug(f'New CENTRAL connection handle: 0x{connection_handle:04X}')
else:
connection = None
# Say that the connection has completed
self.send_hci_packet(HCI_LE_Connection_Complete_Event(
status = status,
connection_handle = connection.handle if connection else 0,
role = BT_CENTRAL_ROLE,
peer_address_type = le_create_connection_command.peer_address_type,
peer_address = le_create_connection_command.peer_address,
conn_interval = le_create_connection_command.conn_interval_min,
conn_latency = le_create_connection_command.conn_latency,
supervision_timeout = le_create_connection_command.supervision_timeout,
master_clock_accuracy = 0
))
def on_link_peripheral_disconnection_complete(self, disconnection_command, status):
'''
Called when a disconnection has been completed
'''
# Send a disconnection complete event
self.send_hci_packet(HCI_Disconnection_Complete_Event(
status = status,
connection_handle = disconnection_command.connection_handle,
reason = disconnection_command.reason
))
# Remove the connection
if connection := self.find_central_connection_by_handle(disconnection_command.connection_handle):
logger.debug(f'CENTRAL Connection removed: {connection}')
del self.central_connections[connection.peer_address]
def on_link_peripheral_disconnected(self, peer_address):
'''
Called when a connection to a peripheral is broken
'''
# Send a disconnection complete event
if connection := self.central_connections.get(peer_address):
self.send_hci_packet(HCI_Disconnection_Complete_Event(
status = HCI_SUCCESS,
connection_handle = connection.handle,
reason = HCI_CONNECTION_TIMEOUT_ERROR
))
# Remove the connection
del self.central_connections[peer_address]
else:
logger.warn(f'!!! No central connection found for {peer_address}')
def on_link_encrypted(self, peer_address, rand, ediv, ltk):
# For now, just setup the encryption without asking the host
if connection := self.find_connection_by_address(peer_address):
self.send_hci_packet(
HCI_Encryption_Change_Event(
status = 0,
connection_handle = connection.handle,
encryption_enabled = 1
)
)
def on_link_acl_data(self, sender_address, data):
# Look for the connection to which this data belongs
connection = self.find_connection_by_address(sender_address)
if connection is None:
logger.warning(f'!!! no connection for {sender_address}')
return
# Send the data to the host
# TODO: should fragment
acl_packet = HCI_AclDataPacket(connection.handle, 2, 0, len(data), data)
self.send_hci_packet(acl_packet)
def on_link_advertising_data(self, sender_address, data):
# Ignore if we're not scanning
if self.le_scan_enable == 0:
return
# Send a scan report
report = HCI_Object(
HCI_LE_Advertising_Report_Event.REPORT_FIELDS,
event_type = HCI_LE_Advertising_Report_Event.ADV_IND,
address_type = sender_address.address_type,
address = sender_address,
data = data,
rssi = -50
)
self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
# Simulate a scan response
report = HCI_Object(
HCI_LE_Advertising_Report_Event.REPORT_FIELDS,
event_type = HCI_LE_Advertising_Report_Event.SCAN_RSP,
address_type = sender_address.address_type,
address = sender_address,
data = data,
rssi = -50
)
self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
############################################################
# Advertising support
############################################################
def on_advertising_timer_fired(self):
self.send_advertising_data()
self.advertising_timer_handle = asyncio.get_running_loop().call_later(self.advertising_interval / 1000.0, self.on_advertising_timer_fired)
def start_advertising(self):
# Stop any ongoing advertising before we start again
self.stop_advertising()
# Advertise now
self.advertising_timer_handle = asyncio.get_running_loop().call_soon(self.on_advertising_timer_fired)
def stop_advertising(self):
if self.advertising_timer_handle is not None:
self.advertising_timer_handle.cancel()
self.advertising_timer_handle = None
def send_advertising_data(self):
if self.link and self.advertising_data:
self.link.send_advertising_data(self.random_address, self.advertising_data)
@property
def is_advertising(self):
return self.advertising_timer_handle is not None
############################################################
# HCI handlers
############################################################
def on_hci_command(self, command):
logger.warning(color(f'--- Unsupported command {command}', 'red'))
return bytes([HCI_UNKNOWN_HCI_COMMAND_ERROR])
def on_hci_create_connection_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command
'''
# TODO: classic mode not supported yet
def on_hci_disconnect_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.1.6 Disconnect Command
'''
# First, say that the disconnection is pending
self.send_hci_packet(HCI_Command_Status_Event(
status = HCI_COMMAND_STATUS_PENDING,
num_hci_command_packets = 1,
command_opcode = command.op_code
))
# Notify the link of the disconnection
if not (connection := self.find_central_connection_by_handle(command.connection_handle)):
logger.warn('connection not found')
return
if self.link:
self.link.disconnect(self.random_address, connection.peer_address, command)
else:
# Remove the connection
del self.central_connections[connection.peer_address]
def on_hci_set_event_mask_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.1 Set Event Mask Command
'''
self.event_mask = command.event_mask
return bytes([HCI_SUCCESS])
def on_hci_reset_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.2 Reset Command
'''
# TODO: cleanup what needs to be reset
return bytes([HCI_SUCCESS])
def on_hci_write_local_name_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.11 Write Local Name Command
'''
local_name = command.local_name
if len(local_name):
try:
first_null = local_name.find(0)
if first_null >= 0:
local_name = local_name[:first_null]
self.local_name = str(local_name, 'utf-8')
except UnicodeDecodeError:
pass
return bytes([HCI_SUCCESS])
def on_hci_read_local_name_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.12 Read Local Name Command
'''
local_name = bytes(self.local_name, 'utf-8')[:248]
if len(local_name) < 248:
local_name = local_name + bytes(248 - len(local_name))
return bytes([HCI_SUCCESS]) + local_name
def on_hci_read_class_of_device_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.25 Read Class of Device Command
'''
return bytes([HCI_SUCCESS, 0, 0, 0])
def on_hci_write_class_of_device_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.26 Write Class of Device Command
'''
return bytes([HCI_SUCCESS])
def on_hci_read_synchronous_flow_control_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.36 Read Synchronous Flow Control Enable Command
'''
if self.sync_flow_control:
ret = 1
else:
ret = 0
return bytes([HCI_SUCCESS, ret])
def on_hci_write_synchronous_flow_control_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.37 Write Synchronous Flow Control Enable Command
'''
ret = HCI_SUCCESS
if command.synchronous_flow_control_enable == 1:
self.sync_flow_control = True
elif command.synchronous_flow_control_enable == 0:
self.sync_flow_control = False
else:
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
return bytes([ret])
def on_hci_write_simple_pairing_mode_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
'''
return bytes([HCI_SUCCESS])
def on_hci_set_event_mask_page_2_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.69 Set Event Mask Page 2 Command
'''
self.event_mask_page_2 = command.event_mask_page_2
return bytes([HCI_SUCCESS])
def on_hci_read_le_host_support_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.78 Write LE Host Support Command
'''
return bytes([HCI_SUCCESS, 1, 0])
def on_hci_write_le_host_support_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.79 Write LE Host Support Command
'''
# TODO / Just ignore for now
return bytes([HCI_SUCCESS])
def on_hci_write_authenticated_payload_timeout_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.94 Write Authenticated Payload Timeout Command
'''
# TODO
return struct.pack('<BH', HCI_SUCCESS, command.connection_handle)
def on_hci_read_local_version_information_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.1 Read Local Version Information Command
'''
return struct.pack('<BBHBHH',
HCI_SUCCESS,
self.hci_version,
self.hci_revision,
self.lmp_version,
self.manufacturer_name,
self.lmp_subversion)
def on_hci_read_local_supported_commands_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.2 Read Local Supported Commands Command
'''
return bytes([HCI_SUCCESS]) + self.supported_commands
def on_hci_read_local_supported_features_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.3 Read Local Supported Features Command
'''
return bytes([HCI_SUCCESS]) + self.lmp_features
def on_hci_read_bd_addr_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.6 Read BD_ADDR Command
'''
bd_addr = self._public_address.to_bytes() if self._public_address is not None else bytes(6)
return bytes([HCI_SUCCESS]) + bd_addr
def on_hci_le_set_event_mask_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.1 LE Set Event Mask Command
'''
self.le_event_mask = command.le_event_mask
return bytes([HCI_SUCCESS])
def on_hci_le_read_buffer_size_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.2 LE Read Buffer Size Command
'''
return struct.pack('<BHB',
HCI_SUCCESS,
self.hc_le_data_packet_length,
self.hc_total_num_le_data_packets)
def on_hci_le_read_local_supported_features_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.3 LE Read Local Supported Features Command
'''
return bytes([HCI_SUCCESS]) + self.le_features
def on_hci_le_set_random_address_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.4 LE Set Random Address Command
'''
self.random_address = command.random_address
return bytes([HCI_SUCCESS])
def on_hci_le_set_advertising_parameters_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.5 LE Set Advertising Parameters Command
'''
self.advertising_parameters = command
return bytes([HCI_SUCCESS])
def on_hci_le_read_advertising_channel_tx_power_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.6 LE Read Advertising Channel Tx Power Command
'''
return bytes([HCI_SUCCESS, self.avertising_channel_tx_power])
def on_hci_le_set_advertising_data_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.7 LE Set Advertising Data Command
'''
self.advertising_data = command.advertising_data
return bytes([HCI_SUCCESS])
def on_hci_le_set_scan_response_data_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.8 LE Set Scan Response Data Command
'''
self.le_scan_response_data = command.scan_response_data
return bytes([HCI_SUCCESS])
def on_hci_le_set_advertising_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.9 LE Set Advertising Enable Command
'''
if command.advertising_enable:
self.start_advertising()
else:
self.stop_advertising()
return bytes([HCI_SUCCESS])
def on_hci_le_set_scan_parameters_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.10 LE Set Scan Parameters Command
'''
self.le_scan_type = command.le_scan_type
self.le_scan_interval = command.le_scan_interval
self.le_scan_window = command.le_scan_window
self.le_scan_own_address_type = command.own_address_type
self.le_scanning_filter_policy = command.scanning_filter_policy
return bytes([HCI_SUCCESS])
def on_hci_le_set_scan_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.11 LE Set Scan Enable Command
'''
self.le_scan_enable = command.le_scan_enable
self.filter_duplicates = command.filter_duplicates
return bytes([HCI_SUCCESS])
def on_hci_le_create_connection_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.12 LE Create Connection Command
'''
if not self.link:
return
logger.debug(f'Connection request to {command.peer_address}')
# Check that we don't already have a pending connection
if self.link.get_pending_connection():
self.send_hci_packet(HCI_Command_Status_Event(
status = HCI_COMMAND_DISALLOWED_ERROR,
num_hci_command_packets = 1,
command_opcode = command.op_code
))
return
# Initiate the connection
self.link.connect(self.random_address, command)
# Say that the connection is pending
self.send_hci_packet(HCI_Command_Status_Event(
status = HCI_COMMAND_STATUS_PENDING,
num_hci_command_packets = 1,
command_opcode = command.op_code
))
def on_hci_le_create_connection_cancel_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.13 LE Create Connection Cancel Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_white_list_size_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.14 LE Read White List Size Command
'''
return bytes([HCI_SUCCESS, self.white_list_size])
def on_hci_le_clear_white_list_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.15 LE Clear White List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_add_device_to_white_list_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.16 LE Add Device To White List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_remove_device_from_white_list_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.17 LE Remove Device From White List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_remote_features_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.21 LE Read Remote Features Command
'''
# First, say that the command is pending
self.send_hci_packet(HCI_Command_Status_Event(
status = HCI_COMMAND_STATUS_PENDING,
num_hci_command_packets = 1,
command_opcode = command.op_code
))
# Then send the remote features
self.send_hci_packet(HCI_LE_Read_Remote_Features_Complete_Event(
status = HCI_SUCCESS,
connection_handle = 0,
le_features = bytes.fromhex('dd40000000000000')
))
def on_hci_le_rand_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.23 LE Rand Command
'''
return bytes([HCI_SUCCESS]) + struct.pack('Q', random.randint(0, 1 << 64))
def on_hci_le_start_encryption_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.24 LE Start Encryption Command
'''
# Check the parameters
if not (connection := self.find_central_connection_by_handle(command.connection_handle)):
logger.warn('connection not found')
return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
# Notify that the connection is now encrypted
self.link.on_connection_encrypted(
self.random_address,
connection.peer_address,
command.random_number,
command.encrypted_diversifier,
command.long_term_key
)
self.send_hci_packet(HCI_Command_Status_Event(
status = HCI_COMMAND_STATUS_PENDING,
num_hci_command_packets = 1,
command_opcode = command.op_code
))
def on_hci_le_read_supported_states_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.27 LE Read Supported States Command
'''
return bytes([HCI_SUCCESS]) + self.le_states
def on_hci_le_read_suggested_default_data_length_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.34 LE Read Suggested Default Data Length Command
'''
return struct.pack('<BHH',
HCI_SUCCESS,
self.suggested_max_tx_octets,
self.suggested_max_tx_time)
def on_hci_le_write_suggested_default_data_length_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.35 LE Write Suggested Default Data Length Command
'''
self.suggested_max_tx_octets, self.suggested_max_tx_time = struct.unpack('<HH', command.parameters[:4])
return bytes([HCI_SUCCESS])
def on_hci_le_read_local_p_256_public_key_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.36 LE Read P-256 Public Key Command
'''
# TODO create key and send HCI_LE_Read_Local_P-256_Public_Key_Complete event
return bytes([HCI_SUCCESS])
def on_hci_le_add_device_to_resolving_list_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.38 LE Add Device To Resolving List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_clear_resolving_list_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.40 LE Clear Resolving List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_resolving_list_size_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.41 LE Read Resolving List Size Command
'''
return bytes([HCI_SUCCESS, self.resolving_list_size])
def on_hci_le_set_address_resolution_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.44 LE Set Address Resolution Enable Command
'''
ret = HCI_SUCCESS
if command.address_resolution == 1:
self.le_address_resolution = True
elif command.address_resolution == 0:
self.le_address_resolution = False
else:
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
return bytes([ret])
def on_hci_le_set_resolvable_private_address_timeout_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.45 LE Set Resolvable Private Address Timeout Command
'''
self.le_rpa_timeout = command.rpa_timeout
return bytes([HCI_SUCCESS])
def on_hci_le_read_maximum_data_length_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.46 LE Read Maximum Data Length Command
'''
return struct.pack('<BHHHH',
HCI_SUCCESS,
self.supported_max_tx_octets,
self.supported_max_tx_time,
self.supported_max_rx_octets,
self.supported_max_rx_time)
def on_hci_le_set_default_phy_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.48 LE Set Default PHY Command
'''
self.default_phy = {
'all_phys': command.all_phys,
'tx_phys': command.tx_phys,
'rx_phys': command.rx_phys
}
return bytes([HCI_SUCCESS])

852
bumble/core.py Normal file
View File

@@ -0,0 +1,852 @@
# 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 struct
from .company_ids import COMPANY_IDENTIFIERS
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
BT_CENTRAL_ROLE = 0
BT_PERIPHERAL_ROLE = 1
BT_BR_EDR_TRANSPORT = 0
BT_LE_TRANSPORT = 1
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
def bit_flags_to_strings(bits, bit_flag_names):
names = []
index = 0
while bits != 0:
if bits & 1:
name = bit_flag_names[index] if index < len(bit_flag_names) else f'#{index}'
names.append(name)
bits >>= 1
index += 1
return names
def name_or_number(dictionary, number, width=2):
name = dictionary.get(number)
if name is not None:
return name
return f'[0x{number:0{width}X}]'
def padded_bytes(buffer, size):
padding_size = max(size - len(buffer), 0)
return buffer + bytes(padding_size)
# -----------------------------------------------------------------------------
# Exceptions
# -----------------------------------------------------------------------------
class BaseError(Exception):
""" Base class for errors with an error code, error name and namespace"""
def __init__(self, error_code, error_namespace='', error_name='', details=''):
super().__init__()
self.error_code = error_code
self.error_namespace = error_namespace
self.error_name = error_name
self.details = details
def __str__(self):
if self.error_namespace:
namespace = f'{self.error_namespace}/'
else:
namespace = ''
if self.error_name:
name = f'{self.error_name} [0x{self.error_code:X}]'
else:
name = f'0x{self.error_code:X}'
return f'{type(self).__name__}({namespace}{name})'
class ProtocolError(BaseError):
""" Protocol Error """
class TimeoutError(Exception):
""" Timeout Error """
class InvalidStateError(Exception):
""" Invalid State Error """
class ConnectionError(BaseError):
""" Connection Error """
FAILURE = 0x01
CONNECTION_REFUSED = 0x02
# -----------------------------------------------------------------------------
# UUID
#
# NOTE: the internal byte representation is in little-endian byte order
#
# Base UUID: 00000000-0000-1000-8000- 00805F9B34FB
# -----------------------------------------------------------------------------
class UUID:
'''
See Bluetooth spec Vol 3, Part B - 2.5.1 UUID
'''
BASE_UUID = bytes.fromhex('00001000800000805F9B34FB')
UUIDS = [] # Registry of all instances created
def __init__(self, uuid_str_or_int, name = None):
if type(uuid_str_or_int) is int:
self.uuid_bytes = struct.pack('<H', uuid_str_or_int)
else:
if len(uuid_str_or_int) == 36:
if uuid_str_or_int[8] != '-' or uuid_str_or_int[13] != '-' or uuid_str_or_int[18] != '-' or uuid_str_or_int[23] != '-':
raise ValueError('invalid UUID format')
uuid_str = uuid_str_or_int.replace('-', '')
else:
uuid_str = uuid_str_or_int
if len(uuid_str) != 32 and len(uuid_str) != 8 and len(uuid_str) != 4:
raise ValueError('invalid UUID format')
self.uuid_bytes = bytes(reversed(bytes.fromhex(uuid_str)))
self.name = name
def register(self):
# Register this object in the class registry, and update the entry's name if it wasn't set already
for uuid in self.UUIDS:
if self == uuid:
if uuid.name is None:
uuid.name = self.name
return uuid
self.UUIDS.append(self)
return self
@classmethod
def from_bytes(cls, uuid_bytes, name = None):
if len(uuid_bytes) in {2, 4, 16}:
self = cls.__new__(cls)
self.uuid_bytes = uuid_bytes
self.name = name
return self.register()
else:
raise ValueError('only 2, 4 and 16 bytes are allowed')
@classmethod
def from_16_bits(cls, uuid_16, name = None):
return cls.from_bytes(struct.pack('<H', uuid_16), name)
@classmethod
def from_32_bits(cls, uuid_32, name = None):
return cls.from_bytes(struct.pack('<I', uuid_32), name)
@classmethod
def parse_uuid(cls, bytes, offset):
return len(bytes), cls.from_bytes(bytes[offset:])
@classmethod
def parse_uuid_2(cls, bytes, offset):
return offset + 2, cls.from_bytes(bytes[offset:offset + 2])
def to_bytes(self, force_128 = False):
if len(self.uuid_bytes) == 16 or not force_128:
return self.uuid_bytes
elif len(self.uuid_bytes) == 4:
return self.uuid_bytes + UUID.BASE_UUID
else:
return self.uuid_bytes + bytes([0, 0]) + UUID.BASE_UUID
def to_pdu_bytes(self):
'''
Convert to bytes for use in an ATT PDU.
According to Vol 3, Part F - 3.2.1 Attribute Type:
"All 32-bit Attribute UUIDs shall be converted to 128-bit UUIDs when the
Attribute UUID is contained in an ATT PDU."
'''
return self.to_bytes(force_128 = (len(self.uuid_bytes) == 4))
def to_hex_str(self):
if len(self.uuid_bytes) == 2 or len(self.uuid_bytes) == 4:
return bytes(reversed(self.uuid_bytes)).hex().upper()
else:
return ''.join([
bytes(reversed(self.uuid_bytes[12:16])).hex(),
bytes(reversed(self.uuid_bytes[10:12])).hex(),
bytes(reversed(self.uuid_bytes[8:10])).hex(),
bytes(reversed(self.uuid_bytes[6:8])).hex(),
bytes(reversed(self.uuid_bytes[0:6])).hex()
]).upper()
def __bytes__(self):
return self.to_bytes()
def __eq__(self, other):
if isinstance(other, UUID):
return self.to_bytes(force_128 = True) == other.to_bytes(force_128 = True)
elif type(other) is str:
return UUID(other) == self
return False
def __hash__(self):
return hash(self.uuid_bytes)
def __str__(self):
if len(self.uuid_bytes) == 2:
v = struct.unpack('<H', self.uuid_bytes)[0]
result = f'UUID-16:{v:04X}'
elif len(self.uuid_bytes) == 4:
v = struct.unpack('<I', self.uuid_bytes)[0]
result = f'UUID-32:{v:08X}'
else:
result = '-'.join([
bytes(reversed(self.uuid_bytes[12:16])).hex(),
bytes(reversed(self.uuid_bytes[10:12])).hex(),
bytes(reversed(self.uuid_bytes[8:10])).hex(),
bytes(reversed(self.uuid_bytes[6:8])).hex(),
bytes(reversed(self.uuid_bytes[0:6])).hex()
]).upper()
if self.name is not None:
return result + f' ({self.name})'
else:
return result
def __repr__(self):
return str(self)
# -----------------------------------------------------------------------------
# Common UUID constants
# -----------------------------------------------------------------------------
# Protocol Identifiers
BT_SDP_PROTOCOL_ID = UUID.from_16_bits(0x0001, 'SDP')
BT_UDP_PROTOCOL_ID = UUID.from_16_bits(0x0002, 'UDP')
BT_RFCOMM_PROTOCOL_ID = UUID.from_16_bits(0x0003, 'RFCOMM')
BT_TCP_PROTOCOL_ID = UUID.from_16_bits(0x0004, 'TCP')
BT_TCS_BIN_PROTOCOL_ID = UUID.from_16_bits(0x0005, 'TCP-BIN')
BT_TCS_AT_PROTOCOL_ID = UUID.from_16_bits(0x0006, 'TCS-AT')
BT_ATT_PROTOCOL_ID = UUID.from_16_bits(0x0007, 'ATT')
BT_OBEX_PROTOCOL_ID = UUID.from_16_bits(0x0008, 'OBEX')
BT_IP_PROTOCOL_ID = UUID.from_16_bits(0x0009, 'IP')
BT_FTP_PROTOCOL_ID = UUID.from_16_bits(0x000A, 'FTP')
BT_HTTP_PROTOCOL_ID = UUID.from_16_bits(0x000C, 'HTTP')
BT_WSP_PROTOCOL_ID = UUID.from_16_bits(0x000E, 'WSP')
BT_BNEP_PROTOCOL_ID = UUID.from_16_bits(0x000F, 'BNEP')
BT_UPNP_PROTOCOL_ID = UUID.from_16_bits(0x0010, 'UPNP')
BT_HIDP_PROTOCOL_ID = UUID.from_16_bits(0x0011, 'HIDP')
BT_HARDCOPY_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0012, 'HardcopyControlChannel')
BT_HARDCOPY_DATA_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x0014, 'HardcopyDataChannel')
BT_HARDCOPY_NOTIFICATION_PROTOCOL_ID = UUID.from_16_bits(0x0016, 'HardcopyNotification')
BT_AVTCP_PROTOCOL_ID = UUID.from_16_bits(0x0017, 'AVCTP')
BT_AVDTP_PROTOCOL_ID = UUID.from_16_bits(0x0019, 'AVDTP')
BT_CMTP_PROTOCOL_ID = UUID.from_16_bits(0x001B, 'CMTP')
BT_MCAP_CONTROL_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x001E, 'MCAPControlChannel')
BT_MCAP_DATA_CHANNEL_PROTOCOL_ID = UUID.from_16_bits(0x001F, 'MCAPDataChannel')
BT_L2CAP_PROTOCOL_ID = UUID.from_16_bits(0x0100, 'L2CAP')
# Service Classes and Profiles
BT_SERVICE_DISCOVERY_SERVER_SERVICE_CLASS_ID_SERVICE = UUID.from_16_bits(0x1000, 'ServiceDiscoveryServerServiceClassID')
BT_BROWSE_GROUP_DESCRIPTOR_SERVICE_CLASS_ID_SERVICE = UUID.from_16_bits(0x1001, 'BrowseGroupDescriptorServiceClassID')
BT_SERIAL_PORT_SERVICE = UUID.from_16_bits(0x1101, 'SerialPort')
BT_LAN_ACCESS_USING_PPP_SERVICE = UUID.from_16_bits(0x1102, 'LANAccessUsingPPP')
BT_DIALUP_NETWORKING_SERVICE = UUID.from_16_bits(0x1103, 'DialupNetworking')
BT_IR_MCSYNC_SERVICE = UUID.from_16_bits(0x1104, 'IrMCSync')
BT_OBEX_OBJECT_PUSH_SERVICE = UUID.from_16_bits(0x1105, 'OBEXObjectPush')
BT_OBEX_FILE_TRANSFER_SERVICE = UUID.from_16_bits(0x1106, 'OBEXFileTransfer')
BT_IR_MCSYNC_COMMAND_SERVICE = UUID.from_16_bits(0x1107, 'IrMCSyncCommand')
BT_HEADSET_SERVICE = UUID.from_16_bits(0x1108, 'Headset')
BT_CORDLESS_TELEPHONY_SERVICE = UUID.from_16_bits(0x1109, 'CordlessTelephony')
BT_AUDIO_SOURCE_SERVICE = UUID.from_16_bits(0x110A, 'AudioSource')
BT_AUDIO_SINK_SERVICE = UUID.from_16_bits(0x110B, 'AudioSink')
BT_AV_REMOTE_CONTROL_TARGET_SERVICE = UUID.from_16_bits(0x110C, 'A/V_RemoteControlTarget')
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE = UUID.from_16_bits(0x110D, 'AdvancedAudioDistribution')
BT_AV_REMOTE_CONTROL_SERVICE = UUID.from_16_bits(0x110E, 'A/V_RemoteControl')
BT_AV_REMOTE_CONTROL_CONTROLLER_SERVICE = UUID.from_16_bits(0x110F, 'A/V_RemoteControlController')
BT_INTERCOM_SERVICE = UUID.from_16_bits(0x1110, 'Intercom')
BT_FAX_SERVICE = UUID.from_16_bits(0x1111, 'Fax')
BT_HEADSET_AUDIO_GATEWAY_SERVICE = UUID.from_16_bits(0x1112, 'Headset - Audio Gateway')
BT_WAP_SERVICE = UUID.from_16_bits(0x1113, 'WAP')
BT_WAP_CLIENT_SERVICE = UUID.from_16_bits(0x1114, 'WAP_CLIENT')
BT_PANU_SERVICE = UUID.from_16_bits(0x1115, 'PANU')
BT_NAP_SERVICE = UUID.from_16_bits(0x1116, 'NAP')
BT_GN_SERVICE = UUID.from_16_bits(0x1117, 'GN')
BT_DIRECT_PRINTING_SERVICE = UUID.from_16_bits(0x1118, 'DirectPrinting')
BT_REFERENCE_PRINTING_SERVICE = UUID.from_16_bits(0x1119, 'ReferencePrinting')
BT_BASIC_IMAGING_PROFILE_SERVICE = UUID.from_16_bits(0x111A, 'Basic Imaging Profile')
BT_IMAGING_RESPONDER_SERVICE = UUID.from_16_bits(0x111B, 'ImagingResponder')
BT_IMAGING_AUTOMATIC_ARCHIVE_SERVICE = UUID.from_16_bits(0x111C, 'ImagingAutomaticArchive')
BT_IMAGING_REFERENCED_OBJECTS_SERVICE = UUID.from_16_bits(0x111D, 'ImagingReferencedObjects')
BT_HANDSFREE_SERVICE = UUID.from_16_bits(0x111E, 'Handsfree')
BT_HANDSFREE_AUDIO_GATEWAY_SERVICE = UUID.from_16_bits(0x111F, 'HandsfreeAudioGateway')
BT_DIRECT_PRINTING_REFERENCE_OBJECTS_SERVICE = UUID.from_16_bits(0x1120, 'DirectPrintingReferenceObjectsService')
BT_REFLECTED_UI_SERVICE = UUID.from_16_bits(0x1121, 'ReflectedUI')
BT_BASIC_PRINTING_SERVICE = UUID.from_16_bits(0x1122, 'BasicPrinting')
BT_PRINTING_STATUS_SERVICE = UUID.from_16_bits(0x1123, 'PrintingStatus')
BT_HUMAN_INTERFACE_DEVICE_SERVICE = UUID.from_16_bits(0x1124, 'HumanInterfaceDeviceService')
BT_HARDCOPY_CABLE_REPLACEMENT_SERVICE = UUID.from_16_bits(0x1125, 'HardcopyCableReplacement')
BT_HCR_PRINT_SERVICE = UUID.from_16_bits(0x1126, 'HCR_Print')
BT_HCR_SCAN_SERVICE = UUID.from_16_bits(0x1127, 'HCR_Scan')
BT_COMMON_ISDN_ACCESS_SERVICE = UUID.from_16_bits(0x1128, 'Common_ISDN_Access')
BT_SIM_ACCESS_SERVICE = UUID.from_16_bits(0x112D, 'SIM_Access')
BT_PHONEBOOK_ACCESS_PCE_SERVICE = UUID.from_16_bits(0x112E, 'Phonebook Access - PCE')
BT_PHONEBOOK_ACCESS_PSE_SERVICE = UUID.from_16_bits(0x112F, 'Phonebook Access - PSE')
BT_PHONEBOOK_ACCESS_SERVICE = UUID.from_16_bits(0x1130, 'Phonebook Access')
BT_HEADSET_HS_SERVICE = UUID.from_16_bits(0x1131, 'Headset - HS')
BT_MESSAGE_ACCESS_SERVER_SERVICE = UUID.from_16_bits(0x1132, 'Message Access Server')
BT_MESSAGE_NOTIFICATION_SERVER_SERVICE = UUID.from_16_bits(0x1133, 'Message Notification Server')
BT_MESSAGE_ACCESS_PROFILE_SERVICE = UUID.from_16_bits(0x1134, 'Message Access Profile')
BT_GNSS_SERVICE = UUID.from_16_bits(0x1135, 'GNSS')
BT_GNSS_SERVER_SERVICE = UUID.from_16_bits(0x1136, 'GNSS_Server')
BT_3D_DISPLAY_SERVICE = UUID.from_16_bits(0x1137, '3D Display')
BT_3D_GLASSES_SERVICE = UUID.from_16_bits(0x1138, '3D Glasses')
BT_3D_SYNCHRONIZATION_SERVICE = UUID.from_16_bits(0x1139, '3D Synchronization')
BT_MPS_PROFILE_SERVICE = UUID.from_16_bits(0x113A, 'MPS Profile')
BT_MPS_SC_SERVICE = UUID.from_16_bits(0x113B, 'MPS SC')
BT_ACCESS_SERVICE_SERVICE = UUID.from_16_bits(0x113C, 'CTN Access Service')
BT_CTN_NOTIFICATION_SERVICE_SERVICE = UUID.from_16_bits(0x113D, 'CTN Notification Service')
BT_CTN_PROFILE_SERVICE = UUID.from_16_bits(0x113E, 'CTN Profile')
BT_PNP_INFORMATION_SERVICE = UUID.from_16_bits(0x1200, 'PnPInformation')
BT_GENERIC_NETWORKING_SERVICE = UUID.from_16_bits(0x1201, 'GenericNetworking')
BT_GENERIC_FILE_TRANSFER_SERVICE = UUID.from_16_bits(0x1202, 'GenericFileTransfer')
BT_GENERIC_AUDIO_SERVICE = UUID.from_16_bits(0x1203, 'GenericAudio')
BT_GENERIC_TELEPHONY_SERVICE = UUID.from_16_bits(0x1204, 'GenericTelephony')
BT_UPNP_SERVICE = UUID.from_16_bits(0x1205, 'UPNP_Service')
BT_UPNP_IP_SERVICE = UUID.from_16_bits(0x1206, 'UPNP_IP_Service')
BT_ESDP_UPNP_IP_PAN_SERVICE = UUID.from_16_bits(0x1300, 'ESDP_UPNP_IP_PAN')
BT_ESDP_UPNP_IP_LAP_SERVICE = UUID.from_16_bits(0x1301, 'ESDP_UPNP_IP_LAP')
BT_ESDP_UPNP_L2CAP_SERVICE = UUID.from_16_bits(0x1302, 'ESDP_UPNP_L2CAP')
BT_VIDEO_SOURCE_SERVICE = UUID.from_16_bits(0x1303, 'VideoSource')
BT_VIDEO_SINK_SERVICE = UUID.from_16_bits(0x1304, 'VideoSink')
BT_VIDEO_DISTRIBUTION_SERVICE = UUID.from_16_bits(0x1305, 'VideoDistribution')
BT_HDP_SERVICE = UUID.from_16_bits(0x1400, 'HDP')
BT_HDP_SOURCE_SERVICE = UUID.from_16_bits(0x1401, 'HDP Source')
BT_HDP_SINK_SERVICE = UUID.from_16_bits(0x1402, 'HDP Sink')
# -----------------------------------------------------------------------------
# DeviceClass
# -----------------------------------------------------------------------------
class DeviceClass:
# Major Service Classes (flags combined with OR)
LIMITED_DISCOVERABLE_MODE_SERVICE_CLASS = (1 << 0)
LE_AUDIO_SERVICE_CLASS = (1 << 1)
RESERVED = (1 << 2)
POSITIONING_SERVICE_CLASS = (1 << 3)
NETWORKING_SERVICE_CLASS = (1 << 4)
RENDERING_SERVICE_CLASS = (1 << 5)
CAPTURING_SERVICE_CLASS = (1 << 6)
OBJECT_TRANSFER_SERVICE_CLASS = (1 << 7)
AUDIO_SERVICE_CLASS = (1 << 8)
TELEPHONY_SERVICE_CLASS = (1 << 9)
INFORMATION_SERVICE_CLASS = (1 << 10)
SERVICE_CLASS_LABELS = [
'Limited Discoverable Mode',
'LE audio',
'(reserved)',
'Positioning',
'Networking',
'Rendering',
'Capturing',
'Object Transfer',
'Audio',
'Telephony',
'Information'
]
# Major Device Classes
MISCELLANEOUS_MAJOR_DEVICE_CLASS = 0x00
COMPUTER_MAJOR_DEVICE_CLASS = 0x01
PHONE_MAJOR_DEVICE_CLASS = 0x02
LAN_NETWORK_ACCESS_POINT_MAJOR_DEVICE_CLASS = 0x03
AUDIO_VIDEO_MAJOR_DEVICE_CLASS = 0x04
PERIPHERAL_MAJOR_DEVICE_CLASS = 0x05
IMAGING_MAJOR_DEVICE_CLASS = 0x06
WEARABLE_MAJOR_DEVICE_CLASS = 0x07
TOY_MAJOR_DEVICE_CLASS = 0x08
HEALTH_MAJOR_DEVICE_CLASS = 0x09
UNCATEGORIZED_MAJOR_DEVICE_CLASS = 0x1F
MAJOR_DEVICE_CLASS_NAMES = {
MISCELLANEOUS_MAJOR_DEVICE_CLASS: 'Miscellaneous',
COMPUTER_MAJOR_DEVICE_CLASS: 'Computer',
PHONE_MAJOR_DEVICE_CLASS: 'Phone',
LAN_NETWORK_ACCESS_POINT_MAJOR_DEVICE_CLASS: 'LAN/Network Access Point',
AUDIO_VIDEO_MAJOR_DEVICE_CLASS: 'Audio/Video',
PERIPHERAL_MAJOR_DEVICE_CLASS: 'Peripheral',
IMAGING_MAJOR_DEVICE_CLASS: 'Imaging',
WEARABLE_MAJOR_DEVICE_CLASS: 'Wearable',
TOY_MAJOR_DEVICE_CLASS: 'Toy',
HEALTH_MAJOR_DEVICE_CLASS: 'Health',
UNCATEGORIZED_MAJOR_DEVICE_CLASS: 'Uncategorized'
}
COMPUTER_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
COMPUTER_DESKTOP_WORKSTATION_MINOR_DEVICE_CLASS = 0x01
COMPUTER_SERVER_CLASS_COMPUTER_MINOR_DEVICE_CLASS = 0x02
COMPUTER_LAPTOP_COMPUTER_MINOR_DEVICE_CLASS = 0x03
COMPUTER_HANDHELD_PC_PDA_MINOR_DEVICE_CLASS = 0x04
COMPUTER_PALM_SIZE_PC_PDA_MINOR_DEVICE_CLASS = 0x05
COMPUTER_WEARABLE_COMPUTER_MINOR_DEVICE_CLASS = 0x06
COMPUTER_TABLET_MINOR_DEVICE_CLASS = 0x07
COMPUTER_MINOR_DEVICE_CLASS_NAMES = {
COMPUTER_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
COMPUTER_DESKTOP_WORKSTATION_MINOR_DEVICE_CLASS: 'Desktop workstation',
COMPUTER_SERVER_CLASS_COMPUTER_MINOR_DEVICE_CLASS: 'Server-class computer',
COMPUTER_LAPTOP_COMPUTER_MINOR_DEVICE_CLASS: 'Laptop',
COMPUTER_HANDHELD_PC_PDA_MINOR_DEVICE_CLASS: 'Handheld PC/PDA',
COMPUTER_PALM_SIZE_PC_PDA_MINOR_DEVICE_CLASS: 'Palm-size PC/PDA',
COMPUTER_WEARABLE_COMPUTER_MINOR_DEVICE_CLASS: 'Wearable computer',
COMPUTER_TABLET_MINOR_DEVICE_CLASS: 'Tablet'
}
PHONE_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
PHONE_CELLULAR_MINOR_DEVICE_CLASS = 0x01
PHONE_CORDLESS_MINOR_DEVICE_CLASS = 0x02
PHONE_SMARTPHONE_MINOR_DEVICE_CLASS = 0x03
PHONE_WIRED_MODEM_OR_VOICE_GATEWAY_MINOR_DEVICE_CLASS = 0x04
PHONE_COMMON_ISDN_MINOR_DEVICE_CLASS = 0x05
PHONE_MINOR_DEVICE_CLASS_NAMES = {
PHONE_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
PHONE_CELLULAR_MINOR_DEVICE_CLASS: 'Cellular',
PHONE_CORDLESS_MINOR_DEVICE_CLASS: 'Cordless',
PHONE_SMARTPHONE_MINOR_DEVICE_CLASS: 'Smartphone',
PHONE_WIRED_MODEM_OR_VOICE_GATEWAY_MINOR_DEVICE_CLASS: 'Wired modem or voice gateway',
PHONE_COMMON_ISDN_MINOR_DEVICE_CLASS: 'Common ISDN access'
}
AUDIO_VIDEO_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
AUDIO_VIDEO_WEARABLE_HEADSET_DEVICE_MINOR_DEVICE_CLASS = 0x01
AUDIO_VIDEO_HANDS_FREE_DEVICE_MINOR_DEVICE_CLASS = 0x02
# (RESERVED) = 0x03
AUDIO_VIDEO_MICROPHONE_MINOR_DEVICE_CLASS = 0x04
AUDIO_VIDEO_LOUDSPEAKER_MINOR_DEVICE_CLASS = 0x05
AUDIO_VIDEO_HEADPHONES_MINOR_DEVICE_CLASS = 0x06
AUDIO_VIDEO_PORTABLE_AUDIO_MINOR_DEVICE_CLASS = 0x07
AUDIO_VIDEO_CAR_AUDIO_MINOR_DEVICE_CLASS = 0x08
AUDIO_VIDEO_SET_TOP_BOX_MINOR_DEVICE_CLASS = 0x09
AUDIO_VIDEO_HIFI_AUDIO_DEVICE_MINOR_DEVICE_CLASS = 0x0A
AUDIO_VIDEO_VCR_MINOR_DEVICE_CLASS = 0x0B
AUDIO_VIDEO_VIDEO_CAMERA_MINOR_DEVICE_CLASS = 0x0C
AUDIO_VIDEO_CAMCORDER_MINOR_DEVICE_CLASS = 0x0D
AUDIO_VIDEO_VIDEO_MONITOR_MINOR_DEVICE_CLASS = 0x0E
AUDIO_VIDEO_VIDEO_DISPLAY_AND_LOUDSPEAKER_MINOR_DEVICE_CLASS = 0x0F
AUDIO_VIDEO_VIDEO_CONFERENCING_MINOR_DEVICE_CLASS = 0x10
# (RESERVED) = 0x11
AUDIO_VIDEO_GAMING_OR_TOY_MINOR_DEVICE_CLASS = 0x12
AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES = {
AUDIO_VIDEO_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
AUDIO_VIDEO_WEARABLE_HEADSET_DEVICE_MINOR_DEVICE_CLASS: 'Wearable Headset Device',
AUDIO_VIDEO_HANDS_FREE_DEVICE_MINOR_DEVICE_CLASS: 'Hands-free Device',
AUDIO_VIDEO_MICROPHONE_MINOR_DEVICE_CLASS: 'Microphone',
AUDIO_VIDEO_LOUDSPEAKER_MINOR_DEVICE_CLASS: 'Loudspeaker',
AUDIO_VIDEO_HEADPHONES_MINOR_DEVICE_CLASS: 'Headphones',
AUDIO_VIDEO_PORTABLE_AUDIO_MINOR_DEVICE_CLASS: 'Portable Audio',
AUDIO_VIDEO_CAR_AUDIO_MINOR_DEVICE_CLASS: 'Car audio',
AUDIO_VIDEO_SET_TOP_BOX_MINOR_DEVICE_CLASS: 'Set-top box',
AUDIO_VIDEO_HIFI_AUDIO_DEVICE_MINOR_DEVICE_CLASS: 'HiFi Audio Device',
AUDIO_VIDEO_VCR_MINOR_DEVICE_CLASS: 'VCR',
AUDIO_VIDEO_VIDEO_CAMERA_MINOR_DEVICE_CLASS: 'Video Camera',
AUDIO_VIDEO_CAMCORDER_MINOR_DEVICE_CLASS: 'Camcorder',
AUDIO_VIDEO_VIDEO_MONITOR_MINOR_DEVICE_CLASS: 'Video Monitor',
AUDIO_VIDEO_VIDEO_DISPLAY_AND_LOUDSPEAKER_MINOR_DEVICE_CLASS: 'Video Display and Loudspeaker',
AUDIO_VIDEO_VIDEO_CONFERENCING_MINOR_DEVICE_CLASS: 'Video Conferencing',
AUDIO_VIDEO_GAMING_OR_TOY_MINOR_DEVICE_CLASS: 'Gaming/Toy'
}
PERIPHERAL_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
PERIPHERAL_KEYBOARD_MINOR_DEVICE_CLASS = 0x10
PERIPHERAL_POINTING_DEVICE_MINOR_DEVICE_CLASS = 0x20
PERIPHERAL_COMBO_KEYBOARD_POINTING_DEVICE_MINOR_DEVICE_CLASS = 0x30
PERIPHERAL_JOYSTICK_MINOR_DEVICE_CLASS = 0x01
PERIPHERAL_GAMEPAD_MINOR_DEVICE_CLASS = 0x02
PERIPHERAL_REMOTE_CONTROL_MINOR_DEVICE_CLASS = 0x03
PERIPHERAL_SENSING_DEVICE_MINOR_DEVICE_CLASS = 0x04
PERIPHERAL_DIGITIZER_TABLET_MINOR_DEVICE_CLASS = 0x05
PERIPHERAL_CARD_READER_MINOR_DEVICE_CLASS = 0x06
PERIPHERAL_DIGITAL_PEN_MINOR_DEVICE_CLASS = 0x07
PERIPHERAL_HANDHELD_SCANNER_MINOR_DEVICE_CLASS = 0x08
PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS = 0x09
PERIPHERAL_MINOR_DEVICE_CLASS_NAMES = {
PERIPHERAL_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
PERIPHERAL_KEYBOARD_MINOR_DEVICE_CLASS: 'Keyboard',
PERIPHERAL_POINTING_DEVICE_MINOR_DEVICE_CLASS: 'Pointing device',
PERIPHERAL_COMBO_KEYBOARD_POINTING_DEVICE_MINOR_DEVICE_CLASS: 'Combo keyboard/pointing device',
PERIPHERAL_JOYSTICK_MINOR_DEVICE_CLASS: 'Joystick',
PERIPHERAL_GAMEPAD_MINOR_DEVICE_CLASS: 'Gamepad',
PERIPHERAL_REMOTE_CONTROL_MINOR_DEVICE_CLASS: 'Remote control',
PERIPHERAL_SENSING_DEVICE_MINOR_DEVICE_CLASS: 'Sensing device',
PERIPHERAL_DIGITIZER_TABLET_MINOR_DEVICE_CLASS: 'Digitizer tablet',
PERIPHERAL_CARD_READER_MINOR_DEVICE_CLASS: 'Card Reader',
PERIPHERAL_DIGITAL_PEN_MINOR_DEVICE_CLASS: 'Digital Pen',
PERIPHERAL_HANDHELD_SCANNER_MINOR_DEVICE_CLASS: 'Handheld scanner',
PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS: 'Handheld gestural input device'
}
MINOR_DEVICE_CLASS_NAMES = {
COMPUTER_MAJOR_DEVICE_CLASS: COMPUTER_MINOR_DEVICE_CLASS_NAMES,
PHONE_MAJOR_DEVICE_CLASS: PHONE_MINOR_DEVICE_CLASS_NAMES,
AUDIO_VIDEO_MAJOR_DEVICE_CLASS: AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES,
PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES
}
@staticmethod
def split_class_of_device(class_of_device):
# Split the bit fields of the composite class of device value into:
# (service_classes, major_device_class, minor_device_class)
return ((class_of_device >> 13 & 0x7FF), (class_of_device >> 8 & 0x1F), (class_of_device >> 2 & 0x3F))
@staticmethod
def pack_class_of_device(service_classes, major_device_class, minor_device_class):
return service_classes << 13 | major_device_class << 8 | minor_device_class << 2
@staticmethod
def service_class_labels(service_class_flags):
return bit_flags_to_strings(service_class_flags, DeviceClass.SERVICE_CLASS_LABELS)
@staticmethod
def major_device_class_name(device_class):
return name_or_number(DeviceClass.MAJOR_DEVICE_CLASS_NAMES, device_class)
@staticmethod
def minor_device_class_name(major_device_class, minor_device_class):
class_names = DeviceClass.MINOR_DEVICE_CLASS_NAMES.get(major_device_class)
if class_names is None:
return f'#{minor_device_class:02X}'
return name_or_number(class_names, minor_device_class)
# -----------------------------------------------------------------------------
# Advertising Data
# -----------------------------------------------------------------------------
class AdvertisingData:
# This list is only partial, it still needs to be filled in from the spec
FLAGS = 0x01
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07
SHORTENED_LOCAL_NAME = 0x08
COMPLETE_LOCAL_NAME = 0x09
TX_POWER_LEVEL = 0x0A
CLASS_OF_DEVICE = 0x0D
SIMPLE_PAIRING_HASH_C = 0x0E
SIMPLE_PAIRING_HASH_C_192 = 0x0E
SIMPLE_PAIRING_RANDOMIZER_R = 0x0F
SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F
DEVICE_ID = 0x10
SECURITY_MANAGER_TK_VALUE = 0x10
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11
PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15
SERVICE_DATA = 0x16
SERVICE_DATA_16_BIT_UUID = 0x16
PUBLIC_TARGET_ADDRESS = 0x17
RANDOM_TARGET_ADDRESS = 0x18
APPEARANCE = 0x19
ADVERTISING_INTERVAL = 0x1A
LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B
LE_ROLE = 0x1C
SIMPLE_PAIRING_HASH_C_256 = 0x1D
SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F
SERVICE_DATA_32_BIT_UUID = 0x20
SERVICE_DATA_128_BIT_UUID = 0x21
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22
LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23
URI = 0x24
INDOOR_POSITIONING = 0x25
TRANSPORT_DISCOVERY_DATA = 0x26
LE_SUPPORTED_FEATURES = 0x27
CHANNEL_MAP_UPDATE_INDICATION = 0x28
PB_ADV = 0x29
MESH_MESSAGE = 0x2A
MESH_BEACON = 0x2B
BIGINFO = 0x2C
BROADCAST_CODE = 0x2D
RESOLVABLE_SET_IDENTIFIER = 0x2E
ADVERTISING_INTERVAL_LONG = 0x2F
THREE_D_INFORMATION_DATA = 0x3D
MANUFACTURER_SPECIFIC_DATA = 0xFF
AD_TYPE_NAMES = {
FLAGS: 'FLAGS',
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
SHORTENED_LOCAL_NAME: 'SHORTENED_LOCAL_NAME',
COMPLETE_LOCAL_NAME: 'COMPLETE_LOCAL_NAME',
TX_POWER_LEVEL: 'TX_POWER_LEVEL',
CLASS_OF_DEVICE: 'CLASS_OF_DEVICE',
SIMPLE_PAIRING_HASH_C: 'SIMPLE_PAIRING_HASH_C',
SIMPLE_PAIRING_HASH_C_192: 'SIMPLE_PAIRING_HASH_C_192',
SIMPLE_PAIRING_RANDOMIZER_R: 'SIMPLE_PAIRING_RANDOMIZER_R',
SIMPLE_PAIRING_RANDOMIZER_R_192: 'SIMPLE_PAIRING_RANDOMIZER_R_192',
DEVICE_ID: 'DEVICE_ID',
SECURITY_MANAGER_TK_VALUE: 'SECURITY_MANAGER_TK_VALUE',
SECURITY_MANAGER_OUT_OF_BAND_FLAGS: 'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
PERIPHERAL_CONNECTION_INTERVAL_RANGE: 'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS',
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS',
SERVICE_DATA: 'SERVICE_DATA',
SERVICE_DATA_16_BIT_UUID: 'SERVICE_DATA_16_BIT_UUID',
PUBLIC_TARGET_ADDRESS: 'PUBLIC_TARGET_ADDRESS',
RANDOM_TARGET_ADDRESS: 'RANDOM_TARGET_ADDRESS',
APPEARANCE: 'APPEARANCE',
ADVERTISING_INTERVAL: 'ADVERTISING_INTERVAL',
LE_BLUETOOTH_DEVICE_ADDRESS: 'LE_BLUETOOTH_DEVICE_ADDRESS',
LE_ROLE: 'LE_ROLE',
SIMPLE_PAIRING_HASH_C_256: 'SIMPLE_PAIRING_HASH_C_256',
SIMPLE_PAIRING_RANDOMIZER_R_256: 'SIMPLE_PAIRING_RANDOMIZER_R_256',
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS',
SERVICE_DATA_32_BIT_UUID: 'SERVICE_DATA_32_BIT_UUID',
SERVICE_DATA_128_BIT_UUID: 'SERVICE_DATA_128_BIT_UUID',
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE: 'LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE',
LE_SECURE_CONNECTIONS_RANDOM_VALUE: 'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
URI: 'URI',
INDOOR_POSITIONING: 'INDOOR_POSITIONING',
TRANSPORT_DISCOVERY_DATA: 'TRANSPORT_DISCOVERY_DATA',
LE_SUPPORTED_FEATURES: 'LE_SUPPORTED_FEATURES',
CHANNEL_MAP_UPDATE_INDICATION: 'CHANNEL_MAP_UPDATE_INDICATION',
PB_ADV: 'PB_ADV',
MESH_MESSAGE: 'MESH_MESSAGE',
MESH_BEACON: 'MESH_BEACON',
BIGINFO: 'BIGINFO',
BROADCAST_CODE: 'BROADCAST_CODE',
RESOLVABLE_SET_IDENTIFIER: 'RESOLVABLE_SET_IDENTIFIER',
ADVERTISING_INTERVAL_LONG: 'ADVERTISING_INTERVAL_LONG',
THREE_D_INFORMATION_DATA: 'THREE_D_INFORMATION_DATA',
MANUFACTURER_SPECIFIC_DATA: 'MANUFACTURER_SPECIFIC_DATA'
}
LE_LIMITED_DISCOVERABLE_MODE_FLAG = 0x01
LE_GENERAL_DISCOVERABLE_MODE_FLAG = 0x02
BR_EDR_NOT_SUPPORTED_FLAG = 0x04
BR_EDR_CONTROLLER_FLAG = 0x08
BR_EDR_HOST_FLAG = 0x10
def __init__(self, ad_structures = []):
self.ad_structures = ad_structures[:]
@staticmethod
def from_bytes(data):
instance = AdvertisingData()
instance.append(data)
return instance
@staticmethod
def flags_to_string(flags, short=False):
flag_names = [
'LE Limited',
'LE General',
'No BR/EDR',
'BR/EDR C',
'BR/EDR H'
] if short else [
'LE Limited Discoverable Mode',
'LE General Discoverable Mode',
'BR/EDR Not Supported',
'Simultaneous LE and BR/EDR (Controller)',
'Simultaneous LE and BR/EDR (Host)'
]
return ','.join(bit_flags_to_strings(flags, flag_names))
@staticmethod
def uuid_list_to_objects(ad_data, uuid_size):
uuids = []
offset = 0
while (uuid_size * (offset + 1)) <= len(ad_data):
uuids.append(UUID.from_bytes(ad_data[offset:offset + uuid_size]))
offset += uuid_size
return uuids
@staticmethod
def uuid_list_to_string(ad_data, uuid_size):
return ', '.join([
str(uuid)
for uuid in AdvertisingData.uuid_list_to_objects(ad_data, uuid_size)
])
@staticmethod
def ad_data_to_string(ad_type, ad_data):
if ad_type == AdvertisingData.FLAGS:
ad_type_str = 'Flags'
ad_data_str = AdvertisingData.flags_to_string(ad_data[0], short=True)
elif ad_type == AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:
ad_type_str = 'Complete List of 16-bit Service Class UUIDs'
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 2)
elif ad_type == AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:
ad_type_str = 'Incomplete List of 16-bit Service Class UUIDs'
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 2)
elif ad_type == AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:
ad_type_str = 'Complete List of 32-bit Service Class UUIDs'
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 4)
elif ad_type == AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:
ad_type_str = 'Incomplete List of 32-bit Service Class UUIDs'
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 4)
elif ad_type == AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:
ad_type_str = 'Complete List of 128-bit Service Class UUIDs'
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 16)
elif ad_type == AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:
ad_type_str = 'Incomplete List of 128-bit Service Class UUIDs'
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 16)
elif ad_type == AdvertisingData.SERVICE_DATA_16_BIT_UUID:
ad_type_str = 'Service Data'
uuid = UUID.from_bytes(ad_data[:2])
ad_data_str = f'service={uuid}, data={ad_data[2:].hex()}'
elif ad_type == AdvertisingData.SERVICE_DATA_32_BIT_UUID:
ad_type_str = 'Service Data'
uuid = UUID.from_bytes(ad_data[:4])
ad_data_str = f'service={uuid}, data={ad_data[4:].hex()}'
elif ad_type == AdvertisingData.SERVICE_DATA_128_BIT_UUID:
ad_type_str = 'Service Data'
uuid = UUID.from_bytes(ad_data[:16])
ad_data_str = f'service={uuid}, data={ad_data[16:].hex()}'
elif ad_type == AdvertisingData.SHORTENED_LOCAL_NAME:
ad_type_str = 'Shortened Local Name'
ad_data_str = f'"{ad_data.decode("utf-8")}"'
elif ad_type == AdvertisingData.COMPLETE_LOCAL_NAME:
ad_type_str = 'Complete Local Name'
ad_data_str = f'"{ad_data.decode("utf-8")}"'
elif ad_type == AdvertisingData.TX_POWER_LEVEL:
ad_type_str = 'TX Power Level'
ad_data_str = str(ad_data[0])
elif ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
ad_type_str = 'Manufacturer Specific Data'
company_id = struct.unpack_from('<H', ad_data, 0)[0]
company_name = COMPANY_IDENTIFIERS.get(company_id, f'0x{company_id:04X}')
ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
elif ad_type == AdvertisingData.APPEARANCE:
ad_type_str = 'Appearance'
ad_data_str = ad_data.hex()
else:
ad_type_str = AdvertisingData.AD_TYPE_NAMES.get(ad_type, f'0x{ad_type:02X}')
ad_data_str = ad_data.hex()
return f'[{ad_type_str}]: {ad_data_str}'
@staticmethod
def ad_data_to_object(ad_type, ad_data):
if ad_type in {
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS
}:
return AdvertisingData.uuid_list_to_objects(ad_data, 2)
elif ad_type in {
AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS
}:
return AdvertisingData.uuid_list_to_objects(ad_data, 4)
elif ad_type in {
AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS
}:
return AdvertisingData.uuid_list_to_objects(ad_data, 16)
elif ad_type == AdvertisingData.SERVICE_DATA_16_BIT_UUID:
return (UUID.from_bytes(ad_data[:2]), ad_data[2:])
elif ad_type == AdvertisingData.SERVICE_DATA_32_BIT_UUID:
return (UUID.from_bytes(ad_data[:4]), ad_data[4:])
elif ad_type == AdvertisingData.SERVICE_DATA_128_BIT_UUID:
return (UUID.from_bytes(ad_data[:16]), ad_data[16:])
elif ad_type in {
AdvertisingData.SHORTENED_LOCAL_NAME,
AdvertisingData.COMPLETE_LOCAL_NAME
}:
return ad_data.decode("utf-8")
elif ad_type == AdvertisingData.TX_POWER_LEVEL:
return ad_data[0]
elif ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
return (struct.unpack_from('<H', ad_data, 0)[0], ad_data[2:])
else:
return ad_data
def append(self, data):
offset = 0
while offset + 1 < len(data):
length = data[offset]
offset += 1
if length > 0:
ad_type = data[offset]
ad_data = data[offset + 1:offset + length]
self.ad_structures.append((ad_type, ad_data))
offset += length
def get(self, type_id, return_all=False, raw=True):
'''
Get Advertising Data Structure(s) with a given type
If return_all is True, returns a (possibly empty) list of matches,
else returns the first entry, or None if no structure matches.
'''
def process_ad_data(ad_data):
return ad_data if raw else self.ad_data_to_object(type_id, ad_data)
if return_all:
return [process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id]
else:
return next((process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id), None)
def __bytes__(self):
return b''.join([bytes([len(x[1]) + 1, x[0]]) + x[1] for x in self.ad_structures])
def to_string(self, separator=', '):
return separator.join([AdvertisingData.ad_data_to_string(x[0], x[1]) for x in self.ad_structures])
def __str__(self):
return self.to_string()
# -----------------------------------------------------------------------------
# Connection Parameters
# -----------------------------------------------------------------------------
class ConnectionParameters:
def __init__(self, connection_interval, connection_latency, supervision_timeout):
self.connection_interval = connection_interval
self.connection_latency = connection_latency
self.supervision_timeout = supervision_timeout
def __str__(self):
return f'ConnectionParameters(connection_interval={self.connection_interval}, connection_latency={self.connection_latency}, supervision_timeout={self.supervision_timeout}'
# -----------------------------------------------------------------------------
# Connection PHY
# -----------------------------------------------------------------------------
class ConnectionPHY:
def __init__(self, tx_phy, rx_phy):
self.tx_phy = tx_phy
self.rx_phy = rx_phy
def __str__(self):
return f'ConnectionPHY(tx_phy={self.tx_phy}, rx_phy={self.rx_phy})'

229
bumble/crypto.py Normal file
View File

@@ -0,0 +1,229 @@
# 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.
# -----------------------------------------------------------------------------
# Crypto support
#
# See Bluetooth spec Vol 3, Part H - 2.2 CRYPTOGRAPHIC TOOLBOX
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import logging
import operator
import platform
if platform.system() != 'Emscripten':
import secrets
from cryptography.hazmat.primitives.ciphers import (
Cipher,
algorithms,
modes
)
from cryptography.hazmat.primitives.asymmetric.ec import (
generate_private_key,
ECDH,
EllipticCurvePublicNumbers,
EllipticCurvePrivateNumbers,
SECP256R1
)
from cryptography.hazmat.primitives import cmac
else:
# TODO: implement stubs
pass
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class EccKey:
def __init__(self, private_key):
self.private_key = private_key
@classmethod
def generate(cls):
private_key = generate_private_key(SECP256R1())
return cls(private_key)
@classmethod
def from_private_key_bytes(cls, d_bytes, x_bytes, y_bytes):
d = int.from_bytes(d_bytes, byteorder='big', signed=False)
x = int.from_bytes(x_bytes, byteorder='big', signed=False)
y = int.from_bytes(y_bytes, byteorder='big', signed=False)
private_key = EllipticCurvePrivateNumbers(d, EllipticCurvePublicNumbers(x, y, SECP256R1())).private_key()
return cls(private_key)
@property
def x(self):
return self.private_key.public_key().public_numbers().x.to_bytes(32, byteorder='big')
@property
def y(self):
return self.private_key.public_key().public_numbers().y.to_bytes(32, byteorder='big')
def dh(self, public_key_x, public_key_y):
x = int.from_bytes(public_key_x, byteorder='big', signed=False)
y = int.from_bytes(public_key_y, byteorder='big', signed=False)
public_key = EllipticCurvePublicNumbers(x, y, SECP256R1()).public_key()
shared_key = self.private_key.exchange(ECDH(), public_key)
return shared_key
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
def xor(x, y):
assert(len(x) == len(y))
return bytes(map(operator.xor, x, y))
# -----------------------------------------------------------------------------
def r():
'''
Generate 16 bytes of random data
'''
return secrets.token_bytes(16)
# -----------------------------------------------------------------------------
def e(key, data):
'''
AES-128 ECB, expecting byte-swapped inputs and producing a byte-swapped output.
See Bluetooth spec Vol 3, Part H - 2.2.1 Security function e
'''
cipher = Cipher(algorithms.AES(bytes(reversed(key))), modes.ECB())
encryptor = cipher.encryptor()
return bytes(reversed(encryptor.update(bytes(reversed(data)))))
# -----------------------------------------------------------------------------
def ah(k, r):
'''
See Bluetooth spec Vol 3, Part H - 2.2.2 Random Address Hash function ah
'''
padding = bytes(13)
r_prime = r + padding
return e(k, r_prime)[0:3]
# -----------------------------------------------------------------------------
def c1(k, r, preq, pres, iat, rat, ia, ra):
'''
See Bluetooth spec, Vol 3, Part H - 2.2.3 Confirm value generation function c1 for LE Legacy Pairing
'''
p1 = bytes([iat, rat]) + preq + pres
p2 = ra + ia + bytes([0, 0, 0, 0])
return e(k, xor(e(k, xor(r, p1)), p2))
# -----------------------------------------------------------------------------
def s1(k, r1, r2):
'''
See Bluetooth spec, Vol 3, Part H - 2.2.4 Key generation function s1 for LE Legacy Pairing
'''
return e(k, r2[0:8] + r1[0:8])
# -----------------------------------------------------------------------------
def aes_cmac(m, k):
'''
See Bluetooth spec, Vol 3, Part H - 2.2.5 FunctionAES-CMAC
NOTE: the input and output of this internal function are in big-endian byte order
'''
mac = cmac.CMAC(algorithms.AES(k))
mac.update(m)
return mac.finalize()
# -----------------------------------------------------------------------------
def f4(u, v, x, z):
'''
See Bluetooth spec, Vol 3, Part H - 2.2.6 LE Secure Connections Confirm Value Generation Function f4
'''
return bytes(reversed(aes_cmac(bytes(reversed(u)) + bytes(reversed(v)) + z, bytes(reversed(x)))))
# -----------------------------------------------------------------------------
def f5(w, n1, n2, a1, a2):
'''
See Bluetooth spec, Vol 3, Part H - 2.2.7 LE Secure Connections Key Generation Function f5
NOTE: this returns a tuple: (MacKey, LTK) in little-endian byte order
'''
salt = bytes.fromhex('6C888391AAF5A53860370BDB5A6083BE')
t = aes_cmac(bytes(reversed(w)), salt)
key_id = bytes([0x62, 0x74, 0x6c, 0x65])
return (
bytes(reversed(aes_cmac(
bytes([0]) +
key_id +
bytes(reversed(n1)) +
bytes(reversed(n2)) +
bytes(reversed(a1)) +
bytes(reversed(a2)) +
bytes([1, 0]),
t
))),
bytes(reversed(aes_cmac(
bytes([1]) +
key_id +
bytes(reversed(n1)) +
bytes(reversed(n2)) +
bytes(reversed(a1)) +
bytes(reversed(a2)) +
bytes([1, 0]),
t
)))
)
# -----------------------------------------------------------------------------
def f6(w, n1, n2, r, io_cap, a1, a2):
'''
See Bluetooth spec, Vol 3, Part H - 2.2.8 LE Secure Connections Check Value Generation Function f6
'''
return bytes(reversed(aes_cmac(
bytes(reversed(n1)) +
bytes(reversed(n2)) +
bytes(reversed(r)) +
bytes(reversed(io_cap)) +
bytes(reversed(a1)) +
bytes(reversed(a2)),
bytes(reversed(w))
)))
# -----------------------------------------------------------------------------
def g2(u, v, x, y):
'''
See Bluetooth spec, Vol 3, Part H - 2.2.9 LE Secure Connections Numeric Comparison Value Generation Function g2
'''
return int.from_bytes(
aes_cmac(bytes(reversed(u)) + bytes(reversed(v)) + bytes(reversed(y)), bytes(reversed(x)))[-4:],
byteorder='big'
)

1256
bumble/device.py Normal file

File diff suppressed because it is too large Load Diff

59
bumble/gap.py Normal file
View File

@@ -0,0 +1,59 @@
# 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 logging
import struct
from .gatt import (
Service,
Characteristic,
GATT_GENERIC_ACCESS_SERVICE,
GATT_DEVICE_NAME_CHARACTERISTIC,
GATT_APPEARANCE_CHARACTERISTIC
)
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
class GenericAccessService(Service):
def __init__(self, device_name, appearance = (0, 0)):
device_name_characteristic = Characteristic(
GATT_DEVICE_NAME_CHARACTERISTIC,
Characteristic.READ,
Characteristic.READABLE,
device_name.encode('utf-8')[:248]
)
appearance_characteristic = Characteristic(
GATT_APPEARANCE_CHARACTERISTIC,
Characteristic.READ,
Characteristic.READABLE,
struct.pack('<H', (appearance[0] << 6) | appearance[1])
)
super().__init__(GATT_GENERIC_ACCESS_SERVICE, [
device_name_characteristic,
appearance_characteristic
])

308
bumble/gatt.py Normal file
View File

@@ -0,0 +1,308 @@
# 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.
# -----------------------------------------------------------------------------
# GATT - Generic Attribute Profile
#
# See Bluetooth spec @ Vol 3, Part G
#
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import logging
from colors import color
from .core import *
from .hci import *
from .att import *
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
GATT_REQUEST_TIMEOUT = 30 # seconds
GATT_MAX_ATTRIBUTE_VALUE_SIZE = 512
# Services
GATT_GENERIC_ACCESS_SERVICE = UUID.from_16_bits(0x1800, 'Generic Access')
GATT_GENERIC_ATTRIBUTE_SERVICE = UUID.from_16_bits(0x1801, 'Generic Attribute')
GATT_IMMEDIATE_ALERT_SERVICE = UUID.from_16_bits(0x1802, 'Immediate Alert')
GATT_LINK_LOSS_SERVICE = UUID.from_16_bits(0x1803, 'Link Loss')
GATT_TX_POWER_SERVICE = UUID.from_16_bits(0x1804, 'TX Power')
GATT_CURRENT_TIME_SERVICE = UUID.from_16_bits(0x1805, 'Current Time')
GATT_REFERENCE_TIME_UPDATE_SERVICE = UUID.from_16_bits(0x1806, 'Reference Time Update')
GATT_NEXT_DST_CHANGE_SERVICE = UUID.from_16_bits(0x1807, 'Next DST Change')
GATT_GLUCOSE_SERVICE = UUID.from_16_bits(0x1808, 'Glucose')
GATT_HEALTH_THERMOMETER_SERVICE = UUID.from_16_bits(0x1809, 'Health Thermometer')
GATT_DEVICE_INFORMATION_SERVICE = UUID.from_16_bits(0x180A, 'Device Information')
GATT_DEVICE_HEART_RATE_SERVICE = UUID.from_16_bits(0x180D, 'Heart Rate')
GATT_PHONE_ALTERT_STATUS_SERVICE = UUID.from_16_bits(0x180E, 'Phone Alert Status')
GATT_DEVICE_BATTERY_SERVICE = UUID.from_16_bits(0x180F, 'Battery')
GATT_BLOOD_PRESSURE_SERVICE = UUID.from_16_bits(0x1810, 'Blood Pressure')
GATT_ALTERT_NOTIFICATION_SERVICE = UUID.from_16_bits(0x1811, 'Alert Notification')
GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE = UUID.from_16_bits(0x1812, 'Human Interface Device')
GATT_DEVICE_SCAN_PARAMETERS_SERVICE = UUID.from_16_bits(0x1813, 'Scan Parameters')
GATT_RUNNING_SPEED_AND_CADENCE_SERVICE = UUID.from_16_bits(0x1814, 'Running Speed and Cadence')
GATT_AUTOMATION_IO_SERVICE = UUID.from_16_bits(0x1815, 'Automation IO')
GATT_CYCLING_SPEED_AND_CADENCE_SERVICE = UUID.from_16_bits(0x1816, 'Cycling Speed and Cadence')
GATT_CYCLING_POWER_SERVICE = UUID.from_16_bits(0x1818, 'Cycling Power')
GATT_LOCATION_AND_NAVIGATION_SERVICE = UUID.from_16_bits(0x1819, 'Location and Navigation')
GATT_ENVIRONMENTAL_SENSING_SERVICE = UUID.from_16_bits(0x181A, 'Environmental Sensing')
GATT_BODY_COMPOSITION_SERVICE = UUID.from_16_bits(0x181B, 'Body Composition')
GATT_USER_DATA_SERVICE = UUID.from_16_bits(0x181C, 'User Data')
GATT_WEIGHT_SCALE_SERVICE = UUID.from_16_bits(0x181D, 'Weight Scale')
GATT_BOND_MANAGEMENT_SERVICE = UUID.from_16_bits(0x181E, 'Bond Management')
GATT_CONTINUOUS_GLUCOSE_MONITORING_SERVICE = UUID.from_16_bits(0x181F, 'Continuous Glucose Monitoring')
GATT_INTERNET_PROTOCOL_SUPPORT_SERVICE = UUID.from_16_bits(0x1820, 'Internet Protocol Support')
GATT_INDOOR_POSITIONING_SERVICE = UUID.from_16_bits(0x1821, 'Indoor Positioning')
GATT_PULSE_OXIMETER_SERVICE = UUID.from_16_bits(0x1822, 'Pulse Oximeter')
GATT_HTTP_PROXY_SERVICE = UUID.from_16_bits(0x1823, 'HTTP Proxy')
GATT_TRANSPORT_DISCOVERY_SERVICE = UUID.from_16_bits(0x1824, 'Transport Discovery')
GATT_OBJECT_TRANSFER_SERVICE = UUID.from_16_bits(0x1825, 'Object Transfer')
GATT_FITNESS_MACHINE_SERVICE = UUID.from_16_bits(0x1826, 'Fitness Machine')
GATT_MESH_PROVISIONING_SERVICE = UUID.from_16_bits(0x1827, 'Mesh Provisioning')
GATT_MESH_PROXY_SERVICE = UUID.from_16_bits(0x1828, 'Mesh Proxy')
GATT_RECONNECTION_CONFIGURATION_SERVICE = UUID.from_16_bits(0x1829, 'Reconnection Configuration')
GATT_INSULIN_DELIVERY_SERVICE = UUID.from_16_bits(0x183A, 'Insulin Delivery')
GATT_BINARY_SENSOR_SERVICE = UUID.from_16_bits(0x183B, 'Binary Sensor')
GATT_EMERGENCY_CONFIGURATION_SERVICE = UUID.from_16_bits(0x183C, 'Emergency Configuration')
GATT_PHYSICAL_ACTIVITY_MONITOR_SERVICE = UUID.from_16_bits(0x183E, 'Physical Activity Monitor')
GATT_AUDIO_INPUT_CONTROL_SERVICE = UUID.from_16_bits(0x1843, 'Audio Input Control')
GATT_VOLUME_CONTROL_SERVICE = UUID.from_16_bits(0x1844, 'Volume Control')
GATT_VOLUME_OFFSET_CONTROL_SERVICE = UUID.from_16_bits(0x1845, 'Volume Offset Control')
GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification Service')
GATT_DEVICE_TIME_SERVICE = UUID.from_16_bits(0x1847, 'Device Time')
GATT_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, 'Media Control Service')
GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, 'Generic Media Control Service')
GATT_CONSTANT_TONE_EXTENSION_SERVICE = UUID.from_16_bits(0x184A, 'Constant Tone Extension')
GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, 'Telephone Bearer Service')
GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer Service')
GATT_MICROPHONE_CONTROL_SERVICE = UUID.from_16_bits(0x184D, 'Microphone Control')
# Types
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2800, 'Primary Service')
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2801, 'Secondary Service')
GATT_INCLUDE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2802, 'Include')
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2803, 'Characteristic')
# Descriptors
GATT_CHARACTERISTIC_EXTENDED_PROPERTIES_DESCRIPTOR = UUID.from_16_bits(0x2900, 'Characteristic Extended Properties')
GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR = UUID.from_16_bits(0x2901, 'Characteristic User Description')
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR = UUID.from_16_bits(0x2902, 'Client Characteristic Configuration')
GATT_SERVER_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR = UUID.from_16_bits(0x2903, 'Server Characteristic Configuration')
GATT_CHARACTERISTIC_PRESENTATION_FORMAT_DESCRIPTOR = UUID.from_16_bits(0x2904, 'Characteristic Format')
GATT_CHARACTERISTIC_AGGREGATE_FORMAT_DESCRIPTOR = UUID.from_16_bits(0x2905, 'Characteristic Aggregate Format')
GATT_VALID_RANGE_DESCRIPTOR = UUID.from_16_bits(0x2906, 'Valid Range')
GATT_EXTERNAL_REPORT_DESCRIPTOR = UUID.from_16_bits(0x2907, 'External Report')
GATT_REPORT_REFERENCE_DESCRIPTOR = UUID.from_16_bits(0x2908, 'Report Reference')
GATT_NUMBER_OF_DIGITALS_DESCRIPTOR = UUID.from_16_bits(0x2909, 'Number of Digitals')
GATT_VALUE_TRIGGER_SETTING_DESCRIPTOR = UUID.from_16_bits(0x290A, 'Value Trigger Setting')
GATT_ENVIRONMENTAL_SENSING_CONFIGURATION_DESCRIPTOR = UUID.from_16_bits(0x290B, 'Environmental Sensing Configuration')
GATT_ENVIRONMENTAL_SENSING_MEASUREMENT_DESCRIPTOR = UUID.from_16_bits(0x290C, 'Environmental Sensing Measurement')
GATT_ENVIRONMENTAL_SENSING_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290D, 'Environmental Sensing Trigger Setting')
GATT_TIME_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290E, 'Time Trigger Setting')
GATT_COMPLETE_BE_EDR_TRANSPORT_BLOCK_DATA_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Complete BR-EDR Transport Block Data')
# Device Information Service
GATT_SYSTEM_ID_CHARACTERISTIC = UUID.from_16_bits(0x2A23, 'System ID')
GATT_MODEL_NUMBER_STRING_CHARACTERISTIC = UUID.from_16_bits(0x2A24, 'Model Number String')
GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC = UUID.from_16_bits(0x2A25, 'Serial Number String')
GATT_FIRMWARE_REVISION_STRING_CHARACTERISTIC = UUID.from_16_bits(0x2A26, 'Firmware Revision String')
GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC = UUID.from_16_bits(0x2A27, 'Hardware Revision String')
GATT_SOFTWARE_REVISION_STRING_CHARACTERISTIC = UUID.from_16_bits(0x2A28, 'Software Revision String')
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC = UUID.from_16_bits(0x2A29, 'Manufacturer Name String')
GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC = UUID.from_16_bits(0x2A2A, 'IEEE 11073-20601 Regulatory Certification Data List')
GATT_PNP_ID_CHARACTERISTIC = UUID.from_16_bits(0x2A50, 'PnP ID')
# Human Interface Device
GATT_HID_INFORMATION_CHARACTERISTIC = UUID.from_16_bits(0x2A4A, 'HID Information')
GATT_REPORT_MAP_CHARACTERISTIC = UUID.from_16_bits(0x2A4B, 'Report Map')
GATT_HID_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2A4C, 'HID Control Point')
GATT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A4D, 'Report')
GATT_PROTOCOL_MODE_CHARACTERISTIC = UUID.from_16_bits(0x2A4E, 'Protocol Mode')
# Misc
GATT_DEVICE_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2A00, 'Device Name')
GATT_APPEARANCE_CHARACTERISTIC = UUID.from_16_bits(0x2A01, 'Appearance')
GATT_PERIPHERAL_PRIVACY_FLAG_CHARACTERISTIC = UUID.from_16_bits(0x2A02, 'Peripheral Privacy Flag')
GATT_RECONNECTION_ADDRESS_CHARACTERISTIC = UUID.from_16_bits(0x2A03, 'Reconnection Address')
GATT_PERIPHERAL_PREFERRREED_CONNECTION_PARAMETERS_CHARACTERISTIC = UUID.from_16_bits(0x2A04, 'Peripheral Preferred Connection Parameters')
GATT_SERVICE_CHANGED_CHARACTERISTIC = UUID.from_16_bits(0x2A05, 'Service Changed')
GATT_ALERT_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A06, 'Alert Level')
GATT_TX_POWER_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A07, 'Tx Power Level')
GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level')
GATT_BOOT_KEYBOARD_INPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A22, 'Boot Keyboard Input Report')
GATT_CURRENT_TIME_CHARACTERISTIC = UUID.from_16_bits(0x2A2B, 'Current Time')
GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report')
GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bits(0x2AA6, 'Central Address Resolution')
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
def show_services(services):
for service in services:
print(color(str(service), 'cyan'))
for characteristic in service.characteristics:
print(color(' ' + str(characteristic), 'magenta'))
for descriptor in characteristic.descriptors:
print(color(' ' + str(descriptor), 'green'))
# -----------------------------------------------------------------------------
class Service(Attribute):
'''
See Vol 3, Part G - 3.1 SERVICE DEFINITION
'''
def __init__(self, uuid, characteristics, primary=True):
# Convert the uuid to a UUID object if it isn't already
if type(uuid) is str:
uuid = UUID(uuid)
super().__init__(
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE if primary else GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
Attribute.READABLE,
uuid.to_pdu_bytes()
)
self.uuid = uuid
self.included_services = []
self.characteristics = characteristics[:]
self.end_group_handle = 0
self.primary = primary
def __str__(self):
return f'Service(handle=0x{self.handle:04X}, end=0x{self.end_group_handle:04X}, uuid={self.uuid}){"" if self.primary else "*"}'
# -----------------------------------------------------------------------------
class Characteristic(Attribute):
'''
See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION
'''
# Property flags
BROADCAST = 0x01
READ = 0x02
WRITE_WITHOUT_RESPONSE = 0x04
WRITE = 0x08
NOTIFY = 0x10
INDICATE = 0X20
AUTHENTICATED_SIGNED_WRITES = 0X40
EXTENDED_PROPERTIES = 0X80
PROPERTY_NAMES = {
BROADCAST: 'BROADCAST',
READ: 'READ',
WRITE_WITHOUT_RESPONSE: 'WRITE_WITHOUT_RESPONSE',
WRITE: 'WRITE',
NOTIFY: 'NOTIFY',
INDICATE: 'INDICATE',
AUTHENTICATED_SIGNED_WRITES: 'AUTHENTICATED_SIGNED_WRITES',
EXTENDED_PROPERTIES: 'EXTENDED_PROPERTIES'
}
@staticmethod
def property_name(property):
return Characteristic.PROPERTY_NAMES.get(property, '')
def __init__(self, uuid, properties, permissions, value = b'', descriptors = []):
# Convert the uuid to a UUID object if it isn't already
if type(uuid) is str:
uuid = UUID(uuid)
super().__init__(uuid, permissions, value)
self.uuid = uuid
self.properties = properties
self._descriptors = descriptors
self._descriptors_discovered = False
self.end_group_handle = 0
self.attach_descriptors()
def attach_descriptors(self):
""" Let all the descriptors know they are attached to this characteristic """
for descriptor in self._descriptors:
descriptor.characteristic = self
def add_descriptor(self, descriptor):
descriptor.characteristic = self
self.descriptors.append(descriptor)
def get_descriptor(self, descriptor_type):
for descriptor in self.descriptors:
if descriptor.uuid == descriptor_type:
return descriptor
@property
def descriptors(self):
return self._descriptors
@descriptors.setter
def descriptors(self, value):
self._descriptors = value
self._descriptors_discovered = True
self.attach_descriptors()
@property
def descriptors_discovered(self):
return self._descriptors_discovered
def get_properties_as_string(self):
return ','.join([self.property_name(p) for p in self.PROPERTY_NAMES.keys() if self.properties & p])
def __str__(self):
return f'Characteristic(handle=0x{self.handle:04X}, end=0x{self.end_group_handle:04X}, uuid={self.uuid}, properties={self.get_properties_as_string()})'
# -----------------------------------------------------------------------------
class CharacteristicValue:
def __init__(self, read=None, write=None):
self._read = read
self._write = write
def read(self, connection):
return self._read(connection) if self._read else b''
def write(self, connection, value):
if self._write:
self._write(connection, value)
# -----------------------------------------------------------------------------
class Descriptor(Attribute):
'''
See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations
'''
def __init__(self, uuid, permissions, value = b''):
# Convert the uuid to a UUID object if it isn't already
if type(uuid) is str:
uuid = UUID(uuid)
super().__init__(uuid, permissions, value)
self.uuid = uuid
self.characteristic = None
def __str__(self):
return f'Descriptor(handle=0x{self.handle:04X}, uuid={self.uuid}, value={self.read_value(None).hex()})'

645
bumble/gatt_client.py Normal file
View File

@@ -0,0 +1,645 @@
# 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.
# -----------------------------------------------------------------------------
# GATT - Generic Attribute Profile
# Client
#
# See Bluetooth spec @ Vol 3, Part G
#
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
import logging
import struct
from colors import color
from .core import ProtocolError, TimeoutError
from .hci import *
from .att import *
from .gatt import (
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
GATT_REQUEST_TIMEOUT,
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
Service,
Characteristic,
Descriptor
)
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# GATT Client
# -----------------------------------------------------------------------------
class Client:
def __init__(self, connection):
self.connection = connection
self.mtu = ATT_DEFAULT_MTU
self.mtu_exchange_done = False
self.request_semaphore = asyncio.Semaphore(1)
self.pending_request = None
self.pending_response = None
self.notification_subscribers = {} # Notification subscribers, by attribute handle
self.indication_subscribers = {} # Indication subscribers, by attribute handle
self.services = []
def send_gatt_pdu(self, pdu):
self.connection.send_l2cap_pdu(ATT_CID, pdu)
async def send_command(self, command):
logger.debug(f'GATT Command from client: [0x{self.connection.handle:04X}] {command}')
self.send_gatt_pdu(command.to_bytes())
async def send_request(self, request):
logger.debug(f'GATT Request from client: [0x{self.connection.handle:04X}] {request}')
# Wait until we can send (only one pending command at a time for the connection)
response = None
async with self.request_semaphore:
assert(self.pending_request is None)
assert(self.pending_response is None)
# Create a future value to hold the eventual response
self.pending_response = asyncio.get_running_loop().create_future()
self.pending_request = request
try:
self.send_gatt_pdu(request.to_bytes())
response = await asyncio.wait_for(self.pending_response, GATT_REQUEST_TIMEOUT)
except asyncio.TimeoutError:
logger.warning(color('!!! GATT Request timeout', 'red'))
raise TimeoutError(f'GATT timeout for {request.name}')
finally:
self.pending_request = None
self.pending_response = None
return response
def send_confirmation(self, confirmation):
logger.debug(f'GATT Confirmation from client: [0x{self.connection.handle:04X}] {confirmation}')
self.send_gatt_pdu(confirmation.to_bytes())
async def request_mtu(self, mtu):
# Check the range
if mtu < ATT_DEFAULT_MTU:
raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
if mtu > 0xFFFF:
raise ValueError('MTU must be <= 0xFFFF')
# We can only send one request per connection
if self.mtu_exchange_done:
return
# Send the request
self.mtu_exchange_done = True
response = await self.send_request(ATT_Exchange_MTU_Request(client_rx_mtu = mtu))
if response.op_code == ATT_ERROR_RESPONSE:
raise ProtocolError(
response.error_code,
'att',
ATT_PDU.error_name(response.error_code),
response
)
self.mtu = max(ATT_DEFAULT_MTU, response.server_rx_mtu)
return self.mtu
def get_services_by_uuid(self, uuid):
return [service for service in self.services if service.uuid == uuid]
def get_characteristics_by_uuid(self, uuid, service = None):
services = [service] if service else self.services
return [c for c in [c for s in services for c in s.characteristics] if c.uuid == uuid]
def on_service_discovered(self, service):
''' Add a service to the service list if it wasn't already there '''
already_known = False
for existing_service in self.services:
if existing_service.handle == service.handle:
already_known = True
break
if not already_known:
self.services.append(service)
async def discover_services(self, uuids = None):
'''
See Vol 3, Part G - 4.4.1 Discover All Primary Services
'''
starting_handle = 0x0001
services = []
while starting_handle < 0xFFFF:
response = await self.send_request(
ATT_Read_By_Group_Type_Request(
starting_handle = starting_handle,
ending_handle = 0xFFFF,
attribute_group_type = GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE
)
)
if response is None:
# TODO raise appropriate exception
return []
# Check if we reached the end of the iteration
if response.op_code == ATT_ERROR_RESPONSE:
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
# Unexpected end
logger.waning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}')
# TODO raise appropriate exception
return
break
for attribute_handle, end_group_handle, attribute_value in response.attributes:
if attribute_handle < starting_handle or end_group_handle < attribute_handle:
# Something's not right
logger.warning(f'bogus handle values: {attribute_handle} {end_group_handle}')
return
# Create a primary service object
service = Service(UUID.from_bytes(attribute_value), [], True)
service.handle = attribute_handle
service.end_group_handle = end_group_handle
# Filter out returned services based on the given uuids list
if (not uuids) or (service.uuid in uuids):
services.append(service)
# Add the service to the peer's service list
self.on_service_discovered(service)
# Stop if for some reason the list was empty
if not response.attributes:
break
# Move on to the next chunk
starting_handle = response.attributes[-1][1] + 1
return services
async def discover_service(self, uuid):
'''
See Vol 3, Part G - 4.4.2 Discover Primary Service by Service UUID
'''
# Force uuid to be a UUID object
if type(uuid) is str:
uuid = UUID(uuid)
starting_handle = 0x0001
services = []
while starting_handle < 0xFFFF:
response = await self.send_request(
ATT_Find_By_Type_Value_Request(
starting_handle = starting_handle,
ending_handle = 0xFFFF,
attribute_type = GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
attribute_value = uuid.to_pdu_bytes()
)
)
if response is None:
# TODO raise appropriate exception
return []
# Check if we reached the end of the iteration
if response.op_code == ATT_ERROR_RESPONSE:
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
# Unexpected end
logger.waning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}')
# TODO raise appropriate exception
return
break
for attribute_handle, end_group_handle in response.handles_information:
if attribute_handle < starting_handle or end_group_handle < attribute_handle:
# Something's not right
logger.warning(f'bogus handle values: {attribute_handle} {end_group_handle}')
return
# Create a primary service object
service = Service(uuid, [], True)
service.handle = attribute_handle
service.end_group_handle = end_group_handle
# Add the service to the peer's service list
services.append(service)
self.on_service_discovered(service)
# Check if we've reached the end already
if end_group_handle == 0xFFFF:
break
# Stop if for some reason the list was empty
if not response.handles_information:
break
# Move on to the next chunk
starting_handle = response.handles_information[-1][1] + 1
return services
async def discover_included_services(self, service):
'''
See Vol 3, Part G - 4.5.1 Find Included Services
'''
# TODO
return []
async def discover_characteristics(self, uuids, service):
'''
See Vol 3, Part G - 4.6.1 Discover All Characteristics of a Service and 4.6.2 Discover Characteristics by UUID
'''
# Cast the UUIDs type from string to object if needed
uuids = [UUID(uuid) if type(uuid) is str else uuid for uuid in uuids]
# Decide which services to discover for
services = [service] if service else self.services
# Perform characteristic discovery for each service
discovered_characteristics = []
for service in services:
starting_handle = service.handle
ending_handle = service.end_group_handle
characteristics = []
while starting_handle <= ending_handle:
response = await self.send_request(
ATT_Read_By_Type_Request(
starting_handle = starting_handle,
ending_handle = ending_handle,
attribute_type = GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
)
)
if response is None:
# TODO raise appropriate exception
return []
# Check if we reached the end of the iteration
if response.op_code == ATT_ERROR_RESPONSE:
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
# Unexpected end
logger.warning(f'!!! unexpected error while discovering characteristics: {HCI_Constant.error_name(response.error_code)}')
# TODO raise appropriate exception
return
break
# Stop if for some reason the list was empty
if not response.attributes:
break
# Process all characteristics returned in this iteration
for attribute_handle, attribute_value in response.attributes:
if attribute_handle < starting_handle:
# Something's not right
logger.warning(f'bogus handle value: {attribute_handle}')
return []
properties, handle = struct.unpack_from('<BH', attribute_value)
characteristic_uuid = UUID.from_bytes(attribute_value[3:])
characteristic = Characteristic(characteristic_uuid, properties, 0)
characteristic.handle = handle
# Set the previous characteristic's end handle
if characteristics:
characteristics[-1].end_group_handle = attribute_handle - 1
characteristics.append(characteristic)
# Move on to the next characteristics
starting_handle = response.attributes[-1][0] + 1
# Set the end handle for the last characteristic
if characteristics:
characteristics[-1].end_group_handle = service.end_group_handle
# Set the service's characteristics
characteristics = [c for c in characteristics if not uuids or c.uuid in uuids]
service.characteristics = characteristics
discovered_characteristics.extend(characteristics)
return discovered_characteristics
async def discover_descriptors(self, characteristic = None, start_handle = None, end_handle = None):
'''
See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors
'''
if characteristic:
starting_handle = characteristic.handle + 1
ending_handle = characteristic.end_group_handle
elif start_handle and end_handle:
starting_handle = start_handle
ending_handle = end_handle
else:
return []
descriptors = []
while starting_handle <= ending_handle:
response = await self.send_request(
ATT_Find_Information_Request(
starting_handle = starting_handle,
ending_handle = ending_handle
)
)
if response is None:
# TODO raise appropriate exception
return []
# Check if we reached the end of the iteration
if response.op_code == ATT_ERROR_RESPONSE:
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
# Unexpected end
logger.warning(f'!!! unexpected error while discovering descriptors: {HCI_Constant.error_name(response.error_code)}')
# TODO raise appropriate exception
return []
break
# Stop if for some reason the list was empty
if not response.information:
break
# Process all descriptors returned in this iteration
for attribute_handle, attribute_uuid in response.information:
if attribute_handle < starting_handle:
# Something's not right
logger.warning(f'bogus handle value: {attribute_handle}')
return []
descriptor = Descriptor(UUID.from_bytes(attribute_uuid), 0)
descriptor.handle = attribute_handle
descriptors.append(descriptor)
# TODO: read descriptor value
# Move on to the next descriptor
starting_handle = response.information[-1][0] + 1
# Set the characteristic's descriptors
if characteristic:
characteristic.descriptors = descriptors
return descriptors
async def discover_attributes(self):
'''
Discover all attributes, regardless of type
'''
starting_handle = 0x0001
ending_handle = 0xFFFF
attributes = []
while True:
response = await self.send_request(
ATT_Find_Information_Request(
starting_handle = starting_handle,
ending_handle = ending_handle
)
)
if response is None:
return []
# Check if we reached the end of the iteration
if response.op_code == ATT_ERROR_RESPONSE:
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
# Unexpected end
logger.warning(f'!!! unexpected error while discovering attributes: {HCI_Constant.error_name(response.error_code)}')
return []
break
for attribute_handle, attribute_uuid in response.information:
if attribute_handle < starting_handle:
# Something's not right
logger.warning(f'bogus handle value: {attribute_handle}')
return []
attribute = Attribute(attribute_uuid, 0)
attribute.handle = attribute_handle
attributes.append(attribute)
# Move on to the next attributes
starting_handle = attributes[-1].handle + 1
return attributes
async def subscribe(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('subscribing to characteristic with no CCCD descriptor')
return
# Set the subscription bits and select the subscriber set
bits = 0
subscriber_sets = []
if characteristic.properties & Characteristic.NOTIFY:
bits |= 0x0001
subscriber_sets.append(self.notification_subscribers.setdefault(characteristic.handle, set()))
if characteristic.properties & Characteristic.INDICATE:
bits |= 0x0002
subscriber_sets.append(self.indication_subscribers.setdefault(characteristic.handle, set()))
# Add subscribers to the sets
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))
await self.write_value(cccd, struct.pack('<H', bits), with_response=True)
async def read_value(self, attribute, no_long_read=False):
'''
See Vol 3, Part G - 4.8.1 Read Characteristic Value
`attribute` can be an Attribute object, or a handle value
'''
# Send a request to read
attribute_handle = attribute if type(attribute) is int else attribute.handle
response = await self.send_request(ATT_Read_Request(attribute_handle = attribute_handle))
if response is None:
raise TimeoutError('read timeout')
if response.op_code == ATT_ERROR_RESPONSE:
raise ProtocolError(
response.error_code,
'att',
ATT_PDU.error_name(response.error_code),
response
)
# If the value is the max size for the MTU, try to read more unless the caller
# specifically asked not to do that
attribute_value = response.attribute_value
if not no_long_read and len(attribute_value) == self.mtu - 1:
logger.debug('using READ BLOB to get the rest of the value')
offset = len(attribute_value)
while True:
response = await self.send_request(
ATT_Read_Blob_Request(attribute_handle = attribute_handle, value_offset = offset)
)
if response is None:
raise TimeoutError('read timeout')
if response.op_code == ATT_ERROR_RESPONSE:
if response.error_code == ATT_ATTRIBUTE_NOT_LONG_ERROR or response.error_code == ATT_INVALID_OFFSET_ERROR:
break
raise ProtocolError(
response.error_code,
'att',
ATT_PDU.error_name(response.error_code),
response
)
part = response.part_attribute_value
attribute_value += part
if len(part) < self.mtu - 1:
break
offset += len(part)
# Return the value as bytes
return attribute_value
async def read_characteristics_by_uuid(self, uuid, service):
'''
See Vol 3, Part G - 4.8.2 Read Using Characteristic UUID
'''
if service is None:
starting_handle = 0x0001
ending_handle = 0xFFFF
else:
starting_handle = service.handle
ending_handle = service.end_group_handle
characteristics_values = []
while starting_handle <= ending_handle:
response = await self.send_request(
ATT_Read_By_Type_Request(
starting_handle = starting_handle,
ending_handle = ending_handle,
attribute_type = uuid
)
)
if response is None:
# TODO raise appropriate exception
return []
# Check if we reached the end of the iteration
if response.op_code == ATT_ERROR_RESPONSE:
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
# Unexpected end
logger.warning(f'!!! unexpected error while reading characteristics: {HCI_Constant.error_name(response.error_code)}')
# TODO raise appropriate exception
return []
break
# Stop if for some reason the list was empty
if not response.attributes:
break
# Process all characteristics returned in this iteration
for attribute_handle, attribute_value in response.attributes:
if attribute_handle < starting_handle:
# Something's not right
logger.warning(f'bogus handle value: {attribute_handle}')
return []
characteristics_values.append(attribute_value)
# Move on to the next characteristics
starting_handle = response.attributes[-1][0] + 1
return characteristics_values
async def write_value(self, attribute, value, with_response=False):
'''
See Vol 3, Part G - 4.9.1 Write Without Response & 4.9.3 Write Characteristic Value
`attribute` can be an Attribute object, or a handle value
'''
# Send a request or command to write
attribute_handle = attribute if type(attribute) is int else attribute.handle
if with_response:
response = await self.send_request(
ATT_Write_Request(
attribute_handle = attribute_handle,
attribute_value = value
)
)
if response.op_code == ATT_ERROR_RESPONSE:
raise ProtocolError(
response.error_code,
'att',
ATT_PDU.error_name(response.error_code), response
)
else:
await self.send_command(
ATT_Write_Command(
attribute_handle = attribute_handle,
attribute_value = value
)
)
def on_gatt_pdu(self, att_pdu):
logger.debug(f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}')
if att_pdu.op_code in ATT_RESPONSES:
if self.pending_request is None:
# Not expected!
logger.warning('!!! unexpected response, there is no pending request')
return
# Sanity check: the response should match the pending request unless it is an error response
if att_pdu.op_code != ATT_ERROR_RESPONSE:
expected_response_name = self.pending_request.name.replace('_REQUEST', '_RESPONSE')
if att_pdu.name != expected_response_name:
logger.warning(f'!!! mismatched response: expected {expected_response_name}')
return
# Return the response to the coroutine that is waiting for it
self.pending_response.set_result(att_pdu)
else:
handler_name = f'on_{att_pdu.name.lower()}'
handler = getattr(self, handler_name, None)
if handler is not None:
handler(att_pdu)
else:
logger.warning(f'{color(f"--- Ignoring GATT Response from [0x{self.connection.handle:04X}]:", "red")} {att_pdu}')
def on_att_handle_value_notification(self, notification):
# Call all subscribers
subscribers = self.notification_subscribers.get(notification.attribute_handle, [])
if not subscribers:
logger.warning('!!! received notification with no subscriber')
for subscriber in subscribers:
subscriber(notification.attribute_value)
def on_att_handle_value_indication(self, indication):
# Call all subscribers
subscribers = self.indication_subscribers.get(indication.attribute_handle, [])
if not subscribers:
logger.warning('!!! received indication with no subscriber')
for subscriber in subscribers:
subscriber(indication.attribute_value)
# Confirm that we received the indication
self.send_confirmation(ATT_Handle_Value_Confirmation())

698
bumble/gatt_server.py Normal file
View File

@@ -0,0 +1,698 @@
# 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.
# -----------------------------------------------------------------------------
# GATT - Generic Attribute Profile
# Server
#
# See Bluetooth spec @ Vol 3, Part G
#
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
import logging
from collections import defaultdict
from pyee import EventEmitter
from colors import color
from .core import *
from .hci import *
from .att import *
from .gatt import *
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# GATT Server
# -----------------------------------------------------------------------------
class Server(EventEmitter):
def __init__(self, device):
super().__init__()
self.device = device
self.attributes = [] # Attributes, ordered by increasing handle values
self.attributes_by_handle = {} # Map for fast attribute access by handle
self.max_mtu = 23 # FIXME: 517 # The max MTU we're willing to negotiate
self.subscribers = {} # Map of subscriber states by connection handle and attribute handle
self.mtus = {} # Map of ATT MTU values by connection handle
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
self.pending_confirmations = defaultdict(lambda: None)
def send_gatt_pdu(self, connection_handle, pdu):
self.device.send_l2cap_pdu(connection_handle, ATT_CID, pdu)
def next_handle(self):
return 1 + len(self.attributes)
def get_attribute(self, handle):
attribute = self.attributes_by_handle.get(handle)
if attribute:
return attribute
# Not in the cached map, perform a linear lookup
for attribute in self.attributes:
if attribute.handle == handle:
# Store in cached map
self.attributes_by_handle[handle] = attribute
return attribute
return None
def add_attribute(self, attribute):
# Assign a handle to this attribute
attribute.handle = self.next_handle()
attribute.end_group_handle = attribute.handle # TODO: keep track of descriptors in the group
# Add this attribute to the list
self.attributes.append(attribute)
def add_service(self, service):
# Add the service attribute to the DB
self.add_attribute(service)
# TODO: add included services
# Add all characteristics
for characteristic in service.characteristics:
# Add a Characteristic Declaration (Vol 3, Part G - 3.3.1 Characteristic Declaration)
declaration_bytes = struct.pack(
'<BH',
characteristic.properties,
self.next_handle() + 1, # The value will be the next attribute after this declaration
) + characteristic.uuid.to_pdu_bytes()
characteristic_declaration = Attribute(
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
Attribute.READABLE,
declaration_bytes
)
self.add_attribute(characteristic_declaration)
# Add the characteristic value
self.add_attribute(characteristic)
# Add the descriptors
for descriptor in characteristic.descriptors:
self.add_attribute(descriptor)
# If the characteristic supports subscriptions, add a CCCD descriptor
# unless there is one already
if (
characteristic.properties & (Characteristic.NOTIFY | Characteristic.INDICATE) and
characteristic.get_descriptor(GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR) is None
):
self.add_attribute(
Descriptor(
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
Attribute.READABLE | Attribute.WRITEABLE,
CharacteristicValue(
read=lambda connection, characteristic=characteristic: self.read_cccd(connection, characteristic),
write=lambda connection, value, characteristic=characteristic: self.write_cccd(connection, characteristic, value)
)
)
)
# Update the service and characteristic group ends
characteristic_declaration.end_group_handle = self.attributes[-1].handle
characteristic.end_group_handle = self.attributes[-1].handle
# Update the service group end
service.end_group_handle = self.attributes[-1].handle
def add_services(self, services):
for service in services:
self.add_service(service)
def read_cccd(self, connection, characteristic):
if connection is None:
return bytes([0, 0])
subscribers = self.subscribers.get(connection.handle)
cccd = None
if subscribers:
cccd = subscribers.get(characteristic.handle)
return cccd or bytes([0, 0])
def write_cccd(self, connection, characteristic, value):
logger.debug(f'Subscription update for connection={connection.handle:04X}, handle={characteristic.handle:04X}: {value.hex()}')
# Sanity check
if len(value) != 2:
logger.warn('CCCD value not 2 bytes long')
return
cccds = self.subscribers.setdefault(connection.handle, {})
cccds[characteristic.handle] = value
logger.debug(f'CCCDs: {cccds}')
notify_enabled = (value[0] & 0x01 != 0)
indicate_enabled = (value[0] & 0x02 != 0)
characteristic.emit('subscription', connection, notify_enabled, indicate_enabled)
self.emit('characteristic_subscription', connection, characteristic, notify_enabled, indicate_enabled)
def send_response(self, connection, response):
logger.debug(f'GATT Response from server: [0x{connection.handle:04X}] {response}')
self.send_gatt_pdu(connection.handle, response.to_bytes())
async def notify_subscriber(self, connection, attribute, force=False):
# Check if there's a subscriber
if not force:
subscribers = self.subscribers.get(connection.handle)
if not subscribers:
logger.debug('not notifying, no subscribers')
return
cccd = subscribers.get(attribute.handle)
if not cccd:
logger.debug(f'not notifying, no subscribers for handle {attribute.handle:04X}')
return
if len(cccd) != 2 or (cccd[0] & 0x01 == 0):
logger.debug(f'not notifying, cccd={cccd.hex()}')
return
# Get the value
value = attribute.read_value(connection)
# Truncate if needed
mtu = self.get_mtu(connection)
if len(value) > mtu - 3:
value = value[:mtu - 3]
# Notify
notification = ATT_Handle_Value_Notification(
attribute_handle = attribute.handle,
attribute_value = value
)
logger.debug(f'GATT Notify from server: [0x{connection.handle:04X}] {notification}')
self.send_gatt_pdu(connection.handle, notification.to_bytes())
async def notify_subscribers(self, attribute, force=False):
# Get all the connections for which there's at least one subscription
connections = [
connection for connection in [
self.device.lookup_connection(connection_handle)
for (connection_handle, subscribers) in self.subscribers.items()
if force or subscribers.get(attribute.handle)
]
if connection is not None
]
# Notify for each connection
if connections:
await asyncio.wait([
self.notify_subscriber(connection, attribute, force)
for connection in connections
])
async def indicate_subscriber(self, connection, attribute, force=False):
# Check if there's a subscriber
if not force:
subscribers = self.subscribers.get(connection.handle)
if not subscribers:
logger.debug('not indicating, no subscribers')
return
cccd = subscribers.get(attribute.handle)
if not cccd:
logger.debug(f'not indicating, no subscribers for handle {attribute.handle:04X}')
return
if len(cccd) != 2 or (cccd[0] & 0x02 == 0):
logger.debug(f'not indicating, cccd={cccd.hex()}')
return
# Get the value
value = attribute.read_value(connection)
# Truncate if needed
mtu = self.get_mtu(connection)
if len(value) > mtu - 3:
value = value[:mtu - 3]
# Indicate
indication = ATT_Handle_Value_Indication(
attribute_handle = attribute.handle,
attribute_value = value
)
logger.debug(f'GATT Indicate from server: [0x{connection.handle:04X}] {indication}')
# Wait until we can send (only one pending indication at a time per connection)
async with self.indication_semaphores[connection.handle]:
assert(self.pending_confirmations[connection.handle] is None)
# Create a future value to hold the eventual response
self.pending_confirmations[connection.handle] = asyncio.get_running_loop().create_future()
try:
self.send_gatt_pdu(connection.handle, indication.to_bytes())
await asyncio.wait_for(self.pending_confirmations[connection.handle], GATT_REQUEST_TIMEOUT)
except asyncio.TimeoutError:
logger.warning(color('!!! GATT Indicate timeout', 'red'))
raise TimeoutError(f'GATT timeout for {indication.name}')
finally:
self.pending_confirmations[connection.handle] = None
async def indicate_subscribers(self, attribute):
# Get all the connections for which there's at least one subscription
connections = [
connection for connection in [
self.device.lookup_connection(connection_handle)
for (connection_handle, subscribers) in self.subscribers.items()
if subscribers.get(attribute.handle)
]
if connection is not None
]
# Indicate for each connection
if connections:
await asyncio.wait([
self.indicate_subscriber(connection, attribute)
for connection in connections
])
def on_disconnection(self, connection):
if connection.handle in self.mtus:
del self.mtus[connection.handle]
if connection.handle in self.subscribers:
del self.subscribers[connection.handle]
if connection.handle in self.indication_semaphores:
del self.indication_semaphores[connection.handle]
if connection.handle in self.pending_confirmations:
del self.pending_confirmations[connection.handle]
def on_gatt_pdu(self, connection, att_pdu):
logger.debug(f'GATT Request to server: [0x{connection.handle:04X}] {att_pdu}')
handler_name = f'on_{att_pdu.name.lower()}'
handler = getattr(self, handler_name, None)
if handler is not None:
try:
handler(connection, att_pdu)
except ATT_Error as error:
logger.debug(f'normal exception returned by handler: {error}')
response = ATT_Error_Response(
request_opcode_in_error = att_pdu.op_code,
attribute_handle_in_error = error.att_handle,
error_code = error.error_code
)
self.send_response(connection, response)
except Exception as error:
logger.warning(f'{color("!!! Exception in handler:", "red")} {error}')
response = ATT_Error_Response(
request_opcode_in_error = att_pdu.op_code,
attribute_handle_in_error = 0x0000,
error_code = ATT_UNLIKELY_ERROR_ERROR
)
self.send_response(connection, response)
raise error
else:
# No specific handler registered
if att_pdu.op_code in ATT_REQUESTS:
# Invoke the generic handler
self.on_att_request(connection, att_pdu)
else:
# Just ignore
logger.warning(f'{color("--- Ignoring GATT Request from [0x{connection.handle:04X}]:", "red")} {att_pdu}')
def get_mtu(self, connection):
return self.mtus.get(connection.handle, ATT_DEFAULT_MTU)
#######################################################
# ATT handlers
#######################################################
def on_att_request(self, connection, pdu):
'''
Handler for requests without a more specific handler
'''
logger.warning(f'{color(f"--- Unsupported ATT Request from [0x{connection.handle:04X}]:", "red")} {pdu}')
response = ATT_Error_Response(
request_opcode_in_error = pdu.op_code,
attribute_handle_in_error = 0x0000,
error_code = ATT_REQUEST_NOT_SUPPORTED_ERROR
)
self.send_response(connection, response)
def on_att_exchange_mtu_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.2.1 Exchange MTU Request
'''
mtu = max(ATT_DEFAULT_MTU, min(self.max_mtu, request.client_rx_mtu))
self.mtus[connection.handle] = mtu
self.send_response(connection, ATT_Exchange_MTU_Response(server_rx_mtu = mtu))
# Notify the device
self.device.on_connection_att_mtu_update(connection.handle, mtu)
def on_att_find_information_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.3.1 Find Information Request
'''
# Check the request parameters
if request.starting_handle == 0 or request.starting_handle > request.ending_handle:
self.send_response(connection, ATT_Error_Response(
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.starting_handle,
error_code = ATT_INVALID_HANDLE_ERROR
))
return
# Build list of returned attributes
pdu_space_available = self.get_mtu(connection) - 2
attributes = []
uuid_size = 0
for attribute in (
attribute for attribute in self.attributes if
attribute.handle >= request.starting_handle and
attribute.handle <= request.ending_handle
):
# TODO: check permissions
this_uuid_size = len(attribute.type.to_pdu_bytes())
if attributes:
# Check if this attribute has the same type size as the previous one
if this_uuid_size != uuid_size:
break
# Check if there's enough space for one more entry
uuid_size = this_uuid_size
if pdu_space_available < 2 + uuid_size:
break
# Add the attribute to the list
attributes.append(attribute)
pdu_space_available -= 2 + uuid_size
# Return the list of attributes
if attributes:
information_data_list = [
struct.pack('<H', attribute.handle) + attribute.type.to_pdu_bytes()
for attribute in attributes
]
response = ATT_Find_Information_Response(
format = 1 if len(attributes[0].type.to_pdu_bytes()) == 2 else 2,
information_data = b''.join(information_data_list)
)
else:
response = ATT_Error_Response(
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.starting_handle,
error_code = ATT_ATTRIBUTE_NOT_FOUND_ERROR
)
self.send_response(connection, response)
def on_att_find_by_type_value_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.3.3 Find By Type Value Request
'''
# Build list of returned attributes
pdu_space_available = self.get_mtu(connection) - 2
attributes = []
for attribute in (
attribute for attribute in self.attributes if
attribute.handle >= request.starting_handle and
attribute.handle <= request.ending_handle and
attribute.type == request.attribute_type and
attribute.read_value(connection) == request.attribute_value and
pdu_space_available >= 4
):
# TODO: check permissions
# Add the attribute to the list
attributes.append(attribute)
pdu_space_available -= 4
# Return the list of attributes
if attributes:
handles_information_list = []
for attribute in attributes:
if attribute.type in {
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
}:
# Part of a group
group_end_handle = attribute.end_group_handle
else:
# Not part of a group
group_end_handle = attribute.handle
handles_information_list.append(struct.pack('<HH', attribute.handle, group_end_handle))
response = ATT_Find_By_Type_Value_Response(
handles_information_list = b''.join(handles_information_list)
)
else:
response = ATT_Error_Response(
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.starting_handle,
error_code = ATT_ATTRIBUTE_NOT_FOUND_ERROR
)
self.send_response(connection, response)
def on_att_read_by_type_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.4.1 Read By Type Request
'''
mtu = self.get_mtu(connection)
pdu_space_available = mtu - 2
attributes = []
for attribute in (
attribute for attribute in self.attributes if
attribute.type == request.attribute_type and
attribute.handle >= request.starting_handle and
attribute.handle <= request.ending_handle and
pdu_space_available
):
# TODO: check permissions
# Check the attribute value size
attribute_value = attribute.read_value(connection)
max_attribute_size = min(mtu - 4, 253)
if len(attribute_value) > max_attribute_size:
# We need to truncate
attribute_value = attribute_value[:max_attribute_size]
if attributes and len(attributes[0][1]) != len(attribute_value):
# Not the same size as previous attribute, stop here
break
# Check if there is enough space
entry_size = 2 + len(attribute_value)
if pdu_space_available < entry_size:
break
# Add the attribute to the list
attributes.append((attribute.handle, attribute_value))
pdu_space_available -= entry_size
if attributes:
attribute_data_list = [struct.pack('<H', handle) + value for handle, value in attributes]
response = ATT_Read_By_Type_Response(
length = entry_size,
attribute_data_list = b''.join(attribute_data_list)
)
else:
response = ATT_Error_Response(
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.starting_handle,
error_code = ATT_ATTRIBUTE_NOT_FOUND_ERROR
)
self.send_response(connection, response)
def on_att_read_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.4.3 Read Request
'''
if attribute := self.get_attribute(request.attribute_handle):
# TODO: check permissions
value = attribute.read_value(connection)
value_size = min(self.get_mtu(connection) - 1, len(value))
response = ATT_Read_Response(
attribute_value = value[:value_size]
)
else:
response = ATT_Error_Response(
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.attribute_handle,
error_code = ATT_INVALID_HANDLE_ERROR
)
self.send_response(connection, response)
def on_att_read_blob_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.4.5 Read Blob Request
'''
if attribute := self.get_attribute(request.attribute_handle):
# TODO: check permissions
mtu = self.get_mtu(connection)
value = attribute.read_value(connection)
if request.value_offset > len(value):
response = ATT_Error_Response(
request_opcode_in_error = request.op_code,
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,
attribute_handle_in_error = request.attribute_handle,
error_code = ATT_ATTRIBUTE_NOT_LONG_ERROR
)
else:
part_size = min(mtu - 1, len(value) - request.value_offset)
response = ATT_Read_Blob_Response(
part_attribute_value = value[request.value_offset:request.value_offset + part_size]
)
else:
response = ATT_Error_Response(
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.attribute_handle,
error_code = ATT_INVALID_HANDLE_ERROR
)
self.send_response(connection, response)
def on_att_read_by_group_type_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.4.9 Read by Group Type Request
'''
if request.attribute_group_type not in {
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
GATT_INCLUDE_ATTRIBUTE_TYPE
}:
response = ATT_Error_Response(
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.starting_handle,
error_code = ATT_UNSUPPORTED_GROUP_TYPE_ERROR
)
self.send_response(connection, response)
return
mtu = self.get_mtu(connection)
pdu_space_available = mtu - 2
attributes = []
for attribute in (
attribute for attribute in self.attributes if
attribute.type == request.attribute_group_type and
attribute.handle >= request.starting_handle and
attribute.handle <= request.ending_handle and
pdu_space_available
):
# Check the attribute value size
attribute_value = attribute.read_value(connection)
max_attribute_size = min(mtu - 6, 251)
if len(attribute_value) > max_attribute_size:
# We need to truncate
attribute_value = attribute_value[:max_attribute_size]
if attributes and len(attributes[0][2]) != len(attribute_value):
# Not the same size as previous attributes, stop here
break
# Check if there is enough space
entry_size = 4 + len(attribute_value)
if pdu_space_available < entry_size:
break
# Add the attribute to the list
attributes.append((attribute.handle, attribute.end_group_handle, attribute_value))
pdu_space_available -= entry_size
if attributes:
attribute_data_list = [
struct.pack('<HH', handle, end_group_handle) + value
for handle, end_group_handle, value in attributes
]
response = ATT_Read_By_Group_Type_Response(
length = len(attribute_data_list[0]),
attribute_data_list = b''.join(attribute_data_list)
)
else:
response = ATT_Error_Response(
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.starting_handle,
error_code = ATT_ATTRIBUTE_NOT_FOUND_ERROR
)
self.send_response(connection, response)
def on_att_write_request(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.5.1 Write Request
'''
# Check that the attribute exists
attribute = self.get_attribute(request.attribute_handle)
if attribute is None:
self.send_response(connection, ATT_Error_Response(
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.attribute_handle,
error_code = ATT_INVALID_HANDLE_ERROR
))
return
# TODO: check permissions
# Check the request parameters
if len(request.attribute_value) > GATT_MAX_ATTRIBUTE_VALUE_SIZE:
self.send_response(connection, ATT_Error_Response(
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.attribute_handle,
error_code = ATT_INVALID_ATTRIBUTE_LENGTH_ERROR
))
return
# Accept the value
attribute.write_value(connection, request.attribute_value)
# Done
self.send_response(connection, ATT_Write_Response())
def on_att_write_command(self, connection, request):
'''
See Bluetooth spec Vol 3, Part F - 3.4.5.3 Write Command
'''
# Check that the attribute exists
attribute = self.get_attribute(request.attribute_handle)
if attribute is None:
return
# TODO: check permissions
# Check the request parameters
if len(request.attribute_value) > GATT_MAX_ATTRIBUTE_VALUE_SIZE:
return
# Accept the value
try:
attribute.write_value(connection, request.attribute_value)
except Exception as error:
logger.warning(f'!!! ignoring exception: {error}')
def on_att_handle_value_confirmation(self, connection, confirmation):
'''
See Bluetooth spec Vol 3, Part F - 3.4.7.3 Handle Value Confirmation
'''
if self.pending_confirmations[connection.handle] is None:
# Not expected!
logger.warning('!!! unexpected confirmation, there is no pending indication')
return
self.pending_confirmations[connection.handle].set_result(None)

3471
bumble/hci.py Normal file

File diff suppressed because it is too large Load Diff

179
bumble/helpers.py Normal file
View File

@@ -0,0 +1,179 @@
# 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 logging
from colors import color
from .core import name_or_number
from .gatt import ATT_PDU, ATT_CID
from .l2cap import (
L2CAP_PDU,
L2CAP_CONNECTION_REQUEST,
L2CAP_CONNECTION_RESPONSE,
L2CAP_SIGNALING_CID,
L2CAP_LE_SIGNALING_CID,
L2CAP_Control_Frame,
L2CAP_Connection_Response
)
from .hci import (
HCI_EVENT_PACKET,
HCI_ACL_DATA_PACKET,
HCI_DISCONNECTION_COMPLETE_EVENT,
HCI_AclDataPacketAssembler
)
from .rfcomm import RFCOMM_Frame, RFCOMM_PSM
from .sdp import SDP_PDU, SDP_PSM
from .avdtp import (
MessageAssembler as AVDTP_MessageAssembler,
AVDTP_PSM
)
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
PSM_NAMES = {
RFCOMM_PSM: 'RFCOMM',
SDP_PSM: 'SDP',
AVDTP_PSM: 'AVDTP'
# TODO: add more PSM values
}
# -----------------------------------------------------------------------------
class PacketTracer:
class AclStream:
def __init__(self, analyzer):
self.analyzer = analyzer
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
self.psms = {} # PSM, by source_cid
self.peer = None # ACL stream in the other direction
def on_acl_pdu(self, pdu):
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
if l2cap_pdu.cid == ATT_CID:
att_pdu = ATT_PDU.from_bytes(l2cap_pdu.payload)
self.analyzer.emit(att_pdu)
elif l2cap_pdu.cid == L2CAP_SIGNALING_CID or l2cap_pdu.cid == L2CAP_LE_SIGNALING_CID:
control_frame = L2CAP_Control_Frame.from_bytes(l2cap_pdu.payload)
self.analyzer.emit(control_frame)
# Check if this signals a new channel
if control_frame.code == L2CAP_CONNECTION_REQUEST:
self.psms[control_frame.source_cid] = control_frame.psm
elif control_frame.code == L2CAP_CONNECTION_RESPONSE:
if control_frame.result == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL:
if self.peer:
if psm := self.peer.psms.get(control_frame.source_cid):
# Found a pending connection
self.psms[control_frame.destination_cid] = psm
# For AVDTP connections, create a packet assembler for each direction
if psm == AVDTP_PSM:
self.avdtp_assemblers[control_frame.source_cid] = AVDTP_MessageAssembler(self.on_avdtp_message)
self.peer.avdtp_assemblers[control_frame.destination_cid] = AVDTP_MessageAssembler(self.peer.on_avdtp_message)
else:
# Try to find the PSM associated with this PDU
if self.peer and (psm := self.peer.psms.get(l2cap_pdu.cid)):
if psm == SDP_PSM:
sdp_pdu = SDP_PDU.from_bytes(l2cap_pdu.payload)
self.analyzer.emit(sdp_pdu)
elif psm == RFCOMM_PSM:
rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
self.analyzer.emit(rfcomm_frame)
elif psm == AVDTP_PSM:
self.analyzer.emit(f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, PSM=AVDTP]: {l2cap_pdu.payload.hex()}')
assembler = self.avdtp_assemblers.get(l2cap_pdu.cid)
if assembler:
assembler.on_pdu(l2cap_pdu.payload)
else:
psm_string = name_or_number(PSM_NAMES, psm)
self.analyzer.emit(f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, PSM={psm_string}]: {l2cap_pdu.payload.hex()}')
else:
self.analyzer.emit(l2cap_pdu)
def on_avdtp_message(self, transaction_label, message):
self.analyzer.emit(f'{color("AVDTP", "green")} [{transaction_label}] {message}')
def feed_packet(self, packet):
self.packet_assembler.feed_packet(packet)
class Analyzer:
def __init__(self, label, emit_message):
self.label = label
self.emit_message = emit_message
self.acl_streams = {} # ACL streams, by connection handle
self.peer = None # Analyzer in the other direction
def start_acl_stream(self, connection_handle):
logger.info(f'[{self.label}] +++ Creating ACL stream for connection 0x{connection_handle:04X}')
stream = PacketTracer.AclStream(self)
self.acl_streams[connection_handle] = stream
# Associate with a peer stream if we can
if peer_stream := self.peer.acl_streams.get(connection_handle):
stream.peer = peer_stream
peer_stream.peer = stream
return stream
def end_acl_stream(self, connection_handle):
if connection_handle in self.acl_streams:
logger.info(f'[{self.label}] --- Removing ACL stream for connection 0x{connection_handle:04X}')
del self.acl_streams[connection_handle]
# Let the other forwarder know so it can cleanup its stream as well
self.peer.end_acl_stream(connection_handle)
def on_packet(self, packet):
self.emit(packet)
if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
# Look for an existing stream for this handle, create one if it is the
# first ACL packet for that connection handle
if (stream := self.acl_streams.get(packet.connection_handle)) is None:
stream = self.start_acl_stream(packet.connection_handle)
stream.feed_packet(packet)
elif packet.hci_packet_type == HCI_EVENT_PACKET:
if packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT:
self.end_acl_stream(packet.connection_handle)
def emit(self, message):
self.emit_message(f'[{self.label}] {message}')
def trace(self, packet, direction=0):
if direction == 0:
self.host_to_controller_analyzer.on_packet(packet)
else:
self.controller_to_host_analyzer.on_packet(packet)
def __init__(
self,
host_to_controller_label=color('HOST->CONTROLLER', 'blue'),
controller_to_host_label=color('CONTROLLER->HOST', 'cyan'),
emit_message=logger.info
):
self.host_to_controller_analyzer = PacketTracer.Analyzer(host_to_controller_label, emit_message)
self.controller_to_host_analyzer = PacketTracer.Analyzer(controller_to_host_label, emit_message)
self.host_to_controller_analyzer.peer = self.controller_to_host_analyzer
self.controller_to_host_analyzer.peer = self.host_to_controller_analyzer

94
bumble/hfp.py Normal file
View File

@@ -0,0 +1,94 @@
# 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 logging
import asyncio
import collections
from colors import color
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Protocol Support
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
class HfpProtocol:
def __init__(self, dlc):
self.dlc = dlc
self.buffer = ''
self.lines = collections.deque()
self.lines_available = asyncio.Event()
dlc.sink = self.feed
def feed(self, data):
# Convert the data to a string if needed
if type(data) == bytes:
data = data.decode('utf-8')
logger.debug(f'<<< Data received: {data}')
# Add to the buffer and look for lines
self.buffer += data
while (separator := self.buffer.find('\r')) >= 0:
line = self.buffer[:separator].strip()
self.buffer = self.buffer[separator + 1:]
if len(line) > 0:
self.on_line(line)
def on_line(self, line):
self.lines.append(line)
self.lines_available.set()
def send_command_line(self, line):
logger.debug(color(f'>>> {line}', 'yellow'))
self.dlc.write(line + '\r')
def send_response_line(self, line):
logger.debug(color(f'>>> {line}', 'yellow'))
self.dlc.write('\r\n' + line + '\r\n')
async def next_line(self):
await self.lines_available.wait()
line = self.lines.popleft()
if not self.lines:
self.lines_available.clear()
logger.debug(color(f'<<< {line}', 'green'))
return line
async def initialize_service(self):
# Perform Service Level Connection Initialization
self.send_command_line('AT+BRSF=2072') # Retrieve Supported Features
line = await(self.next_line())
line = await(self.next_line())
self.send_command_line('AT+CIND=?')
line = await(self.next_line())
line = await(self.next_line())
self.send_command_line('AT+CIND?')
line = await(self.next_line())
line = await(self.next_line())
self.send_command_line('AT+CMER=3,0,0,1')
line = await(self.next_line())

604
bumble/host.py Normal file
View File

@@ -0,0 +1,604 @@
# 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 logging
from pyee import EventEmitter
from colors import color
from .hci import *
from .l2cap import *
from .att import *
from .gatt import *
from .smp import *
from .core import ConnectionParameters
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
HOST_DEFAULT_HC_LE_ACL_DATA_PACKET_LENGTH = 27
HOST_HC_TOTAL_NUM_LE_ACL_DATA_PACKETS = 1
HOST_DEFAULT_HC_ACL_DATA_PACKET_LENGTH = 27
HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
# -----------------------------------------------------------------------------
class Connection:
def __init__(self, host, handle, role, peer_address):
self.host = host
self.handle = handle
self.role = role
self.peer_address = peer_address
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
def on_hci_acl_data_packet(self, packet):
self.assembler.feed_packet(packet)
def on_acl_pdu(self, pdu):
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
if l2cap_pdu.cid == ATT_CID:
self.host.on_gatt_pdu(self, l2cap_pdu.payload)
elif l2cap_pdu.cid == SMP_CID:
self.host.on_smp_pdu(self, l2cap_pdu.payload)
else:
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
# -----------------------------------------------------------------------------
class Host(EventEmitter):
def __init__(self, controller_source = None, controller_sink = None):
super().__init__()
self.hci_sink = None
self.ready = False # True when we can accept incoming packets
self.connections = {} # Connections, by connection handle
self.pending_command = None
self.pending_response = None
self.hc_le_acl_data_packet_length = HOST_DEFAULT_HC_LE_ACL_DATA_PACKET_LENGTH
self.hc_total_num_le_acl_data_packets = HOST_HC_TOTAL_NUM_LE_ACL_DATA_PACKETS
self.hc_acl_data_packet_length = HOST_DEFAULT_HC_ACL_DATA_PACKET_LENGTH
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_supported_commands = bytes(64)
self.command_semaphore = asyncio.Semaphore(1)
self.long_term_key_provider = None
self.link_key_provider = None
# Connect to the source and sink if specified
if controller_source:
controller_source.set_packet_sink(self)
if controller_sink:
self.set_packet_sink(controller_sink)
async def reset(self):
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())
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}')
self.reset_done = True
@property
def controller(self):
return self.hci_sink
@controller.setter
def controller(self, controller):
self.set_packet_sink(controller)
if controller:
controller.set_packet_sink(self)
def set_packet_sink(self, sink):
self.hci_sink = sink
def send_hci_packet(self, packet):
self.hci_sink.on_packet(packet.to_bytes())
async def send_command(self, command):
logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
# Wait until we can send (only one pending command at a time)
async with self.command_semaphore:
assert(self.pending_command is None)
assert(self.pending_response is None)
# Create a future value to hold the eventual response
self.pending_response = asyncio.get_running_loop().create_future()
self.pending_command = command
try:
self.send_hci_packet(command)
response = await self.pending_response
# TODO: check error values
return response
except Exception as error:
logger.warning(f'{color("!!! Exception while sending HCI packet:", "red")} {error}')
# raise error
finally:
self.pending_command = None
self.pending_response = None
# Use this method to send a command from a task
def send_command_sync(self, command):
async def send_command(command):
await self.send_command(command)
asyncio.create_task(send_command(command))
def send_l2cap_pdu(self, connection_handle, cid, pdu):
l2cap_pdu = L2CAP_PDU(cid, pdu).to_bytes()
# Send the data to the controller via ACL packets
bytes_remaining = len(l2cap_pdu)
offset = 0
pb_flag = 0
while bytes_remaining:
data_total_length = min(bytes_remaining, self.hc_le_acl_data_packet_length)
acl_packet = HCI_AclDataPacket(
connection_handle = connection_handle,
pb_flag = pb_flag,
bc_flag = 0,
data_total_length = data_total_length,
data = l2cap_pdu[offset:offset + data_total_length]
)
logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: (CID={cid}) {acl_packet}')
self.queue_acl_packet(acl_packet)
pb_flag = 1
offset += data_total_length
bytes_remaining -= data_total_length
def queue_acl_packet(self, acl_packet):
self.acl_packet_queue.appendleft(acl_packet)
self.check_acl_packet_queue()
if len(self.acl_packet_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):
# Send all we can
while len(self.acl_packet_queue) > 0 and self.acl_packets_in_flight < self.hc_total_num_le_acl_data_packets:
packet = self.acl_packet_queue.pop()
self.send_hci_packet(packet)
self.acl_packets_in_flight += 1
# Packet Sink protocol (packets coming from the controller via HCI)
def on_packet(self, packet):
hci_packet = HCI_Packet.from_bytes(packet)
if self.ready or (
hci_packet.hci_packet_type == HCI_EVENT_PACKET and
hci_packet.event_code == HCI_COMMAND_COMPLETE_EVENT and
hci_packet.command_opcode == HCI_RESET_COMMAND
):
self.on_hci_packet(hci_packet)
else:
logger.debug('reset not done, ignoring packet from controller')
def on_hci_packet(self, packet):
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
# If the packet is a command, invoke the handler for this packet
if packet.hci_packet_type == HCI_COMMAND_PACKET:
self.on_hci_command_packet(packet)
elif packet.hci_packet_type == HCI_EVENT_PACKET:
self.on_hci_event_packet(packet)
elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
self.on_hci_acl_data_packet(packet)
else:
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
def on_hci_command_packet(self, command):
logger.warning(f'!!! unexpected command packet: {command}')
def on_hci_event_packet(self, event):
handler_name = f'on_{event.name.lower()}'
handler = getattr(self, handler_name, self.on_hci_event)
handler(event)
def on_hci_acl_data_packet(self, packet):
# Look for the connection to which this data belongs
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)
def on_command_processed(self, event):
if self.pending_response:
# Check that it is what we were expecting
if self.pending_command.op_code != event.command_opcode:
logger.warning(f'!!! command result mismatch, expected 0x{self.pending_command.op_code:X} but got 0x{event.command_opcode:X}')
self.pending_response.set_result(event)
else:
logger.warning('!!! no pending response future to set')
############################################################
# HCI handlers
############################################################
def on_hci_event(self, event):
logger.warning(f'{color(f"--- Ignoring event {event}", "red")}')
def on_hci_command_complete_event(self, event):
if event.command_opcode == 0:
# This is used just for the Num_HCI_Command_Packets field, not related to an actual command
logger.debug('no-command event')
else:
return self.on_command_processed(event)
def on_hci_command_status_event(self, event):
return self.on_command_processed(event)
def on_hci_number_of_completed_packets_event(self, event):
total_packets = sum(event.num_completed_packets)
if total_packets <= self.acl_packets_in_flight:
self.acl_packets_in_flight -= total_packets
self.check_acl_packet_queue()
else:
logger.warning(color(f'!!! {total_packets} completed but only {self.acl_packets_in_flight} in flight'))
self.acl_packets_in_flight = 0
# Classic only
def on_hci_connection_request_event(self, event):
# For now, just accept everything
# TODO: delegate the decision
self.send_command_sync(
HCI_Accept_Connection_Request_Command(
bd_addr = event.bd_addr,
role = 0x01 # Remain the peripheral
)
)
def on_hci_le_connection_complete_event(self, event):
# Check if this is a cancellation
if event.status == HCI_SUCCESS:
# Create/update the connection
logger.debug(f'### CONNECTION: [0x{event.connection_handle:04X}] {event.peer_address} as {HCI_Constant.role_name(event.role)}')
connection = self.connections.get(event.connection_handle)
if connection is None:
connection = Connection(self, event.connection_handle, event.role, event.peer_address)
self.connections[event.connection_handle] = connection
# Notify the client
connection_parameters = ConnectionParameters(
event.conn_interval,
event.conn_latency,
event.supervision_timeout
)
self.emit(
'connection',
event.connection_handle,
BT_LE_TRANSPORT,
event.peer_address,
None,
event.role,
connection_parameters
)
else:
logger.debug(f'### CONNECTION FAILED: {event.status}')
# Notify the listeners
self.emit('connection_failure', event.status)
def on_hci_le_enhanced_connection_complete_event(self, event):
# Just use the same implementation as for the non-enhanced event for now
self.on_hci_le_connection_complete_event(event)
def on_hci_connection_complete_event(self, event):
if event.status == HCI_SUCCESS:
# Create/update the connection
logger.debug(f'### BR/EDR CONNECTION: [0x{event.connection_handle:04X}] {event.bd_addr}')
connection = self.connections.get(event.connection_handle)
if connection is None:
connection = Connection(self, event.connection_handle, BT_CENTRAL_ROLE, event.bd_addr)
self.connections[event.connection_handle] = connection
# Notify the client
self.emit(
'connection',
event.connection_handle,
BT_BR_EDR_TRANSPORT,
event.bd_addr,
None,
BT_CENTRAL_ROLE,
None
)
else:
logger.debug(f'### BR/EDR CONNECTION FAILED: {event.status}')
# Notify the client
self.emit('connection_failure', event.status)
def on_hci_disconnection_complete_event(self, event):
# Find the connection
if (connection := self.connections.get(event.connection_handle)) is None:
logger.warning('!!! DISCONNECTION COMPLETE: unknown handle')
return
if event.status == HCI_SUCCESS:
logger.debug(f'### DISCONNECTION: [0x{event.connection_handle:04X}] {connection.peer_address} as {HCI_Constant.role_name(connection.role)}, reason={event.reason}')
del self.connections[event.connection_handle]
# Notify the listeners
self.emit('disconnection', event.connection_handle, event.reason)
else:
logger.debug(f'### DISCONNECTION FAILED: {event.status}')
# Notify the listeners
self.emit('disconnection_failure', event.status)
def on_hci_le_connection_update_complete_event(self, event):
if (connection := self.connections.get(event.connection_handle)) is None:
logger.warning('!!! CONNECTION PARAMETERS UPDATE COMPLETE: unknown handle')
return
# Notify the client
if event.status == HCI_SUCCESS:
connection_parameters = ConnectionParameters(
event.conn_interval,
event.conn_latency,
event.supervision_timeout
)
self.emit('connection_parameters_update', connection.handle, connection_parameters)
else:
self.emit('connection_parameters_update_failure', connection.handle, event.status)
def on_hci_le_phy_update_complete_event(self, event):
if (connection := self.connections.get(event.connection_handle)) is None:
logger.warning('!!! CONNECTION PHY UPDATE COMPLETE: unknown handle')
return
# Notify the client
if event.status == HCI_SUCCESS:
connection_phy = ConnectionPHY(event.tx_phy, event.rx_phy)
self.emit('connection_phy_update', connection.handle, connection_phy)
else:
self.emit('connection_phy_update_failure', connection.handle, event.status)
def on_hci_le_advertising_report_event(self, event):
for report in event.reports:
self.emit(
'advertising_report',
report.address,
report.data,
report.rssi,
report.event_type
)
def on_hci_le_remote_connection_parameter_request_event(self, event):
if event.connection_handle not in self.connections:
logger.warning('!!! REMOTE CONNECTION PARAMETER REQUEST: unknown handle')
return
# For now, just accept everything
# TODO: delegate the decision
self.send_command_sync(
HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(
connection_handle = event.connection_handle,
interval_min = event.interval_min,
interval_max = event.interval_max,
latency = event.latency,
timeout = event.timeout,
minimum_ce_length = 0,
maximum_ce_length = 0
)
)
def on_hci_le_long_term_key_request_event(self, event):
if (connection := self.connections.get(event.connection_handle)) is None:
logger.warning('!!! LE LONG TERM KEY REQUEST: unknown handle')
return
async def send_long_term_key():
if self.long_term_key_provider is None:
logger.debug('no long term key provider')
long_term_key = None
else:
long_term_key = await self.long_term_key_provider(
connection.handle,
event.random_number,
event.encryption_diversifier
)
if long_term_key:
response = HCI_LE_Long_Term_Key_Request_Reply_Command(
connection_handle = event.connection_handle,
long_term_key = long_term_key
)
else:
response = HCI_LE_Long_Term_Key_Request_Negative_Reply_Command(
connection_handle = event.connection_handle
)
await self.send_command(response)
asyncio.create_task(send_long_term_key())
def on_hci_synchronous_connection_complete_event(self, event):
pass
def on_hci_synchronous_connection_changed_event(self, event):
pass
def on_hci_role_change_event(self, event):
if event.status == HCI_SUCCESS:
logger.debug(f'role change for {event.bd_addr}: {HCI_Constant.role_name(event.new_role)}')
# TODO: lookup the connection and update the role
else:
logger.debug(f'role change for {event.bd_addr} failed: {HCI_Constant.error_name(event.status)}')
def on_hci_le_data_length_change_event(self, event):
self.emit(
'connection_data_length_change',
event.connection_handle,
event.max_tx_octets,
event.max_tx_time,
event.max_rx_octets,
event.max_rx_time
)
def on_hci_authentication_complete_event(self, event):
# Notify the client
if event.status == HCI_SUCCESS:
self.emit('connection_authentication_complete', event.connection_handle)
else:
self.emit('connection_authentication_failure', event.connection_handle, event.status)
def on_hci_encryption_change_event(self, event):
# Notify the client
if event.status == HCI_SUCCESS:
self.emit('connection_encryption_change', event.connection_handle, event.encryption_enabled)
else:
self.emit('connection_encryption_failure', event.connection_handle, event.status)
def on_hci_encryption_key_refresh_complete_event(self, event):
# Notify the client
if event.status == HCI_SUCCESS:
self.emit('connection_encryption_key_refresh', event.connection_handle)
else:
self.emit('connection_encryption_key_refresh_failure', event.connection_handle, event.status)
def on_hci_link_supervision_timeout_changed_event(self, event):
pass
def on_hci_max_slots_change_event(self, event):
pass
def on_hci_page_scan_repetition_mode_change_event(self, event):
pass
def on_hci_link_key_notification_event(self, event):
logger.debug(f'link key for {event.bd_addr}: {event.link_key.hex()}, type={HCI_Constant.link_key_type_name(event.key_type)}')
self.emit('link_key', event.bd_addr, event.link_key, event.key_type)
def on_hci_simple_pairing_complete_event(self, event):
logger.debug(f'simple pairing complete for {event.bd_addr}: status={HCI_Constant.status_name(event.status)}')
def on_hci_pin_code_request_event(self, event):
# For now, just refuse all requests
# TODO: delegate the decision
self.send_command_sync(
HCI_PIN_Code_Request_Negative_Reply_Command(
bd_addr = event.bd_addr
)
)
def on_hci_link_key_request_event(self, event):
async def send_link_key():
if self.link_key_provider is None:
logger.debug('no link key provider')
link_key = None
else:
link_key = await self.link_key_provider(event.bd_addr)
if link_key:
response = HCI_Link_Key_Request_Reply_Command(
bd_addr = event.bd_addr,
link_key = link_key
)
else:
response = HCI_Link_Key_Request_Negative_Reply_Command(
bd_addr = event.bd_addr
)
await self.send_command(response)
asyncio.create_task(send_link_key())
def on_hci_io_capability_request_event(self, event):
# For now, just return NoInputNoOutput and no MITM
# TODO: delegate the decision
self.send_command_sync(
HCI_IO_Capability_Request_Reply_Command(
bd_addr = event.bd_addr,
io_capability = HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
oob_data_present = 0x00,
authentication_requirements = 0x00 # 0x02 # FIXME: testing only
)
)
def on_hci_io_capability_response_event(self, event):
pass
def on_hci_user_confirmation_request_event(self, event):
# For now, just confirm everything
# TODO: delegate the decision
self.send_command_sync(
HCI_User_Confirmation_Request_Reply_Command(bd_addr = event.bd_addr)
)
def on_hci_inquiry_complete_event(self, event):
self.emit('inquiry_complete')
def on_hci_inquiry_result_with_rssi_event(self, event):
for response in event.responses:
self.emit(
'inquiry_result',
response.bd_addr,
response.class_of_device,
b'',
response.rssi
)
def on_hci_extended_inquiry_result_event(self, event):
self.emit(
'inquiry_result',
event.bd_addr,
event.class_of_device,
event.extended_inquiry_response,
event.rssi
)

273
bumble/keys.py Normal file
View File

@@ -0,0 +1,273 @@
# 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.
# -----------------------------------------------------------------------------
# Keys and Key Storage
#
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import logging
import os
import json
from colors import color
from .hci import Address
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
class PairingKeys:
class Key:
def __init__(self, value, authenticated=False, ediv=None, rand=None):
self.value = value
self.authenticated = authenticated
self.ediv = ediv
self.rand = rand
@classmethod
def from_dict(cls, key_dict):
value = bytes.fromhex(key_dict['value'])
authenticated = key_dict.get('authenticated', False)
ediv = key_dict.get('ediv')
rand = key_dict.get('rand')
if rand is not None:
rand = bytes.fromhex(rand)
return cls(value, authenticated, ediv, rand)
def to_dict(self):
key_dict = {'value': self.value.hex(), 'authenticated': self.authenticated}
if self.ediv is not None:
key_dict['ediv'] = self.ediv
if self.rand is not None:
key_dict['rand'] = self.rand.hex()
return key_dict
def __init__(self):
self.address_type = None
self.ltk = None
self.ltk_central = None
self.ltk_peripheral = None
self.irk = None
self.csrk = None
self.link_key = None # Classic
@staticmethod
def key_from_dict(keys_dict, key_name):
key_dict = keys_dict.get(key_name)
if key_dict is not None:
return PairingKeys.Key.from_dict(key_dict)
@staticmethod
def from_dict(keys_dict):
keys = PairingKeys()
keys.address_type = keys_dict.get('address_type')
keys.ltk = PairingKeys.key_from_dict(keys_dict, 'ltk')
keys.ltk_central = PairingKeys.key_from_dict(keys_dict, 'ltk_central')
keys.ltk_peripheral = PairingKeys.key_from_dict(keys_dict, 'ltk_peripheral')
keys.irk = PairingKeys.key_from_dict(keys_dict, 'irk')
keys.csrk = PairingKeys.key_from_dict(keys_dict, 'csrk')
keys.link_key = PairingKeys.key_from_dict(keys_dict, 'link_key')
return keys
def to_dict(self):
keys = {}
if self.address_type is not None:
keys['address_type'] = self.address_type
if self.ltk is not None:
keys['ltk'] = self.ltk.to_dict()
if self.ltk_central is not None:
keys['ltk_central'] = self.ltk_central.to_dict()
if self.ltk_peripheral is not None:
keys['ltk_peripheral'] = self.ltk_peripheral.to_dict()
if self.irk is not None:
keys['irk'] = self.irk.to_dict()
if self.csrk is not None:
keys['csrk'] = self.csrk.to_dict()
if self.link_key is not None:
keys['link_key'] = self.link_key.to_dict()
return keys
def print(self, prefix=''):
keys_dict = self.to_dict()
for (property, value) in keys_dict.items():
if type(value) is dict:
print(f'{prefix}{color(property, "cyan")}:')
for (key_property, key_value) in value.items():
print(f'{prefix} {color(key_property, "green")}: {key_value}')
else:
print(f'{prefix}{color(property, "cyan")}: {value}')
# -----------------------------------------------------------------------------
class KeyStore:
async def delete(self, name):
pass
async def update(self, name, keys):
pass
async def get(self, name):
return PairingKeys()
async def get_all(self):
return []
async def get_resolving_keys(self):
all_keys = await self.get_all()
resolving_keys = []
for (name, keys) in all_keys:
if keys.irk is not None:
if keys.address_type is None:
address_type = Address.RANDOM_DEVICE_ADDRESS
else:
address_type = keys.address_type
resolving_keys.append((keys.irk.value, Address(name, address_type)))
return resolving_keys
async def print(self, prefix=''):
entries = await self.get_all()
separator = ''
for (name, keys) in entries:
print(separator + prefix + color(name, 'yellow'))
keys.print(prefix = prefix + ' ')
separator = '\n'
@staticmethod
def create_for_device(device_config):
if device_config.keystore is None:
return None
keystore_type = device_config.keystore.split(':', 1)[0]
if keystore_type == 'JsonKeyStore':
return JsonKeyStore.from_device_config(device_config)
return None
# -----------------------------------------------------------------------------
class JsonKeyStore(KeyStore):
APP_NAME = 'Bumble'
APP_AUTHOR = 'Google'
KEYS_DIR = 'Pairing'
DEFAULT_NAMESPACE = '__DEFAULT__'
def __init__(self, namespace, filename=None):
self.namespace = namespace if namespace is not None else self.DEFAULT_NAMESPACE
if filename is None:
# Use a default for the current user
import appdirs
self.directory_name = os.path.join(
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR),
self.KEYS_DIR
)
json_filename = f'{self.namespace}.json'.lower().replace(':', '-')
self.filename = os.path.join(self.directory_name, json_filename)
else:
self.filename = filename
self.directory_name = os.path.dirname(os.path.abspath(self.filename))
logger.debug(f'JSON keystore: {self.filename}')
@staticmethod
def from_device_config(device_config):
params = device_config.keystore.split(':', 1)[1:]
namespace = str(device_config.address)
if params:
filename = params[1]
else:
filename = None
return JsonKeyStore(namespace, filename)
async def load(self):
try:
with open(self.filename, 'r') as json_file:
return json.load(json_file)
except FileNotFoundError:
return {}
async def save(self, db):
# Create the directory if it doesn't exist
if not os.path.exists(self.directory_name):
os.makedirs(self.directory_name, exist_ok=True)
# Save to a temporary file
temp_filename = self.filename + '.tmp'
with open(temp_filename, 'w') as output:
json.dump(db, output, sort_keys=True, indent=4)
# Atomically replace the previous file
os.rename(temp_filename, self.filename)
async def delete(self, name):
db = await self.load()
namespace = db.get(self.namespace)
if namespace is None:
raise KeyError(name)
del namespace[name]
await self.save(db)
async def update(self, name, keys):
db = await self.load()
namespace = db.setdefault(self.namespace, {})
namespace[name] = keys.to_dict()
await self.save(db)
async def get_all(self):
db = await self.load()
namespace = db.get(self.namespace)
if namespace is None:
return []
return [(name, PairingKeys.from_dict(keys)) for (name, keys) in namespace.items()]
async def get(self, name):
db = await self.load()
namespace = db.get(self.namespace)
if namespace is None:
return None
keys = namespace.get(name)
if keys is None:
return None
return PairingKeys.from_dict(keys)

1079
bumble/l2cap.py Normal file

File diff suppressed because it is too large Load Diff

360
bumble/link.py Normal file
View File

@@ -0,0 +1,360 @@
# 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 logging
import asyncio
import websockets
from functools import partial
from colors import color
from bumble.hci import (
Address,
HCI_SUCCESS,
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
HCI_CONNECTION_TIMEOUT_ERROR
)
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
def parse_parameters(params_str):
result = {}
for param_str in params_str.split(','):
if '=' in param_str:
key, value = param_str.split('=')
result[key] = value
return result
# -----------------------------------------------------------------------------
# TODO: add more support for various LL exchanges (see Vol 6, Part B - 2.4 DATA CHANNEL PDU)
# -----------------------------------------------------------------------------
class LocalLink:
'''
Link bus for controllers to communicate with each other
'''
def __init__(self):
self.controllers = set()
self.pending_connection = None
def add_controller(self, controller):
logger.debug(f'new controller: {controller}')
self.controllers.add(controller)
def remove_controller(self, controller):
self.controllers.remove(controller)
def find_controller(self, address):
for controller in self.controllers:
if controller.random_address == address:
return controller
return None
def on_address_changed(self, controller):
pass
def get_pending_connection(self):
return self.pending_connection
def send_advertising_data(self, sender_address, data):
# Send the advertising data to all controllers, except the sender
for controller in self.controllers:
if controller.random_address != sender_address:
controller.on_link_advertising_data(sender_address, data)
def send_acl_data(self, sender_address, destination_address, data):
# Send the data to the first controller with a matching address
if controller := self.find_controller(destination_address):
controller.on_link_acl_data(sender_address, data)
def on_connection_complete(self):
# Check that we expect this call
if not self.pending_connection:
logger.warning('on_connection_complete with no pending connection')
return
central_address, le_create_connection_command = self.pending_connection
self.pending_connection = None
# Find the controller that initiated the connection
if not (central_controller := self.find_controller(central_address)):
logger.warning('!!! Initiating controller not found')
return
# Connect to the first controller with a matching address
if peripheral_controller := self.find_controller(le_create_connection_command.peer_address):
central_controller.on_link_peripheral_connection_complete(le_create_connection_command, HCI_SUCCESS)
peripheral_controller.on_link_central_connected(central_address)
return
# No peripheral found
central_controller.on_link_peripheral_connection_complete(
le_create_connection_command,
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR
)
def connect(self, central_address, le_create_connection_command):
logger.debug(f'$$$ CONNECTION {central_address} -> {le_create_connection_command.peer_address}')
self.pending_connection = (central_address, le_create_connection_command)
asyncio.get_running_loop().call_soon(self.on_connection_complete)
def on_disconnection_complete(self, central_address, peripheral_address, disconnect_command):
# Find the controller that initiated the disconnection
if not (central_controller := self.find_controller(central_address)):
logger.warning('!!! Initiating controller not found')
return
# Disconnect from the first controller with a matching address
if peripheral_controller := self.find_controller(peripheral_address):
peripheral_controller.on_link_central_disconnected(central_address, disconnect_command.reason)
central_controller.on_link_peripheral_disconnection_complete(disconnect_command, HCI_SUCCESS)
def disconnect(self, central_address, peripheral_address, disconnect_command):
logger.debug(f'$$$ DISCONNECTION {central_address} -> {peripheral_address}: reason = {disconnect_command.reason}')
args = [central_address, peripheral_address, disconnect_command]
asyncio.get_running_loop().call_soon(self.on_disconnection_complete, *args)
def on_connection_encrypted(self, central_address, peripheral_address, rand, ediv, ltk):
logger.debug(f'*** ENCRYPTION {central_address} -> {peripheral_address}')
if central_controller := self.find_controller(central_address):
central_controller.on_link_encrypted(peripheral_address, rand, ediv, ltk)
if peripheral_controller := self.find_controller(peripheral_address):
peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk)
# -----------------------------------------------------------------------------
class RemoteLink:
'''
A Link implementation that communicates with other virtual controllers via a
WebSocket relay
'''
def __init__(self, uri):
self.controller = None
self.uri = uri
self.execution_queue = asyncio.Queue()
self.websocket = asyncio.get_running_loop().create_future()
self.rpc_result = None
self.pending_connection = None
self.central_connections = set() # List of addresses that we have connected to
self.peripheral_connections = set() # List of addresses that have connected to us
# Connect and run asynchronously
asyncio.create_task(self.run_connection())
asyncio.create_task(self.run_executor_loop())
def add_controller(self, controller):
if self.controller:
raise ValueError('controller already set')
self.controller = controller
def remove_controller(self, controller):
if self.controller != controller:
raise ValueError('controller mismatch')
self.controller = None
def get_pending_connection(self):
return self.pending_connection
async def wait_until_connected(self):
await self.websocket
def execute(self, async_function):
self.execution_queue.put_nowait(async_function())
async def run_executor_loop(self):
logger.debug('executor loop starting')
while True:
item = await self.execution_queue.get()
try:
await item
except Exception as error:
logger.warning(f'{color("!!! Exception in async handler:", "red")} {error}')
async def run_connection(self):
# Connect to the relay
logger.debug(f'connecting to {self.uri}')
websocket = await websockets.connect(self.uri)
self.websocket.set_result(websocket)
logger.debug(f'connected to {self.uri}')
while True:
message = await websocket.recv()
logger.debug(f'received message: {message}')
keyword, *payload = message.split(':', 1)
handler_name = f'on_{keyword}_received'
handler = getattr(self, handler_name, None)
if handler:
await handler(payload[0] if payload else None)
def close(self):
if self.websocket.done():
logger.debug('closing websocket')
websocket = self.websocket.result()
asyncio.create_task(websocket.close())
async def on_result_received(self, result):
if self.rpc_result:
self.rpc_result.set_result(result)
async def on_left_received(self, address):
if address in self.central_connections:
self.controller.on_link_peripheral_disconnected(Address(address))
self.central_connections.remove(address)
if address in self.peripheral_connections:
self.controller.on_link_central_disconnected(address, HCI_CONNECTION_TIMEOUT_ERROR)
self.peripheral_connections.remove(address)
async def on_unreachable_received(self, target):
await self.on_left_received(target)
async def on_message_received(self, message):
sender, *payload = message.split('/', 1)
if payload:
keyword, *payload = payload[0].split(':', 1)
handler_name = f'on_{keyword}_message_received'
handler = getattr(self, handler_name, None)
if handler:
await handler(sender, payload[0] if payload else None)
async def on_advertisement_message_received(self, sender, advertisement):
try:
self.controller.on_link_advertising_data(Address(sender), bytes.fromhex(advertisement))
except Exception:
logger.exception('exception')
async def on_acl_message_received(self, sender, acl_data):
try:
self.controller.on_link_acl_data(Address(sender), bytes.fromhex(acl_data))
except Exception:
logger.exception('exception')
async def on_connect_message_received(self, sender, _):
# Remember the connection
self.peripheral_connections.add(sender)
# Notify the controller
logger.debug(f'connection from central {sender}')
self.controller.on_link_central_connected(Address(sender))
# Accept the connection by responding to it
await self.send_targetted_message(sender, 'connected')
async def on_connected_message_received(self, sender, _):
if not self.pending_connection:
logger.warn('received a connection ack, but no connection is pending')
return
# Remember the connection
self.central_connections.add(sender)
# Notify the controller
logger.debug(f'connected to peripheral {self.pending_connection.peer_address}')
self.controller.on_link_peripheral_connection_complete(self.pending_connection, HCI_SUCCESS)
async def on_disconnect_message_received(self, sender, message):
# Notify the controller
params = parse_parameters(message)
reason = int(params.get('reason', str(HCI_CONNECTION_TIMEOUT_ERROR)))
self.controller.on_link_central_disconnected(Address(sender), reason)
# Forget the connection
if sender in self.peripheral_connections:
self.peripheral_connections.remove(sender)
async def on_encrypted_message_received(self, sender, message):
# TODO parse params to get real args
self.controller.on_link_encrypted(Address(sender), bytes(8), 0, bytes(16))
async def send_rpc_command(self, command):
# Ensure we have a connection
websocket = await self.websocket
# Create a future value to hold the eventual result
assert(self.rpc_result is None)
self.rpc_result = asyncio.get_running_loop().create_future()
# Send the command
await websocket.send(command)
# Wait for the result
rpc_result = await self.rpc_result
self.rpc_result = None
logger.debug(f'rpc_result: {rpc_result}')
# TODO: parse the result
async def send_targetted_message(self, target, message):
# Ensure we have a connection
websocket = await self.websocket
# Send the message
await websocket.send(f'@{target} {message}')
async def notify_address_changed(self):
await self.send_rpc_command(f'/set-address {self.controller.random_address}')
def on_address_changed(self, controller):
logger.info(f'address changed for {controller}: {controller.random_address}')
# Notify the relay of the change
self.execute(self.notify_address_changed)
async def send_advertising_data_to_relay(self, data):
await self.send_targetted_message('*', f'advertisement:{data.hex()}')
def send_advertising_data(self, sender_address, data):
self.execute(partial(self.send_advertising_data_to_relay, data))
async def send_acl_data_to_relay(self, peer_address, data):
await self.send_targetted_message(peer_address, f'acl:{data.hex()}')
def send_acl_data(self, sender_address, peer_address, data):
self.execute(partial(self.send_acl_data_to_relay, peer_address, data))
async def send_connection_request_to_relay(self, peer_address):
await self.send_targetted_message(peer_address, 'connect')
def connect(self, central_address, le_create_connection_command):
if self.pending_connection:
logger.warn('connection already pending')
return
self.pending_connection = le_create_connection_command
self.execute(partial(self.send_connection_request_to_relay, str(le_create_connection_command.peer_address)))
def on_disconnection_complete(self, disconnect_command):
self.controller.on_link_peripheral_disconnection_complete(disconnect_command, HCI_SUCCESS)
def disconnect(self, central_address, peripheral_address, disconnect_command):
logger.debug(f'disconnect {central_address} -> {peripheral_address}: reason = {disconnect_command.reason}')
self.execute(partial(self.send_targetted_message, peripheral_address, f'disconnect:reason={disconnect_command.reason}'))
asyncio.get_running_loop().call_soon(self.on_disconnection_complete, disconnect_command)
def on_connection_encrypted(self, central_address, peripheral_address, rand, ediv, ltk):
asyncio.get_running_loop().call_soon(self.controller.on_link_encrypted, peripheral_address, rand, ediv, ltk)
self.execute(partial(self.send_targetted_message, peripheral_address, f'encrypted:ltk={ltk.hex()}'))

840
bumble/rfcomm.py Normal file
View File

@@ -0,0 +1,840 @@
# 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 logging
import asyncio
from colors import color
from .utils import EventEmitter
from .core import InvalidStateError, ProtocolError, ConnectionError
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
RFCOMM_PSM = 0x0003
# Frame types
RFCOMM_SABM_FRAME = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
RFCOMM_UA_FRAME = 0x63 # Control field [0,1,1,0,_,0,1,1] LSB-first
RFCOMM_DM_FRAME = 0x0F # Control field [1,1,1,1,_,0,0,0] LSB-first
RFCOMM_DISC_FRAME = 0x43 # Control field [0,1,0,_,0,0,1,1] LSB-first
RFCOMM_UIH_FRAME = 0xEF # Control field [1,1,1,_,1,1,1,1] LSB-first
RFCOMM_UI_FRAME = 0x03 # Control field [0,0,0,_,0,0,1,1] LSB-first
RFCOMM_FRAME_TYPE_NAMES = {
RFCOMM_SABM_FRAME: 'SABM',
RFCOMM_UA_FRAME: 'UA',
RFCOMM_DM_FRAME: 'DM',
RFCOMM_DISC_FRAME: 'DISC',
RFCOMM_UIH_FRAME: 'UIH',
RFCOMM_UI_FRAME: 'UI'
}
# MCC Types
RFCOMM_MCC_PN_TYPE = 0x20
RFCOMM_MCC_MSC_TYPE = 0x38
# FCS CRC
CRC_TABLE = bytes([
0X00, 0X91, 0XE3, 0X72, 0X07, 0X96, 0XE4, 0X75,
0X0E, 0X9F, 0XED, 0X7C, 0X09, 0X98, 0XEA, 0X7B,
0X1C, 0X8D, 0XFF, 0X6E, 0X1B, 0X8A, 0XF8, 0X69,
0X12, 0X83, 0XF1, 0X60, 0X15, 0X84, 0XF6, 0X67,
0X38, 0XA9, 0XDB, 0X4A, 0X3F, 0XAE, 0XDC, 0X4D,
0X36, 0XA7, 0XD5, 0X44, 0X31, 0XA0, 0XD2, 0X43,
0X24, 0XB5, 0XC7, 0X56, 0X23, 0XB2, 0XC0, 0X51,
0X2A, 0XBB, 0XC9, 0X58, 0X2D, 0XBC, 0XCE, 0X5F,
0X70, 0XE1, 0X93, 0X02, 0X77, 0XE6, 0X94, 0X05,
0X7E, 0XEF, 0X9D, 0X0C, 0X79, 0XE8, 0X9A, 0X0B,
0X6C, 0XFD, 0X8F, 0X1E, 0X6B, 0XFA, 0X88, 0X19,
0X62, 0XF3, 0X81, 0X10, 0X65, 0XF4, 0X86, 0X17,
0X48, 0XD9, 0XAB, 0X3A, 0X4F, 0XDE, 0XAC, 0X3D,
0X46, 0XD7, 0XA5, 0X34, 0X41, 0XD0, 0XA2, 0X33,
0X54, 0XC5, 0XB7, 0X26, 0X53, 0XC2, 0XB0, 0X21,
0X5A, 0XCB, 0XB9, 0X28, 0X5D, 0XCC, 0XBE, 0X2F,
0XE0, 0X71, 0X03, 0X92, 0XE7, 0X76, 0X04, 0X95,
0XEE, 0X7F, 0X0D, 0X9C, 0XE9, 0X78, 0X0A, 0X9B,
0XFC, 0X6D, 0X1F, 0X8E, 0XFB, 0X6A, 0X18, 0X89,
0XF2, 0X63, 0X11, 0X80, 0XF5, 0X64, 0X16, 0X87,
0XD8, 0X49, 0X3B, 0XAA, 0XDF, 0X4E, 0X3C, 0XAD,
0XD6, 0X47, 0X35, 0XA4, 0XD1, 0X40, 0X32, 0XA3,
0XC4, 0X55, 0X27, 0XB6, 0XC3, 0X52, 0X20, 0XB1,
0XCA, 0X5B, 0X29, 0XB8, 0XCD, 0X5C, 0X2E, 0XBF,
0X90, 0X01, 0X73, 0XE2, 0X97, 0X06, 0X74, 0XE5,
0X9E, 0X0F, 0X7D, 0XEC, 0X99, 0X08, 0X7A, 0XEB,
0X8C, 0X1D, 0X6F, 0XFE, 0X8B, 0X1A, 0X68, 0XF9,
0X82, 0X13, 0X61, 0XF0, 0X85, 0X14, 0X66, 0XF7,
0XA8, 0X39, 0X4B, 0XDA, 0XAF, 0X3E, 0X4C, 0XDD,
0XA6, 0X37, 0X45, 0XD4, 0XA1, 0X30, 0X42, 0XD3,
0XB4, 0X25, 0X57, 0XC6, 0XB3, 0X22, 0X50, 0XC1,
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
])
RFCOMM_DEFAULT_INITIAL_RX_CREDITS = 7
RFCOMM_DEFAULT_PREFERRED_MTU = 1280
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
# -----------------------------------------------------------------------------
def fcs(buffer):
fcs = 0xFF
for byte in buffer:
fcs = CRC_TABLE[fcs ^ byte]
return 0xFF - fcs
# -----------------------------------------------------------------------------
class RFCOMM_Frame:
def __init__(self, type, c_r, dlci, p_f, information = b'', with_credits = False):
self.type = type
self.c_r = c_r
self.dlci = dlci
self.p_f = p_f
self.information = information
length = len(information)
if with_credits:
length -= 1
if length > 0x7F:
# 2-byte length indicator
self.length = bytes([(length & 0x7F) << 1, (length >> 7) & 0xFF])
else:
# 1-byte length indicator
self.length = bytes([(length << 1) | 1])
self.address = (dlci << 2) | (c_r << 1) | 1
self.control = type | (p_f << 4)
if type == RFCOMM_UIH_FRAME:
self.fcs = fcs(bytes([self.address, self.control]))
else:
self.fcs = fcs(bytes([self.address, self.control]) + self.length)
def type_name(self):
return RFCOMM_FRAME_TYPE_NAMES[self.type]
@staticmethod
def parse_mcc(data):
type = data[0] >> 2
c_r = (data[0] >> 1) & 1
length = data[1]
if data[1] & 1:
length >>= 1
value = data[2:]
else:
length = (data[3] << 7) & (length >> 1)
value = data[3:3 + length]
return (type, c_r, value)
@staticmethod
def make_mcc(type, c_r, data):
return bytes([(type << 2 | c_r << 1 | 1) & 0xFF, (len(data) & 0x7F) << 1 | 1]) + data
@staticmethod
def sabm(c_r, dlci):
return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
@staticmethod
def ua(c_r, dlci):
return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
@staticmethod
def dm(c_r, dlci):
return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
@staticmethod
def disc(c_r, dlci):
return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
@staticmethod
def uih(c_r, dlci, information, p_f = 0):
return RFCOMM_Frame(RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits = (p_f == 1))
@staticmethod
def from_bytes(data):
# Extract fields
dlci = (data[0] >> 2) & 0x3F
c_r = (data[0] >> 1) & 0x01
type = data[1] & 0xEF
p_f = (data[1] >> 4) & 0x01
length = data[2]
if length & 0x01:
length >>= 1
information = data[3:-1]
else:
length = (data[3] << 7) & (length >> 1)
information = data[4:-1]
fcs = data[-1]
# Construct the frame and check the CRC
frame = RFCOMM_Frame(type, c_r, dlci, p_f, information)
if frame.fcs != fcs:
logger.warn(f'FCS mismatch: got {fcs:02X}, expected {frame.fcs:02X}')
raise ValueError('fcs mismatch')
return frame
def __bytes__(self):
return bytes([self.address, self.control]) + self.length + self.information + bytes([self.fcs])
def __str__(self):
return f'{color(self.type_name(), "yellow")}(c/r={self.c_r},dlci={self.dlci},p/f={self.p_f},length={len(self.information)},fcs=0x{self.fcs:02X})'
# -----------------------------------------------------------------------------
class RFCOMM_MCC_PN:
def __init__(self, dlci, cl, priority, ack_timer, max_frame_size, max_retransmissions, window_size):
self.dlci = dlci
self.cl = cl
self.priority = priority
self.ack_timer = ack_timer
self.max_frame_size = max_frame_size
self.max_retransmissions = max_retransmissions
self.window_size = window_size
@staticmethod
def from_bytes(data):
return RFCOMM_MCC_PN(
dlci = data[0],
cl = data[1],
priority = data[2],
ack_timer = data[3],
max_frame_size = data[4] | data[5] << 8,
max_retransmissions = data[6],
window_size = data[7]
)
def __bytes__(self):
return bytes([
self.dlci & 0xFF,
self.cl & 0xFF,
self.priority & 0xFF,
self.ack_timer & 0xFF,
self.max_frame_size & 0xFF,
(self.max_frame_size >> 8) & 0xFF,
self.max_retransmissions & 0xFF,
self.window_size & 0xFF
])
def __str__(self):
return f'PN(dlci={self.dlci},cl={self.cl},priority={self.priority},ack_timer={self.ack_timer},max_frame_size={self.max_frame_size},max_retransmissions={self.max_retransmissions},window_size={self.window_size})'
# -----------------------------------------------------------------------------
class RFCOMM_MCC_MSC:
def __init__(self, dlci, fc, rtc, rtr, ic, dv):
self.dlci = dlci
self.fc = fc
self.rtc = rtc
self.rtr = rtr
self.ic = ic
self.dv = dv
@staticmethod
def from_bytes(data):
return RFCOMM_MCC_MSC(
dlci = data[0] >> 2,
fc = data[1] >> 1 & 1,
rtc = data[1] >> 2 & 1,
rtr = data[1] >> 3 & 1,
ic = data[1] >> 6 & 1,
dv = data[1] >> 7 & 1
)
def __bytes__(self):
return bytes([
(self.dlci << 2) | 3,
1 | self.fc << 1 | self.rtc << 2 | self.rtr << 3 | self.ic << 6 | self.dv << 7
])
def __str__(self):
return f'MSC(dlci={self.dlci},fc={self.fc},rtc={self.rtc},rtr={self.rtr},ic={self.ic},dv={self.dv})'
# -----------------------------------------------------------------------------
class DLC(EventEmitter):
# States
INIT = 0x00
CONNECTING = 0x01
CONNECTED = 0x02
DISCONNECTING = 0x03
DISCONNECTED = 0x04
RESET = 0x05
STATE_NAMES = {
INIT: 'INIT',
CONNECTING: 'CONNECTING',
CONNECTED: 'CONNECTED',
DISCONNECTING: 'DISCONNECTING',
DISCONNECTED: 'DISCONNECTED',
RESET: 'RESET'
}
def __init__(self, multiplexer, dlci, max_frame_size, initial_tx_credits):
super().__init__()
self.multiplexer = multiplexer
self.dlci = dlci
self.rx_credits = RFCOMM_DEFAULT_INITIAL_RX_CREDITS
self.rx_threshold = self.rx_credits // 2
self.tx_credits = initial_tx_credits
self.tx_buffer = b''
self.state = DLC.INIT
self.role = multiplexer.role
self.c_r = 1 if self.role == Multiplexer.INITIATOR else 0
self.sink = None
# Compute the MTU
max_overhead = 4 + 1 # header with 2-byte length + fcs
self.mtu = min(max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead)
@staticmethod
def state_name(state):
return DLC.STATE_NAMES[state]
def change_state(self, new_state):
logger.debug(f'{self} state change -> {color(self.state_name(new_state), "magenta")}')
self.state = new_state
def send_frame(self, frame):
self.multiplexer.send_frame(frame)
def on_frame(self, frame):
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
handler(frame)
def on_sabm_frame(self, frame):
if self.state != DLC.CONNECTING:
logger.warn(color('!!! received SABM when not in CONNECTING state', 'red'))
return
self.send_frame(RFCOMM_Frame.ua(c_r = 1 - self.c_r, dlci = self.dlci))
# Exchange the modem status with the peer
msc = RFCOMM_MCC_MSC(
dlci = self.dlci,
fc = 0,
rtc = 1,
rtr = 1,
ic = 0,
dv = 1
)
mcc = RFCOMM_Frame.make_mcc(type = RFCOMM_MCC_MSC_TYPE, c_r = 1, data = bytes(msc))
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(
RFCOMM_Frame.uih(
c_r = self.c_r,
dlci = 0,
information = mcc
)
)
self.change_state(DLC.CONNECTED)
self.emit('open')
def on_ua_frame(self, frame):
if self.state != DLC.CONNECTING:
logger.warn(color('!!! received SABM when not in CONNECTING state', 'red'))
return
# Exchange the modem status with the peer
msc = RFCOMM_MCC_MSC(
dlci = self.dlci,
fc = 0,
rtc = 1,
rtr = 1,
ic = 0,
dv = 1
)
mcc = RFCOMM_Frame.make_mcc(type = RFCOMM_MCC_MSC_TYPE, c_r = 1, data = bytes(msc))
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(
RFCOMM_Frame.uih(
c_r = self.c_r,
dlci = 0,
information = mcc
)
)
self.change_state(DLC.CONNECTED)
self.multiplexer.on_dlc_open_complete(self)
def on_dm_frame(self, frame):
# TODO: handle all states
pass
def on_disc_frame(self, frame):
# TODO: handle all states
self.send_frame(RFCOMM_Frame.ua(c_r = 1 - self.c_r, dlci = self.dlci))
def on_uih_frame(self, frame):
data = frame.information
if frame.p_f == 1:
# With credits
credits = frame.information[0]
self.tx_credits += credits
logger.debug(f'<<< Credits [{self.dlci}]: received {credits}, total={self.tx_credits}')
data = data[1:]
logger.debug(f'{color("<<< Data", "yellow")} [{self.dlci}] {len(data)} bytes, rx_credits={self.rx_credits}: {data.hex()}')
if len(data) and self.sink:
self.sink(data)
# Update the credits
if self.rx_credits > 0:
self.rx_credits -= 1
else:
logger.warn(color('!!! received frame with no rx credits', 'red'))
# Check if there's anything to send (including credits)
self.process_tx()
def on_ui_frame(self, frame):
pass
def on_mcc_msc(self, c_r, msc):
if c_r:
# Command
logger.debug(f'<<< MCC MSC Command: {msc}')
msc = RFCOMM_MCC_MSC(
dlci = self.dlci,
fc = 0,
rtc = 1,
rtr = 1,
ic = 0,
dv = 1
)
mcc = RFCOMM_Frame.make_mcc(type = RFCOMM_MCC_MSC_TYPE, c_r = 0, data = bytes(msc))
logger.debug(f'>>> MCC MSC Response: {msc}')
self.send_frame(
RFCOMM_Frame.uih(
c_r = self.c_r,
dlci = 0,
information = mcc
)
)
else:
# Response
logger.debug(f'<<< MCC MSC Response: {msc}')
def connect(self):
if not self.state == DLC.INIT:
raise InvalidStateError('invalid state')
self.change_state(DLC.CONNECTING)
self.connection_result = asyncio.get_running_loop().create_future()
self.send_frame(
RFCOMM_Frame.sabm(
c_r = self.c_r,
dlci = self.dlci
)
)
def accept(self):
if not self.state == DLC.INIT:
raise InvalidStateError('invalid state')
pn = RFCOMM_MCC_PN(
dlci = self.dlci,
cl = 0xE0,
priority = 7,
ack_timer = 0,
max_frame_size = RFCOMM_DEFAULT_PREFERRED_MTU,
max_retransmissions = 0,
window_size = RFCOMM_DEFAULT_INITIAL_RX_CREDITS
)
mcc = RFCOMM_Frame.make_mcc(type = RFCOMM_MCC_PN_TYPE, c_r = 0, data = bytes(pn))
logger.debug(f'>>> PN Response: {pn}')
self.send_frame(
RFCOMM_Frame.uih(
c_r = self.c_r,
dlci = 0,
information = mcc
)
)
self.change_state(DLC.CONNECTING)
def rx_credits_needed(self):
if self.rx_credits <= self.rx_threshold:
return RFCOMM_DEFAULT_INITIAL_RX_CREDITS - self.rx_credits
else:
return 0
def process_tx(self):
# Send anything we can (or an empty frame if we need to send rx credits)
rx_credits_needed = self.rx_credits_needed()
while (self.tx_buffer and self.tx_credits > 0) or rx_credits_needed > 0:
# Get the next chunk, up to MTU size
if rx_credits_needed > 0:
chunk = bytes([rx_credits_needed]) + self.tx_buffer[:self.mtu - 1]
self.tx_buffer = self.tx_buffer[len(chunk) - 1:]
self.rx_credits += rx_credits_needed
tx_credit_spent = (len(chunk) > 1)
else:
chunk = self.tx_buffer[:self.mtu]
self.tx_buffer = self.tx_buffer[len(chunk):]
tx_credit_spent = True
# Update the tx credits
# (no tx credit spent for empty frames that only contain rx credits)
if tx_credit_spent:
self.tx_credits -= 1
# Send the frame
logger.debug(f'>>> sending {len(chunk)} bytes with {rx_credits_needed} credits, rx_credits={self.rx_credits}, tx_credits={self.tx_credits}')
self.send_frame(
RFCOMM_Frame.uih(
c_r = self.c_r,
dlci = self.dlci,
information = chunk,
p_f = 1 if rx_credits_needed > 0 else 0
)
)
rx_credits_needed = 0
# Stream protocol
def write(self, data):
# We can only send bytes
if type(data) != bytes:
if type(data) == str:
# Automatically convert strings to bytes using UTF-8
data = data.encode('utf-8')
else:
raise ValueError('write only accept bytes or strings')
self.tx_buffer += data
self.process_tx()
def drain(self):
# TODO
pass
def __str__(self):
return f'DLC(dlci={self.dlci},state={self.state_name(self.state)})'
# -----------------------------------------------------------------------------
class Multiplexer(EventEmitter):
# Roles
INITIATOR = 0x00
RESPONDER = 0x01
# States
INIT = 0x00
CONNECTING = 0x01
CONNECTED = 0x02
OPENING = 0x03
DISCONNECTING = 0x04
DISCONNECTED = 0x05
RESET = 0x06
STATE_NAMES = {
INIT: 'INIT',
CONNECTING: 'CONNECTING',
CONNECTED: 'CONNECTED',
OPENING: 'OPENING',
DISCONNECTING: 'DISCONNECTING',
DISCONNECTED: 'DISCONNECTED',
RESET: 'RESET'
}
def __init__(self, l2cap_channel, role):
super().__init__()
self.role = role
self.l2cap_channel = l2cap_channel
self.state = Multiplexer.INIT
self.dlcs = {} # DLCs, by DLCI
self.connection_result = None
self.disconnection_result = None
self.open_result = None
self.acceptor = None
# Become a sink for the L2CAP channel
l2cap_channel.sink = self.on_pdu
@staticmethod
def state_name(state):
return Multiplexer.STATE_NAMES[state]
def change_state(self, new_state):
logger.debug(f'{self} state change -> {color(self.state_name(new_state), "cyan")}')
self.state = new_state
def send_frame(self, frame):
logger.debug(f'>>> Multiplexer sending {frame}')
self.l2cap_channel.send_pdu(frame)
def on_pdu(self, pdu):
frame = RFCOMM_Frame.from_bytes(pdu)
logger.debug(f'<<< Multiplexer received {frame}')
# Dispatch to this multiplexer or to a dlc, depending on the address
if frame.dlci == 0:
self.on_frame(frame)
else:
if frame.type == RFCOMM_DM_FRAME:
# DM responses are for a DLCI, but since we only create the dlc when we receive
# a PN response (because we need the parameters), we handle DM frames at the Multiplexer
# level
self.on_dm_frame(frame)
else:
dlc = self.dlcs.get(frame.dlci)
if dlc is None:
logger.warn(f'no dlc for DLCI {frame.dlci}')
return
dlc.on_frame(frame)
def on_frame(self, frame):
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
handler(frame)
def on_sabm_frame(self, frame):
if self.state != Multiplexer.INIT:
logger.debug('not in INIT state, ignoring SABM')
return
self.change_state(Multiplexer.CONNECTED)
self.send_frame(RFCOMM_Frame.ua(c_r = 1, dlci = 0))
def on_ua_frame(self, frame):
if self.state == Multiplexer.CONNECTING:
self.change_state(Multiplexer.CONNECTED)
if self.connection_result:
self.connection_result.set_result(0)
self.connection_result = None
elif self.state == Multiplexer.DISCONNECTING:
self.change_state(Multiplexer.DISCONNECTED)
if self.disconnection_result:
self.disconnection_result.set_result(None)
self.disconnection_result = None
def on_dm_frame(self, frame):
if self.state == Multiplexer.OPENING:
self.change_state(Multiplexer.CONNECTED)
if self.open_result:
self.open_result.set_exception(ConnectionError(ConnectionError.CONNECTION_REFUSED))
else:
logger.warn(f'unexpected state for DM: {self}')
def on_disc_frame(self, frame):
self.change_state(Multiplexer.DISCONNECTED)
self.send_frame(RFCOMM_Frame.ua(c_r = 0 if self.role == Multiplexer.INITIATOR else 1, dlci = 0))
def on_uih_frame(self, frame):
(type, c_r, value) = RFCOMM_Frame.parse_mcc(frame.information)
if type == RFCOMM_MCC_PN_TYPE:
pn = RFCOMM_MCC_PN.from_bytes(value)
self.on_mcc_pn(c_r, pn)
elif type == RFCOMM_MCC_MSC_TYPE:
mcs = RFCOMM_MCC_MSC.from_bytes(value)
self.on_mcc_msc(c_r, mcs)
def on_ui_frame(self, frame):
pass
def on_mcc_pn(self, c_r, pn):
if c_r == 1:
# Command
logger.debug(f'<<< PN Command: {pn}')
# Check with the multiplexer if there's an acceptor for this channel
if pn.dlci & 1:
# Not expected, this is an initiator-side number
# TODO: error out
logger.warn(f'invalid DLCI: {pn.dlci}')
else:
if self.acceptor:
channel_number = pn.dlci >> 1
if self.acceptor(channel_number):
# Create a new DLC
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
self.dlcs[pn.dlci] = dlc
# Re-emit the handshake completion event
dlc.on('open', lambda: self.emit('dlc', dlc))
# Respond to complete the handshake
dlc.accept()
else:
# No acceptor, we're in Disconnected Mode
self.send_frame(RFCOMM_Frame.dm(c_r = 1, dlci = pn.dlci))
else:
# No acceptor?? shouldn't happen
logger.warn(color('!!! no acceptor registered', 'red'))
else:
# Response
logger.debug(f'>>> PN Response: {pn}')
if self.state == Multiplexer.OPENING:
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
self.dlcs[pn.dlci] = dlc
dlc.connect()
else:
logger.warn('ignoring PN response')
def on_mcc_msc(self, c_r, msc):
dlc = self.dlcs.get(msc.dlci)
if dlc is None:
logger.warn(f'no dlc for DLCI {msc.dlci}')
return
dlc.on_mcc_msc(c_r, msc)
async def connect(self):
if self.state != Multiplexer.INIT:
raise InvalidStateError('invalid state')
self.change_state(Multiplexer.CONNECTING)
self.connection_result = asyncio.get_running_loop().create_future()
self.send_frame(RFCOMM_Frame.sabm(c_r = 1, dlci = 0))
return await self.connection_result
async def disconnect(self):
if self.state != Multiplexer.CONNECTED:
return
self.disconnection_result = asyncio.get_running_loop().create_future()
self.change_state(Multiplexer.DISCONNECTING)
self.send_frame(RFCOMM_Frame.disc(c_r = 1 if self.role == Multiplexer.INITIATOR else 0, dlci = 0))
await self.disconnection_result
async def open_dlc(self, channel):
if self.state != Multiplexer.CONNECTED:
if self.state == Multiplexer.OPENING:
raise InvalidStateError('open already in progress')
else:
raise InvalidStateError('not connected')
pn = RFCOMM_MCC_PN(
dlci = channel << 1,
cl = 0xF0,
priority = 7,
ack_timer = 0,
max_frame_size = RFCOMM_DEFAULT_PREFERRED_MTU,
max_retransmissions = 0,
window_size = RFCOMM_DEFAULT_INITIAL_RX_CREDITS
)
mcc = RFCOMM_Frame.make_mcc(type = RFCOMM_MCC_PN_TYPE, c_r = 1, data = bytes(pn))
logger.debug(f'>>> Sending MCC: {pn}')
self.open_result = asyncio.get_running_loop().create_future()
self.change_state(Multiplexer.OPENING)
self.send_frame(
RFCOMM_Frame.uih(
c_r = 1 if self.role == Multiplexer.INITIATOR else 0,
dlci = 0,
information = mcc
)
)
result = await self.open_result
self.open_result = None
return result
def on_dlc_open_complete(self, dlc):
logger.debug(f'DLC [{dlc.dlci}] open complete')
self.change_state(Multiplexer.CONNECTED)
if self.open_result:
self.open_result.set_result(dlc)
def __str__(self):
return f'Multiplexer(state={self.state_name(self.state)})'
# -----------------------------------------------------------------------------
class Client:
def __init__(self, device, connection):
self.device = device
self.connection = connection
self.l2cap_channel = None
self.multiplexer = None
async def start(self):
# Create a new L2CAP connection
try:
self.l2cap_channel = await self.device.l2cap_channel_manager.connect(self.connection, RFCOMM_PSM)
except ProtocolError as error:
logger.warn(f'L2CAP connection failed: {error}')
raise
# Create a mutliplexer to manage DLCs with the server
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.INITIATOR)
# Connect the multiplexer
await self.multiplexer.connect()
return self.multiplexer
async def shutdown(self):
# Disconnect the multiplexer
await self.multiplexer.disconnect()
self.multiplexer = None
# Close the L2CAP channel
# TODO
# -----------------------------------------------------------------------------
class Server(EventEmitter):
def __init__(self, device):
super().__init__()
self.device = device
self.multiplexer = None
self.acceptors = {}
# Register ourselves with the L2CAP channel manager
device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
def listen(self, acceptor):
# Find a free channel number
for channel in range(RFCOMM_DYNAMIC_CHANNEL_NUMBER_START, RFCOMM_DYNAMIC_CHANNEL_NUMBER_END + 1):
if channel not in self.acceptors:
self.acceptors[channel] = acceptor
return channel
# All channels used...
return 0
def on_connection(self, l2cap_channel):
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
def on_l2cap_channel_open(self, l2cap_channel):
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
# Create a new multiplexer for the channel
multiplexer = Multiplexer(l2cap_channel, Multiplexer.RESPONDER)
multiplexer.acceptor = self.accept_dlc
multiplexer.on('dlc', self.on_dlc)
# Notify
self.emit('start', multiplexer)
def accept_dlc(self, channel_number):
return channel_number in self.acceptors
def on_dlc(self, dlc):
logger.debug(f'@@@ new DLC connected: {dlc}')
# Let the acceptor know
acceptor = self.acceptors.get(dlc.dlci >> 1)
if acceptor:
acceptor(dlc)

1021
bumble/sdp.py Normal file

File diff suppressed because it is too large Load Diff

1514
bumble/smp.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
# Copyright 2021-2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import logging
from .common import Transport, AsyncPipeSink
from ..link import RemoteLink
from ..controller import Controller
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_transport(name):
'''
Open a transport by name.
The name must be <type>:<parameters>
Where <parameters> depend on the type (and may be empty for some types).
The supported types are: serial,udp,tcp,pty,usb
'''
scheme, *spec = name.split(':', 1)
if scheme == 'serial' and spec:
from .serial import open_serial_transport
return await open_serial_transport(spec[0])
elif scheme == 'udp' and spec:
from .udp import open_udp_transport
return await open_udp_transport(spec[0])
elif scheme == 'tcp-client' and spec:
from .tcp_client import open_tcp_client_transport
return await open_tcp_client_transport(spec[0])
elif scheme == 'tcp-server' and spec:
from .tcp_server import open_tcp_server_transport
return await open_tcp_server_transport(spec[0])
elif scheme == 'ws-client' and spec:
from .ws_client import open_ws_client_transport
return await open_ws_client_transport(spec[0])
elif scheme == 'ws-server' and spec:
from .ws_server import open_ws_server_transport
return await open_ws_server_transport(spec[0])
elif scheme == 'pty':
from .pty import open_pty_transport
return await open_pty_transport(spec[0] if spec else None)
elif scheme == 'file':
from .file import open_file_transport
return await open_file_transport(spec[0] if spec else None)
elif scheme == 'vhci':
from .vhci import open_vhci_transport
return await open_vhci_transport(spec[0] if spec else None)
elif scheme == 'hci-socket':
from .hci_socket import open_hci_socket_transport
return await open_hci_socket_transport(spec[0] if spec else None)
elif scheme == 'usb':
from .usb import open_usb_transport
return await open_usb_transport(spec[0] if spec else None)
elif scheme == 'pyusb':
from .pyusb import open_pyusb_transport
return await open_pyusb_transport(spec[0] if spec else None)
elif scheme == 'android-emulator':
from .android_emulator import open_android_emulator_transport
return await open_android_emulator_transport(spec[0] if spec else None)
else:
raise ValueError('unknown transport scheme')
# -----------------------------------------------------------------------------
async def open_transport_or_link(name):
if name.startswith('link-relay:'):
link = RemoteLink(name[11:])
await link.wait_until_connected()
controller = Controller('remote', link = link)
class LinkTransport(Transport):
async def close(self):
link.close()
return LinkTransport(controller, AsyncPipeSink(controller))
else:
return await open_transport(name)

View File

@@ -0,0 +1,107 @@
# 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 logging
import grpc
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink
from .emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
from .emulated_bluetooth_packets_pb2 import HCIPacket
from .emulated_bluetooth_vhci_pb2_grpc import VhciForwardingServiceStub
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_android_emulator_transport(spec):
'''
Open a transport connection to an Android emulator via its gRPC interface.
The parameter string has this syntax:
[<remote-host>:<remote-port>][,mode=<host|controller>]
The <remote-host>:<remote-port> part is optional, it defaults to localhost:8554
The mode=<mode> part is optional, it defaults to mode=host
When the mode is set to 'controller', the connection is for a controller (i.e the
Android Bluetooth stack will use the connected endpoint as its controller). When
the mode is set to 'host', the connection is to the 'Root Canal' virtual controller
that runs as part of the emulator, and used by the Android Bluetooth stack.
Examples:
(empty string) --> connect as a host to the emulator on localhost:8554
localhost:8555 --> connect as a host to the emulator on localhost:8555
mode=controller --> connect as a controller to the emulator on localhost:8554
'''
# Wrapper for I/O operations
class HciDevice:
def __init__(self, hci_device):
self.hci_device = hci_device
async def read(self):
packet = await self.hci_device.read()
return bytes([packet.type]) + packet.packet
async def write(self, packet):
await self.hci_device.write(
HCIPacket(
type = packet[0],
packet = packet[1:]
)
)
# Parse the parameters
mode = 'host'
server_host = 'localhost'
server_port = 8554
if spec is not None:
params = spec.split(',')
for param in params:
if param.startswith('mode='):
mode = param.split('=')[1]
elif ':' in param:
server_host, server_port = param.split(':')
else:
raise ValueError('invalid parameter')
# Connect to the gRPC server
server_address = f'{server_host}:{server_port}'
logger.debug(f'connecting to gRPC server at {server_address}')
channel = grpc.aio.insecure_channel(server_address)
if mode == 'host':
# Connect as a host
service = EmulatedBluetoothServiceStub(channel)
hci_device = HciDevice(service.registerHCIDevice())
elif mode == 'controller':
# Connect as a controller
service = VhciForwardingServiceStub(channel)
hci_device = HciDevice(service.attachVhci())
else:
raise ValueError('invalid mode')
# Create the transport object
transport = PumpedTransport(
PumpedPacketSource(hci_device.read),
PumpedPacketSink(hci_device.write),
channel.close
)
transport.start()
return transport

326
bumble/transport/common.py Normal file
View File

@@ -0,0 +1,326 @@
# 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 struct
import asyncio
import logging
from colors import color
from .. import hci
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Information needed to parse HCI packets with a generic parser:
# For each packet type, the info represents:
# (length-size, length-offset, unpack-type)
HCI_PACKET_INFO = {
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
hci.HCI_EVENT_PACKET: (1, 1, 'B')
}
# -----------------------------------------------------------------------------
class PacketPump:
'''
Pump HCI packets from a reader to a sink
'''
def __init__(self, reader, sink):
self.reader = reader
self.sink = sink
async def run(self):
while True:
try:
# Get a packet from the source
packet = hci.HCI_Packet.from_bytes(await self.reader.next_packet())
# Deliver the packet to the sink
self.sink.on_packet(packet)
except Exception as error:
logger.warning(f'!!! {error}')
# -----------------------------------------------------------------------------
class PacketParser:
'''
In-line parser that accepts data and emits 'on_packet' when a full packet has been parsed
'''
NEED_TYPE = 0
NEED_LENGTH = 1
NEED_BODY = 2
def __init__(self, sink = None):
self.sink = sink
self.extended_packet_info = {}
self.reset()
def reset(self):
self.state = PacketParser.NEED_TYPE
self.bytes_needed = 1
self.packet = bytearray()
self.packet_info = None
def feed_data(self, data):
data_offset = 0
data_left = len(data)
while data_left and self.bytes_needed:
consumed = min(self.bytes_needed, data_left)
self.packet.extend(data[data_offset:data_offset + consumed])
data_offset += consumed
data_left -= consumed
self.bytes_needed -= consumed
if self.bytes_needed == 0:
if self.state == PacketParser.NEED_TYPE:
packet_type = self.packet[0]
self.packet_info = HCI_PACKET_INFO.get(packet_type) or self.extended_packet_info.get(packet_type)
if self.packet_info is None:
raise ValueError(f'invalid packet type {packet_type}')
self.state = PacketParser.NEED_LENGTH
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
elif self.state == PacketParser.NEED_LENGTH:
body_length = struct.unpack_from(self.packet_info[2], self.packet, 1 + self.packet_info[1])[0]
self.bytes_needed = body_length
self.state = PacketParser.NEED_BODY
# Emit a packet if one is complete
if self.state == PacketParser.NEED_BODY and not self.bytes_needed:
if self.sink:
try:
self.sink.on_packet(bytes(self.packet))
except Exception as error:
logger.warning(color(f'!!! Exception in on_packet: {error}', 'red'))
self.reset()
def set_packet_sink(self, sink):
self.sink = sink
# -----------------------------------------------------------------------------
class PacketReader:
'''
Reader that reads HCI packets from a sync source
'''
def __init__(self, source):
self.source = source
def next_packet(self):
# Get the packet type
packet_type = self.source.read(1)
if len(packet_type) != 1:
return None
# Get the packet info based on its type
packet_info = HCI_PACKET_INFO.get(packet_type[0])
if packet_info is None:
raise ValueError(f'invalid packet type {packet_type} found')
# Read the header (that includes the length)
header_size = packet_info[0] + packet_info[1]
header = self.source.read(header_size)
if len(header) != header_size:
raise ValueError('packet too short')
# Read the body
body_length = struct.unpack_from(packet_info[2], header, packet_info[1])[0]
body = self.source.read(body_length)
if len(body) != body_length:
raise ValueError('packet too short')
return packet_type + header + body
# -----------------------------------------------------------------------------
class AsyncPacketReader:
'''
Reader that reads HCI packets from an async source
'''
def __init__(self, source):
self.source = source
async def next_packet(self):
# Get the packet type
packet_type = await self.source.readexactly(1)
# Get the packet info based on its type
packet_info = HCI_PACKET_INFO.get(packet_type[0])
if packet_info is None:
raise ValueError(f'invalid packet type {packet_type} found')
# Read the header (that includes the length)
header_size = packet_info[0] + packet_info[1]
header = await self.source.readexactly(header_size)
# Read the body
body_length = struct.unpack_from(packet_info[2], header, packet_info[1])[0]
body = await self.source.readexactly(body_length)
return packet_type + header + body
# -----------------------------------------------------------------------------
class AsyncPipeSink:
'''
Sink that forwards packets asynchronously to another sink
'''
def __init__(self, sink):
self.sink = sink
self.loop = asyncio.get_running_loop()
def on_packet(self, packet):
self.loop.call_soon(self.sink.on_packet, packet)
# -----------------------------------------------------------------------------
class ParserSource:
"""
Base class designed to be subclassed by transport-specific source classes
"""
def __init__(self):
self.parser = PacketParser()
self.terminated = asyncio.get_running_loop().create_future()
def set_packet_sink(self, sink):
self.parser.set_packet_sink(sink)
async def wait_for_termination(self):
return await self.terminated
def close(self):
pass
# -----------------------------------------------------------------------------
class StreamPacketSource(asyncio.Protocol, ParserSource):
def data_received(self, data):
self.parser.feed_data(data)
# -----------------------------------------------------------------------------
class StreamPacketSink:
def __init__(self, transport):
self.transport = transport
def on_packet(self, packet):
self.transport.write(packet)
def close(self):
self.transport.close()
# -----------------------------------------------------------------------------
class Transport:
def __init__(self, source, sink):
self.source = source
self.sink = sink
async def __aenter__(self):
return self
async def __aexit__(self, *args):
await self.close()
def __iter__(self):
return iter((self.source, self.sink))
async def close(self):
self.source.close()
self.sink.close()
# -----------------------------------------------------------------------------
class PumpedPacketSource(ParserSource):
def __init__(self, receive):
super().__init__()
self.receive_function = receive
self.pump_task = None
def start(self):
async def pump_packets():
while True:
try:
packet = await self.receive_function()
self.parser.feed_data(packet)
except asyncio.exceptions.CancelledError:
logger.debug('source pump task done')
break
except Exception as error:
logger.warn(f'exception while waiting for packet: {error}')
self.terminated.set_result(error)
break
self.pump_task = asyncio.get_running_loop().create_task(pump_packets())
def close(self):
if self.pump_task:
self.pump_task.cancel()
# -----------------------------------------------------------------------------
class PumpedPacketSink:
def __init__(self, send):
self.send_function = send
self.packet_queue = asyncio.Queue()
self.pump_task = None
def on_packet(self, packet):
self.packet_queue.put_nowait(packet)
def start(self):
async def pump_packets():
while True:
try:
packet = await self.packet_queue.get()
await self.send_function(packet)
except asyncio.exceptions.CancelledError:
logger.debug('sink pump task done')
break
except Exception as error:
logger.warn(f'exception while sending packet: {error}')
break
self.pump_task = asyncio.get_running_loop().create_task(pump_packets())
def close(self):
if self.pump_task:
self.pump_task.cancel()
# -----------------------------------------------------------------------------
class PumpedTransport(Transport):
def __init__(self, source, sink, close_function):
super().__init__(source, sink)
self.close_function = close_function
def start(self):
self.source.start()
self.sink.start()
async def close(self):
await super().close()
await self.close_function()

View File

@@ -0,0 +1,52 @@
# 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.
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: emulated_bluetooth_packets.proto
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n emulated_bluetooth_packets.proto\x12\x1b\x61ndroid.emulation.bluetooth\"\xfb\x01\n\tHCIPacket\x12?\n\x04type\x18\x01 \x01(\x0e\x32\x31.android.emulation.bluetooth.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"\x9c\x01\n\nPacketType\x12\x1b\n\x17PACKET_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PACKET_TYPE_HCI_COMMAND\x10\x01\x12\x13\n\x0fPACKET_TYPE_ACL\x10\x02\x12\x13\n\x0fPACKET_TYPE_SCO\x10\x03\x12\x15\n\x11PACKET_TYPE_EVENT\x10\x04\x12\x13\n\x0fPACKET_TYPE_ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3')
_HCIPACKET = DESCRIPTOR.message_types_by_name['HCIPacket']
_HCIPACKET_PACKETTYPE = _HCIPACKET.enum_types_by_name['PacketType']
HCIPacket = _reflection.GeneratedProtocolMessageType('HCIPacket', (_message.Message,), {
'DESCRIPTOR' : _HCIPACKET,
'__module__' : 'emulated_bluetooth_packets_pb2'
# @@protoc_insertion_point(class_scope:android.emulation.bluetooth.HCIPacket)
})
_sym_db.RegisterMessage(HCIPacket)
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
_HCIPACKET._serialized_start=66
_HCIPACKET._serialized_end=317
_HCIPACKET_PACKETTYPE._serialized_start=161
_HCIPACKET_PACKETTYPE._serialized_end=317
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,53 @@
# 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.
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: emulated_bluetooth.proto
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x65mulated_bluetooth.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto\"\x19\n\x07RawData\x12\x0e\n\x06packet\x18\x01 \x01(\x0c\x32\xcb\x02\n\x18\x45mulatedBluetoothService\x12\x64\n\x12registerClassicPhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12`\n\x0eregisterBlePhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12g\n\x11registerHCIDevice\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42\"\n\x1e\x63om.android.emulator.bluetoothP\x01\x62\x06proto3')
_RAWDATA = DESCRIPTOR.message_types_by_name['RawData']
RawData = _reflection.GeneratedProtocolMessageType('RawData', (_message.Message,), {
'DESCRIPTOR' : _RAWDATA,
'__module__' : 'emulated_bluetooth_pb2'
# @@protoc_insertion_point(class_scope:android.emulation.bluetooth.RawData)
})
_sym_db.RegisterMessage(RawData)
_EMULATEDBLUETOOTHSERVICE = DESCRIPTOR.services_by_name['EmulatedBluetoothService']
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\036com.android.emulator.bluetoothP\001'
_RAWDATA._serialized_start=91
_RAWDATA._serialized_end=116
_EMULATEDBLUETOOTHSERVICE._serialized_start=119
_EMULATEDBLUETOOTHSERVICE._serialized_end=450
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,207 @@
# 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.
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
from . import emulated_bluetooth_pb2 as emulated__bluetooth__pb2
class EmulatedBluetoothServiceStub(object):
"""An Emulated Bluetooth Service exposes the emulated bluetooth chip from the
android emulator. It allows you to register emulated bluetooth devices and
control the packets that are exchanged between the device and the world.
This service enables you to establish a "virtual network" of emulated
bluetooth devices that can interact with each other.
Note: This is not yet finalized, it is likely that these definitions will
evolve.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.registerClassicPhy = channel.stream_stream(
'/android.emulation.bluetooth.EmulatedBluetoothService/registerClassicPhy',
request_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
response_deserializer=emulated__bluetooth__pb2.RawData.FromString,
)
self.registerBlePhy = channel.stream_stream(
'/android.emulation.bluetooth.EmulatedBluetoothService/registerBlePhy',
request_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
response_deserializer=emulated__bluetooth__pb2.RawData.FromString,
)
self.registerHCIDevice = channel.stream_stream(
'/android.emulation.bluetooth.EmulatedBluetoothService/registerHCIDevice',
request_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
response_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
)
class EmulatedBluetoothServiceServicer(object):
"""An Emulated Bluetooth Service exposes the emulated bluetooth chip from the
android emulator. It allows you to register emulated bluetooth devices and
control the packets that are exchanged between the device and the world.
This service enables you to establish a "virtual network" of emulated
bluetooth devices that can interact with each other.
Note: This is not yet finalized, it is likely that these definitions will
evolve.
"""
def registerClassicPhy(self, request_iterator, context):
"""Connect device to link layer. This will establish a direct connection
to the emulated bluetooth chip and configure the following:
- Each connection creates a new device and attaches it to the link layer
- Link Layer packets are transmitted directly to the phy
This should be used for classic connections.
This is used to directly connect various android emulators together.
For example a wear device can connect to an android emulator through
this.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def registerBlePhy(self, request_iterator, context):
"""Connect device to link layer. This will establish a direct connection
to root canal and execute the following:
- Each connection creates a new device and attaches it to the link layer
- Link Layer packets are transmitted directly to the phy
This should be used for BLE connections.
This is used to directly connect various android emulators together.
For example a wear device can connect to an android emulator through
this.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def registerHCIDevice(self, request_iterator, context):
"""Connect the device to the emulated bluetooth chip. The device will
participate in the network. You can configure the chip to scan, advertise
and setup connections with other devices that are connected to the
network.
This is usually used when you have a need for an emulated bluetooth chip
and have a bluetooth stack that can interpret and handle the packets
correctly.
For example the apache nimble stack can use this endpoint as the
transport layer.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_EmulatedBluetoothServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'registerClassicPhy': grpc.stream_stream_rpc_method_handler(
servicer.registerClassicPhy,
request_deserializer=emulated__bluetooth__pb2.RawData.FromString,
response_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
),
'registerBlePhy': grpc.stream_stream_rpc_method_handler(
servicer.registerBlePhy,
request_deserializer=emulated__bluetooth__pb2.RawData.FromString,
response_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
),
'registerHCIDevice': grpc.stream_stream_rpc_method_handler(
servicer.registerHCIDevice,
request_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
response_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'android.emulation.bluetooth.EmulatedBluetoothService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
# This class is part of an EXPERIMENTAL API.
class EmulatedBluetoothService(object):
"""An Emulated Bluetooth Service exposes the emulated bluetooth chip from the
android emulator. It allows you to register emulated bluetooth devices and
control the packets that are exchanged between the device and the world.
This service enables you to establish a "virtual network" of emulated
bluetooth devices that can interact with each other.
Note: This is not yet finalized, it is likely that these definitions will
evolve.
"""
@staticmethod
def registerClassicPhy(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerClassicPhy',
emulated__bluetooth__pb2.RawData.SerializeToString,
emulated__bluetooth__pb2.RawData.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def registerBlePhy(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerBlePhy',
emulated__bluetooth__pb2.RawData.SerializeToString,
emulated__bluetooth__pb2.RawData.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def registerHCIDevice(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerHCIDevice',
emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
emulated__bluetooth__packets__pb2.HCIPacket.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)

View File

@@ -0,0 +1,43 @@
# 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.
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: emulated_bluetooth_vhci.proto
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1d\x65mulated_bluetooth_vhci.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto2y\n\x15VhciForwardingService\x12`\n\nattachVhci\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3')
_VHCIFORWARDINGSERVICE = DESCRIPTOR.services_by_name['VhciForwardingService']
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
_VHCIFORWARDINGSERVICE._serialized_start=96
_VHCIFORWARDINGSERVICE._serialized_end=217
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,114 @@
# 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.
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
class VhciForwardingServiceStub(object):
"""This is a service which allows you to directly intercept the VHCI packets
that are coming and going to the device before they are delivered to
the rootcanal service described below.
This service is usually not available on the emulator, and must be explictly
requested from the commandline.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.attachVhci = channel.stream_stream(
'/android.emulation.bluetooth.VhciForwardingService/attachVhci',
request_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
response_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
)
class VhciForwardingServiceServicer(object):
"""This is a service which allows you to directly intercept the VHCI packets
that are coming and going to the device before they are delivered to
the rootcanal service described below.
This service is usually not available on the emulator, and must be explictly
requested from the commandline.
"""
def attachVhci(self, request_iterator, context):
"""This attach directly to /dev/vhci inside the android guest if available
- This will disable root canal.
- You will have to provide your own virtual bluetooth chip.
Some things to be aware of:
- Only one client can be active.
- Registering when bluetooth is active in android can result in
undefined behavior.
- If a client disconnects, rootcanal will be activated again
Status codes:
- FAILED_PRECONDITION (code 9) If another client is controlling /dev/vhci.
This is an internal testing only interface, and is NOT publicly
supported.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_VhciForwardingServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'attachVhci': grpc.stream_stream_rpc_method_handler(
servicer.attachVhci,
request_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
response_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'android.emulation.bluetooth.VhciForwardingService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
# This class is part of an EXPERIMENTAL API.
class VhciForwardingService(object):
"""This is a service which allows you to directly intercept the VHCI packets
that are coming and going to the device before they are delivered to
the rootcanal service described below.
This service is usually not available on the emulator, and must be explictly
requested from the commandline.
"""
@staticmethod
def attachVhci(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.VhciForwardingService/attachVhci',
emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
emulated__bluetooth__packets__pb2.HCIPacket.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)

60
bumble/transport/file.py Normal file
View File

@@ -0,0 +1,60 @@
# 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 io
import logging
from .common import Transport, StreamPacketSource, StreamPacketSink
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_file_transport(spec):
'''
Open a File transport (typically not for a real file, but for a PTY or other unix virtual files).
The parameter string is the path of the file to open
'''
# Open the file
file = io.open(spec, 'r+b', buffering=0)
# Setup reading
read_transport, packet_source = await asyncio.get_running_loop().connect_read_pipe(
lambda: StreamPacketSource(),
file
)
# Setup writing
write_transport, _ = await asyncio.get_running_loop().connect_write_pipe(
lambda: asyncio.BaseProtocol(),
file
)
packet_sink = StreamPacketSink(write_transport)
class FileTransport(Transport):
async def close(self):
read_transport.close()
write_transport.close()
file.close()
return FileTransport(packet_source, packet_sink)

View File

@@ -0,0 +1,146 @@
# 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 logging
import struct
import os
import socket
import ctypes
import collections
from .common import Transport, ParserSource
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_hci_socket_transport(spec):
'''
Open an HCI Socket (only available on some platforms).
The parameter string is either empty (to use the first/default Bluetooth adapter)
or a 0-based integer to indicate the adapter number.
'''
HCI_CHANNEL_USER = 1
# Create a raw HCI socket
try:
hci_socket = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_RAW | socket.SOCK_NONBLOCK, socket.BTPROTO_HCI)
except AttributeError:
# Not supported on this platform
logger.info("HCI sockets not supported on this platform")
raise Exception('Bluetooth HCI sockets not supported on this platform')
# Compute the adapter index
if spec is None:
adapter_index = 0
else:
adapter_index = int(spec)
# Bind the socket
# NOTE: since Python doesn't support binding with the required address format (yet),
# we need to go directly to the C runtime...
try:
ctypes.cdll.LoadLibrary('libc.so.6')
libc = ctypes.CDLL('libc.so.6', use_errno=True)
except OSError:
logger.info("HCI sockets not supported on this platform")
raise Exception('Bluetooth HCI sockets not supported on this platform')
libc.bind.argtypes = (ctypes.c_int, ctypes.POINTER(ctypes.c_char), ctypes.c_int)
libc.bind.restype = ctypes.c_int
bind_address = struct.pack('<HHH', socket.AF_BLUETOOTH, adapter_index, HCI_CHANNEL_USER)
if libc.bind(hci_socket.fileno(), ctypes.create_string_buffer(bind_address), len(bind_address)) != 0:
raise IOError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
class HciSocketSource(ParserSource):
def __init__(self, socket):
super().__init__()
self.socket = socket
asyncio.get_running_loop().add_reader(socket.fileno(), self.recv_until_would_block)
def recv_until_would_block(self):
logger.debug('recv until would block +++')
while True:
try:
packet = self.socket.recv(4096)
logger.debug(f'received packet {len(packet)} bytes')
self.parser.feed_data(packet)
except BlockingIOError:
logger.debug('recv would block')
break
def close(self):
asyncio.get_running_loop().remove_reader(self.socket.fileno())
class HciSocketSink:
def __init__(self, socket):
self.socket = socket
self.packets = collections.deque()
self.writer_added = False
def send_until_would_block(self):
logger.debug('send until would block ---')
while self.packets:
packet = self.packets.pop()
logger.debug('sending packet')
try:
bytes_written = self.socket.send(packet)
except BlockingIOError:
bytes_written = 0
if bytes_written != len(packet):
# Note: we assume here that there are no partial writes
logger.debug('send would block')
break
if self.packets:
# There's still something to send, ensure that we are monitoring the socket
if not self.writer_added:
asyncio.get_running_loop().add_writer(socket.fileno(), self.send_until_would_block)
self.writer_added = True
else:
# Nothing left to send, stop monitoring the socket
if self.writer_added:
asyncio.get_running_loop().remove_writer(self.socket.fileno())
self.writer_added = False
def on_packet(self, packet):
self.packets.appendleft(packet)
self.send_until_would_block()
def close(self):
if self.writer_added:
asyncio.get_running_loop().remove_writer(self.socket.fileno())
class HciSocketTransport(Transport):
def __init__(self, socket, source, sink):
super().__init__(source, sink)
self.socket = socket
async def close(self):
logger.debug('closing HCI socket transport')
self.source.close()
self.sink.close()
self.socket.close()
packet_source = HciSocketSource(hci_socket)
packet_sink = HciSocketSink(hci_socket)
return HciSocketTransport(hci_socket, packet_source, packet_sink)

82
bumble/transport/pty.py Normal file
View File

@@ -0,0 +1,82 @@
# 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 pty
import tty
import io
import atexit
import os
import logging
from .common import Transport, StreamPacketSource, StreamPacketSink
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_pty_transport(spec):
'''
Open a PTY transport.
The parameter string may be empty, or a path name where a symbolic link
to the PTY will be created (the link will be removed when the transport
is closed or when the process exits)
'''
primary, replica = pty.openpty()
replica_path = os.ttyname(replica)
logger.debug(f'pty open at {replica_path}')
tty.setraw(primary)
tty.setraw(replica)
read_transport, packet_source = await asyncio.get_running_loop().connect_read_pipe(
lambda: StreamPacketSource(),
io.open(primary, 'rb', closefd=False)
)
write_transport, _ = await asyncio.get_running_loop().connect_write_pipe(
lambda: asyncio.BaseProtocol(),
io.open(primary, 'wb', closefd=False)
)
packet_sink = StreamPacketSink(write_transport)
def cleanup():
if spec:
try:
os.unlink(spec)
except FileNotFoundError:
pass
# If required, create a symbolic link to the replica
# NOTE: the link will be removed when this process exits
if spec:
os.symlink(replica_path, spec)
logger.debug(f'linked pty at {spec}')
atexit.register(cleanup)
class PtyTransport(Transport):
async def close(self):
write_transport.close()
read_transport.close()
os.close(primary)
os.close(replica)
cleanup()
return PtyTransport(packet_source, packet_sink)

276
bumble/transport/pyusb.py Normal file
View File

@@ -0,0 +1,276 @@
# 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 logging
import usb.core
import usb.util
import threading
import time
from colors import color
from .common import Transport, ParserSource
from .. import hci
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_pyusb_transport(spec):
'''
Open a USB transport. [Implementation based on PyUSB]
The parameter string has this syntax:
either <index> or <vendor>:<product>
With <index> as the 0-based index to select amongst all the devices that appear
to be supporting Bluetooth HCI (0 being the first one), or
Where <vendor> and <product> are the vendor ID and product ID in hexadecimal.
Examples:
0 --> the first BT USB dongle
04b4:f901 --> the BT USB dongle with vendor=04b4 and product=f901
'''
USB_RECIPIENT_DEVICE = 0x00
USB_REQUEST_TYPE_CLASS = 0x01 << 5
USB_ENDPOINT_EVENTS_IN = 0x81
USB_ENDPOINT_ACL_IN = 0x82
USB_ENDPOINT_SCO_IN = 0x83
USB_ENDPOINT_ACL_OUT = 0x02
# USB_ENDPOINT_SCO_OUT = 0x03
USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0
USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
READ_SIZE = 1024
READ_TIMEOUT = 1000
class UsbPacketSink:
def __init__(self, device):
self.device = device
self.thread = threading.Thread(target=self.run)
self.loop = asyncio.get_running_loop()
self.stop_event = None
def on_packet(self, packet):
# TODO: don't block here, just queue for the write thread
if len(packet) == 0:
logger.warning('packet too short')
return
packet_type = packet[0]
try:
if packet_type == hci.HCI_ACL_DATA_PACKET:
self.device.write(USB_ENDPOINT_ACL_OUT, packet[1:])
elif packet_type == hci.HCI_COMMAND_PACKET:
self.device.ctrl_transfer(USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS, 0, 0, 0, packet[1:])
else:
logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
except usb.core.USBTimeoutError:
logger.warning('USB Write Timeout')
except usb.core.USBError as error:
logger.warning(f'USB write error: {error}')
time.sleep(1) # Sleep one second to avoid busy looping
def start(self):
self.thread.start()
async def stop(self):
# Create stop events and wait for them to be signaled
self.stop_event = asyncio.Event()
await self.stop_event.wait()
def run(self):
while self.stop_event is None:
time.sleep(1)
self.loop.call_soon_threadsafe(lambda: self.stop_event.set())
class UsbPacketSource(asyncio.Protocol, ParserSource):
def __init__(self, device, sco_enabled):
super().__init__()
self.device = device
self.loop = asyncio.get_running_loop()
self.queue = asyncio.Queue()
self.event_thread = threading.Thread(
target=self.run,
args=(USB_ENDPOINT_EVENTS_IN, hci.HCI_EVENT_PACKET)
)
self.event_thread.stop_event = None
self.acl_thread = threading.Thread(
target=self.run,
args=(USB_ENDPOINT_ACL_IN, hci.HCI_ACL_DATA_PACKET)
)
self.acl_thread.stop_event = None
# SCO support is optional
self.sco_enabled = sco_enabled
if sco_enabled:
self.sco_thread = threading.Thread(
target=self.run,
args=(USB_ENDPOINT_SCO_IN, hci.HCI_SYNCHRONOUS_DATA_PACKET)
)
self.sco_thread.stop_event = None
def data_received(self, packet):
self.parser.feed_data(packet)
def enqueue(self, packet):
self.queue.put_nowait(packet)
async def dequeue(self):
while True:
try:
data = await self.queue.get()
except asyncio.CancelledError:
return
self.data_received(data)
def start(self):
self.dequeue_task = self.loop.create_task(self.dequeue())
self.event_thread.start()
self.acl_thread.start()
if self.sco_enabled:
self.sco_thread.start()
async def stop(self):
# Stop the dequeuing task
self.dequeue_task.cancel()
# Create stop events and wait for them to be signaled
self.event_thread.stop_event = asyncio.Event()
self.acl_thread.stop_event = asyncio.Event()
await self.event_thread.stop_event.wait()
await self.acl_thread.stop_event.wait()
if self.sco_enabled:
await self.sco_thread.stop_event.wait()
def run(self, endpoint, packet_type):
# Read until asked to stop
current_thread = threading.current_thread()
while current_thread.stop_event is None:
try:
# Read, with a timeout of 1 second
data = self.device.read(endpoint, READ_SIZE, timeout=READ_TIMEOUT)
packet = bytes([packet_type]) + data.tobytes()
self.loop.call_soon_threadsafe(self.enqueue, packet)
except usb.core.USBTimeoutError:
continue
except usb.core.USBError:
# Don't log this: because pyusb doesn't really support multiple threads
# reading at the same time, we can get occasional USBError(errno=5)
# Input/Output errors reported, but they seem to be harmless.
# Until support for async or multi-thread support is added to pyusb,
# we'll just live with this as is...
# logger.warning(f'USB read error: {error}')
time.sleep(1) # Sleep one second to avoid busy looping
stop_event = current_thread.stop_event
self.loop.call_soon_threadsafe(lambda: stop_event.set())
class UsbTransport(Transport):
def __init__(self, device, source, sink):
super().__init__(source, sink)
self.device = device
async def close(self):
await self.source.stop()
await self.sink.stop()
usb.util.release_interface(self.device, 0)
# Find the device according to the spec moniker
if ':' in spec:
vendor_id, product_id = spec.split(':')
device = usb.core.find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
else:
device_index = int(spec)
devices = list(usb.core.find(
find_all = 1,
bDeviceClass = USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
bDeviceSubClass = USB_DEVICE_SUBCLASS_RF_CONTROLLER,
bDeviceProtocol = USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
))
if len(devices) > device_index:
device = devices[device_index]
else:
device = None
if device is None:
raise ValueError('device not found')
logger.debug(f'USB Device: {device}')
# Detach the kernel driver if needed
if device.is_kernel_driver_active(0):
logger.debug("detaching kernel driver")
device.detach_kernel_driver(0)
# Set configuration, if needed
try:
configuration = device.get_active_configuration()
except usb.core.USBError:
device.set_configuration()
configuration = device.get_active_configuration()
interface = configuration[(0, 0)]
logger.debug(f'USB Interface: {interface}')
usb.util.claim_interface(device, 0)
# Select an alternate setting for SCO, if available
sco_enabled = False
# NOTE: this is disabled for now, because SCO with alternate settings is broken,
# see: https://github.com/libusb/libusb/issues/36
#
# best_packet_size = 0
# best_interface = None
# sco_enabled = False
# for interface in configuration:
# iso_in_endpoint = None
# iso_out_endpoint = None
# for endpoint in interface:
# if (endpoint.bEndpointAddress == USB_ENDPOINT_SCO_IN and
# usb.util.endpoint_direction(endpoint.bEndpointAddress) == usb.util.ENDPOINT_IN and
# usb.util.endpoint_type(endpoint.bmAttributes) == usb.util.ENDPOINT_TYPE_ISO):
# iso_in_endpoint = endpoint
# continue
# if (endpoint.bEndpointAddress == USB_ENDPOINT_SCO_OUT and
# usb.util.endpoint_direction(endpoint.bEndpointAddress) == usb.util.ENDPOINT_OUT and
# usb.util.endpoint_type(endpoint.bmAttributes) == usb.util.ENDPOINT_TYPE_ISO):
# iso_out_endpoint = endpoint
# if iso_in_endpoint is not None and iso_out_endpoint is not None:
# if iso_out_endpoint.wMaxPacketSize > best_packet_size:
# best_packet_size = iso_out_endpoint.wMaxPacketSize
# best_interface = interface
# if best_interface is not None:
# logger.debug(f'SCO enabled, selecting alternate setting (wMaxPacketSize={best_packet_size}): {best_interface}')
# sco_enabled = True
# try:
# device.set_interface_altsetting(
# interface = best_interface.bInterfaceNumber,
# alternate_setting = best_interface.bAlternateSetting
# )
# except usb.USBError:
# logger.warning('failed to set alternate setting')
packet_source = UsbPacketSource(device, sco_enabled)
packet_sink = UsbPacketSink(device)
packet_source.start()
packet_sink.start()
return UsbTransport(device, packet_source, packet_sink)

View File

@@ -0,0 +1,72 @@
# Copyright 2021-2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
import logging
import serial_asyncio
from .common import Transport, StreamPacketSource, StreamPacketSink
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_serial_transport(spec):
'''
Open a serial port transport.
The parameter string has this syntax:
<device-path>[,<speed>][,rtscts][,dsrdtr]
When <speed> is omitted, the default value of 1000000 is used
When "rtscts" is specified, RTS/CTS hardware flow control is enabled
When "dsrdtr" is specified, DSR/DTR hardware flow control is enabled
Examples:
/dev/tty.usbmodem0006839912172
/dev/tty.usbmodem0006839912172,1000000
/dev/tty.usbmodem0006839912172,rtscts
'''
speed = 1000000
rtscts = False
dsrdtr = False
if ',' in spec:
parts = spec.split(',')
device = parts[0]
for part in parts[1:]:
if part == 'rtscts':
rtscts = True
elif part == 'dsrdtr':
dsrdtr = True
elif part.isnumeric():
speed = int(part)
else:
device = spec
serial_transport, packet_source = await serial_asyncio.create_serial_connection(
asyncio.get_running_loop(),
lambda: StreamPacketSource(),
device,
baudrate=speed,
rtscts=rtscts,
dsrdtr=dsrdtr
)
packet_sink = StreamPacketSink(serial_transport)
return Transport(packet_source, packet_sink)

View File

@@ -0,0 +1,52 @@
# 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 logging
from .common import Transport, StreamPacketSource, StreamPacketSink
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_tcp_client_transport(spec):
'''
Open a TCP client transport.
The parameter string has this syntax:
<remote-host>:<remote-port>
Example: 127.0.0.1:9001
'''
class TcpPacketSource(StreamPacketSource):
def connection_lost(self, error):
logger.debug(f'connection lost: {error}')
self.terminated.set_result(error)
remote_host, remote_port = spec.split(':')
tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(
lambda: TcpPacketSource(),
host=remote_host,
port=int(remote_port),
)
packet_sink = StreamPacketSink(tcp_transport)
return Transport(packet_source, packet_sink)

View File

@@ -0,0 +1,88 @@
# 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 logging
from .common import Transport, StreamPacketSource
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_tcp_server_transport(spec):
'''
Open a TCP server transport.
The parameter string has this syntax:
<local-host>:<local-port>
Where <local-host> may be the address of a local network interface, or '_'
to accept connections on all local network interfaces.
Example: _:9001
'''
class TcpServerTransport(Transport):
async def close(self):
await super().close()
class TcpServerProtocol:
def __init__(self, packet_source, packet_sink):
self.packet_source = packet_source
self.packet_sink = packet_sink
# Called when a new connection is established
def connection_made(self, transport):
peername = transport.get_extra_info('peername')
logger.debug('connection from {}'.format(peername))
self.packet_sink.transport = transport
# Called when the client is disconnected
def connection_lost(self, error):
logger.debug(f'connection lost: {error}')
self.packet_sink.transport = None
def eof_received(self):
logger.debug('connection end')
self.packet_sink.transport = None
# Called when data is received on the socket
def data_received(self, data):
self.packet_source.data_received(data)
class TcpServerPacketSink:
def __init__(self):
self.transport = None
def on_packet(self, packet):
if self.transport:
self.transport.write(packet)
else:
logger.debug('no client, dropping packet')
local_host, local_port = spec.split(':')
packet_source = StreamPacketSource()
packet_sink = TcpServerPacketSink()
await asyncio.get_running_loop().create_server(
lambda: TcpServerProtocol(packet_source, packet_sink),
host=local_host if local_host != '_' else None,
port=int(local_port),
)
return TcpServerTransport(packet_source, packet_sink)

63
bumble/transport/udp.py Normal file
View File

@@ -0,0 +1,63 @@
# 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 logging
from .common import Transport, ParserSource
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_udp_transport(spec):
'''
Open a UDP transport.
The parameter string has this syntax:
<local-host>:<local-port>,<remote-host>:<remote-port>
Example: 0.0.0.0:9000,127.0.0.1:9001
'''
class UdpPacketSource(asyncio.DatagramProtocol, ParserSource):
def datagram_received(self, data, addr):
self.parser.feed_data(data)
class UdpPacketSink:
def __init__(self, transport):
self.transport = transport
def on_packet(self, packet):
self.transport.sendto(packet)
def close(self):
self.transport.close()
local, remote = spec.split(',')
local_host, local_port = local.split(':')
remote_host, remote_port = remote.split(':')
udp_transport, packet_source = await asyncio.get_running_loop().create_datagram_endpoint(
lambda: UdpPacketSource(),
local_addr=(local_host, int(local_port)),
remote_addr=(remote_host, int(remote_port))
)
packet_sink = UdpPacketSink(udp_transport)
return Transport(packet_source, packet_sink)

324
bumble/transport/usb.py Normal file
View File

@@ -0,0 +1,324 @@
# 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 logging
import usb1
import threading
import collections
from colors import color
from .common import Transport, ParserSource
from .. import hci
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_usb_transport(spec):
'''
Open a USB transport.
The parameter string has this syntax:
either <index> or <vendor>:<product>
With <index> as the 0-based index to select amongst all the devices that appear
to be supporting Bluetooth HCI (0 being the first one), or
Where <vendor> and <product> are the vendor ID and product ID in hexadecimal.
Examples:
0 --> the first BT USB dongle
04b4:f901 --> the BT USB dongle with vendor=04b4 and product=f901
'''
USB_RECIPIENT_DEVICE = 0x00
USB_REQUEST_TYPE_CLASS = 0x01 << 5
USB_ENDPOINT_EVENTS_IN = 0x81
USB_ENDPOINT_ACL_IN = 0x82
USB_ENDPOINT_ACL_OUT = 0x02
USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0
USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
READ_SIZE = 1024
class UsbPacketSink:
def __init__(self, device):
self.device = device
self.transfer = device.getTransfer()
self.packets = collections.deque() # Queue of packets waiting to be sent
self.loop = asyncio.get_running_loop()
self.cancel_done = self.loop.create_future()
self.closed = False
def start(self):
pass
def on_packet(self, packet):
# Ignore packets if we're closed
if self.closed:
return
if len(packet) == 0:
logger.warning('packet too short')
return
# Queue the packet
self.packets.append(packet)
if len(self.packets) == 1:
# The queue was previously empty, re-prime the pump
self.process_queue()
def on_packet_sent(self, transfer):
status = transfer.getStatus()
# logger.debug(f'<<< USB out transfer callback: status={status}')
if status == usb1.TRANSFER_COMPLETED:
self.loop.call_soon_threadsafe(self.on_packet_sent_)
elif status == usb1.TRANSFER_CANCELLED:
self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
else:
logger.warning(color(f'!!! out transfer not completed: status={status}', 'red'))
def on_packet_sent_(self):
if self.packets:
self.packets.popleft()
self.process_queue()
def process_queue(self):
if len(self.packets) == 0:
return # Nothing to do
packet = self.packets[0]
packet_type = packet[0]
if packet_type == hci.HCI_ACL_DATA_PACKET:
self.transfer.setBulk(
USB_ENDPOINT_ACL_OUT,
packet[1:],
callback=self.on_packet_sent
)
logger.debug('submit ACL')
self.transfer.submit()
elif packet_type == hci.HCI_COMMAND_PACKET:
self.transfer.setControl(
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS, 0, 0, 0,
packet[1:],
callback=self.on_packet_sent
)
logger.debug('submit COMMAND')
self.transfer.submit()
else:
logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
async def close(self):
self.closed = True
# Empty the packet queue so that we don't send any more data
self.packets.clear()
# If we have a transfer in flight, cancel it
if self.transfer.isSubmitted():
# Try to cancel the transfer, but that may fail because it may have already completed
try:
self.transfer.cancel()
logger.debug('waiting for OUT transfer cancellation to be done...')
await self.cancel_done
logger.debug('OUT transfer cancellation done')
except usb1.USBError:
logger.debug('OUT transfer likely already completed')
class UsbPacketSource(asyncio.Protocol, ParserSource):
def __init__(self, context, device):
super().__init__()
self.context = context
self.device = device
self.loop = asyncio.get_running_loop()
self.queue = asyncio.Queue()
self.closed = False
self.event_loop_done = self.loop.create_future()
self.cancel_done = {
hci.HCI_EVENT_PACKET: self.loop.create_future(),
hci.HCI_ACL_DATA_PACKET: self.loop.create_future()
}
# Create a thread to process events
self.event_thread = threading.Thread(target=self.run)
def start(self):
# Set up transfer objects for input
self.events_in_transfer = device.getTransfer()
self.events_in_transfer.setInterrupt(
USB_ENDPOINT_EVENTS_IN,
READ_SIZE,
callback=self.on_packet_received,
user_data=hci.HCI_EVENT_PACKET
)
self.events_in_transfer.submit()
self.acl_in_transfer = device.getTransfer()
self.acl_in_transfer.setBulk(
USB_ENDPOINT_ACL_IN,
READ_SIZE,
callback=self.on_packet_received,
user_data=hci.HCI_ACL_DATA_PACKET
)
self.acl_in_transfer.submit()
self.dequeue_task = self.loop.create_task(self.dequeue())
self.event_thread.start()
def on_packet_received(self, transfer):
packet_type = transfer.getUserData()
status = transfer.getStatus()
# logger.debug(f'<<< USB IN transfer callback: status={status} packet_type={packet_type}')
if status == usb1.TRANSFER_COMPLETED:
packet = bytes([packet_type]) + transfer.getBuffer()[:transfer.getActualLength()]
self.loop.call_soon_threadsafe(self.queue.put_nowait, packet)
elif status == usb1.TRANSFER_CANCELLED:
self.loop.call_soon_threadsafe(self.cancel_done[packet_type].set_result, None)
return
else:
logger.warning(color(f'!!! transfer not completed: status={status}', 'red'))
# Re-submit the transfer so we can receive more data
transfer.submit()
async def dequeue(self):
while not self.closed:
try:
packet = await self.queue.get()
except asyncio.CancelledError:
return
self.parser.feed_data(packet)
def run(self):
logger.debug('starting USB event loop')
while self.events_in_transfer.isSubmitted() or self.acl_in_transfer.isSubmitted():
try:
self.context.handleEvents()
except usb1.USBErrorInterrupted:
pass
logger.debug('USB event loop done')
self.event_loop_done.set_result(None)
async def close(self):
self.closed = True
self.dequeue_task.cancel()
# Cancel the transfers
for transfer in (self.events_in_transfer, self.acl_in_transfer):
if transfer.isSubmitted():
# Try to cancel the transfer, but that may fail because it may have already completed
packet_type = transfer.getUserData()
try:
transfer.cancel()
logger.debug(f'waiting for IN[{packet_type}] transfer cancellation to be done...')
await self.cancel_done[packet_type]
logger.debug(f'IN[{packet_type}] transfer cancellation done')
except usb1.USBError:
logger.debug(f'IN[{packet_type}] transfer likely already completed')
# Wait for the thread to terminate
await self.event_loop_done
class UsbTransport(Transport):
def __init__(self, context, device, interface, source, sink):
super().__init__(source, sink)
self.context = context
self.device = device
self.interface = interface
# Get exclusive access
device.claimInterface(interface)
# The source and sink can now start
source.start()
sink.start()
async def close(self):
await self.source.close()
await self.sink.close()
self.device.releaseInterface(self.interface)
self.device.close()
self.context.close()
# Find the device according to the spec moniker
context = usb1.USBContext()
context.open()
try:
found = None
if ':' in spec:
vendor_id, product_id = spec.split(':')
found = context.getByVendorIDAndProductID(int(vendor_id, 16), int(product_id, 16), skip_on_error=True)
else:
device_index = int(spec)
device_iterator = context.getDeviceIterator(skip_on_error=True)
try:
for device in device_iterator:
if device.getDeviceClass() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER and \
device.getDeviceSubClass() == USB_DEVICE_SUBCLASS_RF_CONTROLLER and \
device.getDeviceProtocol() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER:
if device_index == 0:
found = device
break
device_index -= 1
device.close()
finally:
device_iterator.close()
if found is None:
context.close()
raise ValueError('device not found')
logger.debug(f'USB Device: {found}')
device = found.open()
# Set the configuration if needed
try:
configuration = device.getConfiguration()
logger.debug(f'current configuration = {configuration}')
except usb1.USBError:
try:
logger.debug('setting configuration 1')
device.setConfiguration(1)
except usb1.USBError:
logger.debug('failed to set configuration 1')
# Use the first interface
interface = 0
# Detach the kernel driver if supported and needed
if usb1.hasCapability(usb1.CAP_SUPPORTS_DETACH_KERNEL_DRIVER):
try:
if device.kernelDriverActive(interface):
logger.debug("detaching kernel driver")
device.detachKernelDriver(interface)
except usb1.USBError:
pass
source = UsbPacketSource(context, device)
sink = UsbPacketSink(device)
return UsbTransport(context, device, interface, source, sink)
except usb1.USBError as error:
logger.warning(color(f'!!! failed to open USB device: {error}', 'red'))
context.close()
raise

59
bumble/transport/vhci.py Normal file
View File

@@ -0,0 +1,59 @@
# 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 logging
from .file import open_file_transport
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_vhci_transport(spec):
'''
Open a VHCI transport (only available on some platforms).
The parameter string is either empty (to use the default VHCI device
path at /dev/vhci), or the path of a VHCI device
'''
HCI_VENDOR_PKT = 0xff
HCI_BREDR = 0x00 # Controller type
# Open the VHCI device
transport = await open_file_transport(spec or '/dev/vhci')
# Override the source's `data_received` method so that we can
# filter out the vendor packet that is received just after the
# initial open
def vhci_data_received(data):
if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
if len(data) == 4:
hci_index = data[2] << 8 | data[3]
logger.info(f'HCI index {hci_index}')
else:
transport.source.parser.feed_data(data)
transport.source.data_received = vhci_data_received
# Write the initial config
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
return transport

View File

@@ -0,0 +1,49 @@
# 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 logging
import websockets
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_ws_client_transport(spec):
'''
Open a WebSocket client transport.
The parameter string has this syntax:
<remote-host>:<remote-port>
Example: 127.0.0.1:9001
'''
remote_host, remote_port = spec.split(':')
uri = f'ws://{remote_host}:{remote_port}'
websocket = await websockets.connect(uri)
transport = PumpedTransport(
PumpedPacketSource(websocket.recv),
PumpedPacketSink(websocket.send),
websocket.close
)
transport.start()
return transport

View File

@@ -0,0 +1,81 @@
# 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 logging
import websockets
from .common import Transport, ParserSource, PumpedPacketSink
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_ws_server_transport(spec):
'''
Open a WebSocket server transport.
The parameter string has this syntax:
<local-host>:<local-port>
Where <local-host> may be the address of a local network interface, or '_'
to accept connections on all local network interfaces.
Example: _:9001
'''
class WsServerTransport(Transport):
def __init__(self):
source = ParserSource()
sink = PumpedPacketSink(self.send_packet)
self.connection = asyncio.get_running_loop().create_future()
super().__init__(source, sink)
async def serve(self, local_host, local_port):
self.sink.start()
self.server = await websockets.serve(
ws_handler = self.on_connection,
host = local_host if local_host != '_' else None,
port = int(local_port)
)
logger.debug(f'websocket server ready on port {local_port}')
async def on_connection(self, connection):
logger.debug(f'new connection on {connection.local_address} from {connection.remote_address}')
self.connection.set_result(connection)
try:
async for packet in connection:
if type(packet) is bytes:
self.source.parser.feed_data(packet)
else:
logger.warn('discarding packet: not a BINARY frame')
except websockets.WebSocketException as error:
logger.debug(f'exception while receiving packet: {error}')
# Wait for a new connection
self.connection = asyncio.get_running_loop().create_future()
async def send_packet(self, packet):
connection = await self.connection
return await connection.send(packet)
local_host, local_port = spec.split(':')
transport = WsServerTransport()
await transport.serve(local_host, local_port)
return transport

142
bumble/utils.py Normal file
View File

@@ -0,0 +1,142 @@
# 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 logging
import traceback
from functools import wraps
from colors import color
from pyee import EventEmitter
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
def setup_event_forwarding(emitter, forwarder, event_name):
def emit(*args, **kwargs):
forwarder.emit(event_name, *args, **kwargs)
emitter.on(event_name, emit)
# -----------------------------------------------------------------------------
def composite_listener(cls):
"""
Decorator that adds a `register` and `deregister` method to a class, which
registers/deregisters all methods named `on_<event_name>` as a listener for
the <event_name> event with an emitter.
"""
def register(self, emitter):
for method_name in dir(cls):
if method_name.startswith('on_'):
emitter.on(method_name[3:], getattr(self, method_name))
def deregister(self, emitter):
for method_name in dir(cls):
if method_name.startswith('on_'):
emitter.remove_listener(method_name[3:], getattr(self, method_name))
cls._bumble_register_composite = register
cls._bumble_deregister_composite = deregister
return cls
# -----------------------------------------------------------------------------
class CompositeEventEmitter(EventEmitter):
def __init__(self):
super().__init__()
self._listener = None
@property
def listener(self):
return self._listener
@listener.setter
def listener(self, listener):
if self._listener:
# Call the deregistration methods for each base class that has them
for cls in self._listener.__class__.mro():
if hasattr(cls, '_bumble_register_composite'):
cls._bumble_deregister_composite(listener, self)
self._listener = listener
if listener:
# Call the registration methods for each base class that has them
for cls in listener.__class__.mro():
if hasattr(cls, '_bumble_deregister_composite'):
cls._bumble_register_composite(listener, self)
# -----------------------------------------------------------------------------
class AsyncRunner:
class WorkQueue:
def __init__(self, create_task=True):
self.queue = None
self.task = None
self.create_task = create_task
def enqueue(self, coroutine):
# Create a task now if we need to and haven't done so already
if self.create_task and self.task is None:
self.task = asyncio.create_task(self.run())
# Lazy-create the coroutine queue
if self.queue is None:
self.queue = asyncio.Queue()
# Enqueue the work
self.queue.put_nowait(coroutine)
async def run(self):
while True:
item = await self.queue.get()
try:
await item
except Exception as error:
logger.warning(f'{color("!!! Exception in work queue:", "red")} {error}')
# Shared default queue
default_queue = WorkQueue()
@staticmethod
def run_in_task(queue=None):
"""
Function decorator used to adapt an async function into a sync function
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
coroutine = func(*args, **kwargs)
if queue is None:
# Create a task to run the coroutine
async def run():
try:
await coroutine
except Exception:
logger.warning(f'{color("!!! Exception in wrapper:", "red")} {traceback.format_exc()}')
asyncio.create_task(run())
else:
# Queue the coroutine to be awaited by the work queue
queue.enqueue(coroutine)
return wrapper
return decorator

22
docs/README.md Normal file
View File

@@ -0,0 +1,22 @@
Bumble Documentation
====================
The documentation consists of a collection of markdown text files, with the root of the file
hierarchy at `docs/mkdocs/src`, starting with `docs/mkdocs/src/index.md`.
You can read the documentation as text, with any text viewer or your favorite markdown viewer,
or generate a static HTML "site" using `mkdocs`, which you can then open with any browser.
# Static HTML With MkDocs
[MkDocs](https://www.mkdocs.org/) is used to generate a static HTML documentation site.
The `mkdocs` directory contains all the data (actual documentation) and metadata (configuration) for the site.
`mkdocs/requirements.txt` includes the list of Python packages needed to build the site.
`mkdocs/mkdocs.yml` contains the site configuration.
`mkdocs/src/` is the directory where the actual documentation text, in markdown format, is located.
To build, from the project's root directory:
```
$ mkdocs build -f docs/mkdocs/mkdocs.yml
```
You can then open `docs/mkdocs/site/index.html` with any web browser.

BIN
docs/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

41
docs/images/logo.svg Normal file
View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg height="100%" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" xmlns:vectornator="http://vectornator.io" version="1.1" viewBox="0 0 600 600">
<metadata>
<vectornator:setting key="DimensionsVisible" value="1"/>
<vectornator:setting key="PencilOnly" value="0"/>
<vectornator:setting key="SnapToPoints" value="0"/>
<vectornator:setting key="OutlineMode" value="0"/>
<vectornator:setting key="CMYKEnabledKey" value="0"/>
<vectornator:setting key="RulersVisible" value="1"/>
<vectornator:setting key="SnapToEdges" value="0"/>
<vectornator:setting key="GuidesVisible" value="1"/>
<vectornator:setting key="DisplayWhiteBackground" value="0"/>
<vectornator:setting key="doHistoryDisabled" value="0"/>
<vectornator:setting key="SnapToGuides" value="1"/>
<vectornator:setting key="TimeLapseWatermarkDisabled" value="0"/>
<vectornator:setting key="Units" value="Pixels"/>
<vectornator:setting key="DynamicGuides" value="0"/>
<vectornator:setting key="IsolateActiveLayer" value="0"/>
<vectornator:setting key="SnapToGrid" value="0"/>
</metadata>
<defs/>
<g id="Layer 1" vectornator:layerName="Layer 1">
<g opacity="1">
<g opacity="1">
<path stroke="#000000" stroke-width="20" d="M225.484+180.075L375.483+180.075L375.483+430.074L225.484+430.074L225.484+180.075Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M225.484+180.075C225.484+138.654+259.063+105.076+300.484+105.076C341.905+105.076+375.483+138.654+375.483+180.075C375.483+221.496+341.905+255.075+300.484+255.075C259.063+255.075+225.484+221.496+225.484+180.075Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M225.484+430.074C225.484+388.652+259.063+355.074+300.484+355.074C341.905+355.074+375.483+388.652+375.483+430.074C375.483+471.495+341.905+505.073+300.484+505.073C259.063+505.073+225.484+471.495+225.484+430.074Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#0082fc" stroke-width="0.1" d="M235.484+179.892L365.483+179.892L365.483+429.891L235.484+429.891L235.484+179.892Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
</g>
<path stroke="#000000" stroke-width="0.1" d="M35.7722+199.986L159.567+334.722L299.282+200.423L35.7722+199.986Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="0.1" d="M316.022+197.895L441.498+330.932L583.111+198.326L316.022+197.895Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M454.656+45.6273L162.17+339.701L16.4508+190.923L586.277+193.755L444.37+335.604L155.712+46.9328" fill="none" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="61.8698" d="M228.468+309.557L371.511+309.557" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="61.8698" d="M228.468+412.556L371.511+412.556" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<g opacity="1">
<path stroke="#0082fc" stroke-width="0.1" d="M300.38+557.843L300.432+507.202L336.288+507.156C315.748+517.267+300.874+529.509+300.38+557.843Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#0082fc" stroke-width="0.1" d="M300.447+557.843L300.395+507.202L264.539+507.156C285.079+517.267+299.952+529.509+300.447+557.843Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"date":644900643.85054696,"appVersion":"4.1.5","drawing":{"modificationDate":644894800.328192,"activeArtboardIndex":0,"settings":{"outlineMode":false,"isolateActiveLayer":false,"snapToEdges":false,"snapToPoints":false,"guidesVisible":true,"snapToGrid":false,"units":"Pixels","dimensionsVisible":true,"dynamicGuides":false,"isCMYKColorPreviewEnabled":false,"undoHistoryDisabled":false,"snapToGuides":true,"drawOnlyUsingPencil":false,"whiteBackground":false,"rulersVisible":true,"isTimeLapseWatermarkDisabled":false},"artboardPaths":["Artboard0.json"],"documentVersion":"unknown"}}

View File

@@ -0,0 +1 @@
{"documentJSONFilename":"Document.json","undoHistoryJSONFilename":"UndoHistory.json","fileFormatVersion":0,"thumbnailImageFilename":"Thumbnail.png"}

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

BIN
docs/images/logo_framed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg height="100%" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" xmlns:vectornator="http://vectornator.io" version="1.1" viewBox="0 0 745 744.634">
<metadata>
<vectornator:setting key="DimensionsVisible" value="1"/>
<vectornator:setting key="PencilOnly" value="0"/>
<vectornator:setting key="SnapToPoints" value="0"/>
<vectornator:setting key="OutlineMode" value="0"/>
<vectornator:setting key="CMYKEnabledKey" value="0"/>
<vectornator:setting key="RulersVisible" value="1"/>
<vectornator:setting key="SnapToEdges" value="0"/>
<vectornator:setting key="GuidesVisible" value="1"/>
<vectornator:setting key="DisplayWhiteBackground" value="0"/>
<vectornator:setting key="doHistoryDisabled" value="0"/>
<vectornator:setting key="SnapToGuides" value="1"/>
<vectornator:setting key="TimeLapseWatermarkDisabled" value="0"/>
<vectornator:setting key="Units" value="Pixels"/>
<vectornator:setting key="DynamicGuides" value="0"/>
<vectornator:setting key="IsolateActiveLayer" value="0"/>
<vectornator:setting key="SnapToGrid" value="0"/>
</metadata>
<defs/>
<g id="Layer 1" vectornator:layerName="Layer 1">
<path stroke="#000000" stroke-width="18.6464" d="M368.753+729.441L58.8847+550.539L58.8848+192.734L368.753+13.8313L678.621+192.734L678.621+550.539L368.753+729.441Z" fill="#0082fc" stroke-linecap="butt" fill-opacity="0.307489" opacity="1" stroke-linejoin="round"/>
<g opacity="1">
<g opacity="1">
<path stroke="#000000" stroke-width="20" d="M292.873+289.256L442.872+289.256L442.872+539.254L292.873+539.254L292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M292.873+289.256C292.873+247.835+326.452+214.257+367.873+214.257C409.294+214.257+442.872+247.835+442.872+289.256C442.872+330.677+409.294+364.256+367.873+364.256C326.452+364.256+292.873+330.677+292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M292.873+539.254C292.873+497.833+326.452+464.255+367.873+464.255C409.294+464.255+442.872+497.833+442.872+539.254C442.872+580.675+409.294+614.254+367.873+614.254C326.452+614.254+292.873+580.675+292.873+539.254Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#0082fc" stroke-width="0.1" d="M302.873+289.073L432.872+289.073L432.872+539.072L302.873+539.072L302.873+289.073Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
</g>
<path stroke="#000000" stroke-width="0.1" d="M103.161+309.167L226.956+443.903L366.671+309.604L103.161+309.167Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="0.1" d="M383.411+307.076L508.887+440.112L650.5+307.507L383.411+307.076Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M522.045+154.808L229.559+448.882L83.8397+300.104L653.666+302.936L511.759+444.785L223.101+156.114" fill="none" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="61.8698" d="M295.857+418.738L438.9+418.738" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="61.8698" d="M295.857+521.737L438.9+521.737" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<g opacity="1">
<path stroke="#0082fc" stroke-width="0.1" d="M367.769+667.024L367.821+616.383L403.677+616.336C383.137+626.447+368.263+638.69+367.769+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#0082fc" stroke-width="0.1" d="M367.836+667.024L367.784+616.383L331.928+616.336C352.468+626.447+367.341+638.69+367.836+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"date":644900741.09290397,"appVersion":"4.1.5","drawing":{"modificationDate":644894800.328192,"activeArtboardIndex":0,"settings":{"outlineMode":false,"isolateActiveLayer":false,"snapToEdges":false,"snapToPoints":false,"guidesVisible":true,"snapToGrid":false,"units":"Pixels","dimensionsVisible":true,"dynamicGuides":false,"isCMYKColorPreviewEnabled":false,"undoHistoryDisabled":false,"snapToGuides":true,"drawOnlyUsingPencil":false,"whiteBackground":false,"rulersVisible":true,"isTimeLapseWatermarkDisabled":false},"artboardPaths":["Artboard0.json"],"documentVersion":"unknown"}}

View File

@@ -0,0 +1 @@
{"documentJSONFilename":"Document.json","undoHistoryJSONFilename":"UndoHistory.json","fileFormatVersion":0,"thumbnailImageFilename":"Thumbnail.png"}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because one or more lines are too long

88
docs/mkdocs/mkdocs.yml Normal file
View File

@@ -0,0 +1,88 @@
site_name: Bumble
use_directory_urls: true
nav:
- Introduction: index.md
- Getting Started: getting_started.md
- Use Cases:
- Overview: use_cases/index.md
- Use Case 1: use_cases/use_case_1.md
- Use Case 2: use_cases/use_case_2.md
- Use Case 3: use_cases/use_case_3.md
- Use Case 4: use_cases/use_case_4.md
- Use Case 5: use_cases/use_case_5.md
- Use Case 6: use_cases/use_case_6.md
- Components:
- Controller: components/controller.md
- Host: components/host.md
- GATT: components/gatt.md
- Security Manager: components/security_manager.md
- Transports:
- Overview: transports/index.md
- Serial: transports/serial.md
- USB: transports/usb.md
- PTY: transports/pty.md
- UDP: transports/udp.md
- TCP Client: transports/tcp_client.md
- TCP Server: transports/tcp_server.md
- WebSocket Client: transports/ws_client.md
- WebSocket Server: transports/ws_server.md
- VHCI: transports/vhci.md
- HCI Socket: transports/hci_socket.md
- Android Emulator: transports/android_emulator.md
- File: transports/file.md
- API:
- Guide: api/guide.md
- Examples: api/examples.md
- Reference: api/reference.md
- Apps & Tools:
- Overview: apps_and_tools/index.md
- Console: apps_and_tools/console.md
- Link Relay: apps_and_tools/link_relay.md
- HCI Bridge: apps_and_tools/hci_bridge.md
- Golden Gate Bridge: apps_and_tools/gg_bridge.md
- Show: apps_and_tools/show.md
- Hardware:
- Overview: hardware/index.md
- Platforms:
- Overview: platforms/index.md
- macOS: platforms/macos.md
- Linux: platforms/linux.md
- Windows: platforms/windows.md
- Android: platforms/android.md
- Examples:
- Overview: examples/index.md
copyright: Copyright 2021-2022 Google LLC
theme:
name: 'material'
logo: 'images/logo.png'
favicon: 'images/favicon.ico'
custom_dir: 'theme'
plugins:
- mkdocstrings
docs_dir: 'src'
edit_uri: ''
# Repo info
#repo_name: TBD
#repo_url: https://github.com/TBD
# Extensions
markdown_extensions:
- admonition
- attr_list
- pymdownx.details
- pymdownx.superfences
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
- codehilite:
guess_lang: false
- toc:
permalink: true

View File

@@ -0,0 +1,6 @@
# This requirements file is for python3
mkdocs == 1.2
mkdocs-material == 7.1.7
mkdocs-material-extensions == 1.0.1
pymdown-extensions == 8.2
mkdocstrings == 0.15.1

View File

@@ -0,0 +1,2 @@
API EXAMPLES
============

View File

@@ -0,0 +1,2 @@
API DEVELOPER GUIDE
===================

View File

@@ -0,0 +1,19 @@
Bumble Python API
=================
# Classes
## Address
::: bumble.hci.Address
## HCI_Packet
::: bumble.hci.HCI_Packet
## HCI Commands
### HCI_Command
::: bumble.hci.HCI_Command
### HCI_Disconnect_Command
::: bumble.hci.HCI_Disconnect_Command

View File

@@ -0,0 +1,31 @@
CONSOLE APP
===========
![logo](../images/console_screenshot.png){ width=300 height=300 }
The Console app is an interactive text user interface that offers a number of functions, including:
* scanning
* advertising
* connecting to devices
* changing connection parameters
* discovering GATT services and characteristics
* read & write GATT characteristics
The console user interface has 3 main panes:
* a display pane, that displays information, depending on a user-selected mode. The `show` command can be used to switch what is displayed in this pane
* a command history pane that shows a short history of the last commands and their results
* a command pane, with tab completion, where you can enter commands
In addition to the display panes, the console has a status bar, showing the scanning state and the connection state.
!!! info "Running the console app"
```
python console.py <transport-spec>
```
Example:
```
python console.py usb:0
```

View File

@@ -0,0 +1,2 @@
GOLDEN GATE BRIDGE
==================

View File

@@ -0,0 +1,32 @@
HCI BRIDGE
==========
This tool acts as a simple bridge between two HCI transports, with a host on one side and
a controller on the other. All the HCI packets bridged between the two are printed on the console
for logging. This bridge also has the ability to short-circuit some HCI packets (respond to them
with a fixed response instead of bridging them to the other side), which may be useful when used with
a host that send custom HCI commands that the controller may not understand.
!!! info "Running the HCI bridge tool"
```
python hci_bridge.py <host-transport-spec> <controller-transport-spec> [command-short-circuit-list]
```
!!! example "UDP to Serial"
```
python hci_bridge.py udp:0.0.0.0:9000,127.0.0.1:9001 serial:/dev/tty.usbmodem0006839912171,1000000 0x3f:0x0070,0x3f:0x0074,0x3f:0x0077,0x3f:0x0078
```
!!! example "PTY to Link Relay"
```
python hci_bridge.py serial:emulated_uart_pty,1000000 link-relay:ws://127.0.0.1:10723/test
```
In this example, an emulator that exposes a PTY as an interface to its HCI UART is running as
a Bluetooth host, and we are connecting it to a virtual controller attached to a link relay
(through which the communication with other virtual controllers will be mediated).
NOTE: this assumes you're running a Link Relay on port `10723`.

View File

@@ -0,0 +1,12 @@
APPS & TOOLS
============
Included in the project are a few apps and tools, built on top of the core libraries.
These include:
* [Console](console.md) - an interactive text-based console
* [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets
* [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool"
* [`Show`](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form
* [`Link Relay`](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.

View File

@@ -0,0 +1,34 @@
LINK RELAY TOOL
===============
The Link Relay is a WebSocket relay, which acts like an online chat system, where each "chat room" can be joined by multiple virtual controllers, which can then communicate with each other, as if connected with radio communication.
```
usage: python link_relay.py [-h] [--log-level LOG_LEVEL] [--log-config LOG_CONFIG] [--port PORT]
optional arguments:
-h, --help show this help message and exit
--log-level LOG_LEVEL
logger level
--log-config LOG_CONFIG
logger config file (YAML)
--port PORT Port to listen on
```
(the default port is `10723`)
When running, the link relay waits for connections on its listening port.
The WebSocket path used by a connecting client indicates which virtual "chat room" to join.
!!! tip "Connecting to the relay as a controller"
Most of the examples and tools that take a transport moniker as an argument also accept a link relay moniker, which is equivalent to a transport to a virtual controller that is connected to a relay.
The moniker syntax is: `link-relay:ws://<hostname>/<room>` where `<hostname>` is the hostname to connect to and `<room>` is the virtual "chat room" in a relay.
Example: `link-relay:ws://localhost:10723/test` will join the `test` "chat room"
!!! tip "Connecting to the relay as an observer"
It is possible to connect to a "chat room" in a relay as an observer, rather than a virtual controller. In this case, a text-based console can be used to observe what is going on in the "chat room". Tools like [`wscat`](https://github.com/websockets/wscat#readme) or [`websocat`](https://github.com/vi/websocat) can be used for that.
Example: `wscat --connect ws://localhost:10723/test`

View File

View File

@@ -0,0 +1,2 @@
CONTROLLER
==========

View File

@@ -0,0 +1,2 @@
GATT
====

View File

@@ -0,0 +1,2 @@
HOST
====

View File

@@ -0,0 +1,2 @@
SECURITY MANAGER
================

View File

@@ -0,0 +1,72 @@
EXAMPLES
========
The project includes a few simple example applications the illustrate some of the ways the library APIs can be used.
These examples include:
## `battery_service.py`
Run a simple device example with a GATT server that exposes a standard Battery Service.
## `get_peer_device_info.py`
An app that connects to a device, discovers its GATT services, and, if the Device Information Service is found, looks for a Manufacturer Name characteristics, reads and prints it.
## `keyboard.py`
An app that implements a virtual keyboard or mouse, or can connect to a real keyboard and receive key presses.
## `run_a2dp_info.py`
An app that connects to a device, phone or computer and inspects its A2DP (Advanced Audio Profile) capabilities
## `run_a2dp_source.py`
An app that can connect to a Bluetooth speaker and play audio.
## `run_a2dp_sink.py`
An app that implements a virtual Bluetooth speaker that can receive audio.
## `run_advertiser.py`
An app that runs a simple device that just advertises (BLE).
## `run_classic_connect.py`
An app that connects to a Bluetooth Classic device and prints its services.
## `run_classic_discoverable.py`
An app that implements a discoverable and connectable Bluetooth Classic device.
## `run_classic_discovery.py`
An app that discovers Bluetooth Classic devices and prints their information.
## `run_connect_and_encrypt.py`
An app that connected to a device (BLE) and encrypts the connection.
## `run_controller_with_scanner.py`
## `run_controller.py`
Creates two linked controllers, attaches one to a transport, and the other to a local host with a GATT server application. This can be used, for example, to attach a virtual controller to a native stack, like BlueZ on Linux, and use the native tools, like `bluetoothctl`, to scan and connect to the GATT server included in the example.
## `run_gatt_client_and_server.py`
Runs a local GATT server and GATT client, connected to each other. The GATT client discovers and logs all the services and characteristics exposed by the GATT server
## `run_gatt_client.py`
A simple GATT client that either connects to another BLE device or waits for a connection, then dumps its GATT database.
## `run_gatt_server.py`
A simple GATT server that either connects to another BLE device or waits for connections.
## `run_hfp_gateway.py`
A app that implements a Hands Free gateway. It can connect to a Hands Free headset.
## `run_hfp_handsfree.py`
A app that implements a Hands Free headset. It can simulate some of the events that a real headset would
emit, like picking up or hanging up a call, pressing a button, etc.
## `run_notifier.py`
An app that implements a GATT server with characteristics that can be subscribed to, and emits notifications
for those characteristics at regular intervals.
## `run_rfcomm_client.py`
An app that connects to an RFComm server and bridges the RFComm channel to a local TCP socket
## `run_rfcomm_server.py`
An app that implements an RFComm server and, when a connection is received, bridges the channel to a local TCP socket
## `run_scanner.py`
An app that scan for BLE devices and print the advertisements received.

View File

@@ -0,0 +1,108 @@
GETTING STARTED WITH BUMBLE
===========================
# Prerequisites
You need Python 3.8 or above. Python >= 3.9 is recommended, but 3.8 should be sufficient if
necessary (there may be some optional functionality that will not work on some platforms with
python 3.8).
Visit the [Python site](https://www.python.org/) for instructions on how to install Python
for your platform.
Throughout the documentation, when shell commands are shown, it is assumed that you can
invoke Python as
```
$ python
```
If invoking python is different on your platform (it may be `python3` for example, or just `py` or `py.exe`),
adjust accordingly.
You may be simply using Bumble as a module for your own application or as a dependency to your own
module, or you may be working on modifying or contributing to the Bumble module or example code
itself.
# Working With Bumble As A Module
## Installing
You may choose to install the Bumble module from an online package repository, with a package
manager, or from source.
!!! tip "Python Virtual Environments"
When you install Bumble, you have the option to install it as part of your default
python environment, or in a virtual environment, such as a `venv`, `pyenv` or `conda` environment
### venv
`venv` is a standard module that is included with python.
Visit the [`venv` documentation](https://docs.python.org/3/library/venv.html) page for details.
### Pyenv
`pyenv` lets you easily switch between multiple versions of Python. It's simple, unobtrusive, and follows the UNIX tradition of single-purpose tools that do one thing well.
Visit the [`pyenv` site](https://github.com/pyenv/pyenv) for instructions on how to install
and use `pyenv`
### Conda
Conda is a convenient package manager and virtual environment.
The file `environment.yml` is a Conda environment file that you can use to create
a new Conda environment. Once created, you can simply activate this environment when
working with Bumble.
Visit the [Conda side](https://docs.conda.io/en/latest/) for instructions on how to install
and use Conda.
A few useful commands:
#### Create a new `bumble` Conda environment
```
$ conda env create -f environment.yml
```
This will create a new environment, named `bumble`, which you can then activate with:
```
$ conda activate bumble
```
#### Update an existing `bumble` environment
```
$ conda env update -f environment.yml
```
### Install From Source
The instructions for working with virtual Python environments above also apply in this case.
Install with `pip`
```
$ python -m pip install -e .
```
# Working On The Bumble Code
When you work on the Bumble code itself, and run some of the tests or example apps, or import the
module in your own code, you typically either install the package from source in "development mode" as described above, or you may choose to skip the install phase.
## Without Installing
If you prefer not to install the package (even in development mode), you can load the module directly from its location in the project.
A simple way to do that is to set your `PYTHONPATH` to
point to the root project directory, where the `bumble` subdirectory is located. You may set
`PYTHONPATH` globally, or locally with each command line execution (on Unix-like systems).
Example with a global `PYTHONPATH`, from a unix shell, when the working directory is the root
directory of the project.
```bash
$ export PYTHONPATH=.
$ python apps/console.py serial:/dev/tty.usbmodem0006839912171
```
or running an example, with the working directory set to the `examples` subdirectory
```bash
$ cd examples
$ export PYTHONPATH=..
$ python run_scanner.py usb:0
```
Or course, `export PYTHONPATH` only needs to be invoked once, not before each app/script execution.
Setting `PYTHONPATH` locally with each command would look something like:
```
$ PYTHONPATH=. python examples/run_advertiser.py examples/device1.json serial:/dev/tty.usbmodem0006839912171
```

View File

@@ -0,0 +1,19 @@
HARDWARE
========
The Bumble Host connects to a controller over an [HCI Transport](../transports/index.md).
To use a hardware controller attached to the host on which the host application is running, the transport is typically either [HCI over UART](../transports/serial.md) or [HCI over USB](../transports/usb.md).
On Linux, the [VHCI Transport](../transports/vhci.md) can be used to communicate with any controller hardware managed by the operating system. Alternatively, a remote controller (a phyiscal controller attached to a remote host) can be used by connecting one of the networked transports (such as the [TCP Client transport](../transports/tcp_client.md), the [TCP Server transport](../transports/tcp_server.md) or the [UDP Transport](../transports/udp.md)) to an [HCI Bridge](../apps_and_tools/hci_bridge) bridging the network transport to a physical controller on a remote host.
In theory, any controller that is compliant with the HCI over UART or HCI over USB protocols can be used.
HCI over USB is very common, implemented by a number of commercial Bluetooth dongles.
It is also possible to use an embedded development board, running a specialized application, such as the [`HCI UART`](https://docs.zephyrproject.org/latest/samples/bluetooth/hci_uart/README.html) and [`HCI USB`](https://docs.zephyrproject.org/latest/samples/bluetooth/hci_usb/README.html) demo applications from the [Zephyr project](https://www.zephyrproject.org/), or the [`blehci`](https://mynewt.apache.org/latest/tutorials/ble/blehci_project.html) application from [mynewt/nimble](https://mynewt.apache.org/)
Some specific USB dongles and embedded boards that are known to work include:
* [Nordic nRF52840 DK board](https://www.nordicsemi.com/Products/Development-hardware/nrf52840-dk) with the Zephyr `HCI UART` application
* [Nordic nRF52840 DK board](https://www.nordicsemi.com/Products/Development-hardware/nrf52840-dk) with the mynewt `blehci` application
* [Nordic nrf52840 dongle](https://www.nordicsemi.com/Products/Development-hardware/nRF52840-Dongle) with the Zephyr `HCI USB` application
* [BT820 USB Dongle](https://www.lairdconnect.com/wireless-modules/bluetooth-modules/bluetooth-42-and-40-modules/bt800-series-bluetooth-module)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Some files were not shown because too many files have changed in this diff Show More