mirror of
https://github.com/espressif/esp-idf.git
synced 2026-04-27 19:13:21 +00:00
e40fd6753d
TUI frontend built on Textual with thread-safe backend integration:
- Launch screen with port/baud/log-dir selection and auto-refresh
- Log view with scrollable RichLog and color-coded write methods;
Rich markup escaped to prevent crash on bracket characters
- Stats screen split into two tables by time base:
- Firmware Counters (since chip init): Written and Buffer Loss
- Console Measurements (since console start): Average Throughput
and Peak Write — each with separate frames and bytes sub-columns
- Column widths constrained with min_width/max_width for stable layout
- Division-by-zero guard when peak window is zero
- Shortcut screen overlay
- Status panel with sync state, checksum mode, and transport metrics;
speed display uses named UART_BITS_PER_BYTE constant
- Main app wiring: UART reader thread with threading.Lock for serial
access, frame parser pipeline, stats emission via StatsUpdated
messages (including funnel snapshots via public accessor for
thread-safe delivery), BackendStopped message for disconnect
- BackendStopped posted on open_serial failure (prevents stuck status)
- Select.BLANK guard for baud rate in launch screen
- Explicit None guards replace asserts in backend worker
- Wall-clock burst tracking for REDIR frames (no chip-side timestamp)
- SN gap alerts (FrameLossDetected) and traffic spike notifications
176 lines
5.1 KiB
Python
176 lines
5.1 KiB
Python
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Launch Screen — interactive setup for port, baud rate, and log directory.
|
|
|
|
Shown on startup when --port is not provided via CLI.
|
|
Dismissed with a LaunchConfig result on Connect, or None on quit.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
from textual import on
|
|
from textual import work
|
|
from textual.app import ComposeResult
|
|
from textual.binding import Binding
|
|
from textual.containers import Center
|
|
from textual.containers import Vertical
|
|
from textual.screen import Screen
|
|
from textual.widgets import Button
|
|
from textual.widgets import Input
|
|
from textual.widgets import Label
|
|
from textual.widgets import Select
|
|
from textual_fspicker import SelectDirectory
|
|
|
|
from src.backend.models import LaunchConfig
|
|
from src.backend.uart_transport import list_serial_ports
|
|
|
|
BAUD_RATES: list[int] = [115200, 230400, 460800, 921600, 1500000, 2000000, 3000000]
|
|
DEFAULT_BAUD_RATE: int = 3000000
|
|
|
|
|
|
class LaunchScreen(Screen[LaunchConfig | None]):
|
|
"""Interactive setup screen for BLE Log Console."""
|
|
|
|
DEFAULT_CSS = """
|
|
LaunchScreen {
|
|
align: center middle;
|
|
}
|
|
|
|
#launch-container {
|
|
width: 60;
|
|
max-height: 80%;
|
|
background: $surface;
|
|
padding: 1 2;
|
|
border: thick $accent;
|
|
}
|
|
|
|
#launch-title {
|
|
text-align: center;
|
|
text-style: bold;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
.field-label {
|
|
margin-top: 1;
|
|
}
|
|
|
|
.field-row {
|
|
height: auto;
|
|
layout: horizontal;
|
|
}
|
|
|
|
#port-select {
|
|
width: 1fr;
|
|
}
|
|
|
|
#refresh-btn {
|
|
width: auto;
|
|
min-width: 12;
|
|
}
|
|
|
|
#dir-input {
|
|
width: 1fr;
|
|
}
|
|
|
|
#browse-btn {
|
|
width: auto;
|
|
min-width: 12;
|
|
}
|
|
|
|
#connect-btn {
|
|
margin-top: 2;
|
|
}
|
|
|
|
#no-ports-label {
|
|
color: $warning;
|
|
}
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding('q', 'quit', 'Quit'),
|
|
Binding('Q', 'quit', show=False),
|
|
Binding('ctrl+c', 'quit', show=False, priority=True),
|
|
]
|
|
|
|
def __init__(self, default_log_dir: Path | None = None) -> None:
|
|
super().__init__()
|
|
self._default_log_dir = default_log_dir or Path.cwd()
|
|
|
|
def compose(self) -> ComposeResult:
|
|
ports = list_serial_ports()
|
|
port_options = [(p, p) for p in ports]
|
|
baud_options = [(str(b), b) for b in BAUD_RATES]
|
|
|
|
with Vertical(id='launch-container'):
|
|
yield Label('BLE Log Console Setup', id='launch-title')
|
|
|
|
yield Label('Serial Port', classes='field-label')
|
|
with Vertical(classes='field-row'):
|
|
if port_options:
|
|
yield Select(port_options, value=ports[0], id='port-select')
|
|
else:
|
|
yield Select([], id='port-select', prompt='No ports detected')
|
|
yield Label('No serial ports detected', id='no-ports-label')
|
|
yield Button('Refresh', id='refresh-btn')
|
|
|
|
yield Label('Baud Rate', classes='field-label')
|
|
yield Select(baud_options, value=DEFAULT_BAUD_RATE, id='baud-select')
|
|
|
|
yield Label('Log Directory', classes='field-label')
|
|
with Vertical(classes='field-row'):
|
|
yield Input(str(self._default_log_dir), id='dir-input')
|
|
yield Button('Browse...', id='browse-btn')
|
|
|
|
with Center():
|
|
yield Button('Connect', variant='primary', id='connect-btn')
|
|
|
|
@on(Button.Pressed, '#refresh-btn')
|
|
def refresh_ports(self) -> None:
|
|
"""Re-scan serial ports and update the Select widget."""
|
|
ports = list_serial_ports()
|
|
port_options = [(p, p) for p in ports]
|
|
port_select = self.query_one('#port-select', Select)
|
|
port_select.set_options(port_options)
|
|
if ports:
|
|
port_select.value = ports[0]
|
|
|
|
@on(Button.Pressed, '#browse-btn')
|
|
@work
|
|
async def browse_directory(self) -> None:
|
|
"""Open a directory picker dialog."""
|
|
current = self.query_one('#dir-input', Input).value
|
|
start = Path(current) if current else Path.cwd()
|
|
if not start.is_dir():
|
|
start = Path.cwd()
|
|
chosen = await self.app.push_screen_wait(SelectDirectory(location=start))
|
|
if chosen is not None:
|
|
self.query_one('#dir-input', Input).value = str(chosen)
|
|
|
|
@on(Button.Pressed, '#connect-btn')
|
|
def connect(self) -> None:
|
|
"""Validate and return config."""
|
|
port_select = self.query_one('#port-select', Select)
|
|
baud_select = self.query_one('#baud-select', Select)
|
|
dir_input = self.query_one('#dir-input', Input)
|
|
|
|
if port_select.value is Select.BLANK:
|
|
self.notify('Please select a serial port', severity='error')
|
|
return
|
|
|
|
if baud_select.value is Select.BLANK:
|
|
self.notify('Please select a baud rate', severity='error')
|
|
return
|
|
|
|
log_dir = Path(dir_input.value)
|
|
|
|
config = LaunchConfig(
|
|
port=str(port_select.value),
|
|
baudrate=int(baud_select.value), # type: ignore[arg-type] # guarded above
|
|
log_dir=log_dir,
|
|
)
|
|
self.dismiss(config)
|
|
|
|
def action_quit(self) -> None:
|
|
self.dismiss(None)
|