feat: add FT1 factory test host and protocol updates

This commit is contained in:
chenjintao
2026-09-23 21:53:27 +08:00
parent 99cdd2db8a
commit 04b3b761db
15 changed files with 1603 additions and 176 deletions
+13 -36
View File
@@ -25,13 +25,10 @@
-> ES8311 ID/初始化,设置所有播放音量为 100% -> ES8311 ID/初始化,设置所有播放音量为 100%
-> 播放开机提示音 -> 播放开机提示音
-> 电源 ADC -> 电源 ADC
-> 按住对话键录音,松开后回放,最多录 5 秒 -> 按住 GPIO84(原三击测试按键)录音,松开后回放,最多录 5 秒
-> 工人判断音质 -> 工人判断音质
正常:按另一个按键三次
异常:不再操作,60 秒后 KEY_TRIPLE 失败
-> eSIM READY 和 ICCID -> eSIM READY 和 ICCID
-> 4G 注册、PDP 激活、获取 IPv4 -> 4G 注册、PDP 激活、获取 IPv4
-> Ping 业务域名
-> 前面全部通过后启用 NFC 测试 -> 前面全部通过后启用 NFC 测试
-> 读取 NTAG213 UID -> 读取 NTAG213 UID
-> 播放“全部测试通过” -> 播放“全部测试通过”
@@ -49,16 +46,13 @@
|---:|---|---|---|---:| |---:|---|---|---|---:|
| 0 | `BOOT_AUDIO` | 读取 ES8311 ID,初始化外置 Codec,播放 `U:/factory_boot_zh.mp3` | Codec ID 为 `0x83`,文件存在且播放结束 | 15 秒播放超时 | | 0 | `BOOT_AUDIO` | 读取 ES8311 ID,初始化外置 Codec,播放 `U:/factory_boot_zh.mp3` | Codec ID 为 `0x83`,文件存在且播放结束 | 15 秒播放超时 |
| 1 | `POWER_ADC` | 读取 6 次 VBAT,去掉最大值和最小值后平均 | `3500mV <= 平均值 <= 4200mV` | - | | 1 | `POWER_ADC` | 读取 6 次 VBAT,去掉最大值和最小值后平均 | `3500mV <= 平均值 <= 4200mV` | - |
| 2 | `AUDIO_LOOPBACK` | 按住电源/对话键录音,松开后按原音量回放 | 录音和回放 API 成功且至少有一帧 PCM | 等待按键 60 秒,录音最长 5 秒 | | 2 | `AUDIO_LOOPBACK` | 按住 GPIO84 录音,松开后按原音量回放 | 录音和回放 API 成功且至少有一帧 PCM | 等待按键 60 秒,录音最长 5 秒 |
| 3 | `KEY_TRIPLE` | GPIO84 按下并释放三次,每次输出进度 PASS | 60 秒内累计三次有效按键 | 60 秒 | | 3 | `ESIM` | 查询 SIM 状态并读取 ICCID | `READY`,ICCID 为 18-22 位十进制数字 | 30 秒 |
| 4 | `ESIM` | 查询 SIM 状态并读取 ICCID | `READY`,ICCID 为 18-22 位十进制数字 | 30 秒 | | 4 | `DATA_CALL` | 等待注册、激活 CID 1 IPv4 PDP | 返回非 `0.0.0.0` 的 IPv4 | 注册等待 120 秒 |
| 5 | `DATA_CALL` | 等待注册、激活 CID 1 IPv4 PDP | 返回非 `0.0.0.0` 的 IPv4 | 注册等待 120 秒 | | 5 | `NFC` | 检查 DP1312EA 版本并轮询 NTAG213 | 版本 `0x82`,成功读取 UID | 60 秒 |
| 6 | `PING` | Ping `banban.api.talkingq.com` 3 次 | 至少收到 2 次回复 | 单包 4 秒,总等待 20 秒 |
| 7 | `NFC` | 检查 DP1312EA 版本并轮询 NTAG213 | 版本 `0x82`,成功读取 UID | 60 秒 |
说明:`AUDIO_LOOPBACK` 的软件通过仅代表数据链路完成。最终声学判断由工人完成; 说明:`AUDIO_LOOPBACK` 使用 GPIO84 按键,软件通过仅代表录放数据链路完成。
如果声音异常,工人不按三击键,随后 `KEY_TRIPLE` 超时,整机产测失败。这避免了 最终声学判断由工人完成。
上位机额外的人工确认按钮。
## 4. 音频和文件系统接口 ## 4. 音频和文件系统接口
@@ -104,7 +98,6 @@ Windows 打包方法见 [factory_test_windows_fs.md](factory_test_windows_fs.md)
### 5.2 会话开始 ### 5.2 会话开始
```text ```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 无效不会导致产测失败;测试耗时始终以 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}} 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 三击按键进度 ### 5.5 NFC 和最终报告
每次完整按下并释放都输出一条 `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
```text ```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"}} 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 ```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 ```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、失败码、开始时间、总耗时和全部节点状态: 最终报告包含 IMEI、ICCID、NFC UID、失败码、开始时间、总耗时和全部节点状态:
```text ```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` 后应立即落盘本次记录: 上位机收到 `power_off` 后应立即落盘本次记录:
@@ -174,17 +156,13 @@ FT1 {"v":1,"type":"power_off","seq":20,"session":"20260923-163010","uptime_ms":4
| 1201 | 录音句柄或 160KB 缓冲区创建失败 | | 1201 | 录音句柄或 160KB 缓冲区创建失败 |
| 1202 | 没有录到一帧有效 PCM | | 1202 | 没有录到一帧有效 PCM |
| 1203 | PCM 回放失败 | | 1203 | PCM 回放失败 |
| 1204 | 60 秒内未按下对话键 | | 1204 | 60 秒内未按下 Loopback 按键 |
| 1301 | 音质异常未继续,或 60 秒内未完成三击 |
| 1401 | eSIM 未进入 READY | | 1401 | eSIM 未进入 READY |
| 1402 | ICCID 读取失败 | | 1402 | ICCID 读取失败 |
| 1403 | ICCID 长度或字符格式错误 | | 1403 | ICCID 长度或字符格式错误 |
| 1501 | 4G 网络注册超时 | | 1501 | 4G 网络注册超时 |
| 1502 | IPv4 PDP 激活失败 | | 1502 | IPv4 PDP 激活失败 |
| 1503 | 未获取有效 IPv4 地址 | | 1503 | 未获取有效 IPv4 地址 |
| 1601 | Ping 上下文或同步对象创建失败 |
| 1602 | Ping 启动或等待回调失败 |
| 1603 | 3 包中收到少于 2 包 |
| 1701 | 前置节点未全部通过,NFC 锁定 | | 1701 | 前置节点未全部通过,NFC 锁定 |
| 1702 | DP1312EA 版本寄存器不是 `0x82` | | 1702 | DP1312EA 版本寄存器不是 `0x82` |
| 1703 | 60 秒内没有成功刷卡 | | 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/factory_test.h`:阈值、超时、业务域名、GPIO 和文件路径;
- `interface/factory_test/Makefile`:独立产测编译单元。 - `interface/factory_test/Makefile`:独立产测编译单元。
修改 Ping 地址时只需修改 `FT_PING_HOST`。ADC 阈值、按键超时和 NFC 超时也集中在 ADC 阈值、按键超时和 NFC 超时集中在 `factory_test.h`。GPIO84 是第二个按键,GPIO68 是功放控制;产测代码不会把 GPIO68
`factory_test.h`。GPIO84 是第二个按键,GPIO68 是功放控制;产测代码不会把 GPIO68
当作 LED,也不会把 GPIO86 当作红色 LED,避免和 NFC 复位冲突。 当作 LED,也不会把 GPIO86 当作红色 LED,避免和 NFC 复位冲突。
## 8. 上板前检查 ## 8. 上板前检查
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
logs/
dist/
.pyinstaller-work/
.pyinstaller-spec/
+86
View File
@@ -0,0 +1,86 @@
# EG800 FT1 工厂烧录与产测上位机
本工具配套 `test/factory-test` 分支的独立产测固件,支持批量烧录、USB CDC
协议采集、6 个硬件节点判定和结果落盘。
## 主要功能
- 产测固件和正式固件分开保存,每个工位可独立选择。
- 烧录串口和产测 CDC 串口分开配置,避免把 DIAG/AT/CDC 当成同一个端口。
- 使用原厂 `adownload.exe -u -a`,先进入等待状态,再由关机设备上电触发烧录。
- 提供“烧录选中”单工位入口;USB 自动等待的批量任务按工位顺序执行,避免抢设备。
- 只解析以 `FT1 ` 开头的协议行;普通 SDK 日志只显示,不进入产测数据库。
- 严格校验协议版本、消息类型、节点编号/名称、会话 ID 和递增序号。
- 显示 `BOOT_AUDIO`、`POWER_ADC`、`AUDIO_LOOPBACK`、`ESIM`、
`DATA_CALL`、`NFC` 的实时状态与结构化数据。
- 收到 `session_result` 或 `power_off` 时自动保存完整 JSON 和汇总 CSV。
- 保存 IMEI、ICCID、NFC UID、失败码、固件版本、节点耗时和总耗时。
## 目录和启动
完整烧录工具位于:
```text
burn_tool/aboot-tools-2024.08.08-win-x64/adownload.exe
```
发布程序:
```text
factory_host/dist/EG800FactoryHost.exe
```
默认“产测固件”指向已包含三个产测 MP3 的 FT1 整包;默认“正式固件”指向
`version` 目录中的正式内层 `-fac.zip`。两者都可在外置
`factory_host_config.json` 中替换,烧录时不要选择 `-target.zip` 外层归档。
双击表格中的工位可以分别设置烧录口和产测 CDC 口。默认 USB 自动等待模式不需要
填写烧录口;产测 CDC 口仍需填写。“烧录选中”要求只选择一个工位;“批量烧录”
在未选中行时作用于全部工位,选中后只作用于选中工位。
单工位烧录时保持设备关机并连接 USB,选择工位后点击“烧录选中”。工位状态显示
“等待上电”后再给设备开机。此时 `adownload` 会捕获 AROM USB 下载口并开始烧录,
操作顺序与 Aboot 的 `usbOnly + usbAutoEnable` 默认设置一致。
串口和协议记录位于:
```text
factory_host/dist/logs/factory_results.csv
factory_host/dist/logs/sessions/*.json
```
## FT1 结果规则
产测结果完全采用固件的 `session_result`,上位机不会用模糊正则猜测硬件是否通过。
非零失败码会显示 `docs/factory_test.md` 中对应的中文含义。收到 `power_off` 后会再次
刷新同一 JSON 文件,但不会在 CSV 中重复增加记录。
`AUDIO_LOOPBACK=PASS` 表示 GPIO84(原三击测试按键)的录放数据链路成功。
Loopback 仍然是按住录音、松开回放,最长录音 5 秒。
## 产测音频包
重新生成音频包:
```powershell
python -B .\factory_host\build_factory_audio_package.py
```
脚本会执行以下检查:
1. 源目录必须且只能包含三个固定文件名。
2. 每个文件必须为 16 kHz、单声道、48 kbps MP3。
3. 使用 SDK `mklfs-v2.exe -m` 生成 262144 字节 `customer_fs.bin`。
4. 立即反向解包镜像并逐文件比对 SHA-256。
5. 输出 ZIP、清单及校验值到 `factory_host/dist/factory_audio/`。
不要直接覆盖正式业务 `customer_fs.bin`。需要构建产测整包时,按照
`docs/factory_test_windows_fs.md` 先备份业务镜像,再临时放入产测镜像。
## 开发验证
```powershell
python -B -m unittest discover -s factory_host -p "test_*.py" -v
powershell.exe -NoProfile -ExecutionPolicy Bypass -File factory_host\build_release.ps1
```
+200
View File
@@ -0,0 +1,200 @@
"""Validate and package the three fixed FT1 audio prompts."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import tempfile
import zipfile
from datetime import datetime, timezone
from pathlib import Path
EXPECTED_FILES = (
"factory_boot_zh.mp3",
"factory_pass_zh.mp3",
"factory_shutdown_zh.mp3",
)
IMAGE_SIZE = 262144
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest().upper()
def _skip_id3(data: bytes) -> int:
if not data.startswith(b"ID3") or len(data) < 10:
return 0
size = 0
for value in data[6:10]:
if value & 0x80:
raise ValueError("ID3 使用了无效的 synchsafe size")
size = (size << 7) | value
return 10 + size
def inspect_mp3(path: Path) -> dict:
data = path.read_bytes()
offset = _skip_id3(data)
while offset + 4 <= len(data) and not (data[offset] == 0xFF and data[offset + 1] & 0xE0 == 0xE0):
offset += 1
if offset + 4 > len(data):
raise ValueError(f"{path.name}: 找不到 MPEG 音频帧")
header = int.from_bytes(data[offset : offset + 4], "big")
version_bits = (header >> 19) & 0x3
layer_bits = (header >> 17) & 0x3
bitrate_index = (header >> 12) & 0xF
sample_index = (header >> 10) & 0x3
channel_mode = (header >> 6) & 0x3
if version_bits == 1 or layer_bits != 1 or bitrate_index in (0, 15) or sample_index == 3:
raise ValueError(f"{path.name}: MP3 帧头不受支持")
version = {3: "MPEG1", 2: "MPEG2", 0: "MPEG2.5"}[version_bits]
sample_rates = {
3: (44100, 48000, 32000),
2: (22050, 24000, 16000),
0: (11025, 12000, 8000),
}
mpeg1_bitrates = (0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320)
mpeg2_bitrates = (0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160)
bitrate = (mpeg1_bitrates if version_bits == 3 else mpeg2_bitrates)[bitrate_index]
sample_rate = sample_rates[version_bits][sample_index]
channels = 1 if channel_mode == 3 else 2
if (sample_rate, channels, bitrate) != (16000, 1, 48):
raise ValueError(
f"{path.name}: 需要 16000Hz/单声道/48kbps,实际为 {sample_rate}Hz/{channels}ch/{bitrate}kbps"
)
duration_ms = round(len(data) * 8 / (bitrate * 1000) * 1000)
return {
"name": path.name,
"bytes": len(data),
"sha256": sha256(path),
"mpeg": version,
"sample_rate_hz": sample_rate,
"channels": channels,
"bitrate_kbps": bitrate,
"estimated_duration_ms": duration_ms,
}
def build_package(root: Path, output: Path) -> tuple[Path, Path, dict]:
source = root / "audio_prompts" / "factory_test" / "customer_fs"
tool = root / "ql-cross-tool" / "win32" / "mklfs-v2.exe"
if not source.is_dir():
raise FileNotFoundError(f"音频目录不存在: {source}")
if not tool.is_file():
raise FileNotFoundError(f"mklfs-v2.exe 不存在: {tool}")
actual = sorted(path.name for path in source.iterdir() if path.is_file())
if actual != sorted(EXPECTED_FILES):
raise ValueError(f"音频目录必须且只能包含 {EXPECTED_FILES},实际为 {actual}")
output.mkdir(parents=True, exist_ok=True)
image = output / "customer_fs.bin"
if image.exists():
image.unlink()
audio = [inspect_mp3(source / name) for name in EXPECTED_FILES]
result = subprocess.run(
[str(tool), "-m", "-c", str(source), "-s", str(IMAGE_SIZE), "-i", str(image)],
cwd=str(root),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"mklfs-v2 失败 ({result.returncode}): {result.stdout}\n{result.stderr}")
if not image.is_file() or image.stat().st_size != IMAGE_SIZE:
raise RuntimeError(f"customer_fs.bin 大小错误: {image.stat().st_size if image.exists() else '不存在'}")
verified_files = []
with tempfile.TemporaryDirectory(prefix="verify-", dir=output) as verify_dir:
verify_path = Path(verify_dir)
verify = subprocess.run(
[str(tool), "-o", "-c", str(verify_path), "-s", str(IMAGE_SIZE), "-i", str(image)],
cwd=str(root),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if verify.returncode != 0:
raise RuntimeError(f"customer_fs 解包验证失败 ({verify.returncode}): {verify.stdout}\n{verify.stderr}")
extracted = sorted(path.name for path in verify_path.iterdir() if path.is_file())
if extracted != sorted(EXPECTED_FILES):
raise RuntimeError(f"customer_fs 文件清单错误: {extracted}")
for name in EXPECTED_FILES:
source_hash = sha256(source / name)
extracted_hash = sha256(verify_path / name)
if source_hash != extracted_hash:
raise RuntimeError(f"customer_fs 解包内容不一致: {name}")
verified_files.append({"name": name, "sha256": extracted_hash})
try:
revision = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=str(root), capture_output=True, text=True, check=True
).stdout.strip()
except (OSError, subprocess.SubprocessError):
revision = ""
manifest = {
"format": "banban-factory-audio-v1",
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"git_revision": revision,
"filesystem": {
"name": "customer_fs.bin",
"bytes": image.stat().st_size,
"sha256": sha256(image),
"extract_verified": True,
"verified_files": verified_files,
},
"mount_paths": [f"U:/{name}" for name in EXPECTED_FILES],
"audio": audio,
}
manifest_path = output / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8-sig")
readme_path = output / "README.txt"
readme_path.write_text(
"班班 FT1 产测音频包\n\n"
"customer_fs.bin 固定为 262144 字节,用于 EG800AKCN_11LG 的 customer_fs 分区。\n"
"三个 MP3 必须位于 U:/ 根目录;不要将此镜像混入正式业务固件。\n"
"烧录后以 FT1 BOOT_AUDIO 和最终 session_result 作为设备侧验证。\n",
encoding="utf-8-sig",
)
package = output / "EG800AKCN_11LG-FT1-factory-audio.zip"
if package.exists():
package.unlink()
with zipfile.ZipFile(package, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
archive.write(image, "customer_fs.bin")
archive.write(manifest_path, "manifest.json")
archive.write(readme_path, "README.txt")
for name in EXPECTED_FILES:
archive.write(source / name, f"customer_fs/{name}")
manifest["package"] = {"name": package.name, "bytes": package.stat().st_size, "sha256": sha256(package)}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8-sig")
return image, package, manifest
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent)
parser.add_argument("--output", type=Path, default=Path(__file__).resolve().parent / "dist" / "factory_audio")
args = parser.parse_args()
image, package, manifest = build_package(args.root.resolve(), args.output.resolve())
print(f"customer_fs={image}")
print(f"package={package}")
print(f"customer_fs_sha256={manifest['filesystem']['sha256']}")
print(f"package_sha256={manifest['package']['sha256']}")
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
param(
[string]$Output = "dist\factory_firmware"
)
$ErrorActionPreference = "Stop"
$toolRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectRoot = Split-Path -Parent $toolRoot
$audioImage = Join-Path $toolRoot "dist\factory_audio\customer_fs.bin"
$configuredImage = Join-Path $projectRoot "ql-config\config\EG800AKCN_11LG\customer_fs.bin"
$flashPackage = Join-Path $projectRoot "target\EG800AKCN_11LG\EG800AKCN_11LG.zip"
$outDir = Join-Path $toolRoot $Output
$backup = Join-Path $outDir "customer_fs.business-backup.bin"
$export = Join-Path $outDir "EG800AKCN_11LG-FT1-factory-test.zip"
if (-not (Test-Path -LiteralPath $audioImage)) {
throw "Factory audio image is missing. Run build_factory_audio_package.py first."
}
if ((Get-Item -LiteralPath $audioImage).Length -ne 262144) {
throw "Factory customer_fs.bin is not 262144 bytes."
}
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
Copy-Item -LiteralPath $configuredImage -Destination $backup -Force
$originalHash = (Get-FileHash -LiteralPath $configuredImage -Algorithm SHA256).Hash
$audioHash = (Get-FileHash -LiteralPath $audioImage -Algorithm SHA256).Hash
try {
Copy-Item -LiteralPath $audioImage -Destination $configuredImage -Force
if ((Get-FileHash -LiteralPath $configuredImage -Algorithm SHA256).Hash -ne $audioHash) {
throw "Temporary customer_fs.bin hash verification failed."
}
& cmd.exe /d /c "build.bat app"
if ($LASTEXITCODE -ne 0) { throw "build.bat app failed with exit code $LASTEXITCODE" }
& cmd.exe /d /c "build.bat firmware"
if ($LASTEXITCODE -ne 0) { throw "build.bat firmware failed with exit code $LASTEXITCODE" }
if (-not (Test-Path -LiteralPath $flashPackage)) {
throw "Flashable inner package was not generated: $flashPackage"
}
Copy-Item -LiteralPath $flashPackage -Destination $export -Force
$exportHash = (Get-FileHash -LiteralPath $export -Algorithm SHA256).Hash
@{
package = (Split-Path -Leaf $export)
bytes = (Get-Item -LiteralPath $export).Length
sha256 = $exportHash
customer_fs_sha256 = $audioHash
git_revision = (& git -C $projectRoot rev-parse HEAD).Trim()
generated_at = (Get-Date).ToString("o")
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $outDir "firmware_manifest.json") -Encoding UTF8
Write-Host "Factory firmware: $export"
Write-Host "SHA256: $exportHash"
}
finally {
Copy-Item -LiteralPath $backup -Destination $configuredImage -Force
$restoredHash = (Get-FileHash -LiteralPath $configuredImage -Algorithm SHA256).Hash
if ($restoredHash -ne $originalHash) {
throw "Business customer_fs.bin restore verification failed. Backup: $backup"
}
Write-Host "Business customer_fs.bin restored: $restoredHash"
}
+21
View File
@@ -0,0 +1,21 @@
param(
[string]$Output = "dist"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
python -c "import PyInstaller" 2>$null
if ($LASTEXITCODE -ne 0) {
throw "未找到 PyInstaller。先执行: python -m pip install pyinstaller pyserial"
}
$outDir = Join-Path $root $Output
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
python -m PyInstaller --noconfirm --clean --onefile --windowed `
--name EG800FactoryHost `
--distpath $outDir `
--workpath (Join-Path $root ".pyinstaller-work") `
--specpath (Join-Path $root ".pyinstaller-spec") `
(Join-Path $root "factory_host.py")
Copy-Item (Join-Path $root "factory_host_config.json") $outDir -Force
Write-Host "输出: $(Join-Path $outDir 'EG800FactoryHost.exe')"
+779
View File
@@ -0,0 +1,779 @@
# -*- coding: utf-8 -*-
"""EG800 factory host: batch flashing, serial production tests, and reports.
The aboot command line is intentionally configuration-driven. A firmware
package must be selected explicitly and the configured argument list may use
{aboot}, {firmware}, {port}, and {station} placeholders.
"""
from __future__ import annotations
import csv
import json
import os
import queue
import re
import shlex
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from tkinter import END, BOTH, LEFT, RIGHT, X, Y, StringVar, Tk, Toplevel, filedialog, messagebox
from tkinter import ttk
from ft1_protocol import FT1Session, NODE_NAMES, ProtocolError, error_text, parse_ft1_line
APP_DIR = Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else Path(__file__).resolve().parent
def discover_project_root(start: Path) -> Path:
"""Find the checkout root from either the source or the dist directory."""
for candidate in (start, *start.parents):
if (candidate / "burn_tool").exists() and ((candidate / "version").exists() or (candidate / "target").exists()):
return candidate
return start.parent
ROOT_DIR = discover_project_root(APP_DIR)
CONFIG_PATH = APP_DIR / "factory_host_config.json"
LOG_DIR = APP_DIR / "logs"
SESSION_DIR = LOG_DIR / "sessions"
DEFAULT_CONFIG = {
"flash": {
"aboot_path": "burn_tool/aboot.exe",
"downloader_path": "burn_tool/adownload.exe",
"args": ["-u", "-a", "-s", "{baudrate}", "-q", "{firmware}"],
"timeout_seconds": 900,
"workers": 2,
},
"firmwares": {"production": "", "official": ""},
"serial": {"baudrate": 115200, "encoding": "utf-8", "line_ending": "\\r\\n"},
"protocol": {"prefix": "FT1 ", "version": 1, "auto_save": True},
}
def load_config(path: Path = CONFIG_PATH) -> dict:
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2), encoding="utf-8-sig")
return json.loads(json.dumps(DEFAULT_CONFIG))
try:
raw = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"配置文件读取失败: {exc}") from exc
merged = json.loads(json.dumps(DEFAULT_CONFIG))
merge_dict(merged, raw)
return merged
def merge_dict(dst: dict, src: dict) -> None:
for key, value in src.items():
if isinstance(value, dict) and isinstance(dst.get(key), dict):
merge_dict(dst[key], value)
else:
dst[key] = value
def save_config(config: dict, path: Path = CONFIG_PATH) -> None:
path.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig")
def resolve_project_path(value: str) -> Path:
raw = Path(value)
if raw.is_absolute():
return raw
candidates = [ROOT_DIR / raw, APP_DIR / raw, Path.cwd() / raw]
for candidate in candidates:
if candidate.exists():
return candidate.resolve()
return candidates[0].resolve()
def find_downloader(configured: str, aboot_configured: str = "") -> Path | None:
candidates = []
if configured:
raw = Path(configured)
candidates.extend([raw, ROOT_DIR / raw, APP_DIR / raw])
if aboot_configured:
raw = Path(aboot_configured)
candidates.extend([raw.parent / "adownload.exe", ROOT_DIR / raw.parent / "adownload.exe", APP_DIR / raw.parent / "adownload.exe"])
candidates.extend([ROOT_DIR / "burn_tool" / "adownload.exe", APP_DIR / "burn_tool" / "adownload.exe"])
for candidate in candidates:
try:
if candidate.is_file():
return candidate.resolve()
except OSError:
continue
for root in (ROOT_DIR / "burn_tool", APP_DIR / "burn_tool"):
try:
if root.is_dir():
found = next(root.rglob("adownload.exe"), None)
if found and found.is_file():
return found.resolve()
except OSError:
continue
return None
def expand_args(args: list[str], *, aboot: Path, firmware: Path, port: str, station: str, baudrate: int = 115200) -> list[str]:
values = {"aboot": str(aboot), "firmware": str(firmware), "port": port, "station": station, "baudrate": str(baudrate)}
return [str(arg).format_map(values) for arg in args]
def parse_command_template(template: str) -> list[str]:
"""Parse a user-entered Windows command line while retaining quoted paths."""
if not template.strip():
return []
return [part.strip('"') for part in shlex.split(template, posix=False)]
def list_serial_ports() -> list[str]:
"""Return present COM ports without requiring a third-party module."""
ports = []
try:
import serial.tools.list_ports # type: ignore
ports = [item.device for item in serial.tools.list_ports.comports()]
except ImportError:
pass
if not ports and os.name == "nt":
try:
result = subprocess.run(
["powershell.exe", "-NoProfile", "-Command", "Get-CimInstance Win32_SerialPort | Select-Object -ExpandProperty DeviceID"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
ports = [line.strip() for line in result.stdout.splitlines() if re.fullmatch(r"COM\d+", line.strip(), re.I)]
except (OSError, subprocess.SubprocessError):
pass
return sorted(set(ports), key=lambda item: int(item[3:]) if item[3:].isdigit() else item)
class SerialReader:
"""Small Windows serial reader using pyserial when available, file handles otherwise."""
def __init__(self, port: str, baudrate: int, encoding: str, on_line):
self.port = port
self.baudrate = baudrate
self.encoding = encoding or "utf-8"
self.on_line = on_line
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._stream = None
self._pyserial = None
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, name=f"serial-{self.port}", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
try:
if self._stream is not None:
self._stream.close()
except OSError:
pass
if self._thread and self._thread.is_alive():
self._thread.join(timeout=1.0)
def send(self, text: str, line_ending: str = "\r\n") -> None:
payload = (text + line_ending).encode(self.encoding, errors="replace")
if self._pyserial is not None:
self._pyserial.write(payload)
elif self._stream is not None:
self._stream.write(payload)
def _run(self) -> None:
try:
try:
import serial # type: ignore
self._pyserial = serial.Serial(self.port, self.baudrate, timeout=0.25)
self._stream = self._pyserial
except ImportError:
if os.name == "nt":
subprocess.run(["mode", self.port, f"BAUD={self.baudrate}", "PARITY=n", "DATA=8", "STOP=1"], capture_output=True, check=False)
device = self.port if self.port.startswith("\\\\.\\") else "\\\\.\\" + self.port
self._stream = open(device, "r+b", buffering=0)
buffer = bytearray()
while not self._stop.is_set():
chunk = self._stream.read(256)
if not chunk:
continue
buffer.extend(chunk)
while b"\n" in buffer:
raw, _, remainder = buffer.partition(b"\n")
buffer = bytearray(remainder)
self.on_line(raw.rstrip(b"\r").decode(self.encoding, errors="replace"))
if buffer:
self.on_line(buffer.decode(self.encoding, errors="replace"))
except Exception as exc: # serial backends surface OS-specific errors
self.on_line(f"[SERIAL_ERROR] {exc}")
finally:
try:
if self._stream is not None:
self._stream.close()
except OSError:
pass
self._stream = None
self._pyserial = None
@dataclass
class Station:
number: str
flash_port: str = ""
test_port: str = ""
status: str = "待机"
firmware_kind: str = "production"
message: str = ""
reader: SerialReader | None = None
protocol: FT1Session = field(default_factory=FT1Session)
report_path: Path | None = None
report_saved: bool = False
class FactoryHost(Tk):
def __init__(self):
super().__init__()
self.title("EG800 工厂烧录与产测上位机")
self.geometry("1440x850")
self.minsize(1100, 680)
self.config_data = load_config()
self.stations: dict[str, Station] = {}
self.events: queue.Queue = queue.Queue()
self.executor: ThreadPoolExecutor | None = None
self.flash_cancel = threading.Event()
self.session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
LOG_DIR.mkdir(parents=True, exist_ok=True)
SESSION_DIR.mkdir(parents=True, exist_ok=True)
self.report_path = LOG_DIR / f"factory_{self.session_id}.csv"
self._build_ui()
self._load_default_rows()
self.protocol("WM_DELETE_WINDOW", self._on_close)
self.after(100, self._drain_events)
def _build_ui(self) -> None:
top = ttk.Frame(self, padding=8)
top.pack(fill=X)
ttk.Label(top, text="固件:").pack(side=LEFT)
self.firmware_kind = StringVar(value="production")
ttk.Radiobutton(top, text="产测固件", variable=self.firmware_kind, value="production").pack(side=LEFT, padx=3)
ttk.Radiobutton(top, text="正式固件", variable=self.firmware_kind, value="official").pack(side=LEFT, padx=3)
self.firmware_label = StringVar()
ttk.Label(top, textvariable=self.firmware_label, width=55).pack(side=LEFT, padx=12)
ttk.Button(top, text="选择固件", command=self.choose_firmware).pack(side=LEFT)
ttk.Button(top, text="扫描串口", command=self.scan_ports).pack(side=LEFT, padx=4)
status = ttk.Frame(self, padding=(8, 0, 8, 6))
status.pack(fill=X)
self.tool_status = StringVar()
ttk.Label(status, textvariable=self.tool_status).pack(side=LEFT)
ttk.Label(status, text=f"配置: {CONFIG_PATH}").pack(side=RIGHT)
controls = ttk.Frame(self, padding=(8, 0, 8, 6))
controls.pack(fill=X)
ttk.Button(controls, text="添加工位", command=self.add_station).pack(side=LEFT)
ttk.Button(controls, text="删除选中", command=self.remove_station).pack(side=LEFT, padx=4)
ttk.Button(controls, text="烧录选中", command=self.start_selected_flash).pack(side=LEFT, padx=(16, 4))
ttk.Button(controls, text="批量烧录", command=self.start_flash).pack(side=LEFT)
ttk.Button(controls, text="停止烧录", command=self.stop_flash).pack(side=LEFT)
ttk.Button(controls, text="开始串口产测", command=self.start_tests).pack(side=LEFT, padx=16)
ttk.Button(controls, text="停止串口", command=self.stop_serial).pack(side=LEFT)
ttk.Button(controls, text="打开配置", command=self.open_config).pack(side=LEFT, padx=16)
ttk.Button(controls, text="重新加载", command=self.reload_config).pack(side=LEFT)
ttk.Button(controls, text="导出报表", command=self.export_report).pack(side=RIGHT)
table_frame = ttk.Frame(self, padding=(8, 0, 8, 4))
table_frame.pack(fill=BOTH, expand=True)
columns = ("station", "flash_port", "test_port", "status", "session", "imei", "progress", "firmware", "message")
self.table = ttk.Treeview(table_frame, columns=columns, show="headings", selectmode="extended")
headings = {
"station": "工位", "flash_port": "烧录口", "test_port": "产测口", "status": "状态",
"session": "会话", "imei": "IMEI", "progress": "节点", "firmware": "固件", "message": "最近信息",
}
widths = {
"station": 55, "flash_port": 75, "test_port": 75, "status": 105, "session": 145,
"imei": 125, "progress": 70, "firmware": 80, "message": 440,
}
for column in columns:
self.table.heading(column, text=headings[column])
self.table.column(column, width=widths[column], anchor="w")
self.table.pack(side=LEFT, fill=BOTH, expand=True)
scroll = ttk.Scrollbar(table_frame, orient="vertical", command=self.table.yview)
scroll.pack(side=RIGHT, fill=Y)
hscroll = ttk.Scrollbar(table_frame, orient="horizontal", command=self.table.xview)
hscroll.pack(side="bottom", fill=X)
self.table.configure(yscrollcommand=scroll.set, xscrollcommand=hscroll.set)
self.table.bind("<Double-1>", self.edit_station)
self.table.bind("<<TreeviewSelect>>", self._refresh_node_table)
node_frame = ttk.LabelFrame(self, text="FT1 节点状态", padding=6)
node_frame.pack(fill=X, padx=8, pady=(0, 4))
node_columns = ("id", "name", "state", "code", "elapsed", "data")
self.node_table = ttk.Treeview(node_frame, columns=node_columns, show="headings", height=8)
for column, label, width in (
("id", "ID", 40), ("name", "节点", 150), ("state", "状态", 80),
("code", "代码", 60), ("elapsed", "耗时(ms)", 85), ("data", "数据", 700),
):
self.node_table.heading(column, text=label)
self.node_table.column(column, width=width, anchor="w")
self.node_table.pack(fill=X)
bottom = ttk.LabelFrame(self, text="实时日志", padding=6)
bottom.pack(fill=BOTH, expand=False, padx=8, pady=(0, 8))
self.log_text = ttk.Treeview(bottom, columns=("time", "station", "text"), show="headings", height=8)
for column, label, width in (("time", "时间", 90), ("station", "工位", 70), ("text", "日志", 850)):
self.log_text.heading(column, text=label)
self.log_text.column(column, width=width, anchor="w")
self.log_text.pack(fill=BOTH, expand=True)
self._refresh_tool_status()
def _load_default_rows(self) -> None:
ports = list_serial_ports()
for index, port in enumerate(ports, 1):
self.add_station(number=str(index), flash_port=port, test_port=port)
if not ports:
self.add_station(number="1")
def _refresh_tool_status(self) -> None:
downloader = find_downloader(self.config_data["flash"].get("downloader_path", ""), self.config_data["flash"].get("aboot_path", ""))
firmware = resolve_project_path(self.config_data["firmwares"].get(self.firmware_kind.get(), ""))
tool_text = f"adownload: {downloader}" if downloader else "adownload: 未找到(请把 burn_tool 完整目录放在根目录或在配置中指定)"
package_text = f"固件: {firmware}" if firmware.is_file() else "固件: 未选择"
self.tool_status.set(tool_text + " | " + package_text)
self.firmware_label.set(str(firmware) if firmware.is_file() else "未选择当前固件")
def choose_firmware(self) -> None:
path = filedialog.askopenfilename(title="选择固件包", filetypes=(("固件包", "*.zip *.bin *.img *.json"), ("所有文件", "*.*")))
if path:
self.config_data["firmwares"][self.firmware_kind.get()] = path
save_config(self.config_data)
self._refresh_tool_status()
def scan_ports(self) -> None:
ports = list_serial_ports()
self._log("系统", f"检测到串口: {', '.join(ports) if ports else '无'}")
for station in self.stations.values():
if not station.flash_port and ports:
station.flash_port = ports.pop(0)
if not station.test_port:
station.test_port = station.flash_port
self._update_row(station)
def open_config(self) -> None:
try:
if os.name == "nt":
os.startfile(str(CONFIG_PATH)) # type: ignore[attr-defined]
else:
subprocess.Popen(["xdg-open", str(CONFIG_PATH)])
except OSError as exc:
messagebox.showerror("打开配置失败", str(exc))
def reload_config(self) -> None:
try:
self.config_data = load_config()
self._refresh_tool_status()
self._log("系统", "配置已重新加载")
except RuntimeError as exc:
messagebox.showerror("配置错误", str(exc))
def add_station(self, number: str | None = None, flash_port: str = "", test_port: str = "") -> None:
if number is None:
number = str(len(self.stations) + 1)
while number in self.stations:
number = str(int(number) + 1) if number.isdigit() else f"{number}_new"
station = Station(number=number, flash_port=flash_port, test_port=test_port, firmware_kind=self.firmware_kind.get())
self.stations[number] = station
self.table.insert("", END, iid=number, values=(number, flash_port, test_port, station.status, "", "", "0/6", "产测固件", ""))
def remove_station(self) -> None:
for iid in self.table.selection():
station = self.stations.pop(iid, None)
if station and station.reader:
station.reader.stop()
self.table.delete(iid)
def edit_station(self, _event=None) -> None:
selected = self.table.selection()
if not selected:
return
station = self.stations[selected[0]]
dialog = Toplevel(self)
dialog.title(f"编辑工位 {station.number}")
dialog.transient(self)
ttk.Label(dialog, text="烧录串口(自动 USB 模式可空):").grid(row=0, column=0, padx=8, pady=8)
flash_port_var = StringVar(value=station.flash_port)
ttk.Entry(dialog, textvariable=flash_port_var, width=20).grid(row=0, column=1, padx=8, pady=8)
ttk.Label(dialog, text="产测 CDC 串口:").grid(row=1, column=0, padx=8, pady=8)
test_port_var = StringVar(value=station.test_port)
ttk.Entry(dialog, textvariable=test_port_var, width=20).grid(row=1, column=1, padx=8, pady=8)
ttk.Label(dialog, text="固件:").grid(row=2, column=0, padx=8, pady=8)
kind_var = StringVar(value=station.firmware_kind)
ttk.Combobox(dialog, textvariable=kind_var, values=("production", "official"), state="readonly", width=17).grid(row=2, column=1, padx=8, pady=8)
def apply():
station.flash_port = flash_port_var.get().strip().upper()
station.test_port = test_port_var.get().strip().upper()
station.firmware_kind = kind_var.get()
self._update_row(station)
dialog.destroy()
ttk.Button(dialog, text="确定", command=apply).grid(row=3, column=0, columnspan=2, pady=8)
def _update_row(self, station: Station) -> None:
if self.table.exists(station.number):
protocol = station.protocol
self.table.item(station.number, values=(
station.number, station.flash_port, station.test_port, station.status,
protocol.session, protocol.imei, f"{protocol.passed_count}/6",
"产测固件" if station.firmware_kind == "production" else "正式固件", station.message,
))
def _refresh_node_table(self, _event=None) -> None:
selected = self.table.selection()
for iid in self.node_table.get_children():
self.node_table.delete(iid)
if not selected or selected[0] not in self.stations:
return
protocol = self.stations[selected[0]].protocol
for node_id, name in enumerate(NODE_NAMES):
detail = protocol.node_details.get(name, {})
data = json.dumps(detail.get("data", {}), ensure_ascii=False, separators=(",", ":"))
self.node_table.insert("", END, values=(
node_id, name, protocol.nodes[name], detail.get("code", ""),
detail.get("elapsed_ms", ""), data if data != "{}" else "",
))
def start_selected_flash(self) -> None:
selected = self.table.selection()
if len(selected) != 1:
messagebox.showwarning("请选择工位", "请先只选中一个工位,再点击“烧录选中”。")
return
self._start_flash_jobs(selected)
def start_flash(self) -> None:
selected = self.table.selection() or tuple(self.stations.keys())
self._start_flash_jobs(selected)
def _start_flash_jobs(self, selected) -> None:
downloader = find_downloader(self.config_data["flash"].get("downloader_path", ""), self.config_data["flash"].get("aboot_path", ""))
if not downloader:
messagebox.showerror("无法烧录", "未找到 adownload.exe。请把 burn_tool 完整目录放到根目录,或在配置中指定 flash.downloader_path。aboot.exe 是图形界面,不能作为命令行烧录进程。")
return
jobs = [
self.stations[iid]
for iid in selected
if iid in self.stations
and self.stations[iid].status not in ("排队中", "等待上电", "烧录中")
]
if not jobs:
self._log("系统", "没有可提交的工位(正在烧录的工位不会重复排队)")
return
configured_args = self.config_data["flash"].get("args", ["{firmware}"])
if isinstance(configured_args, str):
configured_args = parse_command_template(configured_args)
auto_usb = any(str(arg) in ("-a", "--auto-enable") for arg in configured_args)
uses_named_port = any("{port}" in str(arg) for arg in configured_args)
self.flash_cancel.clear()
workers = max(1, int(self.config_data["flash"].get("workers", 2)))
if auto_usb and not uses_named_port and len(jobs) > 1:
workers = 1
self._log("系统", "USB 自动等待模式将按工位顺序烧录;请只给当前等待工位上电")
self.executor = ThreadPoolExecutor(max_workers=min(workers, len(jobs)))
for station in jobs:
station.status = "排队中"
station.message = ""
self._update_row(station)
self.executor.submit(self._flash_one, station, downloader)
self._log("系统", f"已提交 {len(jobs)} 个烧录任务,并发数 {workers}")
def stop_flash(self) -> None:
self.flash_cancel.set()
self._log("系统", "已请求停止后续烧录任务;正在执行的 adownload 将在超时或退出后结束")
def _flash_one(self, station: Station, downloader: Path) -> None:
if self.flash_cancel.is_set():
self.events.put(("station", station.number, "已取消", ""))
return
firmware = resolve_project_path(self.config_data["firmwares"].get(station.firmware_kind, ""))
if not firmware.is_file():
self.events.put(("station", station.number, "失败", f"固件不存在: {firmware}"))
return
configured_args = self.config_data["flash"].get("args", ["{firmware}"])
if isinstance(configured_args, str):
configured_args = parse_command_template(configured_args)
baudrate = int(self.config_data["serial"].get("baudrate", 115200))
if not station.flash_port and any("{port}" in str(item) for item in configured_args):
self.events.put(("station", station.number, "失败", "未设置烧录串口"))
return
args = expand_args(configured_args, aboot=downloader, firmware=firmware, port=station.flash_port, station=station.number, baudrate=baudrate)
command = [str(downloader)] + args
auto_usb = any(str(arg) in ("-a", "--auto-enable") for arg in configured_args)
status = "等待上电" if auto_usb else "烧录中"
hint = "下载器已启动,请现在给设备上电" if auto_usb else ""
self.events.put(("station", station.number, status, hint or " ".join(shlex.quote(x) for x in command)))
self.events.put(("log", station.number, "命令: " + " ".join(shlex.quote(x) for x in command)))
try:
process = subprocess.Popen(command, cwd=str(downloader.parent), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1)
output_tail = []
deadline = time.monotonic() + int(self.config_data["flash"].get("timeout_seconds", 900))
assert process.stdout is not None
lines: queue.Queue[str | None] = queue.Queue()
def read_output() -> None:
try:
for raw_line in process.stdout:
lines.put(raw_line.rstrip())
finally:
lines.put(None)
threading.Thread(target=read_output, name=f"flash-output-{station.number}", daemon=True).start()
reader_done = False
timed_out = False
while not reader_done or process.poll() is None:
try:
line = lines.get(timeout=0.1)
if line is None:
reader_done = True
elif line:
output_tail.append(line)
output_tail = output_tail[-3:]
self.events.put(("log", station.number, line))
except queue.Empty:
pass
if self.flash_cancel.is_set() or time.monotonic() > deadline:
timed_out = not self.flash_cancel.is_set()
process.terminate()
break
try:
return_code = process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
return_code = process.wait(timeout=5)
if self.flash_cancel.is_set():
result, message = "已取消", "用户停止"
elif timed_out:
result, message = "失败", "等待上电或烧录超时"
elif return_code == 0:
result, message = "烧录成功", " | ".join(output_tail[-2:])
else:
result, message = "失败", f"退出码 {return_code}: {' | '.join(output_tail[-2:])}"
except (OSError, subprocess.SubprocessError) as exc:
result, message = "失败", str(exc)
self.events.put(("station", station.number, result, message))
def start_tests(self) -> None:
selected = self.table.selection() or tuple(self.stations.keys())
for iid in selected:
if iid not in self.stations:
continue
station = self.stations[iid]
if not station.test_port:
station.status, station.message = "失败", "未设置产测 CDC 串口"
self._update_row(station)
continue
station.protocol.reset()
station.report_path = None
station.report_saved = False
station.status = "等待设备"
station.message = "等待 FT1 session_start"
if station.reader:
station.reader.stop()
station.reader = SerialReader(
station.test_port,
int(self.config_data["serial"].get("baudrate", 115200)),
self.config_data["serial"].get("encoding", "utf-8"),
lambda line, s=station: self._serial_line(s, line),
)
station.reader.start()
self._update_row(station)
self._refresh_node_table()
self._log("系统", "FT1 串口产测已启动;只保存以 FT1 开头的协议行")
def _serial_line(self, station: Station, line: str) -> None:
self.events.put(("serial", station.number, line))
def _process_serial_line(self, station: Station, line: str) -> None:
if line.startswith("[SERIAL_ERROR]"):
station.status, station.message = "失败", line
self._log(station.number, line)
self._update_row(station)
return
try:
message = parse_ft1_line(line)
except ProtocolError as exc:
station.status = "协议错误"
station.message = str(exc)
self._log(station.number, f"[FT1错误] {exc}: {line}")
self._update_row(station)
return
if message is None:
self._log(station.number, f"[SDK] {line}")
return
previous_session = station.protocol.session
try:
description = station.protocol.apply(message)
except ProtocolError as exc:
station.status = "协议错误"
station.message = str(exc)
self._log(station.number, f"[FT1错误] {exc}")
self._update_row(station)
return
if message["type"] == "session_start" and station.protocol.session != previous_session:
station.report_path = None
station.report_saved = False
msg_type = message["type"]
if msg_type == "session_start":
station.status = "产测中"
elif msg_type == "node_start":
station.status = f"测试 {message['name']}"
elif msg_type == "node_result" and message["result"] == "FAIL":
station.status = "产测失败"
elif msg_type == "session_result":
station.status = "产测通过" if message["result"] == "PASS" else "产测失败"
elif msg_type == "power_off" and station.protocol.result == "PASS":
station.status = "通过/已关机"
station.message = description
self._log(station.number, f"[FT1] {description}")
self._update_row(station)
self._refresh_node_table()
if self.config_data.get("protocol", {}).get("auto_save", True) and msg_type in {"session_result", "power_off"}:
self._save_station_session(station, msg_type)
def _save_station_session(self, station: Station, trigger: str) -> Path | None:
protocol = station.protocol
if not protocol.messages:
return None
safe_session = re.sub(r"[^A-Za-z0-9_.-]+", "_", protocol.session or "UNKNOWN")
if station.report_path is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
station.report_path = SESSION_DIR / f"{timestamp}_station-{station.number}_{safe_session}.json"
record = {
"saved_at": datetime.now().isoformat(timespec="seconds"),
"save_trigger": trigger,
"station": station.number,
"flash_port": station.flash_port,
"test_port": station.test_port,
"firmware_kind": station.firmware_kind,
"summary": protocol.summary(),
"messages": protocol.messages,
}
station.report_path.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8-sig")
if not station.report_saved:
csv_path = LOG_DIR / "factory_results.csv"
new_file = not csv_path.exists()
with csv_path.open("a", newline="", encoding="utf-8-sig") as handle:
writer = csv.writer(handle)
if new_file:
writer.writerow([
"保存时间", "工位", "产测串口", "会话", "结果", "失败码", "失败原因",
"固件", "IMEI", "ICCID", "NFC_UID", "开始时间", "耗时_ms", *NODE_NAMES,
])
summary = protocol.summary()
writer.writerow([
record["saved_at"], station.number, station.test_port, protocol.session,
summary["result"], protocol.fail_code, summary["fail_reason"], protocol.firmware,
protocol.imei, protocol.iccid, protocol.nfc_uid, protocol.start_time or "",
protocol.duration_ms, *(protocol.nodes[name] for name in NODE_NAMES),
])
station.report_saved = True
self._log(station.number, f"记录已保存: {station.report_path}")
return station.report_path
def stop_serial(self) -> None:
for station in self.stations.values():
if station.reader:
station.reader.stop()
station.reader = None
if station.protocol.messages and not station.report_saved:
self._save_station_session(station, "manual_stop")
if station.status in ("等待设备", "产测中") or station.status.startswith("测试 "):
station.status = "已停止"
self._update_row(station)
self._log("系统", "串口监听已停止")
def _drain_events(self) -> None:
try:
while True:
event = self.events.get_nowait()
kind = event[0]
if kind == "station":
_, number, status, message = event
if number in self.stations:
station = self.stations[number]
station.status, station.message = status, message
self._update_row(station)
self._log(number, f"{status}: {message}")
elif kind == "log":
_, number, line = event
self._log(number, line)
elif kind == "serial":
_, number, line = event
if number in self.stations:
self._process_serial_line(self.stations[number], line)
except queue.Empty:
pass
self.after(100, self._drain_events)
def _log(self, station: str, text: str) -> None:
timestamp = datetime.now().strftime("%H:%M:%S")
self.log_text.insert("", END, values=(timestamp, station, text))
children = self.log_text.get_children()
if len(children) > 1000:
self.log_text.delete(children[0])
def export_report(self) -> None:
path = filedialog.asksaveasfilename(title="导出产测报表", defaultextension=".csv", initialfile=f"factory_{self.session_id}.csv", filetypes=(("CSV", "*.csv"),))
if not path:
return
with open(path, "w", newline="", encoding="utf-8-sig") as handle:
writer = csv.writer(handle)
writer.writerow([
"时间", "工位", "烧录口", "产测口", "状态", "会话", "结果", "失败码", "失败原因",
"固件类型", "产测固件版本", "IMEI", "ICCID", "NFC_UID", "开始时间", "耗时_ms", *NODE_NAMES,
])
for station in self.stations.values():
protocol = station.protocol
summary = protocol.summary()
writer.writerow([
datetime.now().isoformat(timespec="seconds"), station.number, station.flash_port,
station.test_port, station.status, protocol.session, summary["result"],
protocol.fail_code, summary["fail_reason"], station.firmware_kind, protocol.firmware,
protocol.imei, protocol.iccid, protocol.nfc_uid, protocol.start_time or "",
protocol.duration_ms, *(protocol.nodes[name] for name in NODE_NAMES),
])
self._log("系统", f"报表已导出: {path}")
def _on_close(self) -> None:
self.stop_serial()
self.flash_cancel.set()
self.destroy()
def main() -> None:
try:
app = FactoryHost()
app.mainloop()
except RuntimeError as exc:
messagebox.showerror("启动失败", str(exc))
raise
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
{
"flash": {
"aboot_path": "burn_tool/aboot.exe",
"downloader_path": "burn_tool/aboot-tools-2024.08.08-win-x64/adownload.exe",
"args": [
"-u",
"-a",
"-s",
"{baudrate}",
"-q",
"{firmware}"
],
"timeout_seconds": 900,
"workers": 2
},
"firmwares": {
"production": "factory_host/dist/factory_firmware/EG800AKCN_11LG-FT1-factory-test.zip",
"official": "version/EG800AKCN_11LG-1.8.4-20260921171244/EG800AKCN_11LG-1.8.4-20260921171244-fac.zip"
},
"serial": {
"baudrate": 115200,
"encoding": "utf-8",
"line_ending": "\\r\\n"
},
"protocol": {
"prefix": "FT1 ",
"version": 1,
"auto_save": true
}
}
+246
View File
@@ -0,0 +1,246 @@
"""Parser and state model for the Banban FT1 factory-test protocol."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
PREFIX = "FT1 "
VERSION = 1
MESSAGE_TYPES = {
"session_start",
"node_start",
"node_progress",
"node_result",
"session_result",
"power_off",
}
NODE_NAMES = (
"BOOT_AUDIO",
"POWER_ADC",
"AUDIO_LOOPBACK",
"ESIM",
"DATA_CALL",
"NFC",
)
ERROR_MESSAGES = {
0: "成功",
1001: "ES8311 初始化或设备 ID 检查失败",
1002: "开机提示音文件不存在",
1003: "开机提示音播放失败或超时",
1101: "ADC 初始化或读取失败",
1102: "电压低于 3500mV",
1103: "电压高于 4200mV",
1201: "录音句柄或缓冲区创建失败",
1202: "没有录到有效 PCM",
1203: "PCM 回放失败",
1204: "60 秒内未按下 Loopback 按键",
1401: "eSIM 未进入 READY",
1402: "ICCID 读取失败",
1403: "ICCID 格式错误",
1501: "4G 网络注册超时",
1502: "IPv4 PDP 激活失败",
1503: "未获取有效 IPv4 地址",
1701: "前置节点未全部通过,NFC 锁定",
1702: "DP1312EA 版本不是 0x82",
1703: "60 秒内未成功刷卡",
1704: "NFC UID 无效",
1801: "最终提示音播放失败",
1802: "正常关机接口异常返回",
}
class ProtocolError(ValueError):
pass
def error_text(code: Any) -> str:
try:
number = int(code)
except (TypeError, ValueError):
return f"未知失败码 {code}"
return ERROR_MESSAGES.get(number, f"未知失败码 {number}")
def _require_int(message: dict, key: str, minimum: int = 0) -> int:
value = message.get(key)
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
raise ProtocolError(f"字段 {key} 必须是大于等于 {minimum} 的整数")
return value
def parse_ft1_line(line: str) -> dict | None:
"""Parse one serial line. Non-FT1 lines return None and are not records."""
line = line.strip("\r\n")
if not line.startswith(PREFIX):
return None
try:
message = json.loads(line[len(PREFIX) :])
except json.JSONDecodeError as exc:
raise ProtocolError(f"JSON 无效: {exc.msg}") from exc
if not isinstance(message, dict):
raise ProtocolError("协议载荷必须是 JSON 对象")
if message.get("v") != VERSION:
raise ProtocolError(f"不支持的协议版本: {message.get('v')}")
msg_type = message.get("type")
if msg_type not in MESSAGE_TYPES:
raise ProtocolError(f"不支持的消息类型: {msg_type}")
_require_int(message, "seq", 1)
_require_int(message, "uptime_ms", 0)
if not isinstance(message.get("session"), str) or not message["session"]:
raise ProtocolError("缺少 session")
if msg_type in {"node_start", "node_progress", "node_result"}:
node = _require_int(message, "node", 0)
if node >= len(NODE_NAMES):
raise ProtocolError(f"节点编号越界: {node}")
if message.get("name") != NODE_NAMES[node]:
raise ProtocolError(f"节点名称不匹配: {node}/{message.get('name')}")
if msg_type == "node_progress":
data = message.get("data")
if not isinstance(data, dict):
raise ProtocolError("node_progress 缺少 data")
current = _require_int(data, "current", 0)
required = _require_int(data, "required", 1)
if current > required:
raise ProtocolError("节点进度超过 required")
if msg_type == "node_result":
if message.get("result") not in {"PASS", "FAIL"}:
raise ProtocolError("node_result 的 result 必须为 PASS 或 FAIL")
_require_int(message, "code", 0)
_require_int(message, "elapsed_ms", 0)
if not isinstance(message.get("data"), dict):
raise ProtocolError("node_result 的 data 必须是对象")
if msg_type == "session_result":
if message.get("result") not in {"PASS", "FAIL"}:
raise ProtocolError("session_result 的 result 必须为 PASS 或 FAIL")
_require_int(message, "fail_code", 0)
_require_int(message, "duration_ms", 0)
nodes = message.get("nodes")
if not isinstance(nodes, dict):
raise ProtocolError("session_result 缺少 nodes")
missing = [name for name in NODE_NAMES if nodes.get(name) not in {"WAIT", "PASS", "FAIL"}]
if missing:
raise ProtocolError("节点汇总无效: " + ", ".join(missing))
if msg_type == "power_off":
_require_int(message, "code", 0)
return message
@dataclass
class FT1Session:
session: str = ""
last_seq: int = 0
firmware: str = ""
start_time: str | None = None
imei: str = ""
iccid: str = ""
nfc_uid: str = ""
result: str = ""
fail_code: int = 0
duration_ms: int = 0
current_node: str = ""
session_start_seen: bool = False
session_result_seen: bool = False
power_off_seen: bool = False
nodes: dict[str, str] = field(default_factory=lambda: {name: "WAIT" for name in NODE_NAMES})
node_details: dict[str, dict] = field(default_factory=dict)
messages: list[dict] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def reset(self, session: str = "") -> None:
fresh = type(self)(session=session)
self.__dict__.update(fresh.__dict__)
def apply(self, message: dict) -> str:
msg_type = message["type"]
incoming_session = message["session"]
if msg_type == "session_start" and incoming_session != self.session:
self.reset(incoming_session)
elif not self.session:
self.reset(incoming_session)
self.warnings.append("未收到 session_start,记录可能不完整")
elif incoming_session != self.session:
raise ProtocolError(f"会话突变: {self.session} -> {incoming_session}")
seq = message["seq"]
if seq <= self.last_seq:
raise ProtocolError(f"seq 未递增: {seq} <= {self.last_seq}")
if self.last_seq and seq != self.last_seq + 1:
self.warnings.append(f"消息序号缺口: {self.last_seq} -> {seq}")
self.last_seq = seq
self.messages.append(message)
for key in ("imei", "iccid", "nfc_uid"):
value = message.get(key)
if isinstance(value, str) and value:
setattr(self, key, value)
if msg_type == "session_start":
self.session_start_seen = True
self.firmware = str(message.get("fw", ""))
self.start_time = message.get("start_time")
return f"会话开始 {self.firmware or self.session}"
if msg_type == "node_start":
name = message["name"]
self.current_node = name
self.nodes[name] = "RUNNING"
return f"开始 {name}"
if msg_type == "node_progress":
name = message["name"]
data = message["data"]
self.current_node = name
self.nodes[name] = f"{data['current']}/{data['required']}"
self.node_details[name] = {"data": data}
return f"{name} {data['current']}/{data['required']}"
if msg_type == "node_result":
name = message["name"]
self.nodes[name] = message["result"]
self.node_details[name] = {
"code": message["code"],
"elapsed_ms": message["elapsed_ms"],
"data": message["data"],
}
return f"{name} {message['result']} ({message['code']})"
if msg_type == "session_result":
self.session_result_seen = True
self.result = message["result"]
self.fail_code = message["fail_code"]
self.duration_ms = message["duration_ms"]
self.start_time = message.get("start_time")
self.nodes.update(message["nodes"])
return f"整机 {self.result}: {error_text(self.fail_code)}"
self.power_off_seen = True
return "设备准备关机"
@property
def complete(self) -> bool:
return self.session_start_seen and self.session_result_seen
@property
def passed_count(self) -> int:
return sum(state == "PASS" for state in self.nodes.values())
def summary(self) -> dict:
return {
"protocol_version": VERSION,
"session": self.session,
"firmware": self.firmware,
"start_time": self.start_time,
"imei": self.imei,
"iccid": self.iccid,
"nfc_uid": self.nfc_uid,
"result": self.result or "INCOMPLETE",
"fail_code": self.fail_code,
"fail_reason": error_text(self.fail_code) if self.result else "会话未完成",
"duration_ms": self.duration_ms,
"nodes": dict(self.nodes),
"node_details": dict(self.node_details),
"session_start_seen": self.session_start_seen,
"session_result_seen": self.session_result_seen,
"power_off_seen": self.power_off_seen,
"protocol_complete": self.complete,
"last_seq": self.last_seq,
"warnings": list(self.warnings),
}
+3
View File
@@ -0,0 +1,3 @@
# Optional: improves COM enumeration and serial I/O. The application has a
# Windows file-handle fallback when pyserial is unavailable.
pyserial>=3.5
+98
View File
@@ -0,0 +1,98 @@
import json
import tempfile
import unittest
from pathlib import Path
import factory_host
from ft1_protocol import FT1Session, ProtocolError, parse_ft1_line
class FactoryHostTests(unittest.TestCase):
def test_expand_args(self):
args = factory_host.expand_args(
["--port", "{port}", "{firmware}", "--station", "{station}"],
aboot=Path(r"D:\burn_tool\aboot.exe"),
firmware=Path(r"D:\fw\prod.zip"),
port="COM14",
station="2",
baudrate=115200,
)
self.assertEqual(args[1], "COM14")
self.assertEqual(args[2], r"D:\fw\prod.zip")
self.assertEqual(args[-1], "2")
def test_merge_and_config_round_trip(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "config.json"
config = factory_host.load_config(path)
config["flash"]["workers"] = 4
factory_host.save_config(config, path)
loaded = factory_host.load_config(path)
self.assertEqual(loaded["flash"]["workers"], 4)
self.assertIn("protocol", loaded)
def test_parse_command_template(self):
self.assertEqual(factory_host.parse_command_template('"C:\\Program Files\\aboot.exe" --port COM14'), [r"C:\Program Files\aboot.exe", "--port", "COM14"])
def test_default_flash_waits_for_arom_usb(self):
args = factory_host.DEFAULT_CONFIG["flash"]["args"]
self.assertIn("-u", args)
self.assertIn("-a", args)
self.assertNotIn("{port}", args)
def test_project_root_and_relative_path(self):
self.assertTrue((factory_host.ROOT_DIR / "burn_tool").is_dir())
config = factory_host.load_config(factory_host.CONFIG_PATH)
self.assertEqual(
Path(config["firmwares"]["production"]).name,
"EG800AKCN_11LG-FT1-factory-test.zip",
)
self.assertTrue(
factory_host.resolve_project_path(config["firmwares"]["production"]).is_file()
)
self.assertTrue(
factory_host.resolve_project_path(config["firmwares"]["official"]).is_file()
)
def test_ft1_ignores_sdk_log_and_parses_node(self):
self.assertIsNone(parse_ft1_line("DP1312EA is OK!"))
message = parse_ft1_line(
'FT1 {"v":1,"type":"node_result","seq":3,"session":"BOOT-1",'
'"uptime_ms":2450,"imei":"860123456789012","iccid":"","nfc_uid":"",'
'"node":1,"name":"POWER_ADC","result":"PASS","code":0,"elapsed_ms":350,'
'"data":{"voltage_mv":3865}}'
)
self.assertEqual(message["data"]["voltage_mv"], 3865)
def test_ft1_session_result_and_sequence_guard(self):
session = FT1Session()
start = parse_ft1_line(
'FT1 {"v":1,"type":"session_start","seq":1,"session":"BOOT-1",'
'"uptime_ms":2050,"imei":"860123456789012","iccid":"","nfc_uid":"",'
'"fw":"FACTORY_TEST_1.0.0","start_time":null}'
)
session.apply(start)
result = parse_ft1_line(
'FT1 {"v":1,"type":"session_result","seq":2,"session":"BOOT-1",'
'"uptime_ms":40120,"result":"FAIL","fail_code":1203,"imei":"860123456789012",'
'"iccid":"","nfc_uid":"","start_time":null,"duration_ms":38070,"nodes":'
'{"BOOT_AUDIO":"PASS","POWER_ADC":"PASS","AUDIO_LOOPBACK":"FAIL",'
'"ESIM":"WAIT","DATA_CALL":"WAIT","NFC":"WAIT"}}'
)
session.apply(result)
self.assertEqual(session.result, "FAIL")
self.assertEqual(session.nodes["AUDIO_LOOPBACK"], "FAIL")
self.assertTrue(session.complete)
with self.assertRaises(ProtocolError):
session.apply(result)
def test_ft1_rejects_node_name_mismatch(self):
with self.assertRaises(ProtocolError):
parse_ft1_line(
'FT1 {"v":1,"type":"node_start","seq":2,"session":"BOOT-1",'
'"uptime_ms":2100,"node":1,"name":"NFC"}'
)
if __name__ == "__main__":
unittest.main()
@@ -5,7 +5,6 @@
#include "ql_audio.h" #include "ql_audio.h"
#include "ql_fs.h" #include "ql_fs.h"
#include "ql_gpio.h" #include "ql_gpio.h"
#include "ql_power.h"
#include "ql_rtos.h" #include "ql_rtos.h"
#include "factory_audio.h" #include "factory_audio.h"
@@ -16,7 +15,9 @@
#define ES8311_DEVICE_ID 0x83 #define ES8311_DEVICE_ID 0x83
#define ES8311_VOLUME_100 0x8E #define ES8311_VOLUME_100 0x8E
#define FT_PCM_CHUNK_BYTES 640 #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 void sw_i2c_init(void);
extern int8 sw_i2c_start(void); extern int8 sw_i2c_start(void);
@@ -157,7 +158,7 @@ int factory_audio_play_prompt(const char *path)
return -1; 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) { if (ret != 0 || g_mp3_result != 2) {
ql_stop_mp3_play(g_mp3_handle, 0); ql_stop_mp3_play(g_mp3_handle, 0);
factory_pa_control(0); factory_pa_control(0);
@@ -167,15 +168,20 @@ int factory_audio_play_prompt(const char *path)
return 0; 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(); u32 start = ft_now_ms();
while (ql_get_pwrkey_status() != expected) { do {
if (ft_now_ms() - start >= timeout_ms) return -1; 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(10);
} } while (ft_now_ms() - start < timeout_ms);
ql_rtos_task_sleep_ms(40); return -1;
return ql_get_pwrkey_status() == expected ? 0 : -1;
} }
int factory_audio_wait_and_loopback(u32 wait_timeout_ms, u32 *recorded_ms) 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_record_handle record_handle = 0;
audio_track_handle track_handle = 0; audio_track_handle track_handle = 0;
int ret = -1; int ret = -1;
u32 release_wait_start = 0;
if (recorded_ms) *recorded_ms = 0; if (recorded_ms) *recorded_ms = 0;
if (wait_pwrkey_level(0, 10000) != 0 || ql_gpio_init(FT_LOOPBACK_KEY_PIN, PIN_DIRECTION_IN, PIN_PULL_PU, PIN_LEVEL_HIGH);
wait_pwrkey_level(1, wait_timeout_ms) != 0) { if (wait_loopback_key_level(PIN_LEVEL_HIGH, 5000) != 0 ||
wait_loopback_key_level(PIN_LEVEL_LOW, wait_timeout_ms) != 0) {
return -4; return -4;
} }
buffer = (unsigned char *)malloc(FT_AUDIO_BUFFER_BYTES); 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, record_handle = ql_audio_record_open(FT_AUDIO_RATE_HZ, FT_AUDIO_CHANNELS,
0, factory_record_callback); 0, factory_record_callback);
if (!record_handle) goto cleanup; 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; request = FT_PCM_CHUNK_BYTES;
if (request > FT_AUDIO_BUFFER_BYTES - total) { if (request > FT_AUDIO_BUFFER_BYTES - total) {
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); ql_audio_record_close(record_handle, 0);
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; PIN_LEVEL_E level = PIN_LEVEL_HIGH;
} else if (wait_pwrkey_level(0, 3000) != 0) { ql_gpio_get_level(FT_LOOPBACK_KEY_PIN, &level);
goto cleanup; 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) { 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); es8311_write(0x32, ES8311_VOLUME_100);
ql_set_volume(AUDIOHAL_SPK_VOL_11); ql_set_volume(AUDIOHAL_SPK_VOL_11);
factory_pa_control(1); 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, track_handle = ql_audio_track_open(FT_AUDIO_RATE_HZ, FT_AUDIO_CHANNELS,
0, factory_track_callback); 0x100, factory_track_callback);
if (!track_handle) { if (!track_handle) {
ret = -3; ret = -3;
goto cleanup; 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) { for (offset = 0; offset < total; offset += FT_PCM_CHUNK_BYTES) {
unsigned int size = total - offset; unsigned int size = total - offset;
unsigned int retries = 0;
if (size > FT_PCM_CHUNK_BYTES) size = FT_PCM_CHUNK_BYTES; if (size > FT_PCM_CHUNK_BYTES) size = FT_PCM_CHUNK_BYTES;
if (ql_audio_track_write(track_handle, (char *)(buffer + offset), size) != 0) { while (ql_audio_track_write(track_handle, (char *)(buffer + offset), size) != 0) {
ret = -3; if (++retries > FT_PCM_WRITE_RETRIES) {
goto cleanup; 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 / ql_rtos_task_sleep_ms(total * 1000U /
(FT_AUDIO_RATE_HZ * 2U * FT_AUDIO_CHANNELS) + 300U); (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) const char *ft_node_name(ft_node_t node)
{ {
static const char *names[FT_NODE_COUNT] = { static const char *names[FT_NODE_COUNT] = {
"BOOT_AUDIO", "POWER_ADC", "AUDIO_LOOPBACK", "KEY_TRIPLE", "BOOT_AUDIO", "POWER_ADC", "AUDIO_LOOPBACK",
"ESIM", "DATA_CALL", "PING", "NFC" "ESIM", "DATA_CALL", "NFC"
}; };
if (node < 0 || node >= FT_NODE_COUNT) { 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," ft_emit("{\"v\":%d,\"type\":\"session_start\",\"seq\":%lu,"
"\"session\":\"%s\",\"uptime_ms\":%lu,\"imei\":\"%s\"," "\"session\":\"%s\",\"uptime_ms\":%lu,\"imei\":\"%s\","
"\"iccid\":\"%s\",\"nfc_uid\":\"%s\",\"fw\":\"%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, FT_PROTOCOL_VERSION, (unsigned long)++g_protocol.seq,
g_protocol.session, (unsigned long)ft_now_ms(), g_protocol.imei, g_protocol.session, (unsigned long)ft_now_ms(), g_protocol.imei,
g_protocol.iccid, g_protocol.nfc_uid, FT_FIRMWARE_VERSION, 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] ? "\"" : ""); 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," "\"nfc_uid\":\"%s\",\"start_time\":%s%s%s,"
"\"duration_ms\":%lu,\"nodes\":{" "\"duration_ms\":%lu,\"nodes\":{"
"\"BOOT_AUDIO\":\"%s\",\"POWER_ADC\":\"%s\"," "\"BOOT_AUDIO\":\"%s\",\"POWER_ADC\":\"%s\","
"\"AUDIO_LOOPBACK\":\"%s\",\"KEY_TRIPLE\":\"%s\"," "\"AUDIO_LOOPBACK\":\"%s\",\"ESIM\":\"%s\","
"\"ESIM\":\"%s\",\"DATA_CALL\":\"%s\"," "\"DATA_CALL\":\"%s\",\"NFC\":\"%s\"}}",
"\"PING\":\"%s\",\"NFC\":\"%s\"}}",
FT_PROTOCOL_VERSION, (unsigned long)++g_protocol.seq, FT_PROTOCOL_VERSION, (unsigned long)++g_protocol.seq,
g_protocol.session, (unsigned long)ft_now_ms(), passed ? "PASS" : "FAIL", g_protocol.session, (unsigned long)ft_now_ms(), passed ? "PASS" : "FAIL",
code, g_protocol.imei, g_protocol.iccid, g_protocol.nfc_uid, 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), (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[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[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[4]), ft_state_name(g_protocol.nodes[5]));
ft_state_name(g_protocol.nodes[6]), ft_state_name(g_protocol.nodes[7]));
} }
void ft_protocol_power_off(void) void ft_protocol_power_off(void)
@@ -8,7 +8,6 @@
#include "ql_gpio.h" #include "ql_gpio.h"
#include "ql_iic.h" #include "ql_iic.h"
#include "ql_nw.h" #include "ql_nw.h"
#include "ql_ping.h"
#include "ql_power.h" #include "ql_power.h"
#include "ql_rtos.h" #include "ql_rtos.h"
#include "ql_sim.h" #include "ql_sim.h"
@@ -21,10 +20,6 @@
#include "nfc.h" #include "nfc.h"
#include "reg.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 #define FT_NFC_VERSION_EXPECTED 0x82
int g_testmode = 1; 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_bind(const char *uid) { (void)uid; }
void handle_nfc_trigger(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 char g_ip_address[64];
static int iccid_valid(const char *iccid) static int iccid_valid(const char *iccid)
{ {
unsigned int i;
unsigned int len; unsigned int len;
if (!iccid) return 0; if (!iccid) return 0;
len = strlen(iccid); len = strlen(iccid);
if (len < 18 || len > 22) return 0; return len >= 18 && len <= 22;
for (i = 0; i < len; ++i) {
if (iccid[i] < '0' || iccid[i] > '9') return 0;
}
return 1;
} }
static ft_error_t run_boot_audio(char *data, unsigned int data_size) 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; 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) static ft_error_t run_esim(char *data, unsigned int data_size)
{ {
int status = QL_SIM_STATUS_UNKNOWN; 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; 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) static ft_error_t run_nfc(char *data, unsigned int data_size)
{ {
u8 uid[7] = {0}; 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_BOOT_AUDIO: return run_boot_audio(data, data_size);
case FT_NODE_POWER_ADC: return run_power_adc(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_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_ESIM: return run_esim(data, data_size);
case FT_NODE_DATA_CALL: return run_data_call(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); case FT_NODE_NFC: return run_nfc(data, data_size);
default: return FT_ERR_NFC_LOCKED; default: return FT_ERR_NFC_LOCKED;
} }
@@ -6,7 +6,6 @@
#define FT_PROTOCOL_VERSION 1 #define FT_PROTOCOL_VERSION 1
#define FT_FIRMWARE_VERSION "FACTORY_TEST_1.0.0" #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_MIN_MV 3500
#define FT_ADC_MAX_MV 4200 #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_AUDIO_BUFFER_BYTES (FT_AUDIO_RATE_HZ * 2 * FT_AUDIO_MAX_MS / 1000)
#define FT_DIALOG_KEY_TIMEOUT_MS 60000 #define FT_DIALOG_KEY_TIMEOUT_MS 60000
#define FT_TRIPLE_KEY_TIMEOUT_MS 60000
#define FT_SIM_TIMEOUT_MS 30000 #define FT_SIM_TIMEOUT_MS 30000
#define FT_NFC_TIMEOUT_MS 60000 #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_PA_PIN GPIO_PIN_NO_68
#define FT_AUDIO_BOOT_PATH "U:/factory_boot_zh.mp3" #define FT_AUDIO_BOOT_PATH "U:/factory_boot_zh.mp3"
@@ -33,10 +31,8 @@ typedef enum {
FT_NODE_BOOT_AUDIO = 0, FT_NODE_BOOT_AUDIO = 0,
FT_NODE_POWER_ADC, FT_NODE_POWER_ADC,
FT_NODE_AUDIO_LOOPBACK, FT_NODE_AUDIO_LOOPBACK,
FT_NODE_KEY_TRIPLE,
FT_NODE_ESIM, FT_NODE_ESIM,
FT_NODE_DATA_CALL, FT_NODE_DATA_CALL,
FT_NODE_PING,
FT_NODE_NFC, FT_NODE_NFC,
FT_NODE_COUNT FT_NODE_COUNT
} ft_node_t; } ft_node_t;
@@ -53,16 +49,12 @@ typedef enum {
FT_ERR_RECORD_EMPTY = 1202, FT_ERR_RECORD_EMPTY = 1202,
FT_ERR_LOOPBACK_PLAY = 1203, FT_ERR_LOOPBACK_PLAY = 1203,
FT_ERR_DIALOG_KEY_TIMEOUT = 1204, FT_ERR_DIALOG_KEY_TIMEOUT = 1204,
FT_ERR_TRIPLE_KEY_TIMEOUT = 1301,
FT_ERR_SIM_NOT_READY = 1401, FT_ERR_SIM_NOT_READY = 1401,
FT_ERR_ICCID_READ = 1402, FT_ERR_ICCID_READ = 1402,
FT_ERR_ICCID_FORMAT = 1403, FT_ERR_ICCID_FORMAT = 1403,
FT_ERR_NETWORK_REGISTER = 1501, FT_ERR_NETWORK_REGISTER = 1501,
FT_ERR_PDP_ACTIVATE = 1502, FT_ERR_PDP_ACTIVATE = 1502,
FT_ERR_IP_INVALID = 1503, 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_LOCKED = 1701,
FT_ERR_NFC_CHIP = 1702, FT_ERR_NFC_CHIP = 1702,
FT_ERR_NFC_TIMEOUT = 1703, FT_ERR_NFC_TIMEOUT = 1703,