initial import
29
CONTRIBUTING.md
Normal 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
@@ -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
@@ -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
@@ -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
601
apps/console.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
0
apps/link_relay/__init__.py
Normal file
276
apps/link_relay/link_relay.py
Normal 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()
|
||||
21
apps/link_relay/logging.yml
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
554
bumble/a2dp.py
Normal 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
@@ -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
82
bumble/bridge.py
Normal 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
895
bumble/controller.py
Normal 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
@@ -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
@@ -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
59
bumble/gap.py
Normal 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
@@ -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
@@ -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
@@ -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
179
bumble/helpers.py
Normal 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
@@ -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
@@ -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
@@ -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
360
bumble/link.py
Normal 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
@@ -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
1514
bumble/smp.py
Normal file
95
bumble/transport/__init__.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import 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)
|
||||
107
bumble/transport/android_emulator.py
Normal 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
@@ -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()
|
||||
52
bumble/transport/emulated_bluetooth_packets_pb2.py
Normal 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)
|
||||
53
bumble/transport/emulated_bluetooth_pb2.py
Normal 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)
|
||||
207
bumble/transport/emulated_bluetooth_pb2_grpc.py
Normal 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)
|
||||
43
bumble/transport/emulated_bluetooth_vhci_pb2.py
Normal 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)
|
||||
114
bumble/transport/emulated_bluetooth_vhci_pb2_grpc.py
Normal 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
@@ -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)
|
||||
|
||||
146
bumble/transport/hci_socket.py
Normal 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
@@ -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
@@ -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)
|
||||
72
bumble/transport/serial.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import 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)
|
||||
|
||||
52
bumble/transport/tcp_client.py
Normal 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)
|
||||
88
bumble/transport/tcp_server.py
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
49
bumble/transport/ws_client.py
Normal 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
|
||||
81
bumble/transport/ws_server.py
Normal 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
@@ -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
@@ -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
|
After Width: | Height: | Size: 24 KiB |
41
docs/images/logo.svg
Normal 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 |
1
docs/images/logo.vectornator/Artboard0.json
Normal file
1
docs/images/logo.vectornator/Document.json
Normal 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"}}
|
||||
1
docs/images/logo.vectornator/Manifest.json
Normal file
@@ -0,0 +1 @@
|
||||
{"documentJSONFilename":"Document.json","undoHistoryJSONFilename":"UndoHistory.json","fileFormatVersion":0,"thumbnailImageFilename":"Thumbnail.png"}
|
||||
BIN
docs/images/logo.vectornator/Thumbnail.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
1
docs/images/logo.vectornator/UndoHistory.json
Normal file
BIN
docs/images/logo_framed.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
42
docs/images/logo_framed.svg
Normal 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 |
1
docs/images/logo_framed.vectornator/Artboard0.json
Normal file
1
docs/images/logo_framed.vectornator/Document.json
Normal 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"}}
|
||||
1
docs/images/logo_framed.vectornator/Manifest.json
Normal file
@@ -0,0 +1 @@
|
||||
{"documentJSONFilename":"Document.json","undoHistoryJSONFilename":"UndoHistory.json","fileFormatVersion":0,"thumbnailImageFilename":"Thumbnail.png"}
|
||||
BIN
docs/images/logo_framed.vectornator/Thumbnail.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
1
docs/images/logo_framed.vectornator/UndoHistory.json
Normal file
88
docs/mkdocs/mkdocs.yml
Normal 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
|
||||
6
docs/mkdocs/requirements.txt
Normal 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
|
||||
2
docs/mkdocs/src/api/examples.md
Normal file
@@ -0,0 +1,2 @@
|
||||
API EXAMPLES
|
||||
============
|
||||
2
docs/mkdocs/src/api/guide.md
Normal file
@@ -0,0 +1,2 @@
|
||||
API DEVELOPER GUIDE
|
||||
===================
|
||||
19
docs/mkdocs/src/api/reference.md
Normal 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
|
||||
|
||||
31
docs/mkdocs/src/apps_and_tools/console.md
Normal file
@@ -0,0 +1,31 @@
|
||||
CONSOLE APP
|
||||
===========
|
||||
|
||||
{ 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
|
||||
```
|
||||
2
docs/mkdocs/src/apps_and_tools/gg_bridge.md
Normal file
@@ -0,0 +1,2 @@
|
||||
GOLDEN GATE BRIDGE
|
||||
==================
|
||||
32
docs/mkdocs/src/apps_and_tools/hci_bridge.md
Normal 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`.
|
||||
|
||||
|
||||
12
docs/mkdocs/src/apps_and_tools/index.md
Normal 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.
|
||||
|
||||
34
docs/mkdocs/src/apps_and_tools/link_relay.md
Normal 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`
|
||||
|
||||
0
docs/mkdocs/src/apps_and_tools/show.md
Normal file
2
docs/mkdocs/src/components/controller.md
Normal file
@@ -0,0 +1,2 @@
|
||||
CONTROLLER
|
||||
==========
|
||||
2
docs/mkdocs/src/components/gatt.md
Normal file
@@ -0,0 +1,2 @@
|
||||
GATT
|
||||
====
|
||||
2
docs/mkdocs/src/components/host.md
Normal file
@@ -0,0 +1,2 @@
|
||||
HOST
|
||||
====
|
||||
2
docs/mkdocs/src/components/security_manager.md
Normal file
@@ -0,0 +1,2 @@
|
||||
SECURITY MANAGER
|
||||
================
|
||||
72
docs/mkdocs/src/examples/index.md
Normal 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.
|
||||
108
docs/mkdocs/src/getting_started.md
Normal 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
|
||||
```
|
||||
19
docs/mkdocs/src/hardware/index.md
Normal 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)
|
||||
1
docs/mkdocs/src/images/bumble_layers.svg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
docs/mkdocs/src/images/console_screenshot.png
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
docs/mkdocs/src/images/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/mkdocs/src/images/logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/mkdocs/src/images/logo_framed.png
Normal file
|
After Width: | Height: | Size: 48 KiB |