refactor(flash-gui): 对齐 DLPilot 重构 GUI 并内置 OpenOCD 发布链

This commit is contained in:
2026-09-14 18:54:20 +08:00
parent e8eb194fb8
commit 4c6cdc3bf8
16 changed files with 1896 additions and 933 deletions
+664
View File
@@ -0,0 +1,664 @@
#!/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.0'
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._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 ')