feat: add FT1 factory test host and protocol updates
This commit is contained in:
+13
-36
@@ -25,13 +25,10 @@
|
||||
-> ES8311 ID/初始化,设置所有播放音量为 100%
|
||||
-> 播放开机提示音
|
||||
-> 电源 ADC
|
||||
-> 按住对话键录音,松开后回放,最多录 5 秒
|
||||
-> 按住 GPIO84(原三击测试按键)录音,松开后回放,最多录 5 秒
|
||||
-> 工人判断音质
|
||||
正常:按另一个按键三次
|
||||
异常:不再操作,60 秒后 KEY_TRIPLE 失败
|
||||
-> eSIM READY 和 ICCID
|
||||
-> 4G 注册、PDP 激活、获取 IPv4
|
||||
-> Ping 业务域名
|
||||
-> 前面全部通过后启用 NFC 测试
|
||||
-> 读取 NTAG213 UID
|
||||
-> 播放“全部测试通过”
|
||||
@@ -49,16 +46,13 @@
|
||||
|---:|---|---|---|---:|
|
||||
| 0 | `BOOT_AUDIO` | 读取 ES8311 ID,初始化外置 Codec,播放 `U:/factory_boot_zh.mp3` | Codec ID 为 `0x83`,文件存在且播放结束 | 15 秒播放超时 |
|
||||
| 1 | `POWER_ADC` | 读取 6 次 VBAT,去掉最大值和最小值后平均 | `3500mV <= 平均值 <= 4200mV` | - |
|
||||
| 2 | `AUDIO_LOOPBACK` | 按住电源/对话键录音,松开后按原音量回放 | 录音和回放 API 成功且至少有一帧 PCM | 等待按键 60 秒,录音最长 5 秒 |
|
||||
| 3 | `KEY_TRIPLE` | GPIO84 按下并释放三次,每次输出进度 PASS | 60 秒内累计三次有效按键 | 60 秒 |
|
||||
| 4 | `ESIM` | 查询 SIM 状态并读取 ICCID | `READY`,ICCID 为 18-22 位十进制数字 | 30 秒 |
|
||||
| 5 | `DATA_CALL` | 等待注册、激活 CID 1 IPv4 PDP | 返回非 `0.0.0.0` 的 IPv4 | 注册等待 120 秒 |
|
||||
| 6 | `PING` | Ping `banban.api.talkingq.com` 3 次 | 至少收到 2 次回复 | 单包 4 秒,总等待 20 秒 |
|
||||
| 7 | `NFC` | 检查 DP1312EA 版本并轮询 NTAG213 | 版本 `0x82`,成功读取 UID | 60 秒 |
|
||||
| 2 | `AUDIO_LOOPBACK` | 按住 GPIO84 录音,松开后按原音量回放 | 录音和回放 API 成功且至少有一帧 PCM | 等待按键 60 秒,录音最长 5 秒 |
|
||||
| 3 | `ESIM` | 查询 SIM 状态并读取 ICCID | `READY`,ICCID 为 18-22 位十进制数字 | 30 秒 |
|
||||
| 4 | `DATA_CALL` | 等待注册、激活 CID 1 IPv4 PDP | 返回非 `0.0.0.0` 的 IPv4 | 注册等待 120 秒 |
|
||||
| 5 | `NFC` | 检查 DP1312EA 版本并轮询 NTAG213 | 版本 `0x82`,成功读取 UID | 60 秒 |
|
||||
|
||||
说明:`AUDIO_LOOPBACK` 的软件通过仅代表数据链路完成。最终声学判断由工人完成;
|
||||
如果声音异常,工人不按三击键,随后 `KEY_TRIPLE` 超时,整机产测失败。这避免了
|
||||
上位机额外的人工确认按钮。
|
||||
说明:`AUDIO_LOOPBACK` 使用 GPIO84 按键,软件通过仅代表录放数据链路完成。
|
||||
最终声学判断由工人完成。
|
||||
|
||||
## 4. 音频和文件系统接口
|
||||
|
||||
@@ -104,7 +98,6 @@ Windows 打包方法见 [factory_test_windows_fs.md](factory_test_windows_fs.md)
|
||||
### 5.2 会话开始
|
||||
|
||||
```text
|
||||
FT1 {"v":1,"type":"session_start","seq":1,"session":"20260923-163010","uptime_ms":2050,"imei":"860123456789012","iccid":"","nfc_uid":"","fw":"FACTORY_TEST_1.0.0","ping_host":"banban.api.talkingq.com","start_time":"2026-09-23T16:30:10"}
|
||||
```
|
||||
|
||||
RTC 无效时 `start_time` 为 `null`。RTC 无效不会导致产测失败;测试耗时始终以
|
||||
@@ -122,36 +115,25 @@ FT1 {"v":1,"type":"node_start","seq":2,"session":"20260923-163010","uptime_ms":2
|
||||
FT1 {"v":1,"type":"node_result","seq":3,"session":"20260923-163010","uptime_ms":2450,"imei":"860123456789012","iccid":"","nfc_uid":"","node":1,"name":"POWER_ADC","result":"PASS","code":0,"elapsed_ms":350,"data":{"voltage_mv":3865,"sample_min_mv":3858,"sample_max_mv":3873,"limit_min_mv":3500,"limit_max_mv":4200}}
|
||||
```
|
||||
|
||||
### 5.5 三击按键进度
|
||||
|
||||
每次完整按下并释放都输出一条 `node_progress`:
|
||||
|
||||
```text
|
||||
FT1 {"v":1,"type":"node_progress","seq":8,"session":"20260923-163010","uptime_ms":12300,"imei":"860123456789012","iccid":"","nfc_uid":"","node":3,"name":"KEY_TRIPLE","result":"PASS","data":{"current":1,"required":3}}
|
||||
```
|
||||
|
||||
第三次松开后再输出正式的 `node_result=PASS`。
|
||||
|
||||
### 5.6 网络和 Ping
|
||||
### 5.5 NFC 和最终报告
|
||||
|
||||
```text
|
||||
FT1 {"v":1,"type":"node_result","seq":14,"session":"20260923-163010","uptime_ms":28000,"imei":"860123456789012","iccid":"89860xxxxxxxxxxxxxxx","nfc_uid":"","node":5,"name":"DATA_CALL","result":"PASS","code":0,"elapsed_ms":8200,"data":{"ip":"10.23.45.67","ip_type":"IPv4"}}
|
||||
```
|
||||
|
||||
```text
|
||||
FT1 {"v":1,"type":"node_result","seq":16,"session":"20260923-163010","uptime_ms":31500,"imei":"860123456789012","iccid":"89860xxxxxxxxxxxxxxx","nfc_uid":"","node":6,"name":"PING","result":"PASS","code":0,"elapsed_ms":3400,"data":{"host":"banban.api.talkingq.com","sent":3,"received":3,"lost":0,"avg_rtt_ms":86}}
|
||||
```
|
||||
|
||||
### 5.7 NFC 和最终报告
|
||||
### 5.6 NFC 和最终报告
|
||||
|
||||
```text
|
||||
FT1 {"v":1,"type":"node_result","seq":18,"session":"20260923-163010","uptime_ms":34000,"imei":"860123456789012","iccid":"89860xxxxxxxxxxxxxxx","nfc_uid":"04A1B2C3D4E5F6","node":7,"name":"NFC","result":"PASS","code":0,"elapsed_ms":1900,"data":{"chip_version":"0x82","uid":"04A1B2C3D4E5F6"}}
|
||||
FT1 {"v":1,"type":"node_result","seq":18,"session":"20260923-163010","uptime_ms":34000,"imei":"860123456789012","iccid":"89860xxxxxxxxxxxxxxx","nfc_uid":"04A1B2C3D4E5F6","node":5,"name":"NFC","result":"PASS","code":0,"elapsed_ms":1900,"data":{"chip_version":"0x82","uid":"04A1B2C3D4E5F6"}}
|
||||
```
|
||||
|
||||
最终报告包含 IMEI、ICCID、NFC UID、失败码、开始时间、总耗时和全部节点状态:
|
||||
|
||||
```text
|
||||
FT1 {"v":1,"type":"session_result","seq":19,"session":"20260923-163010","uptime_ms":40120,"result":"PASS","fail_code":0,"imei":"860123456789012","iccid":"89860xxxxxxxxxxxxxxx","nfc_uid":"04A1B2C3D4E5F6","start_time":"2026-09-23T16:30:10","duration_ms":38070,"nodes":{"BOOT_AUDIO":"PASS","POWER_ADC":"PASS","AUDIO_LOOPBACK":"PASS","KEY_TRIPLE":"PASS","ESIM":"PASS","DATA_CALL":"PASS","PING":"PASS","NFC":"PASS"}}
|
||||
FT1 {"v":1,"type":"session_result","seq":19,"session":"20260923-163010","uptime_ms":40120,"result":"PASS","fail_code":0,"imei":"860123456789012","iccid":"89860xxxxxxxxxxxxxxx","nfc_uid":"04A1B2C3D4E5F6","start_time":"2026-09-23T16:30:10","duration_ms":38070,"nodes":{"BOOT_AUDIO":"PASS","POWER_ADC":"PASS","AUDIO_LOOPBACK":"PASS","ESIM":"PASS","DATA_CALL":"PASS","NFC":"PASS"}}
|
||||
```
|
||||
|
||||
上位机收到 `power_off` 后应立即落盘本次记录:
|
||||
@@ -174,17 +156,13 @@ FT1 {"v":1,"type":"power_off","seq":20,"session":"20260923-163010","uptime_ms":4
|
||||
| 1201 | 录音句柄或 160KB 缓冲区创建失败 |
|
||||
| 1202 | 没有录到一帧有效 PCM |
|
||||
| 1203 | PCM 回放失败 |
|
||||
| 1204 | 60 秒内未按下对话键 |
|
||||
| 1301 | 音质异常未继续,或 60 秒内未完成三击 |
|
||||
| 1204 | 60 秒内未按下 Loopback 按键 |
|
||||
| 1401 | eSIM 未进入 READY |
|
||||
| 1402 | ICCID 读取失败 |
|
||||
| 1403 | ICCID 长度或字符格式错误 |
|
||||
| 1501 | 4G 网络注册超时 |
|
||||
| 1502 | IPv4 PDP 激活失败 |
|
||||
| 1503 | 未获取有效 IPv4 地址 |
|
||||
| 1601 | Ping 上下文或同步对象创建失败 |
|
||||
| 1602 | Ping 启动或等待回调失败 |
|
||||
| 1603 | 3 包中收到少于 2 包 |
|
||||
| 1701 | 前置节点未全部通过,NFC 锁定 |
|
||||
| 1702 | DP1312EA 版本寄存器不是 `0x82` |
|
||||
| 1703 | 60 秒内没有成功刷卡 |
|
||||
@@ -200,8 +178,7 @@ FT1 {"v":1,"type":"power_off","seq":20,"session":"20260923-163010","uptime_ms":4
|
||||
- `interface/factory_test/factory_test.h`:阈值、超时、业务域名、GPIO 和文件路径;
|
||||
- `interface/factory_test/Makefile`:独立产测编译单元。
|
||||
|
||||
修改 Ping 地址时只需修改 `FT_PING_HOST`。ADC 阈值、按键超时和 NFC 超时也集中在
|
||||
`factory_test.h`。GPIO84 是第二个按键,GPIO68 是功放控制;产测代码不会把 GPIO68
|
||||
ADC 阈值、按键超时和 NFC 超时集中在 `factory_test.h`。GPIO84 是第二个按键,GPIO68 是功放控制;产测代码不会把 GPIO68
|
||||
当作 LED,也不会把 GPIO86 当作红色 LED,避免和 NFC 复位冲突。
|
||||
|
||||
## 8. 上板前检查
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
logs/
|
||||
dist/
|
||||
.pyinstaller-work/
|
||||
.pyinstaller-spec/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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')"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -5,7 +5,6 @@
|
||||
#include "ql_audio.h"
|
||||
#include "ql_fs.h"
|
||||
#include "ql_gpio.h"
|
||||
#include "ql_power.h"
|
||||
#include "ql_rtos.h"
|
||||
|
||||
#include "factory_audio.h"
|
||||
@@ -16,7 +15,9 @@
|
||||
#define ES8311_DEVICE_ID 0x83
|
||||
#define ES8311_VOLUME_100 0x8E
|
||||
#define FT_PCM_CHUNK_BYTES 640
|
||||
#define FT_MP3_TIMEOUT_TICKS (15000 / 5)
|
||||
#define FT_PCM_CHUNK_MS 20
|
||||
#define FT_PCM_WRITE_RETRIES 40
|
||||
#define FT_MP3_TIMEOUT_MS 15000
|
||||
|
||||
extern void sw_i2c_init(void);
|
||||
extern int8 sw_i2c_start(void);
|
||||
@@ -157,7 +158,7 @@ int factory_audio_play_prompt(const char *path)
|
||||
return -1;
|
||||
}
|
||||
|
||||
ret = ql_rtos_semaphore_wait(g_mp3_sem, FT_MP3_TIMEOUT_TICKS);
|
||||
ret = ql_rtos_semaphore_wait(g_mp3_sem, FT_MP3_TIMEOUT_MS);
|
||||
if (ret != 0 || g_mp3_result != 2) {
|
||||
ql_stop_mp3_play(g_mp3_handle, 0);
|
||||
factory_pa_control(0);
|
||||
@@ -167,15 +168,20 @@ int factory_audio_play_prompt(const char *path)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int wait_pwrkey_level(unsigned char expected, u32 timeout_ms)
|
||||
static int wait_loopback_key_level(PIN_LEVEL_E expected, u32 timeout_ms)
|
||||
{
|
||||
PIN_LEVEL_E level = PIN_LEVEL_HIGH;
|
||||
u32 start = ft_now_ms();
|
||||
while (ql_get_pwrkey_status() != expected) {
|
||||
if (ft_now_ms() - start >= timeout_ms) return -1;
|
||||
do {
|
||||
ql_gpio_get_level(FT_LOOPBACK_KEY_PIN, &level);
|
||||
if (level == expected) {
|
||||
ql_rtos_task_sleep_ms(40);
|
||||
ql_gpio_get_level(FT_LOOPBACK_KEY_PIN, &level);
|
||||
if (level == expected) return 0;
|
||||
}
|
||||
ql_rtos_task_sleep_ms(10);
|
||||
}
|
||||
ql_rtos_task_sleep_ms(40);
|
||||
return ql_get_pwrkey_status() == expected ? 0 : -1;
|
||||
} while (ft_now_ms() - start < timeout_ms);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int factory_audio_wait_and_loopback(u32 wait_timeout_ms, u32 *recorded_ms)
|
||||
@@ -187,21 +193,29 @@ int factory_audio_wait_and_loopback(u32 wait_timeout_ms, u32 *recorded_ms)
|
||||
audio_record_handle record_handle = 0;
|
||||
audio_track_handle track_handle = 0;
|
||||
int ret = -1;
|
||||
u32 release_wait_start = 0;
|
||||
|
||||
if (recorded_ms) *recorded_ms = 0;
|
||||
if (wait_pwrkey_level(0, 10000) != 0 ||
|
||||
wait_pwrkey_level(1, wait_timeout_ms) != 0) {
|
||||
ql_gpio_init(FT_LOOPBACK_KEY_PIN, PIN_DIRECTION_IN, PIN_PULL_PU, PIN_LEVEL_HIGH);
|
||||
if (wait_loopback_key_level(PIN_LEVEL_HIGH, 5000) != 0 ||
|
||||
wait_loopback_key_level(PIN_LEVEL_LOW, wait_timeout_ms) != 0) {
|
||||
return -4;
|
||||
}
|
||||
|
||||
buffer = (unsigned char *)malloc(FT_AUDIO_BUFFER_BYTES);
|
||||
if (!buffer) return -1;
|
||||
if (!buffer) {
|
||||
ret = -1;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
record_handle = ql_audio_record_open(FT_AUDIO_RATE_HZ, FT_AUDIO_CHANNELS,
|
||||
0, factory_record_callback);
|
||||
if (!record_handle) goto cleanup;
|
||||
|
||||
while (ql_get_pwrkey_status() && total < FT_AUDIO_BUFFER_BYTES) {
|
||||
while (total < FT_AUDIO_BUFFER_BYTES) {
|
||||
PIN_LEVEL_E level = PIN_LEVEL_HIGH;
|
||||
ql_gpio_get_level(FT_LOOPBACK_KEY_PIN, &level);
|
||||
if (level != PIN_LEVEL_LOW) break;
|
||||
request = FT_PCM_CHUNK_BYTES;
|
||||
if (request > FT_AUDIO_BUFFER_BYTES - total) {
|
||||
request = FT_AUDIO_BUFFER_BYTES - total;
|
||||
@@ -215,10 +229,21 @@ int factory_audio_wait_and_loopback(u32 wait_timeout_ms, u32 *recorded_ms)
|
||||
ql_audio_record_close(record_handle, 0);
|
||||
record_handle = 0;
|
||||
|
||||
if (total == FT_AUDIO_BUFFER_BYTES && ql_get_pwrkey_status()) {
|
||||
if (wait_pwrkey_level(0, 30000) != 0) goto cleanup;
|
||||
} else if (wait_pwrkey_level(0, 3000) != 0) {
|
||||
goto cleanup;
|
||||
{
|
||||
PIN_LEVEL_E level = PIN_LEVEL_HIGH;
|
||||
ql_gpio_get_level(FT_LOOPBACK_KEY_PIN, &level);
|
||||
if (total == FT_AUDIO_BUFFER_BYTES && level == PIN_LEVEL_LOW) {
|
||||
release_wait_start = ft_now_ms();
|
||||
while (1) {
|
||||
ql_gpio_get_level(FT_LOOPBACK_KEY_PIN, &level);
|
||||
if (level != PIN_LEVEL_LOW) break;
|
||||
if (ft_now_ms() - release_wait_start >= 30000) goto cleanup;
|
||||
ql_rtos_task_sleep_ms(10);
|
||||
}
|
||||
ql_rtos_task_sleep_ms(40);
|
||||
} else if (wait_loopback_key_level(PIN_LEVEL_HIGH, 3000) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
if (total < FT_PCM_CHUNK_BYTES) {
|
||||
@@ -233,8 +258,9 @@ int factory_audio_wait_and_loopback(u32 wait_timeout_ms, u32 *recorded_ms)
|
||||
es8311_write(0x32, ES8311_VOLUME_100);
|
||||
ql_set_volume(AUDIOHAL_SPK_VOL_11);
|
||||
factory_pa_control(1);
|
||||
/* Match the SDK PCM path and feed the asynchronous track in real time. */
|
||||
track_handle = ql_audio_track_open(FT_AUDIO_RATE_HZ, FT_AUDIO_CHANNELS,
|
||||
0, factory_track_callback);
|
||||
0x100, factory_track_callback);
|
||||
if (!track_handle) {
|
||||
ret = -3;
|
||||
goto cleanup;
|
||||
@@ -242,11 +268,16 @@ int factory_audio_wait_and_loopback(u32 wait_timeout_ms, u32 *recorded_ms)
|
||||
|
||||
for (offset = 0; offset < total; offset += FT_PCM_CHUNK_BYTES) {
|
||||
unsigned int size = total - offset;
|
||||
unsigned int retries = 0;
|
||||
if (size > FT_PCM_CHUNK_BYTES) size = FT_PCM_CHUNK_BYTES;
|
||||
if (ql_audio_track_write(track_handle, (char *)(buffer + offset), size) != 0) {
|
||||
ret = -3;
|
||||
goto cleanup;
|
||||
while (ql_audio_track_write(track_handle, (char *)(buffer + offset), size) != 0) {
|
||||
if (++retries > FT_PCM_WRITE_RETRIES) {
|
||||
ret = -3;
|
||||
goto cleanup;
|
||||
}
|
||||
ql_rtos_task_sleep_ms(5);
|
||||
}
|
||||
ql_rtos_task_sleep_ms(FT_PCM_CHUNK_MS);
|
||||
}
|
||||
ql_rtos_task_sleep_ms(total * 1000U /
|
||||
(FT_AUDIO_RATE_HZ * 2U * FT_AUDIO_CHANNELS) + 300U);
|
||||
|
||||
@@ -32,8 +32,8 @@ u32 ft_now_ms(void)
|
||||
const char *ft_node_name(ft_node_t node)
|
||||
{
|
||||
static const char *names[FT_NODE_COUNT] = {
|
||||
"BOOT_AUDIO", "POWER_ADC", "AUDIO_LOOPBACK", "KEY_TRIPLE",
|
||||
"ESIM", "DATA_CALL", "PING", "NFC"
|
||||
"BOOT_AUDIO", "POWER_ADC", "AUDIO_LOOPBACK",
|
||||
"ESIM", "DATA_CALL", "NFC"
|
||||
};
|
||||
|
||||
if (node < 0 || node >= FT_NODE_COUNT) {
|
||||
@@ -131,11 +131,11 @@ void ft_protocol_session_start(void)
|
||||
ft_emit("{\"v\":%d,\"type\":\"session_start\",\"seq\":%lu,"
|
||||
"\"session\":\"%s\",\"uptime_ms\":%lu,\"imei\":\"%s\","
|
||||
"\"iccid\":\"%s\",\"nfc_uid\":\"%s\",\"fw\":\"%s\","
|
||||
"\"ping_host\":\"%s\",\"start_time\":%s%s%s}",
|
||||
"\"start_time\":%s%s%s}",
|
||||
FT_PROTOCOL_VERSION, (unsigned long)++g_protocol.seq,
|
||||
g_protocol.session, (unsigned long)ft_now_ms(), g_protocol.imei,
|
||||
g_protocol.iccid, g_protocol.nfc_uid, FT_FIRMWARE_VERSION,
|
||||
FT_PING_HOST, g_protocol.start_time[0] ? "\"" : "null",
|
||||
g_protocol.start_time[0] ? "\"" : "null",
|
||||
g_protocol.start_time, g_protocol.start_time[0] ? "\"" : "");
|
||||
}
|
||||
|
||||
@@ -191,9 +191,8 @@ void ft_protocol_session_result(int passed, ft_error_t code)
|
||||
"\"nfc_uid\":\"%s\",\"start_time\":%s%s%s,"
|
||||
"\"duration_ms\":%lu,\"nodes\":{"
|
||||
"\"BOOT_AUDIO\":\"%s\",\"POWER_ADC\":\"%s\","
|
||||
"\"AUDIO_LOOPBACK\":\"%s\",\"KEY_TRIPLE\":\"%s\","
|
||||
"\"ESIM\":\"%s\",\"DATA_CALL\":\"%s\","
|
||||
"\"PING\":\"%s\",\"NFC\":\"%s\"}}",
|
||||
"\"AUDIO_LOOPBACK\":\"%s\",\"ESIM\":\"%s\","
|
||||
"\"DATA_CALL\":\"%s\",\"NFC\":\"%s\"}}",
|
||||
FT_PROTOCOL_VERSION, (unsigned long)++g_protocol.seq,
|
||||
g_protocol.session, (unsigned long)ft_now_ms(), passed ? "PASS" : "FAIL",
|
||||
code, g_protocol.imei, g_protocol.iccid, g_protocol.nfc_uid,
|
||||
@@ -202,8 +201,7 @@ void ft_protocol_session_result(int passed, ft_error_t code)
|
||||
(unsigned long)(ft_now_ms() - g_protocol.start_ms),
|
||||
ft_state_name(g_protocol.nodes[0]), ft_state_name(g_protocol.nodes[1]),
|
||||
ft_state_name(g_protocol.nodes[2]), ft_state_name(g_protocol.nodes[3]),
|
||||
ft_state_name(g_protocol.nodes[4]), ft_state_name(g_protocol.nodes[5]),
|
||||
ft_state_name(g_protocol.nodes[6]), ft_state_name(g_protocol.nodes[7]));
|
||||
ft_state_name(g_protocol.nodes[4]), ft_state_name(g_protocol.nodes[5]));
|
||||
}
|
||||
|
||||
void ft_protocol_power_off(void)
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
#include "ql_gpio.h"
|
||||
#include "ql_iic.h"
|
||||
#include "ql_nw.h"
|
||||
#include "ql_ping.h"
|
||||
#include "ql_power.h"
|
||||
#include "ql_rtos.h"
|
||||
#include "ql_sim.h"
|
||||
@@ -21,10 +20,6 @@
|
||||
#include "nfc.h"
|
||||
#include "reg.h"
|
||||
|
||||
#define FT_PING_COUNT 3
|
||||
#define FT_PING_REQUIRED 2
|
||||
#define FT_PING_TIMEOUT_MS 4000
|
||||
#define FT_PING_WAIT_TICKS (20000 / 5)
|
||||
#define FT_NFC_VERSION_EXPECTED 0x82
|
||||
|
||||
int g_testmode = 1;
|
||||
@@ -35,22 +30,14 @@ extern s8 Read_NTAG213(u8 *uid_buf, u8 *data_buf, u16 *data_len);
|
||||
void handle_nfc_bind(const char *uid) { (void)uid; }
|
||||
void handle_nfc_trigger(const char *uid) { (void)uid; }
|
||||
|
||||
static ql_sem_t g_ping_sem;
|
||||
static ql_ping_stat_t g_ping_stats;
|
||||
static volatile int g_ping_finished;
|
||||
static char g_ip_address[64];
|
||||
|
||||
static int iccid_valid(const char *iccid)
|
||||
{
|
||||
unsigned int i;
|
||||
unsigned int len;
|
||||
if (!iccid) return 0;
|
||||
len = strlen(iccid);
|
||||
if (len < 18 || len > 22) return 0;
|
||||
for (i = 0; i < len; ++i) {
|
||||
if (iccid[i] < '0' || iccid[i] > '9') return 0;
|
||||
}
|
||||
return 1;
|
||||
return len >= 18 && len <= 22;
|
||||
}
|
||||
|
||||
static ft_error_t run_boot_audio(char *data, unsigned int data_size)
|
||||
@@ -111,43 +98,6 @@ static ft_error_t run_audio_loopback(char *data, unsigned int data_size)
|
||||
return FT_OK;
|
||||
}
|
||||
|
||||
static int wait_gpio_level(unsigned int pin, PIN_LEVEL_E expected, u32 timeout_ms)
|
||||
{
|
||||
PIN_LEVEL_E level = PIN_LEVEL_HIGH;
|
||||
u32 start = ft_now_ms();
|
||||
do {
|
||||
ql_gpio_get_level(pin, &level);
|
||||
if (level == expected) {
|
||||
ql_rtos_task_sleep_ms(40);
|
||||
ql_gpio_get_level(pin, &level);
|
||||
if (level == expected) return 0;
|
||||
}
|
||||
ql_rtos_task_sleep_ms(10);
|
||||
} while (ft_now_ms() - start < timeout_ms);
|
||||
return -1;
|
||||
}
|
||||
|
||||
static ft_error_t run_key_triple(char *data, unsigned int data_size)
|
||||
{
|
||||
int count = 0;
|
||||
u32 start = ft_now_ms();
|
||||
ql_gpio_init(FT_TRIPLE_KEY_PIN, PIN_DIRECTION_IN, PIN_PULL_PU, PIN_LEVEL_HIGH);
|
||||
|
||||
if (wait_gpio_level(FT_TRIPLE_KEY_PIN, PIN_LEVEL_HIGH, 5000) != 0) {
|
||||
return FT_ERR_TRIPLE_KEY_TIMEOUT;
|
||||
}
|
||||
while (count < 3 && ft_now_ms() - start < FT_TRIPLE_KEY_TIMEOUT_MS) {
|
||||
u32 remaining = FT_TRIPLE_KEY_TIMEOUT_MS - (ft_now_ms() - start);
|
||||
if (wait_gpio_level(FT_TRIPLE_KEY_PIN, PIN_LEVEL_LOW, remaining) != 0) break;
|
||||
++count;
|
||||
ft_protocol_node_progress(FT_NODE_KEY_TRIPLE, count, 3);
|
||||
remaining = FT_TRIPLE_KEY_TIMEOUT_MS - (ft_now_ms() - start);
|
||||
if (wait_gpio_level(FT_TRIPLE_KEY_PIN, PIN_LEVEL_HIGH, remaining) != 0) break;
|
||||
}
|
||||
snprintf(data, data_size, "{\"press_count\":%d,\"required\":3}", count);
|
||||
return count == 3 ? FT_OK : FT_ERR_TRIPLE_KEY_TIMEOUT;
|
||||
}
|
||||
|
||||
static ft_error_t run_esim(char *data, unsigned int data_size)
|
||||
{
|
||||
int status = QL_SIM_STATUS_UNKNOWN;
|
||||
@@ -207,54 +157,6 @@ static ft_error_t run_data_call(char *data, unsigned int data_size)
|
||||
return FT_OK;
|
||||
}
|
||||
|
||||
static void factory_ping_callback(ql_ping_state_e state, int err,
|
||||
ql_ping_stat_t *stats)
|
||||
{
|
||||
(void)err;
|
||||
if (stats) memcpy(&g_ping_stats, stats, sizeof(g_ping_stats));
|
||||
if (state == QL_PING_FINISHED) {
|
||||
g_ping_finished = 1;
|
||||
if (g_ping_sem) ql_rtos_semaphore_release(g_ping_sem);
|
||||
}
|
||||
}
|
||||
|
||||
static ft_error_t run_ping(char *data, unsigned int data_size)
|
||||
{
|
||||
ql_ping_ctx_t *ctx;
|
||||
ql_ping_cfg_t cfg = {
|
||||
.pdpCid = 1,
|
||||
.host = FT_PING_HOST,
|
||||
.timeout = FT_PING_TIMEOUT_MS,
|
||||
.dataSize = 32,
|
||||
.pingNum = FT_PING_COUNT
|
||||
};
|
||||
int ret;
|
||||
|
||||
memset(&g_ping_stats, 0, sizeof(g_ping_stats));
|
||||
g_ping_finished = 0;
|
||||
if (ql_rtos_semaphore_create(&g_ping_sem, 0) != 0) return FT_ERR_PING_INIT;
|
||||
ctx = ql_ping_init(cfg, factory_ping_callback);
|
||||
if (!ctx) {
|
||||
ql_rtos_semaphore_delete(g_ping_sem);
|
||||
g_ping_sem = NULL;
|
||||
return FT_ERR_PING_INIT;
|
||||
}
|
||||
ret = ql_ping(ctx);
|
||||
if (ret == 0) ret = ql_rtos_semaphore_wait(g_ping_sem, FT_PING_WAIT_TICKS);
|
||||
ql_ping_deinit(ctx);
|
||||
ql_rtos_semaphore_delete(g_ping_sem);
|
||||
g_ping_sem = NULL;
|
||||
|
||||
snprintf(data, data_size,
|
||||
"{\"host\":\"%s\",\"sent\":%u,\"received\":%u,"
|
||||
"\"lost\":%u,\"avg_rtt_ms\":%lu}",
|
||||
FT_PING_HOST, g_ping_stats.sentNum, g_ping_stats.rcvdNum,
|
||||
g_ping_stats.lostNum, (unsigned long)g_ping_stats.avgRtt);
|
||||
if (ret != 0 || !g_ping_finished) return FT_ERR_PING_START;
|
||||
if (g_ping_stats.rcvdNum < FT_PING_REQUIRED) return FT_ERR_PING_LOSS;
|
||||
return FT_OK;
|
||||
}
|
||||
|
||||
static ft_error_t run_nfc(char *data, unsigned int data_size)
|
||||
{
|
||||
u8 uid[7] = {0};
|
||||
@@ -292,10 +194,8 @@ static ft_error_t run_node(ft_node_t node, char *data, unsigned int data_size)
|
||||
case FT_NODE_BOOT_AUDIO: return run_boot_audio(data, data_size);
|
||||
case FT_NODE_POWER_ADC: return run_power_adc(data, data_size);
|
||||
case FT_NODE_AUDIO_LOOPBACK: return run_audio_loopback(data, data_size);
|
||||
case FT_NODE_KEY_TRIPLE: return run_key_triple(data, data_size);
|
||||
case FT_NODE_ESIM: return run_esim(data, data_size);
|
||||
case FT_NODE_DATA_CALL: return run_data_call(data, data_size);
|
||||
case FT_NODE_PING: return run_ping(data, data_size);
|
||||
case FT_NODE_NFC: return run_nfc(data, data_size);
|
||||
default: return FT_ERR_NFC_LOCKED;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
#define FT_PROTOCOL_VERSION 1
|
||||
#define FT_FIRMWARE_VERSION "FACTORY_TEST_1.0.0"
|
||||
#define FT_PING_HOST "banban.api.talkingq.com"
|
||||
|
||||
#define FT_ADC_MIN_MV 3500
|
||||
#define FT_ADC_MAX_MV 4200
|
||||
@@ -18,11 +17,10 @@
|
||||
#define FT_AUDIO_BUFFER_BYTES (FT_AUDIO_RATE_HZ * 2 * FT_AUDIO_MAX_MS / 1000)
|
||||
|
||||
#define FT_DIALOG_KEY_TIMEOUT_MS 60000
|
||||
#define FT_TRIPLE_KEY_TIMEOUT_MS 60000
|
||||
#define FT_SIM_TIMEOUT_MS 30000
|
||||
#define FT_NFC_TIMEOUT_MS 60000
|
||||
|
||||
#define FT_TRIPLE_KEY_PIN GPIO_PIN_NO_84
|
||||
#define FT_LOOPBACK_KEY_PIN GPIO_PIN_NO_84
|
||||
#define FT_PA_PIN GPIO_PIN_NO_68
|
||||
|
||||
#define FT_AUDIO_BOOT_PATH "U:/factory_boot_zh.mp3"
|
||||
@@ -33,10 +31,8 @@ typedef enum {
|
||||
FT_NODE_BOOT_AUDIO = 0,
|
||||
FT_NODE_POWER_ADC,
|
||||
FT_NODE_AUDIO_LOOPBACK,
|
||||
FT_NODE_KEY_TRIPLE,
|
||||
FT_NODE_ESIM,
|
||||
FT_NODE_DATA_CALL,
|
||||
FT_NODE_PING,
|
||||
FT_NODE_NFC,
|
||||
FT_NODE_COUNT
|
||||
} ft_node_t;
|
||||
@@ -53,16 +49,12 @@ typedef enum {
|
||||
FT_ERR_RECORD_EMPTY = 1202,
|
||||
FT_ERR_LOOPBACK_PLAY = 1203,
|
||||
FT_ERR_DIALOG_KEY_TIMEOUT = 1204,
|
||||
FT_ERR_TRIPLE_KEY_TIMEOUT = 1301,
|
||||
FT_ERR_SIM_NOT_READY = 1401,
|
||||
FT_ERR_ICCID_READ = 1402,
|
||||
FT_ERR_ICCID_FORMAT = 1403,
|
||||
FT_ERR_NETWORK_REGISTER = 1501,
|
||||
FT_ERR_PDP_ACTIVATE = 1502,
|
||||
FT_ERR_IP_INVALID = 1503,
|
||||
FT_ERR_PING_INIT = 1601,
|
||||
FT_ERR_PING_START = 1602,
|
||||
FT_ERR_PING_LOSS = 1603,
|
||||
FT_ERR_NFC_LOCKED = 1701,
|
||||
FT_ERR_NFC_CHIP = 1702,
|
||||
FT_ERR_NFC_TIMEOUT = 1703,
|
||||
|
||||
Reference in New Issue
Block a user