From c92fbe754885898003fe3f4a1061f317a9780b1f Mon Sep 17 00:00:00 2001 From: wongyiekheng Date: Sun, 29 Mar 2026 13:01:12 +0800 Subject: [PATCH] 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) --- Flash/flash_amebapro3.py | 294 ++++--- ameba_control_panel/app.py | 237 +++++- ameba_control_panel/config.py | 53 +- .../controllers/debugger_tab_controller.py | 238 ++++++ .../controllers/device_tab_controller.py | 477 ++++------- ameba_control_panel/managers/__init__.py | 0 ameba_control_panel/managers/flash_manager.py | 251 ++++++ ameba_control_panel/managers/log_manager.py | 174 ++++ .../services/command_player.py | 10 +- .../services/jlink_debug_service.py | 765 ++++++++++++++++++ ameba_control_panel/services/log_buffer.py | 13 +- ameba_control_panel/services/port_service.py | 55 +- .../services/serial_service.py | 55 +- ameba_control_panel/services/session_store.py | 74 +- .../services/settings_service.py | 119 +++ ameba_control_panel/theme.py | 328 ++++++++ .../views/debugger_tab_view.py | 196 +++++ ameba_control_panel/views/device_tab_view.py | 484 ++++++++--- ameba_control_panel/views/log_view.py | 122 ++- ameba_control_panel/views/settings_dialog.py | 157 ++++ script/auto_run.py | 19 +- script/package_exe.py | 135 +++- tests/conftest.py | 43 + tests/test_log_buffer.py | 60 ++ tests/test_session_store.py | 66 ++ tests/test_settings_service.py | 34 + 26 files changed, 3815 insertions(+), 644 deletions(-) create mode 100644 ameba_control_panel/controllers/debugger_tab_controller.py create mode 100644 ameba_control_panel/managers/__init__.py create mode 100644 ameba_control_panel/managers/flash_manager.py create mode 100644 ameba_control_panel/managers/log_manager.py create mode 100644 ameba_control_panel/services/jlink_debug_service.py create mode 100644 ameba_control_panel/services/settings_service.py create mode 100644 ameba_control_panel/theme.py create mode 100644 ameba_control_panel/views/debugger_tab_view.py create mode 100644 ameba_control_panel/views/settings_dialog.py create mode 100644 tests/conftest.py create mode 100644 tests/test_log_buffer.py create mode 100644 tests/test_session_store.py create mode 100644 tests/test_settings_service.py diff --git a/Flash/flash_amebapro3.py b/Flash/flash_amebapro3.py index 2314c6b..e6d0d3d 100644 --- a/Flash/flash_amebapro3.py +++ b/Flash/flash_amebapro3.py @@ -202,9 +202,19 @@ def main(): parser.add_argument("-bin", dest="single_image", help="Single image path (custom address mode)") parser.add_argument("-s", "--start", dest="start_addr", help="Start address (hex) for single image") parser.add_argument("-e", "--end", dest="end_addr", help="End address (hex) for single image") + parser.add_argument( + "--multi-bin", + dest="multi_bin", + action="append", + nargs=3, + metavar=("IMAGE", "START", "END"), + help="Repeatable image triplet for one-shot flashing.", + ) parser.add_argument("--debug", action="store_true", help="Set log-level to debug") parser.add_argument("--auto-dtr", action="store_true", help="(Deprecated) legacy auto DTR/RTS toggle; ignored when using GPIO helper.") + parser.add_argument("--dtr-rts", action="store_true", dest="dtr_rts", + help="Use DTR/RTS on the target port for BOOT/RESET control instead of ASCII GPIO commands on a bridge port.") parser.add_argument("-r", "--reset", action="store_true", help="Force reset to normal mode after flashing (post-process RESET). Default unless -dl 1.") parser.add_argument("-dl", "--download-mode", type=int, choices=[0, 1], @@ -222,9 +232,40 @@ def main(): bridge_port = args.bridge_port or args.port target_port = args.target_port + def parse_addr(text: str) -> int | None: + try: + value = int(text, 0) + except Exception: + return None + return value if value >= 0 else None + + multi_images: list[tuple[pathlib.Path, int, int]] = [] + if args.multi_bin: + for image_path, start_text, end_text in args.multi_bin: + path = pathlib.Path(image_path).expanduser() + if not path.exists(): + print(f"Multi image not found: {path}", file=sys.stderr) + sys.exit(1) + start_value = parse_addr(start_text) + end_value = parse_addr(end_text) + if start_value is None or end_value is None or start_value >= end_value: + print(f"Invalid multi image range: {image_path} {start_text} {end_text}", file=sys.stderr) + sys.exit(1) + multi_images.append((path, start_value, end_value)) + + if multi_images and any([args.single_image, args.boot, args.app, args.image_dir]): + print("Cannot combine --multi-bin with -bin/-s/-e, -a/-b, or -i.", file=sys.stderr) + sys.exit(1) + # Decide image directory vs per-image flashing. image_dir = args.image_dir - if args.single_image: + if multi_images: + boot_path = None + app_path = None + image_dir = None + start_addr = None + end_addr = None + elif args.single_image: boot_path = pathlib.Path(args.single_image).expanduser() if not boot_path.exists(): print("Custom image not found", file=sys.stderr) @@ -260,7 +301,7 @@ def main(): # address ranges expected by AmebaPro3 (boot: 0x08000000-0x08040000, # app: 0x08040000-0x08440000). This avoids relying on the profile's # partition table size checks, which can reject larger binaries. - if image_dir and not (boot_path or app_path or args.single_image): + if image_dir and not (boot_path or app_path or args.single_image or multi_images): candidate_boot = pathlib.Path(image_dir) / "amebapro3_boot.bin" candidate_app = pathlib.Path(image_dir) / "amebapro3_app.bin" if candidate_boot.exists() and candidate_app.exists(): @@ -285,7 +326,7 @@ def main(): patched_load = False # If control-only flags are provided alongside any flash inputs, bail out early. - if control_only and any([args.single_image, boot_path, app_path, image_dir]): + if control_only and any([args.single_image, boot_path, app_path, image_dir, multi_images]): print("Cannot combine -dl/-r with flashing arguments; use them alone to toggle mode/reset.", file=sys.stderr) sys.exit(1) @@ -307,12 +348,9 @@ def main(): JsonUtils.load_from_file = patched_load # type: ignore patched_load = True - # Inline GPIO control using ASCII BOOT/RESET commands handled by AmebaSmart firmware. + # -- GPIO control: ASCII commands (legacy) -------------------------------- def send_boot_reset(port: str, baud: int, idx: int, boot: int | None = None, reset: int | None = None): - """ - Issue ASCII commands understood by AmebaSmart CDC bridge. - Firmware uses "BOOT <0|1>" and "RESET <0|1>". - """ + """ASCII commands understood by AmebaSmart CDC bridge.""" with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser: if boot is not None: ser.write(f"BOOT {idx} {boot}\r\n".encode()) @@ -320,57 +358,48 @@ def main(): ser.write(f"RESET {idx} {reset}\r\n".encode()) ser.flush() - def boot_seq(port: str, baud: int, idx: int): - """ - Enter download mode (dl=1): - boot=1 held throughout flash, reset pulse: boot 0 1 -> reset 0 1 -> reset 0 0 -> reset 0 1 - Send within a single serial open to avoid CDC drop between steps. BOOT left asserted. - """ + def boot_seq_ascii(port: str, baud: int, idx: int): + """Enter download mode via ASCII GPIO commands on control port.""" with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser: - ser.write(f"BOOT {idx} 1\r\n".encode()) # boot 0 1 - ser.write(f"RESET {idx} 1\r\n".encode()) # reset 0 1 + ser.write(f"BOOT {idx} 1\r\n".encode()) + ser.write(f"RESET {idx} 1\r\n".encode()) ser.flush() time.sleep(0.1) - ser.write(f"RESET {idx} 0\r\n".encode()) # reset 0 0 + ser.write(f"RESET {idx} 0\r\n".encode()) ser.flush() time.sleep(0.1) - ser.write(f"RESET {idx} 1\r\n".encode()) # reset 0 1 + ser.write(f"RESET {idx} 1\r\n".encode()) ser.flush() - time.sleep(0.2) # allow CDC re-enumeration into download; BOOT stays asserted + time.sleep(0.2) - def reset_seq(port: str, baud: int, idx: int): - """ - Pulse RESET only (BOOT unchanged): reset=1 -> reset=0 -> reset=1 - """ + def reset_seq_ascii(port: str, baud: int, idx: int): + """Pulse RESET only via ASCII GPIO commands.""" send_boot_reset(port, baud, idx, reset=1) time.sleep(0.05) send_boot_reset(port, baud, idx, reset=0) time.sleep(0.05) send_boot_reset(port, baud, idx, reset=1) - def reset_exit_seq(port: str, baud: int, idx: int): - """ - Exit download mode (dl=0): - reset=1/boot=0 -> reset=0/boot=0 -> reset=1/boot=0 (final high). - """ - time.sleep(0.1) # small guard before exit sequence + def reset_exit_seq_ascii(port: str, baud: int, idx: int): + """Exit download mode via ASCII GPIO commands.""" + time.sleep(0.1) with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser: - ser.write(f"BOOT {idx} 0\r\n".encode()) # boot 0 0 + ser.write(f"BOOT {idx} 0\r\n".encode()) ser.flush() time.sleep(0.2) - ser.write(f"RESET {idx} 1\r\n".encode()) # reset 0 1 (assert) + ser.write(f"RESET {idx} 1\r\n".encode()) ser.flush() time.sleep(0.2) - ser.write(f"RESET {idx} 0\r\n".encode()) # reset 0 0 (release) + ser.write(f"RESET {idx} 0\r\n".encode()) ser.flush() time.sleep(0.2) - ser.write(f"RESET {idx} 1\r\n".encode()) # final high + ser.write(f"RESET {idx} 1\r\n".encode()) ser.flush() time.sleep(0.5) - def release_to_normal(port: str, baud: int, idx: int): - """After flashing: BOOT=0, RESET high, with retries for re-enum.""" - reset_exit_seq(port, baud, idx) + def release_to_normal_ascii(port: str, baud: int, idx: int): + """After flashing: BOOT=0, RESET high via ASCII GPIO.""" + reset_exit_seq_ascii(port, baud, idx) time.sleep(0.2) try: send_boot_reset(port, baud, idx, boot=0) @@ -378,26 +407,119 @@ def main(): except SerialException: pass + # -- GPIO control: DTR/RTS on DUT port (no control port needed) ---------- + # Mapping: DTR → BOOT pin, RTS → CHIP_EN (reset) pin. + # True = asserted, False = released. + + def boot_seq_dtr(port: str, baud: int): + """Enter download mode via DTR/RTS on the DUT port.""" + with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser: + ser.setDTR(True) # BOOT asserted + ser.setRTS(True) # RESET asserted (hold chip in reset) + time.sleep(0.1) + ser.setRTS(False) # RESET released → chip starts with BOOT=1 → download mode + time.sleep(0.1) + ser.setRTS(True) # RESET back to idle + time.sleep(0.2) + + def reset_seq_dtr(port: str, baud: int): + """Pulse RESET via RTS (BOOT unchanged).""" + with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser: + ser.setRTS(True) + time.sleep(0.05) + ser.setRTS(False) + time.sleep(0.05) + ser.setRTS(True) + + def reset_exit_seq_dtr(port: str, baud: int): + """Exit download mode via DTR/RTS: BOOT=0, pulse RESET.""" + time.sleep(0.1) + with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser: + ser.setDTR(False) # BOOT released + time.sleep(0.2) + ser.setRTS(True) # RESET assert + time.sleep(0.2) + ser.setRTS(False) # RESET release → chip starts with BOOT=0 → normal mode + time.sleep(0.2) + ser.setRTS(True) # idle + time.sleep(0.5) + + def release_to_normal_dtr(port: str, baud: int): + """After flashing: BOOT=0, RESET idle via DTR/RTS.""" + reset_exit_seq_dtr(port, baud) + time.sleep(0.2) + try: + with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser: + ser.setDTR(False) + ser.setRTS(True) + except SerialException: + pass + + # -- Dispatch: pick ASCII or DTR/RTS based on --dtr-rts flag ------------- + use_dtr_rts = args.dtr_rts + + def boot_seq(port: str, baud: int, idx: int = 0): + if use_dtr_rts: + boot_seq_dtr(port, baud) + else: + boot_seq_ascii(port, baud, idx) + + def reset_seq(port: str, baud: int, idx: int = 0): + if use_dtr_rts: + reset_seq_dtr(port, baud) + else: + reset_seq_ascii(port, baud, idx) + + def reset_exit_seq(port: str, baud: int, idx: int = 0): + if use_dtr_rts: + reset_exit_seq_dtr(port, baud) + else: + reset_exit_seq_ascii(port, baud, idx) + + def release_to_normal(port: str, baud: int, idx: int = 0): + if use_dtr_rts: + release_to_normal_dtr(port, baud) + else: + release_to_normal_ascii(port, baud, idx) + # Reset-only / mode-only handling: if -dl/-r is provided without any images, # just toggle the lines and exit without running flash.py. if control_only: - ctrl_port = args.bridge_port or args.port + if use_dtr_rts: + ctrl_port = target_port or bridge_port + else: + ctrl_port = bridge_port or args.port if not ctrl_port: - print("Bridge port (--port) is required for GPIO control", file=sys.stderr) + print("A port is required for GPIO control (bridge port or --dtr-rts with target port)", file=sys.stderr) sys.exit(1) - idx = 0 # only UART index 0 drives BOOT/RESET; others are no-op per firmware + idx = 0 try: if args.download_mode == 1: boot_seq(ctrl_port, args.baudrate, idx) elif args.download_mode == 0: reset_exit_seq(ctrl_port, args.baudrate, idx) elif args.reset: - reset_seq(ctrl_port, args.baudrate, idx) # retain BOOT state + reset_seq(ctrl_port, args.baudrate, idx) sys.exit(0) finally: if patched_load: JsonUtils.load_from_file = orig_load # type: ignore + def build_partition_entry(image_path, start_addr: int, end_addr: int) -> dict: + p = pathlib.Path(image_path) if isinstance(image_path, str) else image_path + return { + "ImageName": str(p), + "StartAddress": start_addr, + "EndAddress": end_addr, + "MemoryType": 1, # NOR + "FullErase": False, + "Mandatory": True, + "Description": p.name, + } + + def encode_partition_table(entries: list[dict]) -> str: + return base64.b64encode(json.dumps(entries).encode()).decode() + def run_flash(argv_tail): """Invoke flash.main with a constructed argv list.""" original_argv = sys.argv @@ -411,13 +533,16 @@ def main(): finally: sys.argv = original_argv - def try_reset_port(port: str): - """Best-effort reset using inline BOOT/RESET commands, then small delay.""" - try: - reset_seq(port, args.baudrate) - time.sleep(0.5) - except SerialException: - pass + def flash_with_retry(argv, label="flash"): + """Run flash, retry once with boot_seq if first attempt fails.""" + rc = run_flash(argv) + if rc != 0 and gpio_port: + boot_seq(gpio_port, args.baudrate, 0) + rc = run_flash(argv) + if rc != 0: + print(f"{label} failed (code {rc}).", file=sys.stderr) + sys.exit(rc) + return rc rc = 0 try: @@ -437,57 +562,31 @@ def main(): common += ["--port", flash_port] common += args.extra - # If flashing (not control_only), pre-drive into download mode via bridge if available. - if not control_only and bridge_port: - boot_seq(bridge_port, args.baudrate, 0) + # Pre-drive into download mode before flashing. + gpio_port = flash_port if use_dtr_rts else bridge_port + if not control_only and gpio_port: + boot_seq(gpio_port, args.baudrate, 0) - if args.single_image: + if multi_images: + partition_entries = [build_partition_entry(p, s, e) for p, s, e in multi_images] + merged_argv = common + ["--partition-table", encode_partition_table(partition_entries)] + rc = flash_with_retry(merged_argv, "Multi-image flash") + elif args.single_image: single_argv = common + [ "--image", str(boot_path), "--start-address", start_addr, "--end-address", end_addr, ] - run_flash(single_argv) + rc = run_flash(single_argv) + if rc != 0: + sys.exit(rc) elif boot_path and app_path: - # Always build a custom partition table (base64-encoded JSON) so flash.py - # downloads both images in one session without intermediate reset. partition_entries = [ - { - "ImageName": str(boot_path), - "StartAddress": int("0x08000000", 16), - "EndAddress": int("0x08040000", 16), - "MemoryType": 1, # NOR - "FullErase": False, - "Mandatory": True, - "Description": boot_path.name, - }, - { - "ImageName": str(app_path), - "StartAddress": int("0x08040000", 16), - "EndAddress": int("0x08440000", 16), - "MemoryType": 1, # NOR - "FullErase": False, - "Mandatory": True, - "Description": app_path.name, - }, + build_partition_entry(boot_path, 0x08000000, 0x08040000), + build_partition_entry(app_path, 0x08040000, 0x08440000), ] - partition_json = json.dumps(partition_entries) - partition_b64 = base64.b64encode(partition_json.encode()).decode() - - merged_argv = common + [ - "--partition-table", partition_b64, - ] - rc = run_flash(merged_argv) - if rc != 0: - # Retry once: drive download again, then re-run single pass; if still fail, fallback two-step. - if bridge_port: - boot_seq(bridge_port, args.baudrate, 0) - rc = run_flash(merged_argv) - if rc != 0: - print("Single-pass flash failed (code {}). Aborting (no post-flash).".format(rc), file=sys.stderr) - sys.exit(rc) - if rc != 0: - sys.exit(rc) + merged_argv = common + ["--partition-table", encode_partition_table(partition_entries)] + rc = flash_with_retry(merged_argv, "Single-pass flash") else: dir_argv = common.copy() if image_dir: @@ -498,24 +597,25 @@ def main(): finally: if patched_load: JsonUtils.load_from_file = orig_load # type: ignore - # After successful flashing, release BOOT and reset to normal mode via bridge if available. + # After flashing, release BOOT and reset to normal mode. if not control_only: - ctrl_port = bridge_port or flash_port - if ctrl_port: + post_port = gpio_port or flash_port + if post_port: for _ in range(3): try: - release_to_normal(ctrl_port, args.baudrate, 0) + release_to_normal(post_port, args.baudrate, 0) break except SerialException: time.sleep(0.5) else: - # Best-effort final deassert to cover missed bytes. try: - send_boot_reset(ctrl_port, args.baudrate, 0, boot=0) - send_boot_reset(ctrl_port, args.baudrate, 0, reset=1) + if use_dtr_rts: + release_to_normal_dtr(post_port, args.baudrate) + else: + send_boot_reset(post_port, args.baudrate, 0, boot=0) + send_boot_reset(post_port, args.baudrate, 0, reset=1) except SerialException: pass - # (optional cleanup of timing files could go here) if __name__ == "__main__": diff --git a/ameba_control_panel/app.py b/ameba_control_panel/app.py index da61018..7bad246 100644 --- a/ameba_control_panel/app.py +++ b/ameba_control_panel/app.py @@ -1,51 +1,242 @@ from __future__ import annotations +import logging +import logging.handlers import sys -from PySide6.QtGui import QIcon, QPalette, QColor -from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget +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(config.APP_NAME) + 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.controllers: list[DeviceTabController] = [] - for profile in config.DEVICE_PROFILES: - controller = DeviceTabController(profile) - self.controllers.append(controller) - self._tabs.addTab(controller.view, profile.label) + + 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.controllers: + for c in self._dut_controllers: c.shutdown() + self._session.save_now() super().closeEvent(event) -def _apply_light_palette(app: QApplication) -> None: - palette = QPalette() - palette.setColor(QPalette.Window, QColor(245, 245, 245)) - palette.setColor(QPalette.WindowText, QColor(0, 0, 0)) - palette.setColor(QPalette.Base, QColor(255, 255, 255)) - palette.setColor(QPalette.AlternateBase, QColor(240, 240, 240)) - palette.setColor(QPalette.ToolTipBase, QColor(255, 255, 255)) - palette.setColor(QPalette.ToolTipText, QColor(0, 0, 0)) - palette.setColor(QPalette.Text, QColor(0, 0, 0)) - palette.setColor(QPalette.Button, QColor(240, 240, 240)) - palette.setColor(QPalette.ButtonText, QColor(0, 0, 0)) - palette.setColor(QPalette.BrightText, QColor(255, 0, 0)) - app.setPalette(palette) +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) - _apply_light_palette(app) + app.setStyleSheet(theme.build_stylesheet(theme.LIGHT)) window = MainWindow() window.resize(1200, 800) window.show() diff --git a/ameba_control_panel/config.py b/ameba_control_panel/config.py index 5f71250..68d4fa9 100644 --- a/ameba_control_panel/config.py +++ b/ameba_control_panel/config.py @@ -2,15 +2,45 @@ from __future__ import annotations import os from dataclasses import dataclass +from enum import Enum from pathlib import Path from typing import Tuple +class _StrEnumMixin(str, Enum): + """Behaves like StrEnum on Python < 3.11.""" + + +class Direction(_StrEnumMixin): + RX = "rx" + TX = "tx" + INFO = "info" + + +class Mode(_StrEnumMixin): + NORMAL = "normal" + DOWNLOAD = "download" + RESET = "reset" + + +CHECKBOX_STYLE = ( + "QCheckBox::indicator {" + " width: 14px; height: 14px; border: 1px solid #666; border-radius: 2px; background: #ffffff;" + "}" + "QCheckBox::indicator:checked {" + " background: #1b5e20; border: 1px solid #1b5e20;" + "}" +) + APP_NAME = "Ameba Control Panel" +APP_VERSION = "3.1.0" UI_LOG_TAIL_LINES = 100_000 LOG_FLUSH_INTERVAL_MS = 30 +LOG_ARCHIVE_MAX = 500_000 +LOG_FLUSH_BATCH_LIMIT = 200 +PARTIAL_LINE_HOLD_SEC = 0.3 PERF_UPDATE_INTERVAL_MS = 1_000 -PORT_REFRESH_INTERVAL_MS = 2_000 +PORT_REFRESH_INTERVAL_MS = 5_000 DEFAULT_BAUD = 1_500_000 COMMON_BAUD_RATES = [ 115_200, @@ -26,20 +56,23 @@ COMMON_BAUD_RATES = [ TIMESTAMP_FMT = "%Y-%m-%d %H:%M:%S.%f" -@dataclass(frozen=True) +@dataclass class DeviceProfile: key: str label: str - rx_color: str - tx_color: str - info_color: str + rx_color: str = "#1a8a3d" + tx_color: str = "#2944a8" + info_color: str = "#7970a9" -DEVICE_PROFILES: Tuple[DeviceProfile, ...] = ( - DeviceProfile("amebapro3", "AmebaPro3 (RTL8735C)", "#1b5e20", "#0d47a1", "#424242"), - DeviceProfile("amebapro2", "AmebaPro2 (RTL8735B)", "#1b5e20", "#0d47a1", "#424242"), - DeviceProfile("amebasmart", "AmebaSmart (RTL8730E)", "#1b5e20", "#0d47a1", "#424242"), -) +DEFAULT_PROFILE = DeviceProfile("dut_1", "DUT 1") + + +def parse_baud(text: str) -> int: + try: + return int(text) + except (ValueError, TypeError): + return DEFAULT_BAUD def app_data_dir() -> Path: diff --git a/ameba_control_panel/controllers/debugger_tab_controller.py b/ameba_control_panel/controllers/debugger_tab_controller.py new file mode 100644 index 0000000..7c1a941 --- /dev/null +++ b/ameba_control_panel/controllers/debugger_tab_controller.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List + +from PySide6.QtCore import QObject, QTimer, Slot, QCoreApplication +from PySide6.QtWidgets import QMessageBox + +from ameba_control_panel.services.jlink_debug_service import JLinkDebugService, JLinkState +from ameba_control_panel.services.session_store import SessionStore +from ameba_control_panel.views.debugger_tab_view import DebuggerTabView + + +class DebuggerTabController(QObject): + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.view = DebuggerTabView() + self.service = JLinkDebugService() + self._session = SessionStore() + self._session_state = self._session.get("debugger") + self._connected = False + + self._poll_timer = QTimer(self) + self._poll_timer.setInterval(200) + self._poll_timer.timeout.connect(self._poll_registers) + + self._wire_ui() + self._restore_session() + self.service.refresh_emulators() + + def _wire_ui(self) -> None: + v = self.view + v.refresh_probes_btn.clicked.connect(self.service.refresh_emulators) + v.connect_btn.toggled.connect(self._toggle_connection) + v.halt_btn.clicked.connect(self.service.halt_target) + v.run_btn.clicked.connect(self.service.run_target) + v.step_in_btn.clicked.connect(self.service.step_in) + v.step_over_btn.clicked.connect(self.service.step_over) + v.step_out_btn.clicked.connect(self.service.step_out) + v.command_send_btn.clicked.connect(self._send_command) + v.command_input.returnPressed.connect(self._send_command) + + v.jlink_combo.currentIndexChanged.connect(self._save_session) + v.processor_combo.currentIndexChanged.connect(self._on_processor_changed) + v.device_edit.editingFinished.connect(self._save_session) + v.interface_combo.currentIndexChanged.connect(self._save_session) + v.speed_spin.valueChanged.connect(self._save_session) + + self.service.status_changed.connect(self._on_status_changed) + self.service.log_line.connect(v.append_log) + self.service.command_result.connect(v.append_log) + self.service.emulators_updated.connect(self._on_emulators_updated) + self.service.registers_updated.connect(v.set_registers) + + def _restore_session(self) -> None: + if not self._session_state: + self._apply_processor_default_device() + self._apply_processor_default_interface() + return + if "processor" in self._session_state: + idx = self.view.processor_combo.findData(str(self._session_state.get("processor") or "np")) + if idx >= 0: + self.view.processor_combo.blockSignals(True) + self.view.processor_combo.setCurrentIndex(idx) + self.view.processor_combo.blockSignals(False) + self._apply_processor_default_device() + self._apply_processor_default_interface() + if "device" in self._session_state: + processor = self.view.selected_processor() + session_device = str(self._session_state.get("device") or "") + self.view.device_edit.setText(self._normalize_device_for_processor(processor, session_device)) + if "interface" in self._session_state: + processor = self.view.selected_processor() + interface = self._normalize_interface_for_processor( + processor, str(self._session_state.get("interface") or "SWD") + ) + idx = self.view.interface_combo.findText(interface) + if idx >= 0: + self.view.interface_combo.setCurrentIndex(idx) + if "speed_khz" in self._session_state: + try: + self.view.speed_spin.setValue(int(self._session_state.get("speed_khz") or 4000)) + except Exception: + pass + + def _save_session(self) -> None: + payload = { + "probe": self.view.selected_emulator(), + "processor": self.view.selected_processor(), + "device": self.view.device_edit.text(), + "interface": self.view.interface_combo.currentText(), + "speed_khz": self.view.speed_spin.value(), + } + self._session.set("debugger", payload) + self._session_state = payload + + def _on_processor_changed(self) -> None: + self._apply_processor_default_device() + self._apply_processor_default_interface() + self._save_session() + + def _apply_processor_default_device(self) -> None: + selected = self.view.selected_processor() + current = self.view.device_edit.text().strip() + defaults = { + "np": "Cortex-M33", + "fp": "Cortex-M23", + "mp": "Cortex-M33", + "ca32": "Cortex-A7", + } + legacy_defaults = {"Cortex-A32"} + if not current or current in set(defaults.values()) | legacy_defaults: + self.view.device_edit.setText(defaults.get(selected, "Cortex-M33")) + + def _apply_processor_default_interface(self) -> None: + selected = self.view.selected_processor() + current = self.view.interface_combo.currentText().strip().upper() + defaults = { + "np": "SWD", + "fp": "SWD", + "mp": "SWD", + "ca32": "JTAG", + } + desired = defaults.get(selected, "SWD") + if selected == "ca32" and current == "SWD": + idx = self.view.interface_combo.findText(desired) + if idx >= 0: + self.view.interface_combo.setCurrentIndex(idx) + + def _normalize_interface_for_processor(self, processor: str, interface: str) -> str: + proc = (processor or "").strip().lower() + text = (interface or "").strip().upper() + if proc == "ca32" and text == "SWD": + return "JTAG" + return text or "SWD" + + def _normalize_device_for_processor(self, processor: str, device: str) -> str: + proc = (processor or "").strip().lower() + text = (device or "").strip() + if proc == "ca32" and text == "Cortex-A32": + return "Cortex-A7" + if proc == "fp" and text == "Cortex-M33": + return "Cortex-M23" + return text + + def _resolve_processor_script(self, processor: str) -> Path: + processor_name = (processor or "np").strip().lower() + folders = [processor_name] + if processor_name == "ca32": + folders.append("ap") + candidates: list[Path] = [] + for folder in folders: + candidates.extend( + [ + Path(QCoreApplication.applicationDirPath()) / "script" / "processor" / folder / "Amebatest.JLinkScript", + Path(QCoreApplication.applicationDirPath()) / "processor" / folder / "Amebatest.JLinkScript", + Path(__file__).resolve().parents[2] / "script" / "processor" / folder / "Amebatest.JLinkScript", + ] + ) + for path in candidates: + if path.exists(): + return path.resolve() + return candidates[-1].resolve() + + @Slot(list) + def _on_emulators_updated(self, serials: List[str]) -> None: + preferred = str(self._session_state.get("probe") or "") if self._session_state else "" + self.view.set_emulators(serials, preferred=preferred) + if serials: + self.view.append_log(f"Detected {len(serials)} JLink probe(s).") + else: + self.view.append_log("No JLink probe detected.") + + @Slot(object) + def _on_status_changed(self, state: JLinkState) -> None: + self._connected = state.connected + self.view.set_connection_state(state.connected, state.message or ("Connected" if state.connected else "Disconnected")) + self.view.append_log(state.message or ("Connected" if state.connected else "Disconnected")) + if state.connected: + self._poll_timer.start() + else: + self._poll_timer.stop() + + def _toggle_connection(self, checked: bool) -> None: + if checked: + self._save_session() + processor = self.view.selected_processor() + defaults = { + "np": "Cortex-M33", + "fp": "Cortex-M23", + "mp": "Cortex-M33", + "ca32": "Cortex-A7", + } + entered_device = self._normalize_device_for_processor(processor, self.view.device_edit.text()) + device = entered_device or defaults.get(processor, "Cortex-M33") + if self.view.device_edit.text().strip() != device: + self.view.device_edit.setText(device) + self._save_session() + interface = self._normalize_interface_for_processor( + processor, self.view.interface_combo.currentText() + ) + if self.view.interface_combo.currentText().strip().upper() != interface: + idx = self.view.interface_combo.findText(interface) + if idx >= 0: + self.view.interface_combo.setCurrentIndex(idx) + self._save_session() + script_path = self._resolve_processor_script(processor) + if not script_path.exists(): + QMessageBox.warning(self.view, "Debugger", f"JLinkScript not found: {script_path}") + self.view.connect_btn.setChecked(False) + return + self.service.connect_target( + self.view.selected_emulator(), + device, + interface, + self.view.speed_spin.value(), + keep_running=True, + script_path=str(script_path), + ) + self.view.append_log(f"Using script: {script_path}") + else: + self.service.disconnect_target() + + def _send_command(self) -> None: + text = self.view.command_input.text().strip() + if not text: + return + self.view.append_log(f"> {text}") + self.service.exec_command(text) + self.view.command_input.clear() + + def _poll_registers(self) -> None: + if self._connected: + self.service.read_registers() + + def shutdown(self) -> None: + self._poll_timer.stop() + self.service.shutdown() diff --git a/ameba_control_panel/controllers/device_tab_controller.py b/ameba_control_panel/controllers/device_tab_controller.py index ee7f030..f27a15a 100644 --- a/ameba_control_panel/controllers/device_tab_controller.py +++ b/ameba_control_panel/controllers/device_tab_controller.py @@ -1,138 +1,156 @@ from __future__ import annotations -from collections import deque +import logging from pathlib import Path -from typing import Deque, List, Optional, Tuple +from typing import List, Optional -from PySide6.QtCore import QObject, QTimer, QEvent, Qt, Slot, QCoreApplication -from PySide6.QtGui import QAction, QKeySequence, QShortcut -from PySide6.QtWidgets import QFileDialog, QMessageBox +from PySide6.QtCore import QObject, QTimer, Qt, Slot +from PySide6.QtGui import QAction, QKeySequence +from PySide6.QtWidgets import QMessageBox from ameba_control_panel import config +from ameba_control_panel.config import Direction, Mode +from ameba_control_panel.managers.flash_manager import FlashManager, FLASH_IMAGE_SLOTS +from ameba_control_panel.managers.log_manager import LogManager from ameba_control_panel.services.command_player import CommandPlayer -from ameba_control_panel.services.flash_runner import FlashRunner from ameba_control_panel.services.history_service import HistoryService -from ameba_control_panel.services.log_buffer import LogBuffer, LogLine -from ameba_control_panel.services.port_service import PortInfo, scan_ports -from ameba_control_panel.services.search_service import SearchWorker +from ameba_control_panel.services.port_service import PortInfo, PortScanner from ameba_control_panel.services.serial_service import SerialService, SerialState from ameba_control_panel.services.session_store import SessionStore from ameba_control_panel.views.device_tab_view import DeviceTabView +logger = logging.getLogger(__name__) + +_SESSION_CHECKBOX_FIELDS = [ + ("boot_checked", "boot_flash_checkbox"), + ("app_checked", "app_flash_checkbox"), + ("nn_checked", "nn_flash_checkbox"), +] + +_SESSION_TEXT_FIELDS = [ + ("rdev_path", "rdev_path_edit"), + ("floader_path", "floader_path_edit"), + ("app_path", "app_path_edit"), + ("app_start_addr", "app_start_addr_edit"), + ("app_end_addr", "app_end_addr_edit"), + ("boot_path", "boot_path_edit"), + ("boot_start_addr", "boot_start_addr_edit"), + ("boot_end_addr", "boot_end_addr_edit"), + ("nn_bin_path", "nn_bin_path_edit"), + ("nn_start_addr", "nn_start_addr_edit"), + ("nn_end_addr", "nn_end_addr_edit"), + ("cmd_file", "cmdlist_path_edit"), +] + class DeviceTabController(QObject): + """Orchestrates one device tab: delegates to FlashManager and LogManager.""" + def __init__(self, profile, parent=None) -> None: super().__init__(parent) + self._alive = True self.profile = profile self.view = DeviceTabView(profile) self.serial = SerialService() self.history = HistoryService(profile.key) - self.log_buffer = LogBuffer() - self._pending: Deque[Tuple[str, str]] = deque() + + self.log = LogManager(self.view, lambda: self._alive, parent=self) + self.flash = FlashManager( + self.view, self.serial, + alive=lambda: self._alive, + enqueue=self.log.enqueue_line, + save_session=self._save_session, + ) + self._port_list: List[PortInfo] = [] - self._search_worker: Optional[SearchWorker] = None - self._matches: List[int] = [] - self._match_index = -1 + self._port_scanner: Optional[PortScanner] = None self._command_player: Optional[CommandPlayer] = None - self._flash_runner: Optional[FlashRunner] = None - self._connected_port: Optional[str] = None - self._connected_baud: Optional[int] = None self._session = SessionStore() self._session_state = self._session.get(profile.key) - self._flush_timer = QTimer(self) - self._flush_timer.setInterval(config.LOG_FLUSH_INTERVAL_MS) - self._flush_timer.timeout.connect(self._flush_pending) - self._flush_timer.start() - self._port_timer = QTimer(self) self._port_timer.setInterval(config.PORT_REFRESH_INTERVAL_MS) - self._port_timer.timeout.connect(self.refresh_ports) + self._port_timer.timeout.connect(self._start_port_scan) self._port_timer.start() self._wire_ui() self._load_history() - self.refresh_ports(initial=True) self._restore_session() + # Defer first port scan so window shows immediately + QTimer.singleShot(100, lambda: self._alive and self._start_port_scan(initial=True)) # UI wiring ---------------------------------------------------------------- def _wire_ui(self) -> None: v = self.view - v.history_list.installEventFilter(self) - v.refresh_button.clicked.connect(lambda: self.refresh_ports(force_log=True)) v.connect_button.toggled.connect(self._toggle_connection) v.send_button.clicked.connect(self._send_from_input) v.command_input.returnPressed.connect(self._send_from_input) v.history_list.itemClicked.connect(self._load_history_item) v.history_list.itemDoubleClicked.connect(self._send_history_item) - v.clear_btn.clicked.connect(self._clear_log) - v.save_btn.clicked.connect(self._save_log) - v.copy_btn.clicked.connect(v.log_view.copy_selected) - v.find_btn.clicked.connect(self._run_find) - v.find_all_btn.clicked.connect(self._run_find_all) - v.next_btn.clicked.connect(self._find_next) - v.prev_btn.clicked.connect(self._find_prev) - v.cmdlist_browse_btn.clicked.connect(self._browse_cmdlist) - v.load_cmdlist_btn.clicked.connect(self._start_cmdlist_playback) - v.app_browse_btn.clicked.connect(self._browse_app_path) - v.boot_browse_btn.clicked.connect(self._browse_boot_path) - v.flash_btn.clicked.connect(self._run_flash) - v.normal_btn.clicked.connect(lambda: self._run_mode("normal")) - v.download_btn.clicked.connect(lambda: self._run_mode("download")) - v.reset_btn.clicked.connect(lambda: self._run_mode("reset")) - # Delete via shortcut/action self._delete_action = QAction(v.history_list) self._delete_action.setShortcut(QKeySequence.Delete) self._delete_action.setShortcutContext(Qt.WidgetWithChildrenShortcut) self._delete_action.triggered.connect(self._delete_selected_history) v.history_list.addAction(self._delete_action) - v.history_list.installEventFilter(self) - v.history_list.viewport().installEventFilter(self) - self.serial.line_received.connect(self._enqueue_line) + v.clear_btn.clicked.connect(self.log.clear) + v.save_btn.clicked.connect(self.log.save) + v.find_all_btn.clicked.connect(self.log.run_find) + v.find_input.returnPressed.connect(self.log.run_find) + v.next_btn.clicked.connect(self.log.find_next) + v.prev_btn.clicked.connect(self.log.find_prev) + v.cmdlist_browse_btn.clicked.connect(self._browse_cmdlist) + v.load_cmdlist_btn.clicked.connect(self._start_cmdlist_playback) + v.rdev_browse_btn.clicked.connect(lambda: self.flash.browse_file("Select device profile (.rdev)", v.rdev_path_edit, "Device profiles (*.rdev);;All files (*)")) + v.floader_browse_btn.clicked.connect(lambda: self.flash.browse_file("Select floader binary", v.floader_path_edit, "Binary files (*.bin);;All files (*)")) + v.app_browse_btn.clicked.connect(lambda: self.flash.browse_file("Select application image", v.app_path_edit)) + v.boot_browse_btn.clicked.connect(lambda: self.flash.browse_file("Select bootloader image", v.boot_path_edit)) + v.nn_bin_browse_btn.clicked.connect(lambda: self.flash.browse_file("Select NN model binary", v.nn_bin_path_edit)) + v.flash_btn.clicked.connect(self.flash.run_flash) + v.normal_btn.clicked.connect(lambda: self.flash.run_mode(Mode.NORMAL)) + v.download_btn.clicked.connect(lambda: self.flash.run_mode(Mode.DOWNLOAD)) + v.reset_btn.clicked.connect(lambda: self.flash.run_mode(Mode.RESET)) + + self.serial.line_received.connect(self.log.enqueue_line) self.serial.status_changed.connect(self._on_serial_status) v.log_view.set_colors(self.profile.rx_color, self.profile.tx_color, self.profile.info_color) + # Session persistence signals v.dut_port_combo.currentIndexChanged.connect(self._save_session) v.control_port_combo.currentIndexChanged.connect(self._save_session) v.dut_baud_combo.editTextChanged.connect(self._save_session) v.control_baud_combo.editTextChanged.connect(self._save_session) - v.app_path_edit.editingFinished.connect(self._save_session) - v.boot_path_edit.editingFinished.connect(self._save_session) + v.rdev_path_edit.editingFinished.connect(self._save_session) + v.floader_path_edit.editingFinished.connect(self._save_session) + for _, cb_attr, path_attr, start_attr, end_attr in FLASH_IMAGE_SLOTS: + getattr(v, cb_attr).toggled.connect(self._save_session) + getattr(v, path_attr).editingFinished.connect(self._save_session) + getattr(v, start_attr).editingFinished.connect(self._save_session) + getattr(v, end_attr).editingFinished.connect(self._save_session) v.cmdlist_path_edit.editingFinished.connect(self._save_session) - # Keyboard shortcuts - - def eventFilter(self, obj, event): # noqa: N802 - if event.type() == QEvent.KeyPress and obj in (self.view.history_list, self.view.history_list.viewport()): - if event.key() == Qt.Key_Delete: - self._delete_selected_history() - return True - return super().eventFilter(obj, event) # History ------------------------------------------------------------------ def _load_history(self) -> None: - entries = self.history.load() - self.view.populate_history(entries) + self.view.populate_history(self.history.load()) def _restore_session(self) -> None: if not self._session_state: return + for key, attr in _SESSION_CHECKBOX_FIELDS: + if key in self._session_state: + getattr(self.view, attr).setChecked(bool(self._session_state[key])) dut_baud = self._session_state.get("dut_baud") ctrl_baud = self._session_state.get("ctrl_baud") if dut_baud: self.view.dut_baud_combo.setCurrentText(str(dut_baud)) if ctrl_baud: self.view.control_baud_combo.setCurrentText(str(ctrl_baud)) - if app := self._session_state.get("app_path"): - self.view.app_path_edit.setText(app) - if boot := self._session_state.get("boot_path"): - self.view.boot_path_edit.setText(boot) - if cmd := self._session_state.get("cmd_file"): - self.view.cmdlist_path_edit.setText(cmd) + for key, attr in _SESSION_TEXT_FIELDS: + if key in self._session_state: + getattr(self.view, attr).setText(str(self._session_state.get(key) or "")) def _load_history_item(self, item) -> None: self.view.command_input.setText(item.text()) - self.view.command_input.setFocus() def _send_history_item(self, item) -> None: self._send_command(item.text(), add_to_history=False) @@ -144,69 +162,64 @@ class DeviceTabController(QObject): rows = [self.view.history_list.row(it) for it in items] self.history.delete_indices(rows) self._load_history() - self._save_session() def _save_session(self) -> None: - dut_baud = int(self.view.dut_baud_combo.currentText() or config.DEFAULT_BAUD) - ctrl_baud = int(self.view.control_baud_combo.currentText() or config.DEFAULT_BAUD) + if not self._alive: + return + dut_baud = config.parse_baud(self.view.dut_baud_combo.currentText()) + ctrl_baud = config.parse_baud(self.view.control_baud_combo.currentText()) payload = { "dut_port": self.view.dut_port_combo.currentData(), "ctrl_port": self.view.control_port_combo.currentData(), "dut_baud": dut_baud, "ctrl_baud": ctrl_baud, - "app_path": self.view.app_path_edit.text(), - "boot_path": self.view.boot_path_edit.text(), - "cmd_file": self.view.cmdlist_path_edit.text(), } + for key, attr in _SESSION_CHECKBOX_FIELDS: + payload[key] = getattr(self.view, attr).isChecked() + for key, attr in _SESSION_TEXT_FIELDS: + payload[key] = getattr(self.view, attr).text() self._session.set(self.profile.key, payload) - # Ports -------------------------------------------------------------------- - def refresh_ports(self, initial: bool = False, force_log: bool = False) -> None: - new_ports = scan_ports() - new_map = {p.device: p.description for p in new_ports} - old_map = {p.device: p.description for p in self._port_list} + # Ports (background scan) -------------------------------------------------- + def _start_port_scan(self, initial: bool = False) -> None: + if self._port_scanner and self._port_scanner.isRunning(): + return + self._port_scan_initial = initial + self._port_scanner = PortScanner() + self._port_scanner.result.connect(self._on_port_scan_done) + self._port_scanner.start() + @Slot(list) + def _on_port_scan_done(self, new_ports: List[PortInfo]) -> None: + if not self._alive: + return + initial = getattr(self, "_port_scan_initial", False) + self._port_scanner = None + + new_map = {p.device: p.description for p in new_ports} removed = [p for p in self._port_list if p.device not in new_map] - added = [p for p in new_ports if p.device not in old_map] + added = [p for p in new_ports if p.device not in {q.device for q in self._port_list}] added.sort(key=lambda p: p.device) - if not added and not removed and not force_log and not initial: + if not added and not removed and not initial: return + for p in removed: + self.log.enqueue_line(f"Port removed: {p.device}", Direction.INFO) - if removed: - for p in removed: - self._enqueue_line(f"Port removed: {p.device}", "info") - elif force_log: - self._enqueue_line("Port list refreshed", "info") - - merged: List[PortInfo] = list(self._port_list) - for p in added: - merged.append(p) - # prune removed from merged preserving order - merged = [p for p in merged if p.device in new_map] + merged = [p for p in self._port_list if p.device in new_map] + added self._port_list = merged - current_dut = self.view.dut_port_combo.currentText() - current_ctrl = self.view.control_port_combo.currentText() + preferred_dut = self._session_state.get("dut_port") if initial else self.view.dut_port_combo.currentData() + preferred_ctrl = self._session_state.get("ctrl_port") if initial else self.view.control_port_combo.currentData() - # Prefer session selections during first load - preferred_dut = self._session_state.get("dut_port") if initial else current_dut - preferred_ctrl = self._session_state.get("ctrl_port") if initial else current_ctrl - - def _update_combo(combo, selected): + for combo, selected in [(self.view.dut_port_combo, preferred_dut), (self.view.control_port_combo, preferred_ctrl)]: combo.blockSignals(True) combo.clear() for p in self._port_list: combo.addItem(f"{p.device} ({p.description})", p.device) - index = combo.findData(selected) - if index >= 0: - combo.setCurrentIndex(index) - elif combo.count() > 0: - combo.setCurrentIndex(0) + idx = combo.findData(selected) + combo.setCurrentIndex(idx if idx >= 0 else 0) combo.blockSignals(False) - - _update_combo(self.view.dut_port_combo, preferred_dut) - _update_combo(self.view.control_port_combo, preferred_ctrl) if initial: self._save_session() @@ -216,162 +229,51 @@ class DeviceTabController(QObject): self._connect_serial() else: self.serial.close() - self.view.connect_button.setText("Connect") - self._enqueue_line("Disconnected", "info") def _connect_serial(self) -> None: port = self.view.dut_port_combo.currentData() - baud = int(self.view.dut_baud_combo.currentText() or config.DEFAULT_BAUD) + baud = config.parse_baud(self.view.dut_baud_combo.currentText()) if not port: QMessageBox.warning(self.view, "Connect", "Please choose a DUT COM port.") self.view.connect_button.setChecked(False) return self.serial.open(port, baud) - self._connected_port = port - self._connected_baud = baud + self.flash.set_connected(port, baud) @Slot(object) def _on_serial_status(self, state: SerialState) -> None: + if not self._alive: + return + self.view.connect_button.blockSignals(True) if state.connected: - self.view.connect_button.setText("Disconnect") - self._enqueue_line(f"Connected to {state.port} @ {state.baudrate}", "info") - self._connected_port = state.port - self._connected_baud = state.baudrate + self.view.connect_button.setChecked(True) + self.flash.set_connected(state.port, state.baudrate) self._save_session() else: if state.error: - self._enqueue_line(f"Serial error: {state.error}", "info") + self.log.enqueue_line(f"Serial error: {state.error}", Direction.INFO) self.view.connect_button.setChecked(False) - self.view.connect_button.setText("Connect") if self._command_player and self._command_player.isRunning(): self._command_player.stop() + self.view.connect_button.blockSignals(False) # Sending ------------------------------------------------------------------ def _send_from_input(self) -> None: - text = self.view.command_input.text() - self._send_command(text, add_to_history=True) + self._send_command(self.view.command_input.text(), add_to_history=True) def _send_command(self, text: str, add_to_history: bool) -> None: - if not text.strip(): - return if not self.serial.is_connected(): - self._enqueue_line("Cannot send: not connected", "info") + self.log.enqueue_line("Cannot send: not connected", Direction.INFO) return self.serial.write(text) - if add_to_history: + if add_to_history and text.strip(): self.history.add(text) self._load_history() self.view.command_input.clear() - # Log handling ------------------------------------------------------------- - @Slot(str, str) - def _enqueue_line(self, text: str, direction: str) -> None: - # Drop control chars and whitespace-only lines - cleaned = "".join(ch for ch in text if ch.isprintable()) - if not cleaned.strip(): - return - # Remove embedded timestamp/com port wrappers from flash helper output - if direction == "info" and cleaned.startswith("[") and "]" in cleaned: - trimmed = cleaned - for _ in range(2): - if trimmed.startswith("[") and "]" in trimmed: - trimmed = trimmed.split("]", 1)[1].lstrip() - while trimmed.startswith("[") and "]" in trimmed: - prefix = trimmed.split("]", 1)[0] - if prefix.startswith("[COM") or prefix.startswith("[main"): - trimmed = trimmed.split("]", 1)[1].lstrip() - continue - break - cleaned = trimmed - if direction == "info" and "Flash helper completed with code 0" in cleaned: - return - self._pending.append((cleaned, direction)) - - def _flush_pending(self) -> None: - if not self._pending: - return - to_flush: List[LogLine] = [] - while self._pending: - text, direction = self._pending.popleft() - if not text.strip(): - continue - line = self.log_buffer.append(text, direction) - to_flush.append(line) - # Only show RX/INFO in UI - visible = [l for l in to_flush if l.direction != "tx"] - self.view.log_view.append_lines(visible) - - def _clear_log(self) -> None: - self.log_buffer.clear() - self.view.log_view.clear_log() - self._matches.clear() - self._match_index = -1 - self.view.log_view.set_matches([]) - - def _save_log(self) -> None: - path, _ = QFileDialog.getSaveFileName(self.view, "Save Log", str(Path.home() / "ameba_log.txt")) - if not path: - return - Path(path).write_text(self.log_buffer.as_text(full=True), encoding="utf-8") - self._enqueue_line(f"Saved log to {path}", "info") - - # Search ------------------------------------------------------------------- - def _run_find(self) -> None: - self._run_find_all() - if self._matches: - self._match_index = 0 - self._scroll_to_match() - - def _run_find_all(self) -> None: - if self._search_worker and self._search_worker.isRunning(): - return - needle = self.view.find_input.text() - if not needle: - self.view.log_view.set_matches([]) - self._matches = [] - self._match_index = -1 - return - lines = [l.as_display() for l in self.log_buffer.tail() if l.direction != "tx"] - self._search_worker = SearchWorker(lines, needle, self.view.case_checkbox.isChecked()) - self._search_worker.finished.connect(self._on_search_finished) - self._search_worker.start() - - @Slot(list) - def _on_search_finished(self, rows: List[int]) -> None: - self._matches = rows - self.view.log_view.set_matches(rows) - self._match_index = 0 if rows else -1 - self._scroll_to_match() - - def _find_next(self) -> None: - if not self._matches: - self._run_find_all() - return - self._match_index = (self._match_index + 1) % len(self._matches) - self._scroll_to_match() - - def _find_prev(self) -> None: - if not self._matches: - self._run_find_all() - return - self._match_index = (self._match_index - 1) % len(self._matches) - self._scroll_to_match() - - def _scroll_to_match(self) -> None: - if self._match_index < 0 or not self._matches: - return - row = self._matches[self._match_index] - doc = self.view.log_view.document() - block = doc.findBlockByNumber(row) - if not block.isValid(): - return - cursor = self.view.log_view.textCursor() - cursor.setPosition(block.position()) - self.view.log_view.setTextCursor(cursor) - self.view.log_view.centerCursor() - # Command list playback ---------------------------------------------------- def _browse_cmdlist(self) -> None: + from PySide6.QtWidgets import QFileDialog path, _ = QFileDialog.getOpenFileName(self.view, "Command list", "", "Text files (*.txt);;All files (*)") if path: self.view.cmdlist_path_edit.setText(path) @@ -386,129 +288,46 @@ class DeviceTabController(QObject): QMessageBox.warning(self.view, "CmdList", "Please choose a valid command list file.") return if not self.serial.is_connected(): - self._enqueue_line("Cannot play command list: not connected", "info") + self.log.enqueue_line("Cannot play command list: not connected", Direction.INFO) return - self._command_player = CommandPlayer( - filepath, - self.view.per_cmd_delay.value(), - self.view.per_char_delay.value(), - ) + from ameba_control_panel.services.settings_service import Settings + settings = Settings() + self._command_player = CommandPlayer(filepath, settings.cmd_delay_ms, settings.char_delay_ms) self._command_player.send_raw.connect(self._send_raw_from_player) - self._command_player.finished_file.connect(self._on_cmdlist_finished) - self._command_player.error.connect(lambda msg: self._enqueue_line(f"CmdList error: {msg}", "info")) + self._command_player.finished_file.connect(lambda: self._alive and self.log.enqueue_line("Command list finished", Direction.INFO)) + self._command_player.error.connect(lambda msg: self._alive and self.log.enqueue_line(f"CmdList error: {msg}", Direction.INFO)) self._command_player.command_started.connect(self._on_cmd_started) self._command_player.start() - self._enqueue_line(f"Playing command list: {filepath.name}", "info") + self.log.enqueue_line(f"Playing command list: {filepath.name}", Direction.INFO) @Slot(str) def _on_cmd_started(self, cmd: str) -> None: + if not self._alive: + return self.history.add(cmd) self._load_history() @Slot(bytes) def _send_raw_from_player(self, payload: bytes) -> None: + if not self._alive: + return if not self.serial.is_connected(): - self._enqueue_line("Command list stopped: disconnected", "info") + self.log.enqueue_line("Command list stopped: disconnected", Direction.INFO) if self._command_player: self._command_player.stop() return self.serial.write_raw(payload) - def _on_cmdlist_finished(self) -> None: - self._enqueue_line("Command list finished", "info") - - # Flash / modes ------------------------------------------------------------ - def _browse_app_path(self) -> None: - path, _ = QFileDialog.getOpenFileName(self.view, "Select application image", "", "Binary files (*);;All files (*)") - if path: - self.view.app_path_edit.setText(path) - self._save_session() - - def _browse_boot_path(self) -> None: - path, _ = QFileDialog.getOpenFileName(self.view, "Select bootloader image", "", "Binary files (*);;All files (*)") - if path: - self.view.boot_path_edit.setText(path) - self._save_session() - - def _run_flash(self) -> None: - app = Path(self.view.app_path_edit.text()) - boot = Path(self.view.boot_path_edit.text()) - dut = self.view.dut_port_combo.currentData() - ctrl = self.view.control_port_combo.currentData() - baud = int(self.view.dut_baud_combo.currentText() or config.DEFAULT_BAUD) - if not (app.exists() and boot.exists() and dut and ctrl): - QMessageBox.warning(self.view, "Flash", "Please provide app, boot paths and both COM ports.") - return - args = ["--boot", str(boot), "--app", str(app), "-t", dut, "-p", ctrl, "-B", str(baud)] - self._invoke_flash(args, close_uart=True, auto_normal=True) - - def _run_mode(self, mode: str) -> None: - dut = self.view.dut_port_combo.currentData() - ctrl = self.view.control_port_combo.currentData() - baud = int(self.view.dut_baud_combo.currentText() or config.DEFAULT_BAUD) - if not (dut and ctrl): - QMessageBox.warning(self.view, "Mode", "Select DUT and Control COM ports.") - return - if mode == "download": - args = ["--download-mode", "1", "-t", dut, "-p", ctrl, "-B", str(baud)] - elif mode == "normal": - args = ["--download-mode", "0", "-t", dut, "-p", ctrl, "-B", str(baud)] - elif mode == "reset": - args = ["--reset", "-t", dut, "-p", ctrl, "-B", str(baud)] - else: - return - self._invoke_flash(args, close_uart=False, auto_normal=False) - - def _invoke_flash(self, args: List[str], close_uart: bool, auto_normal: bool) -> None: - if self._flash_runner and self._flash_runner.isRunning(): - QMessageBox.information(self.view, "Flash", "Flash helper already running.") - return - was_connected = self.serial.is_connected() - if close_uart and was_connected: - self.serial.close() - flash_script = self._resolve_flash_script() - if not flash_script.exists(): - QMessageBox.critical(self.view, "Flash", f"flash_amebapro3.py not found at {flash_script}") - return - self._flash_runner = FlashRunner(args, flash_script) - self._flash_runner.output.connect(lambda line: self._enqueue_line(line, "info")) - self._flash_runner.finished.connect( - lambda code: self._on_flash_finished(code, close_uart, auto_normal, was_connected) - ) - self._flash_runner.start() - self._enqueue_line(f"Running flash helper with args: {' '.join(args)}", "info") - - def _resolve_flash_script(self) -> Path: - candidates = [ - Path(QCoreApplication.applicationDirPath()) / "Flash" / "flash_amebapro3.py", - Path(QCoreApplication.applicationDirPath()) / "flash_amebapro3.py", - Path(__file__).resolve().parents[2] / "Flash" / "flash_amebapro3.py", - Path(__file__).resolve().parent / "../../Flash/flash_amebapro3.py", - ] - for path in candidates: - if path.exists(): - return path.resolve() - return candidates[0].resolve() - - def _on_flash_finished(self, code: int, close_uart: bool, auto_normal: bool, was_connected: bool) -> None: - self._enqueue_line(f"Flash helper completed with code {code}", "info") - if self._flash_runner: - self._flash_runner.wait(100) - self._flash_runner = None - if close_uart and was_connected and self._connected_port and self._connected_baud: - QTimer.singleShot( - 200, - lambda: self.serial.open(self._connected_port or "", self._connected_baud or config.DEFAULT_BAUD), - ) - if auto_normal: - QTimer.singleShot(500, lambda: self._run_mode("normal")) - # Cleanup ------------------------------------------------------------------ def shutdown(self) -> None: + self._alive = False + self._port_timer.stop() + self.log.shutdown() + self.flash.shutdown() if self._command_player and self._command_player.isRunning(): self._command_player.stop() self._command_player.wait(3000) - if self._flash_runner and self._flash_runner.isRunning(): - self._flash_runner.requestInterruption() - self._flash_runner.wait(5000) + if self._port_scanner and self._port_scanner.isRunning(): + self._port_scanner.wait(2000) self.serial.close() + self._session.save_now() diff --git a/ameba_control_panel/managers/__init__.py b/ameba_control_panel/managers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ameba_control_panel/managers/flash_manager.py b/ameba_control_panel/managers/flash_manager.py new file mode 100644 index 0000000..5d8ad69 --- /dev/null +++ b/ameba_control_panel/managers/flash_manager.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Callable, List, Optional, Tuple, TYPE_CHECKING + +from PySide6.QtCore import QCoreApplication, QTimer +from PySide6.QtWidgets import QFileDialog, QLineEdit, QMessageBox + +from ameba_control_panel import config +from ameba_control_panel.config import Direction, Mode +from ameba_control_panel.services.flash_runner import FlashRunner + +if TYPE_CHECKING: + from ameba_control_panel.services.serial_service import SerialService + from ameba_control_panel.views.device_tab_view import DeviceTabView + +logger = logging.getLogger(__name__) + +FLASH_IMAGE_SLOTS = [ + ("Bootloader", "boot_flash_checkbox", "boot_path_edit", "boot_start_addr_edit", "boot_end_addr_edit"), + ("Application", "app_flash_checkbox", "app_path_edit", "app_start_addr_edit", "app_end_addr_edit"), + ("NN Model", "nn_flash_checkbox", "nn_bin_path_edit", "nn_start_addr_edit", "nn_end_addr_edit"), +] + + +class FlashManager: + """Handles flash, mode switching, and DTR/RTS GPIO control.""" + + def __init__( + self, + view: DeviceTabView, + serial: SerialService, + alive: Callable[[], bool], + enqueue: Callable[[str, Direction], None], + save_session: Callable[[], None], + ) -> None: + self.view = view + self.serial = serial + self._alive = alive + self._enqueue = enqueue + self._save_session = save_session + self._flash_runner: Optional[FlashRunner] = None + self._connected_port: Optional[str] = None + self._connected_baud: Optional[int] = None + self._dtr_busy = False + + @property + def is_flashing(self) -> bool: + return bool(self._flash_runner and self._flash_runner.isRunning()) + + def set_connected(self, port: str, baud: int) -> None: + self._connected_port = port + self._connected_baud = baud + + def browse_file(self, title: str, target: QLineEdit, file_filter: str = "Binary files (*);;All files (*)") -> None: + path, _ = QFileDialog.getOpenFileName(self.view, title, "", file_filter) + if path: + target.setText(path) + self._save_session() + + @staticmethod + def normalized_addr(value: str) -> Optional[str]: + text = value.strip() + if not text: + return None + try: + parsed = int(text, 0) + except ValueError: + return None + return hex(parsed) if parsed >= 0 else None + + def build_flash_image(self, label: str, filepath: str, start_addr: str, end_addr: str) -> Optional[Tuple[str, str, str]]: + binary_text = filepath.strip() + if not binary_text: + QMessageBox.warning(self.view, "Flash", f"{label}: file path is empty.") + return None + binary = Path(binary_text) + if not binary.exists(): + QMessageBox.warning(self.view, "Flash", f"{label}: file not found.") + return None + start = self.normalized_addr(start_addr) + end = self.normalized_addr(end_addr) + if not (start and end): + QMessageBox.warning(self.view, "Flash", f"{label}: provide valid start/end addresses.") + return None + if int(start, 16) >= int(end, 16): + QMessageBox.warning(self.view, "Flash", f"{label}: start must be less than end.") + return None + return str(binary), start, end + + def use_dtr_rts(self) -> bool: + return not self.view.control_port_combo.currentData() + + def run_flash(self) -> None: + dut = self.view.dut_port_combo.currentData() + ctrl = self.view.control_port_combo.currentData() + baud = config.parse_baud(self.view.dut_baud_combo.currentText()) + if not dut: + QMessageBox.warning(self.view, "Flash", "Please select a DUT COM port.") + return + images: List[Tuple[str, str, str]] = [] + for label, cb_attr, path_attr, start_attr, end_attr in FLASH_IMAGE_SLOTS: + if not getattr(self.view, cb_attr).isChecked(): + continue + image = self.build_flash_image( + label, getattr(self.view, path_attr).text(), + getattr(self.view, start_attr).text(), getattr(self.view, end_attr).text(), + ) + if image is None: + return + images.append(image) + # Dynamic custom binaries from Advanced section + for entry in self.view.get_custom_bins(): + if not entry["checkbox"].isChecked(): + continue + image = self.build_flash_image( + "Custom", entry["path_edit"].text(), + entry["start_edit"].text(), entry["end_edit"].text(), + ) + if image is None: + return + images.append(image) + if not images: + QMessageBox.warning(self.view, "Flash", "Select at least one image checkbox to flash.") + return + args: List[str] = [] + for path, start, end in images: + args.extend(["--multi-bin", path, start, end]) + args.extend(["-t", dut, "-B", str(baud)]) + rdev = self.view.rdev_path_edit.text().strip() + if rdev: + args.extend(["--profile", rdev]) + if ctrl: + args.extend(["-p", ctrl]) + else: + args.append("--dtr-rts") + self._save_session() + self._invoke_flash(args, close_uart=True, auto_normal=True) + + def run_mode(self, mode: Mode) -> None: + if not self._alive(): + return + dut = self.view.dut_port_combo.currentData() + ctrl = self.view.control_port_combo.currentData() + baud = config.parse_baud(self.view.dut_baud_combo.currentText()) + if not dut: + QMessageBox.warning(self.view, "Mode", "Select a DUT COM port.") + return + if self.use_dtr_rts() and self.serial.is_connected(): + self._run_mode_dtr_rts(mode) + return + if not ctrl: + QMessageBox.warning(self.view, "Mode", "No Control COM and DUT not connected for DTR/RTS.") + return + mode_args = { + Mode.DOWNLOAD: ["--download-mode", "1"], + Mode.NORMAL: ["--download-mode", "0"], + Mode.RESET: ["--reset"], + } + args = mode_args[mode] + ["-t", dut, "-p", ctrl, "-B", str(baud)] + self._invoke_flash(args, close_uart=False, auto_normal=False) + + def _run_mode_dtr_rts(self, mode: Mode) -> None: + if self._dtr_busy: + return + self._dtr_busy = True + if mode == Mode.DOWNLOAD: + self.serial.set_dtr(True) + self.serial.set_rts(True) + QTimer.singleShot(100, self._dtr_step2_download) + elif mode == Mode.NORMAL: + self.serial.set_dtr(False) + self.serial.set_rts(True) + QTimer.singleShot(200, self._dtr_pulse_reset) + elif mode == Mode.RESET: + self.serial.set_rts(True) + QTimer.singleShot(50, self._dtr_pulse_reset) + + def _dtr_step2_download(self) -> None: + if not self._alive(): + return + self.serial.set_rts(False) + QTimer.singleShot(100, self._dtr_finish) + self._enqueue("Download mode (DTR/RTS)", Direction.INFO) + + def _dtr_pulse_reset(self) -> None: + if not self._alive(): + return + self.serial.set_rts(False) + QTimer.singleShot(100, self._dtr_finish) + self._enqueue("Reset (DTR/RTS)", Direction.INFO) + + def _dtr_finish(self) -> None: + if self._alive(): + self.serial.set_rts(True) + self._dtr_busy = False + + def _invoke_flash(self, args: List[str], close_uart: bool, auto_normal: bool) -> None: + if self.is_flashing: + QMessageBox.information(self.view, "Flash", "Flash helper already running.") + return + was_connected = self.serial.is_connected() + if close_uart and was_connected: + self.serial.close() + flash_script = self._resolve_flash_script() + if not flash_script.exists(): + QMessageBox.critical(self.view, "Flash", f"flash_amebapro3.py not found at {flash_script}") + return + self._flash_runner = FlashRunner(args, flash_script) + self._flash_runner.output.connect(lambda line: self._enqueue(line, Direction.INFO)) + self._flash_runner.finished.connect( + lambda code: self._on_flash_finished(code, close_uart, auto_normal, was_connected) + ) + self._flash_runner.start() + logger.info("Flash started: %s", " ".join(args)) + self._enqueue(f"Running flash helper with args: {' '.join(args)}", Direction.INFO) + + @staticmethod + def _resolve_flash_script() -> Path: + candidates = [ + Path(QCoreApplication.applicationDirPath()) / "Flash" / "flash_amebapro3.py", + Path(QCoreApplication.applicationDirPath()) / "flash_amebapro3.py", + Path(__file__).resolve().parents[2] / "Flash" / "flash_amebapro3.py", + ] + for path in candidates: + if path.exists(): + return path.resolve() + return candidates[0].resolve() + + def _on_flash_finished(self, code: int, close_uart: bool, auto_normal: bool, was_connected: bool) -> None: + if not self._alive(): + return + logger.info("Flash completed with code %d", code) + self._enqueue(f"Flash helper completed with code {code}", Direction.INFO) + if self._flash_runner: + self._flash_runner.wait(100) + self._flash_runner = None + if close_uart and was_connected and self._connected_port and self._connected_baud: + QTimer.singleShot(200, self._reconnect_after_flash) + if auto_normal: + QTimer.singleShot(500, lambda: self._alive() and self.run_mode(Mode.NORMAL)) + + def _reconnect_after_flash(self) -> None: + if self._alive() and self._connected_port and self._connected_baud: + self.serial.open(self._connected_port, self._connected_baud) + + def shutdown(self) -> None: + if self._flash_runner and self._flash_runner.isRunning(): + self._flash_runner.requestInterruption() + self._flash_runner.wait(5000) diff --git a/ameba_control_panel/managers/log_manager.py b/ameba_control_panel/managers/log_manager.py new file mode 100644 index 0000000..beadbf8 --- /dev/null +++ b/ameba_control_panel/managers/log_manager.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from collections import deque +from pathlib import Path +from typing import Callable, Deque, List, Optional, Tuple, TYPE_CHECKING + +import logging + +from PySide6.QtCore import QObject, QTimer, Slot +from PySide6.QtWidgets import QFileDialog + +logger = logging.getLogger(__name__) + +from ameba_control_panel import config +from ameba_control_panel.config import Direction +from ameba_control_panel.services.log_buffer import LogBuffer, LogLine +from ameba_control_panel.services.search_service import SearchWorker + +if TYPE_CHECKING: + from ameba_control_panel.views.device_tab_view import DeviceTabView + +_MAX_PREFIX_STRIP_ITERATIONS = 10 +_SUPPRESSED_LOG_MSG = "Flash helper completed with code 0" + + +class LogManager(QObject): + """Handles log buffering, flushing to view, searching, and save.""" + + def __init__(self, view: DeviceTabView, alive: Callable[[], bool], parent: QObject | None = None) -> None: + super().__init__(parent) + self.view = view + self._alive = alive + self.buffer = LogBuffer() + self._pending: Deque[Tuple[str, Direction]] = deque() + self._search_worker: Optional[SearchWorker] = None + self._matches: List[int] = [] + self._match_index = -1 + + self._flush_timer = QTimer(self) + self._flush_timer.setInterval(config.LOG_FLUSH_INTERVAL_MS) + self._flush_timer.timeout.connect(self._flush_pending) + self._flush_timer.start() + + def enqueue_line(self, text: str, direction: Direction | str) -> None: + if not self._alive(): + return + cleaned = "".join(ch for ch in text if ch.isprintable()) + if not cleaned.strip(): + return + if direction == Direction.INFO and cleaned.startswith("[") and "]" in cleaned: + cleaned = self._strip_info_prefixes(cleaned) + if direction == Direction.INFO and _SUPPRESSED_LOG_MSG in cleaned: + return + self._pending.append((cleaned, Direction(direction))) + + @staticmethod + def _strip_info_prefixes(text: str) -> str: + for _ in range(2): + if text.startswith("[") and "]" in text: + text = text.split("]", 1)[1].lstrip() + for _ in range(_MAX_PREFIX_STRIP_ITERATIONS): + if not (text.startswith("[") and "]" in text): + break + prefix = text.split("]", 1)[0] + if prefix.startswith("[COM") or prefix.startswith("[main"): + text = text.split("]", 1)[1].lstrip() + else: + break + return text + + def _flush_pending(self) -> None: + if not self._alive() or not self._pending: + # Reset to normal interval when idle + if self._flush_timer.interval() != config.LOG_FLUSH_INTERVAL_MS: + self._flush_timer.setInterval(config.LOG_FLUSH_INTERVAL_MS) + return + to_flush: List[LogLine] = [] + count = 0 + while self._pending and count < config.LOG_FLUSH_BATCH_LIMIT: + text, direction = self._pending.popleft() + if not text.strip(): + continue + line = self.buffer.append(text, direction) + to_flush.append(line) + count += 1 + visible = [line for line in to_flush if line.direction != Direction.TX] + if visible: + self.view.log_view.append_lines(visible) + # Adaptive: slow down flush when queue is heavy to reduce UI stalls + pending_count = len(self._pending) + if pending_count > 500: + self._flush_timer.setInterval(200) + elif pending_count > 100: + self._flush_timer.setInterval(100) + else: + self._flush_timer.setInterval(config.LOG_FLUSH_INTERVAL_MS) + + def clear(self) -> None: + self.buffer.clear() + self.view.log_view.clear_log() + self._matches.clear() + self._match_index = -1 + self.view.log_view.set_matches([], -1) + + def save(self) -> None: + path, _ = QFileDialog.getSaveFileName(self.view, "Save Log", str(Path.home() / "ameba_log.txt")) + if not path: + return + try: + Path(path).write_text(self.buffer.as_text(full=True), encoding="utf-8") + self.enqueue_line(f"Saved log to {path}", Direction.INFO) + except OSError as exc: + logger.error("Failed to save log: %s", exc) + from PySide6.QtWidgets import QMessageBox + QMessageBox.warning(self.view, "Save Log", f"Failed to save: {exc}") + + # Search ------------------------------------------------------------------- + def run_find(self) -> None: + if self._search_worker and self._search_worker.isRunning(): + return + needle = self.view.find_input.text() + self.view.log_view.set_needle(needle, self.view.case_checkbox.isChecked()) + if not needle: + self.view.log_view.set_matches([], -1) + self._matches = [] + self._match_index = -1 + return + lines = [l.as_display() for l in self.view.log_view.displayed_lines()] + self._search_worker = SearchWorker(lines, needle, self.view.case_checkbox.isChecked()) + self._search_worker.finished.connect(self._on_search_finished) + self._search_worker.start() + + @Slot(list) + def _on_search_finished(self, rows: List[int]) -> None: + if not self._alive(): + return + self._matches = rows + self._match_index = 0 if rows else -1 + self.view.log_view.set_matches(rows, self._match_index) + self._scroll_to_match() + self._search_worker = None + + def find_next(self) -> None: + self._advance_match(1) + + def find_prev(self) -> None: + self._advance_match(-1) + + def _advance_match(self, direction: int) -> None: + if not self._matches: + self.run_find() + return + n = len(self._matches) + self._match_index = (self._match_index + direction) % n if n else 0 + self.view.log_view.set_matches(self._matches, self._match_index) + self._scroll_to_match() + + def _scroll_to_match(self) -> None: + if self._match_index < 0 or not self._matches: + return + row = self._matches[self._match_index] + doc = self.view.log_view.document() + block = doc.findBlockByNumber(row) + if not block.isValid(): + return + cursor = self.view.log_view.textCursor() + cursor.setPosition(block.position()) + self.view.log_view.setTextCursor(cursor) + self.view.log_view.centerCursor() + + def shutdown(self) -> None: + self._flush_timer.stop() + if self._search_worker and self._search_worker.isRunning(): + self._search_worker.wait(1000) diff --git a/ameba_control_panel/services/command_player.py b/ameba_control_panel/services/command_player.py index b589c60..b747627 100644 --- a/ameba_control_panel/services/command_player.py +++ b/ameba_control_panel/services/command_player.py @@ -1,5 +1,6 @@ from __future__ import annotations +import threading import time from pathlib import Path from typing import Optional @@ -24,7 +25,8 @@ class CommandPlayer(QThread): self._filepath = filepath self._per_cmd_delay = max(0, per_cmd_delay_ms) / 1000.0 self._per_char_delay = max(0, per_char_delay_ms) / 1000.0 - self._running = True + self._running = threading.Event() + self._running.set() def run(self) -> None: try: @@ -35,7 +37,7 @@ class CommandPlayer(QThread): try: for raw in lines: - if not self._running: + if not self._running.is_set(): break stripped = raw.strip("\r\n") if not stripped: @@ -43,7 +45,7 @@ class CommandPlayer(QThread): self.command_started.emit(stripped) if self._per_char_delay > 0: for ch in stripped: - if not self._running: + if not self._running.is_set(): break self.send_raw.emit(ch.encode("utf-8", errors="ignore")) time.sleep(self._per_char_delay) @@ -57,4 +59,4 @@ class CommandPlayer(QThread): @Slot() def stop(self) -> None: - self._running = False + self._running.clear() diff --git a/ameba_control_panel/services/jlink_debug_service.py b/ameba_control_panel/services/jlink_debug_service.py new file mode 100644 index 0000000..bd5a4e1 --- /dev/null +++ b/ameba_control_panel/services/jlink_debug_service.py @@ -0,0 +1,765 @@ +from __future__ import annotations + +import inspect +import queue +import re +import threading +from pathlib import Path +from dataclasses import dataclass +from typing import Any, List, Optional, Tuple + +from PySide6.QtCore import QObject, QThread, Signal, Slot + +try: + import pylink # type: ignore +except Exception: + pylink = None + + +@dataclass +class JLinkState: + connected: bool + message: str = "" + + +class _JLinkWorker(QThread): + status_changed = Signal(object) # JLinkState + log_line = Signal(str) + emulators_updated = Signal(list) # list[str] + registers_updated = Signal(list) # list[tuple[str, int]] + command_result = Signal(str) + + def __init__(self, parent: Optional[QObject] = None) -> None: + super().__init__(parent) + self._running = threading.Event() + self._ops: "queue.Queue[tuple[str, tuple[Any, ...]]]" = queue.Queue() + self._jlink: Any = None + self._read_request_queued = False + + def run(self) -> None: + self._running.set() + if pylink is None: + self.status_changed.emit(JLinkState(False, "pylink-square not installed; debugger unavailable.")) + while self._running.is_set(): + try: + op, args = self._ops.get(timeout=0.1) + except queue.Empty: + continue + try: + if op == "refresh": + self._do_refresh_emulators() + elif op == "connect": + self._do_connect(*args) + elif op == "disconnect": + self._disconnect_internal() + elif op == "halt": + self._do_halt() + elif op == "run": + self._do_run() + elif op == "step_in": + self._do_step_in() + elif op == "step_over": + self._do_step_over() + elif op == "step_out": + self._do_step_out() + elif op == "exec": + self._do_exec_command(*args) + elif op == "read_registers": + self._read_request_queued = False + self._do_read_registers() + except Exception as exc: # noqa: BLE001 + self.log_line.emit(f"JLink error: {exc}") + self._disconnect_internal() + + @Slot() + def stop(self) -> None: + self._running.clear() + + @Slot() + def refresh_emulators(self) -> None: + self._ops.put(("refresh", ())) + + @Slot(str, str, str, int, bool, str) + def connect_target( + self, + serial_no: str, + device: str, + interface: str, + speed_khz: int, + keep_running: bool, + script_path: str, + ) -> None: + self._ops.put(("connect", (serial_no, device, interface, speed_khz, keep_running, script_path))) + + @Slot() + def disconnect_target(self) -> None: + self._ops.put(("disconnect", ())) + + @Slot() + def halt_target(self) -> None: + self._ops.put(("halt", ())) + + @Slot() + def run_target(self) -> None: + self._ops.put(("run", ())) + + @Slot() + def step_in(self) -> None: + self._ops.put(("step_in", ())) + + @Slot() + def step_over(self) -> None: + self._ops.put(("step_over", ())) + + @Slot() + def step_out(self) -> None: + self._ops.put(("step_out", ())) + + @Slot(str) + def exec_command(self, command: str) -> None: + self._ops.put(("exec", (command,))) + + @Slot() + def read_registers(self) -> None: + if self._read_request_queued: + return + self._read_request_queued = True + self._ops.put(("read_registers", ())) + + def _do_refresh_emulators(self) -> None: + if pylink is None: + self.emulators_updated.emit([]) + return + serials: List[str] = [] + probe = None + try: + emulators: Any = [] + probe = pylink.JLink() + connected_fn = getattr(probe, "connected_emulators", None) + if callable(connected_fn): + emulators = connected_fn() + else: + class_connected = getattr(pylink.JLink, "connected_emulators", None) + if callable(class_connected): + try: + emulators = class_connected() + except TypeError: + emulators = class_connected(probe) + for emu in emulators or []: + serial = None + for attr in ("SerialNumber", "SerialNo", "serial_number"): + if hasattr(emu, attr): + value = getattr(emu, attr) + if callable(value): + value = value() + if value: + serial = str(value) + break + if serial is None: + serial = str(emu) + serials.append(serial) + except Exception as exc: # noqa: BLE001 + self.log_line.emit(f"Cannot enumerate JLink probes: {exc}") + finally: + if probe is not None and hasattr(probe, "close"): + try: + probe.close() + except Exception: + pass + self.emulators_updated.emit(serials) + + def _do_connect( + self, + serial_no: str, + device: str, + interface: str, + speed_khz: int, + keep_running: bool, + script_path: str, + ) -> None: + if pylink is None: + self.status_changed.emit(JLinkState(False, "pylink-square not installed; debugger unavailable.")) + return + self._disconnect_internal(emit=False) + requested_target = (device or "").strip() or "Cortex-M33" + candidates = self._candidate_targets(requested_target, script_path) + iface_candidates = self._candidate_interfaces(interface, script_path) + serial = self._parse_serial(serial_no) + last_error: Optional[Exception] = None + for iface_idx, iface in enumerate(iface_candidates): + if iface_idx > 0: + self.log_line.emit(f"Retrying with interface: {iface}") + for idx, target in enumerate(candidates): + jlink = None + try: + jlink = pylink.JLink() + if serial is None: + jlink.open() + else: + jlink.open(serial) + self._set_interface(jlink, iface) + self._apply_jlink_script(jlink, script_path) + self._connect_target(jlink, target, speed_khz, keep_running) + self._jlink = jlink + if keep_running: + self._resume_if_halted() + if idx > 0: + self.log_line.emit(f"Connected with fallback device: {target}") + if iface_idx > 0: + self.log_line.emit(f"Connected with fallback interface: {iface}") + self.status_changed.emit(JLinkState(True, f"Connected: {target} @ {speed_khz} kHz ({iface.upper()})")) + self._do_read_registers() + return + except Exception as exc: # noqa: BLE001 + last_error = exc + if jlink is not None: + try: + jlink.close() + except Exception: + pass + has_next_target = idx + 1 < len(candidates) + if self._is_unsupported_device_error(exc) and has_next_target: + self.log_line.emit( + f"Device '{target}' unsupported; retrying with '{candidates[idx + 1]}'." + ) + continue + if has_next_target: + continue + # Move to next interface if available; if this is last interface, fail below. + break + if last_error is not None: + hint = "" + if self._is_unsupported_device_error(last_error): + hint = " (all target/interface fallbacks exhausted; check J-Link software/probe Cortex-A support)" + self.status_changed.emit(JLinkState(False, f"Connect failed: {last_error}{hint}")) + self._disconnect_internal(emit=False) + + def _do_halt(self) -> None: + if not self._ensure_connected(): + return + try: + if hasattr(self._jlink, "halt"): + self._jlink.halt() + else: + self._exec_command_internal("halt") + self.command_result.emit("Processor halted.") + self._do_read_registers() + except Exception as exc: # noqa: BLE001 + self.command_result.emit(f"Halt failed: {exc}") + + def _do_run(self) -> None: + if not self._ensure_connected(): + return + try: + self._resume_if_halted(force=True) + self.command_result.emit("Processor running.") + except Exception as exc: # noqa: BLE001 + self.command_result.emit(f"Run failed: {exc}") + + def _do_step_in(self) -> None: + if not self._ensure_connected(): + return + try: + self._halt_if_running() + if hasattr(self._jlink, "step_into"): + self._jlink.step_into() + elif hasattr(self._jlink, "step"): + self._jlink.step() + else: + self._exec_command_internal("Step") + self.command_result.emit("Step in.") + self._do_read_registers() + except Exception as exc: # noqa: BLE001 + self.command_result.emit(f"Step in failed: {exc}") + + def _do_step_over(self) -> None: + if not self._ensure_connected(): + return + try: + self._halt_if_running() + if hasattr(self._jlink, "step_over"): + self._jlink.step_over() + else: + self._exec_command_internal("StepOver") + self.command_result.emit("Step over.") + self._do_read_registers() + except Exception as exc: # noqa: BLE001 + self.command_result.emit(f"Step over failed: {exc}") + + def _do_step_out(self) -> None: + if not self._ensure_connected(): + return + try: + self._halt_if_running() + if hasattr(self._jlink, "step_out"): + self._jlink.step_out() + else: + self._exec_command_internal("StepOut") + self.command_result.emit("Step out.") + self._do_read_registers() + except Exception as exc: # noqa: BLE001 + self.command_result.emit(f"Step out failed: {exc}") + + def _do_exec_command(self, command: str) -> None: + if not self._ensure_connected(): + return + cmd = command.strip() + if not cmd: + return + try: + handled, text = self._handle_alias_command(cmd) + if handled: + self.command_result.emit(text) + self._do_read_registers() + return + result = self._exec_command_internal(cmd) + if result: + self.command_result.emit(str(result)) + else: + self.command_result.emit("Command sent.") + self._do_read_registers() + except Exception as exc: # noqa: BLE001 + self.command_result.emit(f"Command failed: {exc}") + + def _handle_alias_command(self, cmd: str) -> Tuple[bool, str]: + read_match = re.match(r"^\s*x(?:/(\d+))?[a-zA-Z]*\s+([^\s]+)\s*$", cmd) + if read_match: + count = int(read_match.group(1) or "1") + if count <= 0: + raise ValueError("Read count must be > 0.") + addr = int(read_match.group(2), 0) + values = self._memory_read32(addr, count) + lines = [f"0x{addr + idx * 4:08X}: 0x{value & 0xFFFFFFFF:08X}" for idx, value in enumerate(values)] + return True, "\n".join(lines) + + write_match = re.match(r"^\s*set\s+\*([^\s=]+)\s*=\s*([^\s]+)\s*$", cmd) + if write_match: + addr = int(write_match.group(1), 0) + value = int(write_match.group(2), 0) & 0xFFFFFFFF + self._memory_write32(addr, value) + readback = self._memory_read32(addr, 1)[0] + return True, f"0x{addr:08X} <= 0x{value:08X} (readback 0x{readback & 0xFFFFFFFF:08X})" + + return False, "" + + def _do_read_registers(self) -> None: + if not self._ensure_connected(): + self.registers_updated.emit([]) + return + rows: List[Tuple[str, int]] = [] + for name, token in self._enumerate_register_tokens(): + value = self._read_register_value(token, name) + if value is None: + continue + rows.append((name, value)) + self.registers_updated.emit(rows) + + def _enumerate_register_tokens(self) -> List[Tuple[str, Any]]: + tokens: List[Tuple[str, Any]] = [] + seen: set[str] = set() + if self._jlink and hasattr(self._jlink, "register_list"): + try: + raw = self._jlink.register_list() or [] + for item in raw: + name, token = self._normalize_register_entry(item) + if not name or name in seen: + continue + seen.add(name) + tokens.append((name, token)) + except Exception: + pass + # If backend does not expose register_list, fall back to ARM core indices. + if not tokens: + core_fallback: List[Tuple[str, int]] = [ + ("R0", 0), + ("R1", 1), + ("R2", 2), + ("R3", 3), + ("R4", 4), + ("R5", 5), + ("R6", 6), + ("R7", 7), + ("R8", 8), + ("R9", 9), + ("R10", 10), + ("R11", 11), + ("R12", 12), + ("SP", 13), + ("LR", 14), + ("PC", 15), + ("xPSR", 16), + ("MSP", 17), + ("PSP", 18), + ("CONTROL", 20), + ] + for name, idx in core_fallback: + seen.add(name) + tokens.append((name, idx)) + # Ensure core context registers are always shown when available. + for key in ("PC", "LR", "SP", "MSP", "PSP", "xPSR", "CONTROL"): + if key not in seen: + tokens.append((key, key)) + return tokens + + def _normalize_register_entry(self, item: Any) -> Tuple[str, Any]: + if isinstance(item, str): + return item, item + if isinstance(item, int): + return self._register_name(item) or f"REG{item}", item + if isinstance(item, (tuple, list)) and item: + if len(item) >= 2 and isinstance(item[1], str): + return item[1], item[0] + if isinstance(item[0], str): + return item[0], item[0] + if isinstance(item[0], int): + return self._register_name(item[0]) or f"REG{item[0]}", item[0] + if hasattr(item, "Name") and hasattr(item, "Index"): + return str(item.Name), int(item.Index) + return "", item + + def _register_name(self, index: int) -> Optional[str]: + if not self._jlink: + return None + name_fn = getattr(self._jlink, "register_name", None) + if callable(name_fn): + try: + name = name_fn(index) + return str(name) + except Exception: + return None + return None + + def _read_register_value(self, token: Any, name: str) -> Optional[int]: + if not self._jlink: + return None + candidates = [token] + if name not in candidates: + candidates.append(name) + for method_name in ("register_read", "register", "read_register", "reg_read"): + method = getattr(self._jlink, method_name, None) + if not callable(method): + continue + for candidate in candidates: + try: + value = method(candidate) + parsed = self._coerce_register_value(value) + if parsed is not None: + return parsed + except Exception: + continue + return None + + def _coerce_register_value(self, value: Any) -> Optional[int]: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return int(value) & 0xFFFFFFFF + raw_attr = getattr(value, "value", None) + if isinstance(raw_attr, int): + return int(raw_attr) & 0xFFFFFFFF + if isinstance(value, str): + text = value.strip() + if not text: + return None + try: + return int(text, 0) & 0xFFFFFFFF + except Exception: + return None + if isinstance(value, dict): + for key in ("value", "Value", "reg_value", "register_value"): + item = value.get(key) + if isinstance(item, int): + return int(item) & 0xFFFFFFFF + if isinstance(value, (tuple, list)) and value: + # Some backends return (status, value). Prefer index 1 when index 0 + # looks like a tiny status code. + if len(value) >= 2 and isinstance(value[0], int) and isinstance(value[1], int): + first = int(value[0]) + second = int(value[1]) + if first in (-1, 0, 1) and second not in (-1, 0, 1): + return second & 0xFFFFFFFF + if abs(first) <= 4 and abs(second) > 4: + return second & 0xFFFFFFFF + return first & 0xFFFFFFFF + for item in value: + parsed = self._coerce_register_value(item) + if parsed is not None: + return parsed + return None + + def _memory_read32(self, addr: int, count: int) -> List[int]: + if not self._jlink: + raise RuntimeError("JLink not connected.") + for method_name in ("memory_read32", "mem_read32", "read_mem32"): + method = getattr(self._jlink, method_name, None) + if callable(method): + values = method(addr, count) + if isinstance(values, int): + return [values] + return list(values) + raise RuntimeError("memory_read32 is not supported by this JLink backend.") + + def _memory_write32(self, addr: int, value: int) -> None: + if not self._jlink: + raise RuntimeError("JLink not connected.") + for method_name in ("memory_write32", "mem_write32", "write_mem32"): + method = getattr(self._jlink, method_name, None) + if callable(method): + method(addr, [value]) + return + # Fallback to JLink Commander write command if memory_write32 is unavailable. + self._exec_command_internal(f"w4 0x{addr:08X} 0x{value:08X}") + + def _parse_serial(self, serial_no: str) -> Optional[int]: + text = (serial_no or "").strip() + if not text: + return None + digits = "".join(ch for ch in text if ch.isdigit()) + if not digits: + return None + try: + return int(digits) + except ValueError: + return None + + def _set_interface(self, jlink: Any, interface: str) -> None: + if not hasattr(jlink, "set_tif"): + return + if pylink is None or not hasattr(pylink, "enums") or not hasattr(pylink.enums, "JLinkInterfaces"): + return + iface = interface.upper() + interfaces = pylink.enums.JLinkInterfaces + if iface == "JTAG" and hasattr(interfaces, "JTAG"): + jlink.set_tif(interfaces.JTAG) + elif hasattr(interfaces, "SWD"): + jlink.set_tif(interfaces.SWD) + + def _connect_target(self, jlink: Any, target: str, speed_khz: int, keep_running: bool) -> None: + connect_fn = getattr(jlink, "connect") + sig = inspect.signature(connect_fn) + kwargs: dict[str, Any] = {} + if "speed" in sig.parameters: + kwargs["speed"] = speed_khz + if "verbose" in sig.parameters: + kwargs["verbose"] = False + if "halt" in sig.parameters: + kwargs["halt"] = not keep_running + try: + connect_fn(target, **kwargs) + except TypeError: + connect_fn(target) + + def _is_unsupported_device_error(self, exc: Exception) -> bool: + text = str(exc).lower() + return ( + "unsupported device selected" in text + or "unknown device" in text + or "device not found" in text + or "unknown device name" in text + or "could not find supported cpu" in text + or "supported cpu" in text + ) + + def _candidate_targets(self, requested_target: str, script_path: str) -> List[str]: + candidates: List[str] = [] + + def add(name: str) -> None: + text = (name or "").strip() + if not text: + return + lowered = text.lower() + if any(existing.lower() == lowered for existing in candidates): + return + candidates.append(text) + + add(requested_target) + normalized_path = script_path.replace("\\", "/").lower() + if "/processor/fp/" in normalized_path: + add("Cortex-M23") + add("Cortex-M33") + elif "/processor/np/" in normalized_path or "/processor/mp/" in normalized_path: + add("Cortex-M33") + add("Cortex-M4") + elif "/processor/ap/" in normalized_path or "/processor/ca32/" in normalized_path: + add("Cortex-A32") + add("Cortex-A7") + add("Cortex-A5") + add("Cortex-A9") + add("Cortex-A53") + else: + add("Cortex-M33") + return candidates + + def _candidate_interfaces(self, requested_interface: str, script_path: str) -> List[str]: + candidates: List[str] = [] + + def add(name: str) -> None: + text = (name or "").strip().upper() + if text not in {"SWD", "JTAG"}: + return + if text not in candidates: + candidates.append(text) + + add(requested_interface) + normalized_path = script_path.replace("\\", "/").lower() + if "/processor/ap/" in normalized_path or "/processor/ca32/" in normalized_path: + add("JTAG") + add("SWD") + else: + add("SWD") + add("JTAG") + return candidates + + def _apply_jlink_script(self, jlink: Any, script_path: str) -> None: + path_text = (script_path or "").strip() + if not path_text: + return + path = Path(path_text).expanduser().resolve() + if not path.exists(): + raise FileNotFoundError(f"JLinkScript not found: {path}") + raw_path = str(path) + for method_name in ( + "set_jlink_script_file", + "set_script_file", + "set_scriptfile", + "set_jlinkscriptfile", + ): + method = getattr(jlink, method_name, None) + if not callable(method): + continue + try: + method(raw_path) + self.log_line.emit(f"JLinkScript loaded: {raw_path}") + return + except TypeError: + try: + method(path=raw_path) + self.log_line.emit(f"JLinkScript loaded: {raw_path}") + return + except Exception: + pass + except Exception: + pass + + exec_fn = getattr(jlink, "exec_command", None) + if callable(exec_fn): + for command in ( + f'JLinkScriptFile = "{raw_path}"', + f'ScriptFile = "{raw_path}"', + f'SetJLinkScriptFile "{raw_path}"', + ): + try: + exec_fn(command) + self.log_line.emit(f"JLinkScript loaded: {raw_path}") + return + except Exception: + continue + raise RuntimeError("Current pylink backend does not expose JLinkScript configuration.") + + def _ensure_connected(self) -> bool: + if self._jlink is None: + self.status_changed.emit(JLinkState(False, "JLink is not connected.")) + return False + return True + + def _is_halted(self) -> Optional[bool]: + if not self._jlink: + return None + for method_name in ("halted", "is_halted"): + method = getattr(self._jlink, method_name, None) + if callable(method): + try: + return bool(method()) + except Exception: + continue + return None + + def _halt_if_running(self) -> None: + halted = self._is_halted() + if halted is False and hasattr(self._jlink, "halt"): + self._jlink.halt() + + def _resume_if_halted(self, force: bool = False) -> None: + if not self._jlink: + return + halted = self._is_halted() + if force or halted is True: + if hasattr(self._jlink, "go"): + self._jlink.go() + elif hasattr(self._jlink, "restart"): + self._jlink.restart() + + def _exec_command_internal(self, command: str) -> Any: + if not self._jlink or not hasattr(self._jlink, "exec_command"): + raise RuntimeError("exec_command is not supported by this JLink backend.") + return self._jlink.exec_command(command) + + def _disconnect_internal(self, emit: bool = True) -> None: + if self._jlink is not None: + try: + self._jlink.close() + except Exception: + pass + self._jlink = None + if emit: + self.status_changed.emit(JLinkState(False, "JLink disconnected.")) + + +class JLinkDebugService(QObject): + status_changed = Signal(object) # JLinkState + log_line = Signal(str) + emulators_updated = Signal(list) + registers_updated = Signal(list) + command_result = Signal(str) + + def __init__(self, parent: Optional[QObject] = None) -> None: + super().__init__(parent) + self._worker = _JLinkWorker() + self._worker.status_changed.connect(self.status_changed) + self._worker.log_line.connect(self.log_line) + self._worker.emulators_updated.connect(self.emulators_updated) + self._worker.registers_updated.connect(self.registers_updated) + self._worker.command_result.connect(self.command_result) + self._worker.start() + + def shutdown(self) -> None: + self._worker.stop() + self._worker.wait(2000) + + def refresh_emulators(self) -> None: + self._worker.refresh_emulators() + + def connect_target( + self, + serial_no: str, + device: str, + interface: str, + speed_khz: int, + keep_running: bool = True, + script_path: str = "", + ) -> None: + self._worker.connect_target(serial_no, device, interface, speed_khz, keep_running, script_path) + + def disconnect_target(self) -> None: + self._worker.disconnect_target() + + def halt_target(self) -> None: + self._worker.halt_target() + + def run_target(self) -> None: + self._worker.run_target() + + def step_in(self) -> None: + self._worker.step_in() + + def step_over(self) -> None: + self._worker.step_over() + + def step_out(self) -> None: + self._worker.step_out() + + def exec_command(self, command: str) -> None: + self._worker.exec_command(command) + + def read_registers(self) -> None: + self._worker.read_registers() diff --git a/ameba_control_panel/services/log_buffer.py b/ameba_control_panel/services/log_buffer.py index 16d1e12..28f3158 100644 --- a/ameba_control_panel/services/log_buffer.py +++ b/ameba_control_panel/services/log_buffer.py @@ -2,16 +2,17 @@ from __future__ import annotations from collections import deque from dataclasses import dataclass -from typing import Deque, List, Sequence +from typing import Deque, Sequence from ameba_control_panel import config +from ameba_control_panel.config import Direction from ameba_control_panel.utils.timeutils import timestamp_ms @dataclass class LogLine: text: str - direction: str # "rx", "tx", "info" + direction: Direction timestamp: str def as_display(self) -> str: @@ -19,14 +20,14 @@ class LogLine: class LogBuffer: - """Keeps a full archive plus a bounded UI tail.""" + """Keeps a bounded archive plus a bounded UI tail.""" def __init__(self, max_tail: int = config.UI_LOG_TAIL_LINES) -> None: self._max_tail = max_tail self._tail: Deque[LogLine] = deque(maxlen=max_tail) - self._archive: List[LogLine] = [] + self._archive: Deque[LogLine] = deque(maxlen=config.LOG_ARCHIVE_MAX) - def append(self, text: str, direction: str) -> LogLine: + def append(self, text: str, direction: Direction) -> LogLine: line = LogLine(text=text.rstrip("\n"), direction=direction, timestamp=timestamp_ms()) self._tail.append(line) self._archive.append(line) @@ -40,7 +41,7 @@ class LogBuffer: def tail(self) -> Deque[LogLine]: return self._tail - def archive(self) -> List[LogLine]: + def archive(self) -> Deque[LogLine]: return self._archive def clear(self) -> None: diff --git a/ameba_control_panel/services/port_service.py b/ameba_control_panel/services/port_service.py index 2697c00..877034b 100644 --- a/ameba_control_panel/services/port_service.py +++ b/ameba_control_panel/services/port_service.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import List +from PySide6.QtCore import QThread, Signal from serial.tools import list_ports @@ -12,8 +13,52 @@ class PortInfo: description: str -def scan_ports(include_synthetic: bool = True) -> List[PortInfo]: - ports = [PortInfo(p.device, p.description) for p in list_ports.comports()] - if include_synthetic: - ports.append(PortInfo("synthetic", "Synthetic loopback source")) - return ports +# Allowlist: substrings in description or manufacturer that indicate MCU/UART devices. +# Matches common USB-to-UART bridges used in embedded development. +_UART_KEYWORDS = ( + "USB Serial", + "USB-SERIAL", + "USB-to-Serial", + "FTDI", + "FT232", + "FT2232", + "CP210", + "CH340", + "CH910", + "CH343", + "PL2303", + "CDC", + "ACM", + "Realtek", + "Ameba", + "Silicon Labs", + "Prolific", + "SEGGER", + "J-Link", + "JLink", + "STMicroelectronics", + "STLink", + "CMSIS-DAP", + "DAPLink", + "Cypress", + "WCH", + "Nuvoton", +) + + +def scan_ports() -> List[PortInfo]: + result = [] + for p in list_ports.comports(): + combined = f"{p.description} {p.manufacturer or ''}" + if any(kw.lower() in combined.lower() for kw in _UART_KEYWORDS): + result.append(PortInfo(p.device, p.description)) + return result + + +class PortScanner(QThread): + """Runs scan_ports() off the main thread to avoid UI stalls.""" + result = Signal(list) + + def run(self) -> None: + ports = scan_ports() + self.result.emit(ports) diff --git a/ameba_control_panel/services/serial_service.py b/ameba_control_panel/services/serial_service.py index 9a82539..eb7ce34 100644 --- a/ameba_control_panel/services/serial_service.py +++ b/ameba_control_panel/services/serial_service.py @@ -1,8 +1,11 @@ from __future__ import annotations +import logging import queue import threading import time + +logger = logging.getLogger(__name__) from dataclasses import dataclass from typing import Optional, Tuple @@ -38,36 +41,56 @@ class _SerialWorker(QThread): self._serial = serial.Serial(self._port, self._baudrate, timeout=0.05) self.status_changed.emit(SerialState(self._port, self._baudrate, True)) except Exception as exc: # noqa: BLE001 + logger.error("Serial open failed %s@%d: %s", self._port, self._baudrate, exc) self.status_changed.emit(SerialState(self._port, self._baudrate, False, str(exc))) return self._running.set() + partial_line = "" + partial_line_ts = 0.0 + partial_hold_timeout = config.PARTIAL_LINE_HOLD_SEC try: while self._running.is_set(): # writes try: while True: payload, log_tx = self._write_queue.get_nowait() - self._serial.write(payload) + try: + self._serial.write(payload) + except serial.SerialException: + logger.error("Serial write failed on %s", self._port) + self._running.clear() + break if log_tx: try: text = payload.decode(errors="ignore").rstrip("\r\n") except Exception: text = repr(payload) self.line_received.emit(text, "tx") - # fallthrough only when queue empty except queue.Empty: pass - # reads - line = self._serial.readline() - if line: + # reads — accumulate partial lines until \n or stale timeout + raw = self._serial.readline() + if raw: try: - text = decode_line(line).strip("\r\n") + text = decode_line(raw) except Exception: - text = repr(line) - self.line_received.emit(text, "rx") + text = repr(raw) + if raw.endswith(b"\n") or raw.endswith(b"\r"): + full = (partial_line + text).strip("\r\n") + partial_line = "" + if full: + self.line_received.emit(full, "rx") + else: + partial_line += text + partial_line_ts = time.monotonic() + elif partial_line and (time.monotonic() - partial_line_ts) >= partial_hold_timeout: + self.line_received.emit(partial_line.strip("\r\n"), "rx") + partial_line = "" finally: + if partial_line: + self.line_received.emit(partial_line.strip("\r\n"), "rx") if self._serial: try: self._serial.close() @@ -85,6 +108,14 @@ class _SerialWorker(QThread): def write_bytes(self, payload: bytes) -> None: self._write_queue.put((payload, False)) + def set_dtr(self, state: bool) -> None: + if self._serial: + self._serial.setDTR(state) + + def set_rts(self, state: bool) -> None: + if self._serial: + self._serial.setRTS(state) + @Slot() def stop(self) -> None: self._running.clear() @@ -170,5 +201,13 @@ class SerialService(QObject): if self._worker: self._worker.write_bytes(data) # type: ignore[attr-defined] + def set_dtr(self, state: bool) -> None: + if self._worker and hasattr(self._worker, "set_dtr"): + self._worker.set_dtr(state) + + def set_rts(self, state: bool) -> None: + if self._worker and hasattr(self._worker, "set_rts"): + self._worker.set_rts(state) + def is_connected(self) -> bool: return bool(self._worker and self._worker.isRunning()) diff --git a/ameba_control_panel/services/session_store.py b/ameba_control_panel/services/session_store.py index bdab980..8b19967 100644 --- a/ameba_control_panel/services/session_store.py +++ b/ameba_control_panel/services/session_store.py @@ -1,33 +1,89 @@ from __future__ import annotations import json -import tempfile +import logging +import threading from pathlib import Path from typing import Dict, Any +from ameba_control_panel.config import app_data_dir -SESSION_PATH = Path(tempfile.gettempdir()) / "AmebaControlPanel" / "session.json" +_SCHEMA_VERSION = 1 +_DEBOUNCE_SEC = 0.3 + +logger = logging.getLogger(__name__) class SessionStore: def __init__(self) -> None: - self._path = SESSION_PATH - self._data: Dict[str, Dict[str, Any]] = {} + self._path = app_data_dir() / "session.json" + self._data: Dict[str, Any] = {} + self._lock = threading.Lock() + self._save_timer: threading.Timer | None = None self._load() def _load(self) -> None: try: - self._data = json.loads(self._path.read_text(encoding="utf-8")) + raw = json.loads(self._path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + self._data = raw + else: + self._data = {} + except FileNotFoundError: + self._data = {} except Exception: + logger.warning("Corrupt session file, starting fresh: %s", self._path) self._data = {} + def _write_file(self) -> None: + with self._lock: + self._data["_schema_version"] = _SCHEMA_VERSION + payload = json.dumps(self._data, indent=2) + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + tmp = self._path.with_suffix(".json.tmp") + tmp.write_text(payload, encoding="utf-8") + tmp.replace(self._path) + except Exception: + logger.exception("Failed to write session file: %s", self._path) + + def _schedule_save(self) -> None: + """Debounced save — coalesces rapid writes into one disk I/O on a background thread.""" + if self._save_timer is not None: + self._save_timer.cancel() + self._save_timer = threading.Timer(_DEBOUNCE_SEC, self._write_file) + self._save_timer.daemon = True + self._save_timer.start() + def save(self) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8") + self._schedule_save() + + def save_now(self) -> None: + """Blocking save for shutdown — ensures data hits disk.""" + if self._save_timer is not None: + self._save_timer.cancel() + self._save_timer = None + self._write_file() def get(self, device_key: str) -> Dict[str, Any]: - return dict(self._data.get(device_key, {})) + with self._lock: + return dict(self._data.get(device_key, {})) def set(self, device_key: str, payload: Dict[str, Any]) -> None: - self._data[device_key] = payload + with self._lock: + self._data[device_key] = payload + self.save() + + def remove(self, device_key: str) -> None: + with self._lock: + self._data.pop(device_key, None) + self.save() + + def get_tab_list(self) -> list: + with self._lock: + return list(self._data.get("__tab_list__", [])) + + def set_tab_list(self, tabs: list) -> None: + with self._lock: + self._data["__tab_list__"] = tabs self.save() diff --git a/ameba_control_panel/services/settings_service.py b/ameba_control_panel/services/settings_service.py new file mode 100644 index 0000000..4a9a103 --- /dev/null +++ b/ameba_control_panel/services/settings_service.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path + +from ameba_control_panel.config import app_data_dir + +logger = logging.getLogger(__name__) + +_DEFAULTS = { + # Font + "font_family": "JetBrains Mono", + "font_size": 10, + "ui_font_size": 10, + # Serial & Log + "default_baud": 1_500_000, + "partial_line_hold_ms": 300, + "port_scan_interval_sec": 5, + "log_tail_lines": 100_000, + "log_archive_max": 500_000, + "log_flush_interval_ms": 30, + "log_flush_batch_limit": 200, + # Flash + "default_rdev_path": "", + "default_floader_path": "", + "default_boot_start": "0x08000000", + "default_boot_end": "0x08040000", + "default_app_start": "0x08040000", + "default_app_end": "0x08440000", + "default_nn_start": "0x088A3000", + "default_nn_end": "0x08EB2FFF", + # Command + "cmd_delay_ms": 50, + "char_delay_ms": 0, + "history_max_entries": 500, +} + + +class Settings: + def __init__(self) -> None: + self._path = app_data_dir() / "settings.json" + self._data: dict = {} + self._load() + + def _load(self) -> None: + try: + self._data = json.loads(self._path.read_text(encoding="utf-8")) + except FileNotFoundError: + self._data = {} + except Exception: + logger.warning("Corrupt settings file, using defaults: %s", self._path) + self._data = {} + + def save(self) -> None: + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + tmp = self._path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(self._data, indent=2), encoding="utf-8") + tmp.replace(self._path) + except Exception: + logger.exception("Failed to write settings: %s", self._path) + + def get(self, key: str): + return self._data.get(key, _DEFAULTS.get(key)) + + def set(self, key: str, value) -> None: + self._data[key] = value + + # Convenience properties for frequently accessed settings + @property + def font_family(self) -> str: return self.get("font_family") + @font_family.setter + def font_family(self, v: str) -> None: self.set("font_family", v) + + @property + def font_size(self) -> int: return self.get("font_size") + @font_size.setter + def font_size(self, v: int) -> None: self.set("font_size", v) + + @property + def ui_font_size(self) -> int: return self.get("ui_font_size") + @ui_font_size.setter + def ui_font_size(self, v: int) -> None: self.set("ui_font_size", v) + + @property + def default_baud(self) -> int: return self.get("default_baud") + @default_baud.setter + def default_baud(self, v: int) -> None: self.set("default_baud", v) + + @property + def partial_line_hold_ms(self) -> int: return self.get("partial_line_hold_ms") + @partial_line_hold_ms.setter + def partial_line_hold_ms(self, v: int) -> None: self.set("partial_line_hold_ms", v) + + @property + def port_scan_interval_sec(self) -> int: return self.get("port_scan_interval_sec") + @port_scan_interval_sec.setter + def port_scan_interval_sec(self, v: int) -> None: self.set("port_scan_interval_sec", v) + + @property + def log_tail_lines(self) -> int: return self.get("log_tail_lines") + @log_tail_lines.setter + def log_tail_lines(self, v: int) -> None: self.set("log_tail_lines", v) + + @property + def cmd_delay_ms(self) -> int: return self.get("cmd_delay_ms") + @cmd_delay_ms.setter + def cmd_delay_ms(self, v: int) -> None: self.set("cmd_delay_ms", v) + + @property + def char_delay_ms(self) -> int: return self.get("char_delay_ms") + @char_delay_ms.setter + def char_delay_ms(self, v: int) -> None: self.set("char_delay_ms", v) + + @property + def history_max_entries(self) -> int: return self.get("history_max_entries") + @history_max_entries.setter + def history_max_entries(self, v: int) -> None: self.set("history_max_entries", v) diff --git a/ameba_control_panel/theme.py b/ameba_control_panel/theme.py new file mode 100644 index 0000000..c3b5ca9 --- /dev/null +++ b/ameba_control_panel/theme.py @@ -0,0 +1,328 @@ +"""Dracula PRO (Van Helsing) inspired themes.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Palette: + name: str + bg: str + bg_secondary: str + bg_input: str + bg_button: str + bg_button_hover: str + bg_button_pressed: str + border: str + border_focus: str + text: str + text_dim: str + text_bright: str + accent: str + accent_dim: str + selection: str + green: str + green_bright: str + orange: str + pink: str + purple: str + red: str + yellow: str + tab_active: str + tab_inactive: str + scroll_handle: str + scroll_hover: str + log_rx: str + log_tx: str + log_info: str + + +# Dracula PRO Van Helsing palette +_D = dict( + cyan="#80ffea", green="#8aff80", orange="#ffca80", + pink="#ff80bf", purple="#9580ff", red="#ff9580", + yellow="#ffff80", comment="#7970a9", +) + +DARK = Palette( + name="dark", + bg="#1e2029", + bg_secondary="#282a36", + bg_input="#313442", + bg_button="#414458", + bg_button_hover="#515570", + bg_button_pressed="#363949", + border="#414458", + border_focus=_D["cyan"], + text="#f8f8f2", + text_dim=_D["comment"], + text_bright="#ffffff", + accent=_D["cyan"], + accent_dim="#2a4a50", + selection="#44475a", + green=_D["green"], + green_bright="#50fa7b", + orange=_D["orange"], + pink=_D["pink"], + purple=_D["purple"], + red=_D["red"], + yellow=_D["yellow"], + tab_active="#1e2029", + tab_inactive="#282a36", + scroll_handle="#44475a", + scroll_hover="#515570", + log_rx=_D["green"], + log_tx=_D["cyan"], + log_info=_D["comment"], +) + +LIGHT = Palette( + name="light", + bg="#ffffff", + bg_secondary="#f4f4f8", + bg_input="#ffffff", + bg_button="#eeeef2", + bg_button_hover="#dddde4", + bg_button_pressed="#ccccd4", + border="#d4d4dc", + border_focus="#6c5ce7", + text="#282a36", + text_dim="#7970a9", + text_bright="#1e2029", + accent="#6c5ce7", + accent_dim="#eae7fd", + selection="#ddd8fd", + green="#2ebc50", + green_bright="#27ae60", + orange="#e67e22", + pink="#e84393", + purple="#6c5ce7", + red="#e74c3c", + yellow="#f1c40f", + tab_active="#ffffff", + tab_inactive="#f4f4f8", + scroll_handle="#c4c4cc", + scroll_hover="#a4a4ac", + log_rx="#1a8a3d", + log_tx="#2944a8", + log_info="#7970a9", +) + + +def build_stylesheet(p: Palette) -> str: + return f""" +/* ── Dracula PRO Theme ────────────────────────────── */ + +* {{ outline: none; }} + +QWidget {{ + background-color: {p.bg}; + color: {p.text}; + font-family: "Segoe UI", "SF Pro Text", sans-serif; + font-size: 10pt; +}} + +QMainWindow {{ background-color: {p.bg}; }} + +/* ── Menu ─────────────────────────────────────────── */ +QMenuBar {{ + background-color: {p.bg_secondary}; + color: {p.text}; + border-bottom: 1px solid {p.border}; + padding: 2px; +}} +QMenuBar::item {{ padding: 5px 12px; border-radius: 4px; }} +QMenuBar::item:selected {{ background-color: {p.selection}; }} +QMenu {{ + background-color: {p.bg_secondary}; + color: {p.text}; + border: 1px solid {p.border}; + border-radius: 8px; + padding: 4px; +}} +QMenu::item {{ padding: 6px 28px 6px 16px; border-radius: 4px; }} +QMenu::item:selected {{ background-color: {p.accent}; color: {p.bg}; }} +QMenu::separator {{ height: 1px; background: {p.border}; margin: 4px 8px; }} + +/* ── Tabs ─────────────────────────────────────────── */ +QTabWidget::pane {{ + border: 1px solid {p.border}; + background: {p.bg}; + top: -1px; +}} +QTabBar {{ background: {p.bg_secondary}; qproperty-drawBase: 0; }} +QTabBar::tab {{ + background: {p.tab_inactive}; + color: {p.text_dim}; + padding: 7px 18px; + border: none; + border-bottom: 2px solid transparent; + margin-right: 1px; +}} +QTabBar::tab:selected {{ + background: {p.tab_active}; + color: {p.accent}; + border-bottom: 2px solid {p.accent}; +}} +QTabBar::tab:hover:!selected {{ color: {p.text}; background: {p.bg_button}; }} + +/* ── GroupBox (Collapsible Card) ──────────────────── */ +QGroupBox {{ + background-color: {p.bg_secondary}; + border: 1px solid {p.border}; + border-radius: 8px; + margin-top: 14px; + padding: 10px 8px 8px 8px; + font-weight: 600; +}} +QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 2px 10px; + color: {p.accent}; + font-size: 10pt; +}} +QGroupBox::indicator {{ + width: 0px; height: 0px; + margin: 0px; padding: 0px; + border: none; +}} + +/* ── Buttons ──────────────────────────────────────── */ +QPushButton {{ + background-color: {p.bg_button}; + color: {p.text}; + border: 1px solid {p.border}; + border-radius: 6px; + padding: 5px 14px; + min-height: 24px; + font-weight: 500; +}} +QPushButton:hover {{ + background-color: {p.bg_button_hover}; + border-color: {p.accent}; +}} +QPushButton:pressed {{ background-color: {p.bg_button_pressed}; }} +QPushButton:disabled {{ color: {p.text_dim}; }} +QPushButton[checkable="true"]:checked {{ + background-color: {p.green}; + color: {p.bg}; + border-color: {p.green}; + font-weight: bold; +}} +QPushButton[checkable="true"]:checked:hover {{ background-color: {p.green_bright}; }} + +/* ── Inputs ───────────────────────────────────────── */ +QLineEdit {{ + background-color: {p.bg_input}; + color: {p.text}; + border: 1px solid {p.border}; + border-radius: 6px; + padding: 4px 8px; + selection-background-color: {p.selection}; + selection-color: {p.text_bright}; +}} +QLineEdit:focus {{ border: 1.5px solid {p.accent}; }} +QComboBox {{ + background-color: {p.bg_input}; + color: {p.text}; + border: 1px solid {p.border}; + border-radius: 6px; + padding: 4px 8px; + min-height: 24px; +}} +QComboBox:hover {{ border-color: {p.accent}; }} +QComboBox::drop-down {{ border: none; width: 22px; }} +QComboBox QAbstractItemView {{ + background-color: {p.bg_secondary}; + color: {p.text}; + border: 1px solid {p.border}; + border-radius: 6px; + selection-background-color: {p.accent}; + selection-color: {p.bg}; + padding: 4px; + outline: none; +}} +QSpinBox {{ + background-color: {p.bg_input}; + color: {p.text}; + border: 1px solid {p.border}; + border-radius: 6px; + padding: 4px 8px; +}} + +/* ── CheckBox ─────────────────────────────────────── */ +QCheckBox {{ color: {p.text}; spacing: 6px; }} +QCheckBox::indicator {{ + width: 14px; height: 14px; + border: 2px solid {p.text_dim}; + border-radius: 4px; + background: transparent; +}} +QCheckBox::indicator:checked {{ + background: {p.accent}; + border-color: {p.accent}; +}} + +/* ── List ─────────────────────────────────────────── */ +QListWidget {{ + background-color: {p.bg_input}; + color: {p.text}; + border: 1px solid {p.border}; + border-radius: 6px; + outline: none; + padding: 2px; +}} +QListWidget::item {{ padding: 3px 8px; border-radius: 4px; }} +QListWidget::item:selected {{ background-color: {p.selection}; color: {p.text_bright}; }} +QListWidget::item:hover:!selected {{ background-color: {p.bg_button}; }} + +/* ── ScrollBar ────────────────────────────────────── */ +QScrollBar:vertical {{ + background: transparent; width: 8px; margin: 2px 0; +}} +QScrollBar::handle:vertical {{ + background: {p.scroll_handle}; border-radius: 4px; min-height: 30px; +}} +QScrollBar::handle:vertical:hover {{ background: {p.scroll_hover}; }} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} +QScrollBar:horizontal {{ + background: transparent; height: 8px; margin: 0 2px; +}} +QScrollBar::handle:horizontal {{ + background: {p.scroll_handle}; border-radius: 4px; min-width: 30px; +}} +QScrollBar::handle:horizontal:hover {{ background: {p.scroll_hover}; }} +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} + +/* ── Misc ─────────────────────────────────────────── */ +QScrollArea {{ background: transparent; border: none; }} +QSplitter::handle {{ background: {p.border}; width: 2px; }} +QTextEdit {{ + background-color: {p.bg_input}; + color: {p.text}; + border: 1px solid {p.border}; + border-radius: 6px; + selection-background-color: {p.selection}; + padding: 4px; +}} +QToolTip {{ + background-color: {p.bg_secondary}; + color: {p.text}; + border: 1px solid {p.border}; + border-radius: 6px; + padding: 6px 10px; +}} +QDialog {{ background-color: {p.bg}; }} +QDialogButtonBox QPushButton {{ min-width: 80px; }} +QLabel {{ color: {p.text}; background: transparent; }} +QToolButton {{ + background: transparent; + color: {p.text}; + border: none; + border-radius: 6px; + padding: 4px 8px; + font-size: 14pt; +}} +QToolButton:hover {{ background-color: {p.bg_button_hover}; }} +""" diff --git a/ameba_control_panel/views/debugger_tab_view.py b/ameba_control_panel/views/debugger_tab_view.py new file mode 100644 index 0000000..c85c9aa --- /dev/null +++ b/ameba_control_panel/views/debugger_tab_view.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from typing import Iterable, Optional, Tuple + +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont +from PySide6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPlainTextEdit, + QPushButton, + QSpinBox, + QSplitter, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + + +class DebuggerTabView(QWidget): + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._build_ui() + + def _build_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(8, 8, 8, 8) + root.setSpacing(6) + + row1 = QHBoxLayout() + row1.setSpacing(6) + self.jlink_combo = QComboBox() + self.refresh_probes_btn = QPushButton("Refresh JLink") + self.processor_combo = QComboBox() + self.processor_combo.addItem("NP", "np") + self.processor_combo.addItem("FP", "fp") + self.processor_combo.addItem("MP", "mp") + self.processor_combo.addItem("CA32", "ca32") + self.device_edit = QLineEdit("Cortex-M33") + self.interface_combo = QComboBox() + self.interface_combo.addItems(["SWD", "JTAG"]) + self.speed_spin = QSpinBox() + self.speed_spin.setRange(100, 50_000) + self.speed_spin.setValue(4_000) + self.speed_spin.setSuffix(" kHz") + self.connect_btn = QPushButton("Connect JLink") + self.connect_btn.setCheckable(True) + self.status_label = QLabel("Disconnected") + row1.addWidget(QLabel("Probe")) + row1.addWidget(self.jlink_combo, 1) + row1.addWidget(self.refresh_probes_btn) + row1.addWidget(QLabel("Processor")) + row1.addWidget(self.processor_combo) + row1.addWidget(QLabel("Device")) + row1.addWidget(self.device_edit) + row1.addWidget(QLabel("IF")) + row1.addWidget(self.interface_combo) + row1.addWidget(QLabel("Speed")) + row1.addWidget(self.speed_spin) + row1.addWidget(self.connect_btn) + row1.addWidget(self.status_label) + root.addLayout(row1) + + splitter = QSplitter(Qt.Horizontal) + root.addWidget(splitter, 1) + + left = QWidget() + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(0, 0, 0, 0) + left_layout.setSpacing(4) + left_layout.addWidget(QLabel("Debugger Log")) + mono = QFont("JetBrains Mono") + mono.setStyleHint(QFont.Monospace) + mono.setPointSize(10) + self.log_view = QPlainTextEdit() + self.log_view.setReadOnly(True) + self.log_view.setFont(mono) + left_layout.addWidget(self.log_view, 1) + + action_row = QHBoxLayout() + action_row.setSpacing(6) + self.halt_btn = QPushButton("Halt") + self.run_btn = QPushButton("Run") + self.step_in_btn = QPushButton("Step In") + self.step_over_btn = QPushButton("Step Over") + self.step_out_btn = QPushButton("Step Out") + action_row.addWidget(self.halt_btn) + action_row.addWidget(self.run_btn) + action_row.addWidget(self.step_in_btn) + action_row.addWidget(self.step_over_btn) + action_row.addWidget(self.step_out_btn) + action_row.addStretch() + left_layout.addLayout(action_row) + + cmd_row = QHBoxLayout() + cmd_row.setSpacing(6) + self.command_input = QLineEdit() + self.command_input.setPlaceholderText("JLink command (e.g., x 0x20000000, set *0x20000000=0x12345678)") + self.command_send_btn = QPushButton("Send Cmd") + cmd_row.addWidget(self.command_input, 1) + cmd_row.addWidget(self.command_send_btn) + left_layout.addLayout(cmd_row) + splitter.addWidget(left) + + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(4) + right_layout.addWidget(QLabel("Register Map")) + self.register_table = QTableWidget(0, 2) + self.register_table.setHorizontalHeaderLabels(["Register", "Value"]) + self.register_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.register_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.register_table.verticalHeader().setVisible(False) + self.register_table.setAlternatingRowColors(True) + self.register_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.register_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) + self.register_table.setFont(mono) + right_layout.addWidget(self.register_table, 1) + splitter.addWidget(right) + + splitter.setChildrenCollapsible(False) + splitter.setHandleWidth(8) + splitter.setStretchFactor(0, 4) + splitter.setStretchFactor(1, 2) + splitter.setSizes([860, 340]) + + self.set_controls_enabled(False) + self.append_log("Connect JLink to start live register refresh.") + + def set_emulators(self, serials: Iterable[str], preferred: Optional[str] = None) -> None: + current = preferred if preferred is not None else self.selected_emulator() + self.jlink_combo.blockSignals(True) + self.jlink_combo.clear() + self.jlink_combo.addItem("Auto", "") + for serial in serials: + serial_text = str(serial).strip() + self.jlink_combo.addItem(serial_text, serial_text) + idx = self.jlink_combo.findData(current) + if idx >= 0: + self.jlink_combo.setCurrentIndex(idx) + else: + self.jlink_combo.setCurrentIndex(0) + self.jlink_combo.blockSignals(False) + + def selected_emulator(self) -> str: + data = self.jlink_combo.currentData() + if data is None: + return "" + return str(data) + + def selected_processor(self) -> str: + data = self.processor_combo.currentData() + if data is None: + return "np" + return str(data) + + def append_log(self, line: str) -> None: + text = line.rstrip() + if text: + self.log_view.appendPlainText(text) + + def set_connection_state(self, connected: bool, message: str) -> None: + self.connect_btn.blockSignals(True) + self.connect_btn.setChecked(connected) + self.connect_btn.setText("Disconnect JLink" if connected else "Connect JLink") + self.connect_btn.blockSignals(False) + self.status_label.setText(message) + self.set_controls_enabled(connected) + + def set_controls_enabled(self, connected: bool) -> None: + for widget in ( + self.halt_btn, + self.run_btn, + self.step_in_btn, + self.step_over_btn, + self.step_out_btn, + self.command_input, + self.command_send_btn, + ): + widget.setEnabled(connected) + + def set_registers(self, registers: Iterable[Tuple[str, int]]) -> None: + rows = list(registers) + self.register_table.setRowCount(len(rows)) + for row, (name, value) in enumerate(rows): + name_item = QTableWidgetItem(name) + value_item = QTableWidgetItem(f"0x{value:08X} ({value})") + value_item.setTextAlignment(Qt.AlignVCenter | Qt.AlignRight) + self.register_table.setItem(row, 0, name_item) + self.register_table.setItem(row, 1, value_item) diff --git a/ameba_control_panel/views/device_tab_view.py b/ameba_control_panel/views/device_tab_view.py index cfc2480..f543f86 100644 --- a/ameba_control_panel/views/device_tab_view.py +++ b/ameba_control_panel/views/device_tab_view.py @@ -1,23 +1,27 @@ from __future__ import annotations from pathlib import Path -from typing import Iterable +from typing import Iterable, List -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve from PySide6.QtGui import QFont, QKeySequence, QShortcut from PySide6.QtWidgets import ( QAbstractItemView, QCheckBox, QComboBox, - QGridLayout, + QCompleter, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, - QSpinBox, + QScrollArea, + QSizePolicy, QSplitter, + QStackedWidget, + QToolButton, QVBoxLayout, QWidget, ) @@ -25,111 +29,299 @@ from PySide6.QtWidgets import ( from ameba_control_panel import config from ameba_control_panel.views.log_view import LogView +_FLASH_DIR = str((Path(__file__).resolve().parents[2] / "Flash").resolve()) +_SIDEBAR_COLLAPSED = 48 +_SIDEBAR_EXPANDED = 160 + + +class _SidebarButton(QToolButton): + """Icon button with hover-expand label.""" + def __init__(self, icon_text: str, label: str, parent=None): + super().__init__(parent) + self._icon_text = icon_text + self._label = label + self.setText(icon_text) + self.setToolTip(label) + self.setCheckable(True) + self.setAutoExclusive(True) + self.setFixedHeight(40) + self.setStyleSheet(""" + QToolButton { font-size: 16px; border: none; border-radius: 6px; padding: 4px; text-align: left; } + QToolButton:hover { background: rgba(255,255,255,0.08); } + QToolButton:checked { background: rgba(255,255,255,0.12); border-left: 2px solid; } + """) + + def set_expanded(self, expanded: bool) -> None: + if expanded: + self.setText(f" {self._icon_text} {self._label}") + else: + self.setText(self._icon_text) + class DeviceTabView(QWidget): def __init__(self, profile, parent=None) -> None: super().__init__(parent) self.profile = profile + self._custom_bins: List[dict] = [] self._build_ui() + @staticmethod + def _create_addr_edit(default: str = "", placeholder: str = "") -> QLineEdit: + edit = QLineEdit(default) + edit.setFixedWidth(100) + if placeholder: + edit.setPlaceholderText(placeholder) + return edit + + def _create_image_block(self, label: str, path_edit: QLineEdit, browse_btn: QPushButton, + start_edit: QLineEdit, end_edit: QLineEdit, checkbox: QCheckBox) -> QWidget: + container = QWidget() + block = QVBoxLayout(container) + block.setContentsMargins(0, 2, 0, 2) + block.setSpacing(2) + row1 = QHBoxLayout() + row1.setSpacing(4) + row1.addWidget(checkbox) + row1.addWidget(QLabel(label)) + row1.addWidget(path_edit, 1) + row1.addWidget(browse_btn) + addr_toggle = QPushButton("Addr") + addr_toggle.setCheckable(True) + addr_toggle.setMinimumWidth(50) + row1.addWidget(addr_toggle) + block.addLayout(row1) + addr_container = QWidget() + addr_row = QHBoxLayout(addr_container) + addr_row.setContentsMargins(22, 0, 0, 0) + addr_row.setSpacing(4) + addr_row.addWidget(QLabel("Start")) + addr_row.addWidget(start_edit) + addr_row.addWidget(QLabel("End")) + addr_row.addWidget(end_edit) + addr_row.addStretch() + addr_container.setVisible(False) + block.addWidget(addr_container) + addr_toggle.toggled.connect(lambda on: addr_container.setVisible(on)) + return container + def _build_ui(self) -> None: - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(8, 8, 8, 8) - main_layout.setSpacing(6) + root = QHBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # ===================================================================== + # COLUMN 1 — Icon sidebar (48px, expands to 160px on hover) + # ===================================================================== + self._sidebar = QWidget() + self._sidebar.setFixedWidth(_SIDEBAR_COLLAPSED) + self._sidebar.setObjectName("sidebar") + self._sidebar.setStyleSheet("#sidebar { border-right: 1px solid rgba(255,255,255,0.08); }") + sidebar_layout = QVBoxLayout(self._sidebar) + sidebar_layout.setContentsMargins(4, 8, 4, 8) + sidebar_layout.setSpacing(4) + + self._sidebar_btns: list[_SidebarButton] = [] + self._btn_connect = _SidebarButton("\U0001F50C", "Connection") + self._btn_flash = _SidebarButton("\u26A1", "Flash") + self._btn_advanced = _SidebarButton("\U0001F527", "Advanced") + + for btn in [self._btn_connect, self._btn_flash, self._btn_advanced]: + sidebar_layout.addWidget(btn) + self._sidebar_btns.append(btn) + + sidebar_layout.addStretch() + + self.settings_btn = _SidebarButton("\u2699", "Settings") + self.settings_btn.setCheckable(False) + self.theme_btn = _SidebarButton("\u263E", "Theme") + self.theme_btn.setCheckable(False) + sidebar_layout.addWidget(self.settings_btn) + sidebar_layout.addWidget(self.theme_btn) + self._sidebar_btns.extend([self.settings_btn, self.theme_btn]) + + self._sidebar.enterEvent = self._sidebar_enter + self._sidebar.leaveEvent = self._sidebar_leave + + root.addWidget(self._sidebar) + + # ===================================================================== + # COLUMN 2 — Config panel + Command History (300px) + # ===================================================================== + middle = QWidget() + middle.setMinimumWidth(350) + mid_layout = QVBoxLayout(middle) + mid_layout.setContentsMargins(4, 4, 4, 4) + mid_layout.setSpacing(4) + + # Stacked config panels (switched by sidebar buttons) + self._config_stack = QStackedWidget() + + # -- Connection panel -------------------------------------------------- + conn_panel = QWidget() + conn_layout = QVBoxLayout(conn_panel) + conn_layout.setContentsMargins(0, 0, 0, 0) + conn_layout.setSpacing(4) self.dut_port_combo = QComboBox() - self.refresh_button = QPushButton("Refresh") + self.dut_port_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents) self.dut_baud_combo = QComboBox() self.dut_baud_combo.setEditable(True) for b in config.COMMON_BAUD_RATES: self.dut_baud_combo.addItem(str(b)) self.dut_baud_combo.setCurrentText(str(config.DEFAULT_BAUD)) - self.connect_button = QPushButton("Connect") - self.connect_button.setCheckable(True) self.control_port_combo = QComboBox() + self.control_port_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents) self.control_baud_combo = QComboBox() self.control_baud_combo.setEditable(True) for b in config.COMMON_BAUD_RATES: self.control_baud_combo.addItem(str(b)) self.control_baud_combo.setCurrentText(str(config.DEFAULT_BAUD)) - self.normal_btn = QPushButton("Normal Mode") - self.download_btn = QPushButton("Download Mode") - self.reset_btn = QPushButton("Device Reset") + self.connect_button = QPushButton("Connect") + self.connect_button.setCheckable(True) + self.normal_btn = QPushButton("Normal") + self.download_btn = QPushButton("Download") + self.reset_btn = QPushButton("Reset") - row1 = QHBoxLayout() - row1.setSpacing(6) - row1.addWidget(QLabel("DUT COM")) - row1.addWidget(self.dut_port_combo) - row1.addWidget(QLabel("Baud")) - row1.addWidget(self.dut_baud_combo) - row1.addSpacing(12) - row1.addWidget(QLabel("Control COM")) - row1.addWidget(self.control_port_combo) - row1.addWidget(QLabel("Baud")) - row1.addWidget(self.control_baud_combo) - row1.addWidget(self.connect_button) - row1.addWidget(self.refresh_button) - row1.addStretch() - row1.addWidget(self.normal_btn) - row1.addWidget(self.download_btn) - row1.addWidget(self.reset_btn) - main_layout.addLayout(row1) + for lbl, combo, baud in [("DUT", self.dut_port_combo, self.dut_baud_combo), + ("Ctrl", self.control_port_combo, self.control_baud_combo)]: + row = QHBoxLayout() + row.addWidget(QLabel(lbl)) + row.addWidget(combo, 1) + row.addWidget(baud) + conn_layout.addLayout(row) - self.app_path_edit = QLineEdit() - self.app_browse_btn = QPushButton("Browse") - row2 = QHBoxLayout() - row2.setSpacing(6) - row2.addWidget(QLabel("Application")) - row2.addWidget(self.app_path_edit) - row2.addWidget(self.app_browse_btn) - main_layout.addLayout(row2) + btn_row = QHBoxLayout() + btn_row.addWidget(self.connect_button) + btn_row.addWidget(self.normal_btn) + btn_row.addWidget(self.download_btn) + btn_row.addWidget(self.reset_btn) + conn_layout.addLayout(btn_row) + conn_layout.addStretch() + self._config_stack.addWidget(conn_panel) # index 0 + # -- Flash panel ------------------------------------------------------- + flash_scroll = QScrollArea() + flash_scroll.setWidgetResizable(True) + flash_scroll.setFrameShape(QScrollArea.NoFrame) + flash_inner = QWidget() + flash_layout = QVBoxLayout(flash_inner) + flash_layout.setContentsMargins(0, 0, 0, 0) + flash_layout.setSpacing(4) + + self.boot_flash_checkbox = QCheckBox() + self.boot_flash_checkbox.setChecked(True) self.boot_path_edit = QLineEdit() - self.boot_browse_btn = QPushButton("Browse") + self.boot_browse_btn = QPushButton("Open") + self.boot_start_addr_edit = self._create_addr_edit("0x08000000") + self.boot_end_addr_edit = self._create_addr_edit("0x08040000") + + self.app_flash_checkbox = QCheckBox() + self.app_flash_checkbox.setChecked(True) + self.app_path_edit = QLineEdit() + self.app_browse_btn = QPushButton("Open") + self.app_start_addr_edit = self._create_addr_edit("0x08040000") + self.app_end_addr_edit = self._create_addr_edit("0x08440000") + + self.nn_flash_checkbox = QCheckBox() + self.nn_flash_checkbox.setChecked(False) + self.nn_bin_path_edit = QLineEdit() + self.nn_bin_browse_btn = QPushButton("Open") + self.nn_start_addr_edit = self._create_addr_edit("0x088A3000") + self.nn_end_addr_edit = self._create_addr_edit("0x08EB2FFF") + + flash_layout.addWidget(self._create_image_block( + "Boot", self.boot_path_edit, self.boot_browse_btn, + self.boot_start_addr_edit, self.boot_end_addr_edit, self.boot_flash_checkbox)) + flash_layout.addWidget(self._create_image_block( + "App", self.app_path_edit, self.app_browse_btn, + self.app_start_addr_edit, self.app_end_addr_edit, self.app_flash_checkbox)) + flash_layout.addWidget(self._create_image_block( + "NN", self.nn_bin_path_edit, self.nn_bin_browse_btn, + self.nn_start_addr_edit, self.nn_end_addr_edit, self.nn_flash_checkbox)) + + self._custom_bin_container = QVBoxLayout() + flash_layout.addLayout(self._custom_bin_container) + + self._add_custom_btn = QPushButton("+ Add Custom Binary") + self._add_custom_btn.clicked.connect(self.add_custom_bin) self.flash_btn = QPushButton("Flash") - row3 = QHBoxLayout() - row3.setSpacing(6) - row3.addWidget(QLabel("Bootloader")) - row3.addWidget(self.boot_path_edit) - row3.addWidget(self.boot_browse_btn) - row3.addWidget(self.flash_btn) - main_layout.addLayout(row3) + flash_btn_row = QHBoxLayout() + flash_btn_row.addWidget(self._add_custom_btn) + flash_btn_row.addStretch() + flash_btn_row.addWidget(self.flash_btn) + flash_layout.addLayout(flash_btn_row) + flash_layout.addStretch() + flash_scroll.setWidget(flash_inner) + self._config_stack.addWidget(flash_scroll) # index 1 - splitter = QSplitter(Qt.Horizontal) - main_layout.addWidget(splitter, 1) + # -- Advanced panel ---------------------------------------------------- + adv_panel = QWidget() + adv_layout = QVBoxLayout(adv_panel) + adv_layout.setContentsMargins(0, 0, 0, 0) + adv_layout.setSpacing(4) - # Left history pane - left_widget = QWidget() - left_layout = QVBoxLayout(left_widget) - left_layout.setContentsMargins(0, 0, 6, 0) - left_layout.setSpacing(4) - history_label = QLabel("Command History") + self.rdev_path_edit = QLineEdit(str(Path(_FLASH_DIR) / "Devices" / "Profiles" / "AmebaPro3_FreeRTOS_NOR.rdev")) + self.rdev_browse_btn = QPushButton("Open") + rdev_row = QHBoxLayout() + rdev_row.addWidget(QLabel("Profile")) + rdev_row.addWidget(self.rdev_path_edit, 1) + rdev_row.addWidget(self.rdev_browse_btn) + adv_layout.addLayout(rdev_row) + + self.floader_path_edit = QLineEdit(str(Path(_FLASH_DIR) / "Devices" / "Floaders" / "floader_amebapro3.bin")) + self.floader_browse_btn = QPushButton("Open") + floader_row = QHBoxLayout() + floader_row.addWidget(QLabel("Floader")) + floader_row.addWidget(self.floader_path_edit, 1) + floader_row.addWidget(self.floader_browse_btn) + adv_layout.addLayout(floader_row) + adv_layout.addStretch() + self._config_stack.addWidget(adv_panel) # index 2 + + # Wire sidebar buttons to stack + self._btn_connect.clicked.connect(lambda: self._show_panel(0)) + self._btn_flash.clicked.connect(lambda: self._show_panel(1)) + self._btn_advanced.clicked.connect(lambda: self._show_panel(2)) + self._btn_connect.setChecked(True) + + mid_layout.addWidget(self._config_stack, 0) + + # Separator + sep = QWidget() + sep.setFixedHeight(1) + sep.setStyleSheet("background: rgba(255,255,255,0.08);") + mid_layout.addWidget(sep) + + # -- Command History (always visible) ---------------------------------- + hist_label = QLabel("Command History") + hist_label.setStyleSheet("font-weight: bold; padding: 2px;") + mid_layout.addWidget(hist_label) self.history_list = QListWidget() self.history_list.setSelectionMode(QListWidget.ExtendedSelection) self.history_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.history_list.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) - self.history_list.setFixedWidth(220) - left_layout.addWidget(history_label) - left_layout.addWidget(self.history_list, 1) - splitter.addWidget(left_widget) + mid_layout.addWidget(self.history_list, 1) - # Right log and controls - right_widget = QWidget() - right_layout = QVBoxLayout(right_widget) - right_layout.setContentsMargins(0, 0, 0, 0) + content_splitter = QSplitter(Qt.Horizontal) + content_splitter.addWidget(middle) + + # ===================================================================== + # COLUMN 3 — Log view + find + cmd + send + # ===================================================================== + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(4, 4, 4, 4) right_layout.setSpacing(4) toolbar = QHBoxLayout() toolbar.setSpacing(6) self.clear_btn = QPushButton("Clear") - self.save_btn = QPushButton("Save") - self.copy_btn = QPushButton("Copy") + self.save_btn = QPushButton("Save Log") toolbar.addStretch() - toolbar.addWidget(self.copy_btn) - toolbar.addWidget(self.save_btn) toolbar.addWidget(self.clear_btn) + toolbar.addWidget(self.save_btn) right_layout.addLayout(toolbar) self.log_view = LogView(config.UI_LOG_TAIL_LINES) @@ -142,60 +334,150 @@ class DeviceTabView(QWidget): find_row = QHBoxLayout() find_row.setSpacing(6) self.find_input = QLineEdit() - self.case_checkbox = QCheckBox("Case sensitive") - self.find_btn = QPushButton("Find") - self.next_btn = QPushButton("Next") + self.find_input.setPlaceholderText("Search log...") + self.case_checkbox = QCheckBox("Aa") + self.case_checkbox.setToolTip("Case sensitive") + self.find_all_btn = QPushButton("Find") self.prev_btn = QPushButton("Prev") - self.find_all_btn = QPushButton("Find All") - find_row.addWidget(QLabel("Find")) + self.next_btn = QPushButton("Next") find_row.addWidget(self.find_input, 1) find_row.addWidget(self.case_checkbox) - find_row.addWidget(self.find_btn) - find_row.addWidget(self.next_btn) - find_row.addWidget(self.prev_btn) find_row.addWidget(self.find_all_btn) + find_row.addWidget(self.prev_btn) + find_row.addWidget(self.next_btn) right_layout.addLayout(find_row) - cmdlist_row = QHBoxLayout() - cmdlist_row.setSpacing(6) + cmd_row = QHBoxLayout() + cmd_row.setSpacing(6) self.cmdlist_path_edit = QLineEdit() - self.cmdlist_browse_btn = QPushButton("Browse") - self.per_cmd_delay = QSpinBox() - self.per_cmd_delay.setRange(0, 60_000) - self.per_cmd_delay.setSuffix(" ms/cmd") - self.per_cmd_delay.setValue(50) - self.per_char_delay = QSpinBox() - self.per_char_delay.setRange(0, 5_000) - self.per_char_delay.setSuffix(" ms/char") - self.per_char_delay.setValue(0) + self.cmdlist_path_edit.setPlaceholderText("Command list file...") + self.cmdlist_browse_btn = QPushButton("Open") self.load_cmdlist_btn = QPushButton("Load") - cmdlist_row.addWidget(QLabel("Cmd File")) - cmdlist_row.addWidget(self.cmdlist_path_edit, 1) - cmdlist_row.addWidget(self.cmdlist_browse_btn) - cmdlist_row.addWidget(self.per_cmd_delay) - cmdlist_row.addWidget(self.per_char_delay) - cmdlist_row.addWidget(self.load_cmdlist_btn) - right_layout.addLayout(cmdlist_row) + cmd_row.addWidget(self.cmdlist_path_edit, 1) + cmd_row.addWidget(self.cmdlist_browse_btn) + cmd_row.addWidget(self.load_cmdlist_btn) + right_layout.addLayout(cmd_row) send_row = QHBoxLayout() send_row.setSpacing(6) self.command_input = QLineEdit() self.command_input.setPlaceholderText("Enter command") + self._history_completer = QCompleter([], self) + self._history_completer.setCaseSensitivity(Qt.CaseInsensitive) + self._history_completer.setFilterMode(Qt.MatchContains) + self.command_input.setCompleter(self._history_completer) self.send_button = QPushButton("Send") send_row.addWidget(self.command_input, 1) send_row.addWidget(self.send_button) right_layout.addLayout(send_row) - splitter.addWidget(right_widget) - splitter.setStretchFactor(1, 4) + content_splitter.addWidget(right) + content_splitter.setChildrenCollapsible(False) + content_splitter.setStretchFactor(0, 0) + content_splitter.setStretchFactor(1, 1) + content_splitter.setSizes([500, 700]) + root.addWidget(content_splitter, 1) - # Shortcuts - QShortcut(QKeySequence("Ctrl+S"), self, activated=self._copy_all) + QShortcut(QKeySequence("Ctrl+S"), self, activated=self.log_view.copy_selected) + + # -- Sidebar expand/collapse on hover -------------------------------------- + + def _sidebar_enter(self, event) -> None: + self._animate_sidebar(_SIDEBAR_EXPANDED) + for btn in self._sidebar_btns: + btn.set_expanded(True) + + def _sidebar_leave(self, event) -> None: + self._animate_sidebar(_SIDEBAR_COLLAPSED) + for btn in self._sidebar_btns: + btn.set_expanded(False) + + def _animate_sidebar(self, target_width: int) -> None: + if hasattr(self, "_sidebar_anim") and self._sidebar_anim is not None: + self._sidebar_anim.stop() + self._sidebar_anim = QPropertyAnimation(self._sidebar, b"minimumWidth") + self._sidebar_anim.setDuration(150) + self._sidebar_anim.setStartValue(self._sidebar.width()) + self._sidebar_anim.setEndValue(target_width) + self._sidebar_anim.setEasingCurve(QEasingCurve.OutCubic) + # Also animate maximumWidth to keep fixedWidth behavior + self._sidebar_anim2 = QPropertyAnimation(self._sidebar, b"maximumWidth") + self._sidebar_anim2.setDuration(150) + self._sidebar_anim2.setStartValue(self._sidebar.width()) + self._sidebar_anim2.setEndValue(target_width) + self._sidebar_anim2.setEasingCurve(QEasingCurve.OutCubic) + self._sidebar_anim.start() + self._sidebar_anim2.start() + + # -- Panel switching ------------------------------------------------------- + + def _show_panel(self, index: int) -> None: + self._config_stack.setCurrentIndex(index) + + # -- Dynamic custom binaries ----------------------------------------------- + + def add_custom_bin(self) -> dict: + cb = QCheckBox() + cb.setChecked(False) + path_edit = QLineEdit() + browse_btn = QPushButton("Open") + start_edit = self._create_addr_edit(placeholder="0x08000000") + end_edit = self._create_addr_edit(placeholder="0x08040000") + remove_btn = QPushButton("X") + remove_btn.setFixedWidth(32) + remove_btn.setStyleSheet("color: #ff5555; font-weight: bold;") + + layout = QVBoxLayout() + layout.setSpacing(2) + row1 = QHBoxLayout() + row1.setSpacing(4) + row1.addWidget(cb) + row1.addWidget(QLabel("Custom")) + row1.addWidget(path_edit, 1) + row1.addWidget(browse_btn) + row1.addWidget(remove_btn) + layout.addLayout(row1) + row2 = QHBoxLayout() + row2.setSpacing(4) + row2.addSpacing(22) + row2.addWidget(QLabel("Start")) + row2.addWidget(start_edit) + row2.addWidget(QLabel("End")) + row2.addWidget(end_edit) + row2.addStretch() + layout.addLayout(row2) + + container = QWidget() + container.setLayout(layout) + self._custom_bin_container.addWidget(container) + + entry = { + "checkbox": cb, "path_edit": path_edit, "browse_btn": browse_btn, + "start_edit": start_edit, "end_edit": end_edit, + "remove_btn": remove_btn, "container": container, + } + self._custom_bins.append(entry) + remove_btn.clicked.connect(lambda: self._remove_custom_bin(entry)) + return entry + + def _remove_custom_bin(self, entry: dict) -> None: + if entry in self._custom_bins: + self._custom_bins.remove(entry) + entry["container"].deleteLater() + + def get_custom_bins(self) -> List[dict]: + return list(self._custom_bins) + + # -- History --------------------------------------------------------------- def populate_history(self, entries: Iterable[str]) -> None: + items = list(entries) + self._history_completer.model().setStringList(items) + # Check if scrolled to bottom before update + sb = self.history_list.verticalScrollBar() + was_at_bottom = sb.value() >= sb.maximum() - 1 self.history_list.clear() - for entry in entries: + for entry in items: QListWidgetItem(entry, self.history_list) - - def _copy_all(self) -> None: - self.log_view.copy_selected() + if was_at_bottom: + self.history_list.scrollToBottom() diff --git a/ameba_control_panel/views/log_view.py b/ameba_control_panel/views/log_view.py index 2ed34a3..268640f 100644 --- a/ameba_control_panel/views/log_view.py +++ b/ameba_control_panel/views/log_view.py @@ -6,11 +6,15 @@ from typing import Iterable, List from PySide6.QtGui import QColor, QFont, QTextCharFormat, QTextCursor, QTextOption from PySide6.QtWidgets import QTextEdit +from ameba_control_panel.config import Direction from ameba_control_panel.services.log_buffer import LogLine +_HIGHLIGHT_BG = QColor("#fff59d") # Yellow — all matches +_FOCUS_BG = QColor("#ff9800") # Orange — current focused match + class LogView(QTextEdit): - """Fast-ish append-only log with selectable text and match highlighting.""" + """Fast append-only log with selectable text and match highlighting.""" def __init__(self, max_items: int, parent=None) -> None: super().__init__(parent) @@ -23,61 +27,125 @@ class LogView(QTextEdit): font.setPointSize(10) self.setFont(font) self._max_items = max_items - self._lines = deque() + self._lines: deque = deque() self._colors = { - "rx": QColor("#1b5e20"), - "tx": QColor("#0d47a1"), - "info": QColor("#424242"), + Direction.RX: QColor("#1a8a3d"), + Direction.TX: QColor("#2944a8"), + Direction.INFO: QColor("#7970a9"), } + self._fmt_cache = self._build_fmt_cache() self._match_rows: List[int] = [] + self._focus_idx: int = -1 + self._needle: str = "" + self._case_sensitive: bool = False + + def _build_fmt_cache(self) -> dict: + cache = {} + for direction, color in self._colors.items(): + fmt = QTextCharFormat() + fmt.setForeground(color) + cache[direction] = fmt + return cache def set_colors(self, rx: str, tx: str, info: str) -> None: - self._colors = {"rx": QColor(rx), "tx": QColor(tx), "info": QColor(info)} + self._colors = { + Direction.RX: QColor(rx), + Direction.TX: QColor(tx), + Direction.INFO: QColor(info), + } + self._fmt_cache = self._build_fmt_cache() def append_lines(self, lines: Iterable[LogLine]) -> None: + if not lines: + return cursor = self.textCursor() cursor.movePosition(QTextCursor.End) + cursor.beginEditBlock() doc = self.document() + # Reuse format objects — avoids allocation per line for line in lines: self._lines.append(line) - fmt = QTextCharFormat() - fmt.setForeground(self._colors.get(line.direction, self._colors["info"])) - cursor.insertText(line.as_display(), fmt) + cursor.insertText(line.as_display(), self._fmt_cache[line.direction]) cursor.insertBlock() - self.setTextCursor(cursor) - # Trim overflow blocks and mirror deque - while len(self._lines) > self._max_items and doc.blockCount() > 0: - self._lines.popleft() - block = doc.firstBlock() - cur = QTextCursor(block) - cur.select(QTextCursor.BlockUnderCursor) - cur.removeSelectedText() - cur.deleteChar() - self._apply_matches() + # Batch trim — remove excess in one block + overflow = len(self._lines) - self._max_items + if overflow > 0: + for _ in range(overflow): + self._lines.popleft() + block = doc.firstBlock() + cur = QTextCursor(block) + cur.select(QTextCursor.BlockUnderCursor) + cur.removeSelectedText() + cur.deleteChar() + if self._match_rows: + self._match_rows = [] + self._focus_idx = -1 + self.setExtraSelections([]) + cursor.endEditBlock() self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) def clear_log(self) -> None: self._lines.clear() self.clear() self._match_rows = [] + self._focus_idx = -1 + self._needle = "" - def set_matches(self, rows: List[int]) -> None: + def set_needle(self, needle: str, case_sensitive: bool = False) -> None: + self._needle = needle + self._case_sensitive = case_sensitive + + def set_matches(self, rows: List[int], focus_idx: int = -1) -> None: self._match_rows = rows + self._focus_idx = focus_idx self._apply_matches() def _apply_matches(self) -> None: - extra = [] + extra: list = [] doc = self.document() - for row in self._match_rows: + needle = self._needle + case_sensitive = self._case_sensitive + + for i, row in enumerate(self._match_rows): block = doc.findBlockByNumber(row) if not block.isValid(): continue - cursor = QTextCursor(block) - sel = QTextEdit.ExtraSelection() - sel.cursor = cursor - sel.format.setBackground(QColor("#fff59d")) - extra.append(sel) + is_focus = (i == self._focus_idx) + bg = _FOCUS_BG if is_focus else _HIGHLIGHT_BG + + if needle: + text = block.text() + search_text = text if case_sensitive else text.lower() + search_needle = needle if case_sensitive else needle.lower() + start = 0 + while True: + pos = search_text.find(search_needle, start) + if pos < 0: + break + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.StartOfBlock) + cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, pos) + cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, len(needle)) + sel = QTextEdit.ExtraSelection() + sel.cursor = cursor + sel.format.setBackground(bg) + extra.append(sel) + start = pos + len(needle) + else: + # Fallback: highlight whole line + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.StartOfBlock) + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + sel = QTextEdit.ExtraSelection() + sel.cursor = cursor + sel.format.setBackground(bg) + sel.format.setProperty(QTextCharFormat.FullWidthSelection, True) + extra.append(sel) + self.setExtraSelections(extra) + def displayed_lines(self) -> list: + return list(self._lines) + def copy_selected(self) -> None: self.copy() diff --git a/ameba_control_panel/views/settings_dialog.py b/ameba_control_panel/views/settings_dialog.py new file mode 100644 index 0000000..6e32ce9 --- /dev/null +++ b/ameba_control_panel/views/settings_dialog.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QIntValidator +from PySide6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from ameba_control_panel.services.settings_service import Settings + + +def _create_int_input(value: int, lo: int, hi: int, suffix: str = "") -> QLineEdit: + """Plain text input with integer validation.""" + display = f"{value}{suffix}" if suffix else str(value) + edit = QLineEdit(str(value)) + edit.setValidator(QIntValidator(lo, hi)) + edit.setPlaceholderText(f"{lo} - {hi}") + edit.setFixedWidth(140) + return edit + + +def _int_value(edit: QLineEdit) -> int: + """Get integer from a _num input, or 0 if invalid.""" + try: + return int(edit.text()) + except (ValueError, TypeError): + return 0 + + +class SettingsDialog(QDialog): + def __init__(self, settings: Settings, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle("Settings") + self.setMinimumWidth(480) + self._settings = settings + + layout = QVBoxLayout(self) + tabs = QTabWidget() + layout.addWidget(tabs) + + # ── Font tab ────────────────────────────────────── + font_tab = QWidget() + font_form = QFormLayout(font_tab) + + from PySide6.QtGui import QFontDatabase + self._font_family = QComboBox() + self._font_family.setEditable(False) + self._font_family.setMaxVisibleItems(10) + for fam in sorted(QFontDatabase.families()): + self._font_family.addItem(fam) + self._font_family.setCurrentText(settings.font_family) + self._font_family.currentTextChanged.connect(lambda v: self._auto_save("font_family", v)) + font_form.addRow("Log font family", self._font_family) + + self._font_size = _create_int_input(settings.font_size, 6, 24) + self._font_size.editingFinished.connect(lambda: self._auto_save("font_size", _int_value(self._font_size))) + font_form.addRow("Log font size (pt)", self._font_size) + + tabs.addTab(font_tab, "Font") + + # ── Serial & Log tab ───────────────────────────── + serial_tab = QWidget() + serial_form = QFormLayout(serial_tab) + + self._default_baud = _create_int_input(settings.default_baud, 9600, 6_000_000) + self._default_baud.editingFinished.connect(lambda: self._auto_save("default_baud", _int_value(self._default_baud))) + serial_form.addRow("Default baud (bps)", self._default_baud) + + self._partial_hold = _create_int_input(settings.partial_line_hold_ms, 50, 2000) + self._partial_hold.editingFinished.connect(lambda: self._auto_save("partial_line_hold_ms", _int_value(self._partial_hold))) + serial_form.addRow("Partial line hold (ms)", self._partial_hold) + + self._scan_interval = _create_int_input(settings.port_scan_interval_sec, 1, 60) + self._scan_interval.editingFinished.connect(lambda: self._auto_save("port_scan_interval_sec", _int_value(self._scan_interval))) + serial_form.addRow("Port scan interval (sec)", self._scan_interval) + + self._log_tail = _create_int_input(settings.log_tail_lines, 1_000, 1_000_000) + self._log_tail.editingFinished.connect(lambda: self._auto_save("log_tail_lines", _int_value(self._log_tail))) + serial_form.addRow("Log buffer (lines)", self._log_tail) + + self._log_archive = _create_int_input(settings.get("log_archive_max"), 10_000, 2_000_000) + self._log_archive.editingFinished.connect(lambda: self._auto_save("log_archive_max", _int_value(self._log_archive))) + serial_form.addRow("Log archive max (lines)", self._log_archive) + + self._flush_interval = _create_int_input(settings.get("log_flush_interval_ms"), 10, 500) + self._flush_interval.editingFinished.connect(lambda: self._auto_save("log_flush_interval_ms", _int_value(self._flush_interval))) + serial_form.addRow("Log flush interval (ms)", self._flush_interval) + + self._flush_batch = _create_int_input(settings.get("log_flush_batch_limit"), 50, 1000) + self._flush_batch.editingFinished.connect(lambda: self._auto_save("log_flush_batch_limit", _int_value(self._flush_batch))) + serial_form.addRow("Log flush batch (lines)", self._flush_batch) + + tabs.addTab(serial_tab, "Serial") + + # ── Flash tab ───────────────────────────────────── + flash_tab = QWidget() + flash_form = QFormLayout(flash_tab) + + for label, key in [("Boot start addr", "default_boot_start"), ("Boot end addr", "default_boot_end"), + ("App start addr", "default_app_start"), ("App end addr", "default_app_end"), + ("NN start addr", "default_nn_start"), ("NN end addr", "default_nn_end")]: + edit = QLineEdit(settings.get(key) or "") + edit.setFixedWidth(140) + edit.editingFinished.connect(lambda e=edit, k=key: self._auto_save(k, e.text())) + flash_form.addRow(label, edit) + + tabs.addTab(flash_tab, "Flash") + + # ── Command tab ─────────────────────────────────── + cmd_tab = QWidget() + cmd_form = QFormLayout(cmd_tab) + + self._cmd_delay = _create_int_input(settings.cmd_delay_ms, 0, 60_000) + self._cmd_delay.editingFinished.connect(lambda: self._auto_save("cmd_delay_ms", _int_value(self._cmd_delay))) + cmd_form.addRow("Per-command delay (ms)", self._cmd_delay) + + self._char_delay = _create_int_input(settings.char_delay_ms, 0, 5_000) + self._char_delay.editingFinished.connect(lambda: self._auto_save("char_delay_ms", _int_value(self._char_delay))) + cmd_form.addRow("Per-char delay (ms)", self._char_delay) + + self._history_max = _create_int_input(settings.history_max_entries, 10, 10_000) + self._history_max.editingFinished.connect(lambda: self._auto_save("history_max_entries", _int_value(self._history_max))) + cmd_form.addRow("History max entries", self._history_max) + + tabs.addTab(cmd_tab, "Command") + + btn_row = QHBoxLayout() + apply_btn = QPushButton("Apply") + apply_btn.clicked.connect(self._on_apply) + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.accept) + btn_row.addStretch() + btn_row.addWidget(apply_btn) + btn_row.addWidget(close_btn) + layout.addLayout(btn_row) + + self._apply_callback = None + + def set_apply_callback(self, callback) -> None: + self._apply_callback = callback + + def _on_apply(self) -> None: + if self._apply_callback: + self._apply_callback() + + def _auto_save(self, key: str, value) -> None: + self._settings.set(key, value) + self._settings.save() diff --git a/script/auto_run.py b/script/auto_run.py index c1e284b..5486226 100644 --- a/script/auto_run.py +++ b/script/auto_run.py @@ -5,7 +5,6 @@ from pathlib import Path def _bootstrap_path() -> None: root = Path(__file__).resolve().parent.parent if getattr(sys, "frozen", False): - # In onefile, the bundled libs live alongside the exe root = Path(sys._MEIPASS) if hasattr(sys, "_MEIPASS") else root # type: ignore[attr-defined] if str(root) not in sys.path: sys.path.insert(0, str(root)) @@ -17,4 +16,20 @@ from ameba_control_panel.app import main # noqa: E402 if __name__ == "__main__": - main() + if "--profile" in sys.argv: + sys.argv.remove("--profile") + import cProfile + import pstats + profiler = cProfile.Profile() + profiler.enable() + try: + main() + finally: + profiler.disable() + stats = pstats.Stats(profiler) + stats.sort_stats("cumulative") + stats.print_stats(40) + stats.dump_stats("profile_output.prof") + print("Profile saved to profile_output.prof") + else: + main() diff --git a/script/package_exe.py b/script/package_exe.py index 7865a9f..1567026 100644 --- a/script/package_exe.py +++ b/script/package_exe.py @@ -1,3 +1,4 @@ +"""Build Ameba Control Panel executable with PyInstaller.""" from __future__ import annotations import argparse @@ -6,16 +7,84 @@ import sys from pathlib import Path -def _add_data_arg(src: Path, dest: str) -> str: - """ - Format a PyInstaller --add-data argument with the correct path separator - for the current platform. - """ +def _add_data(src: Path, dest: str) -> str: sep = ";" if os.name == "nt" else ":" return f"{src}{sep}{dest}" -def build(onefile: bool) -> None: +def _write_version_file(root: Path, version: str) -> Path: + parts = (version.split(".") + ["0", "0", "0"])[:4] + csv = ", ".join(parts) + dot = ".".join(parts) + out = root / "build" / "version_info.txt" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(f"""\ +VSVersionInfo( + ffi=FixedFileInfo(filevers=({csv}), prodvers=({csv})), + kids=[ + StringFileInfo([StringTable('040904B0', [ + StringStruct('CompanyName', 'Realtek'), + StringStruct('FileDescription', 'Ameba Control Panel'), + StringStruct('FileVersion', '{dot}'), + StringStruct('ProductName', 'Ameba Control Panel'), + StringStruct('ProductVersion', '{dot}'), + ])]), + VarFileInfo([VarStruct('Translation', [0x0409, 0x04B0])]) + ] +) +""", encoding="utf-8") + return out + + +_EXCLUDES = [ + "tkinter", + "matplotlib", + "numpy", + "scipy", + "pandas", + "PIL", + "PySide6.QtWebEngine", + "PySide6.QtWebEngineCore", + "PySide6.QtWebEngineWidgets", + "PySide6.Qt3DCore", + "PySide6.Qt3DRender", + "PySide6.Qt3DInput", + "PySide6.Qt3DAnimation", + "PySide6.Qt3DExtras", + "PySide6.Qt3DLogic", + "PySide6.QtCharts", + "PySide6.QtDataVisualization", + "PySide6.QtMultimedia", + "PySide6.QtMultimediaWidgets", + "PySide6.QtQuick", + "PySide6.QtQuick3D", + "PySide6.QtQuickWidgets", + "PySide6.QtQml", + "PySide6.QtRemoteObjects", + "PySide6.QtSensors", + "PySide6.QtSerialBus", + "PySide6.QtBluetooth", + "PySide6.QtNfc", + "PySide6.QtPositioning", + "PySide6.QtLocation", + "PySide6.QtTest", + "PySide6.QtPdf", + "PySide6.QtPdfWidgets", + "PySide6.QtSvgWidgets", + "PySide6.QtNetworkAuth", + "PySide6.QtDesigner", + "PySide6.QtHelp", + "PySide6.QtOpenGL", + "PySide6.QtOpenGLWidgets", + "PySide6.QtSpatialAudio", + "PySide6.QtStateMachine", + "PySide6.QtTextToSpeech", + "PySide6.QtHttpServer", + "PySide6.QtGraphs", +] + + +def build(*, onefile: bool, icon: str | None = None, splash: str | None = None) -> None: root = Path(__file__).resolve().parent.parent entry = root / "script" / "auto_run.py" flash_dir = root / "Flash" @@ -25,42 +94,62 @@ def build(onefile: bool) -> None: if not flash_dir.exists(): sys.exit(f"Flash folder missing: {flash_dir}") - # Keep PyInstaller searches predictable. os.chdir(root) try: import PyInstaller.__main__ as pyinstaller except ImportError: - sys.exit("PyInstaller is not installed. Run `python -m pip install PyInstaller` first.") + sys.exit("PyInstaller is not installed. Run: pip install PyInstaller") + + sys.path.insert(0, str(root)) + from ameba_control_panel.config import APP_VERSION + version_file = _write_version_file(root, APP_VERSION) args = [ "--noconfirm", "--clean", + "--windowed", "--onefile" if onefile else "--onedir", - "--name=AmebaControlPanel", + f"--name=AmebaControlPanel", f"--distpath={root / 'dist'}", f"--workpath={root / 'build'}", - "--paths", - str(root), - "--collect-all", - "PySide6", + f"--specpath={root}", + f"--version-file={version_file}", + "--paths", str(root), + "--collect-all", "PySide6", "--hidden-import=serial", "--hidden-import=serial.tools.list_ports", "--hidden-import=pyDes", "--hidden-import=colorama", - "--add-data", - _add_data_arg(flash_dir, "Flash"), - str(entry), + "--add-data", _add_data(flash_dir, "Flash"), ] + for mod in _EXCLUDES: + args.extend(["--exclude-module", mod]) + + if icon: + icon_path = Path(icon) + if icon_path.exists(): + args.extend(["--icon", str(icon_path)]) + else: + print(f"Warning: icon not found: {icon_path}", file=sys.stderr) + + if splash: + splash_path = Path(splash) + if splash_path.exists(): + args.extend(["--splash", str(splash_path)]) + else: + print(f"Warning: splash image not found: {splash_path}", file=sys.stderr) + + args.append(str(entry)) pyinstaller.run(args) if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Build Ameba Control Panel executable with PyInstaller") - parser.add_argument( - "--onedir", - action="store_true", - help="Create an onedir bundle instead of a single-file exe", - ) - build(onefile=not parser.parse_args().onedir) + parser = argparse.ArgumentParser(description="Build Ameba Control Panel EXE") + parser.add_argument("--onefile", action="store_true", help="Create single-file exe (slower startup)") + parser.add_argument("--icon", help="Path to .ico file") + parser.add_argument("--splash", help="Path to splash screen image (.png)") + opts = parser.parse_args() + # Default to --onedir for fast startup + build(onefile=opts.onefile, icon=opts.icon, splash=opts.splash) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4175728 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + + +@pytest.fixture +def tmp_dir(tmp_path): + """Temporary directory for test files.""" + return tmp_path + + +@pytest.fixture +def session_file(tmp_path): + """Empty session file path in temp dir.""" + return tmp_path / "session.json" + + +@pytest.fixture +def session_data(): + """Sample session data.""" + return { + "_schema_version": 1, + "__tab_list__": [{"key": "dut_1", "label": "DUT 1"}], + "dut_1": { + "dut_port": "COM3", + "dut_baud": 1500000, + "app_path": "/path/to/app.bin", + "app_start_addr": "0x08040000", + "app_end_addr": "0x08440000", + }, + } + + +@pytest.fixture +def corrupt_session_file(tmp_path): + """Session file with invalid JSON.""" + p = tmp_path / "session.json" + p.write_text("{invalid json!!!", encoding="utf-8") + return p diff --git a/tests/test_log_buffer.py b/tests/test_log_buffer.py new file mode 100644 index 0000000..eab5d9f --- /dev/null +++ b/tests/test_log_buffer.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from unittest.mock import patch + +from ameba_control_panel.config import Direction +from ameba_control_panel.services.log_buffer import LogBuffer, LogLine + + +class TestLogBuffer: + def test_append_returns_logline(self): + buf = LogBuffer(max_tail=10) + line = buf.append("hello", Direction.RX) + assert isinstance(line, LogLine) + assert line.text == "hello" + assert line.direction == Direction.RX + + def test_tail_bounded(self): + buf = LogBuffer(max_tail=5) + for i in range(10): + buf.append(f"line {i}", Direction.RX) + assert len(buf.tail()) == 5 + assert buf.tail()[0].text == "line 5" + + def test_archive_bounded(self): + with patch("ameba_control_panel.services.log_buffer.config.LOG_ARCHIVE_MAX", 3): + buf = LogBuffer(max_tail=10) + for i in range(5): + buf.append(f"line {i}", Direction.RX) + assert len(buf.archive()) == 3 + assert buf.archive()[0].text == "line 2" + + def test_clear(self): + buf = LogBuffer(max_tail=10) + buf.append("test", Direction.INFO) + buf.clear() + assert len(buf.tail()) == 0 + assert len(buf.archive()) == 0 + + def test_as_text(self): + buf = LogBuffer(max_tail=10) + buf.append("hello", Direction.RX) + buf.append("world", Direction.TX) + text = buf.as_text() + assert "hello" in text + assert "world" in text + + def test_direction_preserved(self): + buf = LogBuffer(max_tail=10) + buf.append("rx data", Direction.RX) + buf.append("tx data", Direction.TX) + buf.append("info msg", Direction.INFO) + lines = list(buf.tail()) + assert lines[0].direction == Direction.RX + assert lines[1].direction == Direction.TX + assert lines[2].direction == Direction.INFO + + def test_newline_stripped(self): + buf = LogBuffer(max_tail=10) + line = buf.append("with newline\n", Direction.RX) + assert line.text == "with newline" diff --git a/tests/test_session_store.py b/tests/test_session_store.py new file mode 100644 index 0000000..77631f7 --- /dev/null +++ b/tests/test_session_store.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +from unittest.mock import patch + +from ameba_control_panel.services.session_store import SessionStore + + +class TestSessionStore: + def test_load_empty(self, tmp_path): + with patch("ameba_control_panel.services.session_store.app_data_dir", return_value=tmp_path): + store = SessionStore() + assert store.get("nonexistent") == {} + + def test_set_and_get(self, tmp_path): + with patch("ameba_control_panel.services.session_store.app_data_dir", return_value=tmp_path): + store = SessionStore() + store.set("dut_1", {"port": "COM3", "baud": 115200}) + store.save_now() + # Reload from disk + with patch("ameba_control_panel.services.session_store.app_data_dir", return_value=tmp_path): + store2 = SessionStore() + result = store2.get("dut_1") + assert result["port"] == "COM3" + assert result["baud"] == 115200 + + def test_remove(self, tmp_path): + with patch("ameba_control_panel.services.session_store.app_data_dir", return_value=tmp_path): + store = SessionStore() + store.set("dut_1", {"port": "COM3"}) + store.remove("dut_1") + store.save_now() + assert store.get("dut_1") == {} + + def test_tab_list(self, tmp_path): + with patch("ameba_control_panel.services.session_store.app_data_dir", return_value=tmp_path): + store = SessionStore() + tabs = [{"key": "dut_1", "label": "DUT 1"}, {"key": "dut_2", "label": "DUT 2"}] + store.set_tab_list(tabs) + store.save_now() + with patch("ameba_control_panel.services.session_store.app_data_dir", return_value=tmp_path): + store2 = SessionStore() + assert store2.get_tab_list() == tabs + + def test_corrupt_file_recovers(self, tmp_path): + (tmp_path / "session.json").write_text("{bad json", encoding="utf-8") + with patch("ameba_control_panel.services.session_store.app_data_dir", return_value=tmp_path): + store = SessionStore() + assert store.get("anything") == {} + + def test_schema_version_written(self, tmp_path): + with patch("ameba_control_panel.services.session_store.app_data_dir", return_value=tmp_path): + store = SessionStore() + store.set("x", {"val": 1}) + store.save_now() + data = json.loads((tmp_path / "session.json").read_text()) + assert data["_schema_version"] == 1 + + def test_atomic_write(self, tmp_path): + with patch("ameba_control_panel.services.session_store.app_data_dir", return_value=tmp_path): + store = SessionStore() + store.set("test", {"data": "value"}) + store.save_now() + # No .tmp file should remain + assert not (tmp_path / "session.json.tmp").exists() + assert (tmp_path / "session.json").exists() diff --git a/tests/test_settings_service.py b/tests/test_settings_service.py new file mode 100644 index 0000000..af2c76b --- /dev/null +++ b/tests/test_settings_service.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from unittest.mock import patch + +from ameba_control_panel.services.settings_service import Settings + + +class TestSettings: + def test_defaults(self, tmp_path): + with patch("ameba_control_panel.services.settings_service.app_data_dir", return_value=tmp_path): + s = Settings() + assert s.font_size == 10 + assert s.default_baud == 1_500_000 + assert s.log_tail_lines == 100_000 + assert s.port_scan_interval_sec == 5 + assert s.partial_line_hold_ms == 300 + + def test_set_and_persist(self, tmp_path): + with patch("ameba_control_panel.services.settings_service.app_data_dir", return_value=tmp_path): + s = Settings() + s.font_size = 14 + s.default_baud = 115200 + s.save() + + with patch("ameba_control_panel.services.settings_service.app_data_dir", return_value=tmp_path): + s2 = Settings() + assert s2.font_size == 14 + assert s2.default_baud == 115200 + + def test_corrupt_file_uses_defaults(self, tmp_path): + (tmp_path / "settings.json").write_text("not json!", encoding="utf-8") + with patch("ameba_control_panel.services.settings_service.app_data_dir", return_value=tmp_path): + s = Settings() + assert s.font_size == 10