wongyiekheng c92fbe7548 Second Commit
Major refactor of Ameba Control Panel v3.1.0:
- Three-column layout: icon sidebar, config+history, log view
- Dracula PRO theme with light/dark toggle
- DTR/RTS GPIO control (replaces ASCII commands)
- Multi-CDC firmware support for AmebaSmart control device
- Dynamic DUT tabs with +/- management
- NN Model flash image support
- Settings dialog (Font, Serial, Flash, Command tabs)
- Background port scanning, debounced session store
- Adaptive log flush rate, format cache optimization
- Smooth sidebar animation, deferred startup
- pytest test framework with session/log/settings tests
- Thread safety fixes: _alive guards, parented timers, safe baud parsing
- Find highlight: needle-only highlighting with focused match color
- Partial line buffering for table output
- PyInstaller packaging with version stamp and module exclusions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:01:12 +08:00

248 lines
9.0 KiB
Python

from __future__ import annotations
import logging
import logging.handlers
import sys
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QApplication,
QInputDialog,
QMainWindow,
QMenuBar,
QMessageBox,
QTabWidget,
QToolButton,
)
from ameba_control_panel import config
from ameba_control_panel.config import DeviceProfile
from ameba_control_panel import theme
from ameba_control_panel.controllers.device_tab_controller import DeviceTabController
from ameba_control_panel.services.session_store import SessionStore
from ameba_control_panel.services.settings_service import Settings
from ameba_control_panel.views.settings_dialog import SettingsDialog
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle(f"{config.APP_NAME} v{config.APP_VERSION}")
self._tabs = QTabWidget()
self._tabs.setTabsClosable(True)
self._tabs.tabCloseRequested.connect(self._close_tab)
self._tabs.tabBarDoubleClicked.connect(self._rename_tab)
self.setCentralWidget(self._tabs)
self._session = SessionStore()
self._settings = Settings()
self._dut_controllers: list[DeviceTabController] = []
self._next_dut_num = 1
add_btn = QToolButton()
add_btn.setText("+")
add_btn.setToolTip("Add DUT tab (Ctrl+T)")
add_btn.clicked.connect(self._add_dut_tab_auto)
self._tabs.setCornerWidget(add_btn, Qt.TopLeftCorner)
self._current_palette = theme.LIGHT
self._build_menu_bar()
self._setup_shortcuts()
tab_list = self._session.get_tab_list()
if tab_list:
for entry in tab_list:
key = entry.get("key", "")
label = entry.get("label", "")
if key and label:
self._add_dut_tab(key, label)
else:
self._add_dut_tab("dut_1", "DUT 1")
def _build_menu_bar(self) -> None:
mb = self.menuBar()
view_menu = mb.addMenu("&View")
view_menu.addAction("New Tab", self._add_dut_tab_auto, QKeySequence("Ctrl+T"))
view_menu.addAction("Close Tab", self._close_current_tab, QKeySequence("Ctrl+W"))
view_menu.addSeparator()
view_menu.addAction("Clear Log", self._clear_current_log, QKeySequence("Ctrl+L"))
view_menu.addAction("Find", self._focus_find, QKeySequence("Ctrl+F"))
settings_menu = mb.addMenu("&Settings")
settings_menu.addAction("Preferences...", self._open_settings)
help_menu = mb.addMenu("&Help")
help_menu.addAction("About", self._show_about)
def _toggle_theme(self) -> None:
if self._current_palette.name == "light":
self._current_palette = theme.DARK
else:
self._current_palette = theme.LIGHT
QApplication.instance().setStyleSheet(theme.build_stylesheet(self._current_palette))
p = self._current_palette
for ctrl in self._dut_controllers:
ctrl.view.log_view.set_colors(p.log_rx, p.log_tx, p.log_info)
def _show_about(self) -> None:
QMessageBox.about(self, config.APP_NAME,
f"{config.APP_NAME}\n"
f"Version: {config.APP_VERSION}\n\n"
f"Author: Yiek Heng\n"
f"Email: wongyiekheng@realtek-sg.com\n\n"
f"Licensed under the Apache License, Version 2.0\n"
f"All rights reserved. For internal use only.")
def _setup_shortcuts(self) -> None:
QShortcut(QKeySequence("Ctrl+T"), self, activated=self._add_dut_tab_auto)
QShortcut(QKeySequence("Ctrl+W"), self, activated=self._close_current_tab)
QShortcut(QKeySequence("Ctrl+F"), self, activated=self._focus_find)
QShortcut(QKeySequence("Ctrl+L"), self, activated=self._clear_current_log)
QShortcut(QKeySequence("Ctrl+Return"), self, activated=self._send_current)
QShortcut(QKeySequence("Ctrl+Shift+F"), self, activated=self._flash_current)
QShortcut(QKeySequence("Ctrl+R"), self, activated=self._reset_current)
def _close_current_tab(self) -> None:
self._close_tab(self._tabs.currentIndex())
def _focus_find(self) -> None:
ctrl = self._current_controller()
if ctrl:
ctrl.view.find_input.setFocus()
ctrl.view.find_input.selectAll()
def _clear_current_log(self) -> None:
ctrl = self._current_controller()
if ctrl:
ctrl.log.clear()
def _send_current(self) -> None:
ctrl = self._current_controller()
if ctrl:
ctrl._send_from_input()
def _open_settings(self) -> None:
dlg = SettingsDialog(self._settings, self)
dlg.set_apply_callback(self._apply_settings)
dlg.exec()
self._apply_settings()
def _apply_settings(self) -> None:
font = QFont(self._settings.font_family)
font.setStyleHint(QFont.Monospace)
font.setPointSize(self._settings.font_size)
for ctrl in self._dut_controllers:
ctrl.view.log_view.setFont(font)
ctrl._port_timer.setInterval(self._settings.port_scan_interval_sec * 1000)
def _flash_current(self) -> None:
ctrl = self._current_controller()
if ctrl:
ctrl.flash.run_flash()
def _reset_current(self) -> None:
ctrl = self._current_controller()
if ctrl:
from ameba_control_panel.config import Mode
ctrl.flash.run_mode(Mode.RESET)
def _current_controller(self) -> DeviceTabController | None:
widget = self._tabs.currentWidget()
for ctrl in self._dut_controllers:
if ctrl.view is widget:
return ctrl
return None
def _add_dut_tab(self, key: str, label: str) -> DeviceTabController:
profile = DeviceProfile(key=key, label=label)
controller = DeviceTabController(profile)
self._dut_controllers.append(controller)
# Wire sidebar Settings/Theme buttons
controller.view.settings_btn.clicked.connect(self._open_settings)
controller.view.theme_btn.clicked.connect(self._toggle_theme)
self._tabs.addTab(controller.view, label)
try:
num = int(key.split("_")[-1])
if num >= self._next_dut_num:
self._next_dut_num = num + 1
except (ValueError, IndexError):
pass
self._persist_tab_list()
return controller
def _add_dut_tab_auto(self) -> None:
key = f"dut_{self._next_dut_num}"
label = f"DUT {self._next_dut_num}"
self._add_dut_tab(key, label)
self._tabs.setCurrentIndex(self._tabs.count() - 1)
def _rename_tab(self, index: int) -> None:
old_label = self._tabs.tabText(index)
new_label, ok = QInputDialog.getText(self, "Rename Tab", "Tab name:", text=old_label)
if not ok or not new_label.strip():
return
new_label = new_label.strip()
self._tabs.setTabText(index, new_label)
for ctrl in self._dut_controllers:
if ctrl.view is self._tabs.widget(index):
ctrl.profile = DeviceProfile(key=ctrl.profile.key, label=new_label)
break
self._persist_tab_list()
def _close_tab(self, index: int) -> None:
if len(self._dut_controllers) <= 1:
QMessageBox.information(self, "Close Tab", "Cannot close the last DUT tab.")
return
widget = self._tabs.widget(index)
for ctrl in self._dut_controllers:
if ctrl.view is widget:
ctrl.shutdown()
self._session.remove(ctrl.profile.key)
self._dut_controllers.remove(ctrl)
break
self._tabs.removeTab(index)
self._persist_tab_list()
def _persist_tab_list(self) -> None:
tabs = [{"key": c.profile.key, "label": c.profile.label} for c in self._dut_controllers]
self._session.set_tab_list(tabs)
def closeEvent(self, event) -> None: # noqa: N802
for c in self._dut_controllers:
c.shutdown()
self._session.save_now()
super().closeEvent(event)
def _setup_logging() -> None:
log_dir = config.app_data_dir()
log_dir.mkdir(parents=True, exist_ok=True)
handler = logging.handlers.RotatingFileHandler(
log_dir / "app.log", maxBytes=2_000_000, backupCount=3, encoding="utf-8",
)
handler.setFormatter(logging.Formatter(
"%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S",
))
root = logging.getLogger()
root.setLevel(logging.DEBUG)
root.addHandler(handler)
logging.getLogger(__name__).info("App started v%s", config.APP_VERSION)
def main() -> None:
_setup_logging()
QApplication.setStyle("Fusion")
app = QApplication(sys.argv)
app.setStyleSheet(theme.build_stylesheet(theme.LIGHT))
window = MainWindow()
window.resize(1200, 800)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()