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:
parent
ef919b9053
commit
c92fbe7548
@ -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__":
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
238
ameba_control_panel/controllers/debugger_tab_controller.py
Normal file
238
ameba_control_panel/controllers/debugger_tab_controller.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
0
ameba_control_panel/managers/__init__.py
Normal file
0
ameba_control_panel/managers/__init__.py
Normal file
251
ameba_control_panel/managers/flash_manager.py
Normal file
251
ameba_control_panel/managers/flash_manager.py
Normal 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)
|
||||
174
ameba_control_panel/managers/log_manager.py
Normal file
174
ameba_control_panel/managers/log_manager.py
Normal 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)
|
||||
@ -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()
|
||||
|
||||
765
ameba_control_panel/services/jlink_debug_service.py
Normal file
765
ameba_control_panel/services/jlink_debug_service.py
Normal 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()
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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()
|
||||
|
||||
119
ameba_control_panel/services/settings_service.py
Normal file
119
ameba_control_panel/services/settings_service.py
Normal 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)
|
||||
328
ameba_control_panel/theme.py
Normal file
328
ameba_control_panel/theme.py
Normal 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}; }}
|
||||
"""
|
||||
196
ameba_control_panel/views/debugger_tab_view.py
Normal file
196
ameba_control_panel/views/debugger_tab_view.py
Normal 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)
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
157
ameba_control_panel/views/settings_dialog.py
Normal file
157
ameba_control_panel/views/settings_dialog.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
@ -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
43
tests/conftest.py
Normal 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
60
tests/test_log_buffer.py
Normal 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"
|
||||
66
tests/test_session_store.py
Normal file
66
tests/test_session_store.py
Normal 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()
|
||||
34
tests/test_settings_service.py
Normal file
34
tests/test_settings_service.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user