Files
AI_Printer/tools/esptool-factory/factory_gui/app.py

2317 lines
93 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import getpass
import hashlib
import json
import os
import platform
import re
import sys
import threading
import tempfile
import time
import tkinter as tk
import uuid
from datetime import datetime, timezone
from pathlib import Path
from tkinter import messagebox, scrolledtext, ttk
from typing import Any, Callable, Iterable, Optional, Tuple
from factory_common.firmware_envelope import (
FirmwareEnvelopeError,
decrypt_firmware_blob,
load_firmware_decrypt_key,
)
from factory_common.signing import SignatureError, verify_file_signature
from factory_private_config import (
EncryptedConfigError,
load_private_config_from_path,
resolve_private_config_path,
)
from .config import (
_load_flash_private_config,
_ui_text,
)
from .constants import (
DEFAULT_BAUD,
DEFAULT_FLASH_MAX_ATTEMPTS,
DEFAULT_FLASH_SECURE_MAX_ATTEMPTS,
DEFAULT_READY_TIMEOUT_S,
)
from .flash_utils import (
_extract_json_blob,
_parse_offset,
_sha256_hex,
_value_to_int,
_wipe_bytearray,
build_flash_entries,
collect_error_hints,
detect_boot_chain_header_state,
detect_secure_boot_block,
extract_mac_from_output,
evaluate_post_flash_status,
get_efuse_flag,
recommend_mode_from_json,
)
from .models import (
DeviceCheckConfig,
FlashEntryReport,
FlashPrivateConfig,
FlashReport,
FlashSource,
)
from .path_utils import _display_path, get_base_dir, is_frozen, resolve_python, select_bin_dir
from .redact import _redact_sensitive, _sanitize_filename
from .serial_utils import ensure_pyserial, list_serial_ports, pick_preferred_port
from .time_utils import _now_iso
from .tooling import run_command, run_esptool_api, run_tool_module
class FlashGUI:
def __init__(self) -> None:
self.base_dir = get_base_dir()
self.frozen = is_frozen()
self.python_cmd: Optional[Path] = None if self.frozen else resolve_python(self.base_dir)
self.run_record: Optional[dict] = None
self.current_device_id: Optional[str] = None
self.device_id_hint: Optional[str] = None
self._post_flash_reset_done = False
self.ready_timeout_s = DEFAULT_READY_TIMEOUT_S
self.flash_max_attempts = DEFAULT_FLASH_MAX_ATTEMPTS
self.flash_secure_max_attempts = DEFAULT_FLASH_SECURE_MAX_ATTEMPTS
self.private_cfg_path = resolve_private_config_path(self.base_dir)
if self.private_cfg_path is None or not self.private_cfg_path.exists():
raise FileNotFoundError(
"缺少工厂私有配置文件factory_private.json。请设置环境变量 "
"FACTORY_PRIVATE_CONFIG/FACTORY_PRIVATE_CONFIG_PATH 指定配置路径。"
)
self.private_cfg_loaded = False
self.ui_title = "产线烧录工具"
self.ui_subtitle = "插入设备后点击开始,系统将自动完成流程"
self.label_port = "串口"
self.label_secure = "启动信息"
self.label_flash_enc = "写入信息"
self.label_key = "配置信息"
self.label_recommend = "下一步"
self.label_log = "日志"
self.plain_text = "模式 A"
self.app_text = "模式 C"
self.safe_log = True
raw_cfg: Optional[dict] = None
try:
raw_cfg = load_private_config_from_path(self.private_cfg_path)
except EncryptedConfigError:
raw_cfg = None
if raw_cfg is not None:
self._apply_private_config(raw_cfg)
else:
self.private_cfg = self._build_placeholder_config()
self.root = tk.Tk()
self._apply_window_icon()
ui_cfg = self.private_cfg.ui
self.ui_title = _ui_text(ui_cfg, "flash_title", "产线烧录工具")
self.ui_subtitle = _ui_text(ui_cfg, "flash_subtitle", "插入设备后点击开始,系统将自动完成流程")
self.label_port = _ui_text(ui_cfg, "label_port", "串口")
self.label_secure = _ui_text(ui_cfg, "label_secure", "启动信息")
self.label_flash_enc = _ui_text(ui_cfg, "label_flash_enc", "写入信息")
self.label_key = _ui_text(ui_cfg, "label_key", "配置信息")
self.label_recommend = _ui_text(ui_cfg, "label_recommend", "下一步")
self.label_log = _ui_text(ui_cfg, "label_log", "日志")
safe_log_value = ui_cfg.get("safe_log")
self.safe_log = bool(safe_log_value) if isinstance(safe_log_value, bool) else True
self.root.title(self.ui_title)
self.root.geometry("820x620")
self.root.minsize(760, 560)
self.root.resizable(True, True)
self.plain_text = _ui_text(ui_cfg, "flash_mode_plain", "模式 A")
self.app_text = _ui_text(ui_cfg, "flash_mode_app", "模式 C")
self.port_var = tk.StringVar(value="COM3")
self.status_var = tk.StringVar(value="就绪。")
self.recommend_var = tk.StringVar(value="插入设备后点击开始。")
self.hint_var = tk.StringVar(value="")
self.mode_status_var = tk.StringVar(value="")
self.key_status_var = tk.StringVar(value="")
self.progress_value = tk.DoubleVar(value=0.0)
self.active_mode_label = ""
self.suppress_ready_status = False
self.checked_port: Optional[str] = None
self._last_port_value = ""
self.worker: Optional[threading.Thread] = None
self.is_busy = False
self.summary_attempted = False
self.summary_ok = False
self.last_rec_mode: Optional[str] = None
self.last_flash_app_only: Optional[bool] = None
self.secure_boot_blocked = False
self.secure_boot_enabled: Optional[bool] = None
self.boot_chain_present: Optional[bool] = None
self.boot_chain_probe_reason: Optional[str] = None
self.download_mode_disabled: Optional[bool] = None
self.secure_download_enabled: Optional[bool] = None
self.manual_encrypt_disabled: Optional[bool] = None
self.last_flash_block_reason: Optional[str] = None
self.card_port_var = tk.StringVar(value="未选择")
self.card_secure_boot_var = tk.StringVar(value="未检测")
self.card_flash_enc_var = tk.StringVar(value="未检测")
self.card_recommend_var = tk.StringVar(value="待检测")
self._build_ui()
self.preflight_check()
self._auto_set_port(initial=True)
self._last_port_value = self._normalize_port(self.port_var.get())
self.port_var.trace_add("write", self._on_port_change)
def _apply_window_icon(self) -> None:
icon_path = self._resolve_icon_path()
if not icon_path.exists():
return
try:
self.root.iconbitmap(default=str(icon_path))
except Exception: # noqa: BLE001
pass
def _resolve_icon_path(self) -> Path:
if self.frozen and hasattr(sys, "_MEIPASS"):
return Path(getattr(sys, "_MEIPASS")) / "talkingq_logo_256x256.ico"
return self.base_dir / "talkingq_logo_256x256.ico"
def _build_placeholder_config(self) -> FlashPrivateConfig:
device_check = DeviceCheckConfig(
crypt_cnt_fields=("SPI_BOOT_CRYPT_CNT",),
secure_boot_field="SECURE_BOOT_EN",
secure_boot_expected=None,
crypt_cnt_expect=None,
flash_key_purpose_match=None,
secure_boot_key_purpose_match=None,
key_read_disable_field="RD_DIS",
)
flash_cfg = FlashPrivateConfig(
chip=os.getenv("FACTORY_FLASH_CHIP", "esp32s3"),
baud=str(DEFAULT_BAUD),
bin_dir=Path("bin"),
layout=[],
app_bin_name="",
app_partition_offset="0x0",
flash_args={
"flash_mode": "dio",
"flash_freq": "80m",
"flash_size": "keep",
"before": "default-reset",
"after": "no-reset",
},
device_check=device_check,
ui={},
)
return flash_cfg
def _apply_private_config(self, raw_cfg: dict) -> None:
self.private_cfg = _load_flash_private_config(self.base_dir, raw_cfg)
self.private_cfg_loaded = True
ui_cfg = self.private_cfg.ui if isinstance(self.private_cfg.ui, dict) else {}
self.ui_title = _ui_text(ui_cfg, "flash_title", self.ui_title)
self.ui_subtitle = _ui_text(ui_cfg, "flash_subtitle", self.ui_subtitle)
self.label_port = _ui_text(ui_cfg, "label_port", self.label_port)
self.label_secure = _ui_text(ui_cfg, "label_secure", self.label_secure)
self.label_flash_enc = _ui_text(ui_cfg, "label_flash_enc", self.label_flash_enc)
self.label_key = _ui_text(ui_cfg, "label_key", self.label_key)
self.label_recommend = _ui_text(ui_cfg, "label_recommend", self.label_recommend)
self.label_log = _ui_text(ui_cfg, "label_log", self.label_log)
safe_log_value = ui_cfg.get("safe_log")
if isinstance(safe_log_value, bool):
self.safe_log = safe_log_value
self.plain_text = _ui_text(ui_cfg, "flash_mode_plain", self.plain_text)
self.app_text = _ui_text(ui_cfg, "flash_mode_app", self.app_text)
if self.run_record and not self.run_record.get("firmware_version"):
self.run_record["firmware_version"] = self.private_cfg.app_bin_name
def _ensure_private_config(self, port: str, show_dialog: bool) -> bool:
if self.private_cfg_loaded:
return True
if self.private_cfg_path is None or not self.private_cfg_path.exists():
if show_dialog:
self._notify("error", "缺少配置", "未找到私有配置文件。")
return False
try:
raw_cfg = load_private_config_from_path(self.private_cfg_path)
except EncryptedConfigError:
self.log("[错误] 检测到加密私有配置。当前仅支持明文配置文件。")
if show_dialog:
self._notify(
"error",
"不支持加密配置",
"当前仅支持明文 conf/factory_private.json。",
)
return False
except (PermissionError, ValueError, FileNotFoundError) as exc:
self.log(f"[错误] 私有配置加载失败:{exc}")
if show_dialog:
self._notify("error", "配置加载失败", str(exc))
return False
self._apply_private_config(raw_cfg)
self._refresh_key_status()
if hasattr(self, "root"):
self.root.after(0, lambda: self.root.title(self.ui_title))
return True
def _refresh_mode_status(self) -> None:
status = "运行模式:本地离线"
self.root.after(0, lambda: self.mode_status_var.set(status))
def _read_chip_mac(self, port: str) -> Optional[str]:
if not port:
return None
candidates: list[Optional[str]] = []
if self.private_cfg_loaded:
candidates.append(self.private_cfg.chip)
candidates.extend(["auto", None])
tried: set[Optional[str]] = set()
for chip in candidates:
if chip in tried:
continue
tried.add(chip)
if chip:
args = ["--chip", chip, "--port", port, "read_mac"]
else:
args = ["--port", port, "read_mac"]
code, output = self.run_tool("esptool", args, stream=False)
if code != 0:
continue
mac = extract_mac_from_output(output)
if mac:
return mac
return None
def _refresh_key_status(self) -> None:
status = "配置签名:已启用"
self.root.after(0, lambda: self.key_status_var.set(status))
def _build_ui(self) -> None:
self.style = ttk.Style(self.root)
for theme in ("clam", "vista"):
if theme in self.style.theme_names():
self.style.theme_use(theme)
break
self.root.option_add("*Font", ("Segoe UI", 10))
palette = {
"bg": "#eef4fb",
"panel": "#f8fbff",
"text": "#15233b",
"muted": "#5a6b85",
"accent": "#1a73e8",
"accent_soft": "#d6e7ff",
"border": "#c9d7ee",
}
self.root.configure(bg=palette["bg"])
self.style.configure(".", background=palette["bg"], foreground=palette["text"])
self.style.configure("TFrame", background=palette["bg"])
self.style.configure("Root.TFrame", background=palette["bg"])
self.style.configure("Panel.TFrame", background=palette["panel"])
self.style.configure("TLabelframe", background=palette["panel"], bordercolor=palette["border"])
self.style.configure("TLabelframe.Label", background=palette["panel"], foreground=palette["text"])
self.style.configure("TLabel", background=palette["bg"], foreground=palette["text"])
self.style.configure("Root.TLabel", background=palette["bg"], foreground=palette["text"])
self.style.configure("Panel.TLabel", background=palette["panel"], foreground=palette["text"])
self.style.configure(
"Header.TLabel",
font=("Segoe UI", 14, "bold"),
foreground=palette["accent"],
background=palette["bg"],
)
self.style.configure(
"SubHeader.TLabel",
font=("Segoe UI", 9),
foreground=palette["muted"],
background=palette["bg"],
)
self.style.configure(
"Step.TLabel",
font=("Segoe UI", 9),
foreground=palette["muted"],
background=palette["bg"],
)
self.style.configure(
"StepActive.TLabel",
font=("Segoe UI", 9, "bold"),
foreground=palette["accent"],
background=palette["bg"],
)
self.style.configure(
"Action.TButton",
padding=(14, 6),
font=("Segoe UI", 10, "bold"),
foreground=palette["text"],
background=palette["accent_soft"],
)
self.style.map(
"Action.TButton",
background=[("active", "#bfd7ff"), ("pressed", "#a8c8ff")],
)
self.style.configure("TButton", padding=(10, 4), background=palette["panel"])
self.style.map("TButton", background=[("active", "#e6f0ff")])
self.style.configure("Disabled.TButton", foreground="#777777")
self.style.configure("Section.TLabelframe.Label", font=("Segoe UI", 10, "bold"))
self.style.configure("TEntry", fieldbackground=palette["panel"])
frame = ttk.Frame(self.root, padding=14, style="Root.TFrame")
frame.pack(fill="both", expand=True)
header = ttk.Frame(frame, style="Root.TFrame")
header.pack(fill="x", pady=(0, 8))
ttk.Label(
header,
text=self.ui_title,
style="Header.TLabel",
).pack(anchor="w")
ttk.Label(
header,
text=self.ui_subtitle,
style="SubHeader.TLabel",
).pack(anchor="w", pady=(2, 0))
port_section = ttk.LabelFrame(frame, text="1. 串口连接", padding=10, style="Section.TLabelframe")
port_section.pack(fill="x", pady=(0, 8))
port_row = ttk.Frame(port_section, style="Panel.TFrame")
port_row.pack(fill="x")
ttk.Label(port_row, text="串口:", style="Panel.TLabel").pack(side="left")
self.port_entry = ttk.Entry(port_row, textvariable=self.port_var, width=18)
self.port_entry.pack(side="left", padx=(6, 12))
self.detect_btn = ttk.Button(port_row, text="刷新串口", command=self.on_auto_detect)
self.detect_btn.pack(side="left", padx=(0, 12))
ttk.Label(
port_row,
text="提示:开始烧录时按住 BOOT点一下 RESET。",
style="Panel.TLabel",
).pack(side="left", padx=(10, 0))
status_row = ttk.Frame(port_section, style="Panel.TFrame")
status_row.pack(fill="x", pady=(6, 0))
ttk.Label(status_row, textvariable=self.mode_status_var, style="Panel.TLabel").pack(
side="left"
)
ttk.Label(status_row, textvariable=self.key_status_var, style="Panel.TLabel").pack(
side="left", padx=(12, 0)
)
detect_section = ttk.LabelFrame(frame, text="2. 一键流程", padding=10, style="Section.TLabelframe")
detect_section.pack(fill="x", pady=(0, 8))
detect_row = ttk.Frame(detect_section, style="Panel.TFrame")
detect_row.pack(fill="x")
self.start_btn = ttk.Button(
detect_row, text="开始", command=self.on_start, style="Action.TButton"
)
self.start_btn.pack(side="left")
self.mode_label = ttk.Label(
detect_row,
textvariable=self.recommend_var,
foreground="#1a73e8",
font=("Segoe UI", 10, "bold"),
wraplength=520,
justify="left",
style="Panel.TLabel",
)
self.mode_label.pack(side="left", padx=(10, 0), fill="x", expand=True)
hint_frame = ttk.Frame(detect_section, style="Panel.TFrame")
hint_frame.pack(fill="x", pady=(6, 0))
ttk.Label(hint_frame, text="提示:", style="Panel.TLabel").pack(side="left", anchor="n")
self.hint_label = ttk.Label(
hint_frame,
textvariable=self.hint_var,
wraplength=680,
justify="left",
foreground="#5a6b85",
style="Panel.TLabel",
)
self.hint_label.pack(side="left", fill="x", expand=True)
log_section = ttk.LabelFrame(
frame, text=self.label_log, padding=10, style="Section.TLabelframe"
)
log_section.pack(fill="both", expand=True)
log_actions = ttk.Frame(log_section, style="Panel.TFrame")
log_actions.pack(fill="x", pady=(0, 6))
self.clear_btn = ttk.Button(log_actions, text="清空日志", command=self.clear_log)
self.clear_btn.pack(side="right")
self.copy_btn = ttk.Button(log_actions, text="复制日志", command=self.copy_log)
self.copy_btn.pack(side="right", padx=(0, 8))
self.log_box = scrolledtext.ScrolledText(
log_section,
height=16,
wrap="word",
background="#ffffff",
foreground="#1f2a33",
insertbackground="#1f2a33",
)
self.log_box.pack(fill="both", expand=True)
self.log_box.configure(state="disabled", font=("Consolas", 10))
status_frame = ttk.Frame(frame, style="Root.TFrame")
status_frame.pack(fill="x", pady=(8, 0))
ttk.Label(status_frame, text="提示:", style="Root.TLabel").pack(side="left")
self.status_label = ttk.Label(status_frame, textvariable=self.status_var, style="Root.TLabel")
self.status_label.pack(side="left", padx=(6, 0))
self.progress = ttk.Progressbar(
status_frame,
mode="indeterminate",
length=140,
maximum=100,
variable=self.progress_value,
)
self.progress.pack(side="right", padx=(8, 0))
self.progress.pack_forget()
self.update_flash_buttons()
def _notify(self, kind: str, title: str, message: str) -> None:
def _show() -> None:
if kind == "info":
messagebox.showinfo(title, message)
return
if kind == "warn":
messagebox.showwarning(title, message)
return
messagebox.showerror(title, message)
self.root.after(0, _show)
def log(self, message: str, timestamp: bool = True) -> None:
safe_message = _redact_sensitive(message)
def _append() -> None:
self.log_box.configure(state="normal")
prefix = ""
if timestamp and safe_message:
prefix = f"[{datetime.now().strftime('%H:%M:%S')}] "
line = prefix + safe_message if safe_message else ""
self.log_box.insert("end", line + "\n")
self.log_box.see("end")
self.log_box.configure(state="disabled")
self.root.after(0, _append)
def log_section(self, title: str) -> None:
self.log("", timestamp=False)
self.log(title, timestamp=False)
self.log("-" * len(title), timestamp=False)
def _resolve_operator(self) -> str:
for key in ("FACTORY_OPERATOR", "OPERATOR_ID", "OPERATOR", "USERNAME", "USER"):
value = os.getenv(key)
if isinstance(value, str) and value.strip():
return value.strip()
try:
return getpass.getuser()
except Exception:
return "unknown"
def _log_dir(self) -> Path:
env_path = os.getenv("FACTORY_LOG_DIR")
if isinstance(env_path, str) and env_path.strip():
return Path(env_path).expanduser()
if os.name == "nt":
base = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA")
if base:
return Path(base) / "TalkingQFactory" / "logs"
return Path.home() / "AppData" / "Local" / "TalkingQFactory" / "logs"
if sys.platform == "darwin":
return Path.home() / "Library" / "Logs" / "TalkingQFactory"
return Path.home() / ".cache" / "talkingq-factory"
def _begin_run_record(self, port: str) -> None:
firmware_version = os.getenv("FACTORY_FIRMWARE_VERSION") or os.getenv("FIRMWARE_VERSION")
if isinstance(firmware_version, str) and firmware_version.strip():
firmware_version = firmware_version.strip()
else:
firmware_version = self.private_cfg.app_bin_name
run_id = uuid.uuid4().hex[:8]
self.run_record = {
"tool": "factory_flash_gui",
"run_id": run_id,
"started_at": _now_iso(),
"operator": self._resolve_operator(),
"host": platform.node(),
"port": port,
"device_id": None,
"firmware_version": firmware_version,
"steps": {},
}
def _record_error(self, stage: str, message: str, code: Optional[str] = None) -> None:
if not self.run_record:
return
self.run_record["error"] = {
"stage": stage,
"message": message,
"code": code,
}
def _record_step_result(
self,
step: str,
start_ts: float,
status: str,
details: Optional[dict] = None,
) -> None:
if not self.run_record:
return
started_at = datetime.fromtimestamp(start_ts, tz=timezone.utc).isoformat()
payload = {
"started_at": started_at,
"duration_s": int(max(0.0, time.time() - start_ts)),
"status": status,
}
if details:
payload.update(details)
self.run_record.setdefault("steps", {})[step] = payload
def _sanitize_run_record(self, record: dict) -> dict:
safe: dict = {
"tool": record.get("tool"),
"run_id": record.get("run_id"),
"device_id": record.get("device_id"),
"started_at": record.get("started_at"),
"ended_at": record.get("ended_at"),
"duration_s": record.get("duration_s"),
"success": record.get("success"),
}
error = record.get("error")
if isinstance(error, dict):
safe_error: dict = {}
for key in ("stage", "message", "code"):
value = error.get(key)
if isinstance(value, str):
safe_error[key] = _redact_sensitive(value)
elif value is not None:
safe_error[key] = value
if safe_error:
safe["error"] = safe_error
steps = {}
raw_steps = record.get("steps", {})
if isinstance(raw_steps, dict):
for step, payload in raw_steps.items():
if not isinstance(payload, dict):
continue
step_safe: dict = {}
for key in ("status", "duration_s", "reason", "attempts", "errors"):
if key not in payload:
continue
value = payload.get(key)
if isinstance(value, str):
step_safe[key] = _redact_sensitive(value)
elif isinstance(value, list):
step_safe[key] = [
_redact_sensitive(item) if isinstance(item, str) else item
for item in value
]
else:
step_safe[key] = value
if step_safe:
steps[step] = step_safe
if steps:
safe["steps"] = steps
return safe
def _write_run_record(self, record: dict) -> Optional[Path]:
log_dir = self._log_dir()
try:
log_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
self.log(f"[警告] 无法创建记录目录: {exc}")
return None
record_id = record.get("device_id") or "unknown"
success = record.get("success")
if success is True:
status_tag = "ok"
elif success is False:
status_tag = "failed"
else:
status_tag = "unknown"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{ts}_{_sanitize_filename(str(record_id))}_{status_tag}.json"
path = log_dir / filename
try:
path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
except OSError as exc:
self.log(f"[警告] 记录落盘失败: {exc}")
return None
return path
def _finalize_run_record(self, success: bool) -> None:
if not self.run_record:
return
self.run_record["ended_at"] = _now_iso()
started_at = self.run_record.get("started_at")
if isinstance(started_at, str):
try:
start_dt = datetime.fromisoformat(started_at)
self.run_record["duration_s"] = int((datetime.now(timezone.utc) - start_dt).total_seconds())
except ValueError:
pass
if self.current_device_id and not self.run_record.get("device_id"):
self.run_record["device_id"] = self.current_device_id
if self.device_id_hint and not self.run_record.get("device_id"):
self.run_record["device_id"] = self.device_id_hint
self.run_record["success"] = bool(success)
safe_record = self._sanitize_run_record(self.run_record)
path = self._write_run_record(safe_record)
if path:
if self.safe_log:
device_id = safe_record.get("device_id")
if isinstance(device_id, str) and device_id:
self.log(f"[OK] 产测记录已保存 (device_id={device_id})。")
else:
self.log("[OK] 产测记录已保存。")
else:
self.log(f"[OK] 产测记录已保存: {path}")
self.run_record = None
def _on_flash_output(self, line: str) -> None:
self._parse_flash_progress(line)
if self.safe_log:
return
if line:
self.log(line)
def _parse_flash_progress(self, line: str) -> None:
if not line:
return
if "Wrote " in line and "bytes" in line:
self.set_progress(100.0)
if self.active_mode_label:
self.set_status(f"{self.active_mode_label}进行中... 100%")
return
match = re.search(r"(\d+(?:\.\d+)?)%", line)
if not match:
return
try:
percent = float(match.group(1))
except ValueError:
return
self.set_progress(percent)
if self.active_mode_label:
self.set_status(f"{self.active_mode_label}进行中... {percent:.0f}%")
def set_status(self, message: str) -> None:
self.root.after(0, lambda: self.status_var.set(message))
def set_status_tone(self, tone: str) -> None:
palette = {
"info": "#1a73e8",
"success": "#0b8a3c",
"warn": "#c77c02",
"error": "#c0362c",
}
color = palette.get(tone, "#000")
self.root.after(0, lambda: self.status_label.configure(foreground=color))
def set_busy_indicator(self, busy: bool, mode: str = "indeterminate") -> None:
def _apply() -> None:
if busy:
self.progress.pack(side="right", padx=(8, 0))
if mode == "determinate":
self.progress.configure(mode="determinate")
self.progress_value.set(0.0)
else:
self.progress.configure(mode="indeterminate")
self.progress.start(8)
return
self.progress.stop()
self.progress.pack_forget()
self.root.after(0, _apply)
def set_progress(self, value: float) -> None:
def _apply() -> None:
self.progress_value.set(max(0.0, min(100.0, value)))
self.root.after(0, _apply)
def clear_log(self, update_status: bool = True) -> None:
self.log_box.configure(state="normal")
self.log_box.delete("1.0", "end")
self.log_box.configure(state="disabled")
if update_status:
self.set_status("日志已清空。")
self.set_status_tone("info")
def copy_log(self) -> None:
content = self.log_box.get("1.0", "end").strip()
self.root.clipboard_clear()
if content:
self.root.clipboard_append(content)
self.set_status("日志已复制到剪贴板。")
self.set_status_tone("success")
else:
self.set_status("日志为空。")
self.set_status_tone("warn")
def set_hint_text(self, text: str, tone: str = "info") -> None:
palette = {
"info": "#555555",
"success": "#0b8a3c",
"warn": "#c77c02",
"error": "#c0362c",
}
color = palette.get(tone, "#555555")
def _apply() -> None:
self.hint_var.set(text)
self.hint_label.configure(foreground=color)
self.root.after(0, _apply)
def _normalize_port(self, port: Optional[str]) -> str:
if not port:
return ""
return port.strip().upper()
def _on_port_change(self, *_args) -> None:
port = self._normalize_port(self.port_var.get())
if port == self._last_port_value:
return
self._last_port_value = port
if self.is_busy:
self.update_flash_buttons()
return
if not port:
self.checked_port = None
self.summary_attempted = False
self.summary_ok = False
self.last_rec_mode = None
self.secure_boot_blocked = False
self.secure_boot_enabled = None
self.boot_chain_present = None
self.boot_chain_probe_reason = None
self.download_mode_disabled = None
self.secure_download_enabled = None
self.manual_encrypt_disabled = None
self.device_id_hint = None
self.recommend_var.set("请选择串口后点击开始。")
self.set_hint_text("", tone="info")
self.card_port_var.set("未选择")
self.card_secure_boot_var.set("未检测")
self.card_flash_enc_var.set("未检测")
self.card_recommend_var.set("待检测")
self.update_flash_buttons()
return
if self.summary_attempted:
self.summary_attempted = False
self.summary_ok = False
self.last_rec_mode = None
self.secure_boot_blocked = False
self.secure_boot_enabled = None
self.boot_chain_present = None
self.boot_chain_probe_reason = None
self.download_mode_disabled = None
self.secure_download_enabled = None
self.manual_encrypt_disabled = None
self.device_id_hint = None
self.recommend_var.set("串口已变更,请点击开始重新检测。")
self.set_hint_text("检测结果仅对当前串口有效。", tone="warn")
elif not self.summary_attempted:
self.recommend_var.set("准备开始:点击开始自动检测并执行流程。")
self.set_hint_text("", tone="info")
self.card_port_var.set(port)
self.card_secure_boot_var.set("待检测")
self.card_flash_enc_var.set("待检测")
self.card_recommend_var.set("请先设备检测")
self.update_flash_buttons()
def update_flash_buttons(self) -> None:
self.root.after(0, self._apply_flash_buttons)
def _apply_flash_buttons(self) -> None:
busy = self.is_busy
self.detect_btn.state(["disabled"] if busy else ["!disabled"])
self.port_entry.configure(state="disabled" if busy else "normal")
start_state = ["!disabled"]
if busy:
start_state = ["disabled"]
self.start_btn.state(start_state)
def disable_buttons(self, progress_mode: str = "indeterminate") -> None:
self.is_busy = True
self.set_busy_indicator(True, mode=progress_mode)
self.update_flash_buttons()
def enable_buttons(self) -> None:
self.is_busy = False
self.set_busy_indicator(False)
self.update_flash_buttons()
self.active_mode_label = ""
def on_auto_detect(self) -> None:
port = self._auto_detect_port(show_dialog=True)
if port:
self.port_var.set(port)
def on_start(self) -> None:
self.start_task(self.flow_task, progress_mode="determinate")
def start_task(self, func, progress_mode: str = "indeterminate") -> None:
if self.worker and self.worker.is_alive():
return
self.disable_buttons(progress_mode=progress_mode)
self.set_status("处理中...")
self.set_status_tone("info")
self.worker = threading.Thread(target=self._run_wrapper, args=(func,), daemon=True)
self.worker.start()
def _run_wrapper(self, func) -> None:
try:
func()
finally:
self.root.after(0, self.enable_buttons)
if self.suppress_ready_status:
self.suppress_ready_status = False
return
self.set_status("就绪。")
self.set_status_tone("info")
def prepare_secure_entries(
self, entries: Iterable[Tuple[object, Path]]
) -> Optional[list[FlashSource]]:
missing: list[Path] = []
sources: list[Tuple[int, Path, Path]] = []
for offset, path in entries:
source = Path(str(path) + ".enc")
try:
offset_int = _parse_offset(offset)
except ValueError as exc:
self.log("[错误] 固件偏移配置无效。")
if not self.safe_log:
self.log(f" - {exc}")
self._notify("error", "偏移无效", "固件偏移配置无效,请查看日志。")
return None
sources.append((offset_int, path, source))
if not source.exists():
missing.append(source)
if missing:
self.log("[错误] 缺少必要固件文件。")
if not self.safe_log:
for item in missing:
self.log(f" - {_display_path(self.base_dir, item)}")
self._notify("error", "文件缺失", "缺少必要固件文件,请查看日志。")
return None
try:
load_firmware_decrypt_key()
except FirmwareEnvelopeError as exc:
self.log("[错误] .enc 固件解密密钥不可用。")
if not self.safe_log:
self.log(f" - {exc}")
self._notify("error", "缺少解密密钥", str(exc))
return None
resolved: list[FlashSource] = []
for offset_int, orig, source in sources:
try:
verify_file_signature(source)
except SignatureError as exc:
self.log("[错误] 固件校验失败。")
if not self.safe_log:
self.log(f" - {_display_path(self.base_dir, source)}")
self.log(f" - {exc}")
self._notify("error", "文件校验失败", "文件校验失败,请查看日志。")
return None
resolved.append(
FlashSource(
offset=offset_int,
path=source,
name=orig.name,
)
)
return resolved
def _is_force_bootloader_entry(self, entry: FlashSource) -> bool:
name = entry.name.strip().lower()
return entry.offset == 0x0 and "bootloader" in name and name.endswith(".bin")
@staticmethod
def _normalize_chip_name(value: object) -> str:
text = str(value or "").strip().lower()
return re.sub(r"[^0-9a-z]", "", text)
@staticmethod
def _format_rev_full(rev: int) -> str:
if rev < 0:
return "unknown"
return f"v{rev // 100}.{rev % 100}"
def _guard_force_bootloader_write(
self,
esp: Any,
entry: FlashSource,
payload: bytes,
*,
encrypt: bool,
) -> None:
from esptool.bin_image import LoadFirmwareImage
if not self._is_force_bootloader_entry(entry):
raise RuntimeError(
f"force 风险拦截:仅允许 bootloader@0x0当前条目为 {entry.name} @ {hex(entry.offset)}"
)
expected_chip = self._normalize_chip_name(self.private_cfg.chip)
chip_name = str(getattr(esp, "CHIP_NAME", "") or "")
actual_chip = self._normalize_chip_name(chip_name)
if expected_chip and expected_chip != "auto" and actual_chip and expected_chip != actual_chip:
raise RuntimeError(
"force 风险拦截:设备芯片与配置不一致,"
f"cfg={self.private_cfg.chip}, detected={chip_name or 'unknown'}"
)
try:
image = LoadFirmwareImage(chip_name or self.private_cfg.chip, payload)
except Exception as exc: # noqa: BLE001
raise RuntimeError(
f"force 风险拦截:无法解析 bootloader 镜像头({entry.name}),已拒绝写入。"
) from exc
image_chip_id = getattr(image, "chip_id", None)
target_chip_id = getattr(esp, "IMAGE_CHIP_ID", None)
if image_chip_id != target_chip_id:
raise RuntimeError(
"force 风险拦截bootloader 芯片 ID 与目标芯片不匹配,"
f"image={image_chip_id}, target={target_chip_id}"
)
def _to_int(value: object, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
image_min_rev = _to_int(getattr(image, "min_rev", 0), 0)
image_min_rev_full = _to_int(getattr(image, "min_rev_full", 0), 0)
image_max_rev_full = _to_int(getattr(image, "max_rev_full", 0), 0)
if image_max_rev_full == 0:
use_rev_full_fields = False
elif image_max_rev_full == 65535:
use_rev_full_fields = not (image_min_rev_full == 0 and image_min_rev != 0)
else:
use_rev_full_fields = True
if use_rev_full_fields:
try:
chip_rev_full = _to_int(getattr(esp, "get_chip_revision")(), -1)
except Exception as exc: # noqa: BLE001
raise RuntimeError("force 风险拦截:无法读取芯片 revision 信息。") from exc
if chip_rev_full < image_min_rev_full or chip_rev_full > image_max_rev_full:
upper = (
"max rev not set"
if image_max_rev_full == 65535
else self._format_rev_full(image_max_rev_full)
)
raise RuntimeError(
"force 风险拦截bootloader revision 约束不满足,"
f"requires=[{self._format_rev_full(image_min_rev_full)} - {upper}], "
f"chip={self._format_rev_full(chip_rev_full)}"
)
else:
try:
if chip_name == "ESP32-C3":
chip_rev = _to_int(getattr(esp, "get_minor_chip_version")(), -1)
else:
chip_rev = _to_int(getattr(esp, "get_major_chip_version")(), -1)
except Exception as exc: # noqa: BLE001
raise RuntimeError("force 风险拦截:无法读取芯片 revision 信息。") from exc
if chip_rev < image_min_rev:
raise RuntimeError(
"force 风险拦截bootloader 最小 revision 要求不满足,"
f"requires={image_min_rev}, chip={chip_rev}"
)
if not encrypt and chip_name != "ESP8266":
try:
flash_encryption_enabled = bool(getattr(esp, "get_flash_encryption_enabled")())
except Exception as exc: # noqa: BLE001
raise RuntimeError(
"force 风险拦截:无法确认 Flash Encryption 状态,已拒绝明文 force 写入。"
) from exc
if flash_encryption_enabled:
raise RuntimeError(
"force 风险拦截:检测到 Flash Encryption 已启用,拒绝明文 force 写入。"
)
secure_download_mode = bool(getattr(esp, "secure_download_mode", False))
if chip_name != "ESP32" and secure_download_mode:
try:
security_info = getattr(esp, "get_security_info")()
flash_crypt_cnt = _to_int(
security_info.get("flash_crypt_cnt") if isinstance(security_info, dict) else 0
)
except Exception as exc: # noqa: BLE001
raise RuntimeError(
"force 风险拦截:无法读取 secure download 状态下的 flash_crypt_cnt。"
) from exc
if bin(flash_crypt_cnt).count("1") & 1 != 0:
raise RuntimeError(
"force 风险拦截:检测到加密状态,拒绝在 secure download 模式下明文 force 写入。"
)
def ensure_files_exist(self, paths: Iterable[Path]) -> bool:
missing = [str(p) for p in paths if not p.exists()]
if missing:
self.log("[错误] 缺少必要文件。")
if not self.safe_log:
for item in missing:
self.log(f" - {_display_path(self.base_dir, Path(item))}")
self._notify("error", "文件缺失", "缺少必要文件,请查看日志。")
return False
return True
def _auto_detect_port(self, show_dialog: bool) -> Optional[str]:
ports = list_serial_ports(self.python_cmd, show_dialog)
if not ports:
if show_dialog:
self._notify("warn", "未检测到串口", "未检测到可用串口,请手动输入。")
return None
selected = pick_preferred_port(ports)
if len(ports) > 1:
if self.safe_log:
self.log("[提示] 检测到多个串口,已自动选择。")
else:
self.log(f"[提示] 检测到多个串口: {', '.join(ports)}")
self.log(f"[提示] 自动选择: {selected}")
if show_dialog:
message = "检测到多个串口,已自动选择。"
if not self.safe_log:
message = f"检测到多个串口:\n{', '.join(ports)}\n\n已自动选择: {selected}"
self._notify("info", "检测到多个串口", message)
return selected
def _auto_set_port(self, initial: bool) -> None:
if not initial and self.port_var.get().strip():
return
port = self._auto_detect_port(show_dialog=False)
if port:
self.port_var.set(port)
def preflight_check(self) -> None:
if not self.ensure_tools_available():
self.set_status("缺少工具。")
self.set_status_tone("error")
return
self.log("[OK] 检测到工具,准备就绪。")
self._refresh_mode_status()
self._refresh_key_status()
self.set_status("就绪。")
self.set_status_tone("success")
self.update_flash_buttons()
def flow_task(self) -> None:
self.suppress_ready_status = True
self.current_device_id = None
self.last_flash_app_only = None
self._post_flash_reset_done = False
self._begin_run_record("")
success = False
try:
port = self.port_var.get().strip()
if not port:
port = self._auto_detect_port(show_dialog=False) or ""
if not port:
self.set_status("未检测到串口。")
self.set_status_tone("warn")
self._notify("warn", "未检测到串口", "未检测到可用串口,请手动输入。")
self._record_error("preflight", "no_port")
return
self.port_var.set(port)
if self.run_record is not None:
self.run_record["port"] = port
if not self._ensure_private_config(port, show_dialog=True):
self.set_status("配置加载失败。")
self.set_status_tone("error")
self._record_error("preflight", "private_config_missing")
return
self.set_progress(0.0)
self.recommend_var.set("步骤 1/3设备检测中...")
if not self.device_check_task():
self._record_error("device_check", "failed")
return
flash_start_ts = time.time()
mode = self._decide_flash_mode()
if mode is None:
reason = self.last_flash_block_reason or "mode_unknown"
self._record_error("flash", reason)
self._record_step_result(
"flash",
flash_start_ts,
"failed",
{"reason": reason},
)
return
encrypt, app_only, mode_label = mode
self.last_flash_app_only = app_only
self.recommend_var.set(f"步骤 2/3{mode_label}烧录中...")
if not self.flash_task(encrypt=encrypt, app_only=app_only):
self._record_error("flash", "failed")
return
self.recommend_var.set("步骤 3/3安全复检中...")
if not self.post_flash_check_task(port):
self._record_error("post_flash_check", "failed")
return
self._attempt_reset(port)
time.sleep(1.0)
self.recommend_var.set("完成")
self.set_status("流程完成。")
self.set_status_tone("success")
self._notify("info", "完成", "烧录流程完成。")
success = True
finally:
self._finalize_run_record(success)
def _decide_flash_mode(self) -> Optional[Tuple[bool, bool, str]]:
self.last_flash_block_reason = None
if not self.summary_ok:
self._notify("error", "检测失败", "设备检测未通过,无法继续。")
self.last_flash_block_reason = "summary_failed"
return None
if self.download_mode_disabled is True:
self._notify("error", "下载模式已禁用", "ROM 下载模式已禁用,无法串口烧录。")
self.last_flash_block_reason = "download_mode_disabled"
return None
if self.secure_download_enabled is True:
self._notify(
"error",
"安全下载模式",
"检测到安全下载模式已启用,当前工具不支持安全下载协议。",
)
self.last_flash_block_reason = "secure_download_enabled"
return None
encrypt: Optional[bool] = None
if self.last_rec_mode == "1":
encrypt = False
elif self.last_rec_mode == "2":
encrypt = True
if encrypt is None:
self._notify("error", "模式未知", "无法判断烧录模式,请检查设备状态。")
self.last_flash_block_reason = "rec_mode_unknown"
return None
if encrypt and self.manual_encrypt_disabled is True:
self._notify(
"error",
"串口加密受限",
"检测到禁用串口手动加密,无法使用当前工具进行加密写入。",
)
self.last_flash_block_reason = "manual_encrypt_disabled"
return None
if self.secure_boot_enabled is True:
if self.boot_chain_present is False:
if encrypt:
return True, False, "加密全量(初次写入)"
return False, False, f"{self.plain_text}(初次写入)"
mode_label = self.app_text if encrypt else f"{self.app_text}(不加密)"
return encrypt, True, mode_label
if encrypt is False:
return False, False, f"{self.plain_text}"
if encrypt is True:
return True, False, "加密全量"
self._notify("error", "模式未知", "无法判断烧录模式,请检查设备状态。")
return None
def _read_efuse_summary(self, port: str) -> Tuple[int, str, Optional[dict]]:
args = [
"--chip",
self.private_cfg.chip,
"--port",
port,
"summary",
"--format",
"json",
]
code, output = self.run_tool("espefuse", args, stream=False)
summary_json = _extract_json_blob(output) if code == 0 else None
return code, output, summary_json
def _log_efuse_summary(self, output: str) -> None:
if not output:
return
self.log_section("eFuse Summary")
if self.safe_log:
self.log("已获取(内容已脱敏)。", timestamp=False)
return
for line in output.splitlines():
self.log(line, timestamp=False)
def device_check_task(self) -> bool:
start_ts = time.time()
port = self.port_var.get().strip()
if not port:
port = self._auto_detect_port(show_dialog=False) or ""
if not port:
self._notify("warn", "未检测到串口", "未检测到可用串口,请手动输入。")
self._record_step_result(
"device_check",
start_ts,
"failed",
{"reason": "no_port"},
)
return False
self.port_var.set(port)
if not self._ensure_private_config(port, show_dialog=True):
self._record_step_result(
"device_check",
start_ts,
"failed",
{"reason": "private_config_missing"},
)
return False
if not self.ensure_tools_available():
self._record_step_result(
"device_check",
start_ts,
"failed",
{"reason": "tools_missing"},
)
return False
self.set_status("正在检测设备...")
self.set_status_tone("info")
self.log_section("设备检测")
if not self.safe_log:
self.log(f"串口: {port}")
self.recommend_var.set("正在检测设备...")
self.set_hint_text("", tone="info")
code, summary_output, summary_json = self._read_efuse_summary(port)
if summary_output:
self._log_efuse_summary(summary_output)
self.summary_attempted = True
if code != 0 or summary_json is None:
if code != 0:
self.log("[警告] 设备检测失败。")
else:
self.log("[警告] 设备检测失败JSON 解析失败。")
self.set_status("检测失败。")
self.set_status_tone("warn")
self.summary_ok = False
self.last_rec_mode = None
self.secure_boot_blocked = False
self.secure_boot_enabled = None
self.boot_chain_present = None
self.boot_chain_probe_reason = None
self.download_mode_disabled = None
self.secure_download_enabled = None
self.manual_encrypt_disabled = None
self.checked_port = None
self.card_secure_boot_var.set("检测失败")
self.card_flash_enc_var.set("检测失败")
self.card_recommend_var.set("请检查连接后重试")
hints = collect_error_hints(summary_output)
# 不展示检测结果,只提示信息
self.recommend_var.set("检测失败,请查看提示后重试。")
self.set_hint_text("\n".join(f"- {hint}" for hint in hints), tone="warn")
self.log("操作指引:", timestamp=False)
for hint in hints:
self.log(f" - {hint}", timestamp=False)
self.update_flash_buttons()
self.log("流程已停止,请重试设备检测。")
self._record_step_result(
"device_check",
start_ts,
"failed",
{
"reason": "summary_json_failed" if summary_json is None else "summary_failed",
"hints": hints,
"port": port,
},
)
return False
rec_mode, rec_reason = recommend_mode_from_json(
summary_json, self.private_cfg.device_check.crypt_cnt_fields
)
self.summary_ok = True
self.last_rec_mode = rec_mode
self.secure_boot_blocked = False
self.secure_boot_enabled = get_efuse_flag(
summary_json, self.private_cfg.device_check.secure_boot_field
)
self.download_mode_disabled = get_efuse_flag(
summary_json, "DIS_DOWNLOAD_MODE", "UART_DOWNLOAD_DIS"
)
self.secure_download_enabled = get_efuse_flag(
summary_json, "ENABLE_SECURITY_DOWNLOAD", "SECURE_DOWNLOAD_MODE"
)
self.manual_encrypt_disabled = get_efuse_flag(
summary_json, "DIS_DOWNLOAD_MANUAL_ENCRYPT"
)
if self.secure_boot_enabled is True and rec_mode == "1":
self.boot_chain_present, self.boot_chain_probe_reason = self._probe_boot_chain_header(port)
elif self.secure_boot_enabled is True and rec_mode == "2":
# When flash encryption is enabled, raw flash reads are ciphertext and
# cannot be used to infer plaintext image headers at 0x0.
self.boot_chain_present = True
self.boot_chain_probe_reason = "skip_probe_flash_encryption_enabled"
else:
self.boot_chain_present = None
self.boot_chain_probe_reason = "skip_probe_not_applicable"
self.checked_port = port
mac = self._read_chip_mac(port)
if mac:
self.device_id_hint = mac
if self.run_record and not self.run_record.get("device_id"):
self.run_record["device_id"] = mac
flash_enc_status = "未知"
if rec_mode == "1":
flash_enc_status = "未启用"
elif rec_mode == "2":
flash_enc_status = "已启用"
self.card_flash_enc_var.set(flash_enc_status)
if self.secure_boot_enabled is True:
self.card_secure_boot_var.set("已启用")
elif self.secure_boot_enabled is False:
self.card_secure_boot_var.set("未启用")
else:
self.card_secure_boot_var.set("未知")
boot_chain_missing = self.boot_chain_present is False
if self.secure_boot_enabled:
if boot_chain_missing:
if rec_mode == "1":
recommend_text = f"下一步:{self.plain_text}(初次全量)"
elif rec_mode == "2":
recommend_text = "检测到安全启动已启用且启动链缺失,将执行加密全量初次写入。"
else:
recommend_text = "检测到安全启动已启用且启动链缺失,请先核对设备信息。"
elif rec_mode == "1":
recommend_text = f"下一步:{self.app_text}(不加密)"
elif rec_mode == "2":
recommend_text = f"下一步:{self.app_text}(加密)"
else:
recommend_text = "请先核对设备信息"
elif rec_mode == "1":
recommend_text = f"下一步:{self.plain_text}"
elif rec_mode == "2":
recommend_text = "检测到闪存加密已启用但安全启动未启用,将执行加密全量烧录并在首启后启用安全启动。"
else:
recommend_text = "请先核对设备信息"
if self.download_mode_disabled is True:
recommend_text = "检测到下载模式已禁用,请按工艺处理。"
elif self.secure_download_enabled is True:
recommend_text = "检测到安全下载模式已启用,请按工艺处理。"
elif self.manual_encrypt_disabled is True and rec_mode == "2":
recommend_text = "检测到禁用串口手动加密,请使用预加密固件或 OTA。"
self.recommend_var.set(recommend_text)
self.card_recommend_var.set(recommend_text)
hint_lines = []
hint_tone = "info"
if rec_mode == "1":
if self.secure_boot_enabled:
if boot_chain_missing:
hint_lines.append("检测到启动链缺失,将执行模式 A全量写入完成初次写入。")
else:
hint_lines.append("提示:安全启动已启用但闪存加密未启用,将以非加密写入方式执行模式 C。")
hint_tone = "warn"
else:
hint_lines.append("已就绪:执行模式 A全量写入")
elif rec_mode == "2":
if self.secure_boot_enabled:
if boot_chain_missing:
hint_lines.append("检测到启动链缺失,将执行加密全量初次写入。")
hint_tone = "warn"
else:
hint_lines.append("已就绪:执行模式 C加密")
else:
hint_lines.append("提示:将执行加密全量烧录,首启后自动启用安全启动。")
hint_tone = "warn"
else:
hint_lines.append("信息不明确:请按工艺要求核对后再选择模式。")
hint_tone = "warn"
if self.secure_boot_enabled:
if boot_chain_missing:
hint_lines.append("检测到 flash@0x0 启动头无效/空白,将改为全量写入并在需要时使用 force。")
else:
hint_lines.append("已限制,仅允许模式 C。")
if self.download_mode_disabled is True:
hint_lines.append("检测到下载模式已禁用,串口烧录不可用。")
hint_tone = "error"
if self.secure_download_enabled is True:
hint_lines.append("检测到安全下载模式已启用,当前工具不支持安全下载。")
if hint_tone != "error":
hint_tone = "warn"
if self.manual_encrypt_disabled is True:
if rec_mode == "2":
hint_lines.append("检测到禁用串口手动加密,无法进行加密写入。")
hint_tone = "error"
else:
hint_lines.append("检测到禁用串口手动加密(仅影响加密写入)。")
if hint_tone != "error":
hint_tone = "warn"
self.set_hint_text("\n".join(hint_lines), tone=hint_tone)
self.log("-" * 46, timestamp=False)
self.log("设备检测完成,已自动确定烧录模式。")
self.set_status("检测完成。")
self.set_status_tone("success")
self.update_flash_buttons()
self._record_step_result(
"device_check",
start_ts,
"ok",
{
"port": port,
"recommend_mode": rec_mode,
"recommend_reason": rec_reason,
"secure_boot_enabled": self.secure_boot_enabled,
"boot_chain_present": self.boot_chain_present,
"boot_chain_probe_reason": self.boot_chain_probe_reason,
"flash_enc_status": flash_enc_status,
"download_mode_disabled": self.download_mode_disabled,
"secure_download_enabled": self.secure_download_enabled,
"manual_encrypt_disabled": self.manual_encrypt_disabled,
},
)
return True
def _format_recommendation(
self, mode: Optional[str], reason: Optional[str], warn_reason: Optional[str]
) -> str:
if warn_reason and not mode:
return f"摘要不可用({warn_reason})。"
if mode:
if mode == "1":
label = self.plain_text
elif mode == "2":
label = self.app_text
else:
label = f"模式 {mode}"
reason_suffix = f"{reason}" if reason else ""
if mode in ("1", "2"):
return f"下一步:{label}(模式 {mode}{reason_suffix}"
return f"模式提示:{mode}{reason_suffix}"
return "暂无指引。"
def flash_task(self, encrypt: bool, app_only: bool = False) -> bool:
start_ts = time.time()
if not self.summary_attempted:
self.log("[警告] 请先完成设备检测。")
self._notify("warn", "请先设备检测", "请先完成设备检测,再开始烧录。")
self._record_step_result(
"flash",
start_ts,
"failed",
{"reason": "summary_missing"},
)
return False
port = self.port_var.get().strip()
if not port:
port = self._auto_detect_port(show_dialog=False) or ""
if not port:
self._notify("warn", "未检测到串口", "未检测到可用串口,请手动输入。")
self._record_step_result(
"flash",
start_ts,
"failed",
{"reason": "no_port"},
)
return False
self.port_var.set(port)
if not self._ensure_private_config(port, show_dialog=True):
self._record_step_result(
"flash",
start_ts,
"failed",
{"reason": "private_config_missing"},
)
return False
bin_dir = select_bin_dir(self.base_dir, self.private_cfg.bin_dir)
entries = build_flash_entries(
bin_dir,
self.private_cfg.layout,
app_only,
self.private_cfg.app_partition_offset,
self.private_cfg.app_bin_name,
)
secure_entries = self.prepare_secure_entries(entries)
if not secure_entries:
return False
self.log("固件目录已加载。")
if app_only:
mode_label = self.app_text if encrypt else f"{self.app_text}(不加密)"
else:
mode_label = self.plain_text
self.active_mode_label = mode_label
self.suppress_ready_status = True
self.set_progress(0.0)
self.set_status(f"{mode_label}进行中... 0%")
self.set_status_tone("info")
self.log_section(f"{mode_label}烧录开始")
if not self.safe_log:
self.log(f"串口: {port} | 模式: {mode_label}")
# Force is only allowed for secure-boot recovery of a missing boot chain.
force_full_flash = bool(
self.secure_boot_enabled is True and not app_only and self.boot_chain_present is False
)
if force_full_flash:
low_entries = [entry for entry in secure_entries if entry.offset < 0x8000]
force_entries = [entry for entry in low_entries if self._is_force_bootloader_entry(entry)]
if len(low_entries) != 1 or len(force_entries) != 1:
self.log("[错误] force 安全策略校验失败:仅允许 bootloader@0x0 使用 force。")
self._record_step_result(
"flash",
start_ts,
"failed",
{
"reason": "force_policy_mismatch",
"entries": [
{"name": entry.name, "offset": entry.offset, "path": str(entry.path)}
for entry in secure_entries
],
},
)
self._notify("error", "烧录策略异常", "force 策略校验失败,已停止以避免误写低地址。")
return False
self.log("[提示] 检测到安全启动已启用且启动链缺失:仅对 bootloader@0x0 启用 force。")
strict_flash = bool(encrypt or self.secure_boot_enabled)
max_attempts = self.flash_secure_max_attempts if strict_flash else self.flash_max_attempts
attempts_used = 0
flash_ok = False
last_output = ""
last_report: Optional[FlashReport] = None
last_failure_stage = "固件烧录"
baud_override: Optional[str] = None
for attempt in range(1, max_attempts + 1):
attempts_used = attempt
if attempt > 1:
self.log(f"[提示] 第 {attempt} 次烧录尝试。")
code, output, report = self.do_flash(
port,
encrypt=encrypt,
entries=secure_entries,
on_output=self._on_flash_output,
baud_override=baud_override,
force=force_full_flash,
)
last_output = output
last_report = report
if not output.strip():
self.log("(无输出)")
if code == 0:
self.set_status("写后校验中...")
self.set_status_tone("info")
verify_ok, verify_err = self._verify_flash_readback(
port, report, encrypt, baud_override=baud_override
)
self._log_flash_report(report)
if verify_ok:
self.log("[OK] 固件烧录完成。")
self.set_progress(100.0)
flash_ok = True
break
last_failure_stage = "写后校验"
last_output = verify_err or "readback_verify_failed"
self.log("[错误] 写后校验失败。")
else:
if detect_secure_boot_block(output):
self.secure_boot_blocked = True
self.update_flash_buttons()
if strict_flash:
break
if attempt < max_attempts:
self.log("[警告] 烧录失败,准备复位后重试。")
if baud_override is None:
baud_override = "115200"
self.log("[提示] 已自动降速到 115200 后重试。")
self._attempt_reset(port)
time.sleep(1.0)
if not flash_ok:
failure_entries = None
if last_report:
failure_entries = [
{
"name": item.name,
"offset": item.offset,
"size": item.size,
"sha256": item.sha256,
"readback_sha256": item.readback_sha256,
"verified": item.verified,
}
for item in last_report.entries
]
self._record_step_result(
"flash",
start_ts,
"failed",
{
"reason": last_failure_stage,
"attempts": attempts_used,
"mode": mode_label,
"encrypt": encrypt,
"app_only": app_only,
"force": force_full_flash,
"app_bin_name": self.private_cfg.app_bin_name,
"readback_errors": last_report.readback_errors if last_report else None,
"entries": failure_entries,
},
)
self.handle_flash_failure(last_failure_stage, last_output)
return False
if last_report:
app_entry = next(
(item for item in last_report.entries if item.name == self.private_cfg.app_bin_name),
None,
)
self._record_step_result(
"flash",
start_ts,
"ok",
{
"attempts": attempts_used,
"mode": mode_label,
"encrypt": encrypt,
"app_only": app_only,
"force": force_full_flash,
"app_bin_name": self.private_cfg.app_bin_name,
"entries": [
{
"name": item.name,
"offset": item.offset,
"size": item.size,
"sha256": item.sha256,
"readback_sha256": item.readback_sha256,
"verified": item.verified,
}
for item in last_report.entries
],
"readback_ok": last_report.readback_ok,
"readback_errors": last_report.readback_errors,
"app_sha256": app_entry.sha256 if app_entry else None,
},
)
else:
self._record_step_result(
"flash",
start_ts,
"ok",
{
"attempts": attempts_used,
"mode": mode_label,
"encrypt": encrypt,
"app_only": app_only,
"force": force_full_flash,
},
)
self.log("[OK] 烧录完成。")
needs_reset = self._needs_post_flash_reset()
if not needs_reset:
self._post_flash_reset_done = True
if needs_reset:
self.log("[提示] 正在复位设备进入应用模式...")
self._attempt_reset(port)
self._post_flash_reset_done = True
time.sleep(1.0)
self.set_status("烧录完成。")
self.set_status_tone("success")
self.set_hint_text("", tone="success")
return True
def post_flash_check_task(self, port: str) -> bool:
start_ts = time.time()
if not self.ensure_tools_available():
self._record_step_result(
"post_flash_check",
start_ts,
"failed",
{"reason": "tools_missing"},
)
return False
self.set_status("安全复检中...")
self.set_status_tone("info")
self.log_section("安全复检")
if not self.safe_log:
self.log(f"串口: {port}")
timeout_s = max(5.0, self.ready_timeout_s)
deadline = time.time() + timeout_s
attempts = 0
last_errors: list[str] = []
last_details: dict = {}
last_output = ""
if not self._post_flash_reset_done:
self._attempt_reset(port)
self._post_flash_reset_done = True
time.sleep(1.0)
while time.time() < deadline:
attempts += 1
wait_cap = 60.0
if self.last_flash_app_only:
wait_cap = 5.0
wait_s = min(wait_cap, max(1.0, deadline - time.time()))
self.log(f"[提示] 等待设备完成安全流程(约 {int(wait_s)} 秒)...")
time.sleep(wait_s)
code, output, summary_json = self._read_efuse_summary(port)
last_output = output
if code == 0 and summary_json is not None:
ok, details, errors = evaluate_post_flash_status(
summary_json, self.private_cfg.device_check
)
last_details = details
if ok:
self.log("[OK] 安全复检通过。")
self._record_step_result(
"post_flash_check",
start_ts,
"ok",
{
"attempts": attempts,
"details": details,
},
)
self.set_status("安全复检完成。")
self.set_status_tone("success")
return True
last_errors = errors
else:
last_errors = [
"summary_failed" if code != 0 else "summary_json_failed"
]
self.log("[错误] 安全复检失败。")
if not self.safe_log and last_output:
self.log(last_output)
if last_errors:
self.log(f"[错误] 安全复检未通过: {', '.join(last_errors)}")
self.set_status("安全复检失败。")
self.set_status_tone("error")
if last_errors:
self.set_hint_text(
"\n".join(f"- {item}" for item in last_errors), tone="error"
)
self._notify("error", "安全复检失败", "安全复检失败,请查看日志。")
self._record_step_result(
"post_flash_check",
start_ts,
"failed",
{
"attempts": attempts,
"errors": last_errors,
"details": last_details,
},
)
return False
def handle_flash_failure(self, stage: str, output: str) -> None:
self.log(f"[错误] {stage}失败。请检查连接、串口及 BOOT/RESET 操作。")
self.set_status(f"{stage}失败。")
self.set_status_tone("error")
self.set_progress(0.0)
if detect_secure_boot_block(output):
self.secure_boot_blocked = True
if self.secure_boot_enabled is not True:
self.secure_boot_enabled = True
self.card_secure_boot_var.set("已启用")
self.update_flash_buttons()
hints = collect_error_hints(output)
if detect_secure_boot_block(output):
if self.boot_chain_present is False:
hints.insert(0, "检测到安全启动限制:启动链缺失时请使用全量写入(必要时 force")
else:
hints.insert(0, "检测到安全启动已启用,常规更新仅允许模式 CApp-only")
self.set_hint_text("\n".join(f"- {hint}" for hint in hints), tone="error")
self.log("操作指引:", timestamp=False)
for hint in hints:
self.log(f" - {hint}", timestamp=False)
self._notify("error", f"{stage}失败", f"{stage}失败,请查看日志与提示。")
def _attempt_reset(self, port: str) -> None:
args = ["--chip", self.private_cfg.chip, "--port", port, "run"]
code, output = self.run_tool("esptool", args, stream=False)
if code != 0:
self.log(f"[警告] 复位失败:{output.strip()}")
def _needs_post_flash_reset(self) -> bool:
after = self.private_cfg.flash_args.get("after")
if after is None:
return False
after_norm = str(after).strip()
if not after_norm:
return False
after_norm = after_norm.replace("_", "-").lower()
return after_norm in ("no-reset", "noreset", "none", "no")
def _read_flash_bytes(
self, port: str, offset: int, size: int, baud_override: Optional[str] = None
) -> Optional[bytes]:
fd, temp_path = tempfile.mkstemp(suffix=".bin")
os.close(fd)
args = ["--chip", self.private_cfg.chip, "--port", port]
baud_value = baud_override or self.private_cfg.baud
if baud_value:
baud_int = _value_to_int(baud_value)
if baud_int is not None and baud_int > 921600 and baud_override is None:
baud_value = "115200"
args.extend(["--baud", str(baud_value)])
try:
read_args = args + ["read-flash", hex(offset), str(size), temp_path]
code, output = self.run_tool("esptool", read_args, stream=False)
if code != 0 and ("Unknown command" in output or "invalid choice" in output):
read_args = args + ["read_flash", hex(offset), str(size), temp_path]
code, output = self.run_tool("esptool", read_args, stream=False)
if code != 0:
self.log(f"[警告] 读取闪存失败:{output.strip()}")
return None
try:
return Path(temp_path).read_bytes()
except OSError as exc:
self.log(f"[警告] 读取闪存临时文件失败:{exc}")
return None
finally:
try:
os.remove(temp_path)
except OSError:
pass
def _partition_table_offset_from_layout(self) -> Optional[int]:
for offset, name in self.private_cfg.layout:
if not isinstance(name, str):
continue
lowered = name.lower()
if "partition-table" not in lowered and "partition_table" not in lowered:
continue
try:
return _parse_offset(offset)
except ValueError:
self.log(f"[警告] 分区表偏移无效:{offset}")
return None
return None
def _probe_boot_chain_header(self, port: str) -> tuple[Optional[bool], Optional[str]]:
header = self._read_flash_bytes(port, 0x0, 32)
if header is None:
self.log("[警告] 启动链检测跳过:读取 flash@0x0 失败。")
return None, "probe_failed"
has_boot_chain, reason = detect_boot_chain_header_state(header)
if not has_boot_chain:
self.log(f"[警告] 启动链缺失flash@0x0{reason})。")
return False, reason
partition_offset = self._partition_table_offset_from_layout()
if partition_offset is None:
self.log("[提示] 未找到分区表偏移,已按 bootloader 头有效处理。")
return True, "boot_ok_partition_unknown"
partition_header = self._read_flash_bytes(port, partition_offset, 4)
if partition_header is None:
self.log(f"[警告] 启动链检测跳过:读取分区表头失败({hex(partition_offset)})。")
return None, "partition_probe_failed"
if all(value == 0xFF for value in partition_header):
self.log(f"[警告] 启动链缺失(分区表空白,{hex(partition_offset)})。")
return False, "partition_blank_ff"
if partition_header[:2] != b"\xAA\x50":
self.log(
f"[警告] 启动链缺失(分区表头无效,{hex(partition_offset)}magic=0x{partition_header[0]:02x}{partition_header[1]:02x})。"
)
return False, "partition_invalid_magic"
self.log("[OK] 启动链检测通过bootloader/partition table 头有效)。")
return True, "ok"
def _read_flash_segment(
self, port: str, offset: int, size: int, baud_override: Optional[str] = None
) -> Optional[str]:
fd, temp_path = tempfile.mkstemp(suffix=".bin")
os.close(fd)
args = ["--chip", self.private_cfg.chip, "--port", port]
baud_value = baud_override or self.private_cfg.baud
if baud_value:
baud_int = _value_to_int(baud_value)
if baud_int is not None and baud_int > 921600 and baud_override is None:
baud_value = "115200"
args.extend(["--baud", str(baud_value)])
try:
read_args = args + ["read-flash", hex(offset), str(size), temp_path]
code, output = self.run_tool("esptool", read_args, stream=False)
if code != 0 and ("Unknown command" in output or "invalid choice" in output):
read_args = args + ["read_flash", hex(offset), str(size), temp_path]
code, output = self.run_tool("esptool", read_args, stream=False)
if code != 0:
self.log(f"[错误] 读回校验失败:{output.strip()}")
return None
try:
hasher = hashlib.sha256()
total = 0
with open(temp_path, "rb") as handle:
while True:
chunk = handle.read(65536)
if not chunk:
break
total += len(chunk)
hasher.update(chunk)
except OSError as exc:
self.log(f"[错误] 读回文件读取失败:{exc}")
return None
if total != size:
self.log(f"[警告] 读回长度异常:{total} != {size}")
return hasher.hexdigest()
finally:
try:
os.remove(temp_path)
except OSError:
pass
def _verify_flash_readback(
self,
port: str,
report: FlashReport,
encrypt: bool,
baud_override: Optional[str] = None,
) -> Tuple[bool, str]:
if encrypt:
report.readback_ok = True
report.readback_errors = []
return True, ""
# esptool already performs in-band hash verification for plaintext writes.
# Extra readback can fail due transient port locking on some hosts; keep it opt-in.
if self.private_cfg.ui.get("strict_readback") is not True:
report.readback_ok = True
report.readback_errors = []
return True, ""
if not report.entries:
return True, ""
ok = True
errors: list[str] = []
for entry in report.entries:
readback_hash = self._read_flash_segment(
port, entry.offset, entry.size, baud_override=baud_override
)
if readback_hash is None:
ok = False
errors.append(f"{entry.name}:readback_failed")
continue
entry.readback_sha256 = readback_hash
if encrypt:
entry.verified = None
continue
entry.verified = (entry.readback_sha256 == entry.sha256)
if entry.verified is False:
ok = False
errors.append(f"{entry.name}:hash_mismatch")
report.readback_ok = ok
report.readback_errors = errors
return ok, "; ".join(errors)
def _log_flash_report(self, report: FlashReport) -> None:
if self.safe_log:
return
for entry in report.entries:
self.log(f"[HASH] {entry.name} sha256={entry.sha256}")
if entry.readback_sha256:
self.log(f"[READBACK] {entry.name} sha256={entry.readback_sha256}")
if entry.verified is True:
self.log(f"[OK] 校验一致: {entry.name}")
elif entry.verified is False:
self.log(f"[错误] 校验不一致: {entry.name}")
def do_write_flash(
self,
port: str,
entries: Iterable[FlashSource],
encrypt: bool,
on_output: Optional[Callable[[str], None]],
baud_override: Optional[str] = None,
force: bool = False,
) -> Tuple[int, str, FlashReport]:
if not self.ensure_esptool_api_available():
return 1, "esptool API unavailable", FlashReport(entries=[], readback_ok=False, readback_errors=[])
entries_list = list(entries)
try:
decrypt_key = load_firmware_decrypt_key()
except FirmwareEnvelopeError as exc:
return 1, str(exc), FlashReport(entries=[], readback_ok=False, readback_errors=[])
report = FlashReport(entries=[], readback_ok=False, readback_errors=[])
def _run() -> None:
import esptool
from esptool import cmds
from esptool.logger import EsptoolLogger, log as esptool_log
try:
EsptoolLogger._set_smart_features(False)
except Exception:
pass
try:
esptool_log.set_verbosity("normal")
except Exception:
pass
baud_value = _value_to_int(baud_override or self.private_cfg.baud)
if baud_value is None:
raise ValueError(f"无效波特率: {baud_override or self.private_cfg.baud}")
bootstrap_baud = 115200
use_stub = bool(self.private_cfg.flash_args.get("use_stub", True))
esp = esptool.detect_chip(
port=port,
baud=bootstrap_baud,
connect_mode=self.private_cfg.flash_args["before"],
)
try:
if use_stub:
try:
esp = cmds.run_stub(esp)
except Exception as exc: # noqa: BLE001
if on_output:
on_output(f"[警告] 启动 stub 失败,已降级为 no-stub: {exc}\n")
if baud_value != bootstrap_baud:
try:
esp.change_baud(baud_value)
except Exception as exc: # noqa: BLE001
if on_output:
on_output(
f"[警告] 切换波特率到 {baud_value} 失败,继续使用 {bootstrap_baud}: {exc}\n"
)
# Keep API flow consistent with esptool CLI: attach SPI flash before write.
cmds.attach_flash(esp, None)
for entry in entries_list:
try:
payload = entry.path.read_bytes()
except OSError as exc:
raise RuntimeError(f"固件读取失败: {entry.path.name}") from exc
try:
payload = decrypt_firmware_blob(payload, decrypt_key)
except FirmwareEnvelopeError as exc:
raise RuntimeError(f"固件解密失败: {entry.path.name}: {exc}") from exc
plain: Optional[bytearray] = None
try:
plain = bytearray(payload)
payload = b""
sha256 = _sha256_hex(plain)
report.entries.append(
FlashEntryReport(
name=entry.name,
offset=entry.offset,
size=len(plain),
sha256=sha256,
readback_sha256=None,
verified=None,
)
)
write_kwargs = {
"flash_freq": self.private_cfg.flash_args["flash_freq"],
"flash_mode": self.private_cfg.flash_args["flash_mode"],
"flash_size": self.private_cfg.flash_args["flash_size"],
"encrypt": encrypt,
}
plain_bytes = bytes(plain)
if force and entry.offset < 0x8000 and not self._is_force_bootloader_entry(entry):
raise RuntimeError(
f"force 策略拒绝低地址条目: {entry.name} @ {hex(entry.offset)}"
)
if force and self._is_force_bootloader_entry(entry):
self._guard_force_bootloader_write(
esp,
entry,
plain_bytes,
encrypt=encrypt,
)
write_kwargs["force"] = True
try:
cmds.write_flash(
esp,
[(entry.offset, plain_bytes)],
**write_kwargs,
)
except TypeError as exc:
if write_kwargs.get("force") and "force" in str(exc):
# Compatibility fallback for older esptool APIs.
write_kwargs.pop("force", None)
if on_output:
on_output("[警告] 当前 esptool API 不支持 force 参数,已回退默认写入。\n")
cmds.write_flash(
esp,
[(entry.offset, plain_bytes)],
**write_kwargs,
)
else:
raise
finally:
_wipe_bytearray(plain)
after = self.private_cfg.flash_args["after"]
if after:
after_norm = str(after).strip()
if after_norm:
after_norm = after_norm.replace("_", "-").lower()
if after_norm not in ("no-reset", "noreset", "none", "no"):
esptool.reset_chip(esp, reset_mode=after_norm)
finally:
try:
esp._port.close()
except Exception:
pass
code, output = run_esptool_api(_run, stream=True, on_output=on_output)
return code, output, report
def ensure_esptool_api_available(self) -> bool:
try:
import esptool # noqa: F401
return True
except Exception as exc: # noqa: BLE001
if self.frozen:
self._notify(
"error",
"缺少 esptool",
"内置环境缺少 esptool请重新打包。\n\n" + str(exc),
)
return False
script_path = self.base_dir / "flash_encrypted_gui.py"
self._notify(
"error",
"缺少 esptool",
"当前 Python 环境缺少 esptoolAPI 模式需要同一解释器可用)。\n"
f"请使用虚拟环境运行:\n {self.python_cmd} {script_path}\n"
f"或安装:\n {sys.executable} -m pip install esptool\n\n{exc}",
)
return False
def ensure_tools_available(self) -> bool:
if not self.ensure_esptool_api_available():
return False
code, output = self.run_tool("esptool", ["version"], stream=False)
if code != 0:
if self.frozen:
self._notify("error", "缺少 esptool", "内置环境缺少 esptool请重新打包。\n\n" + output)
return False
self._notify(
"error",
"缺少 esptool",
"未检测到 esptool。请先安装\n"
f"{self.python_cmd} -m pip install esptool\n\n{output}",
)
return False
if self.frozen:
try:
import importlib
importlib.import_module("espefuse")
except Exception as exc: # noqa: BLE001
self._notify(
"error",
"缺少 espefuse",
"内置环境缺少 espefuse请重新打包。\n\n" + str(exc),
)
return False
return True
code, output = self.run_tool("espefuse", ["--help"], stream=False)
if code != 0:
self._notify(
"error",
"缺少 espefuse",
"未检测到 espefuse。请先安装\n"
f"{self.python_cmd} -m pip install esptool\n\n{output}",
)
return False
return True
def run_tool(
self,
tool: str,
args: Iterable[str],
stream: bool,
on_output: Optional[Callable[[str], None]] = None,
) -> Tuple[int, str]:
if self.frozen:
return run_tool_module(tool, args, cwd=self.base_dir, stream=stream, on_output=on_output)
if self.python_cmd is None:
return 1, "Python 环境未就绪。"
cmd = [str(self.python_cmd), "-m", tool, *args]
return run_command(cmd, cwd=self.base_dir, stream=stream, on_output=on_output)
def do_flash(
self,
port: str,
encrypt: bool,
entries: Iterable[FlashSource],
on_output: Optional[Callable[[str], None]],
baud_override: Optional[str] = None,
force: bool = False,
) -> Tuple[int, str, FlashReport]:
return self.do_write_flash(
port,
entries,
encrypt=encrypt,
on_output=on_output,
baud_override=baud_override,
force=force,
)
def run(self) -> None:
self.root.mainloop()
def main() -> None:
try:
app = FlashGUI()
except (FileNotFoundError, PermissionError, ValueError, KeyError, TypeError) as exc:
try:
messagebox.showerror("配置错误", str(exc))
except tk.TclError:
print(exc, file=sys.stderr)
raise SystemExit(1) from exc
app.run()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(1)