feat: automate FT1 factory test status reporting

This commit is contained in:
chenjintao
2026-09-24 11:21:40 +08:00
parent 8b499948dd
commit 06b0eaaf70
11 changed files with 461 additions and 38 deletions
+6 -6
View File
@@ -1,12 +1,12 @@
# EG800 FT1 工厂烧录与产测上位机
本工具配套 `test/factory-test` 分支的独立产测固件,支持批量烧录、USB CDC
本工具配套 `test/factory-test` 分支的独立产测固件,支持批量烧录、DBG UART
协议采集、6 个硬件节点判定和结果落盘。
## 主要功能
- 产测固件和正式固件分开保存,每个工位可独立选择。
- 烧录串口和产测 CDC 串口分开配置,避免把 DIAG/AT/CDC 当成同一个端口。
- 烧录串口和产测 DBG UART 分开配置,避免把 DIAG/AT/调试口误认成烧录口。
- 使用原厂 `adownload.exe -u -a`,先进入等待状态,再由关机设备上电触发烧录。
- 提供“烧录选中”单工位入口;USB 自动等待的批量任务按工位顺序执行,避免抢设备。
- 只解析以 `FT1 ` 开头的协议行;普通 SDK 日志只显示,不进入产测数据库。
@@ -30,13 +30,13 @@ burn_tool/aboot-tools-2024.08.08-win-x64/adownload.exe
factory_host/dist/EG800FactoryHost.exe
```
默认“产测固件”指向已包含六个产测 MP3 的 FT1 整包;默认“正式固件”指向
默认“产测固件”指向已包含节点提示音的 FT1 整包;默认“正式固件”指向
`version` 目录中的正式内层 `-fac.zip`。两者都可在外置
`factory_host_config.json` 中替换,烧录时不要选择 `-target.zip` 外层归档。
双击表格中的工位可以分别设置烧录口和产测 CDC 口。默认 USB 自动等待模式不需要
双击表格中的工位可以分别设置烧录口和产测 DBG UART 口。默认 USB 自动等待模式不需要
填写烧录口;产测 CDC 口仍需填写。“烧录选中”要求只选择一个工位;“批量烧录”
填写烧录口;产测 UART 可留空并自动识别。“烧录选中”要求只选择一个工位;“批量烧录”
在未选中行时作用于全部工位,选中后只作用于选中工位。
单工位烧录时保持设备关机并连接 USB,选择工位后点击“烧录选中”。工位状态显示
@@ -69,7 +69,7 @@ python -B .\factory_host\build_factory_audio_package.py
脚本会执行以下检查:
1. 源目录必须且只能包含六个固定文件名。
1. 源目录必须且只能包含五个固定文件名。
2. 每个文件必须为 16 kHz、单声道、48 kbps MP3。
3. 使用 SDK `mklfs-v2.exe -m` 生成 262144 字节 `customer_fs.bin`。
4. 立即反向解包镜像并逐文件比对 SHA-256。
+173
View File
@@ -0,0 +1,173 @@
# EG800 产测上位机使用说明
## 1. 发布目录
现场使用时至少保留以下内容,并保持相对目录不变:
```text
banban/
├─ burn_tool/
│ └─ aboot-tools-2024.08.08-win-x64/
│ ├─ adownload.exe
│ └─ 其他随工具提供的 DLL、配置和驱动文件
├─ factory_host/
│ └─ dist/
│ ├─ EG800FactoryHost.exe
│ ├─ factory_host_config.json
│ └─ factory_firmware/
│ └─ EG800AKCN_11LG-FT1-factory-test.zip
└─ version/
└─ 正式固件目录/
└─ *-fac.zip
```
`adownload.exe` 不能脱离原来的工具目录单独复制,否则可能缺少 DLL、配置或驱动。
## 2. 首次配置
上位机读取与 EXE 同目录的 `factory_host_config.json`。推荐配置:
```json
{
"flash": {
"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-版本号-fac.zip"
},
"serial": {
"baudrate": 115200,
"encoding": "utf-8",
"line_ending": "\\r\\n"
},
"protocol": {
"prefix": "FT1 ",
"version": 1,
"auto_save": true
}
}
```
- `production`:产测固件,包含 FT1 测试程序、开机/Loopback/NFC/关机音频,以及暂不参与流程的 GNSS 音频。
- `official`:正式业务固件,供产测完成后换烧。
- 固件路径可以使用相对于项目根目录的路径,也可以填写绝对路径。
- 必须选择可直接烧录的内层 FT1 ZIP 或 `*-fac.zip`,不要选择外层 `*-target.zip` 归档。
- 修改配置后重新启动上位机,或使用界面中的重新加载配置功能。
## 3. 烧录产测固件
1. 启动 `EG800FactoryHost.exe`,固件类型选择“产测固件”。
2. 设备保持关机,连接烧录器和 USB。
3. 选择工位后点击单工位烧录;批量生产时使用批量烧录。
4. 界面显示“等待上电”后再给设备开机。
5. 等待状态变成“烧录成功”,不要在烧录过程中拔线或关闭程序。
默认参数使用 `adownload.exe -u -a` 自动等待 AROM 下载设备,因此烧录口和产测 UART 是两个不同角色。
## 4. 自动识别并开始产测
烧录完成、设备正常启动并连接调试 UART 后:
1. 点击“扫描串口”可只识别设备并查看工位;直接点击“开始产测”也会先自动扫描。
2. 上位机被动监听所有候选 COM 口,不向设备发送 `IDENTIFY` 或 `START` 命令。
3. 只有能持续返回合法 FT1 状态广播的 UART 才会自动添加为工位。当前固件使用 `QL_DEBUG_UART_PORT`(GPIO29/30),不使用 USB CDC;AROM、主 UART、DIAG 和 AT 口不会被添加。
4. 每发现一台设备,上位机自动创建或复用一个空工位,并在工位表显示对应 COM 口。
5. 设备上电后自动开始测试;上位机只接收并解析每 500ms 一次的完整状态广播,不需要发送启动命令。
6. 未手工选中工位时,“开始产测”会启动所有已识别工位;选中工位时只启动选中的设备。
每个工位会实时显示:
- 当前测试节点和通过数量;
- 整机通过或失败状态;
- IMEI、ICCID、NFC UID;
- 失败码、结构化测试数据和实时串口日志。
产测固件当前依次执行:开机音频、电源 ADC、音频 Loopback、eSIM、数据连接和 NFC。
## 5. 测试结果与日志
默认自动保存位置:
```text
factory_host/dist/logs/factory_results.csv
factory_host/dist/logs/sessions/*.json
```
- `factory_results.csv`:每台设备一行,适合生产汇总。
- `sessions/*.json`:保存完整协议消息、节点耗时、失败码和各节点数据,适合追查单台设备。
- 普通 SDK 日志会显示在实时日志区,但只有 `FT1 ` 开头的合法协议消息会参与结果判定。
- 最终判定以设备发送的 `session_result` 为准,上位机不会根据普通文本日志猜测通过或失败。
## 6. 替换固件包
### 方法一:修改配置文件
把新固件复制到稳定目录,然后修改:
```json
"firmwares": {
"production": "新产测固件.zip",
"official": "新正式固件-fac.zip"
}
```
推荐每次替换后记录文件名、版本、文件大小和 SHA-256,避免现场混用旧包。
### 方法二:在界面选择
选择“产测固件”或“正式固件”类型,再使用界面的固件选择按钮指定对应 ZIP。选择后检查界面顶部显示的完整路径。
正式固件通常不包含 FT1 产测协议。换烧正式固件后,上位机不能再自动识别为 FT1 产测工位,这是正常现象。
## 7. 更新产测音频并生成固件
音频源目录:
```text
audio_prompts/factory_test/customer_fs/
├─ factory_boot_zh.mp3
├─ factory_audio_loopback_zh.mp3
├─ factory_nfc_test_zh.mp3
├─ factory_gnss_test_zh.mp3
└─ factory_shutdown_zh.mp3
```
文件必须保持脚本要求的名称和音频格式。更新后在项目根目录执行:
```powershell
python -B .\factory_host\build_factory_audio_package.py
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\factory_host\build_factory_firmware.ps1
```
生成物:
```text
factory_host/dist/factory_audio/customer_fs.bin
factory_host/dist/factory_audio/EG800AKCN_11LG-FT1-factory-audio.zip
factory_host/dist/factory_firmware/EG800AKCN_11LG-FT1-factory-test.zip
factory_host/dist/factory_firmware/firmware_manifest.json
```
固件脚本会临时替换构建目录中的 `customer_fs.bin`,完成后自动恢复业务镜像并核对哈希。若脚本报告恢复失败,不要继续发布固件,应先检查备份和当前业务镜像。
## 8. 重新打包上位机
开发环境先安装依赖:
```powershell
python -m pip install -r .\factory_host\requirements.txt
python -m pip install pyinstaller
```
运行测试并打包:
```powershell
python -B -m unittest discover -s factory_host -p "test_*.py" -v
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\factory_host\build_release.ps1
```
发布前至少使用一台设备验证:产测固件烧录、调试 UART 自动识别、START 握手、六项测试结果、JSON/CSV 保存,以及正式固件换烧。
+1 -2
View File
@@ -18,7 +18,6 @@ EXPECTED_FILES = (
"factory_audio_loopback_zh.mp3",
"factory_nfc_test_zh.mp3",
"factory_gnss_test_zh.mp3",
"factory_pass_zh.mp3",
"factory_shutdown_zh.mp3",
)
IMAGE_SIZE = 262144
@@ -168,7 +167,7 @@ def build_package(root: Path, output: Path) -> tuple[Path, Path, dict]:
readme_path.write_text(
"班班 FT1 产测音频包\n\n"
"customer_fs.bin 固定为 262144 字节,用于 EG800AKCN_11LG 的 customer_fs 分区。\n"
"六个 MP3 必须位于 U:/ 根目录;不要将此镜像混入正式业务固件。\n"
"五个 MP3 必须位于 U:/ 根目录;不要将此镜像混入正式业务固件。\n"
"烧录后以 FT1 BOOT_AUDIO 和最终 session_result 作为设备侧验证。\n",
encoding="utf-8-sig",
)
+90 -18
View File
@@ -158,6 +158,32 @@ def list_serial_ports() -> list[str]:
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."""
@@ -170,6 +196,7 @@ class SerialReader:
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():
@@ -189,6 +216,7 @@ class SerialReader:
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)
@@ -207,6 +235,7 @@ class SerialReader:
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)
@@ -222,6 +251,7 @@ class SerialReader:
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()
@@ -342,11 +372,9 @@ class FactoryHost(Tk):
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")
# 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", ""))
@@ -364,14 +392,34 @@ class FactoryHost(Tk):
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)
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:
@@ -417,7 +465,7 @@ class FactoryHost(Tk):
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)
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)
@@ -576,20 +624,24 @@ class FactoryHost(Tk):
self.events.put(("station", station.number, result, message))
def start_tests(self) -> None:
selected = self.table.selection() or tuple(self.stations.keys())
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 = "失败", "未设置产测 CDC 串口"
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 session_start"
station.message = "等待 FT1 状态广播"
if station.reader:
station.reader.stop()
station.reader = SerialReader(
@@ -601,7 +653,7 @@ class FactoryHost(Tk):
station.reader.start()
self._update_row(station)
self._refresh_node_table()
self._log("系统", "FT1 串口产测已启动;只保存以 FT1 开头的协议行")
self._log("系统", "已自动识别 FT1 UART;设备自动开始产测并每500ms广播状态")
def _serial_line(self, station: Station, line: str) -> None:
self.events.put(("serial", station.number, line))
@@ -624,6 +676,26 @@ class FactoryHost(Tk):
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)
+35
View File
@@ -16,6 +16,9 @@ MESSAGE_TYPES = {
"node_result",
"session_result",
"power_off",
"device_ready",
"command_ack",
"status_snapshot",
}
NODE_NAMES = (
"BOOT_AUDIO",
@@ -126,6 +129,22 @@ def parse_ft1_line(line: str) -> dict | None:
raise ProtocolError("节点汇总无效: " + ", ".join(missing))
if msg_type == "power_off":
_require_int(message, "code", 0)
if msg_type == "device_ready":
if not isinstance(message.get("fw"), str):
raise ProtocolError("device_ready 缺少 fw")
if msg_type == "command_ack":
if message.get("command") != "START" or not isinstance(message.get("accepted"), bool):
raise ProtocolError("command_ack 字段无效")
if msg_type == "status_snapshot":
active_node = message.get("active_node")
if isinstance(active_node, bool) or not isinstance(active_node, int) or active_node < -1 or active_node >= len(NODE_NAMES):
raise ProtocolError("status_snapshot active_node 无效")
nodes = message.get("nodes")
if not isinstance(nodes, dict):
raise ProtocolError("status_snapshot 缺少 nodes")
missing = [name for name in NODE_NAMES if nodes.get(name) not in {"WAIT", "PASS", "FAIL"}]
if missing:
raise ProtocolError("status_snapshot 节点状态缺失: " + ", ".join(missing))
return message
@@ -145,6 +164,8 @@ class FT1Session:
session_start_seen: bool = False
session_result_seen: bool = False
power_off_seen: bool = False
device_ready_seen: bool = False
start_ack_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)
@@ -182,6 +203,20 @@ class FT1Session:
self.firmware = str(message.get("fw", ""))
self.start_time = message.get("start_time")
return f"会话开始 {self.firmware or self.session}"
if msg_type == "device_ready":
self.device_ready_seen = True
self.firmware = str(message.get("fw", ""))
return f"设备已识别 {self.firmware}"
if msg_type == "command_ack":
self.start_ack_seen = bool(message.get("accepted"))
return "START 已确认" if self.start_ack_seen else "START 被拒绝"
if msg_type == "status_snapshot":
self.nodes.update(message["nodes"])
active_node = message["active_node"]
self.current_node = NODE_NAMES[active_node] if active_node >= 0 else ""
if active_node >= 0:
self.nodes[self.current_node] = "RUNNING"
return "状态广播 " + ", ".join(f"{name}={self.nodes[name]}" for name in NODE_NAMES)
if msg_type == "node_start":
name = message["name"]
self.current_node = name
+34
View File
@@ -93,6 +93,40 @@ class FactoryHostTests(unittest.TestCase):
'"uptime_ms":2100,"node":1,"name":"NFC"}'
)
def test_ft1_control_messages(self):
ready = parse_ft1_line(
'FT1 {"v":1,"type":"device_ready","seq":1,"session":"BOOT-1",'
'"uptime_ms":100,"fw":"FACTORY_TEST_1.0.0"}'
)
ack = parse_ft1_line(
'FT1 {"v":1,"type":"command_ack","seq":2,"session":"BOOT-1",'
'"uptime_ms":120,"command":"START","accepted":true}'
)
session = FT1Session()
self.assertEqual(session.apply(ready), "设备已识别 FACTORY_TEST_1.0.0")
self.assertEqual(session.apply(ack), "START 已确认")
self.assertTrue(session.device_ready_seen)
self.assertTrue(session.start_ack_seen)
def test_ft1_status_snapshot_keeps_passed_nodes(self):
first = parse_ft1_line(
'FT1 {"v":1,"type":"status_snapshot","seq":1,"session":"BOOT-1",'
'"uptime_ms":100,"active_node":0,"nodes":{"BOOT_AUDIO":"WAIT",'
'"POWER_ADC":"WAIT","AUDIO_LOOPBACK":"WAIT","ESIM":"WAIT",'
'"DATA_CALL":"WAIT","NFC":"WAIT"}}'
)
second = parse_ft1_line(
'FT1 {"v":1,"type":"status_snapshot","seq":2,"session":"BOOT-1",'
'"uptime_ms":600,"active_node":1,"nodes":{"BOOT_AUDIO":"PASS",'
'"POWER_ADC":"WAIT","AUDIO_LOOPBACK":"WAIT","ESIM":"WAIT",'
'"DATA_CALL":"WAIT","NFC":"WAIT"}}'
)
session = FT1Session()
session.apply(first)
session.apply(second)
self.assertEqual(session.nodes["BOOT_AUDIO"], "PASS")
self.assertEqual(session.current_node, "POWER_ADC")
if __name__ == "__main__":
unittest.main()