Second Commit

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
wongyiekheng 2026-03-29 13:01:12 +08:00
parent ef919b9053
commit c92fbe7548
26 changed files with 3815 additions and 644 deletions

View File

@ -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 <idx> <0|1>" and "RESET <idx> <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__":

View File

@ -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()

View File

@ -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:

View File

@ -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()

View File

@ -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()

View File

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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:

View File

@ -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)

View File

@ -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())

View File

@ -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()

View File

@ -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)

View File

@ -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}; }}
"""

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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)

43
tests/conftest.py Normal file
View File

@ -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

60
tests/test_log_buffer.py Normal file
View File

@ -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"

View File

@ -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()

View File

@ -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