666 lines
30 KiB
Python
666 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
DAPLink Flash 主界面 — CustomTkinter 双栏仪表盘
|
|
================================================================
|
|
布局:
|
|
左列 探针与工具链 | 芯片与固件 | 操作(检测/烧录/擦除/读取/复位/停止)
|
|
右列 补齐 Pad(Y-Modem 对齐) | 拼接 Merge(BL+APP+标志) | 运行日志
|
|
默认跟随系统深/浅色外观, 顶栏可切换 Dark/Light/System。
|
|
|
|
本模块只管界面: OpenOCD 子进程与命令构建在 openocd_runner,
|
|
固件补齐/拼接在 fw_image, 运行配置在 config。
|
|
"""
|
|
|
|
import ctypes
|
|
import os
|
|
import queue
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import tkinter as tk
|
|
from tkinter import filedialog, messagebox
|
|
|
|
import customtkinter as ctk
|
|
|
|
import config
|
|
import openocd_runner
|
|
from fw_image import (CHIP_PROFILES, FLAG_VALUE_DEFAULT, FLASH_BASE_DEFAULT,
|
|
merge_firmware, pad_bin, parse_int)
|
|
|
|
MONO = ('Consolas', 12)
|
|
BOLD = ('Microsoft YaHei UI', 13, 'bold')
|
|
TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold')
|
|
|
|
APP_NAME = 'DAPLink Flash'
|
|
APP_VERSION = '2.0.1'
|
|
REPO_URL = 'https://gitea.hulk.wang/hulk/gd32e230f8_firmware_merge_tool'
|
|
|
|
OK_BLUE = '#3B8ED0'
|
|
|
|
|
|
def get_icon_path():
|
|
"""工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)"""
|
|
if getattr(sys, 'frozen', False):
|
|
p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_flash.ico')
|
|
else:
|
|
p = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
'logos', 'logo_flash.ico')
|
|
return p if os.path.exists(p) else None
|
|
|
|
|
|
def _apply_dropdown_theme():
|
|
"""CTk 默认下拉菜单是白底, 在深色界面里风格突兀; 改为随外观自适应。
|
|
在创建任何控件前调用, 对全部 CTkComboBox/菜单生效。"""
|
|
ctk.ThemeManager.theme['DropdownMenu']['fg_color'] = ('#F2F2F2', '#2B2B2B')
|
|
ctk.ThemeManager.theme['DropdownMenu']['hover_color'] = ('#D8D8D8', '#3C3C3C')
|
|
ctk.ThemeManager.theme['DropdownMenu']['text_color'] = ('#1A1A1A', '#DADADA')
|
|
|
|
|
|
def run_gui(auto_close=0):
|
|
"""入口: 配置外观并启动主窗口"""
|
|
ctk.set_appearance_mode('System')
|
|
_apply_dropdown_theme()
|
|
App(auto_close).mainloop()
|
|
|
|
|
|
class App(ctk.CTk):
|
|
def __init__(self, auto_close=0):
|
|
super().__init__()
|
|
self.title(f'{APP_NAME} v{APP_VERSION} — GD32 固件烧录 · 补齐 · 镜像拼接')
|
|
try: # 任务栏独立分组
|
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
|
|
'hulk.DAPLinkFlash')
|
|
except Exception:
|
|
pass
|
|
self.geometry('1360x880')
|
|
self.minsize(1200, 780)
|
|
ic = get_icon_path()
|
|
if ic:
|
|
try:
|
|
self.iconbitmap(ic) # 窗口标题栏图标
|
|
except Exception:
|
|
pass
|
|
|
|
self.cfg = config.load_settings()
|
|
self._toolchain_source = '已配置'
|
|
if not os.path.isfile(self.cfg['openocd']):
|
|
# 未配置或配置指向的路径不存在 → 自动定位(内置/本机/PATH)
|
|
exe, scripts, self._toolchain_source = openocd_runner.locate_toolchain()
|
|
self.cfg['openocd'], self.cfg['scripts'] = exe, scripts
|
|
self.v = {}
|
|
for key, val in self.cfg.items():
|
|
self.v[key] = tk.BooleanVar(value=val) if isinstance(val, bool) \
|
|
else tk.StringVar(value=val)
|
|
for key in ('openocd', 'scripts'):
|
|
self.v[key].trace_add('write', lambda *_a: self._mark_custom_toolchain())
|
|
|
|
self._log_q = queue.Queue()
|
|
self.proc = None
|
|
self._poll_job = None
|
|
self._op_buttons = []
|
|
|
|
self._build()
|
|
self._log(f'{APP_NAME} v{APP_VERSION} — OpenOCD 烧录 · 固件补齐 · 镜像拼接')
|
|
ver = openocd_runner.version(self.cfg['openocd'])
|
|
self._log('工具链: %s (%s)%s' % (self.cfg['openocd'], self._toolchain_source,
|
|
' ' + ver if ver else ''))
|
|
self._poll()
|
|
self.protocol('WM_DELETE_WINDOW', self._on_close)
|
|
if auto_close > 0:
|
|
self.after(auto_close * 1000, self._on_close)
|
|
|
|
# ============================================================
|
|
# 布局
|
|
# ============================================================
|
|
def _set_appearance(self, mode):
|
|
ctk.set_appearance_mode(mode)
|
|
|
|
def _card(self, parent, title, button=None, command=None, right_label=None):
|
|
"""卡片容器: 圆角边框 + 标题行(可带右侧小按钮/右对齐标签)。
|
|
卡片自身用 grid 参与列布局; 卡片内部一律用 pack。"""
|
|
card = ctk.CTkFrame(parent, corner_radius=10, border_width=1,
|
|
border_color=('gray70', 'gray30'))
|
|
head = ctk.CTkFrame(card, fg_color='transparent')
|
|
head.pack(fill='x', padx=12, pady=(8, 0))
|
|
ctk.CTkLabel(head, text=title, font=BOLD, anchor='w').pack(side='left', fill='y')
|
|
if right_label is not None:
|
|
ctk.CTkLabel(head, textvariable=right_label,
|
|
text_color='gray60').pack(side='right', fill='y')
|
|
if button:
|
|
ctk.CTkButton(head, text=button, width=72,
|
|
command=command).pack(side='right', fill='y')
|
|
content = ctk.CTkFrame(card, fg_color='transparent')
|
|
content.pack(fill='both', expand=True, padx=12, pady=(2, 10))
|
|
return card, content
|
|
|
|
def _build(self):
|
|
top = ctk.CTkFrame(self, fg_color='transparent')
|
|
top.pack(fill='x', padx=12, pady=(10, 2))
|
|
ctk.CTkLabel(top, text=APP_NAME, font=TITLE_FONT).pack(side='left')
|
|
ctk.CTkLabel(top, text='GD32 固件烧录 · 补齐 · 镜像拼接',
|
|
text_color='gray60').pack(side='left', padx=10, pady=(6, 0))
|
|
seg = ctk.CTkSegmentedButton(top, values=['Dark', 'Light', 'System'],
|
|
command=self._set_appearance)
|
|
seg.set('System')
|
|
seg.pack(side='right')
|
|
ctk.CTkButton(top, text='关于', width=48, height=24,
|
|
fg_color='transparent', border_width=1,
|
|
border_color=('gray55', 'gray35'),
|
|
text_color='gray60', hover_color=('gray82', 'gray22'),
|
|
command=self._show_about).pack(side='right', padx=(0, 12))
|
|
|
|
body = ctk.CTkFrame(self, fg_color='transparent')
|
|
body.pack(fill='both', expand=True, padx=12, pady=(4, 12))
|
|
body.grid_columnconfigure(0, weight=1, uniform='col')
|
|
body.grid_columnconfigure(1, weight=1, uniform='col')
|
|
body.grid_rowconfigure(0, weight=1)
|
|
left = ctk.CTkFrame(body, fg_color='transparent')
|
|
left.grid(row=0, column=0, sticky='nsew')
|
|
right = ctk.CTkFrame(body, fg_color='transparent')
|
|
right.grid(row=0, column=1, sticky='nsew', padx=(10, 0))
|
|
self._build_left(left)
|
|
self._build_right(right)
|
|
|
|
status = ctk.CTkFrame(self, fg_color='transparent')
|
|
status.pack(fill='x', padx=14, pady=(0, 8))
|
|
self.v_status = tk.StringVar(value='状态: 就绪')
|
|
ctk.CTkLabel(status, textvariable=self.v_status, anchor='w').pack(side='left')
|
|
ctk.CTkLabel(status, text=f'v{APP_VERSION}',
|
|
text_color='gray60').pack(side='right')
|
|
|
|
# -- 左列: 探针与工具链 / 芯片与固件 / 操作 --
|
|
def _build_left(self, col):
|
|
col.grid_rowconfigure(2, weight=1) # 操作卡片吸底
|
|
col.grid_columnconfigure(0, weight=1)
|
|
|
|
self.v_toolchain = tk.StringVar(
|
|
value='工具链: ' + self._toolchain_source)
|
|
card, c = self._card(col, '探针与工具链', button='自动定位',
|
|
command=self._relocate_toolchain,
|
|
right_label=self.v_toolchain)
|
|
card.grid(row=0, column=0, sticky='ew')
|
|
self._field(c, 'OpenOCD 路径', self.v['openocd'], self._browse_openocd)
|
|
self._field(c, '脚本目录', self.v['scripts'], self._browse_scripts)
|
|
probe, _w = self._field_pair(
|
|
c, '烧录器', self.v['probe'], 'Interface', self.v['interface'],
|
|
cls1=(ctk.CTkComboBox, {'state': 'readonly',
|
|
'values': list(openocd_runner.PROBE_PROFILES)}),
|
|
cls2=(ctk.CTkComboBox,
|
|
{'values': sorted(set(openocd_runner.PROBE_PROFILES.values()) - {''})}))
|
|
probe.configure(command=lambda _=None: self._on_probe_change())
|
|
self._field_pair(c, '速度 (kHz)', self.v['speed'], '探针序列号',
|
|
self.v['serial'], width2=150)
|
|
self._field_pair(
|
|
c, 'Target', self.v['target'], '额外命令', self.v['extra'],
|
|
cls1=(ctk.CTkComboBox, {'values': ['target/gd32e23x.cfg']}))
|
|
|
|
card, c = self._card(col, '芯片与固件')
|
|
card.grid(row=1, column=0, sticky='ew', pady=(10, 0))
|
|
chip, _flash = self._field_pair(
|
|
c, '芯片型号', self.v['chip'], 'Flash 大小', self.v['flash_size'],
|
|
cls1=(ctk.CTkComboBox, {'state': 'readonly',
|
|
'values': list(CHIP_PROFILES)}),
|
|
width2=100)
|
|
chip.configure(command=lambda _=None: self._on_chip_change())
|
|
_flash.configure(state='disabled')
|
|
self._field(c, '固件文件', self.v['firmware'], self._browse_firmware)
|
|
self._field(c, '基地址 (仅 BIN)', self.v['base'])
|
|
ctk.CTkLabel(c, text='HEX/ELF 自动使用文件内地址; 完整镜像 0x08000000, '
|
|
'单独烧 APP 0x08002000',
|
|
text_color='gray50', justify='left', anchor='w',
|
|
).pack(fill='x', pady=(2, 0))
|
|
opts = ctk.CTkFrame(c, fg_color='transparent')
|
|
opts.pack(fill='x', pady=(6, 0))
|
|
for key, name in (('erase', '烧录前擦除'), ('verify', '烧录后校验'),
|
|
('reset', '烧录后复位运行')):
|
|
ctk.CTkCheckBox(opts, text=name, variable=self.v[key],
|
|
checkbox_width=18, checkbox_height=18).pack(
|
|
side='left', padx=(0, 14))
|
|
|
|
card, c = self._card(col, '操作')
|
|
card.grid(row=2, column=0, sticky='sew', pady=(10, 0))
|
|
grid = ctk.CTkFrame(c, fg_color='transparent')
|
|
grid.pack(fill='x')
|
|
grid.grid_columnconfigure((0, 1, 2), weight=1, uniform='op')
|
|
for i, (name, cmd) in enumerate((
|
|
('检测设备', self._on_detect), ('烧录固件', self._on_flash),
|
|
('整片擦除', self._on_erase), ('读取 Flash', self._on_read),
|
|
('复位芯片', self._on_reset))):
|
|
btn = ctk.CTkButton(grid, text=name, height=34, command=cmd)
|
|
btn.grid(row=i // 3, column=i % 3, sticky='ew', padx=3, pady=3)
|
|
self._op_buttons.append(btn)
|
|
self.btn_stop = ctk.CTkButton(grid, text='停止', height=34,
|
|
fg_color='gray40', hover_color='gray25',
|
|
command=self._on_stop, state='disabled')
|
|
self.btn_stop.grid(row=1, column=2, sticky='ew', padx=3, pady=3)
|
|
|
|
# -- 右列: 补齐 / 拼接 / 日志 --
|
|
def _build_right(self, col):
|
|
col.grid_rowconfigure(2, weight=1) # 日志卡片吃掉剩余高度
|
|
col.grid_columnconfigure(0, weight=1)
|
|
|
|
card, c = self._card(col, '补齐 (Pad) — 尾部补 0xFF 到页边界, '
|
|
'适配 Bootloader Y-Modem 整包接收')
|
|
card.grid(row=0, column=0, sticky='ew')
|
|
self._field(c, '输入 BIN', self.v['pad_in'], self._browse_pad_in)
|
|
self._field(c, '输出 BIN', self.v['pad_out'], self._browse_pad_out)
|
|
row = ctk.CTkFrame(c, fg_color='transparent')
|
|
row.pack(fill='x', pady=(6, 0))
|
|
ctk.CTkLabel(row, text='对齐:', width=40, anchor='e').pack(side='left')
|
|
ctk.CTkComboBox(row, width=86, state='readonly',
|
|
values=['1024', '2048', '4096'],
|
|
variable=self.v['pad_align']).pack(side='left', padx=(4, 12))
|
|
ctk.CTkLabel(row, text='填充字节:', width=64, anchor='e').pack(side='left')
|
|
ctk.CTkEntry(row, width=76, textvariable=self.v['pad_byte']
|
|
).pack(side='left', padx=(4, 12))
|
|
ctk.CTkButton(row, text='执行补齐', width=96,
|
|
command=self._on_pad).pack(side='left')
|
|
self.v_pad_result = tk.StringVar(value='')
|
|
ctk.CTkLabel(c, textvariable=self.v_pad_result, text_color=OK_BLUE,
|
|
justify='left', anchor='w').pack(fill='x', pady=(4, 0))
|
|
|
|
card, c = self._card(col, '拼接 (Merge) — Bootloader + APP + 有效标志 → 整片镜像')
|
|
card.grid(row=1, column=0, sticky='ew', pady=(10, 0))
|
|
self._field(c, 'Bootloader', self.v['bootloader'], self._browse_bl)
|
|
self._field(c, 'Application', self.v['app'], self._browse_app)
|
|
self._field(c, '输出文件', self.v['merge_out'], self._browse_merge_out)
|
|
row = ctk.CTkFrame(c, fg_color='transparent')
|
|
row.pack(fill='x', pady=(6, 0))
|
|
for label, key, w in (('APP 地址', 'app_addr', 104),
|
|
('标志地址', 'flag_addr', 104),
|
|
('Flash', 'merge_flash', 84),
|
|
('标志值', 'flag_value', 104)):
|
|
ctk.CTkLabel(row, text=label + ':', anchor='e').pack(side='left')
|
|
ctk.CTkEntry(row, width=w, textvariable=self.v[key]
|
|
).pack(side='left', padx=(4, 10))
|
|
ctk.CTkButton(row, text='执行拼接', width=96,
|
|
command=self._on_merge).pack(side='left')
|
|
self.v_merge_result = tk.StringVar(value='')
|
|
ctk.CTkLabel(c, textvariable=self.v_merge_result, text_color=OK_BLUE,
|
|
justify='left', anchor='w').pack(fill='x', pady=(4, 0))
|
|
ctk.CTkLabel(c, text='典型布局: 0x08000000 Bootloader 8KB | 0x08002000 APP | '
|
|
'0x0800FFFC 有效标志 0xEEEEEEEE | Flash 64KB',
|
|
text_color='gray50', justify='left', anchor='w',
|
|
).pack(fill='x', pady=(2, 0))
|
|
|
|
card, c = self._card(col, '运行日志 — OpenOCD 输出与固件工具结果',
|
|
button='清空', command=self._clear_log)
|
|
card.grid(row=2, column=0, sticky='nsew', pady=(10, 0))
|
|
self.txt_log = ctk.CTkTextbox(c, font=MONO)
|
|
self.txt_log.pack(fill='both', expand=True)
|
|
self.txt_log.configure(state='disabled')
|
|
|
|
# ============================================================
|
|
# 输入行辅助
|
|
# ============================================================
|
|
@staticmethod
|
|
def _mk_widget(parent, cls_info, var):
|
|
"""按控件类绑定变量: CTkComboBox 用 variable, 其余用 textvariable"""
|
|
cls, kw = cls_info
|
|
if issubclass(cls, ctk.CTkComboBox):
|
|
return cls(parent, variable=var, **kw)
|
|
return cls(parent, textvariable=var, **kw)
|
|
|
|
def _field(self, parent, label, var, browse=None):
|
|
"""一行: 标签 + 输入框(+ 浏览按钮), pack 布局"""
|
|
row = ctk.CTkFrame(parent, fg_color='transparent')
|
|
row.pack(fill='x', pady=(4, 0))
|
|
ctk.CTkLabel(row, text=label, width=150, anchor='w').pack(side='left')
|
|
ctk.CTkEntry(row, textvariable=var).pack(side='left', fill='x', expand=True)
|
|
if browse:
|
|
ctk.CTkButton(row, text='浏览', width=56,
|
|
command=browse).pack(side='left', padx=(6, 0))
|
|
return row
|
|
|
|
def _field_pair(self, parent, label1, var1, label2, var2,
|
|
cls1=(ctk.CTkEntry, {}), cls2=(ctk.CTkEntry, {}),
|
|
width2=None):
|
|
"""一行放两个字段: label1[控件1] label2[控件2(可定宽)]"""
|
|
row = ctk.CTkFrame(parent, fg_color='transparent')
|
|
row.pack(fill='x', pady=(4, 0))
|
|
ctk.CTkLabel(row, text=label1, width=150, anchor='w').pack(side='left')
|
|
w1 = self._mk_widget(row, cls1, var1)
|
|
w1.pack(side='left', fill='x', expand=True)
|
|
ctk.CTkLabel(row, text=label2, width=90, anchor='e').pack(side='left')
|
|
if width2:
|
|
cls2 = (cls2[0], {**cls2[1], 'width': width2})
|
|
w2 = self._mk_widget(row, cls2, var2)
|
|
w2.pack(side='left', padx=(4, 0))
|
|
return w1, w2
|
|
|
|
# ============================================================
|
|
# 路径选择与联动
|
|
# ============================================================
|
|
def _browse_openocd(self):
|
|
path = filedialog.askopenfilename(
|
|
title='选择 OpenOCD', filetypes=[('OpenOCD', 'openocd.exe'), ('所有文件', '*.*')])
|
|
if path:
|
|
self.v['openocd'].set(path)
|
|
|
|
def _browse_scripts(self):
|
|
path = filedialog.askdirectory(title='选择 OpenOCD scripts 目录')
|
|
if path:
|
|
self.v['scripts'].set(path)
|
|
|
|
def _relocate_toolchain(self):
|
|
exe, scripts, source = openocd_runner.locate_toolchain()
|
|
self.v['openocd'].set(exe)
|
|
self.v['scripts'].set(scripts)
|
|
self._toolchain_source = source
|
|
self.v_toolchain.set('工具链: ' + source)
|
|
ver = openocd_runner.version(exe)
|
|
self._log('工具链定位: %s (%s)%s' % (exe, source, ' ' + ver if ver else ''))
|
|
|
|
def _mark_custom_toolchain(self, *_a):
|
|
if self._toolchain_source != '自定义':
|
|
self._toolchain_source = '自定义'
|
|
self.v_toolchain.set('工具链: 自定义')
|
|
|
|
def _browse_firmware(self):
|
|
path = filedialog.askopenfilename(
|
|
title='选择固件',
|
|
filetypes=[('固件文件', '*.bin *.hex *.elf'), ('所有文件', '*.*')])
|
|
if path:
|
|
self.v['firmware'].set(path)
|
|
|
|
def _browse_pad_in(self):
|
|
path = filedialog.askopenfilename(
|
|
title='选择需要补齐的 BIN', filetypes=[('BIN 文件', '*.bin'), ('所有文件', '*.*')])
|
|
if path:
|
|
self.v['pad_in'].set(path)
|
|
name, ext = os.path.splitext(path)
|
|
self.v['pad_out'].set(name + '_pad' + ext)
|
|
|
|
def _browse_pad_out(self):
|
|
path = filedialog.asksaveasfilename(
|
|
title='保存补齐后的 BIN', defaultextension='.bin',
|
|
filetypes=[('BIN 文件', '*.bin')])
|
|
if path:
|
|
self.v['pad_out'].set(path)
|
|
|
|
def _browse_bl(self):
|
|
path = filedialog.askopenfilename(
|
|
title='选择 Bootloader BIN', filetypes=[('BIN 文件', '*.bin'), ('所有文件', '*.*')])
|
|
if path:
|
|
self.v['bootloader'].set(path)
|
|
self._suggest_merge_output()
|
|
|
|
def _browse_app(self):
|
|
path = filedialog.askopenfilename(
|
|
title='选择 APP BIN', filetypes=[('BIN 文件', '*.bin'), ('所有文件', '*.*')])
|
|
if path:
|
|
self.v['app'].set(path)
|
|
self._suggest_merge_output()
|
|
|
|
def _suggest_merge_output(self):
|
|
if self.v['app'].get():
|
|
name, ext = os.path.splitext(os.path.basename(self.v['app'].get()))
|
|
self.v['merge_out'].set(
|
|
os.path.join(os.path.dirname(self.v['app'].get()), name + '_BL' + ext))
|
|
|
|
def _browse_merge_out(self):
|
|
path = filedialog.asksaveasfilename(
|
|
title='保存拼接后的镜像', defaultextension='.bin',
|
|
filetypes=[('BIN 文件', '*.bin')])
|
|
if path:
|
|
self.v['merge_out'].set(path)
|
|
|
|
def _on_chip_change(self, *_a):
|
|
self.v['flash_size'].set(openocd_runner.chip_flash_size(self.v['chip'].get()))
|
|
self.v['target'].set(openocd_runner.chip_target(self.v['chip'].get()))
|
|
|
|
def _on_probe_change(self, *_a):
|
|
cfg = openocd_runner.PROBE_PROFILES.get(self.v['probe'].get(), '')
|
|
if cfg:
|
|
self.v['interface'].set(cfg)
|
|
|
|
# ============================================================
|
|
# OpenOCD 烧录操作
|
|
# ============================================================
|
|
def _check_tools(self):
|
|
if not os.path.isfile(self.v['openocd'].get()):
|
|
messagebox.showerror('缺少工具',
|
|
'找不到 OpenOCD:\n%s\n\n'
|
|
'可点"自动定位"使用内置/本机工具链。'
|
|
% self.v['openocd'].get())
|
|
return False
|
|
if not os.path.isdir(self.v['scripts'].get()):
|
|
messagebox.showerror('缺少工具',
|
|
'找不到 OpenOCD scripts 目录:\n%s' % self.v['scripts'].get())
|
|
return False
|
|
return True
|
|
|
|
def _build_cmd(self, script):
|
|
return openocd_runner.build_cmd(
|
|
self.v['openocd'].get(), self.v['scripts'].get(),
|
|
self.v['interface'].get(), self.v['target'].get(),
|
|
self.v['speed'].get(), self.v['serial'].get(),
|
|
self.v['extra'].get(), script)
|
|
|
|
def _start(self, title, script):
|
|
if self.proc is not None:
|
|
self._log('已有操作正在执行, 请先停止。')
|
|
return
|
|
if not self._check_tools():
|
|
return
|
|
cmd = self._build_cmd(script)
|
|
self._busy(True)
|
|
self._log('== %s ==' % title)
|
|
self._log('命令: ' + subprocess.list2cmdline(cmd))
|
|
self.proc = openocd_runner.OcdProcess(
|
|
cmd,
|
|
on_line=lambda line: self._log_q.put(('log', line)),
|
|
on_done=lambda code: self._log_q.put(('done', (title, code))))
|
|
self.proc.start()
|
|
|
|
def _on_detect(self):
|
|
self._start('检测设备', openocd_runner.script_detect())
|
|
|
|
def _on_erase(self):
|
|
try:
|
|
self._start('整片擦除', openocd_runner.script_erase(self.v['flash_size'].get()))
|
|
except ValueError as exc:
|
|
messagebox.showerror('参数错误', str(exc))
|
|
|
|
def _on_flash(self):
|
|
filepath = self.v['firmware'].get().strip()
|
|
if not filepath or not os.path.isfile(filepath):
|
|
messagebox.showerror('缺少固件', '请先选择固件文件。')
|
|
return
|
|
try:
|
|
script = openocd_runner.script_flash(
|
|
filepath, self.v['base'].get(),
|
|
erase=self.v['erase'].get(), verify=self.v['verify'].get(),
|
|
reset=self.v['reset'].get())
|
|
except ValueError as exc:
|
|
messagebox.showerror('参数错误', str(exc))
|
|
return
|
|
self._start('烧录固件', script)
|
|
|
|
def _on_read(self):
|
|
out_path = filedialog.asksaveasfilename(
|
|
title='保存 Flash 内容', defaultextension='.bin',
|
|
initialfile='flash_dump.bin', filetypes=[('BIN 文件', '*.bin')])
|
|
if not out_path:
|
|
return
|
|
try:
|
|
self._start('读取 Flash',
|
|
openocd_runner.script_read(out_path, self.v['flash_size'].get()))
|
|
except ValueError as exc:
|
|
messagebox.showerror('参数错误', str(exc))
|
|
|
|
def _on_reset(self):
|
|
self._start('复位芯片', openocd_runner.script_reset())
|
|
|
|
def _on_stop(self):
|
|
if self.proc is not None:
|
|
self.proc.terminate()
|
|
self._log('已发送停止请求。')
|
|
else:
|
|
self._log('当前没有正在执行的操作。')
|
|
|
|
def _poll(self):
|
|
"""工作线程 → GUI 主线程: 80ms 轮询日志队列并同步忙碌状态"""
|
|
try:
|
|
while True:
|
|
kind, payload = self._log_q.get_nowait()
|
|
if kind == 'log':
|
|
self._log(payload)
|
|
elif kind == 'done':
|
|
title, code = payload
|
|
if code == 0:
|
|
self._log('[完成] %s' % title)
|
|
self._set_status('%s 完成' % title)
|
|
else:
|
|
self._log('[失败] %s (退出码 %d)' % (title, code))
|
|
self._set_status('%s 失败' % title)
|
|
self.proc = None # 释放占用, 允许下一次操作
|
|
self._busy(False)
|
|
self._save()
|
|
except queue.Empty:
|
|
pass
|
|
self._poll_job = self.after(80, self._poll)
|
|
|
|
def _busy(self, busy):
|
|
state = 'disabled' if busy else 'normal'
|
|
for btn in self._op_buttons:
|
|
btn.configure(state=state)
|
|
self.btn_stop.configure(state='normal' if busy else 'disabled')
|
|
if busy:
|
|
self._set_status('正在执行...')
|
|
else:
|
|
self._set_status('就绪')
|
|
|
|
# ============================================================
|
|
# 固件工具: 补齐 / 拼接
|
|
# ============================================================
|
|
def _on_pad(self):
|
|
src = self.v['pad_in'].get().strip()
|
|
dst = self.v['pad_out'].get().strip()
|
|
align = parse_int(self.v['pad_align'].get())
|
|
pad_byte = parse_int(self.v['pad_byte'].get())
|
|
if not src or not os.path.isfile(src):
|
|
messagebox.showerror('缺少文件', '请选择需要补齐的 BIN。')
|
|
return
|
|
if not dst:
|
|
messagebox.showerror('缺少文件', '请设置输出文件。')
|
|
return
|
|
if align is None or align <= 0:
|
|
messagebox.showerror('参数错误', '对齐字节无效。')
|
|
return
|
|
if pad_byte is None:
|
|
pad_byte = 0xFF
|
|
try:
|
|
original = os.path.getsize(src)
|
|
padded = pad_bin(src, dst, align, pad_byte)
|
|
self.v_pad_result.set(
|
|
'原始 %d B → 补齐后 %d B (+%d), 对齐 %d B'
|
|
% (original, padded, padded - original, align))
|
|
self._log('补齐完成: %s → %s' % (src, dst))
|
|
self._set_status('补齐完成')
|
|
self._save()
|
|
except Exception as exc:
|
|
messagebox.showerror('补齐失败', str(exc))
|
|
self._log('补齐失败: %s' % exc)
|
|
|
|
def _on_merge(self):
|
|
bl = self.v['bootloader'].get().strip()
|
|
app = self.v['app'].get().strip()
|
|
out = self.v['merge_out'].get().strip()
|
|
app_addr = parse_int(self.v['app_addr'].get())
|
|
flag_addr = parse_int(self.v['flag_addr'].get())
|
|
flash_size = parse_int(self.v['merge_flash'].get())
|
|
flag_value = parse_int(self.v['flag_value'].get())
|
|
if not bl or not os.path.isfile(bl):
|
|
messagebox.showerror('缺少文件', '请选择 Bootloader BIN。')
|
|
return
|
|
if not app or not os.path.isfile(app):
|
|
messagebox.showerror('缺少文件', '请选择 APP BIN。')
|
|
return
|
|
if not out:
|
|
messagebox.showerror('缺少文件', '请设置输出文件。')
|
|
return
|
|
if app_addr is None or flag_addr is None or flash_size is None:
|
|
messagebox.showerror('参数错误', '地址或 Flash 大小无效。')
|
|
return
|
|
if flag_value is None:
|
|
flag_value = FLAG_VALUE_DEFAULT
|
|
try:
|
|
info = merge_firmware(bl, app, out, app_addr, flag_addr,
|
|
flash_size, flag_value)
|
|
self.v_merge_result.set(
|
|
'BL %d B @0x%08X | APP %d B @0x%08X | 镜像 %d B | 标志 @0x%08X'
|
|
% (info['bootloader_size'], FLASH_BASE_DEFAULT, info['app_size'],
|
|
info['app_addr'], info['output_size'], info['flag_addr']))
|
|
self._log('拼接完成: %s' % out)
|
|
self._set_status('拼接完成')
|
|
self._save()
|
|
except Exception as exc:
|
|
messagebox.showerror('拼接失败', str(exc))
|
|
self._log('拼接失败: %s' % exc)
|
|
|
|
# ============================================================
|
|
# 日志 / 状态 / 配置 / 退出
|
|
# ============================================================
|
|
def _log(self, line):
|
|
self.txt_log.configure(state='normal')
|
|
self.txt_log.insert('end', time_stamp() + line + '\n')
|
|
if int(self.txt_log.index('end-1c').split('.')[0]) > 800:
|
|
self.txt_log.delete('1.0', '2.0') # 只保留最近约 800 行
|
|
self.txt_log.see('end')
|
|
self.txt_log.configure(state='disabled')
|
|
|
|
def _clear_log(self):
|
|
self.txt_log.configure(state='normal')
|
|
self.txt_log.delete('1.0', 'end')
|
|
self.txt_log.configure(state='disabled')
|
|
|
|
def _set_status(self, text):
|
|
self.v_status.set('状态: ' + text)
|
|
|
|
def _save(self):
|
|
for key, var in self.v.items():
|
|
self.cfg[key] = var.get()
|
|
self.cfg['openocd'] = self.v['openocd'].get()
|
|
config.save_settings(self.cfg)
|
|
|
|
def _show_about(self):
|
|
dlg = ctk.CTkToplevel(self)
|
|
dlg.title(f'关于 {APP_NAME}')
|
|
dlg.resizable(False, False)
|
|
dlg.after(150, dlg.grab_set())
|
|
box = ctk.CTkFrame(dlg, fg_color='transparent')
|
|
box.pack(padx=26, pady=20)
|
|
ctk.CTkLabel(box, text=APP_NAME, font=TITLE_FONT, anchor='w').pack(anchor='w')
|
|
ctk.CTkLabel(box, text=f'v{APP_VERSION} — GD32E230 固件烧录与打包工具',
|
|
text_color='gray60', anchor='w').pack(anchor='w', pady=(0, 12))
|
|
for line in ('OpenOCD 烧录: 检测 / 烧录 / 擦除 / 读取 / 复位 (DAPLink·GD-Link·ST-Link·J-Link)',
|
|
'补齐 Pad: BIN 尾部补 0xFF 对齐页边界, 适配 Bootloader Y-Modem',
|
|
'拼接 Merge: Bootloader + APP + 有效标志 → 整片 Flash 镜像',
|
|
'发布包内置 OpenOCD 工具链, 免安装解压即用',
|
|
'作者: Hulk'):
|
|
ctk.CTkLabel(box, text='· ' + line, anchor='w').pack(anchor='w', pady=1)
|
|
ctk.CTkLabel(box, text='仓库: ' + REPO_URL, anchor='w',
|
|
text_color=OK_BLUE).pack(anchor='w', pady=(12, 0))
|
|
ctk.CTkButton(box, text='关闭', width=84,
|
|
command=dlg.destroy).pack(anchor='e', pady=(14, 0))
|
|
|
|
def _on_close(self):
|
|
if self.proc is not None:
|
|
if not messagebox.askyesno('确认退出', '烧录操作仍在进行, 是否停止并退出?'):
|
|
return
|
|
self.proc.terminate()
|
|
if self._poll_job:
|
|
try:
|
|
self.after_cancel(self._poll_job)
|
|
except Exception:
|
|
pass
|
|
self._poll_job = None
|
|
self._save()
|
|
self.destroy()
|
|
|
|
|
|
def time_stamp():
|
|
return time.strftime('%H:%M:%S ')
|