forked from auracaster/bumble_mirror
wip
This commit is contained in:
263
apps/console.py
263
apps/console.py
@@ -28,11 +28,16 @@ import click
|
||||
from collections import OrderedDict
|
||||
import colors
|
||||
|
||||
from bumble.core import UUID, AdvertisingData
|
||||
from bumble.device import Device, Connection, Peer
|
||||
from bumble.core import UUID, AdvertisingData, TimeoutError, BT_LE_TRANSPORT
|
||||
from bumble.device import ConnectionParametersPreferences, Device, Connection, Peer
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.gatt import Characteristic
|
||||
from bumble.hci import (
|
||||
HCI_LE_1M_PHY,
|
||||
HCI_LE_2M_PHY,
|
||||
HCI_LE_CODED_PHY,
|
||||
)
|
||||
|
||||
from prompt_toolkit import Application
|
||||
from prompt_toolkit.history import FileHistory
|
||||
@@ -43,6 +48,7 @@ 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.data_structures import Point
|
||||
from prompt_toolkit.layout import (
|
||||
Layout,
|
||||
HSplit,
|
||||
@@ -51,17 +57,20 @@ from prompt_toolkit.layout import (
|
||||
Float,
|
||||
FormattedTextControl,
|
||||
FloatContainer,
|
||||
ConditionalContainer
|
||||
ConditionalContainer,
|
||||
Dimension
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 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
|
||||
BUMBLE_USER_DIR = os.path.expanduser('~/.bumble')
|
||||
DEFAULT_RSSI_BAR_WIDTH = 20
|
||||
DEFAULT_CONNECTION_TIMEOUT = 30.0
|
||||
DISPLAY_MIN_RSSI = -100
|
||||
DISPLAY_MAX_RSSI = -30
|
||||
RSSI_MONITOR_INTERVAL = 5.0 # Seconds
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Globals
|
||||
@@ -69,16 +78,57 @@ DISPLAY_MAX_RSSI = -30
|
||||
App = None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utils
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def le_phy_name(phy_id):
|
||||
return {
|
||||
HCI_LE_1M_PHY: '1M',
|
||||
HCI_LE_2M_PHY: '2M',
|
||||
HCI_LE_CODED_PHY: 'CODED'
|
||||
}.get(phy_id, HCI_Constant.le_phy_name(phy_id))
|
||||
|
||||
|
||||
def rssi_bar(rssi):
|
||||
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)
|
||||
bar_blocks = ('█' * int(bar_ticks / 8)) + blocks[bar_ticks % 8]
|
||||
return f'{rssi:4} {bar_blocks}'
|
||||
|
||||
|
||||
def parse_phys(phys):
|
||||
if phys.lower() == '*':
|
||||
return None
|
||||
else:
|
||||
phy_list = []
|
||||
elements = phys.lower().split(',')
|
||||
for element in elements:
|
||||
if element == '1m':
|
||||
phy_list.append(HCI_LE_1M_PHY)
|
||||
elif element == '2m':
|
||||
phy_list.append(HCI_LE_2M_PHY)
|
||||
elif element == 'coded':
|
||||
phy_list.append(HCI_LE_CODED_PHY)
|
||||
else:
|
||||
raise ValueError('invalid PHY name')
|
||||
return phy_list
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Console App
|
||||
# -----------------------------------------------------------------------------
|
||||
class ConsoleApp:
|
||||
def __init__(self):
|
||||
self.known_addresses = set()
|
||||
self.known_addresses = set()
|
||||
self.known_attributes = []
|
||||
self.device = None
|
||||
self.connected_peer = None
|
||||
self.top_tab = 'scan'
|
||||
self.device = None
|
||||
self.connected_peer = None
|
||||
self.top_tab = 'scan'
|
||||
self.monitor_rssi = False
|
||||
self.connection_rssi = None
|
||||
|
||||
style = Style.from_dict({
|
||||
'output-field': 'bg:#000044 #ffffff',
|
||||
@@ -106,6 +156,10 @@ class ConsoleApp:
|
||||
'on': None,
|
||||
'off': None
|
||||
},
|
||||
'rssi': {
|
||||
'on': None,
|
||||
'off': None
|
||||
},
|
||||
'show': {
|
||||
'scan': None,
|
||||
'services': None,
|
||||
@@ -120,10 +174,17 @@ class ConsoleApp:
|
||||
'services': None,
|
||||
'attributes': None
|
||||
},
|
||||
'request-mtu': None,
|
||||
'read': LiveCompleter(self.known_attributes),
|
||||
'write': LiveCompleter(self.known_attributes),
|
||||
'subscribe': LiveCompleter(self.known_attributes),
|
||||
'unsubscribe': LiveCompleter(self.known_attributes),
|
||||
'set-phy': {
|
||||
'1m': None,
|
||||
'2m': None,
|
||||
'coded': None
|
||||
},
|
||||
'set-default-phy': None,
|
||||
'quit': None,
|
||||
'exit': None
|
||||
})
|
||||
@@ -139,14 +200,16 @@ class ConsoleApp:
|
||||
|
||||
self.input_field.accept_handler = self.accept_input
|
||||
|
||||
self.output_height = 7
|
||||
self.output_height = Dimension(min=7, max=7, weight=1)
|
||||
self.output_lines = []
|
||||
self.output = FormattedTextControl()
|
||||
self.output = FormattedTextControl(get_cursor_position=lambda: Point(0, max(0, len(self.output_lines) - 1)))
|
||||
self.output_max_lines = 20
|
||||
self.scan_results_text = FormattedTextControl()
|
||||
self.services_text = FormattedTextControl()
|
||||
self.attributes_text = FormattedTextControl()
|
||||
self.log_text = FormattedTextControl()
|
||||
self.log_height = 20
|
||||
self.log_text = FormattedTextControl(get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1)))
|
||||
self.log_height = Dimension(min=7, weight=4)
|
||||
self.log_max_lines = 100
|
||||
self.log_lines = []
|
||||
|
||||
container = HSplit([
|
||||
@@ -163,11 +226,10 @@ class ConsoleApp:
|
||||
filter=Condition(lambda: self.top_tab == 'attributes')
|
||||
),
|
||||
ConditionalContainer(
|
||||
Frame(Window(self.log_text), title='Log'),
|
||||
Frame(Window(self.log_text, height=self.log_height), title='Log'),
|
||||
filter=Condition(lambda: self.top_tab == 'log')
|
||||
),
|
||||
Frame(Window(self.output), height=self.output_height),
|
||||
# HorizontalLine(),
|
||||
Frame(Window(self.output, height=self.output_height)),
|
||||
FormattedTextToolbar(text=self.get_status_bar_text, style='reverse'),
|
||||
self.input_field
|
||||
])
|
||||
@@ -199,6 +261,8 @@ class ConsoleApp:
|
||||
)
|
||||
|
||||
async def run_async(self, device_config, transport):
|
||||
rssi_monitoring_task = asyncio.create_task(self.rssi_monitor_loop())
|
||||
|
||||
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)
|
||||
@@ -210,6 +274,8 @@ class ConsoleApp:
|
||||
# Run the UI
|
||||
await self.ui.run_async()
|
||||
|
||||
rssi_monitoring_task.cancel()
|
||||
|
||||
def add_known_address(self, address):
|
||||
self.known_addresses.add(address)
|
||||
|
||||
@@ -224,22 +290,33 @@ class ConsoleApp:
|
||||
|
||||
connection_state = 'NONE'
|
||||
encryption_state = ''
|
||||
att_mtu = ''
|
||||
rssi = '' if self.connection_rssi is None else rssi_bar(self.connection_rssi)
|
||||
|
||||
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}'
|
||||
connection_parameters = f'{connection.parameters.connection_interval}/{connection.parameters.peripheral_latency}/{connection.parameters.supervision_timeout}'
|
||||
if connection.transport == BT_LE_TRANSPORT:
|
||||
phy_state = f' RX={le_phy_name(connection.phy.rx_phy)}/TX={le_phy_name(connection.phy.tx_phy)}'
|
||||
else:
|
||||
phy_state = ''
|
||||
connection_state = f'{connection.peer_address} {connection_parameters} {connection.data_length}{phy_state}'
|
||||
encryption_state = 'ENCRYPTED' if connection.is_encrypted else 'NOT ENCRYPTED'
|
||||
att_mtu = f'ATT_MTU: {connection.att_mtu}'
|
||||
|
||||
return [
|
||||
('ansigreen', f' SCAN: {scanning} '),
|
||||
('', ' '),
|
||||
('ansiblue', f' CONNECTION: {connection_state} '),
|
||||
('', ' '),
|
||||
('ansimagenta', f' {encryption_state} ')
|
||||
('ansimagenta', f' {encryption_state} '),
|
||||
('', ' '),
|
||||
('ansicyan', f' {att_mtu} '),
|
||||
('', ' '),
|
||||
('ansiyellow', f' {rssi} ')
|
||||
]
|
||||
|
||||
def show_error(self, title, details = None):
|
||||
@@ -286,7 +363,7 @@ class ConsoleApp:
|
||||
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 = self.output_lines[-self.output_max_lines:]
|
||||
self.output_lines.append(line)
|
||||
formatted_text = []
|
||||
for line in self.output_lines:
|
||||
@@ -298,7 +375,7 @@ class ConsoleApp:
|
||||
|
||||
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_lines = self.log_lines[-self.log_max_lines:]
|
||||
self.log_text.text = ANSI('\n'.join(self.log_lines))
|
||||
if invalidate:
|
||||
self.ui.invalidate()
|
||||
@@ -351,6 +428,12 @@ class ConsoleApp:
|
||||
if characteristic.handle == attribute_handle:
|
||||
return characteristic
|
||||
|
||||
async def rssi_monitor_loop(self):
|
||||
while True:
|
||||
if self.monitor_rssi and self.connected_peer:
|
||||
self.connection_rssi = await self.connected_peer.connection.get_rssi()
|
||||
await asyncio.sleep(RSSI_MONITOR_INTERVAL)
|
||||
|
||||
async def command(self, command):
|
||||
try:
|
||||
(keyword, *params) = command.strip().split(' ')
|
||||
@@ -379,39 +462,73 @@ class ConsoleApp:
|
||||
else:
|
||||
self.show_error('unsupported arguments for scan command')
|
||||
|
||||
async def do_rssi(self, params):
|
||||
if len(params) == 0:
|
||||
# Toggle monitoring
|
||||
self.monitor_rssi = not self.monitor_rssi
|
||||
elif params[0] == 'on':
|
||||
self.monitor_rssi = True
|
||||
elif params[0] == 'off':
|
||||
self.monitor_rssi = False
|
||||
else:
|
||||
self.show_error('unsupported arguments for rssi command')
|
||||
|
||||
async def do_connect(self, params):
|
||||
if len(params) != 1:
|
||||
self.show_error('invalid syntax', 'expected connect <address>')
|
||||
if len(params) != 1 and len(params) != 2:
|
||||
self.show_error('invalid syntax', 'expected connect <address> [phys]')
|
||||
return
|
||||
|
||||
if len(params) == 1:
|
||||
phys = None
|
||||
else:
|
||||
phys = parse_phys(params[1])
|
||||
if phys is None:
|
||||
connection_parameters_preferences = None
|
||||
else:
|
||||
connection_parameters_preferences = {
|
||||
phy: ConnectionParametersPreferences()
|
||||
for phy in phys
|
||||
}
|
||||
|
||||
self.append_to_output('connecting...')
|
||||
await self.device.connect(params[0])
|
||||
self.top_tab = 'services'
|
||||
|
||||
try:
|
||||
await self.device.connect(
|
||||
params[0],
|
||||
connection_parameters_preferences=connection_parameters_preferences,
|
||||
timeout=DEFAULT_CONNECTION_TIMEOUT
|
||||
)
|
||||
self.top_tab = 'services'
|
||||
except TimeoutError:
|
||||
self.show_error('connection timed out')
|
||||
|
||||
async def do_disconnect(self, params):
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
if self.device.connecting:
|
||||
await self.device.cancel_connection()
|
||||
else:
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
|
||||
await self.connected_peer.connection.disconnect()
|
||||
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>')
|
||||
self.show_error('invalid syntax', 'expected update-parameters <interval-min>-<interval-max>/<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_intervals, max_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)
|
||||
max_latency = int(max_latency)
|
||||
supervision_timeout = int(supervision_timeout)
|
||||
await self.connected_peer.connection.update_parameters(
|
||||
connection_interval_min,
|
||||
connection_interval_max,
|
||||
connection_latency,
|
||||
max_latency,
|
||||
supervision_timeout
|
||||
)
|
||||
|
||||
@@ -442,6 +559,25 @@ class ConsoleApp:
|
||||
self.top_tab = params[0]
|
||||
self.ui.invalidate()
|
||||
|
||||
async def do_get_phy(self, params):
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
|
||||
phy = await self.connected_peer.connection.get_phy()
|
||||
self.append_to_output(f'PHY: RX={HCI_Constant.le_phy_name(phy[0])}, TX={HCI_Constant.le_phy_name(phy[1])}')
|
||||
|
||||
async def do_request_mtu(self, params):
|
||||
if len(params) != 1:
|
||||
self.show_error('invalid syntax', 'expected request-mtu <mtu>')
|
||||
return
|
||||
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
|
||||
await self.connected_peer.request_mtu(int(params[0]))
|
||||
|
||||
async def do_discover(self, params):
|
||||
if not params:
|
||||
self.show_error('invalid syntax', 'expected discover services|attributes')
|
||||
@@ -454,14 +590,14 @@ class ConsoleApp:
|
||||
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
|
||||
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
|
||||
characteristic = self.find_characteristic(params[0])
|
||||
if characteristic is None:
|
||||
self.show_error('no such characteristic')
|
||||
@@ -530,6 +666,42 @@ class ConsoleApp:
|
||||
|
||||
await characteristic.unsubscribe()
|
||||
|
||||
async def do_set_phy(self, params):
|
||||
if len(params) != 1:
|
||||
self.show_error('invalid syntax', 'expected set-phy <tx_rx_phys>|<tx_phys>/<rx_phys>')
|
||||
return
|
||||
|
||||
if not self.connected_peer:
|
||||
self.show_error('not connected')
|
||||
return
|
||||
|
||||
if '/' in params[0]:
|
||||
tx_phys, rx_phys = params[0].split('/')
|
||||
else:
|
||||
tx_phys = params[0]
|
||||
rx_phys = tx_phys
|
||||
|
||||
await self.connected_peer.connection.set_phy(
|
||||
tx_phys=parse_phys(tx_phys),
|
||||
rx_phys=parse_phys(rx_phys)
|
||||
)
|
||||
|
||||
async def do_set_default_phy(self, params):
|
||||
if len(params) != 1:
|
||||
self.show_error('invalid syntax', 'expected set-default-phy <tx_rx_phys>|<tx_phys>/<rx_phys>')
|
||||
return
|
||||
|
||||
if '/' in params[0]:
|
||||
tx_phys, rx_phys = params[0].split('/')
|
||||
else:
|
||||
tx_phys = params[0]
|
||||
rx_phys = tx_phys
|
||||
|
||||
await self.device.set_default_phy(
|
||||
tx_phys=parse_phys(tx_phys),
|
||||
rx_phys=parse_phys(rx_phys)
|
||||
)
|
||||
|
||||
async def do_exit(self, params):
|
||||
self.ui.exit()
|
||||
|
||||
@@ -548,12 +720,14 @@ class DeviceListener(Device.Listener, Connection.Listener):
|
||||
@AsyncRunner.run_in_task()
|
||||
async def on_connection(self, connection):
|
||||
self.app.connected_peer = Peer(connection)
|
||||
self.app.connection_rssi = None
|
||||
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
|
||||
self.app.connection_rssi = None
|
||||
|
||||
def on_connection_parameters_update(self):
|
||||
self.app.append_to_output(f'connection parameters update: {self.app.connected_peer.connection.parameters}')
|
||||
@@ -576,7 +750,7 @@ class DeviceListener(Device.Listener, Connection.Listener):
|
||||
if entry:
|
||||
entry.ad_data = advertisement.data
|
||||
entry.rssi = advertisement.rssi
|
||||
entry.connectable = advertisement.connectable
|
||||
entry.connectable = advertisement.is_connectable
|
||||
else:
|
||||
self.app.add_known_address(str(advertisement.address))
|
||||
self.scan_results[entry_key] = ScanResult(advertisement.address, advertisement.address.address_type, advertisement.data, advertisement.rssi, advertisement.is_connectable)
|
||||
@@ -616,12 +790,7 @@ class ScanResult:
|
||||
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_string = rssi_bar(self.rssi)
|
||||
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}'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user