refactor(flash-gui): 对齐 DLPilot 重构 GUI 并内置 OpenOCD 发布链
This commit is contained in:
@@ -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 ')
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
把 flash_gui.py 打包成单文件 exe(工具名: DAPLinkFlash)
|
||||
================================================================
|
||||
使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter +
|
||||
customtkinter 全部打进一个 exe, 目标机器无需安装任何依赖。
|
||||
OpenOCD 工具链不进 exe, 由 package_release.py 以 toolchain/ 目录随包分发
|
||||
(scripts 目录含 1000+ 文件, 进 onefile 会拖慢每次启动的解压)。
|
||||
|
||||
用法:
|
||||
python build_exe.py 自动安装 PyInstaller(如缺)并打包
|
||||
python build_exe.py --debug 额外打一个带控制台的变体(命令行模式可见输出)
|
||||
|
||||
产物: release/DAPLinkFlash.exe(及 --debug 时的 DAPLinkFlash-console.exe)
|
||||
首次运行 onefile exe 会先解压到临时目录, 启动比脚本慢一两秒, 属正常现象。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 本脚本位于 src/, 打包产物输出到 FlashTool/release/
|
||||
SRC = os.path.dirname(os.path.abspath(__file__))
|
||||
BASE = os.path.dirname(SRC)
|
||||
DIST = os.path.join(BASE, 'release')
|
||||
WORK = os.path.join(SRC, 'build')
|
||||
ICO = os.path.join(SRC, 'logos', 'logo_flash.ico')
|
||||
|
||||
|
||||
def ensure_pyinstaller():
|
||||
try:
|
||||
import PyInstaller # noqa: F401
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
print('PyInstaller 未安装, 正在安装...')
|
||||
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller'])
|
||||
|
||||
|
||||
def extra_collect_args():
|
||||
"""CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错"""
|
||||
try:
|
||||
import customtkinter # noqa: F401
|
||||
return ['--collect-all', 'customtkinter']
|
||||
except ImportError:
|
||||
print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!')
|
||||
return []
|
||||
|
||||
|
||||
def remove_stale(exe_path):
|
||||
"""删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试"""
|
||||
if not os.path.exists(exe_path):
|
||||
return
|
||||
try:
|
||||
os.remove(exe_path)
|
||||
return
|
||||
except PermissionError:
|
||||
pass
|
||||
print(f'{exe_path} 被占用, 结束残留进程后重试...')
|
||||
subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)],
|
||||
capture_output=True)
|
||||
time.sleep(1.0)
|
||||
os.remove(exe_path)
|
||||
|
||||
|
||||
def build(name, extra_args):
|
||||
remove_stale(os.path.join(DIST, name + '.exe'))
|
||||
cmd = [sys.executable, '-m', 'PyInstaller',
|
||||
'--onefile', '--clean', '--name', name,
|
||||
'--distpath', DIST, '--workpath', WORK, '--specpath', WORK,
|
||||
] + extra_args + extra_collect_args() + [os.path.join(SRC, 'flash_gui.py')]
|
||||
if os.path.exists(ICO):
|
||||
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取)
|
||||
cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
|
||||
else:
|
||||
print('警告: 未找到 logos/logo_flash.ico(先运行 logos/make_ico.py), '
|
||||
'本次打包不带图标')
|
||||
print('\n>>', ' '.join(cmd))
|
||||
subprocess.check_call(cmd)
|
||||
out = os.path.join(DIST, name + '.exe')
|
||||
print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)')
|
||||
|
||||
|
||||
def main():
|
||||
build_console = '--debug' in sys.argv
|
||||
ensure_pyinstaller()
|
||||
# 常规版: 双击即用, 无黑框(GUI 与烧录操作全部可用, 命令行模式输出不可见)
|
||||
build('DAPLinkFlash', ['--noconsole'])
|
||||
if build_console:
|
||||
# 调试版: 带控制台, 命令行模式/异常栈可见
|
||||
build('DAPLinkFlash-console', [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
运行配置: flash_gui_settings.json(工具链/芯片/固件路径记忆)
|
||||
================================================================
|
||||
配置落在程序目录: frozen 取 exe 旁, 源码模式取 FlashTool/ 根。
|
||||
(参考 DLPilot/config.py: PyInstaller onefile 打包后 __file__ 指向临时解压
|
||||
目录, 配置必须用 sys.executable 定位到 exe 旁边)
|
||||
|
||||
兼容迁移: 旧版按主机名分文件的 config/flash_gui_<主机名>.json 在首次
|
||||
启动且新配置不存在时自动导入, 之后不再读取旧文件。
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
|
||||
else:
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
SETTINGS_NAME = "flash_gui_settings.json"
|
||||
SETTINGS_PATH = os.path.join(BASE_DIR, SETTINGS_NAME)
|
||||
|
||||
# 键与旧版 config/flash_gui_<主机名>.json 完全一致, 旧配置值可原样搬过来
|
||||
DEFAULTS = {
|
||||
"openocd": "", # 空 = 启动时按 locate_toolchain() 自动定位
|
||||
"scripts": "",
|
||||
"speed": "4000",
|
||||
"serial": "",
|
||||
"extra": "",
|
||||
"chip": "GD32E230F8",
|
||||
"probe": "DAPLink / CMSIS-DAP",
|
||||
"interface": "interface/cmsis-dap.cfg",
|
||||
"target": "target/gd32e23x.cfg",
|
||||
"flash_size": "0x10000",
|
||||
"firmware": "",
|
||||
"base": "0x08000000",
|
||||
"erase": True,
|
||||
"verify": True,
|
||||
"reset": True,
|
||||
"pad_in": "",
|
||||
"pad_out": "",
|
||||
"pad_align": "1024",
|
||||
"pad_byte": "0xFF",
|
||||
"bootloader": "",
|
||||
"app": "",
|
||||
"merge_out": "",
|
||||
"app_addr": "0x08002000",
|
||||
"flag_addr": "0x0800FFFC",
|
||||
"merge_flash": "0x10000",
|
||||
"flag_value": "0xEEEEEEEE",
|
||||
}
|
||||
|
||||
|
||||
def _legacy_settings():
|
||||
"""旧版 config/flash_gui_<主机名>.json 的路径, 优先本机名, 找不到取目录里
|
||||
任意一份; 没有旧配置返回 None"""
|
||||
cfg_dir = os.path.join(BASE_DIR, "config")
|
||||
if not os.path.isdir(cfg_dir):
|
||||
return None
|
||||
host = "flash_gui_%s.json" % re.sub(r"[^A-Za-z0-9_.-]", "_", socket.gethostname())
|
||||
path = os.path.join(cfg_dir, host)
|
||||
if not os.path.isfile(path):
|
||||
found = sorted(glob.glob(os.path.join(cfg_dir, "flash_gui_*.json")))
|
||||
path = found[0] if found else None
|
||||
return path
|
||||
|
||||
|
||||
def load_settings():
|
||||
"""读配置, 带默认值兜底; 新配置不存在时尝试从旧版 per-host 配置迁移"""
|
||||
data = dict(DEFAULTS)
|
||||
for src in (_legacy_settings() if not os.path.isfile(SETTINGS_PATH) else None,
|
||||
SETTINGS_PATH):
|
||||
if not src or not os.path.isfile(src):
|
||||
continue
|
||||
try:
|
||||
with open(src, "r", encoding="utf-8") as f:
|
||||
stored = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not isinstance(stored, dict):
|
||||
continue
|
||||
for key in DEFAULTS:
|
||||
if key in stored:
|
||||
data[key] = stored[key]
|
||||
return data
|
||||
|
||||
|
||||
def save_settings(data):
|
||||
"""只保存 DEFAULTS 里有的键, 防止把界面临时状态写进去"""
|
||||
out = {key: data.get(key, DEFAULTS[key]) for key in DEFAULTS}
|
||||
try:
|
||||
with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, ensure_ascii=False, indent=2)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DAPLink Flash — GD32 固件烧录 · 补齐 · 镜像拼接 — 入口与命令行模式
|
||||
================================================================
|
||||
配合 GD32E230 + DAPLink/CMSIS-DAP(或 ST-Link/J-Link) 使用, OpenOCD 烧录;
|
||||
内含固件补齐(pad)与镜像拼接(merge)工具。发布包内置 OpenOCD 工具链。
|
||||
|
||||
模块划分(重构自原单文件 daplink_flash_gui.py, 界面/打包方式对齐 DLPilot):
|
||||
app_gui.py CustomTkinter 主界面(双栏仪表盘)
|
||||
openocd_runner.py 工具链定位 / OpenOCD 命令构建与子进程
|
||||
fw_image.py 补齐(pad)与拼接(merge) + 芯片参数表
|
||||
config.py 运行目录与 flash_gui_settings.json 配置
|
||||
|
||||
依赖: 全部纯标准库 + customtkinter(py -m pip install customtkinter);
|
||||
内置工具链不存在时需本机已装 OpenOCD 或在界面/参数里指定路径。
|
||||
|
||||
命令行模式(自动化脚本用):
|
||||
python flash_gui.py 启动 GUI
|
||||
python flash_gui.py --seconds 5 GUI 5 秒自动关闭(冒烟测试)
|
||||
python flash_gui.py --detect 检测烧录器与目标芯片
|
||||
python flash_gui.py --flash app.bin 烧录(BIN 默认按基地址 0x08000000)
|
||||
python flash_gui.py --flash app.bin --base 0x08002000 --no-reset
|
||||
python flash_gui.py --erase 整片擦除(按配置的 Flash 大小)
|
||||
python flash_gui.py --read dump.bin 读取整片 Flash
|
||||
python flash_gui.py --reset 复位运行
|
||||
python flash_gui.py --pad app.bin 补齐到 1K 边界(app_pad.bin)
|
||||
python flash_gui.py --merge bl.bin app.bin 拼接整片镜像(*_BL.bin)
|
||||
python flash_gui.py --version 打印 OpenOCD 版本
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import config
|
||||
import openocd_runner
|
||||
from fw_image import (APP_ADDR_DEFAULT, FLASH_BASE_DEFAULT, FLAG_ADDR_DEFAULT,
|
||||
FLAG_VALUE_DEFAULT, merge_firmware, pad_bin, parse_int)
|
||||
|
||||
|
||||
def _toolchain(args):
|
||||
"""命令行工具链解析: 参数 > 配置文件 > 自动定位(内置/本机/PATH)"""
|
||||
saved = config.load_settings()
|
||||
exe = args.openocd or (saved['openocd'] if os.path.isfile(saved['openocd']) else '')
|
||||
scripts = args.scripts or (saved['scripts'] if exe == saved['openocd'] else '')
|
||||
if not exe:
|
||||
exe, scripts2, source = openocd_runner.locate_toolchain()
|
||||
scripts = scripts or scripts2
|
||||
print(f'工具链: {exe} ({source})')
|
||||
if not os.path.isfile(exe):
|
||||
sys.exit(f'找不到 OpenOCD: {exe}\n'
|
||||
f'可用 --openocd 指定, 或把内置工具链放在 exe 旁 toolchain/openocd/')
|
||||
if not scripts or not os.path.isdir(scripts):
|
||||
sys.exit(f'找不到 OpenOCD scripts 目录: {scripts or "(空)"}\n可用 --scripts 指定')
|
||||
return exe, scripts
|
||||
|
||||
|
||||
def _ocd_cmd(args, script):
|
||||
saved = config.load_settings()
|
||||
exe, scripts = _toolchain(args)
|
||||
return openocd_runner.build_cmd(
|
||||
exe, scripts, args.interface or saved['interface'],
|
||||
args.target or saved['target'],
|
||||
str(args.speed) if args.speed else saved['speed'],
|
||||
args.serial or saved['serial'], saved['extra'], script)
|
||||
|
||||
|
||||
def run_openocd(args, title, script):
|
||||
"""跑一条烧录命令, 实时回显输出, 返回退出码"""
|
||||
cmd = _ocd_cmd(args, script)
|
||||
print('== %s ==' % title)
|
||||
print('命令: ' + subprocess.list2cmdline(cmd))
|
||||
code, _lines = openocd_runner.run_sync(cmd)
|
||||
return code
|
||||
|
||||
|
||||
def run_pad(args):
|
||||
if not os.path.isfile(args.pad):
|
||||
sys.exit(f'找不到输入文件: {args.pad}')
|
||||
align = parse_int(str(args.align))
|
||||
if align is None or align <= 0:
|
||||
sys.exit(f'对齐字节无效: {args.align}')
|
||||
pad_byte = parse_int(args.pad_byte)
|
||||
if pad_byte is None:
|
||||
pad_byte = 0xFF
|
||||
stem, ext = os.path.splitext(args.pad)
|
||||
out = args.output or stem + '_pad' + ext
|
||||
_ensure_parent(out)
|
||||
original = os.path.getsize(args.pad)
|
||||
padded = pad_bin(args.pad, out, align, pad_byte)
|
||||
print(f'补齐完成: {out}')
|
||||
print(f' 原始 {original} B → 补齐后 {padded} B (+{padded - original}), 对齐 {align} B')
|
||||
return 0
|
||||
|
||||
|
||||
def run_merge(args):
|
||||
bootloader, app = args.merge
|
||||
for p in (bootloader, app):
|
||||
if not os.path.isfile(p):
|
||||
sys.exit(f'找不到文件: {p}')
|
||||
flash_size = parse_int(str(args.flash_size))
|
||||
app_addr = parse_int(args.app_addr)
|
||||
flag_addr = parse_int(args.flag_addr)
|
||||
flag_value = parse_int(args.flag_value)
|
||||
if flag_value is None:
|
||||
flag_value = FLAG_VALUE_DEFAULT
|
||||
out = args.output or os.path.splitext(app)[0] + '_BL.bin'
|
||||
_ensure_parent(out)
|
||||
try:
|
||||
info = merge_firmware(bootloader, app, out,
|
||||
app_addr, flag_addr, flash_size, flag_value)
|
||||
except ValueError as exc:
|
||||
sys.exit(f'拼接失败: {exc}')
|
||||
print(f'拼接完成: {out}')
|
||||
print(f' Bootloader {info["bootloader_size"]} B @0x{FLASH_BASE_DEFAULT:08X}')
|
||||
print(f' Application {info["app_size"]} B @0x{info["app_addr"]:08X}')
|
||||
print(f' Flag @0x{info["flag_addr"]:08X} 镜像 {info["output_size"]} B')
|
||||
return 0
|
||||
|
||||
|
||||
def _ensure_parent(path):
|
||||
parent = os.path.dirname(os.path.abspath(path))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description='DAPLink Flash — GD32 固件烧录 · 补齐 · 镜像拼接 '
|
||||
'(无参数则启动 GUI)')
|
||||
ap.add_argument('--seconds', type=int, default=0,
|
||||
help='GUI 自动退出秒数(冒烟测试)')
|
||||
# -- 烧录操作(互斥, 按下述顺序判优) --
|
||||
op = ap.add_mutually_exclusive_group()
|
||||
op.add_argument('--detect', action='store_true', help='检测烧录器与目标芯片')
|
||||
op.add_argument('--flash', metavar='FILE', help='烧录固件 (BIN/HEX/ELF)')
|
||||
op.add_argument('--erase', action='store_true', help='整片擦除')
|
||||
op.add_argument('--read', metavar='OUT', help='读取整片 Flash 到 OUT')
|
||||
op.add_argument('--reset', action='store_true', help='复位芯片并运行')
|
||||
op.add_argument('--version', action='store_true', help='打印 OpenOCD 版本')
|
||||
# -- 固件工具 --
|
||||
op.add_argument('--pad', metavar='FILE', dest='pad', help='补齐 BIN 到页边界')
|
||||
op.add_argument('--merge', nargs=2, metavar=('BL', 'APP'),
|
||||
help='拼接 Bootloader + APP → 整片镜像')
|
||||
ap.add_argument('--align', default='1024', help='--pad 对齐字节(默认 1024)')
|
||||
ap.add_argument('--pad-byte', default='0xFF', help='--pad 填充字节(默认 0xFF)')
|
||||
ap.add_argument('-o', '--output', help='--pad/--merge 输出路径(默认自动命名)')
|
||||
ap.add_argument('--app-addr', default='0x%08X' % APP_ADDR_DEFAULT,
|
||||
help='--merge APP 地址(默认 0x08002000)')
|
||||
ap.add_argument('--flag-addr', default='0x%08X' % FLAG_ADDR_DEFAULT,
|
||||
help='--merge 标志地址(默认 0x0800FFFC)')
|
||||
ap.add_argument('--flag-value', default='0x%08X' % FLAG_VALUE_DEFAULT,
|
||||
help='--merge 标志值(默认 0xEEEEEEEE)')
|
||||
ap.add_argument('--flash-size', default='0x10000',
|
||||
help='Flash 大小(默认 0x10000, --erase/--read/--merge 用)')
|
||||
# -- 烧录参数 --
|
||||
ap.add_argument('--base', default='0x%08X' % FLASH_BASE_DEFAULT,
|
||||
help='BIN 烧录基地址(默认 0x08000000)')
|
||||
ap.add_argument('--no-erase', action='store_true', help='烧录前不擦除')
|
||||
ap.add_argument('--no-verify', action='store_true', help='烧录后不校验')
|
||||
ap.add_argument('--no-reset', action='store_true', help='烧录后不复位运行')
|
||||
ap.add_argument('--speed', type=int, help='适配器速度 kHz(默认取配置 4000)')
|
||||
ap.add_argument('--serial', help='探针序列号(多探针时用)')
|
||||
ap.add_argument('--interface', help='interface cfg(默认 interface/cmsis-dap.cfg)')
|
||||
ap.add_argument('--target', help='target cfg(默认 target/gd32e23x.cfg)')
|
||||
ap.add_argument('--openocd', help='OpenOCD 可执行文件路径(默认自动定位)')
|
||||
ap.add_argument('--scripts', help='OpenOCD scripts 目录(默认自动定位)')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.pad:
|
||||
sys.exit(run_pad(args))
|
||||
if args.merge:
|
||||
sys.exit(run_merge(args))
|
||||
if args.version:
|
||||
exe, _scripts = _toolchain(args)
|
||||
print(openocd_runner.version(exe) or '无法获取版本')
|
||||
sys.exit(0)
|
||||
if args.detect:
|
||||
sys.exit(run_openocd(args, '检测设备', openocd_runner.script_detect()))
|
||||
if args.erase:
|
||||
sys.exit(run_openocd(args, '整片擦除',
|
||||
openocd_runner.script_erase(args.flash_size)))
|
||||
if args.flash:
|
||||
sys.exit(run_openocd(args, '烧录固件', openocd_runner.script_flash(
|
||||
args.flash, args.base,
|
||||
erase=not args.no_erase, verify=not args.no_verify,
|
||||
reset=not args.no_reset)))
|
||||
if args.read:
|
||||
sys.exit(run_openocd(args, '读取 Flash',
|
||||
openocd_runner.script_read(args.read, args.flash_size)))
|
||||
if args.reset:
|
||||
sys.exit(run_openocd(args, '复位芯片', openocd_runner.script_reset()))
|
||||
|
||||
# 无操作参数 → 启动 GUI(实现在 app_gui.py)
|
||||
import app_gui
|
||||
app_gui.run_gui(args.seconds)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
固件镜像处理: 补齐(pad)与拼接(merge) — 纯标准库, GUI 与命令行共用
|
||||
================================================================
|
||||
pad - BIN 尾部补 pad_byte 到指定对齐(bootloader 的 Y-Modem 按 1K 整包接收)
|
||||
merge - Bootloader + APP + 有效标志 拼成一整片 Flash 镜像(初次烧录用)
|
||||
|
||||
芯片参数表(CHIP_PROFILES)同时供烧录页(target cfg/Flash 大小)与补齐/拼接
|
||||
的容量校验使用。
|
||||
"""
|
||||
|
||||
import struct
|
||||
|
||||
FLASH_BASE_DEFAULT = 0x08000000
|
||||
APP_ADDR_DEFAULT = 0x08002000
|
||||
FLAG_ADDR_DEFAULT = 0x0800FFFC
|
||||
FLAG_VALUE_DEFAULT = 0xEEEEEEEE
|
||||
|
||||
CHIP_PROFILES = {
|
||||
"GD32E230F4": {"target": "target/gd32e23x.cfg", "flash": 16 * 1024, "ram": 4 * 1024},
|
||||
"GD32E230F6": {"target": "target/gd32e23x.cfg", "flash": 32 * 1024, "ram": 4 * 1024},
|
||||
"GD32E230F8": {"target": "target/gd32e23x.cfg", "flash": 64 * 1024, "ram": 8 * 1024},
|
||||
"GD32E230C8": {"target": "target/gd32e23x.cfg", "flash": 64 * 1024, "ram": 8 * 1024},
|
||||
}
|
||||
|
||||
|
||||
def parse_int(text):
|
||||
"""解析 '0x1C' / '4096' / '1k' / '2m' 形式的数值, 非法返回 None"""
|
||||
s = str(text).strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
mult = 1
|
||||
if s.endswith("k"):
|
||||
mult = 1024
|
||||
s = s[:-1]
|
||||
elif s.endswith("m"):
|
||||
mult = 1024 * 1024
|
||||
s = s[:-1]
|
||||
try:
|
||||
if s.startswith("0x"):
|
||||
return int(s, 16) * mult
|
||||
return int(s, 10) * mult
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def pad_bin(src_path, dst_path, align, pad_byte=0xFF):
|
||||
"""把 BIN 补齐到 align 的整数倍, 返回补齐后大小"""
|
||||
with open(src_path, "rb") as f:
|
||||
data = f.read()
|
||||
if align <= 0:
|
||||
raise ValueError("align must be positive")
|
||||
remainder = len(data) % align
|
||||
if remainder:
|
||||
data += bytes([pad_byte & 0xFF]) * (align - remainder)
|
||||
with open(dst_path, "wb") as f:
|
||||
f.write(data)
|
||||
return len(data)
|
||||
|
||||
|
||||
def merge_firmware(bootloader_path, app_path, output_path, app_addr, flag_addr,
|
||||
flash_size, flag_value=FLAG_VALUE_DEFAULT):
|
||||
"""Bootloader + APP + 有效标志 → 整片 Flash 镜像, 返回尺寸信息 dict"""
|
||||
if app_addr < FLASH_BASE_DEFAULT or app_addr >= FLASH_BASE_DEFAULT + flash_size:
|
||||
raise ValueError("APP address is outside the flash range")
|
||||
if flag_addr < FLASH_BASE_DEFAULT or flag_addr + 4 > FLASH_BASE_DEFAULT + flash_size:
|
||||
raise ValueError("flag address is outside the flash range")
|
||||
|
||||
with open(bootloader_path, "rb") as f:
|
||||
bootloader = f.read()
|
||||
with open(app_path, "rb") as f:
|
||||
app = f.read()
|
||||
|
||||
app_offset = app_addr - FLASH_BASE_DEFAULT
|
||||
flag_offset = flag_addr - FLASH_BASE_DEFAULT
|
||||
|
||||
if len(bootloader) > app_offset:
|
||||
raise ValueError("bootloader (%d bytes) exceeds APP offset (%d bytes)"
|
||||
% (len(bootloader), app_offset))
|
||||
if app_offset + len(app) > flag_offset:
|
||||
raise ValueError("APP end address exceeds the flag address")
|
||||
|
||||
image = bytearray(b"\xFF" * flash_size)
|
||||
image[0:len(bootloader)] = bootloader
|
||||
image[app_offset:app_offset + len(app)] = app
|
||||
struct.pack_into("<I", image, flag_offset, flag_value & 0xFFFFFFFF)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(image)
|
||||
|
||||
return {
|
||||
"bootloader_size": len(bootloader),
|
||||
"app_size": len(app),
|
||||
"output_size": len(image),
|
||||
"app_addr": app_addr,
|
||||
"flag_addr": flag_addr,
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DAPLink Flash 图标栅格化: 黑底 512, 绿色圆角矩形内 FLASH 黑字, 下方 Link 白字
|
||||
================================================================
|
||||
风格对齐 DLPilot(黑底 + 圆角色块 + 两行字), 绿色区分烧录工具定位。
|
||||
|
||||
输出: logos/logo_flash.png(512, 预览/复用)
|
||||
logos/logo_flash.ico(16~256 七档, exe 图标用)
|
||||
|
||||
用法: python make_ico.py (需 Pillow)
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
FONT = r'C:\Windows\Fonts\arialbd.ttf' # Arial Bold, Windows 标配
|
||||
|
||||
|
||||
def render(size=512):
|
||||
s = size / 512.0
|
||||
img = Image.new('RGB', (size, size), (0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rounded_rectangle([38 * s, 64 * s, (38 + 436) * s, (64 + 186) * s],
|
||||
radius=34 * s, fill=(0, 200, 120))
|
||||
if not os.path.exists(FONT):
|
||||
raise SystemExit(f'未找到字体 {FONT}, 无法栅格化')
|
||||
f = ImageFont.truetype(FONT, max(8, round(146 * s)))
|
||||
cx = size / 2
|
||||
# FLASH: 居中于绿色框
|
||||
d.text((cx, 64 * s + 186 * s / 2), 'FLASH', font=f, fill=(0, 0, 0), anchor='mm')
|
||||
# Link: 与 DLPilot 同款基线(中心 ~368)
|
||||
d.text((cx, 368 * s), 'Link', font=f, fill=(255, 255, 255), anchor='mm')
|
||||
return img
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
img = render(512)
|
||||
png = os.path.join(BASE, 'logo_flash.png')
|
||||
ico = os.path.join(BASE, 'logo_flash.ico')
|
||||
img.save(png)
|
||||
img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48),
|
||||
(64, 64), (128, 128), (256, 256)])
|
||||
print(f'写出 {png}')
|
||||
print(f'写出 {ico}')
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OpenOCD 子进程封装: 工具链定位 / 烧录脚本构建 / 后台运行
|
||||
================================================================
|
||||
工具链定位顺序(locate_toolchain):
|
||||
1. 内置 <程序目录>/toolchain/openocd/bin/openocd.exe
|
||||
(发布包自带; PyInstaller frozen 取 exe 旁, 源码模式取 FlashTool/ 下)
|
||||
2. 默认 D:/toolchain/openocd(开发机本机安装)
|
||||
3. PATH 里的 openocd
|
||||
返回值带来源标记, GUI 在界面上显示"工具链: 内置/本机/PATH"。
|
||||
|
||||
GUI 与命令行共用 build_cmd/script_* 这组函数, 保证两边生成的命令一致。
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from fw_image import CHIP_PROFILES, FLASH_BASE_DEFAULT, parse_int
|
||||
|
||||
DEFAULT_OPENOCD = r"D:/toolchain/openocd/bin/openocd.exe"
|
||||
DEFAULT_SCRIPTS = r"D:/toolchain/openocd/openocd/scripts"
|
||||
|
||||
# 探针型号 → OpenOCD interface cfg(空白 = 自定义, 不改手填值)
|
||||
PROBE_PROFILES = {
|
||||
"DAPLink / CMSIS-DAP": "interface/cmsis-dap.cfg",
|
||||
"GD-Link (CMSIS-DAP)": "interface/cmsis-dap.cfg",
|
||||
"ST-Link": "interface/stlink.cfg",
|
||||
"J-Link": "interface/jlink.cfg",
|
||||
"CMSIS-DAP (TCP)": "interface/cmsis-dap-tcp.cfg",
|
||||
"Custom": "",
|
||||
}
|
||||
|
||||
|
||||
def _app_base():
|
||||
"""程序所在目录: frozen 取 exe 旁, 源码模式取 FlashTool/"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return os.path.dirname(os.path.abspath(sys.executable))
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def bundled_toolchain():
|
||||
"""内置工具链路径, 不存在返回 None"""
|
||||
exe = os.path.join(_app_base(), "toolchain", "openocd", "bin", "openocd.exe")
|
||||
if os.path.isfile(exe):
|
||||
scripts = os.path.join(_app_base(), "toolchain", "openocd", "openocd", "scripts")
|
||||
return exe, scripts if os.path.isdir(scripts) else DEFAULT_SCRIPTS
|
||||
return None
|
||||
|
||||
|
||||
def locate_toolchain():
|
||||
"""返回 (openocd_exe, scripts_dir, source),source ∈ 内置/本机/PATH"""
|
||||
bundled = bundled_toolchain()
|
||||
if bundled:
|
||||
return bundled[0], bundled[1], "内置"
|
||||
if os.path.isfile(DEFAULT_OPENOCD):
|
||||
return DEFAULT_OPENOCD, DEFAULT_SCRIPTS, "本机"
|
||||
which = shutil.which("openocd")
|
||||
if which:
|
||||
return which, "", "PATH"
|
||||
return DEFAULT_OPENOCD, DEFAULT_SCRIPTS, "本机"
|
||||
|
||||
|
||||
def strip_ansi(text):
|
||||
return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text)
|
||||
|
||||
|
||||
def tcl_quote(path):
|
||||
return '"' + path.replace("\\", "/").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def ensure_cfg_path(value, folder):
|
||||
"""裸 cfg 文件名(如 gd32e23x.cfg)补上所属目录前缀"""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return value
|
||||
if "/" in value or "\\" in value or not value.lower().endswith(".cfg"):
|
||||
return value
|
||||
return folder + "/" + value
|
||||
|
||||
|
||||
def version(exe):
|
||||
"""openocd --version 首行(如 Open On-Chip Debugger 0.12.0), 失败返回 None"""
|
||||
try:
|
||||
out = subprocess.run([exe, "--version"], capture_output=True, text=True,
|
||||
timeout=10, creationflags=subprocess.CREATE_NO_WINDOW
|
||||
if os.name == "nt" else 0)
|
||||
line = (out.stdout or out.stderr or "").strip().splitlines()
|
||||
return line[0].strip() if line else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 烧录脚本构建(GUI 与 CLI 共用)
|
||||
# ============================================================
|
||||
def build_cmd(openocd, scripts, interface, target, speed="", serial="", extra="", script=""):
|
||||
"""拼 OpenOCD 命令行: -s 搜索目录 + interface/target cfg + -c 选项"""
|
||||
cmd = [openocd.strip()]
|
||||
if scripts and scripts.strip():
|
||||
cmd += ["-s", scripts.strip()]
|
||||
cmd += ["-f", ensure_cfg_path(interface, "interface"),
|
||||
"-f", ensure_cfg_path(target, "target")]
|
||||
if speed and speed.strip():
|
||||
cmd += ["-c", "adapter speed " + speed.strip()]
|
||||
if serial and serial.strip() and "cmsis-dap" in interface.lower():
|
||||
cmd += ["-c", "cmsis_dap_serial " + serial.strip()]
|
||||
if extra and extra.strip():
|
||||
cmd += ["-c", extra.strip()]
|
||||
if script:
|
||||
cmd += ["-c", script]
|
||||
return cmd
|
||||
|
||||
|
||||
def script_detect():
|
||||
return "init; reset halt; flash list; exit"
|
||||
|
||||
|
||||
def script_erase(flash_size_text):
|
||||
size = parse_int(flash_size_text)
|
||||
if size is None or size <= 0:
|
||||
raise ValueError("Flash 大小无效: %s" % flash_size_text)
|
||||
return ("init; reset halt; flash erase_address 0x%08X 0x%X; reset halt; exit"
|
||||
% (FLASH_BASE_DEFAULT, size))
|
||||
|
||||
|
||||
def script_flash(firmware, base_text="", erase=True, verify=True, reset=True):
|
||||
"""HEX/ELF 用文件内地址(base_text 留空), BIN 必须给基地址"""
|
||||
ext = os.path.splitext(firmware)[1].lower()
|
||||
script = "init; reset halt; "
|
||||
addr = ""
|
||||
if ext == ".bin":
|
||||
base = parse_int(base_text)
|
||||
if base is None:
|
||||
raise ValueError("BIN 基地址无效: %s" % base_text)
|
||||
addr = " 0x%08X" % base
|
||||
quoted = tcl_quote(firmware)
|
||||
script += ("flash write_image erase %s%s; " if erase
|
||||
else "flash write_image %s%s; ") % (quoted, addr)
|
||||
if verify:
|
||||
script += "verify_image %s%s; " % (quoted, addr)
|
||||
script += ("reset run" if reset else "reset halt") + "; exit"
|
||||
return script
|
||||
|
||||
|
||||
def script_read(out_path, flash_size_text):
|
||||
size = parse_int(flash_size_text)
|
||||
if size is None or size <= 0:
|
||||
raise ValueError("Flash 大小无效: %s" % flash_size_text)
|
||||
return "init; reset halt; dump_image %s 0x%08X 0x%X; exit" % (
|
||||
tcl_quote(out_path), FLASH_BASE_DEFAULT, size)
|
||||
|
||||
|
||||
def script_reset():
|
||||
return "init; reset run; exit"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 进程运行
|
||||
# ============================================================
|
||||
class OcdProcess:
|
||||
"""后台跑一条 openocd 命令。
|
||||
|
||||
on_line(line)/on_done(code) 在工作线程里回调, GUI 侧自行经队列
|
||||
投递回主线程; CLI 可直接在回调里 print。
|
||||
"""
|
||||
|
||||
def __init__(self, cmd, on_line, on_done):
|
||||
self.cmd = cmd
|
||||
self._on_line = on_line
|
||||
self._on_done = on_done
|
||||
self.proc = None
|
||||
self._thread = None
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return self.proc is not None
|
||||
|
||||
def start(self):
|
||||
self._thread = threading.Thread(target=self._worker, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def terminate(self):
|
||||
if self.proc is not None:
|
||||
try:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _worker(self):
|
||||
creation = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
self.cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
creationflags=creation,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._on_line("启动 OpenOCD 失败: %s" % exc)
|
||||
self._on_done(-1)
|
||||
return
|
||||
|
||||
self.proc = proc
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
line = strip_ansi(line.rstrip("\r\n"))
|
||||
if line.strip():
|
||||
self._on_line(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
code = proc.wait()
|
||||
self.proc = None
|
||||
self._on_done(code)
|
||||
|
||||
|
||||
def run_sync(cmd):
|
||||
"""阻塞运行一条 openocd 命令, 返回 (exit_code, 输出行)"""
|
||||
lines = []
|
||||
done = []
|
||||
|
||||
def on_line(line):
|
||||
lines.append(line)
|
||||
|
||||
def on_done(code):
|
||||
done.append(code)
|
||||
|
||||
job = OcdProcess(cmd, on_line, on_done)
|
||||
job.start()
|
||||
job._thread.join()
|
||||
return (done[0] if done else -1), lines
|
||||
|
||||
|
||||
def chip_target(chip_name):
|
||||
return CHIP_PROFILES.get(chip_name, {}).get("target", "target/gd32e23x.cfg")
|
||||
|
||||
|
||||
def chip_flash_size(chip_name):
|
||||
profile = CHIP_PROFILES.get(chip_name)
|
||||
return "0x%X" % profile["flash"] if profile else "0x10000"
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
组装 DAPLink Flash 发布包: exe + 内置 OpenOCD 工具链 → 文件夹 + zip(解压即用)
|
||||
================================================================
|
||||
release/ 本身就是可直接使用的完整文件夹(exe + toolchain/ + 说明),
|
||||
再原样压成 zip —— 两者内容始终一致。
|
||||
|
||||
zip 解压后根目录为 DAPLinkFlash/, 与 exe 运行目录结构一致:
|
||||
DAPLinkFlash/
|
||||
├── DAPLinkFlash.exe 主程序(先运行 build_exe.py 打包)
|
||||
├── toolchain/openocd/ 内置 OpenOCD(bin + scripts, GUI 自动定位)
|
||||
└── README.md 使用说明
|
||||
|
||||
OpenOCD 来源(--openocd-src > 环境变量 OPENOCD_SRC > 自动探测):
|
||||
1. FlashTool/toolchain/openocd (随仓库自带, 若已 vendor)
|
||||
2. D:/toolchain/openocd (开发机本机安装)
|
||||
目录布局: <src>/bin/openocd.exe + <src>/openocd/scripts/
|
||||
|
||||
用法:
|
||||
python package_release.py 版本号自动取最近 git tag(DAPLinkFlash-v*)
|
||||
python package_release.py 0.1.0 手动指定版本号
|
||||
python package_release.py 0.1.0 --openocd-src D:/toolchain/openocd
|
||||
产物: release/DAPLinkFlash-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
SRC = os.path.dirname(os.path.abspath(__file__))
|
||||
BASE = os.path.dirname(SRC)
|
||||
RELEASE = os.path.join(BASE, 'release')
|
||||
APP_NAME = 'DAPLinkFlash'
|
||||
|
||||
|
||||
def detect_version():
|
||||
"""从最近的 DAPLinkFlash-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
['git', 'describe', '--tags', '--abbrev=0', '--match', 'DAPLinkFlash-v*'],
|
||||
cwd=BASE, stderr=subprocess.DEVNULL)
|
||||
return out.decode().strip().removeprefix(APP_NAME + '-v')
|
||||
except Exception:
|
||||
sys.exit('未能从 git tag 推断版本号, 请手动指定: '
|
||||
'python package_release.py <版本号>')
|
||||
|
||||
|
||||
def resolve_openocd_src(explicit):
|
||||
"""定位 OpenOCD 工具链源目录, 校验布局并返回路径"""
|
||||
candidates = [explicit, os.environ.get('OPENOCD_SRC'),
|
||||
os.path.join(BASE, 'toolchain', 'openocd'),
|
||||
r'D:/toolchain/openocd']
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
exe = os.path.join(cand, 'bin', 'openocd.exe')
|
||||
scripts = os.path.join(cand, 'openocd', 'scripts')
|
||||
if os.path.isfile(exe) and os.path.isdir(scripts):
|
||||
print(f'OpenOCD 工具链来源: {os.path.abspath(cand)}')
|
||||
return os.path.abspath(cand)
|
||||
sys.exit('未找到 OpenOCD 工具链(需 bin/openocd.exe + openocd/scripts/):\n'
|
||||
' python package_release.py <版本> --openocd-src <toolchain/openocd 目录>\n'
|
||||
' 或设置环境变量 OPENOCD_SRC')
|
||||
|
||||
|
||||
def copy_toolchain(openocd_src):
|
||||
"""复制 bin(openocd.exe + 运行 DLL)与 openocd/scripts 到 release/toolchain/"""
|
||||
dst_root = os.path.join(RELEASE, 'toolchain', 'openocd')
|
||||
if os.path.isdir(dst_root):
|
||||
shutil.rmtree(dst_root)
|
||||
count = 0
|
||||
for sub in ('bin', os.path.join('openocd', 'scripts')):
|
||||
src = os.path.join(openocd_src, sub)
|
||||
dst = os.path.join(dst_root, sub)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.copytree(src, dst)
|
||||
for _root, _dirs, files in os.walk(dst):
|
||||
count += len(files)
|
||||
return count
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('version', nargs='?', default=None,
|
||||
help='版本号, 默认取最近 DAPLinkFlash-v* git tag')
|
||||
ap.add_argument('--openocd-src', default=None,
|
||||
help='OpenOCD 工具链目录(含 bin/ 与 openocd/scripts/)')
|
||||
args = ap.parse_args()
|
||||
|
||||
version = (args.version or detect_version()).lstrip('v')
|
||||
exe = os.path.join(RELEASE, APP_NAME + '.exe')
|
||||
readme = os.path.join(BASE, 'README.md')
|
||||
if not os.path.exists(exe):
|
||||
sys.exit(f'缺少 release/{APP_NAME}.exe, 先运行: python build_exe.py')
|
||||
if not os.path.exists(readme):
|
||||
sys.exit(f'缺少 {readme}')
|
||||
openocd_src = resolve_openocd_src(args.openocd_src)
|
||||
|
||||
n_files = copy_toolchain(openocd_src)
|
||||
shutil.copy2(readme, os.path.join(RELEASE, 'README.md'))
|
||||
|
||||
zip_name = f'{APP_NAME}-v{version}-win64.zip'
|
||||
zip_path = os.path.join(RELEASE, zip_name)
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
|
||||
z.write(exe, f'{APP_NAME}/{APP_NAME}.exe')
|
||||
z.write(os.path.join(RELEASE, 'README.md'), f'{APP_NAME}/README.md')
|
||||
tool_root = os.path.join(RELEASE, 'toolchain')
|
||||
for root, _dirs, files in os.walk(tool_root):
|
||||
for f in files:
|
||||
full = os.path.join(root, f)
|
||||
z.write(full, os.path.join(APP_NAME, os.path.relpath(full, RELEASE)))
|
||||
|
||||
exe_mb = os.path.getsize(exe) / 1024 / 1024
|
||||
zip_mb = os.path.getsize(zip_path) / 1024 / 1024
|
||||
print(f'发布文件夹: {RELEASE} (exe {exe_mb:.1f} MB + 内置工具链 {n_files} 个文件, 平铺即用)')
|
||||
print(f'发布包: {zip_path} ({zip_mb:.1f} MB)')
|
||||
print('下一步上传: python publish_gitea.py', zip_name)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
|
||||
================================================================
|
||||
用法:
|
||||
python publish_gitea.py DAPLinkFlash-v0.1.0-win64.zip
|
||||
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
|
||||
python publish_gitea.py <zip> --tag DAPLinkFlash-v0.1.0 --replace
|
||||
|
||||
行为:
|
||||
- tag 与版本默认取 zip 文件名(DAPLinkFlash-vX.Y.Z-win64.zip → DAPLinkFlash-vX.Y.Z)
|
||||
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
|
||||
已存在则复用, 仅上传/更新资产
|
||||
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
|
||||
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
APP_NAME = 'DAPLinkFlash'
|
||||
|
||||
DEFAULT_NOTES = """## DAPLink Flash {version}
|
||||
|
||||
GD32E230 固件烧录与打包工具: OpenOCD 烧录(检测/烧录/擦除/读取/复位)、
|
||||
固件补齐(Pad, Y-Modem 对齐)、镜像拼接(Merge, Bootloader+APP+有效标志)。
|
||||
|
||||
### 安装
|
||||
下载 `{app}-v{version}-win64.zip`, 解压后双击 `{app}.exe`(Windows 10/11 x64, 免安装)。
|
||||
|
||||
### 包内容
|
||||
- `{app}.exe` — 主程序(单文件自包含)
|
||||
- `toolchain/openocd/` — 内置 OpenOCD 工具链(openocd.exe + scripts, GUI 自动定位)
|
||||
- `README.md` — 完整使用说明
|
||||
"""
|
||||
|
||||
|
||||
def git_remote_info():
|
||||
"""从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)"""
|
||||
url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip()
|
||||
m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url)
|
||||
if not m:
|
||||
sys.exit(f'无法从 origin 解析 Gitea 地址: {url}')
|
||||
host, owner, repo = m.groups()
|
||||
return f'https://{host}', owner, repo
|
||||
|
||||
|
||||
class Api:
|
||||
def __init__(self, base, owner, repo, token, insecure):
|
||||
self.api = f'{base}/api/v1/repos/{owner}/{repo}'
|
||||
self.headers = {'Authorization': f'token {token}'}
|
||||
self.ctx = ssl._create_unverified_context() if insecure else None
|
||||
|
||||
def request(self, method, path, data=None, headers=None, retries=4):
|
||||
"""发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试"""
|
||||
last = None
|
||||
for attempt in range(retries):
|
||||
req = urllib.request.Request(self.api + path, data=data, method=method,
|
||||
headers={**self.headers, **(headers or {})})
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=self.ctx) as resp:
|
||||
body = resp.read()
|
||||
return resp.status, json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read() or b'{}')
|
||||
except (urllib.error.URLError, ssl.SSLError, OSError) as e:
|
||||
last = e
|
||||
time.sleep(1.0 + attempt)
|
||||
raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}')
|
||||
|
||||
def get_json(self, path):
|
||||
status, data = self.request('GET', path)
|
||||
return data if status == 200 else None
|
||||
|
||||
def upload_asset(self, release_id, filepath, replace):
|
||||
name = os.path.basename(filepath)
|
||||
status, rel = self.request('GET', f'/releases/{release_id}')
|
||||
if status != 200:
|
||||
sys.exit(f'读取 release {release_id} 失败: {status} {rel}')
|
||||
# Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组
|
||||
assets = rel.get('assets') or []
|
||||
if isinstance(assets, dict):
|
||||
assets = assets.get('items', [])
|
||||
for asset in assets:
|
||||
if asset['name'] == name:
|
||||
if not replace:
|
||||
print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)')
|
||||
return
|
||||
self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}")
|
||||
print(f'已删除旧资产 {name}')
|
||||
boundary = uuid.uuid4().hex
|
||||
payload = open(filepath, 'rb').read()
|
||||
body = (f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
|
||||
f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \
|
||||
f'\r\n--{boundary}--\r\n'.encode()
|
||||
status, data = self.request(
|
||||
'POST', f'/releases/{release_id}/assets?name={name}', body,
|
||||
{'Content-Type': f'multipart/form-data; boundary={boundary}'})
|
||||
if status != 201:
|
||||
sys.exit(f'上传资产失败: {status} {data}')
|
||||
print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('zipfile_path', help='发布 zip(release/ 下)')
|
||||
ap.add_argument('--tag', help=f'release 标签, 默认取 zip 文件名去掉 -win64.zip')
|
||||
ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板')
|
||||
ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token')
|
||||
ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产')
|
||||
ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.exists(args.zipfile_path):
|
||||
sys.exit(f'找不到 {args.zipfile_path}')
|
||||
if not args.token:
|
||||
sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN')
|
||||
|
||||
tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path))
|
||||
version = tag.removeprefix(APP_NAME + '-v')
|
||||
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
|
||||
else DEFAULT_NOTES.format(version=version, app=APP_NAME)
|
||||
|
||||
base, owner, repo = git_remote_info()
|
||||
api = Api(base, owner, repo, args.token, args.insecure)
|
||||
print(f'目标: {base}/{owner}/{repo} tag={tag}')
|
||||
|
||||
rel = api.get_json(f'/releases/tags/{tag}')
|
||||
if rel:
|
||||
print(f'release 已存在(id={rel["id"]}), 复用')
|
||||
else:
|
||||
status, rel = api.request('POST', '/releases', json.dumps({
|
||||
'tag_name': tag,
|
||||
'name': f'{APP_NAME} v{version}',
|
||||
'body': notes,
|
||||
}).encode(), {'Content-Type': 'application/json'})
|
||||
if status != 201:
|
||||
sys.exit(f'创建 release 失败: {status} {rel}')
|
||||
print(f'release 已创建(id={rel["id"]})')
|
||||
api.upload_asset(rel['id'], args.zipfile_path, args.replace)
|
||||
print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user