feat: add FT1 factory test host and protocol updates

This commit is contained in:
chenjintao
2026-09-23 21:53:27 +08:00
parent 99cdd2db8a
commit 04b3b761db
15 changed files with 1603 additions and 176 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
logs/
dist/
.pyinstaller-work/
.pyinstaller-spec/
+86
View File
@@ -0,0 +1,86 @@
# EG800 FT1 工厂烧录与产测上位机
本工具配套 `test/factory-test` 分支的独立产测固件,支持批量烧录、USB CDC
协议采集、6 个硬件节点判定和结果落盘。
## 主要功能
- 产测固件和正式固件分开保存,每个工位可独立选择。
- 烧录串口和产测 CDC 串口分开配置,避免把 DIAG/AT/CDC 当成同一个端口。
- 使用原厂 `adownload.exe -u -a`,先进入等待状态,再由关机设备上电触发烧录。
- 提供“烧录选中”单工位入口;USB 自动等待的批量任务按工位顺序执行,避免抢设备。
- 只解析以 `FT1 ` 开头的协议行;普通 SDK 日志只显示,不进入产测数据库。
- 严格校验协议版本、消息类型、节点编号/名称、会话 ID 和递增序号。
- 显示 `BOOT_AUDIO`、`POWER_ADC`、`AUDIO_LOOPBACK`、`ESIM`、
`DATA_CALL`、`NFC` 的实时状态与结构化数据。
- 收到 `session_result` 或 `power_off` 时自动保存完整 JSON 和汇总 CSV。
- 保存 IMEI、ICCID、NFC UID、失败码、固件版本、节点耗时和总耗时。
## 目录和启动
完整烧录工具位于:
```text
burn_tool/aboot-tools-2024.08.08-win-x64/adownload.exe
```
发布程序:
```text
factory_host/dist/EG800FactoryHost.exe
```
默认“产测固件”指向已包含三个产测 MP3 的 FT1 整包;默认“正式固件”指向
`version` 目录中的正式内层 `-fac.zip`。两者都可在外置
`factory_host_config.json` 中替换,烧录时不要选择 `-target.zip` 外层归档。
双击表格中的工位可以分别设置烧录口和产测 CDC 口。默认 USB 自动等待模式不需要
填写烧录口;产测 CDC 口仍需填写。“烧录选中”要求只选择一个工位;“批量烧录”
在未选中行时作用于全部工位,选中后只作用于选中工位。
单工位烧录时保持设备关机并连接 USB,选择工位后点击“烧录选中”。工位状态显示
“等待上电”后再给设备开机。此时 `adownload` 会捕获 AROM USB 下载口并开始烧录,
操作顺序与 Aboot 的 `usbOnly + usbAutoEnable` 默认设置一致。
串口和协议记录位于:
```text
factory_host/dist/logs/factory_results.csv
factory_host/dist/logs/sessions/*.json
```
## FT1 结果规则
产测结果完全采用固件的 `session_result`,上位机不会用模糊正则猜测硬件是否通过。
非零失败码会显示 `docs/factory_test.md` 中对应的中文含义。收到 `power_off` 后会再次
刷新同一 JSON 文件,但不会在 CSV 中重复增加记录。
`AUDIO_LOOPBACK=PASS` 表示 GPIO84(原三击测试按键)的录放数据链路成功。
Loopback 仍然是按住录音、松开回放,最长录音 5 秒。
## 产测音频包
重新生成音频包:
```powershell
python -B .\factory_host\build_factory_audio_package.py
```
脚本会执行以下检查:
1. 源目录必须且只能包含三个固定文件名。
2. 每个文件必须为 16 kHz、单声道、48 kbps MP3。
3. 使用 SDK `mklfs-v2.exe -m` 生成 262144 字节 `customer_fs.bin`。
4. 立即反向解包镜像并逐文件比对 SHA-256。
5. 输出 ZIP、清单及校验值到 `factory_host/dist/factory_audio/`。
不要直接覆盖正式业务 `customer_fs.bin`。需要构建产测整包时,按照
`docs/factory_test_windows_fs.md` 先备份业务镜像,再临时放入产测镜像。
## 开发验证
```powershell
python -B -m unittest discover -s factory_host -p "test_*.py" -v
powershell.exe -NoProfile -ExecutionPolicy Bypass -File factory_host\build_release.ps1
```
+200
View File
@@ -0,0 +1,200 @@
"""Validate and package the three fixed FT1 audio prompts."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path
EXPECTED_FILES = (
"factory_boot_zh.mp3",
"factory_pass_zh.mp3",
"factory_shutdown_zh.mp3",
)
IMAGE_SIZE = 262144
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest().upper()
def _skip_id3(data: bytes) -> int:
if not data.startswith(b"ID3") or len(data) < 10:
return 0
size = 0
for value in data[6:10]:
if value & 0x80:
raise ValueError("ID3 使用了无效的 synchsafe size")
size = (size << 7) | value
return 10 + size
def inspect_mp3(path: Path) -> dict:
data = path.read_bytes()
offset = _skip_id3(data)
while offset + 4 <= len(data) and not (data[offset] == 0xFF and data[offset + 1] & 0xE0 == 0xE0):
offset += 1
if offset + 4 > len(data):
raise ValueError(f"{path.name}: 找不到 MPEG 音频帧")
header = int.from_bytes(data[offset : offset + 4], "big")
version_bits = (header >> 19) & 0x3
layer_bits = (header >> 17) & 0x3
bitrate_index = (header >> 12) & 0xF
sample_index = (header >> 10) & 0x3
channel_mode = (header >> 6) & 0x3
if version_bits == 1 or layer_bits != 1 or bitrate_index in (0, 15) or sample_index == 3:
raise ValueError(f"{path.name}: MP3 帧头不受支持")
version = {3: "MPEG1", 2: "MPEG2", 0: "MPEG2.5"}[version_bits]
sample_rates = {
3: (44100, 48000, 32000),
2: (22050, 24000, 16000),
0: (11025, 12000, 8000),
}
mpeg1_bitrates = (0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
mpeg2_bitrates = (0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160)
bitrate = (mpeg1_bitrates if version_bits == 3 else mpeg2_bitrates)[bitrate_index]
sample_rate = sample_rates[version_bits][sample_index]
channels = 1 if channel_mode == 3 else 2
if (sample_rate, channels, bitrate) != (16000, 1, 48):
raise ValueError(
f"{path.name}: 需要 16000Hz/单声道/48kbps,实际为 {sample_rate}Hz/{channels}ch/{bitrate}kbps"
)
duration_ms = round(len(data) * 8 / (bitrate * 1000) * 1000)
return {
"name": path.name,
"bytes": len(data),
"sha256": sha256(path),
"mpeg": version,
"sample_rate_hz": sample_rate,
"channels": channels,
"bitrate_kbps": bitrate,
"estimated_duration_ms": duration_ms,
}
def build_package(root: Path, output: Path) -> tuple[Path, Path, dict]:
source = root / "audio_prompts" / "factory_test" / "customer_fs"
tool = root / "ql-cross-tool" / "win32" / "mklfs-v2.exe"
if not source.is_dir():
raise FileNotFoundError(f"音频目录不存在: {source}")
if not tool.is_file():
raise FileNotFoundError(f"mklfs-v2.exe 不存在: {tool}")
actual = sorted(path.name for path in source.iterdir() if path.is_file())
if actual != sorted(EXPECTED_FILES):
raise ValueError(f"音频目录必须且只能包含 {EXPECTED_FILES},实际为 {actual}")
output.mkdir(parents=True, exist_ok=True)
image = output / "customer_fs.bin"
if image.exists():
image.unlink()
audio = [inspect_mp3(source / name) for name in EXPECTED_FILES]
result = subprocess.run(
[str(tool), "-m", "-c", str(source), "-s", str(IMAGE_SIZE), "-i", str(image)],
cwd=str(root),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"mklfs-v2 失败 ({result.returncode}): {result.stdout}\n{result.stderr}")
if not image.is_file() or image.stat().st_size != IMAGE_SIZE:
raise RuntimeError(f"customer_fs.bin 大小错误: {image.stat().st_size if image.exists() else '不存在'}")
verified_files = []
with tempfile.TemporaryDirectory(prefix="verify-", dir=output) as verify_dir:
verify_path = Path(verify_dir)
verify = subprocess.run(
[str(tool), "-o", "-c", str(verify_path), "-s", str(IMAGE_SIZE), "-i", str(image)],
cwd=str(root),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if verify.returncode != 0:
raise RuntimeError(f"customer_fs 解包验证失败 ({verify.returncode}): {verify.stdout}\n{verify.stderr}")
extracted = sorted(path.name for path in verify_path.iterdir() if path.is_file())
if extracted != sorted(EXPECTED_FILES):
raise RuntimeError(f"customer_fs 文件清单错误: {extracted}")
for name in EXPECTED_FILES:
source_hash = sha256(source / name)
extracted_hash = sha256(verify_path / name)
if source_hash != extracted_hash:
raise RuntimeError(f"customer_fs 解包内容不一致: {name}")
verified_files.append({"name": name, "sha256": extracted_hash})
try:
revision = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=str(root), capture_output=True, text=True, check=True
).stdout.strip()
except (OSError, subprocess.SubprocessError):
revision = ""
manifest = {
"format": "banban-factory-audio-v1",
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"git_revision": revision,
"filesystem": {
"name": "customer_fs.bin",
"bytes": image.stat().st_size,
"sha256": sha256(image),
"extract_verified": True,
"verified_files": verified_files,
},
"mount_paths": [f"U:/{name}" for name in EXPECTED_FILES],
"audio": audio,
}
manifest_path = output / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8-sig")
readme_path = output / "README.txt"
readme_path.write_text(
"班班 FT1 产测音频包\n\n"
"customer_fs.bin 固定为 262144 字节,用于 EG800AKCN_11LG 的 customer_fs 分区。\n"
"三个 MP3 必须位于 U:/ 根目录;不要将此镜像混入正式业务固件。\n"
"烧录后以 FT1 BOOT_AUDIO 和最终 session_result 作为设备侧验证。\n",
encoding="utf-8-sig",
)
package = output / "EG800AKCN_11LG-FT1-factory-audio.zip"
if package.exists():
package.unlink()
with zipfile.ZipFile(package, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
archive.write(image, "customer_fs.bin")
archive.write(manifest_path, "manifest.json")
archive.write(readme_path, "README.txt")
for name in EXPECTED_FILES:
archive.write(source / name, f"customer_fs/{name}")
manifest["package"] = {"name": package.name, "bytes": package.stat().st_size, "sha256": sha256(package)}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8-sig")
return image, package, manifest
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent)
parser.add_argument("--output", type=Path, default=Path(__file__).resolve().parent / "dist" / "factory_audio")
args = parser.parse_args()
image, package, manifest = build_package(args.root.resolve(), args.output.resolve())
print(f"customer_fs={image}")
print(f"package={package}")
print(f"customer_fs_sha256={manifest['filesystem']['sha256']}")
print(f"package_sha256={manifest['package']['sha256']}")
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
param(
[string]$Output = "dist\factory_firmware"
)
$ErrorActionPreference = "Stop"
$toolRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectRoot = Split-Path -Parent $toolRoot
$audioImage = Join-Path $toolRoot "dist\factory_audio\customer_fs.bin"
$configuredImage = Join-Path $projectRoot "ql-config\config\EG800AKCN_11LG\customer_fs.bin"
$flashPackage = Join-Path $projectRoot "target\EG800AKCN_11LG\EG800AKCN_11LG.zip"
$outDir = Join-Path $toolRoot $Output
$backup = Join-Path $outDir "customer_fs.business-backup.bin"
$export = Join-Path $outDir "EG800AKCN_11LG-FT1-factory-test.zip"
if (-not (Test-Path -LiteralPath $audioImage)) {
throw "Factory audio image is missing. Run build_factory_audio_package.py first."
}
if ((Get-Item -LiteralPath $audioImage).Length -ne 262144) {
throw "Factory customer_fs.bin is not 262144 bytes."
}
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
Copy-Item -LiteralPath $configuredImage -Destination $backup -Force
$originalHash = (Get-FileHash -LiteralPath $configuredImage -Algorithm SHA256).Hash
$audioHash = (Get-FileHash -LiteralPath $audioImage -Algorithm SHA256).Hash
try {
Copy-Item -LiteralPath $audioImage -Destination $configuredImage -Force
if ((Get-FileHash -LiteralPath $configuredImage -Algorithm SHA256).Hash -ne $audioHash) {
throw "Temporary customer_fs.bin hash verification failed."
}
& cmd.exe /d /c "build.bat app"
if ($LASTEXITCODE -ne 0) { throw "build.bat app failed with exit code $LASTEXITCODE" }
& cmd.exe /d /c "build.bat firmware"
if ($LASTEXITCODE -ne 0) { throw "build.bat firmware failed with exit code $LASTEXITCODE" }
if (-not (Test-Path -LiteralPath $flashPackage)) {
throw "Flashable inner package was not generated: $flashPackage"
}
Copy-Item -LiteralPath $flashPackage -Destination $export -Force
$exportHash = (Get-FileHash -LiteralPath $export -Algorithm SHA256).Hash
@{
package = (Split-Path -Leaf $export)
bytes = (Get-Item -LiteralPath $export).Length
sha256 = $exportHash
customer_fs_sha256 = $audioHash
git_revision = (& git -C $projectRoot rev-parse HEAD).Trim()
generated_at = (Get-Date).ToString("o")
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $outDir "firmware_manifest.json") -Encoding UTF8
Write-Host "Factory firmware: $export"
Write-Host "SHA256: $exportHash"
}
finally {
Copy-Item -LiteralPath $backup -Destination $configuredImage -Force
$restoredHash = (Get-FileHash -LiteralPath $configuredImage -Algorithm SHA256).Hash
if ($restoredHash -ne $originalHash) {
throw "Business customer_fs.bin restore verification failed. Backup: $backup"
}
Write-Host "Business customer_fs.bin restored: $restoredHash"
}
+21
View File
@@ -0,0 +1,21 @@
param(
[string]$Output = "dist"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
python -c "import PyInstaller" 2>$null
if ($LASTEXITCODE -ne 0) {
throw "未找到 PyInstaller。先执行: python -m pip install pyinstaller pyserial"
}
$outDir = Join-Path $root $Output
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
python -m PyInstaller --noconfirm --clean --onefile --windowed `
--name EG800FactoryHost `
--distpath $outDir `
--workpath (Join-Path $root ".pyinstaller-work") `
--specpath (Join-Path $root ".pyinstaller-spec") `
(Join-Path $root "factory_host.py")
Copy-Item (Join-Path $root "factory_host_config.json") $outDir -Force
Write-Host "输出: $(Join-Path $outDir 'EG800FactoryHost.exe')"
+779
View File
@@ -0,0 +1,779 @@
# -*- 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()
+30
View File
@@ -0,0 +1,30 @@
{
"flash": {
"aboot_path": "burn_tool/aboot.exe",
"downloader_path": "burn_tool/aboot-tools-2024.08.08-win-x64/adownload.exe",
"args": [
"-u",
"-a",
"-s",
"{baudrate}",
"-q",
"{firmware}"
],
"timeout_seconds": 900,
"workers": 2
},
"firmwares": {
"production": "factory_host/dist/factory_firmware/EG800AKCN_11LG-FT1-factory-test.zip",
"official": "version/EG800AKCN_11LG-1.8.4-20260921171244/EG800AKCN_11LG-1.8.4-20260921171244-fac.zip"
},
"serial": {
"baudrate": 115200,
"encoding": "utf-8",
"line_ending": "\\r\\n"
},
"protocol": {
"prefix": "FT1 ",
"version": 1,
"auto_save": true
}
}
+246
View File
@@ -0,0 +1,246 @@
"""Parser and state model for the Banban FT1 factory-test protocol."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
PREFIX = "FT1 "
VERSION = 1
MESSAGE_TYPES = {
"session_start",
"node_start",
"node_progress",
"node_result",
"session_result",
"power_off",
}
NODE_NAMES = (
"BOOT_AUDIO",
"POWER_ADC",
"AUDIO_LOOPBACK",
"ESIM",
"DATA_CALL",
"NFC",
)
ERROR_MESSAGES = {
0: "成功",
1001: "ES8311 初始化或设备 ID 检查失败",
1002: "开机提示音文件不存在",
1003: "开机提示音播放失败或超时",
1101: "ADC 初始化或读取失败",
1102: "电压低于 3500mV",
1103: "电压高于 4200mV",
1201: "录音句柄或缓冲区创建失败",
1202: "没有录到有效 PCM",
1203: "PCM 回放失败",
1204: "60 秒内未按下 Loopback 按键",
1401: "eSIM 未进入 READY",
1402: "ICCID 读取失败",
1403: "ICCID 格式错误",
1501: "4G 网络注册超时",
1502: "IPv4 PDP 激活失败",
1503: "未获取有效 IPv4 地址",
1701: "前置节点未全部通过,NFC 锁定",
1702: "DP1312EA 版本不是 0x82",
1703: "60 秒内未成功刷卡",
1704: "NFC UID 无效",
1801: "最终提示音播放失败",
1802: "正常关机接口异常返回",
}
class ProtocolError(ValueError):
pass
def error_text(code: Any) -> str:
try:
number = int(code)
except (TypeError, ValueError):
return f"未知失败码 {code}"
return ERROR_MESSAGES.get(number, f"未知失败码 {number}")
def _require_int(message: dict, key: str, minimum: int = 0) -> int:
value = message.get(key)
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
raise ProtocolError(f"字段 {key} 必须是大于等于 {minimum} 的整数")
return value
def parse_ft1_line(line: str) -> dict | None:
"""Parse one serial line. Non-FT1 lines return None and are not records."""
line = line.strip("\r\n")
if not line.startswith(PREFIX):
return None
try:
message = json.loads(line[len(PREFIX) :])
except json.JSONDecodeError as exc:
raise ProtocolError(f"JSON 无效: {exc.msg}") from exc
if not isinstance(message, dict):
raise ProtocolError("协议载荷必须是 JSON 对象")
if message.get("v") != VERSION:
raise ProtocolError(f"不支持的协议版本: {message.get('v')}")
msg_type = message.get("type")
if msg_type not in MESSAGE_TYPES:
raise ProtocolError(f"不支持的消息类型: {msg_type}")
_require_int(message, "seq", 1)
_require_int(message, "uptime_ms", 0)
if not isinstance(message.get("session"), str) or not message["session"]:
raise ProtocolError("缺少 session")
if msg_type in {"node_start", "node_progress", "node_result"}:
node = _require_int(message, "node", 0)
if node >= len(NODE_NAMES):
raise ProtocolError(f"节点编号越界: {node}")
if message.get("name") != NODE_NAMES[node]:
raise ProtocolError(f"节点名称不匹配: {node}/{message.get('name')}")
if msg_type == "node_progress":
data = message.get("data")
if not isinstance(data, dict):
raise ProtocolError("node_progress 缺少 data")
current = _require_int(data, "current", 0)
required = _require_int(data, "required", 1)
if current > required:
raise ProtocolError("节点进度超过 required")
if msg_type == "node_result":
if message.get("result") not in {"PASS", "FAIL"}:
raise ProtocolError("node_result 的 result 必须为 PASS 或 FAIL")
_require_int(message, "code", 0)
_require_int(message, "elapsed_ms", 0)
if not isinstance(message.get("data"), dict):
raise ProtocolError("node_result 的 data 必须是对象")
if msg_type == "session_result":
if message.get("result") not in {"PASS", "FAIL"}:
raise ProtocolError("session_result 的 result 必须为 PASS 或 FAIL")
_require_int(message, "fail_code", 0)
_require_int(message, "duration_ms", 0)
nodes = message.get("nodes")
if not isinstance(nodes, dict):
raise ProtocolError("session_result 缺少 nodes")
missing = [name for name in NODE_NAMES if nodes.get(name) not in {"WAIT", "PASS", "FAIL"}]
if missing:
raise ProtocolError("节点汇总无效: " + ", ".join(missing))
if msg_type == "power_off":
_require_int(message, "code", 0)
return message
@dataclass
class FT1Session:
session: str = ""
last_seq: int = 0
firmware: str = ""
start_time: str | None = None
imei: str = ""
iccid: str = ""
nfc_uid: str = ""
result: str = ""
fail_code: int = 0
duration_ms: int = 0
current_node: str = ""
session_start_seen: bool = False
session_result_seen: bool = False
power_off_seen: bool = False
nodes: dict[str, str] = field(default_factory=lambda: {name: "WAIT" for name in NODE_NAMES})
node_details: dict[str, dict] = field(default_factory=dict)
messages: list[dict] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def reset(self, session: str = "") -> None:
fresh = type(self)(session=session)
self.__dict__.update(fresh.__dict__)
def apply(self, message: dict) -> str:
msg_type = message["type"]
incoming_session = message["session"]
if msg_type == "session_start" and incoming_session != self.session:
self.reset(incoming_session)
elif not self.session:
self.reset(incoming_session)
self.warnings.append("未收到 session_start,记录可能不完整")
elif incoming_session != self.session:
raise ProtocolError(f"会话突变: {self.session} -> {incoming_session}")
seq = message["seq"]
if seq <= self.last_seq:
raise ProtocolError(f"seq 未递增: {seq} <= {self.last_seq}")
if self.last_seq and seq != self.last_seq + 1:
self.warnings.append(f"消息序号缺口: {self.last_seq} -> {seq}")
self.last_seq = seq
self.messages.append(message)
for key in ("imei", "iccid", "nfc_uid"):
value = message.get(key)
if isinstance(value, str) and value:
setattr(self, key, value)
if msg_type == "session_start":
self.session_start_seen = True
self.firmware = str(message.get("fw", ""))
self.start_time = message.get("start_time")
return f"会话开始 {self.firmware or self.session}"
if msg_type == "node_start":
name = message["name"]
self.current_node = name
self.nodes[name] = "RUNNING"
return f"开始 {name}"
if msg_type == "node_progress":
name = message["name"]
data = message["data"]
self.current_node = name
self.nodes[name] = f"{data['current']}/{data['required']}"
self.node_details[name] = {"data": data}
return f"{name} {data['current']}/{data['required']}"
if msg_type == "node_result":
name = message["name"]
self.nodes[name] = message["result"]
self.node_details[name] = {
"code": message["code"],
"elapsed_ms": message["elapsed_ms"],
"data": message["data"],
}
return f"{name} {message['result']} ({message['code']})"
if msg_type == "session_result":
self.session_result_seen = True
self.result = message["result"]
self.fail_code = message["fail_code"]
self.duration_ms = message["duration_ms"]
self.start_time = message.get("start_time")
self.nodes.update(message["nodes"])
return f"整机 {self.result}: {error_text(self.fail_code)}"
self.power_off_seen = True
return "设备准备关机"
@property
def complete(self) -> bool:
return self.session_start_seen and self.session_result_seen
@property
def passed_count(self) -> int:
return sum(state == "PASS" for state in self.nodes.values())
def summary(self) -> dict:
return {
"protocol_version": VERSION,
"session": self.session,
"firmware": self.firmware,
"start_time": self.start_time,
"imei": self.imei,
"iccid": self.iccid,
"nfc_uid": self.nfc_uid,
"result": self.result or "INCOMPLETE",
"fail_code": self.fail_code,
"fail_reason": error_text(self.fail_code) if self.result else "会话未完成",
"duration_ms": self.duration_ms,
"nodes": dict(self.nodes),
"node_details": dict(self.node_details),
"session_start_seen": self.session_start_seen,
"session_result_seen": self.session_result_seen,
"power_off_seen": self.power_off_seen,
"protocol_complete": self.complete,
"last_seq": self.last_seq,
"warnings": list(self.warnings),
}
+3
View File
@@ -0,0 +1,3 @@
# Optional: improves COM enumeration and serial I/O. The application has a
# Windows file-handle fallback when pyserial is unavailable.
pyserial>=3.5
+98
View File
@@ -0,0 +1,98 @@
import json
import tempfile
import unittest
from pathlib import Path
import factory_host
from ft1_protocol import FT1Session, ProtocolError, parse_ft1_line
class FactoryHostTests(unittest.TestCase):
def test_expand_args(self):
args = factory_host.expand_args(
["--port", "{port}", "{firmware}", "--station", "{station}"],
aboot=Path(r"D:\burn_tool\aboot.exe"),
firmware=Path(r"D:\fw\prod.zip"),
port="COM14",
station="2",
baudrate=115200,
)
self.assertEqual(args[1], "COM14")
self.assertEqual(args[2], r"D:\fw\prod.zip")
self.assertEqual(args[-1], "2")
def test_merge_and_config_round_trip(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "config.json"
config = factory_host.load_config(path)
config["flash"]["workers"] = 4
factory_host.save_config(config, path)
loaded = factory_host.load_config(path)
self.assertEqual(loaded["flash"]["workers"], 4)
self.assertIn("protocol", loaded)
def test_parse_command_template(self):
self.assertEqual(factory_host.parse_command_template('"C:\\Program Files\\aboot.exe" --port COM14'), [r"C:\Program Files\aboot.exe", "--port", "COM14"])
def test_default_flash_waits_for_arom_usb(self):
args = factory_host.DEFAULT_CONFIG["flash"]["args"]
self.assertIn("-u", args)
self.assertIn("-a", args)
self.assertNotIn("{port}", args)
def test_project_root_and_relative_path(self):
self.assertTrue((factory_host.ROOT_DIR / "burn_tool").is_dir())
config = factory_host.load_config(factory_host.CONFIG_PATH)
self.assertEqual(
Path(config["firmwares"]["production"]).name,
"EG800AKCN_11LG-FT1-factory-test.zip",
)
self.assertTrue(
factory_host.resolve_project_path(config["firmwares"]["production"]).is_file()
)
self.assertTrue(
factory_host.resolve_project_path(config["firmwares"]["official"]).is_file()
)
def test_ft1_ignores_sdk_log_and_parses_node(self):
self.assertIsNone(parse_ft1_line("DP1312EA is OK!"))
message = parse_ft1_line(
'FT1 {"v":1,"type":"node_result","seq":3,"session":"BOOT-1",'
'"uptime_ms":2450,"imei":"860123456789012","iccid":"","nfc_uid":"",'
'"node":1,"name":"POWER_ADC","result":"PASS","code":0,"elapsed_ms":350,'
'"data":{"voltage_mv":3865}}'
)
self.assertEqual(message["data"]["voltage_mv"], 3865)
def test_ft1_session_result_and_sequence_guard(self):
session = FT1Session()
start = parse_ft1_line(
'FT1 {"v":1,"type":"session_start","seq":1,"session":"BOOT-1",'
'"uptime_ms":2050,"imei":"860123456789012","iccid":"","nfc_uid":"",'
'"fw":"FACTORY_TEST_1.0.0","start_time":null}'
)
session.apply(start)
result = parse_ft1_line(
'FT1 {"v":1,"type":"session_result","seq":2,"session":"BOOT-1",'
'"uptime_ms":40120,"result":"FAIL","fail_code":1203,"imei":"860123456789012",'
'"iccid":"","nfc_uid":"","start_time":null,"duration_ms":38070,"nodes":'
'{"BOOT_AUDIO":"PASS","POWER_ADC":"PASS","AUDIO_LOOPBACK":"FAIL",'
'"ESIM":"WAIT","DATA_CALL":"WAIT","NFC":"WAIT"}}'
)
session.apply(result)
self.assertEqual(session.result, "FAIL")
self.assertEqual(session.nodes["AUDIO_LOOPBACK"], "FAIL")
self.assertTrue(session.complete)
with self.assertRaises(ProtocolError):
session.apply(result)
def test_ft1_rejects_node_name_mismatch(self):
with self.assertRaises(ProtocolError):
parse_ft1_line(
'FT1 {"v":1,"type":"node_start","seq":2,"session":"BOOT-1",'
'"uptime_ms":2100,"node":1,"name":"NFC"}'
)
if __name__ == "__main__":
unittest.main()