Files
banban/factory_host/factory_host.py
T

852 lines
39 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)
def identify_ft1_port(port: str, baudrate: int = 115200, timeout: float = 1.2) -> bool:
"""Passively probe a DBG UART without assigning unrelated ports."""
try:
import serial # type: ignore
with serial.Serial(port, baudrate, timeout=0.15) as stream:
stream.reset_input_buffer()
deadline = time.monotonic() + timeout
pending = bytearray()
while time.monotonic() < deadline:
chunk = stream.read(256)
if not chunk:
continue
pending.extend(chunk)
while b"\n" in pending:
raw, _, pending = pending.partition(b"\n")
try:
message = parse_ft1_line(raw.rstrip(b"\r").decode("utf-8", errors="replace"))
except ProtocolError:
continue
if message and message.get("type") in {"device_ready", "session_start", "status_snapshot", "node_start", "node_result", "session_result"}:
return True
except (ImportError, OSError, ValueError):
return False
return False
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
self._opened = threading.Event()
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:
self._opened.wait(timeout=2.0)
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)
self._opened.set()
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:
self._opened.clear()
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:
# COM enumeration is intentionally deferred until FT1 identification;
# AROM/DIAG/AT ports must not become fake stations.
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:
found = self._auto_add_ft1_stations()
self._log("系统", f"FT1 自动识别完成,新增/确认工位: {', '.join(found) if found else '无'}")
def _auto_add_ft1_stations(self) -> list[str]:
"""Probe every COM port and create one visible station per FT1 device."""
baudrate = int(self.config_data["serial"].get("baudrate", 115200))
used = {station.test_port for station in self.stations.values() if station.test_port}
found: list[str] = []
for candidate in list_serial_ports():
if candidate in used:
continue
if not identify_ft1_port(candidate, baudrate):
continue
station = next((item for item in self.stations.values()
if not item.test_port and item.reader is None), None)
if station is None:
number = 1
while str(number) in self.stations:
number += 1
self.add_station(number=str(number))
station = self.stations[str(number)]
station.test_port = candidate
station.status = "已识别"
station.message = "FT1 UART 已连接,等待状态广播"
used.add(candidate)
found.append(f"工位 {station.number}={candidate}")
self._update_row(station)
return found
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="产测 DBG UART:").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:
had_selection = bool(self.table.selection())
self._auto_add_ft1_stations()
selected = self.table.selection() if had_selection else tuple(
number for number, station in self.stations.items() if station.test_port
)
for iid in selected:
if iid not in self.stations:
continue
station = self.stations[iid]
if not station.test_port:
station.status, station.message = "失败", "未发现 FT1 产测 DBG UART"
self._update_row(station)
continue
station.protocol.reset()
station.report_path = None
station.report_saved = False
station.status = "等待设备"
station.message = "等待 FT1 状态广播"
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 UART;设备自动开始产测并每500ms广播状态")
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
if message["type"] in {"device_ready", "command_ack"}:
station.message = station.protocol.apply(message)
station.status = "设备已识别" if message["type"] == "device_ready" else ("开始产测" if message.get("accepted") else "START 被拒绝")
self._log(station.number, f"[FT1] {station.message}")
self._update_row(station)
return
if message["type"] == "status_snapshot":
station.protocol.apply(message)
station.status = "产测中" if message["active_node"] >= 0 else "产测已启动"
if station.protocol.session_result_seen:
station.status = "产测通过" if station.protocol.result == "PASS" else "产测失败"
station.message = station.protocol.current_node or "状态广播"
self._log(station.number, f"[FT1] {station.message}: " + ", ".join(
f"{name}={station.protocol.nodes[name]}" for name in NODE_NAMES
))
self._update_row(station)
self._refresh_node_table()
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()