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("-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("-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("-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("--debug", action="store_true", help="Set log-level to debug")
|
||||||
parser.add_argument("--auto-dtr", action="store_true",
|
parser.add_argument("--auto-dtr", action="store_true",
|
||||||
help="(Deprecated) legacy auto DTR/RTS toggle; ignored when using GPIO helper.")
|
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",
|
parser.add_argument("-r", "--reset", action="store_true",
|
||||||
help="Force reset to normal mode after flashing (post-process RESET). Default unless -dl 1.")
|
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],
|
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
|
bridge_port = args.bridge_port or args.port
|
||||||
target_port = args.target_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.
|
# Decide image directory vs per-image flashing.
|
||||||
image_dir = args.image_dir
|
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()
|
boot_path = pathlib.Path(args.single_image).expanduser()
|
||||||
if not boot_path.exists():
|
if not boot_path.exists():
|
||||||
print("Custom image not found", file=sys.stderr)
|
print("Custom image not found", file=sys.stderr)
|
||||||
@ -260,7 +301,7 @@ def main():
|
|||||||
# address ranges expected by AmebaPro3 (boot: 0x08000000-0x08040000,
|
# address ranges expected by AmebaPro3 (boot: 0x08000000-0x08040000,
|
||||||
# app: 0x08040000-0x08440000). This avoids relying on the profile's
|
# app: 0x08040000-0x08440000). This avoids relying on the profile's
|
||||||
# partition table size checks, which can reject larger binaries.
|
# 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_boot = pathlib.Path(image_dir) / "amebapro3_boot.bin"
|
||||||
candidate_app = pathlib.Path(image_dir) / "amebapro3_app.bin"
|
candidate_app = pathlib.Path(image_dir) / "amebapro3_app.bin"
|
||||||
if candidate_boot.exists() and candidate_app.exists():
|
if candidate_boot.exists() and candidate_app.exists():
|
||||||
@ -285,7 +326,7 @@ def main():
|
|||||||
patched_load = False
|
patched_load = False
|
||||||
|
|
||||||
# If control-only flags are provided alongside any flash inputs, bail out early.
|
# 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)
|
print("Cannot combine -dl/-r with flashing arguments; use them alone to toggle mode/reset.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@ -307,12 +348,9 @@ def main():
|
|||||||
JsonUtils.load_from_file = patched_load # type: ignore
|
JsonUtils.load_from_file = patched_load # type: ignore
|
||||||
patched_load = True
|
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):
|
def send_boot_reset(port: str, baud: int, idx: int, boot: int | None = None, reset: int | None = None):
|
||||||
"""
|
"""ASCII commands understood by AmebaSmart CDC bridge."""
|
||||||
Issue ASCII commands understood by AmebaSmart CDC bridge.
|
|
||||||
Firmware uses "BOOT <idx> <0|1>" and "RESET <idx> <0|1>".
|
|
||||||
"""
|
|
||||||
with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser:
|
with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser:
|
||||||
if boot is not None:
|
if boot is not None:
|
||||||
ser.write(f"BOOT {idx} {boot}\r\n".encode())
|
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.write(f"RESET {idx} {reset}\r\n".encode())
|
||||||
ser.flush()
|
ser.flush()
|
||||||
|
|
||||||
def boot_seq(port: str, baud: int, idx: int):
|
def boot_seq_ascii(port: str, baud: int, idx: int):
|
||||||
"""
|
"""Enter download mode via ASCII GPIO commands on control port."""
|
||||||
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.
|
|
||||||
"""
|
|
||||||
with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser:
|
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"BOOT {idx} 1\r\n".encode())
|
||||||
ser.write(f"RESET {idx} 1\r\n".encode()) # reset 0 1
|
ser.write(f"RESET {idx} 1\r\n".encode())
|
||||||
ser.flush()
|
ser.flush()
|
||||||
time.sleep(0.1)
|
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()
|
ser.flush()
|
||||||
time.sleep(0.1)
|
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()
|
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):
|
def reset_seq_ascii(port: str, baud: int, idx: int):
|
||||||
"""
|
"""Pulse RESET only via ASCII GPIO commands."""
|
||||||
Pulse RESET only (BOOT unchanged): reset=1 -> reset=0 -> reset=1
|
|
||||||
"""
|
|
||||||
send_boot_reset(port, baud, idx, reset=1)
|
send_boot_reset(port, baud, idx, reset=1)
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
send_boot_reset(port, baud, idx, reset=0)
|
send_boot_reset(port, baud, idx, reset=0)
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
send_boot_reset(port, baud, idx, reset=1)
|
send_boot_reset(port, baud, idx, reset=1)
|
||||||
|
|
||||||
def reset_exit_seq(port: str, baud: int, idx: int):
|
def reset_exit_seq_ascii(port: str, baud: int, idx: int):
|
||||||
"""
|
"""Exit download mode via ASCII GPIO commands."""
|
||||||
Exit download mode (dl=0):
|
time.sleep(0.1)
|
||||||
reset=1/boot=0 -> reset=0/boot=0 -> reset=1/boot=0 (final high).
|
|
||||||
"""
|
|
||||||
time.sleep(0.1) # small guard before exit sequence
|
|
||||||
with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser:
|
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()
|
ser.flush()
|
||||||
time.sleep(0.2)
|
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()
|
ser.flush()
|
||||||
time.sleep(0.2)
|
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()
|
ser.flush()
|
||||||
time.sleep(0.2)
|
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()
|
ser.flush()
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
def release_to_normal(port: str, baud: int, idx: int):
|
def release_to_normal_ascii(port: str, baud: int, idx: int):
|
||||||
"""After flashing: BOOT=0, RESET high, with retries for re-enum."""
|
"""After flashing: BOOT=0, RESET high via ASCII GPIO."""
|
||||||
reset_exit_seq(port, baud, idx)
|
reset_exit_seq_ascii(port, baud, idx)
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
try:
|
try:
|
||||||
send_boot_reset(port, baud, idx, boot=0)
|
send_boot_reset(port, baud, idx, boot=0)
|
||||||
@ -378,26 +407,119 @@ def main():
|
|||||||
except SerialException:
|
except SerialException:
|
||||||
pass
|
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,
|
# Reset-only / mode-only handling: if -dl/-r is provided without any images,
|
||||||
# just toggle the lines and exit without running flash.py.
|
# just toggle the lines and exit without running flash.py.
|
||||||
if control_only:
|
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:
|
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)
|
sys.exit(1)
|
||||||
idx = 0 # only UART index 0 drives BOOT/RESET; others are no-op per firmware
|
idx = 0
|
||||||
try:
|
try:
|
||||||
if args.download_mode == 1:
|
if args.download_mode == 1:
|
||||||
boot_seq(ctrl_port, args.baudrate, idx)
|
boot_seq(ctrl_port, args.baudrate, idx)
|
||||||
elif args.download_mode == 0:
|
elif args.download_mode == 0:
|
||||||
reset_exit_seq(ctrl_port, args.baudrate, idx)
|
reset_exit_seq(ctrl_port, args.baudrate, idx)
|
||||||
elif args.reset:
|
elif args.reset:
|
||||||
reset_seq(ctrl_port, args.baudrate, idx) # retain BOOT state
|
reset_seq(ctrl_port, args.baudrate, idx)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
finally:
|
finally:
|
||||||
if patched_load:
|
if patched_load:
|
||||||
JsonUtils.load_from_file = orig_load # type: ignore
|
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):
|
def run_flash(argv_tail):
|
||||||
"""Invoke flash.main with a constructed argv list."""
|
"""Invoke flash.main with a constructed argv list."""
|
||||||
original_argv = sys.argv
|
original_argv = sys.argv
|
||||||
@ -411,13 +533,16 @@ def main():
|
|||||||
finally:
|
finally:
|
||||||
sys.argv = original_argv
|
sys.argv = original_argv
|
||||||
|
|
||||||
def try_reset_port(port: str):
|
def flash_with_retry(argv, label="flash"):
|
||||||
"""Best-effort reset using inline BOOT/RESET commands, then small delay."""
|
"""Run flash, retry once with boot_seq if first attempt fails."""
|
||||||
try:
|
rc = run_flash(argv)
|
||||||
reset_seq(port, args.baudrate)
|
if rc != 0 and gpio_port:
|
||||||
time.sleep(0.5)
|
boot_seq(gpio_port, args.baudrate, 0)
|
||||||
except SerialException:
|
rc = run_flash(argv)
|
||||||
pass
|
if rc != 0:
|
||||||
|
print(f"{label} failed (code {rc}).", file=sys.stderr)
|
||||||
|
sys.exit(rc)
|
||||||
|
return rc
|
||||||
|
|
||||||
rc = 0
|
rc = 0
|
||||||
try:
|
try:
|
||||||
@ -437,57 +562,31 @@ def main():
|
|||||||
common += ["--port", flash_port]
|
common += ["--port", flash_port]
|
||||||
common += args.extra
|
common += args.extra
|
||||||
|
|
||||||
# If flashing (not control_only), pre-drive into download mode via bridge if available.
|
# Pre-drive into download mode before flashing.
|
||||||
if not control_only and bridge_port:
|
gpio_port = flash_port if use_dtr_rts else bridge_port
|
||||||
boot_seq(bridge_port, args.baudrate, 0)
|
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 + [
|
single_argv = common + [
|
||||||
"--image", str(boot_path),
|
"--image", str(boot_path),
|
||||||
"--start-address", start_addr,
|
"--start-address", start_addr,
|
||||||
"--end-address", end_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:
|
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 = [
|
partition_entries = [
|
||||||
{
|
build_partition_entry(boot_path, 0x08000000, 0x08040000),
|
||||||
"ImageName": str(boot_path),
|
build_partition_entry(app_path, 0x08040000, 0x08440000),
|
||||||
"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,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
partition_json = json.dumps(partition_entries)
|
merged_argv = common + ["--partition-table", encode_partition_table(partition_entries)]
|
||||||
partition_b64 = base64.b64encode(partition_json.encode()).decode()
|
rc = flash_with_retry(merged_argv, "Single-pass flash")
|
||||||
|
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
dir_argv = common.copy()
|
dir_argv = common.copy()
|
||||||
if image_dir:
|
if image_dir:
|
||||||
@ -498,24 +597,25 @@ def main():
|
|||||||
finally:
|
finally:
|
||||||
if patched_load:
|
if patched_load:
|
||||||
JsonUtils.load_from_file = orig_load # type: ignore
|
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:
|
if not control_only:
|
||||||
ctrl_port = bridge_port or flash_port
|
post_port = gpio_port or flash_port
|
||||||
if ctrl_port:
|
if post_port:
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
try:
|
try:
|
||||||
release_to_normal(ctrl_port, args.baudrate, 0)
|
release_to_normal(post_port, args.baudrate, 0)
|
||||||
break
|
break
|
||||||
except SerialException:
|
except SerialException:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
else:
|
else:
|
||||||
# Best-effort final deassert to cover missed bytes.
|
|
||||||
try:
|
try:
|
||||||
send_boot_reset(ctrl_port, args.baudrate, 0, boot=0)
|
if use_dtr_rts:
|
||||||
send_boot_reset(ctrl_port, args.baudrate, 0, reset=1)
|
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:
|
except SerialException:
|
||||||
pass
|
pass
|
||||||
# (optional cleanup of timing files could go here)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -1,51 +1,242 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from PySide6.QtGui import QIcon, QPalette, QColor
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget
|
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 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.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):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setWindowTitle(config.APP_NAME)
|
self.setWindowTitle(f"{config.APP_NAME} v{config.APP_VERSION}")
|
||||||
self._tabs = QTabWidget()
|
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.setCentralWidget(self._tabs)
|
||||||
self.controllers: list[DeviceTabController] = []
|
|
||||||
for profile in config.DEVICE_PROFILES:
|
self._session = SessionStore()
|
||||||
controller = DeviceTabController(profile)
|
self._settings = Settings()
|
||||||
self.controllers.append(controller)
|
self._dut_controllers: list[DeviceTabController] = []
|
||||||
self._tabs.addTab(controller.view, profile.label)
|
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
|
def closeEvent(self, event) -> None: # noqa: N802
|
||||||
for c in self.controllers:
|
for c in self._dut_controllers:
|
||||||
c.shutdown()
|
c.shutdown()
|
||||||
|
self._session.save_now()
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
def _apply_light_palette(app: QApplication) -> None:
|
def _setup_logging() -> None:
|
||||||
palette = QPalette()
|
log_dir = config.app_data_dir()
|
||||||
palette.setColor(QPalette.Window, QColor(245, 245, 245))
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
palette.setColor(QPalette.WindowText, QColor(0, 0, 0))
|
handler = logging.handlers.RotatingFileHandler(
|
||||||
palette.setColor(QPalette.Base, QColor(255, 255, 255))
|
log_dir / "app.log", maxBytes=2_000_000, backupCount=3, encoding="utf-8",
|
||||||
palette.setColor(QPalette.AlternateBase, QColor(240, 240, 240))
|
)
|
||||||
palette.setColor(QPalette.ToolTipBase, QColor(255, 255, 255))
|
handler.setFormatter(logging.Formatter(
|
||||||
palette.setColor(QPalette.ToolTipText, QColor(0, 0, 0))
|
"%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
palette.setColor(QPalette.Text, QColor(0, 0, 0))
|
))
|
||||||
palette.setColor(QPalette.Button, QColor(240, 240, 240))
|
root = logging.getLogger()
|
||||||
palette.setColor(QPalette.ButtonText, QColor(0, 0, 0))
|
root.setLevel(logging.DEBUG)
|
||||||
palette.setColor(QPalette.BrightText, QColor(255, 0, 0))
|
root.addHandler(handler)
|
||||||
app.setPalette(palette)
|
logging.getLogger(__name__).info("App started v%s", config.APP_VERSION)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
_setup_logging()
|
||||||
QApplication.setStyle("Fusion")
|
QApplication.setStyle("Fusion")
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
_apply_light_palette(app)
|
app.setStyleSheet(theme.build_stylesheet(theme.LIGHT))
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.resize(1200, 800)
|
window.resize(1200, 800)
|
||||||
window.show()
|
window.show()
|
||||||
|
|||||||
@ -2,15 +2,45 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple
|
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_NAME = "Ameba Control Panel"
|
||||||
|
APP_VERSION = "3.1.0"
|
||||||
UI_LOG_TAIL_LINES = 100_000
|
UI_LOG_TAIL_LINES = 100_000
|
||||||
LOG_FLUSH_INTERVAL_MS = 30
|
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
|
PERF_UPDATE_INTERVAL_MS = 1_000
|
||||||
PORT_REFRESH_INTERVAL_MS = 2_000
|
PORT_REFRESH_INTERVAL_MS = 5_000
|
||||||
DEFAULT_BAUD = 1_500_000
|
DEFAULT_BAUD = 1_500_000
|
||||||
COMMON_BAUD_RATES = [
|
COMMON_BAUD_RATES = [
|
||||||
115_200,
|
115_200,
|
||||||
@ -26,20 +56,23 @@ COMMON_BAUD_RATES = [
|
|||||||
TIMESTAMP_FMT = "%Y-%m-%d %H:%M:%S.%f"
|
TIMESTAMP_FMT = "%Y-%m-%d %H:%M:%S.%f"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass
|
||||||
class DeviceProfile:
|
class DeviceProfile:
|
||||||
key: str
|
key: str
|
||||||
label: str
|
label: str
|
||||||
rx_color: str
|
rx_color: str = "#1a8a3d"
|
||||||
tx_color: str
|
tx_color: str = "#2944a8"
|
||||||
info_color: str
|
info_color: str = "#7970a9"
|
||||||
|
|
||||||
|
|
||||||
DEVICE_PROFILES: Tuple[DeviceProfile, ...] = (
|
DEFAULT_PROFILE = DeviceProfile("dut_1", "DUT 1")
|
||||||
DeviceProfile("amebapro3", "AmebaPro3 (RTL8735C)", "#1b5e20", "#0d47a1", "#424242"),
|
|
||||||
DeviceProfile("amebapro2", "AmebaPro2 (RTL8735B)", "#1b5e20", "#0d47a1", "#424242"),
|
|
||||||
DeviceProfile("amebasmart", "AmebaSmart (RTL8730E)", "#1b5e20", "#0d47a1", "#424242"),
|
def parse_baud(text: str) -> int:
|
||||||
)
|
try:
|
||||||
|
return int(text)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return DEFAULT_BAUD
|
||||||
|
|
||||||
|
|
||||||
def app_data_dir() -> Path:
|
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 __future__ import annotations
|
||||||
|
|
||||||
from collections import deque
|
import logging
|
||||||
from pathlib import Path
|
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.QtCore import QObject, QTimer, Qt, Slot
|
||||||
from PySide6.QtGui import QAction, QKeySequence, QShortcut
|
from PySide6.QtGui import QAction, QKeySequence
|
||||||
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
from ameba_control_panel import config
|
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.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.history_service import HistoryService
|
||||||
from ameba_control_panel.services.log_buffer import LogBuffer, LogLine
|
from ameba_control_panel.services.port_service import PortInfo, PortScanner
|
||||||
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.serial_service import SerialService, SerialState
|
from ameba_control_panel.services.serial_service import SerialService, SerialState
|
||||||
from ameba_control_panel.services.session_store import SessionStore
|
from ameba_control_panel.services.session_store import SessionStore
|
||||||
from ameba_control_panel.views.device_tab_view import DeviceTabView
|
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):
|
class DeviceTabController(QObject):
|
||||||
|
"""Orchestrates one device tab: delegates to FlashManager and LogManager."""
|
||||||
|
|
||||||
def __init__(self, profile, parent=None) -> None:
|
def __init__(self, profile, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self._alive = True
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
self.view = DeviceTabView(profile)
|
self.view = DeviceTabView(profile)
|
||||||
self.serial = SerialService()
|
self.serial = SerialService()
|
||||||
self.history = HistoryService(profile.key)
|
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._port_list: List[PortInfo] = []
|
||||||
self._search_worker: Optional[SearchWorker] = None
|
self._port_scanner: Optional[PortScanner] = None
|
||||||
self._matches: List[int] = []
|
|
||||||
self._match_index = -1
|
|
||||||
self._command_player: Optional[CommandPlayer] = 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 = SessionStore()
|
||||||
self._session_state = self._session.get(profile.key)
|
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 = QTimer(self)
|
||||||
self._port_timer.setInterval(config.PORT_REFRESH_INTERVAL_MS)
|
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._port_timer.start()
|
||||||
|
|
||||||
self._wire_ui()
|
self._wire_ui()
|
||||||
self._load_history()
|
self._load_history()
|
||||||
self.refresh_ports(initial=True)
|
|
||||||
self._restore_session()
|
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 ----------------------------------------------------------------
|
# UI wiring ----------------------------------------------------------------
|
||||||
def _wire_ui(self) -> None:
|
def _wire_ui(self) -> None:
|
||||||
v = self.view
|
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.connect_button.toggled.connect(self._toggle_connection)
|
||||||
v.send_button.clicked.connect(self._send_from_input)
|
v.send_button.clicked.connect(self._send_from_input)
|
||||||
v.command_input.returnPressed.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.itemClicked.connect(self._load_history_item)
|
||||||
v.history_list.itemDoubleClicked.connect(self._send_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 = QAction(v.history_list)
|
||||||
self._delete_action.setShortcut(QKeySequence.Delete)
|
self._delete_action.setShortcut(QKeySequence.Delete)
|
||||||
self._delete_action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
|
self._delete_action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
|
||||||
self._delete_action.triggered.connect(self._delete_selected_history)
|
self._delete_action.triggered.connect(self._delete_selected_history)
|
||||||
v.history_list.addAction(self._delete_action)
|
v.history_list.addAction(self._delete_action)
|
||||||
v.history_list.installEventFilter(self)
|
v.clear_btn.clicked.connect(self.log.clear)
|
||||||
v.history_list.viewport().installEventFilter(self)
|
v.save_btn.clicked.connect(self.log.save)
|
||||||
self.serial.line_received.connect(self._enqueue_line)
|
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)
|
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)
|
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.dut_port_combo.currentIndexChanged.connect(self._save_session)
|
||||||
v.control_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.dut_baud_combo.editTextChanged.connect(self._save_session)
|
||||||
v.control_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.rdev_path_edit.editingFinished.connect(self._save_session)
|
||||||
v.boot_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)
|
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 ------------------------------------------------------------------
|
# History ------------------------------------------------------------------
|
||||||
def _load_history(self) -> None:
|
def _load_history(self) -> None:
|
||||||
entries = self.history.load()
|
self.view.populate_history(self.history.load())
|
||||||
self.view.populate_history(entries)
|
|
||||||
|
|
||||||
def _restore_session(self) -> None:
|
def _restore_session(self) -> None:
|
||||||
if not self._session_state:
|
if not self._session_state:
|
||||||
return
|
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")
|
dut_baud = self._session_state.get("dut_baud")
|
||||||
ctrl_baud = self._session_state.get("ctrl_baud")
|
ctrl_baud = self._session_state.get("ctrl_baud")
|
||||||
if dut_baud:
|
if dut_baud:
|
||||||
self.view.dut_baud_combo.setCurrentText(str(dut_baud))
|
self.view.dut_baud_combo.setCurrentText(str(dut_baud))
|
||||||
if ctrl_baud:
|
if ctrl_baud:
|
||||||
self.view.control_baud_combo.setCurrentText(str(ctrl_baud))
|
self.view.control_baud_combo.setCurrentText(str(ctrl_baud))
|
||||||
if app := self._session_state.get("app_path"):
|
for key, attr in _SESSION_TEXT_FIELDS:
|
||||||
self.view.app_path_edit.setText(app)
|
if key in self._session_state:
|
||||||
if boot := self._session_state.get("boot_path"):
|
getattr(self.view, attr).setText(str(self._session_state.get(key) or ""))
|
||||||
self.view.boot_path_edit.setText(boot)
|
|
||||||
if cmd := self._session_state.get("cmd_file"):
|
|
||||||
self.view.cmdlist_path_edit.setText(cmd)
|
|
||||||
|
|
||||||
def _load_history_item(self, item) -> None:
|
def _load_history_item(self, item) -> None:
|
||||||
self.view.command_input.setText(item.text())
|
self.view.command_input.setText(item.text())
|
||||||
self.view.command_input.setFocus()
|
|
||||||
|
|
||||||
def _send_history_item(self, item) -> None:
|
def _send_history_item(self, item) -> None:
|
||||||
self._send_command(item.text(), add_to_history=False)
|
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]
|
rows = [self.view.history_list.row(it) for it in items]
|
||||||
self.history.delete_indices(rows)
|
self.history.delete_indices(rows)
|
||||||
self._load_history()
|
self._load_history()
|
||||||
self._save_session()
|
|
||||||
|
|
||||||
def _save_session(self) -> None:
|
def _save_session(self) -> None:
|
||||||
dut_baud = int(self.view.dut_baud_combo.currentText() or config.DEFAULT_BAUD)
|
if not self._alive:
|
||||||
ctrl_baud = int(self.view.control_baud_combo.currentText() or config.DEFAULT_BAUD)
|
return
|
||||||
|
dut_baud = config.parse_baud(self.view.dut_baud_combo.currentText())
|
||||||
|
ctrl_baud = config.parse_baud(self.view.control_baud_combo.currentText())
|
||||||
payload = {
|
payload = {
|
||||||
"dut_port": self.view.dut_port_combo.currentData(),
|
"dut_port": self.view.dut_port_combo.currentData(),
|
||||||
"ctrl_port": self.view.control_port_combo.currentData(),
|
"ctrl_port": self.view.control_port_combo.currentData(),
|
||||||
"dut_baud": dut_baud,
|
"dut_baud": dut_baud,
|
||||||
"ctrl_baud": ctrl_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)
|
self._session.set(self.profile.key, payload)
|
||||||
|
|
||||||
# Ports --------------------------------------------------------------------
|
# Ports (background scan) --------------------------------------------------
|
||||||
def refresh_ports(self, initial: bool = False, force_log: bool = False) -> None:
|
def _start_port_scan(self, initial: bool = False) -> None:
|
||||||
new_ports = scan_ports()
|
if self._port_scanner and self._port_scanner.isRunning():
|
||||||
new_map = {p.device: p.description for p in new_ports}
|
return
|
||||||
old_map = {p.device: p.description for p in self._port_list}
|
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]
|
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)
|
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
|
return
|
||||||
|
for p in removed:
|
||||||
|
self.log.enqueue_line(f"Port removed: {p.device}", Direction.INFO)
|
||||||
|
|
||||||
if removed:
|
merged = [p for p in self._port_list if p.device in new_map] + added
|
||||||
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]
|
|
||||||
self._port_list = merged
|
self._port_list = merged
|
||||||
|
|
||||||
current_dut = self.view.dut_port_combo.currentText()
|
preferred_dut = self._session_state.get("dut_port") if initial else self.view.dut_port_combo.currentData()
|
||||||
current_ctrl = self.view.control_port_combo.currentText()
|
preferred_ctrl = self._session_state.get("ctrl_port") if initial else self.view.control_port_combo.currentData()
|
||||||
|
|
||||||
# Prefer session selections during first load
|
for combo, selected in [(self.view.dut_port_combo, preferred_dut), (self.view.control_port_combo, preferred_ctrl)]:
|
||||||
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):
|
|
||||||
combo.blockSignals(True)
|
combo.blockSignals(True)
|
||||||
combo.clear()
|
combo.clear()
|
||||||
for p in self._port_list:
|
for p in self._port_list:
|
||||||
combo.addItem(f"{p.device} ({p.description})", p.device)
|
combo.addItem(f"{p.device} ({p.description})", p.device)
|
||||||
index = combo.findData(selected)
|
idx = combo.findData(selected)
|
||||||
if index >= 0:
|
combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||||
combo.setCurrentIndex(index)
|
|
||||||
elif combo.count() > 0:
|
|
||||||
combo.setCurrentIndex(0)
|
|
||||||
combo.blockSignals(False)
|
combo.blockSignals(False)
|
||||||
|
|
||||||
_update_combo(self.view.dut_port_combo, preferred_dut)
|
|
||||||
_update_combo(self.view.control_port_combo, preferred_ctrl)
|
|
||||||
if initial:
|
if initial:
|
||||||
self._save_session()
|
self._save_session()
|
||||||
|
|
||||||
@ -216,162 +229,51 @@ class DeviceTabController(QObject):
|
|||||||
self._connect_serial()
|
self._connect_serial()
|
||||||
else:
|
else:
|
||||||
self.serial.close()
|
self.serial.close()
|
||||||
self.view.connect_button.setText("Connect")
|
|
||||||
self._enqueue_line("Disconnected", "info")
|
|
||||||
|
|
||||||
def _connect_serial(self) -> None:
|
def _connect_serial(self) -> None:
|
||||||
port = self.view.dut_port_combo.currentData()
|
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:
|
if not port:
|
||||||
QMessageBox.warning(self.view, "Connect", "Please choose a DUT COM port.")
|
QMessageBox.warning(self.view, "Connect", "Please choose a DUT COM port.")
|
||||||
self.view.connect_button.setChecked(False)
|
self.view.connect_button.setChecked(False)
|
||||||
return
|
return
|
||||||
self.serial.open(port, baud)
|
self.serial.open(port, baud)
|
||||||
self._connected_port = port
|
self.flash.set_connected(port, baud)
|
||||||
self._connected_baud = baud
|
|
||||||
|
|
||||||
@Slot(object)
|
@Slot(object)
|
||||||
def _on_serial_status(self, state: SerialState) -> None:
|
def _on_serial_status(self, state: SerialState) -> None:
|
||||||
|
if not self._alive:
|
||||||
|
return
|
||||||
|
self.view.connect_button.blockSignals(True)
|
||||||
if state.connected:
|
if state.connected:
|
||||||
self.view.connect_button.setText("Disconnect")
|
self.view.connect_button.setChecked(True)
|
||||||
self._enqueue_line(f"Connected to {state.port} @ {state.baudrate}", "info")
|
self.flash.set_connected(state.port, state.baudrate)
|
||||||
self._connected_port = state.port
|
|
||||||
self._connected_baud = state.baudrate
|
|
||||||
self._save_session()
|
self._save_session()
|
||||||
else:
|
else:
|
||||||
if state.error:
|
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.setChecked(False)
|
||||||
self.view.connect_button.setText("Connect")
|
|
||||||
if self._command_player and self._command_player.isRunning():
|
if self._command_player and self._command_player.isRunning():
|
||||||
self._command_player.stop()
|
self._command_player.stop()
|
||||||
|
self.view.connect_button.blockSignals(False)
|
||||||
|
|
||||||
# Sending ------------------------------------------------------------------
|
# Sending ------------------------------------------------------------------
|
||||||
def _send_from_input(self) -> None:
|
def _send_from_input(self) -> None:
|
||||||
text = self.view.command_input.text()
|
self._send_command(self.view.command_input.text(), add_to_history=True)
|
||||||
self._send_command(text, add_to_history=True)
|
|
||||||
|
|
||||||
def _send_command(self, text: str, add_to_history: bool) -> None:
|
def _send_command(self, text: str, add_to_history: bool) -> None:
|
||||||
if not text.strip():
|
|
||||||
return
|
|
||||||
if not self.serial.is_connected():
|
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
|
return
|
||||||
self.serial.write(text)
|
self.serial.write(text)
|
||||||
if add_to_history:
|
if add_to_history and text.strip():
|
||||||
self.history.add(text)
|
self.history.add(text)
|
||||||
self._load_history()
|
self._load_history()
|
||||||
self.view.command_input.clear()
|
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 ----------------------------------------------------
|
# Command list playback ----------------------------------------------------
|
||||||
def _browse_cmdlist(self) -> None:
|
def _browse_cmdlist(self) -> None:
|
||||||
|
from PySide6.QtWidgets import QFileDialog
|
||||||
path, _ = QFileDialog.getOpenFileName(self.view, "Command list", "", "Text files (*.txt);;All files (*)")
|
path, _ = QFileDialog.getOpenFileName(self.view, "Command list", "", "Text files (*.txt);;All files (*)")
|
||||||
if path:
|
if path:
|
||||||
self.view.cmdlist_path_edit.setText(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.")
|
QMessageBox.warning(self.view, "CmdList", "Please choose a valid command list file.")
|
||||||
return
|
return
|
||||||
if not self.serial.is_connected():
|
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
|
return
|
||||||
self._command_player = CommandPlayer(
|
from ameba_control_panel.services.settings_service import Settings
|
||||||
filepath,
|
settings = Settings()
|
||||||
self.view.per_cmd_delay.value(),
|
self._command_player = CommandPlayer(filepath, settings.cmd_delay_ms, settings.char_delay_ms)
|
||||||
self.view.per_char_delay.value(),
|
|
||||||
)
|
|
||||||
self._command_player.send_raw.connect(self._send_raw_from_player)
|
self._command_player.send_raw.connect(self._send_raw_from_player)
|
||||||
self._command_player.finished_file.connect(self._on_cmdlist_finished)
|
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._enqueue_line(f"CmdList error: {msg}", "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.command_started.connect(self._on_cmd_started)
|
||||||
self._command_player.start()
|
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)
|
@Slot(str)
|
||||||
def _on_cmd_started(self, cmd: str) -> None:
|
def _on_cmd_started(self, cmd: str) -> None:
|
||||||
|
if not self._alive:
|
||||||
|
return
|
||||||
self.history.add(cmd)
|
self.history.add(cmd)
|
||||||
self._load_history()
|
self._load_history()
|
||||||
|
|
||||||
@Slot(bytes)
|
@Slot(bytes)
|
||||||
def _send_raw_from_player(self, payload: bytes) -> None:
|
def _send_raw_from_player(self, payload: bytes) -> None:
|
||||||
|
if not self._alive:
|
||||||
|
return
|
||||||
if not self.serial.is_connected():
|
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:
|
if self._command_player:
|
||||||
self._command_player.stop()
|
self._command_player.stop()
|
||||||
return
|
return
|
||||||
self.serial.write_raw(payload)
|
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 ------------------------------------------------------------------
|
# Cleanup ------------------------------------------------------------------
|
||||||
def shutdown(self) -> None:
|
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():
|
if self._command_player and self._command_player.isRunning():
|
||||||
self._command_player.stop()
|
self._command_player.stop()
|
||||||
self._command_player.wait(3000)
|
self._command_player.wait(3000)
|
||||||
if self._flash_runner and self._flash_runner.isRunning():
|
if self._port_scanner and self._port_scanner.isRunning():
|
||||||
self._flash_runner.requestInterruption()
|
self._port_scanner.wait(2000)
|
||||||
self._flash_runner.wait(5000)
|
|
||||||
self.serial.close()
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -24,7 +25,8 @@ class CommandPlayer(QThread):
|
|||||||
self._filepath = filepath
|
self._filepath = filepath
|
||||||
self._per_cmd_delay = max(0, per_cmd_delay_ms) / 1000.0
|
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._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:
|
def run(self) -> None:
|
||||||
try:
|
try:
|
||||||
@ -35,7 +37,7 @@ class CommandPlayer(QThread):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
for raw in lines:
|
for raw in lines:
|
||||||
if not self._running:
|
if not self._running.is_set():
|
||||||
break
|
break
|
||||||
stripped = raw.strip("\r\n")
|
stripped = raw.strip("\r\n")
|
||||||
if not stripped:
|
if not stripped:
|
||||||
@ -43,7 +45,7 @@ class CommandPlayer(QThread):
|
|||||||
self.command_started.emit(stripped)
|
self.command_started.emit(stripped)
|
||||||
if self._per_char_delay > 0:
|
if self._per_char_delay > 0:
|
||||||
for ch in stripped:
|
for ch in stripped:
|
||||||
if not self._running:
|
if not self._running.is_set():
|
||||||
break
|
break
|
||||||
self.send_raw.emit(ch.encode("utf-8", errors="ignore"))
|
self.send_raw.emit(ch.encode("utf-8", errors="ignore"))
|
||||||
time.sleep(self._per_char_delay)
|
time.sleep(self._per_char_delay)
|
||||||
@ -57,4 +59,4 @@ class CommandPlayer(QThread):
|
|||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def stop(self) -> None:
|
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 collections import deque
|
||||||
from dataclasses import dataclass
|
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 import config
|
||||||
|
from ameba_control_panel.config import Direction
|
||||||
from ameba_control_panel.utils.timeutils import timestamp_ms
|
from ameba_control_panel.utils.timeutils import timestamp_ms
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LogLine:
|
class LogLine:
|
||||||
text: str
|
text: str
|
||||||
direction: str # "rx", "tx", "info"
|
direction: Direction
|
||||||
timestamp: str
|
timestamp: str
|
||||||
|
|
||||||
def as_display(self) -> str:
|
def as_display(self) -> str:
|
||||||
@ -19,14 +20,14 @@ class LogLine:
|
|||||||
|
|
||||||
|
|
||||||
class LogBuffer:
|
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:
|
def __init__(self, max_tail: int = config.UI_LOG_TAIL_LINES) -> None:
|
||||||
self._max_tail = max_tail
|
self._max_tail = max_tail
|
||||||
self._tail: Deque[LogLine] = deque(maxlen=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())
|
line = LogLine(text=text.rstrip("\n"), direction=direction, timestamp=timestamp_ms())
|
||||||
self._tail.append(line)
|
self._tail.append(line)
|
||||||
self._archive.append(line)
|
self._archive.append(line)
|
||||||
@ -40,7 +41,7 @@ class LogBuffer:
|
|||||||
def tail(self) -> Deque[LogLine]:
|
def tail(self) -> Deque[LogLine]:
|
||||||
return self._tail
|
return self._tail
|
||||||
|
|
||||||
def archive(self) -> List[LogLine]:
|
def archive(self) -> Deque[LogLine]:
|
||||||
return self._archive
|
return self._archive
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from PySide6.QtCore import QThread, Signal
|
||||||
from serial.tools import list_ports
|
from serial.tools import list_ports
|
||||||
|
|
||||||
|
|
||||||
@ -12,8 +13,52 @@ class PortInfo:
|
|||||||
description: str
|
description: str
|
||||||
|
|
||||||
|
|
||||||
def scan_ports(include_synthetic: bool = True) -> List[PortInfo]:
|
# Allowlist: substrings in description or manufacturer that indicate MCU/UART devices.
|
||||||
ports = [PortInfo(p.device, p.description) for p in list_ports.comports()]
|
# Matches common USB-to-UART bridges used in embedded development.
|
||||||
if include_synthetic:
|
_UART_KEYWORDS = (
|
||||||
ports.append(PortInfo("synthetic", "Synthetic loopback source"))
|
"USB Serial",
|
||||||
return ports
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
@ -38,36 +41,56 @@ class _SerialWorker(QThread):
|
|||||||
self._serial = serial.Serial(self._port, self._baudrate, timeout=0.05)
|
self._serial = serial.Serial(self._port, self._baudrate, timeout=0.05)
|
||||||
self.status_changed.emit(SerialState(self._port, self._baudrate, True))
|
self.status_changed.emit(SerialState(self._port, self._baudrate, True))
|
||||||
except Exception as exc: # noqa: BLE001
|
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)))
|
self.status_changed.emit(SerialState(self._port, self._baudrate, False, str(exc)))
|
||||||
return
|
return
|
||||||
|
|
||||||
self._running.set()
|
self._running.set()
|
||||||
|
partial_line = ""
|
||||||
|
partial_line_ts = 0.0
|
||||||
|
partial_hold_timeout = config.PARTIAL_LINE_HOLD_SEC
|
||||||
try:
|
try:
|
||||||
while self._running.is_set():
|
while self._running.is_set():
|
||||||
# writes
|
# writes
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
payload, log_tx = self._write_queue.get_nowait()
|
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:
|
if log_tx:
|
||||||
try:
|
try:
|
||||||
text = payload.decode(errors="ignore").rstrip("\r\n")
|
text = payload.decode(errors="ignore").rstrip("\r\n")
|
||||||
except Exception:
|
except Exception:
|
||||||
text = repr(payload)
|
text = repr(payload)
|
||||||
self.line_received.emit(text, "tx")
|
self.line_received.emit(text, "tx")
|
||||||
# fallthrough only when queue empty
|
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# reads
|
# reads — accumulate partial lines until \n or stale timeout
|
||||||
line = self._serial.readline()
|
raw = self._serial.readline()
|
||||||
if line:
|
if raw:
|
||||||
try:
|
try:
|
||||||
text = decode_line(line).strip("\r\n")
|
text = decode_line(raw)
|
||||||
except Exception:
|
except Exception:
|
||||||
text = repr(line)
|
text = repr(raw)
|
||||||
self.line_received.emit(text, "rx")
|
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:
|
finally:
|
||||||
|
if partial_line:
|
||||||
|
self.line_received.emit(partial_line.strip("\r\n"), "rx")
|
||||||
if self._serial:
|
if self._serial:
|
||||||
try:
|
try:
|
||||||
self._serial.close()
|
self._serial.close()
|
||||||
@ -85,6 +108,14 @@ class _SerialWorker(QThread):
|
|||||||
def write_bytes(self, payload: bytes) -> None:
|
def write_bytes(self, payload: bytes) -> None:
|
||||||
self._write_queue.put((payload, False))
|
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()
|
@Slot()
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._running.clear()
|
self._running.clear()
|
||||||
@ -170,5 +201,13 @@ class SerialService(QObject):
|
|||||||
if self._worker:
|
if self._worker:
|
||||||
self._worker.write_bytes(data) # type: ignore[attr-defined]
|
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:
|
def is_connected(self) -> bool:
|
||||||
return bool(self._worker and self._worker.isRunning())
|
return bool(self._worker and self._worker.isRunning())
|
||||||
|
|||||||
@ -1,33 +1,89 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import logging
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any
|
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:
|
class SessionStore:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._path = SESSION_PATH
|
self._path = app_data_dir() / "session.json"
|
||||||
self._data: Dict[str, Dict[str, Any]] = {}
|
self._data: Dict[str, Any] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._save_timer: threading.Timer | None = None
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
|
logger.warning("Corrupt session file, starting fresh: %s", self._path)
|
||||||
self._data = {}
|
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:
|
def save(self) -> None:
|
||||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
self._schedule_save()
|
||||||
self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
|
|
||||||
|
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]:
|
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:
|
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()
|
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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
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.QtGui import QFont, QKeySequence, QShortcut
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QGridLayout,
|
QCompleter,
|
||||||
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSpinBox,
|
QScrollArea,
|
||||||
|
QSizePolicy,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
|
QStackedWidget,
|
||||||
|
QToolButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
@ -25,111 +29,299 @@ from PySide6.QtWidgets import (
|
|||||||
from ameba_control_panel import config
|
from ameba_control_panel import config
|
||||||
from ameba_control_panel.views.log_view import LogView
|
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):
|
class DeviceTabView(QWidget):
|
||||||
def __init__(self, profile, parent=None) -> None:
|
def __init__(self, profile, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
|
self._custom_bins: List[dict] = []
|
||||||
self._build_ui()
|
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:
|
def _build_ui(self) -> None:
|
||||||
main_layout = QVBoxLayout(self)
|
root = QHBoxLayout(self)
|
||||||
main_layout.setContentsMargins(8, 8, 8, 8)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
main_layout.setSpacing(6)
|
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.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 = QComboBox()
|
||||||
self.dut_baud_combo.setEditable(True)
|
self.dut_baud_combo.setEditable(True)
|
||||||
for b in config.COMMON_BAUD_RATES:
|
for b in config.COMMON_BAUD_RATES:
|
||||||
self.dut_baud_combo.addItem(str(b))
|
self.dut_baud_combo.addItem(str(b))
|
||||||
self.dut_baud_combo.setCurrentText(str(config.DEFAULT_BAUD))
|
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 = QComboBox()
|
||||||
|
self.control_port_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||||
self.control_baud_combo = QComboBox()
|
self.control_baud_combo = QComboBox()
|
||||||
self.control_baud_combo.setEditable(True)
|
self.control_baud_combo.setEditable(True)
|
||||||
for b in config.COMMON_BAUD_RATES:
|
for b in config.COMMON_BAUD_RATES:
|
||||||
self.control_baud_combo.addItem(str(b))
|
self.control_baud_combo.addItem(str(b))
|
||||||
self.control_baud_combo.setCurrentText(str(config.DEFAULT_BAUD))
|
self.control_baud_combo.setCurrentText(str(config.DEFAULT_BAUD))
|
||||||
|
|
||||||
self.normal_btn = QPushButton("Normal Mode")
|
self.connect_button = QPushButton("Connect")
|
||||||
self.download_btn = QPushButton("Download Mode")
|
self.connect_button.setCheckable(True)
|
||||||
self.reset_btn = QPushButton("Device Reset")
|
self.normal_btn = QPushButton("Normal")
|
||||||
|
self.download_btn = QPushButton("Download")
|
||||||
|
self.reset_btn = QPushButton("Reset")
|
||||||
|
|
||||||
row1 = QHBoxLayout()
|
for lbl, combo, baud in [("DUT", self.dut_port_combo, self.dut_baud_combo),
|
||||||
row1.setSpacing(6)
|
("Ctrl", self.control_port_combo, self.control_baud_combo)]:
|
||||||
row1.addWidget(QLabel("DUT COM"))
|
row = QHBoxLayout()
|
||||||
row1.addWidget(self.dut_port_combo)
|
row.addWidget(QLabel(lbl))
|
||||||
row1.addWidget(QLabel("Baud"))
|
row.addWidget(combo, 1)
|
||||||
row1.addWidget(self.dut_baud_combo)
|
row.addWidget(baud)
|
||||||
row1.addSpacing(12)
|
conn_layout.addLayout(row)
|
||||||
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)
|
|
||||||
|
|
||||||
self.app_path_edit = QLineEdit()
|
btn_row = QHBoxLayout()
|
||||||
self.app_browse_btn = QPushButton("Browse")
|
btn_row.addWidget(self.connect_button)
|
||||||
row2 = QHBoxLayout()
|
btn_row.addWidget(self.normal_btn)
|
||||||
row2.setSpacing(6)
|
btn_row.addWidget(self.download_btn)
|
||||||
row2.addWidget(QLabel("Application"))
|
btn_row.addWidget(self.reset_btn)
|
||||||
row2.addWidget(self.app_path_edit)
|
conn_layout.addLayout(btn_row)
|
||||||
row2.addWidget(self.app_browse_btn)
|
conn_layout.addStretch()
|
||||||
main_layout.addLayout(row2)
|
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_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")
|
self.flash_btn = QPushButton("Flash")
|
||||||
row3 = QHBoxLayout()
|
flash_btn_row = QHBoxLayout()
|
||||||
row3.setSpacing(6)
|
flash_btn_row.addWidget(self._add_custom_btn)
|
||||||
row3.addWidget(QLabel("Bootloader"))
|
flash_btn_row.addStretch()
|
||||||
row3.addWidget(self.boot_path_edit)
|
flash_btn_row.addWidget(self.flash_btn)
|
||||||
row3.addWidget(self.boot_browse_btn)
|
flash_layout.addLayout(flash_btn_row)
|
||||||
row3.addWidget(self.flash_btn)
|
flash_layout.addStretch()
|
||||||
main_layout.addLayout(row3)
|
flash_scroll.setWidget(flash_inner)
|
||||||
|
self._config_stack.addWidget(flash_scroll) # index 1
|
||||||
|
|
||||||
splitter = QSplitter(Qt.Horizontal)
|
# -- Advanced panel ----------------------------------------------------
|
||||||
main_layout.addWidget(splitter, 1)
|
adv_panel = QWidget()
|
||||||
|
adv_layout = QVBoxLayout(adv_panel)
|
||||||
|
adv_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
adv_layout.setSpacing(4)
|
||||||
|
|
||||||
# Left history pane
|
self.rdev_path_edit = QLineEdit(str(Path(_FLASH_DIR) / "Devices" / "Profiles" / "AmebaPro3_FreeRTOS_NOR.rdev"))
|
||||||
left_widget = QWidget()
|
self.rdev_browse_btn = QPushButton("Open")
|
||||||
left_layout = QVBoxLayout(left_widget)
|
rdev_row = QHBoxLayout()
|
||||||
left_layout.setContentsMargins(0, 0, 6, 0)
|
rdev_row.addWidget(QLabel("Profile"))
|
||||||
left_layout.setSpacing(4)
|
rdev_row.addWidget(self.rdev_path_edit, 1)
|
||||||
history_label = QLabel("Command History")
|
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 = QListWidget()
|
||||||
self.history_list.setSelectionMode(QListWidget.ExtendedSelection)
|
self.history_list.setSelectionMode(QListWidget.ExtendedSelection)
|
||||||
self.history_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
self.history_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||||
self.history_list.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
|
self.history_list.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
|
||||||
self.history_list.setFixedWidth(220)
|
mid_layout.addWidget(self.history_list, 1)
|
||||||
left_layout.addWidget(history_label)
|
|
||||||
left_layout.addWidget(self.history_list, 1)
|
|
||||||
splitter.addWidget(left_widget)
|
|
||||||
|
|
||||||
# Right log and controls
|
content_splitter = QSplitter(Qt.Horizontal)
|
||||||
right_widget = QWidget()
|
content_splitter.addWidget(middle)
|
||||||
right_layout = QVBoxLayout(right_widget)
|
|
||||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
# =====================================================================
|
||||||
|
# COLUMN 3 — Log view + find + cmd + send
|
||||||
|
# =====================================================================
|
||||||
|
right = QWidget()
|
||||||
|
right_layout = QVBoxLayout(right)
|
||||||
|
right_layout.setContentsMargins(4, 4, 4, 4)
|
||||||
right_layout.setSpacing(4)
|
right_layout.setSpacing(4)
|
||||||
|
|
||||||
toolbar = QHBoxLayout()
|
toolbar = QHBoxLayout()
|
||||||
toolbar.setSpacing(6)
|
toolbar.setSpacing(6)
|
||||||
self.clear_btn = QPushButton("Clear")
|
self.clear_btn = QPushButton("Clear")
|
||||||
self.save_btn = QPushButton("Save")
|
self.save_btn = QPushButton("Save Log")
|
||||||
self.copy_btn = QPushButton("Copy")
|
|
||||||
toolbar.addStretch()
|
toolbar.addStretch()
|
||||||
toolbar.addWidget(self.copy_btn)
|
|
||||||
toolbar.addWidget(self.save_btn)
|
|
||||||
toolbar.addWidget(self.clear_btn)
|
toolbar.addWidget(self.clear_btn)
|
||||||
|
toolbar.addWidget(self.save_btn)
|
||||||
right_layout.addLayout(toolbar)
|
right_layout.addLayout(toolbar)
|
||||||
|
|
||||||
self.log_view = LogView(config.UI_LOG_TAIL_LINES)
|
self.log_view = LogView(config.UI_LOG_TAIL_LINES)
|
||||||
@ -142,60 +334,150 @@ class DeviceTabView(QWidget):
|
|||||||
find_row = QHBoxLayout()
|
find_row = QHBoxLayout()
|
||||||
find_row.setSpacing(6)
|
find_row.setSpacing(6)
|
||||||
self.find_input = QLineEdit()
|
self.find_input = QLineEdit()
|
||||||
self.case_checkbox = QCheckBox("Case sensitive")
|
self.find_input.setPlaceholderText("Search log...")
|
||||||
self.find_btn = QPushButton("Find")
|
self.case_checkbox = QCheckBox("Aa")
|
||||||
self.next_btn = QPushButton("Next")
|
self.case_checkbox.setToolTip("Case sensitive")
|
||||||
|
self.find_all_btn = QPushButton("Find")
|
||||||
self.prev_btn = QPushButton("Prev")
|
self.prev_btn = QPushButton("Prev")
|
||||||
self.find_all_btn = QPushButton("Find All")
|
self.next_btn = QPushButton("Next")
|
||||||
find_row.addWidget(QLabel("Find"))
|
|
||||||
find_row.addWidget(self.find_input, 1)
|
find_row.addWidget(self.find_input, 1)
|
||||||
find_row.addWidget(self.case_checkbox)
|
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.find_all_btn)
|
||||||
|
find_row.addWidget(self.prev_btn)
|
||||||
|
find_row.addWidget(self.next_btn)
|
||||||
right_layout.addLayout(find_row)
|
right_layout.addLayout(find_row)
|
||||||
|
|
||||||
cmdlist_row = QHBoxLayout()
|
cmd_row = QHBoxLayout()
|
||||||
cmdlist_row.setSpacing(6)
|
cmd_row.setSpacing(6)
|
||||||
self.cmdlist_path_edit = QLineEdit()
|
self.cmdlist_path_edit = QLineEdit()
|
||||||
self.cmdlist_browse_btn = QPushButton("Browse")
|
self.cmdlist_path_edit.setPlaceholderText("Command list file...")
|
||||||
self.per_cmd_delay = QSpinBox()
|
self.cmdlist_browse_btn = QPushButton("Open")
|
||||||
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.load_cmdlist_btn = QPushButton("Load")
|
self.load_cmdlist_btn = QPushButton("Load")
|
||||||
cmdlist_row.addWidget(QLabel("Cmd File"))
|
cmd_row.addWidget(self.cmdlist_path_edit, 1)
|
||||||
cmdlist_row.addWidget(self.cmdlist_path_edit, 1)
|
cmd_row.addWidget(self.cmdlist_browse_btn)
|
||||||
cmdlist_row.addWidget(self.cmdlist_browse_btn)
|
cmd_row.addWidget(self.load_cmdlist_btn)
|
||||||
cmdlist_row.addWidget(self.per_cmd_delay)
|
right_layout.addLayout(cmd_row)
|
||||||
cmdlist_row.addWidget(self.per_char_delay)
|
|
||||||
cmdlist_row.addWidget(self.load_cmdlist_btn)
|
|
||||||
right_layout.addLayout(cmdlist_row)
|
|
||||||
|
|
||||||
send_row = QHBoxLayout()
|
send_row = QHBoxLayout()
|
||||||
send_row.setSpacing(6)
|
send_row.setSpacing(6)
|
||||||
self.command_input = QLineEdit()
|
self.command_input = QLineEdit()
|
||||||
self.command_input.setPlaceholderText("Enter command")
|
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")
|
self.send_button = QPushButton("Send")
|
||||||
send_row.addWidget(self.command_input, 1)
|
send_row.addWidget(self.command_input, 1)
|
||||||
send_row.addWidget(self.send_button)
|
send_row.addWidget(self.send_button)
|
||||||
right_layout.addLayout(send_row)
|
right_layout.addLayout(send_row)
|
||||||
|
|
||||||
splitter.addWidget(right_widget)
|
content_splitter.addWidget(right)
|
||||||
splitter.setStretchFactor(1, 4)
|
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.log_view.copy_selected)
|
||||||
QShortcut(QKeySequence("Ctrl+S"), self, activated=self._copy_all)
|
|
||||||
|
# -- 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:
|
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()
|
self.history_list.clear()
|
||||||
for entry in entries:
|
for entry in items:
|
||||||
QListWidgetItem(entry, self.history_list)
|
QListWidgetItem(entry, self.history_list)
|
||||||
|
if was_at_bottom:
|
||||||
def _copy_all(self) -> None:
|
self.history_list.scrollToBottom()
|
||||||
self.log_view.copy_selected()
|
|
||||||
|
|||||||
@ -6,11 +6,15 @@ from typing import Iterable, List
|
|||||||
from PySide6.QtGui import QColor, QFont, QTextCharFormat, QTextCursor, QTextOption
|
from PySide6.QtGui import QColor, QFont, QTextCharFormat, QTextCursor, QTextOption
|
||||||
from PySide6.QtWidgets import QTextEdit
|
from PySide6.QtWidgets import QTextEdit
|
||||||
|
|
||||||
|
from ameba_control_panel.config import Direction
|
||||||
from ameba_control_panel.services.log_buffer import LogLine
|
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):
|
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:
|
def __init__(self, max_items: int, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@ -23,61 +27,125 @@ class LogView(QTextEdit):
|
|||||||
font.setPointSize(10)
|
font.setPointSize(10)
|
||||||
self.setFont(font)
|
self.setFont(font)
|
||||||
self._max_items = max_items
|
self._max_items = max_items
|
||||||
self._lines = deque()
|
self._lines: deque = deque()
|
||||||
self._colors = {
|
self._colors = {
|
||||||
"rx": QColor("#1b5e20"),
|
Direction.RX: QColor("#1a8a3d"),
|
||||||
"tx": QColor("#0d47a1"),
|
Direction.TX: QColor("#2944a8"),
|
||||||
"info": QColor("#424242"),
|
Direction.INFO: QColor("#7970a9"),
|
||||||
}
|
}
|
||||||
|
self._fmt_cache = self._build_fmt_cache()
|
||||||
self._match_rows: List[int] = []
|
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:
|
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:
|
def append_lines(self, lines: Iterable[LogLine]) -> None:
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
cursor = self.textCursor()
|
cursor = self.textCursor()
|
||||||
cursor.movePosition(QTextCursor.End)
|
cursor.movePosition(QTextCursor.End)
|
||||||
|
cursor.beginEditBlock()
|
||||||
doc = self.document()
|
doc = self.document()
|
||||||
|
# Reuse format objects — avoids allocation per line
|
||||||
for line in lines:
|
for line in lines:
|
||||||
self._lines.append(line)
|
self._lines.append(line)
|
||||||
fmt = QTextCharFormat()
|
cursor.insertText(line.as_display(), self._fmt_cache[line.direction])
|
||||||
fmt.setForeground(self._colors.get(line.direction, self._colors["info"]))
|
|
||||||
cursor.insertText(line.as_display(), fmt)
|
|
||||||
cursor.insertBlock()
|
cursor.insertBlock()
|
||||||
self.setTextCursor(cursor)
|
# Batch trim — remove excess in one block
|
||||||
# Trim overflow blocks and mirror deque
|
overflow = len(self._lines) - self._max_items
|
||||||
while len(self._lines) > self._max_items and doc.blockCount() > 0:
|
if overflow > 0:
|
||||||
self._lines.popleft()
|
for _ in range(overflow):
|
||||||
block = doc.firstBlock()
|
self._lines.popleft()
|
||||||
cur = QTextCursor(block)
|
block = doc.firstBlock()
|
||||||
cur.select(QTextCursor.BlockUnderCursor)
|
cur = QTextCursor(block)
|
||||||
cur.removeSelectedText()
|
cur.select(QTextCursor.BlockUnderCursor)
|
||||||
cur.deleteChar()
|
cur.removeSelectedText()
|
||||||
self._apply_matches()
|
cur.deleteChar()
|
||||||
|
if self._match_rows:
|
||||||
|
self._match_rows = []
|
||||||
|
self._focus_idx = -1
|
||||||
|
self.setExtraSelections([])
|
||||||
|
cursor.endEditBlock()
|
||||||
self.verticalScrollBar().setValue(self.verticalScrollBar().maximum())
|
self.verticalScrollBar().setValue(self.verticalScrollBar().maximum())
|
||||||
|
|
||||||
def clear_log(self) -> None:
|
def clear_log(self) -> None:
|
||||||
self._lines.clear()
|
self._lines.clear()
|
||||||
self.clear()
|
self.clear()
|
||||||
self._match_rows = []
|
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._match_rows = rows
|
||||||
|
self._focus_idx = focus_idx
|
||||||
self._apply_matches()
|
self._apply_matches()
|
||||||
|
|
||||||
def _apply_matches(self) -> None:
|
def _apply_matches(self) -> None:
|
||||||
extra = []
|
extra: list = []
|
||||||
doc = self.document()
|
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)
|
block = doc.findBlockByNumber(row)
|
||||||
if not block.isValid():
|
if not block.isValid():
|
||||||
continue
|
continue
|
||||||
cursor = QTextCursor(block)
|
is_focus = (i == self._focus_idx)
|
||||||
sel = QTextEdit.ExtraSelection()
|
bg = _FOCUS_BG if is_focus else _HIGHLIGHT_BG
|
||||||
sel.cursor = cursor
|
|
||||||
sel.format.setBackground(QColor("#fff59d"))
|
if needle:
|
||||||
extra.append(sel)
|
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)
|
self.setExtraSelections(extra)
|
||||||
|
|
||||||
|
def displayed_lines(self) -> list:
|
||||||
|
return list(self._lines)
|
||||||
|
|
||||||
def copy_selected(self) -> None:
|
def copy_selected(self) -> None:
|
||||||
self.copy()
|
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:
|
def _bootstrap_path() -> None:
|
||||||
root = Path(__file__).resolve().parent.parent
|
root = Path(__file__).resolve().parent.parent
|
||||||
if getattr(sys, "frozen", False):
|
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]
|
root = Path(sys._MEIPASS) if hasattr(sys, "_MEIPASS") else root # type: ignore[attr-defined]
|
||||||
if str(root) not in sys.path:
|
if str(root) not in sys.path:
|
||||||
sys.path.insert(0, str(root))
|
sys.path.insert(0, str(root))
|
||||||
@ -17,4 +16,20 @@ from ameba_control_panel.app import main # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@ -6,16 +7,84 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def _add_data_arg(src: Path, dest: str) -> str:
|
def _add_data(src: Path, dest: str) -> str:
|
||||||
"""
|
|
||||||
Format a PyInstaller --add-data argument with the correct path separator
|
|
||||||
for the current platform.
|
|
||||||
"""
|
|
||||||
sep = ";" if os.name == "nt" else ":"
|
sep = ";" if os.name == "nt" else ":"
|
||||||
return f"{src}{sep}{dest}"
|
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
|
root = Path(__file__).resolve().parent.parent
|
||||||
entry = root / "script" / "auto_run.py"
|
entry = root / "script" / "auto_run.py"
|
||||||
flash_dir = root / "Flash"
|
flash_dir = root / "Flash"
|
||||||
@ -25,42 +94,62 @@ def build(onefile: bool) -> None:
|
|||||||
if not flash_dir.exists():
|
if not flash_dir.exists():
|
||||||
sys.exit(f"Flash folder missing: {flash_dir}")
|
sys.exit(f"Flash folder missing: {flash_dir}")
|
||||||
|
|
||||||
# Keep PyInstaller searches predictable.
|
|
||||||
os.chdir(root)
|
os.chdir(root)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import PyInstaller.__main__ as pyinstaller
|
import PyInstaller.__main__ as pyinstaller
|
||||||
except ImportError:
|
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 = [
|
args = [
|
||||||
"--noconfirm",
|
"--noconfirm",
|
||||||
"--clean",
|
"--clean",
|
||||||
|
"--windowed",
|
||||||
"--onefile" if onefile else "--onedir",
|
"--onefile" if onefile else "--onedir",
|
||||||
"--name=AmebaControlPanel",
|
f"--name=AmebaControlPanel",
|
||||||
f"--distpath={root / 'dist'}",
|
f"--distpath={root / 'dist'}",
|
||||||
f"--workpath={root / 'build'}",
|
f"--workpath={root / 'build'}",
|
||||||
"--paths",
|
f"--specpath={root}",
|
||||||
str(root),
|
f"--version-file={version_file}",
|
||||||
"--collect-all",
|
"--paths", str(root),
|
||||||
"PySide6",
|
"--collect-all", "PySide6",
|
||||||
"--hidden-import=serial",
|
"--hidden-import=serial",
|
||||||
"--hidden-import=serial.tools.list_ports",
|
"--hidden-import=serial.tools.list_ports",
|
||||||
"--hidden-import=pyDes",
|
"--hidden-import=pyDes",
|
||||||
"--hidden-import=colorama",
|
"--hidden-import=colorama",
|
||||||
"--add-data",
|
"--add-data", _add_data(flash_dir, "Flash"),
|
||||||
_add_data_arg(flash_dir, "Flash"),
|
|
||||||
str(entry),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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)
|
pyinstaller.run(args)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="Build Ameba Control Panel executable with PyInstaller")
|
parser = argparse.ArgumentParser(description="Build Ameba Control Panel EXE")
|
||||||
parser.add_argument(
|
parser.add_argument("--onefile", action="store_true", help="Create single-file exe (slower startup)")
|
||||||
"--onedir",
|
parser.add_argument("--icon", help="Path to .ico file")
|
||||||
action="store_true",
|
parser.add_argument("--splash", help="Path to splash screen image (.png)")
|
||||||
help="Create an onedir bundle instead of a single-file exe",
|
opts = parser.parse_args()
|
||||||
)
|
# Default to --onedir for fast startup
|
||||||
build(onefile=not parser.parse_args().onedir)
|
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