# 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
') 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 -//') 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 ') 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()