780 lines
36 KiB
Python
780 lines
36 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""EG800 factory host: batch flashing, serial production tests, and reports.
|
|
|
|
The aboot command line is intentionally configuration-driven. A firmware
|
|
package must be selected explicitly and the configured argument list may use
|
|
{aboot}, {firmware}, {port}, and {station} placeholders.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import json
|
|
import os
|
|
import queue
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from tkinter import END, BOTH, LEFT, RIGHT, X, Y, StringVar, Tk, Toplevel, filedialog, messagebox
|
|
from tkinter import ttk
|
|
|
|
from ft1_protocol import FT1Session, NODE_NAMES, ProtocolError, error_text, parse_ft1_line
|
|
|
|
|
|
APP_DIR = Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else Path(__file__).resolve().parent
|
|
|
|
|
|
def discover_project_root(start: Path) -> Path:
|
|
"""Find the checkout root from either the source or the dist directory."""
|
|
for candidate in (start, *start.parents):
|
|
if (candidate / "burn_tool").exists() and ((candidate / "version").exists() or (candidate / "target").exists()):
|
|
return candidate
|
|
return start.parent
|
|
|
|
|
|
ROOT_DIR = discover_project_root(APP_DIR)
|
|
CONFIG_PATH = APP_DIR / "factory_host_config.json"
|
|
LOG_DIR = APP_DIR / "logs"
|
|
SESSION_DIR = LOG_DIR / "sessions"
|
|
|
|
|
|
DEFAULT_CONFIG = {
|
|
"flash": {
|
|
"aboot_path": "burn_tool/aboot.exe",
|
|
"downloader_path": "burn_tool/adownload.exe",
|
|
"args": ["-u", "-a", "-s", "{baudrate}", "-q", "{firmware}"],
|
|
"timeout_seconds": 900,
|
|
"workers": 2,
|
|
},
|
|
"firmwares": {"production": "", "official": ""},
|
|
"serial": {"baudrate": 115200, "encoding": "utf-8", "line_ending": "\\r\\n"},
|
|
"protocol": {"prefix": "FT1 ", "version": 1, "auto_save": True},
|
|
}
|
|
|
|
|
|
def load_config(path: Path = CONFIG_PATH) -> dict:
|
|
if not path.exists():
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2), encoding="utf-8-sig")
|
|
return json.loads(json.dumps(DEFAULT_CONFIG))
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise RuntimeError(f"配置文件读取失败: {exc}") from exc
|
|
merged = json.loads(json.dumps(DEFAULT_CONFIG))
|
|
merge_dict(merged, raw)
|
|
return merged
|
|
|
|
|
|
def merge_dict(dst: dict, src: dict) -> None:
|
|
for key, value in src.items():
|
|
if isinstance(value, dict) and isinstance(dst.get(key), dict):
|
|
merge_dict(dst[key], value)
|
|
else:
|
|
dst[key] = value
|
|
|
|
|
|
def save_config(config: dict, path: Path = CONFIG_PATH) -> None:
|
|
path.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig")
|
|
|
|
|
|
def resolve_project_path(value: str) -> Path:
|
|
raw = Path(value)
|
|
if raw.is_absolute():
|
|
return raw
|
|
candidates = [ROOT_DIR / raw, APP_DIR / raw, Path.cwd() / raw]
|
|
for candidate in candidates:
|
|
if candidate.exists():
|
|
return candidate.resolve()
|
|
return candidates[0].resolve()
|
|
|
|
|
|
def find_downloader(configured: str, aboot_configured: str = "") -> Path | None:
|
|
candidates = []
|
|
if configured:
|
|
raw = Path(configured)
|
|
candidates.extend([raw, ROOT_DIR / raw, APP_DIR / raw])
|
|
if aboot_configured:
|
|
raw = Path(aboot_configured)
|
|
candidates.extend([raw.parent / "adownload.exe", ROOT_DIR / raw.parent / "adownload.exe", APP_DIR / raw.parent / "adownload.exe"])
|
|
candidates.extend([ROOT_DIR / "burn_tool" / "adownload.exe", APP_DIR / "burn_tool" / "adownload.exe"])
|
|
for candidate in candidates:
|
|
try:
|
|
if candidate.is_file():
|
|
return candidate.resolve()
|
|
except OSError:
|
|
continue
|
|
for root in (ROOT_DIR / "burn_tool", APP_DIR / "burn_tool"):
|
|
try:
|
|
if root.is_dir():
|
|
found = next(root.rglob("adownload.exe"), None)
|
|
if found and found.is_file():
|
|
return found.resolve()
|
|
except OSError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def expand_args(args: list[str], *, aboot: Path, firmware: Path, port: str, station: str, baudrate: int = 115200) -> list[str]:
|
|
values = {"aboot": str(aboot), "firmware": str(firmware), "port": port, "station": station, "baudrate": str(baudrate)}
|
|
return [str(arg).format_map(values) for arg in args]
|
|
|
|
|
|
def parse_command_template(template: str) -> list[str]:
|
|
"""Parse a user-entered Windows command line while retaining quoted paths."""
|
|
if not template.strip():
|
|
return []
|
|
return [part.strip('"') for part in shlex.split(template, posix=False)]
|
|
|
|
|
|
def list_serial_ports() -> list[str]:
|
|
"""Return present COM ports without requiring a third-party module."""
|
|
ports = []
|
|
try:
|
|
import serial.tools.list_ports # type: ignore
|
|
|
|
ports = [item.device for item in serial.tools.list_ports.comports()]
|
|
except ImportError:
|
|
pass
|
|
if not ports and os.name == "nt":
|
|
try:
|
|
result = subprocess.run(
|
|
["powershell.exe", "-NoProfile", "-Command", "Get-CimInstance Win32_SerialPort | Select-Object -ExpandProperty DeviceID"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
check=False,
|
|
)
|
|
ports = [line.strip() for line in result.stdout.splitlines() if re.fullmatch(r"COM\d+", line.strip(), re.I)]
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return sorted(set(ports), key=lambda item: int(item[3:]) if item[3:].isdigit() else item)
|
|
|
|
|
|
class SerialReader:
|
|
"""Small Windows serial reader using pyserial when available, file handles otherwise."""
|
|
|
|
def __init__(self, port: str, baudrate: int, encoding: str, on_line):
|
|
self.port = port
|
|
self.baudrate = baudrate
|
|
self.encoding = encoding or "utf-8"
|
|
self.on_line = on_line
|
|
self._stop = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
self._stream = None
|
|
self._pyserial = None
|
|
|
|
def start(self) -> None:
|
|
if self._thread and self._thread.is_alive():
|
|
return
|
|
self._stop.clear()
|
|
self._thread = threading.Thread(target=self._run, name=f"serial-{self.port}", daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
try:
|
|
if self._stream is not None:
|
|
self._stream.close()
|
|
except OSError:
|
|
pass
|
|
if self._thread and self._thread.is_alive():
|
|
self._thread.join(timeout=1.0)
|
|
|
|
def send(self, text: str, line_ending: str = "\r\n") -> None:
|
|
payload = (text + line_ending).encode(self.encoding, errors="replace")
|
|
if self._pyserial is not None:
|
|
self._pyserial.write(payload)
|
|
elif self._stream is not None:
|
|
self._stream.write(payload)
|
|
|
|
def _run(self) -> None:
|
|
try:
|
|
try:
|
|
import serial # type: ignore
|
|
|
|
self._pyserial = serial.Serial(self.port, self.baudrate, timeout=0.25)
|
|
self._stream = self._pyserial
|
|
except ImportError:
|
|
if os.name == "nt":
|
|
subprocess.run(["mode", self.port, f"BAUD={self.baudrate}", "PARITY=n", "DATA=8", "STOP=1"], capture_output=True, check=False)
|
|
device = self.port if self.port.startswith("\\\\.\\") else "\\\\.\\" + self.port
|
|
self._stream = open(device, "r+b", buffering=0)
|
|
buffer = bytearray()
|
|
while not self._stop.is_set():
|
|
chunk = self._stream.read(256)
|
|
if not chunk:
|
|
continue
|
|
buffer.extend(chunk)
|
|
while b"\n" in buffer:
|
|
raw, _, remainder = buffer.partition(b"\n")
|
|
buffer = bytearray(remainder)
|
|
self.on_line(raw.rstrip(b"\r").decode(self.encoding, errors="replace"))
|
|
if buffer:
|
|
self.on_line(buffer.decode(self.encoding, errors="replace"))
|
|
except Exception as exc: # serial backends surface OS-specific errors
|
|
self.on_line(f"[SERIAL_ERROR] {exc}")
|
|
finally:
|
|
try:
|
|
if self._stream is not None:
|
|
self._stream.close()
|
|
except OSError:
|
|
pass
|
|
self._stream = None
|
|
self._pyserial = None
|
|
|
|
|
|
@dataclass
|
|
class Station:
|
|
number: str
|
|
flash_port: str = ""
|
|
test_port: str = ""
|
|
status: str = "待机"
|
|
firmware_kind: str = "production"
|
|
message: str = ""
|
|
reader: SerialReader | None = None
|
|
protocol: FT1Session = field(default_factory=FT1Session)
|
|
report_path: Path | None = None
|
|
report_saved: bool = False
|
|
|
|
|
|
class FactoryHost(Tk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.title("EG800 工厂烧录与产测上位机")
|
|
self.geometry("1440x850")
|
|
self.minsize(1100, 680)
|
|
self.config_data = load_config()
|
|
self.stations: dict[str, Station] = {}
|
|
self.events: queue.Queue = queue.Queue()
|
|
self.executor: ThreadPoolExecutor | None = None
|
|
self.flash_cancel = threading.Event()
|
|
self.session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
self.report_path = LOG_DIR / f"factory_{self.session_id}.csv"
|
|
self._build_ui()
|
|
self._load_default_rows()
|
|
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
|
self.after(100, self._drain_events)
|
|
|
|
def _build_ui(self) -> None:
|
|
top = ttk.Frame(self, padding=8)
|
|
top.pack(fill=X)
|
|
ttk.Label(top, text="固件:").pack(side=LEFT)
|
|
self.firmware_kind = StringVar(value="production")
|
|
ttk.Radiobutton(top, text="产测固件", variable=self.firmware_kind, value="production").pack(side=LEFT, padx=3)
|
|
ttk.Radiobutton(top, text="正式固件", variable=self.firmware_kind, value="official").pack(side=LEFT, padx=3)
|
|
self.firmware_label = StringVar()
|
|
ttk.Label(top, textvariable=self.firmware_label, width=55).pack(side=LEFT, padx=12)
|
|
ttk.Button(top, text="选择固件", command=self.choose_firmware).pack(side=LEFT)
|
|
ttk.Button(top, text="扫描串口", command=self.scan_ports).pack(side=LEFT, padx=4)
|
|
|
|
status = ttk.Frame(self, padding=(8, 0, 8, 6))
|
|
status.pack(fill=X)
|
|
self.tool_status = StringVar()
|
|
ttk.Label(status, textvariable=self.tool_status).pack(side=LEFT)
|
|
ttk.Label(status, text=f"配置: {CONFIG_PATH}").pack(side=RIGHT)
|
|
|
|
controls = ttk.Frame(self, padding=(8, 0, 8, 6))
|
|
controls.pack(fill=X)
|
|
ttk.Button(controls, text="添加工位", command=self.add_station).pack(side=LEFT)
|
|
ttk.Button(controls, text="删除选中", command=self.remove_station).pack(side=LEFT, padx=4)
|
|
ttk.Button(controls, text="烧录选中", command=self.start_selected_flash).pack(side=LEFT, padx=(16, 4))
|
|
ttk.Button(controls, text="批量烧录", command=self.start_flash).pack(side=LEFT)
|
|
ttk.Button(controls, text="停止烧录", command=self.stop_flash).pack(side=LEFT)
|
|
ttk.Button(controls, text="开始串口产测", command=self.start_tests).pack(side=LEFT, padx=16)
|
|
ttk.Button(controls, text="停止串口", command=self.stop_serial).pack(side=LEFT)
|
|
ttk.Button(controls, text="打开配置", command=self.open_config).pack(side=LEFT, padx=16)
|
|
ttk.Button(controls, text="重新加载", command=self.reload_config).pack(side=LEFT)
|
|
ttk.Button(controls, text="导出报表", command=self.export_report).pack(side=RIGHT)
|
|
|
|
table_frame = ttk.Frame(self, padding=(8, 0, 8, 4))
|
|
table_frame.pack(fill=BOTH, expand=True)
|
|
columns = ("station", "flash_port", "test_port", "status", "session", "imei", "progress", "firmware", "message")
|
|
self.table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended")
|
|
headings = {
|
|
"station": "工位", "flash_port": "烧录口", "test_port": "产测口", "status": "状态",
|
|
"session": "会话", "imei": "IMEI", "progress": "节点", "firmware": "固件", "message": "最近信息",
|
|
}
|
|
widths = {
|
|
"station": 55, "flash_port": 75, "test_port": 75, "status": 105, "session": 145,
|
|
"imei": 125, "progress": 70, "firmware": 80, "message": 440,
|
|
}
|
|
for column in columns:
|
|
self.table.heading(column, text=headings[column])
|
|
self.table.column(column, width=widths[column], anchor="w")
|
|
self.table.pack(side=LEFT, fill=BOTH, expand=True)
|
|
scroll = ttk.Scrollbar(table_frame, orient="vertical", command=self.table.yview)
|
|
scroll.pack(side=RIGHT, fill=Y)
|
|
hscroll = ttk.Scrollbar(table_frame, orient="horizontal", command=self.table.xview)
|
|
hscroll.pack(side="bottom", fill=X)
|
|
self.table.configure(yscrollcommand=scroll.set, xscrollcommand=hscroll.set)
|
|
self.table.bind("<Double-1>", self.edit_station)
|
|
self.table.bind("<<TreeviewSelect>>", self._refresh_node_table)
|
|
|
|
node_frame = ttk.LabelFrame(self, text="FT1 节点状态", padding=6)
|
|
node_frame.pack(fill=X, padx=8, pady=(0, 4))
|
|
node_columns = ("id", "name", "state", "code", "elapsed", "data")
|
|
self.node_table = ttk.Treeview(node_frame, columns=node_columns, show="headings", height=8)
|
|
for column, label, width in (
|
|
("id", "ID", 40), ("name", "节点", 150), ("state", "状态", 80),
|
|
("code", "代码", 60), ("elapsed", "耗时(ms)", 85), ("data", "数据", 700),
|
|
):
|
|
self.node_table.heading(column, text=label)
|
|
self.node_table.column(column, width=width, anchor="w")
|
|
self.node_table.pack(fill=X)
|
|
|
|
bottom = ttk.LabelFrame(self, text="实时日志", padding=6)
|
|
bottom.pack(fill=BOTH, expand=False, padx=8, pady=(0, 8))
|
|
self.log_text = ttk.Treeview(bottom, columns=("time", "station", "text"), show="headings", height=8)
|
|
for column, label, width in (("time", "时间", 90), ("station", "工位", 70), ("text", "日志", 850)):
|
|
self.log_text.heading(column, text=label)
|
|
self.log_text.column(column, width=width, anchor="w")
|
|
self.log_text.pack(fill=BOTH, expand=True)
|
|
self._refresh_tool_status()
|
|
|
|
def _load_default_rows(self) -> None:
|
|
ports = list_serial_ports()
|
|
for index, port in enumerate(ports, 1):
|
|
self.add_station(number=str(index), flash_port=port, test_port=port)
|
|
if not ports:
|
|
self.add_station(number="1")
|
|
|
|
def _refresh_tool_status(self) -> None:
|
|
downloader = find_downloader(self.config_data["flash"].get("downloader_path", ""), self.config_data["flash"].get("aboot_path", ""))
|
|
firmware = resolve_project_path(self.config_data["firmwares"].get(self.firmware_kind.get(), ""))
|
|
tool_text = f"adownload: {downloader}" if downloader else "adownload: 未找到(请把 burn_tool 完整目录放在根目录或在配置中指定)"
|
|
package_text = f"固件: {firmware}" if firmware.is_file() else "固件: 未选择"
|
|
self.tool_status.set(tool_text + " | " + package_text)
|
|
self.firmware_label.set(str(firmware) if firmware.is_file() else "未选择当前固件")
|
|
|
|
def choose_firmware(self) -> None:
|
|
path = filedialog.askopenfilename(title="选择固件包", filetypes=(("固件包", "*.zip *.bin *.img *.json"), ("所有文件", "*.*")))
|
|
if path:
|
|
self.config_data["firmwares"][self.firmware_kind.get()] = path
|
|
save_config(self.config_data)
|
|
self._refresh_tool_status()
|
|
|
|
def scan_ports(self) -> None:
|
|
ports = list_serial_ports()
|
|
self._log("系统", f"检测到串口: {', '.join(ports) if ports else '无'}")
|
|
for station in self.stations.values():
|
|
if not station.flash_port and ports:
|
|
station.flash_port = ports.pop(0)
|
|
if not station.test_port:
|
|
station.test_port = station.flash_port
|
|
self._update_row(station)
|
|
|
|
def open_config(self) -> None:
|
|
try:
|
|
if os.name == "nt":
|
|
os.startfile(str(CONFIG_PATH)) # type: ignore[attr-defined]
|
|
else:
|
|
subprocess.Popen(["xdg-open", str(CONFIG_PATH)])
|
|
except OSError as exc:
|
|
messagebox.showerror("打开配置失败", str(exc))
|
|
|
|
def reload_config(self) -> None:
|
|
try:
|
|
self.config_data = load_config()
|
|
self._refresh_tool_status()
|
|
self._log("系统", "配置已重新加载")
|
|
except RuntimeError as exc:
|
|
messagebox.showerror("配置错误", str(exc))
|
|
|
|
def add_station(self, number: str | None = None, flash_port: str = "", test_port: str = "") -> None:
|
|
if number is None:
|
|
number = str(len(self.stations) + 1)
|
|
while number in self.stations:
|
|
number = str(int(number) + 1) if number.isdigit() else f"{number}_new"
|
|
station = Station(number=number, flash_port=flash_port, test_port=test_port, firmware_kind=self.firmware_kind.get())
|
|
self.stations[number] = station
|
|
self.table.insert("", END, iid=number, values=(number, flash_port, test_port, station.status, "", "", "0/6", "产测固件", ""))
|
|
|
|
def remove_station(self) -> None:
|
|
for iid in self.table.selection():
|
|
station = self.stations.pop(iid, None)
|
|
if station and station.reader:
|
|
station.reader.stop()
|
|
self.table.delete(iid)
|
|
|
|
def edit_station(self, _event=None) -> None:
|
|
selected = self.table.selection()
|
|
if not selected:
|
|
return
|
|
station = self.stations[selected[0]]
|
|
dialog = Toplevel(self)
|
|
dialog.title(f"编辑工位 {station.number}")
|
|
dialog.transient(self)
|
|
ttk.Label(dialog, text="烧录串口(自动 USB 模式可空):").grid(row=0, column=0, padx=8, pady=8)
|
|
flash_port_var = StringVar(value=station.flash_port)
|
|
ttk.Entry(dialog, textvariable=flash_port_var, width=20).grid(row=0, column=1, padx=8, pady=8)
|
|
ttk.Label(dialog, text="产测 CDC 串口:").grid(row=1, column=0, padx=8, pady=8)
|
|
test_port_var = StringVar(value=station.test_port)
|
|
ttk.Entry(dialog, textvariable=test_port_var, width=20).grid(row=1, column=1, padx=8, pady=8)
|
|
ttk.Label(dialog, text="固件:").grid(row=2, column=0, padx=8, pady=8)
|
|
kind_var = StringVar(value=station.firmware_kind)
|
|
ttk.Combobox(dialog, textvariable=kind_var, values=("production", "official"), state="readonly", width=17).grid(row=2, column=1, padx=8, pady=8)
|
|
|
|
def apply():
|
|
station.flash_port = flash_port_var.get().strip().upper()
|
|
station.test_port = test_port_var.get().strip().upper()
|
|
station.firmware_kind = kind_var.get()
|
|
self._update_row(station)
|
|
dialog.destroy()
|
|
|
|
ttk.Button(dialog, text="确定", command=apply).grid(row=3, column=0, columnspan=2, pady=8)
|
|
|
|
def _update_row(self, station: Station) -> None:
|
|
if self.table.exists(station.number):
|
|
protocol = station.protocol
|
|
self.table.item(station.number, values=(
|
|
station.number, station.flash_port, station.test_port, station.status,
|
|
protocol.session, protocol.imei, f"{protocol.passed_count}/6",
|
|
"产测固件" if station.firmware_kind == "production" else "正式固件", station.message,
|
|
))
|
|
|
|
def _refresh_node_table(self, _event=None) -> None:
|
|
selected = self.table.selection()
|
|
for iid in self.node_table.get_children():
|
|
self.node_table.delete(iid)
|
|
if not selected or selected[0] not in self.stations:
|
|
return
|
|
protocol = self.stations[selected[0]].protocol
|
|
for node_id, name in enumerate(NODE_NAMES):
|
|
detail = protocol.node_details.get(name, {})
|
|
data = json.dumps(detail.get("data", {}), ensure_ascii=False, separators=(",", ":"))
|
|
self.node_table.insert("", END, values=(
|
|
node_id, name, protocol.nodes[name], detail.get("code", ""),
|
|
detail.get("elapsed_ms", ""), data if data != "{}" else "",
|
|
))
|
|
|
|
def start_selected_flash(self) -> None:
|
|
selected = self.table.selection()
|
|
if len(selected) != 1:
|
|
messagebox.showwarning("请选择工位", "请先只选中一个工位,再点击“烧录选中”。")
|
|
return
|
|
self._start_flash_jobs(selected)
|
|
|
|
def start_flash(self) -> None:
|
|
selected = self.table.selection() or tuple(self.stations.keys())
|
|
self._start_flash_jobs(selected)
|
|
|
|
def _start_flash_jobs(self, selected) -> None:
|
|
downloader = find_downloader(self.config_data["flash"].get("downloader_path", ""), self.config_data["flash"].get("aboot_path", ""))
|
|
if not downloader:
|
|
messagebox.showerror("无法烧录", "未找到 adownload.exe。请把 burn_tool 完整目录放到根目录,或在配置中指定 flash.downloader_path。aboot.exe 是图形界面,不能作为命令行烧录进程。")
|
|
return
|
|
jobs = [
|
|
self.stations[iid]
|
|
for iid in selected
|
|
if iid in self.stations
|
|
and self.stations[iid].status not in ("排队中", "等待上电", "烧录中")
|
|
]
|
|
if not jobs:
|
|
self._log("系统", "没有可提交的工位(正在烧录的工位不会重复排队)")
|
|
return
|
|
configured_args = self.config_data["flash"].get("args", ["{firmware}"])
|
|
if isinstance(configured_args, str):
|
|
configured_args = parse_command_template(configured_args)
|
|
auto_usb = any(str(arg) in ("-a", "--auto-enable") for arg in configured_args)
|
|
uses_named_port = any("{port}" in str(arg) for arg in configured_args)
|
|
self.flash_cancel.clear()
|
|
workers = max(1, int(self.config_data["flash"].get("workers", 2)))
|
|
if auto_usb and not uses_named_port and len(jobs) > 1:
|
|
workers = 1
|
|
self._log("系统", "USB 自动等待模式将按工位顺序烧录;请只给当前等待工位上电")
|
|
self.executor = ThreadPoolExecutor(max_workers=min(workers, len(jobs)))
|
|
for station in jobs:
|
|
station.status = "排队中"
|
|
station.message = ""
|
|
self._update_row(station)
|
|
self.executor.submit(self._flash_one, station, downloader)
|
|
self._log("系统", f"已提交 {len(jobs)} 个烧录任务,并发数 {workers}")
|
|
|
|
def stop_flash(self) -> None:
|
|
self.flash_cancel.set()
|
|
self._log("系统", "已请求停止后续烧录任务;正在执行的 adownload 将在超时或退出后结束")
|
|
|
|
def _flash_one(self, station: Station, downloader: Path) -> None:
|
|
if self.flash_cancel.is_set():
|
|
self.events.put(("station", station.number, "已取消", ""))
|
|
return
|
|
firmware = resolve_project_path(self.config_data["firmwares"].get(station.firmware_kind, ""))
|
|
if not firmware.is_file():
|
|
self.events.put(("station", station.number, "失败", f"固件不存在: {firmware}"))
|
|
return
|
|
configured_args = self.config_data["flash"].get("args", ["{firmware}"])
|
|
if isinstance(configured_args, str):
|
|
configured_args = parse_command_template(configured_args)
|
|
baudrate = int(self.config_data["serial"].get("baudrate", 115200))
|
|
if not station.flash_port and any("{port}" in str(item) for item in configured_args):
|
|
self.events.put(("station", station.number, "失败", "未设置烧录串口"))
|
|
return
|
|
args = expand_args(configured_args, aboot=downloader, firmware=firmware, port=station.flash_port, station=station.number, baudrate=baudrate)
|
|
command = [str(downloader)] + args
|
|
auto_usb = any(str(arg) in ("-a", "--auto-enable") for arg in configured_args)
|
|
status = "等待上电" if auto_usb else "烧录中"
|
|
hint = "下载器已启动,请现在给设备上电" if auto_usb else ""
|
|
self.events.put(("station", station.number, status, hint or " ".join(shlex.quote(x) for x in command)))
|
|
self.events.put(("log", station.number, "命令: " + " ".join(shlex.quote(x) for x in command)))
|
|
try:
|
|
process = subprocess.Popen(command, cwd=str(downloader.parent), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1)
|
|
output_tail = []
|
|
deadline = time.monotonic() + int(self.config_data["flash"].get("timeout_seconds", 900))
|
|
assert process.stdout is not None
|
|
lines: queue.Queue[str | None] = queue.Queue()
|
|
|
|
def read_output() -> None:
|
|
try:
|
|
for raw_line in process.stdout:
|
|
lines.put(raw_line.rstrip())
|
|
finally:
|
|
lines.put(None)
|
|
|
|
threading.Thread(target=read_output, name=f"flash-output-{station.number}", daemon=True).start()
|
|
reader_done = False
|
|
timed_out = False
|
|
while not reader_done or process.poll() is None:
|
|
try:
|
|
line = lines.get(timeout=0.1)
|
|
if line is None:
|
|
reader_done = True
|
|
elif line:
|
|
output_tail.append(line)
|
|
output_tail = output_tail[-3:]
|
|
self.events.put(("log", station.number, line))
|
|
except queue.Empty:
|
|
pass
|
|
if self.flash_cancel.is_set() or time.monotonic() > deadline:
|
|
timed_out = not self.flash_cancel.is_set()
|
|
process.terminate()
|
|
break
|
|
try:
|
|
return_code = process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
return_code = process.wait(timeout=5)
|
|
if self.flash_cancel.is_set():
|
|
result, message = "已取消", "用户停止"
|
|
elif timed_out:
|
|
result, message = "失败", "等待上电或烧录超时"
|
|
elif return_code == 0:
|
|
result, message = "烧录成功", " | ".join(output_tail[-2:])
|
|
else:
|
|
result, message = "失败", f"退出码 {return_code}: {' | '.join(output_tail[-2:])}"
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
result, message = "失败", str(exc)
|
|
self.events.put(("station", station.number, result, message))
|
|
|
|
def start_tests(self) -> None:
|
|
selected = self.table.selection() or tuple(self.stations.keys())
|
|
for iid in selected:
|
|
if iid not in self.stations:
|
|
continue
|
|
station = self.stations[iid]
|
|
if not station.test_port:
|
|
station.status, station.message = "失败", "未设置产测 CDC 串口"
|
|
self._update_row(station)
|
|
continue
|
|
station.protocol.reset()
|
|
station.report_path = None
|
|
station.report_saved = False
|
|
station.status = "等待设备"
|
|
station.message = "等待 FT1 session_start"
|
|
if station.reader:
|
|
station.reader.stop()
|
|
station.reader = SerialReader(
|
|
station.test_port,
|
|
int(self.config_data["serial"].get("baudrate", 115200)),
|
|
self.config_data["serial"].get("encoding", "utf-8"),
|
|
lambda line, s=station: self._serial_line(s, line),
|
|
)
|
|
station.reader.start()
|
|
self._update_row(station)
|
|
self._refresh_node_table()
|
|
self._log("系统", "FT1 串口产测已启动;只保存以 FT1 开头的协议行")
|
|
|
|
def _serial_line(self, station: Station, line: str) -> None:
|
|
self.events.put(("serial", station.number, line))
|
|
|
|
def _process_serial_line(self, station: Station, line: str) -> None:
|
|
if line.startswith("[SERIAL_ERROR]"):
|
|
station.status, station.message = "失败", line
|
|
self._log(station.number, line)
|
|
self._update_row(station)
|
|
return
|
|
try:
|
|
message = parse_ft1_line(line)
|
|
except ProtocolError as exc:
|
|
station.status = "协议错误"
|
|
station.message = str(exc)
|
|
self._log(station.number, f"[FT1错误] {exc}: {line}")
|
|
self._update_row(station)
|
|
return
|
|
if message is None:
|
|
self._log(station.number, f"[SDK] {line}")
|
|
return
|
|
|
|
previous_session = station.protocol.session
|
|
try:
|
|
description = station.protocol.apply(message)
|
|
except ProtocolError as exc:
|
|
station.status = "协议错误"
|
|
station.message = str(exc)
|
|
self._log(station.number, f"[FT1错误] {exc}")
|
|
self._update_row(station)
|
|
return
|
|
if message["type"] == "session_start" and station.protocol.session != previous_session:
|
|
station.report_path = None
|
|
station.report_saved = False
|
|
|
|
msg_type = message["type"]
|
|
if msg_type == "session_start":
|
|
station.status = "产测中"
|
|
elif msg_type == "node_start":
|
|
station.status = f"测试 {message['name']}"
|
|
elif msg_type == "node_result" and message["result"] == "FAIL":
|
|
station.status = "产测失败"
|
|
elif msg_type == "session_result":
|
|
station.status = "产测通过" if message["result"] == "PASS" else "产测失败"
|
|
elif msg_type == "power_off" and station.protocol.result == "PASS":
|
|
station.status = "通过/已关机"
|
|
station.message = description
|
|
self._log(station.number, f"[FT1] {description}")
|
|
self._update_row(station)
|
|
self._refresh_node_table()
|
|
|
|
if self.config_data.get("protocol", {}).get("auto_save", True) and msg_type in {"session_result", "power_off"}:
|
|
self._save_station_session(station, msg_type)
|
|
|
|
def _save_station_session(self, station: Station, trigger: str) -> Path | None:
|
|
protocol = station.protocol
|
|
if not protocol.messages:
|
|
return None
|
|
safe_session = re.sub(r"[^A-Za-z0-9_.-]+", "_", protocol.session or "UNKNOWN")
|
|
if station.report_path is None:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
station.report_path = SESSION_DIR / f"{timestamp}_station-{station.number}_{safe_session}.json"
|
|
record = {
|
|
"saved_at": datetime.now().isoformat(timespec="seconds"),
|
|
"save_trigger": trigger,
|
|
"station": station.number,
|
|
"flash_port": station.flash_port,
|
|
"test_port": station.test_port,
|
|
"firmware_kind": station.firmware_kind,
|
|
"summary": protocol.summary(),
|
|
"messages": protocol.messages,
|
|
}
|
|
station.report_path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8-sig")
|
|
if not station.report_saved:
|
|
csv_path = LOG_DIR / "factory_results.csv"
|
|
new_file = not csv_path.exists()
|
|
with csv_path.open("a", newline="", encoding="utf-8-sig") as handle:
|
|
writer = csv.writer(handle)
|
|
if new_file:
|
|
writer.writerow([
|
|
"保存时间", "工位", "产测串口", "会话", "结果", "失败码", "失败原因",
|
|
"固件", "IMEI", "ICCID", "NFC_UID", "开始时间", "耗时_ms", *NODE_NAMES,
|
|
])
|
|
summary = protocol.summary()
|
|
writer.writerow([
|
|
record["saved_at"], station.number, station.test_port, protocol.session,
|
|
summary["result"], protocol.fail_code, summary["fail_reason"], protocol.firmware,
|
|
protocol.imei, protocol.iccid, protocol.nfc_uid, protocol.start_time or "",
|
|
protocol.duration_ms, *(protocol.nodes[name] for name in NODE_NAMES),
|
|
])
|
|
station.report_saved = True
|
|
self._log(station.number, f"记录已保存: {station.report_path}")
|
|
return station.report_path
|
|
|
|
def stop_serial(self) -> None:
|
|
for station in self.stations.values():
|
|
if station.reader:
|
|
station.reader.stop()
|
|
station.reader = None
|
|
if station.protocol.messages and not station.report_saved:
|
|
self._save_station_session(station, "manual_stop")
|
|
if station.status in ("等待设备", "产测中") or station.status.startswith("测试 "):
|
|
station.status = "已停止"
|
|
self._update_row(station)
|
|
self._log("系统", "串口监听已停止")
|
|
|
|
def _drain_events(self) -> None:
|
|
try:
|
|
while True:
|
|
event = self.events.get_nowait()
|
|
kind = event[0]
|
|
if kind == "station":
|
|
_, number, status, message = event
|
|
if number in self.stations:
|
|
station = self.stations[number]
|
|
station.status, station.message = status, message
|
|
self._update_row(station)
|
|
self._log(number, f"{status}: {message}")
|
|
elif kind == "log":
|
|
_, number, line = event
|
|
self._log(number, line)
|
|
elif kind == "serial":
|
|
_, number, line = event
|
|
if number in self.stations:
|
|
self._process_serial_line(self.stations[number], line)
|
|
except queue.Empty:
|
|
pass
|
|
self.after(100, self._drain_events)
|
|
|
|
def _log(self, station: str, text: str) -> None:
|
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
|
self.log_text.insert("", END, values=(timestamp, station, text))
|
|
children = self.log_text.get_children()
|
|
if len(children) > 1000:
|
|
self.log_text.delete(children[0])
|
|
|
|
def export_report(self) -> None:
|
|
path = filedialog.asksaveasfilename(title="导出产测报表", defaultextension=".csv", initialfile=f"factory_{self.session_id}.csv", filetypes=(("CSV", "*.csv"),))
|
|
if not path:
|
|
return
|
|
with open(path, "w", newline="", encoding="utf-8-sig") as handle:
|
|
writer = csv.writer(handle)
|
|
writer.writerow([
|
|
"时间", "工位", "烧录口", "产测口", "状态", "会话", "结果", "失败码", "失败原因",
|
|
"固件类型", "产测固件版本", "IMEI", "ICCID", "NFC_UID", "开始时间", "耗时_ms", *NODE_NAMES,
|
|
])
|
|
for station in self.stations.values():
|
|
protocol = station.protocol
|
|
summary = protocol.summary()
|
|
writer.writerow([
|
|
datetime.now().isoformat(timespec="seconds"), station.number, station.flash_port,
|
|
station.test_port, station.status, protocol.session, summary["result"],
|
|
protocol.fail_code, summary["fail_reason"], station.firmware_kind, protocol.firmware,
|
|
protocol.imei, protocol.iccid, protocol.nfc_uid, protocol.start_time or "",
|
|
protocol.duration_ms, *(protocol.nodes[name] for name in NODE_NAMES),
|
|
])
|
|
self._log("系统", f"报表已导出: {path}")
|
|
|
|
def _on_close(self) -> None:
|
|
self.stop_serial()
|
|
self.flash_cancel.set()
|
|
self.destroy()
|
|
|
|
|
|
def main() -> None:
|
|
try:
|
|
app = FactoryHost()
|
|
app.mainloop()
|
|
except RuntimeError as exc:
|
|
messagebox.showerror("启动失败", str(exc))
|
|
raise
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|