42 changed files with 2059 additions and 302 deletions
+2
View File
@@ -1,6 +1,8 @@
# Firmware build and release output.
/target/
/version/
/output/
/.venv-customer-fs/
# Local legacy-board packages retained when switching to the new-board branch.
/artifacts/legacy-no-gnss/
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18 -1
View File
@@ -1,9 +1,12 @@
@echo off
set CURDIR=%~dp0
for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMddHHmmss"') do set appbuildtime=%%i
for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMddHHmmss"') do set appbuildtime=%%i
set appbuildrev=NULL
set appbuildmodel=EG800AKCN_11LG
set appbuildver=beta1
set otalayoutid=eg800akcn_11lg-hxgnss-fs576-app2e5000
set otabuildprofile=ota_ready_hxgnss_fs576
set pythonexe=ql-cross-tool\win32\owtoolchain\Python\python.exe
if /i not "%1" == "" (
set appbuildver=%1
@@ -18,14 +21,28 @@ echo #define CUSTOM_APP_BUILD_TIME "%appbuildtime%" >> ql-applicati
echo #define CUSTOM_APP_BUILD_REV "%appbuildrev%" >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h
echo #define CUSTOM_APP_BUILD_MODEL "%appbuildmodel%" >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h
echo #define CUSTOM_APP_BUILD_VER "%appbuildver%" >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h
echo #define CUSTOM_APP_OTA_LAYOUT_ID "%otalayoutid%" >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h
echo #define CUSTOM_APP_OTA_BUILD_PROFILE "%otabuildprofile%" >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h
echo #endif >> ql-application\threadx\interface\custom_app\lib\hal\build_macro.h
"%pythonexe%" tools\verify_ota_ready_baseline.py
if errorlevel 1 (
echo "OTA-ready baseline source verification failed"
exit /b 1
)
call build.bat project_name -p EG800AKCN_11LG -b NO_EXTFLASH -x XIP -c %appbuildmodel%
call build.bat clean
call build.bat app
call build.bat kernel
call build.bat firmware
"%pythonexe%" tools\verify_ota_ready_baseline.py --app-bin ql-application\threadx\build\app.bin --kernel-bin ql-kernel\threadx\build\release_kernel.bin
if errorlevel 1 (
echo "OTA-ready baseline artifact verification failed"
exit /b 1
)
if exist "target\%appbuildmodel%.zip" (
mkdir target\%appbuildmodel%\fota\
mkdir target\%appbuildmodel%\fota\a
+273
View File
@@ -0,0 +1,273 @@
# Banban OTA-ready 工厂基线
## 1. 目标和边界
本基线用于尚未量产的 `EG800AKCN_11LG` 设备。目标是在不改变现有对话、音频、
MQTT、NFC、GNSS、按键、休眠和 LED 业务流程的前提下,一次性固定后续本地暂存式
OTA 所依赖的底层条件。
本基线完成:
- 将 HX_GNSS 的 `customer_fs` 从 256 KiB 扩大到 576 KiB;
- 将 `customer_app` 从 460 KiB 调整为 384 KiB;
- 将 APP 物理/XIP 地址固定为 `0x002E5000` / `0x802E5000`;
- 保留 SDK 已有的 `QUEC_APP_FULL_FOTA` 与 FULLOTA2 公共 API;
- 将现有 OTA 命令切换为最小本地暂存式 FULLOTA2;
- 为设备和构建产物加入唯一布局身份;
- 在构建前后执行 fail-closed 校验。
本基线暂不完成:
- 不增加签名 manifest、SHA-256 下载校验、断点续传或后台灰度;
- 不提供 A/B APP 分区,也不承诺自动版本回滚;
- 不在线更新 kernel、分区表或 `customer_fs`。
这版保留现有后端的 `version + url` 命令格式,只改变设备端执行方式。网络包会完整写入
`U:/FotaFile.bin`,通过 SDK FBF 校验并设置升级标志后才重启。
> 生产门禁:完成本文的真机 FULLOTA2 正向、失败注入和断电测试前,后端不得向生产设备
> 下发 OTA 命令。验收通过后可继续使用原协议下发;这条门禁针对测试状态,不再是因为
> 设备仍调用 FULLOTA1。
## 2. 固定身份与 Flash 布局
设备布局身份:
```text
eg800akcn_11lg-hxgnss-fs576-app2e5000
```
构建 profile:
```text
ota_ready_hxgnss_fs576
```
4 MiB 内部 Flash 的尾部分区固定如下:
| 分区 | 起始地址 | 结束地址 | 大小 |
| --- | ---: | ---: | ---: |
| reserved(按当前镜像估算) | `0x002D0000` | `0x002E5000` | 84 KiB |
| customer_app | `0x002E5000` | `0x00345000` | 384 KiB |
| customer_fs | `0x00345000` | `0x003D5000` | 576 KiB |
| nvm | `0x003D5000` | `0x003F5000` | 128 KiB |
| erase_rd | `0x003F5000` | `0x003F7000` | 8 KiB |
| fota_param | `0x003F7000` | `0x003FA000` | 12 KiB |
| factory_a | `0x003FA000` | `0x00400000` | 24 KiB |
`customer_app` 是单槽。FULLOTA2 能保护下载阶段:文件未下载完整或 SDK FBF 校验
失败时不会设置升级标志;但 updater 开始擦写 `customer_app` 后,没有旧 APP 槽可供回滚。
## 3. 本次修改
### 3.1 分区和链接地址
- `CRANELRH_QUEC_FLASH_LAYOUT_SS_04M_HX_GNSS.json`:当前 `CC11XXW_GPS` 构建实际选择的
分区表,`customer_app = 384KiB`、`customer_fs = 576KiB`;
- `CRANELRH_QUEC_FLASH_LAYOUT_SS_04M_OPEN.json`:同步保持 `customer_fs = 640KiB`,避免
无 GNSS variant 回退到旧布局;
- `app_linkscript.ld`:ROM 起始地址改为 `0x802E5000`,最大 ROM 大小收紧为 `0x60000`;
- `customer_fs.bin`:从原 256 KiB LittleFS 中提取全部 14 个音频文件,以 4096 字节块、
144 个块重新生成 576 KiB LittleFS;
- 新文件系统镜像 SHA-256:
`45350804d65fc6ded24caf9b215b71e89f9160d10d4998c5d57d4f4496d6000d`。
### 3.2 运行身份
`ota_baseline.h` 固定布局身份、APP/FS 地址和分区大小。应用启动时打印:
```text
[OTA-BASELINE] layout=eg800akcn_11lg-hxgnss-fs576-app2e5000 ... slot=single
[OTA-BASELINE] U: total=... free=... fota2_reserve=65536
```
该日志用于产线和联调确认设备没有混入旧的 fs256 或错误的 fs640 OPEN 基线,不改变任何业务状态。
### 3.3 构建保护
`build_rel.bat` 在构建前运行源码校验,在生成 APP 后运行容量校验。任何一项不满足都会中止:
- HX_GNSS `customer_fs` 必须为 576 KiB;
- APP 必须为 384 KiB,地址必须为 `0x002E5000`;
- LittleFS 必须为 4096 字节块、144 块且镜像哈希一致;
- 当前 kernel、DSP、RF 和 GNSS 镜像到 APP 之间必须至少保留 64 KiB;
- kernel 必须继续定义 `QUEC_APP_FULL_FOTA`;
- FULLOTA2 所需的 SDK API 必须存在;
- `app.bin + 8192` 不得超过 384 KiB。
- MQTT OTA 入口必须调用 `banban_fullfota2_start()`,不得回退到
`ql_fullfota_app_start()`。
可独立执行:
```powershell
ql-cross-tool\win32\owtoolchain\Python\python.exe tools\verify_ota_ready_baseline.py
ql-cross-tool\win32\owtoolchain\Python\python.exe tools\verify_ota_ready_baseline.py `
--app-bin ql-application\threadx\build\app.bin `
--kernel-bin ql-kernel\threadx\build\release_kernel.bin
```
### 3.4 最小 FULLOTA2 执行路径
`ota_fullfota2.c` 使用独立任务完成同步 HTTP 下载。现有 MQTT 工作线程只负责参数校验、
保存目标版本、回复 `accepted` 和启动任务,不会承担长时间网络读取。
执行顺序:
1. 删除上一次残留的 `U:/FotaFile.bin`;
2. HTTP GET 必须返回 200 和非零 `Content-Length`;
3. FBF 必须大于 8 KiB 且不超过 384 KiB;
4. `U:` 空闲空间必须不小于 `Content-Length + 64 KiB`;
5. 设置内部 Flash 固定暂存路径,初始化 FOTA context;
6. 以 4096 字节缓冲顺序读取并调用 `ql_fota_image_write()`;
7. 实际接收字节数必须严格等于 `Content-Length`;
8. 调用 `ql_fota_image_flush()`,再用 `ql_stat()` 复核落盘文件长度;
9. 调用 `ql_fota_image_verify()` 校验 FBF 并设置升级标志;
10. deinit 后重启,由 updater 写入单槽 `customer_app`。
任一步在设置升级标志前失败,都会关闭 HTTP/FOTA context、删除暂存包、撤销固定升级模式、
清除 pending 状态并把 `fota_start` 恢复为 0。旧 APP 未被擦写,可继续运行并接受下一次命令。
旧实现会在 OTA 前删除服务器管理的提示音。fs576 基线有足够空间后,这版不再主动删除
业务资源;若空间不足则明确失败,不通过牺牲业务文件强行升级。
`http_aofei.c` 中未被当前业务调用的旧 HTTP 轮询 OTA 包装函数原本仍可进入 FULLOTA1,
本基线将其显式禁用。生产固件只有 MQTT `version + url` 命令可以启动 OTA,避免两个入口
使用不同的升级语义。
## 4. 构建和生产烧录
在 Windows SDK 构建环境中执行:
```bat
build_rel.bat <生产版本号>
```
量产必须烧录该构建产生的完整 `*-fac.zip`,不能只烧 `customer_app`。完整工厂包需要同时
携带新 `partition.bin`、新 `customer_fs.bin`、11LG kernel/system 和新地址 APP。
量产前冻结并记录:
- 源码 Git commit;
- 工厂 ZIP 文件名、字节数和 SHA-256;
- `customer_fs.bin` SHA-256;
- `customer_app.bin` 字节数和 SHA-256;
- `partition.bin` 字节数和 SHA-256;
- 编译器、Aboot/arelease 和 FBFMake 版本;
- 首台、末台抽检设备串口中的 OTA baseline 日志。
不要把 `EG800AKCN_91LC` 的 `cp.bin`、工厂包或射频/GNSS 制品复制给本项目。两个项目同为
ASR1605/CRANELRH,但量产包必须由 `EG800AKCN_11LG` 配置生成。
## 5. 生产前测试
### 5.1 静态和产物测试
1. 运行 `verify_ota_ready_baseline.py`,结果必须通过。
2. 解包工厂 ZIP,确认其中 `customer_fs.bin` 为 589824 字节且哈希一致。
3. 使用 Aboot 工具检查 `partition.bin`,确认 APP/FS 地址与本文件一致。
4. 确认 `customer_app.bin + 8192 <= 393216`。
5. 确认 FBF 的目标物理地址是 `0x002E5000`,不能是旧地址 `0x00322000`、
也不能是错误基线地址 `0x002C2000`。
### 5.2 全擦工厂烧录测试
至少准备 3 台板执行完整擦除和工厂烧录:
1. 下载器无越界、校验或 partition 错误。
2. USB 重新枚举,`AT`/`ATI` 正常。
3. 串口打印正确 layout、地址和 576 KiB 文件系统容量。
4. 14 个预置音频均可读取和播放。
5. IMEI、SIM、驻网、数据连接和 GNSS 正常。
6. MQTT 注册、命令收发、心跳和状态上报正常。
7. 唤醒、对话、打断、录音、播放、音量和休眠正常。
8. NFC、按键、LED、电池检测和低电关机正常。
9. 连续冷启动、软重启和断电启动各不少于 20 次。
10. 连续运行至少 8 小时,观察 watchdog、内存、文件系统和网络重连。
### 5.3 文件系统压力测试
1. 启动后记录 `ql_fs_size('U')` 和 `ql_fs_free_size('U')`。
2. 写入一个与计划 FBF 等大的测试文件,关闭、重启并逐字节回读。
3. 删除测试文件,确认空间恢复。
4. 在写入 10%、50%、90% 时分别断电,重启后确认业务 APP 仍可运行并可清理残留文件。
5. 验证正常业务文件占用后,仍满足 `FBF size + 64 KiB` 空闲门槛。
### 5.4 最小 FULLOTA2 验收测试
正式量产前至少做一次 A 到 B 的本地暂存升级。B 可以只修改版本号:
1. 后端按现有协议下发 B 版 `version + url`;
2. 串口必须看到 HTTP 长度、下载进度、FBF verify 成功和重启日志;
3. 重启后确认 updater 安装 B,且 `U:/FotaFile.bin` 被正确处理;
4. 确认 NVM、设备身份、业务配置和 14 个预置音频未损坏;
5. 分别测试 404、无 Content-Length、空间不足、传输中断、截断 FBF、损坏 FBF 和旧地址
FBF,确认旧 APP 保持可运行且下一次 OTA 可以重新开始;
6. 在下载 10%、50%、90% 时断电,确认旧 APP 启动后清理残留状态并可再次升级;
7. 在 `ql_fota_image_verify()` 成功后的 updater 擦写阶段断电,记录底层 updater 的实际恢复
行为;当前单槽设计不能承诺该阶段自动回滚。
## 6. 后续 OTA 加固
后续 APP 版本建议按以下顺序加固,不再改变本基线分区:
1. 后端增加 size、SHA-256、layout ID 和单调递增 release sequence;
2. 下载过程中计算 SHA-256,完成后重新打开本地文件复核;
3. manifest 增加数字签名、公钥轮换和允许的 HTTPS host;
4. 将单 pending 文件升级为双时隙、CRC 和递增 generation;
5. 增加下载进度、稳定失败码和升级阶段上报;
6. 增加按设备灰度、暂停和失败率熔断;
7. 若未来硬件 Flash 扩容,再评估真正 A/B APP。
建议使用两个带 CRC 和递增 generation 的状态文件,例如 `U:/ota_state.a`、
`U:/ota_state.b`。这能抵抗状态文件写入中断,但不构成固件 A/B 回滚。
## 7. 后端联调契约
最小 FULLOTA2 继续兼容现有 URL 和版本协议,不要求后端立即修改。完成首轮生产验收后,
建议后端增加:
```json
{
"product": "EG800AKCN_11LG",
"layout_id": "eg800akcn_11lg-hxgnss-fs576-app2e5000",
"version": "1.9.0",
"release_seq": 10900,
"min_release_seq": 10800,
"url": "https://ota.example/releases/1.9.0/customer_app.fbf",
"size": 212992,
"sha256": "64-lowercase-hex",
"signature": "optional-first-stage"
}
```
设备校验顺序:产品、layout、版本序号、URL host、大小、SHA-256,最后才允许设置升级标志。
当前最小版本保留原服务器
`http://101.35.224.118:8080/assets/firmware/`,并允许 `talkingq.com` 根域及任意合法
子域名通过 HTTP/HTTPS 提供 `.bin` 文件,例如 `ota.talkingq.com` 和
`cdn.cn.talkingq.com`。校验基于解析出的完整 host 边界,`eviltalkingq.com`、
`talkingq.com.evil.example`、userinfo 绕过、非法端口和路径穿越均会被拒绝。
设备状态建议至少包含:
- `ACCEPTED`:命令和布局校验通过;
- `DOWNLOADING`:带下载字节数或百分比;
- `VERIFIED`:本地文件 SHA-256 和 FBF 校验通过;
- `REBOOTING`:升级标志已设置;
- `UPDATED`:目标 APP 已启动并通过健康检查;
- `FAILED`:带稳定错误码和失败阶段。
联调测试矩阵必须覆盖:同版本、降级、错误 layout、大小不符、SHA 不符、404、超时、下载
中断、下载阶段断电、verify 前断电、重启后无法确认,以及后台重复下发同一命令。
如果暂时没有动态 OTA 后端,也可以用“不可变 FBF 对象 + 签名 current.json”实现;但灰度、
设备级进度和失败统计需要后台服务。
## 8. 发布和回退策略
- 新生产设备只允许使用 HX_GNSS fs576 工厂包和 `0x002E5000` APP/FBF。
- 禁止向 fs576 设备发布旧地址 `0x00322000` 或错误地址 `0x002C2000` 的 FBF。
- 如生产前验证失败,可回退源码分支和工厂包重新全擦烧录;不得混烧新旧布局制品。
- 量产后若只是业务问题,可通过 APP OTA 修复;若分区或 kernel 基线有问题,需要返厂或有线
完整烧录,不能假定业务 APP OTA 能安全修改分区表。
- 当前为单槽。发布新版本必须先小批量灰度,并保持可用的有线恢复工厂包。
@@ -1,6 +1,6 @@
__ROM_START_ADDR = 0x80322000;
__MAX_ROM_SIZE = 0x100000;
__ROM_START_ADDR = 0x802E5000;
__MAX_ROM_SIZE = 0x60000;
__RAM_START_ADDR = 0x7e040800;
__MAX_RAM_SIZE = 0x100000;
@@ -86,6 +86,8 @@ SRC_FILES+= \
sleep_wake.c \
battery.c \
gnss.c \
ota_fullfota2.c \
ota_url_policy.c \
#-------------------------------------------------------------------------------
@@ -29,6 +29,7 @@
#include "ql_log.h"
#include "hal_api.h"
#include "nfc.h"
#include "ota_baseline.h"
#define APP_ENABLE_SLEEP
@@ -146,6 +147,17 @@ static void custom_app_task(void * argv)
hal_os_get_build_time(tempbuf);
printf("BUILD TIME:%s\r\n",tempbuf);
printf("[OTA-BASELINE] layout=%s profile=%s app_phys=0x%08lX app_xip=0x%08lX app_size=%lu fs_size=%lu slot=single\r\n",
BANBAN_OTA_LAYOUT_ID,
BANBAN_OTA_BUILD_PROFILE,
(unsigned long)BANBAN_OTA_APP_PHYSICAL_ADDRESS,
(unsigned long)BANBAN_OTA_APP_XIP_ADDRESS,
(unsigned long)BANBAN_OTA_APP_PARTITION_SIZE,
(unsigned long)BANBAN_OTA_FS_PARTITION_SIZE);
printf("[OTA-BASELINE] U: total=%d free=%d fota2_reserve=%lu\r\n",
ql_fs_size('U'),
ql_fs_free_size('U'),
(unsigned long)BANBAN_OTA_STAGING_RESERVE_BYTES);
//app_uartsh_init();
@@ -517,7 +517,9 @@ exit:
int http_ota_fw_download(char *url)
{
return ql_fullfota_app_start(url);
(void)url;
RLOGE("legacy HTTP polling FULLOTA1 is disabled; use MQTT FULLOTA2 command\r\n");
return -1;
}
typedef struct recv_buf
@@ -37,7 +37,7 @@ const char *mp3_file[] ={
"barge_in_cute.mp3",//19 对话打断提示音
};
//http://banban.api.talkingq.com:8080/assets/roles/banban/tts_error.mp3
//http://101.35.224.118:8080/assets/roles/banban/tts_error.mp3
//#define PCM_FILE "http://47.113.99.138:7050/output_16k.pcm"
@@ -197,7 +197,7 @@ void app_file_check_download(void)
if(ql_access(filename_buff, 0)!=0){
printf("downing mp3 file %d,%s\r\n", index,filename_buff);
down_loop:
sprintf(url_buff, "http://banban.api.talkingq.com:8080/assets/roles/banban/%s", mp3_file[index]);
sprintf(url_buff, "http://101.35.224.118:8080/assets/roles/banban/%s", mp3_file[index]);
ret = http_file_download(url_buff);
if(ret!=0&&cnt++<3) goto down_loop;
@@ -0,0 +1,21 @@
#ifndef BANBAN_OTA_BASELINE_H
#define BANBAN_OTA_BASELINE_H
#include <stdint.h>
#include "build_macro.h"
#define BANBAN_OTA_EXPECTED_LAYOUT_ID "eg800akcn_11lg-hxgnss-fs576-app2e5000"
#define BANBAN_OTA_LAYOUT_ID CUSTOM_APP_OTA_LAYOUT_ID
#define BANBAN_OTA_BUILD_PROFILE CUSTOM_APP_OTA_BUILD_PROFILE
#define BANBAN_OTA_APP_PHYSICAL_ADDRESS UINT32_C(0x002E5000)
#define BANBAN_OTA_APP_XIP_ADDRESS UINT32_C(0x802E5000)
#define BANBAN_OTA_APP_PARTITION_SIZE UINT32_C(0x00060000)
#define BANBAN_OTA_FS_PHYSICAL_ADDRESS UINT32_C(0x00345000)
#define BANBAN_OTA_FS_PARTITION_SIZE UINT32_C(0x00090000)
#define BANBAN_OTA_STAGING_RESERVE_BYTES UINT32_C(0x00010000)
#if !defined(CUSTOM_APP_OTA_LAYOUT_ID) || !defined(CUSTOM_APP_OTA_BUILD_PROFILE)
#error "Use build_rel.bat to build the OTA-ready production baseline"
#endif
#endif
@@ -0,0 +1,25 @@
#ifndef BANBAN_OTA_FULLOTA2_H
#define BANBAN_OTA_FULLOTA2_H
typedef enum {
BANBAN_FULLOTA2_OK = 0,
BANBAN_FULLOTA2_ERR_ARGUMENT = -1,
BANBAN_FULLOTA2_ERR_BUSY = -2,
BANBAN_FULLOTA2_ERR_TASK = -3,
BANBAN_FULLOTA2_ERR_HTTP = -4,
BANBAN_FULLOTA2_ERR_LENGTH = -5,
BANBAN_FULLOTA2_ERR_SPACE = -6,
BANBAN_FULLOTA2_ERR_INIT = -7,
BANBAN_FULLOTA2_ERR_WRITE = -8,
BANBAN_FULLOTA2_ERR_FLUSH = -9,
BANBAN_FULLOTA2_ERR_VERIFY = -10
} banban_fullfota2_result_t;
typedef void (*banban_fullfota2_finished_cb_t)(int result);
int banban_fullfota2_start(const char *url,
banban_fullfota2_finished_cb_t finished_cb);
int banban_fullfota2_is_running(void);
void banban_fullfota2_discard_staged_package(void);
#endif
@@ -0,0 +1,6 @@
#ifndef BANBAN_OTA_URL_POLICY_H
#define BANBAN_OTA_URL_POLICY_H
int banban_ota_url_is_allowed(const char *url);
#endif
@@ -4,4 +4,6 @@
#define CUSTOM_APP_BUILD_REV "NULL"
#define CUSTOM_APP_BUILD_MODEL "EG800AKCN_11LG"
#define CUSTOM_APP_BUILD_VER "1.8.0"
#define CUSTOM_APP_OTA_LAYOUT_ID "eg800akcn_11lg-hxgnss-fs576-app2e5000"
#define CUSTOM_APP_OTA_BUILD_PROFILE "ota_ready_hxgnss_fs576"
#endif
@@ -21,6 +21,8 @@
#include "cJSON.h"
#include "build_macro.h"
#include "ql_log.h"
#include "ota_fullfota2.h"
#include "ota_url_policy.h"
@@ -45,7 +47,6 @@
#define OTA_STATE_FORMAT_VERSION 1U
#define OTA_PROMPT_PLAY_TIMEOUT_MS 10000
#define OTA_PROMPT_POLL_MS 50
#define OTA_ALLOWED_URL_PREFIX "http://101.35.224.118:8080/assets/firmware/"
static ql_timer_t mqtt_event_task_timer = NULL;
@@ -242,6 +243,17 @@ static void ota_state_clear(void)
}
}
static void ota_fullfota2_finished(int result)
{
if (result == BANBAN_FULLOTA2_OK) {
return;
}
RLOGE("FULLOTA2 failed before activation: %d", result);
ota_state_clear();
fota_start = 0;
}
static int ota_parse_version(const char *version, unsigned int parts[3])
{
char tail;
@@ -285,24 +297,6 @@ static int ota_compare_version(const char *left, const char *right, int *result)
return 0;
}
static int ota_url_is_allowed(const char *url)
{
size_t prefix_len = strlen(OTA_ALLOWED_URL_PREFIX);
if (url == NULL || url[0] == '\0') {
return 0;
}
if (strncmp(url, OTA_ALLOWED_URL_PREFIX, prefix_len) != 0) {
return 0;
}
if (url[prefix_len] == '\0' || strstr(url + prefix_len, "..") != NULL) {
return 0;
}
return strstr(url + prefix_len, ".bin") != NULL;
}
/* ==================== 解析函数 ==================== */
/**
@@ -1246,6 +1240,8 @@ static void ota_report_pending_success(void)
compare_result != 0) {
RLOGE("OTA pending state version mismatch: current=%s target=%s",
current_version, state.target_version);
banban_fullfota2_discard_staged_package();
ota_state_clear();
return;
}
@@ -1304,7 +1300,7 @@ static void handle_ota_upgrade(command_t *cmd)
return;
}
if (!ota_url_is_allowed(cmd->params.ota.url)) {
if (!banban_ota_url_is_allowed(cmd->params.ota.url)) {
RLOGE("OTA URL is not allowed: %s", cmd->params.ota.url);
return;
}
@@ -1369,12 +1365,10 @@ static void handle_ota_upgrade(command_t *cmd)
}
}
/* Product requirement: keep deleting the configured audio files before OTA. */
app_file_check_del();
ret = ql_fullfota_app_start(cmd->params.ota.url);
ret = banban_fullfota2_start(cmd->params.ota.url,
ota_fullfota2_finished);
if (ret != 0) {
RLOGE("start OTA failed: %d", ret);
RLOGE("start FULLOTA2 task failed: %d", ret);
fota_start = 0;
ota_state_clear();
}
@@ -0,0 +1,272 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ql_fota.h"
#include "ql_fs.h"
#include "ql_power.h"
#include "ql_rtos.h"
#include "webclient.h"
#include "ota_baseline.h"
#include "ota_fullfota2.h"
#define BANBAN_FULLOTA2_PACKAGE_PATH "U:/FotaFile.bin"
#define BANBAN_FULLOTA2_URL_MAX 256U
#define BANBAN_FULLOTA2_READ_BUFFER_SIZE 4096U
#define BANBAN_FULLOTA2_TASK_STACK_SIZE (12U * 1024U)
#define BANBAN_FULLOTA2_TASK_PRIORITY 100U
#define BANBAN_FULLOTA2_HTTP_TIMEOUT_MS 30000
#define BANBAN_FULLOTA2_FBF_OVERHEAD 8192U
typedef struct {
char url[BANBAN_FULLOTA2_URL_MAX];
banban_fullfota2_finished_cb_t finished_cb;
} banban_fullfota2_request_t;
static ql_task_t g_fullfota2_task = NULL;
static volatile int g_fullfota2_running = 0;
static banban_fullfota2_request_t g_fullfota2_request;
static void banban_fullfota2_finish(int result,
struct webclient_session *session,
uint8_t *buffer,
qlFotaImgProcCtxPtr fota)
{
banban_fullfota2_finished_cb_t finished_cb =
g_fullfota2_request.finished_cb;
if (session != NULL) {
webclient_close(session);
}
if (buffer != NULL) {
web_free(buffer);
}
if (fota != NULL) {
ql_fota_deinit(fota);
}
if (result != BANBAN_FULLOTA2_OK) {
ql_fota_fixed_internal_flash_upgrade(0);
if (ql_access(BANBAN_FULLOTA2_PACKAGE_PATH, 0) == 0) {
ql_remove(BANBAN_FULLOTA2_PACKAGE_PATH);
}
}
memset(&g_fullfota2_request, 0, sizeof(g_fullfota2_request));
g_fullfota2_running = 0;
g_fullfota2_task = NULL;
if (finished_cb != NULL) {
finished_cb(result);
}
}
static void banban_fullfota2_task(void *argument)
{
struct webclient_session *session = NULL;
qlFotaImgProcCtxPtr fota = NULL;
uint8_t *buffer = NULL;
int status;
int content_length;
int free_size;
int read_length;
int written = 0;
int next_progress = 10;
int result = BANBAN_FULLOTA2_ERR_HTTP;
struct lfs_info staged_info;
(void)argument;
printf("[FULLOTA2] start url=%s path=%s\r\n",
g_fullfota2_request.url,
BANBAN_FULLOTA2_PACKAGE_PATH);
if (ql_access(BANBAN_FULLOTA2_PACKAGE_PATH, 0) == 0) {
ql_remove(BANBAN_FULLOTA2_PACKAGE_PATH);
}
session = webclient_session_create(2048);
if (session == NULL) {
result = BANBAN_FULLOTA2_ERR_HTTP;
goto failed;
}
webclient_set_timeout(session, BANBAN_FULLOTA2_HTTP_TIMEOUT_MS);
status = webclient_get(session, g_fullfota2_request.url);
if (status != 200) {
printf("[FULLOTA2] HTTP GET failed status=%d\r\n", status);
result = BANBAN_FULLOTA2_ERR_HTTP;
goto failed;
}
content_length = session->content_length;
if (content_length <= (int)BANBAN_FULLOTA2_FBF_OVERHEAD ||
content_length > (int)BANBAN_OTA_APP_PARTITION_SIZE) {
printf("[FULLOTA2] invalid Content-Length=%d\r\n", content_length);
result = BANBAN_FULLOTA2_ERR_LENGTH;
goto failed;
}
free_size = ql_fs_free_size('U');
if (free_size < 0 ||
(uint32_t)free_size <
(uint32_t)content_length + BANBAN_OTA_STAGING_RESERVE_BYTES) {
printf("[FULLOTA2] insufficient space free=%d required=%lu\r\n",
free_size,
(unsigned long)((uint32_t)content_length +
BANBAN_OTA_STAGING_RESERVE_BYTES));
result = BANBAN_FULLOTA2_ERR_SPACE;
goto failed;
}
ql_fota_fixed_internal_flash_upgrade(1);
if (ql_fota_set_package_path(BANBAN_FULLOTA2_PACKAGE_PATH) != 0) {
printf("[FULLOTA2] set package path failed\r\n");
result = BANBAN_FULLOTA2_ERR_INIT;
goto failed;
}
fota = ql_fota_init();
if (fota == NULL) {
printf("[FULLOTA2] ql_fota_init failed\r\n");
result = BANBAN_FULLOTA2_ERR_INIT;
goto failed;
}
buffer = web_malloc(BANBAN_FULLOTA2_READ_BUFFER_SIZE);
if (buffer == NULL) {
printf("[FULLOTA2] allocate read buffer failed\r\n");
result = BANBAN_FULLOTA2_ERR_INIT;
goto failed;
}
while (written < content_length) {
int request_length = content_length - written;
int progress;
if (request_length > (int)BANBAN_FULLOTA2_READ_BUFFER_SIZE) {
request_length = BANBAN_FULLOTA2_READ_BUFFER_SIZE;
}
read_length = webclient_read(session, buffer, request_length);
if (read_length <= 0) {
printf("[FULLOTA2] body read failed ret=%d received=%d expected=%d\r\n",
read_length, written, content_length);
result = BANBAN_FULLOTA2_ERR_HTTP;
goto failed;
}
if (ql_fota_image_write(fota, buffer, read_length) != 0) {
printf("[FULLOTA2] image write failed received=%d chunk=%d\r\n",
written, read_length);
result = BANBAN_FULLOTA2_ERR_WRITE;
goto failed;
}
written += read_length;
progress = (written * 100) / content_length;
if (progress >= next_progress || written == content_length) {
printf("[FULLOTA2] download progress=%d%% bytes=%d/%d\r\n",
progress, written, content_length);
while (next_progress <= progress) {
next_progress += 10;
}
}
}
if (written != content_length) {
result = BANBAN_FULLOTA2_ERR_LENGTH;
goto failed;
}
if (ql_fota_image_flush(fota) != 0) {
printf("[FULLOTA2] image flush failed\r\n");
result = BANBAN_FULLOTA2_ERR_FLUSH;
goto failed;
}
memset(&staged_info, 0, sizeof(staged_info));
if (ql_stat(BANBAN_FULLOTA2_PACKAGE_PATH, &staged_info) != 0 ||
staged_info.size != (uint32_t)content_length) {
printf("[FULLOTA2] staged length mismatch file=%lu expected=%d\r\n",
(unsigned long)staged_info.size, content_length);
result = BANBAN_FULLOTA2_ERR_LENGTH;
goto failed;
}
if (ql_fota_image_verify(fota) != 0) {
printf("[FULLOTA2] FBF verify/setflag failed\r\n");
result = BANBAN_FULLOTA2_ERR_VERIFY;
goto failed;
}
printf("[FULLOTA2] package verified; rebooting into updater\r\n");
banban_fullfota2_finish(BANBAN_FULLOTA2_OK, session, buffer, fota);
ql_rtos_task_sleep_ms(200);
ql_power_reset();
/* ql_power_reset() is not expected to return. Keep the task quiesced if it does. */
while (1) {
ql_rtos_task_sleep_s(60);
}
failed:
printf("[FULLOTA2] failed result=%d\r\n", result);
banban_fullfota2_finish(result, session, buffer, fota);
ql_rtos_task_delete(NULL);
}
int banban_fullfota2_start(const char *url,
banban_fullfota2_finished_cb_t finished_cb)
{
int ret;
size_t url_length;
if (url == NULL || url[0] == '\0') {
return BANBAN_FULLOTA2_ERR_ARGUMENT;
}
url_length = strlen(url);
if (url_length >= sizeof(g_fullfota2_request.url)) {
return BANBAN_FULLOTA2_ERR_ARGUMENT;
}
if (g_fullfota2_running) {
return BANBAN_FULLOTA2_ERR_BUSY;
}
memset(&g_fullfota2_request, 0, sizeof(g_fullfota2_request));
memcpy(g_fullfota2_request.url, url, url_length + 1);
g_fullfota2_request.finished_cb = finished_cb;
g_fullfota2_running = 1;
ret = ql_rtos_task_create(&g_fullfota2_task,
BANBAN_FULLOTA2_TASK_STACK_SIZE,
BANBAN_FULLOTA2_TASK_PRIORITY,
"ota_fullfota2",
banban_fullfota2_task,
NULL);
if (ret != 0) {
memset(&g_fullfota2_request, 0, sizeof(g_fullfota2_request));
g_fullfota2_running = 0;
g_fullfota2_task = NULL;
return BANBAN_FULLOTA2_ERR_TASK;
}
return BANBAN_FULLOTA2_OK;
}
int banban_fullfota2_is_running(void)
{
return g_fullfota2_running ? 1 : 0;
}
void banban_fullfota2_discard_staged_package(void)
{
if (!g_fullfota2_running &&
ql_access(BANBAN_FULLOTA2_PACKAGE_PATH, 0) == 0) {
ql_remove(BANBAN_FULLOTA2_PACKAGE_PATH);
}
}
@@ -0,0 +1,189 @@
#include <stddef.h>
#include <string.h>
#include "ota_url_policy.h"
#define BANBAN_OTA_LEGACY_URL_PREFIX \
"http://101.35.224.118:8080/assets/firmware/"
#define BANBAN_OTA_BASE_DOMAIN "talkingq.com"
static int banban_ascii_equal_nocase(const char *left,
const char *right,
size_t length)
{
size_t i;
for (i = 0; i < length; ++i) {
unsigned char a = (unsigned char)left[i];
unsigned char b = (unsigned char)right[i];
if (a >= 'A' && a <= 'Z') {
a = (unsigned char)(a + ('a' - 'A'));
}
if (b >= 'A' && b <= 'Z') {
b = (unsigned char)(b + ('a' - 'A'));
}
if (a != b) {
return 0;
}
}
return 1;
}
static int banban_hostname_is_valid(const char *host, size_t host_length)
{
size_t i;
int label_has_character = 0;
int previous_was_hyphen = 0;
if (host_length == 0 || host_length > 253) {
return 0;
}
for (i = 0; i < host_length; ++i) {
unsigned char character = (unsigned char)host[i];
if ((character >= 'A' && character <= 'Z') ||
(character >= 'a' && character <= 'z') ||
(character >= '0' && character <= '9')) {
label_has_character = 1;
previous_was_hyphen = 0;
continue;
}
if (character == '-') {
if (!label_has_character) {
return 0;
}
previous_was_hyphen = 1;
continue;
}
if (character == '.') {
if (!label_has_character || previous_was_hyphen) {
return 0;
}
label_has_character = 0;
previous_was_hyphen = 0;
continue;
}
return 0;
}
return label_has_character && !previous_was_hyphen;
}
static int banban_host_is_talkingq(const char *host, size_t host_length)
{
const size_t base_length = sizeof(BANBAN_OTA_BASE_DOMAIN) - 1;
if (!banban_hostname_is_valid(host, host_length)) {
return 0;
}
if (host_length == base_length) {
return banban_ascii_equal_nocase(host,
BANBAN_OTA_BASE_DOMAIN,
base_length);
}
if (host_length <= base_length + 1 ||
host[host_length - base_length - 1] != '.') {
return 0;
}
return banban_ascii_equal_nocase(host + host_length - base_length,
BANBAN_OTA_BASE_DOMAIN,
base_length);
}
static int banban_port_is_valid(const char *port, size_t port_length)
{
size_t i;
unsigned long value = 0;
if (port_length == 0 || port_length > 5) {
return 0;
}
for (i = 0; i < port_length; ++i) {
if (port[i] < '0' || port[i] > '9') {
return 0;
}
value = value * 10 + (unsigned long)(port[i] - '0');
}
return value > 0 && value <= 65535;
}
static int banban_path_is_fbf(const char *path)
{
const char *path_end;
const char *query;
if (path == NULL || path[0] != '/' || path[1] == '\0' ||
strstr(path, "..") != NULL || strchr(path, '\\') != NULL ||
strchr(path, '#') != NULL) {
return 0;
}
query = strchr(path, '?');
path_end = query != NULL ? query : path + strlen(path);
if ((size_t)(path_end - path) < 4) {
return 0;
}
return banban_ascii_equal_nocase(path_end - 4, ".bin", 4);
}
int banban_ota_url_is_allowed(const char *url)
{
const char *authority;
const char *path;
const char *host_end;
const char *colon;
const size_t legacy_prefix_length =
sizeof(BANBAN_OTA_LEGACY_URL_PREFIX) - 1;
if (url == NULL || url[0] == '\0') {
return 0;
}
if (strncmp(url, BANBAN_OTA_LEGACY_URL_PREFIX,
legacy_prefix_length) == 0) {
return banban_path_is_fbf(url + legacy_prefix_length - 1);
}
if (strncmp(url, "http://", 7) == 0) {
authority = url + 7;
} else if (strncmp(url, "https://", 8) == 0) {
authority = url + 8;
} else {
return 0;
}
path = strchr(authority, '/');
if (path == NULL || path == authority) {
return 0;
}
if (memchr(authority, '@', (size_t)(path - authority)) != NULL) {
return 0;
}
colon = memchr(authority, ':', (size_t)(path - authority));
host_end = colon != NULL ? colon : path;
if (!banban_host_is_talkingq(authority,
(size_t)(host_end - authority))) {
return 0;
}
if (colon != NULL &&
!banban_port_is_valid(colon + 1, (size_t)(path - colon - 1))) {
return 0;
}
return banban_path_is_fbf(path);
}
@@ -29,6 +29,9 @@
// 在文件顶部添加状态变量
static uint8_t speech_detect_count = 0; // 连续语音帧计数器
#define SPEECH_DETECT_THRESHOLD 20 // 连续检测到语音的阈值
#define PLAYBACK_VAD_GUARD_MS 800 // 屏蔽播放结束后的尾音和音频通路切换噪声
#define VAD_FRAME_BYTES 640 // 20ms @ 16kHz, 16-bit mono
#define VAD_PREROLL_FRAMES 25 // 保留500ms预录音,避免吞掉用户首字
uint8_t no_speech_timeout = 0;
extern int g_mic_working;
@@ -57,6 +60,9 @@ static int64_t no_sound_start_time = 0;
static int64_t cmd_wait_start_time = 0;
static bool first_sound_detected = false;
static int64_t recording_start_time = 0; // 记录录音开始时间(首次检测到声音时)
static uint8_t vad_preroll[VAD_PREROLL_FRAMES][VAD_FRAME_BYTES];
static size_t vad_preroll_next = 0;
static size_t vad_preroll_count = 0;
static int vad_tts_playback_finished_status = 0;
// 统计
@@ -101,6 +107,9 @@ void set_state(system_state_t new_state) {
no_sound_start_time = now;
first_sound_detected = false;
speech_frame_count = 0;
speech_detect_count = 0;
vad_preroll_next = 0;
vad_preroll_count = 0;
g_mic_working = 0;
break;
case STATE_RECORDING_ACTIVE:
@@ -290,26 +299,63 @@ void vad_leave_message(void)
audio_packet_type = 4;
vad_leave_message_flag = 1;
/* A previous conversation may still be parked in WAITING_COMMAND. NFC
* leave-message recording must enter IDLE first, otherwise g_triggered is
* left pending forever and its 10-second no-speech timer never starts. */
set_state(STATE_IDLE);
vad_audio_trigger_record();
}
int vad_leave_message_is_active(void)
{
return vad_leave_message_flag != 0 || audio_packet_type == 4;
}
/* The conversation key has priority over an unfinished NFC leave-message
* transaction. Clear every local latch before the normal conversation prompt
* is queued so that its recording can never inherit packetType=4. */
int vad_cancel_leave_message(void)
{
int was_active = vad_leave_message_is_active();
if (!was_active) return 0;
printf("cancel pending leave message\r\n");
vad_leave_message_flag = 0;
g_triggered = false;
audio_packet_type = 0;
no_speech_timeout = 0;
speech_detect_count = 0;
vad_preroll_next = 0;
vad_preroll_count = 0;
key_start_leave = 0;
if (vad_audio_is_recording()) {
set_state(STATE_IDLE);
vad_audio_stop_no_wait_resp();
} else {
set_state(STATE_IDLE);
}
return 1;
}
// 主处理函数 - 需要在循环中定期调用
void vad_audio_process(void) {
size_t bytes_read = 0;
int64_t now = hal_os_up_time_ms();
static size_t buffer_offset = 0;
uint8_t mic_buf[640]={0};
int record_len = 640;
uint8_t mic_buf[VAD_FRAME_BYTES]={0};
int record_len = VAD_FRAME_BYTES;
uint16 frame[320];
int index = 0, i = 0;
// 状态机处理
switch (g_state) {
case STATE_IDLE:
no_speech_timeout = 0;
vad_leave_message_flag = 0;
speech_detect_count = 0; // 空闲时清零计数器
case STATE_IDLE:
no_speech_timeout = 0;
speech_detect_count = 0; // 空闲时清零计数器
vad_tts_playback_finished_status = 0;
if (g_triggered) {
@@ -326,15 +372,19 @@ void vad_audio_process(void) {
//每次读取640字节音频数据
ql_audio_record_read(record_hd, mic_buf, &record_len);
//如果检测到声音再去传输
// if(first_sound_detected){
//实际上每次编码需要输入1152字节的PCM数据
if(hal_os_rb_write(ring_sender, (uint8*)mic_buf, record_len, 25)>0){
//quec_usbcdc_write((uint8*)mic_buf, record_len);
ql_rtos_semaphore_release(sender_sem); //告诉线程去获取录音数据
if (g_state == STATE_RECORDING_WAIT_SOUND) {
/* 首次开口确认前不向服务端发送静音。只在本地保留最近500ms,
* 确认开口时再回填,防止截掉用户首字。 */
memcpy(vad_preroll[vad_preroll_next], mic_buf, VAD_FRAME_BYTES);
vad_preroll_next = (vad_preroll_next + 1) % VAD_PREROLL_FRAMES;
if (vad_preroll_count < VAD_PREROLL_FRAMES) {
vad_preroll_count++;
}
} else {
if(hal_os_rb_write(ring_sender, (uint8*)mic_buf, record_len, 25)>0){
ql_rtos_semaphore_release(sender_sem);
}
}
// }
#if 1
for(index=0; index<320; index++)
{
@@ -347,6 +397,12 @@ void vad_audio_process(void) {
// 首次声音检测
if (!first_sound_detected) {
/* 播放刚结束时扬声器尾音和PA切换噪声容易被FVAD认为用户开口。 */
if ((now - no_sound_start_time) < PLAYBACK_VAD_GUARD_MS) {
speech_detect_count = 0;
buffer_offset = 0;
break;
}
if (current_vad == VAD_STATE_ACTIVE) {
speech_detect_count++;
printf("Speech frame count: %d\r\n", speech_detect_count);
@@ -363,8 +419,23 @@ void vad_audio_process(void) {
}
#endif
first_sound_detected = true;
set_state(STATE_RECORDING_ACTIVE);
first_sound_detected = true;
{
size_t first = (vad_preroll_next + VAD_PREROLL_FRAMES - vad_preroll_count) % VAD_PREROLL_FRAMES;
size_t n;
int preroll_queued = 0;
for (n = 0; n < vad_preroll_count; n++) {
size_t frame_index = (first + n) % VAD_PREROLL_FRAMES;
if (hal_os_rb_write(ring_sender, vad_preroll[frame_index], VAD_FRAME_BYTES, 25) > 0) {
preroll_queued = 1;
}
}
if (preroll_queued) {
ql_rtos_semaphore_release(sender_sem);
}
vad_preroll_count = 0;
}
set_state(STATE_RECORDING_ACTIVE);
printf("First speech confirmed by FVAD (continuous %d frames)", SPEECH_DETECT_THRESHOLD);
}
} else {
@@ -418,15 +489,20 @@ void vad_audio_process(void) {
buffer_offset = 0;
break;
case STATE_WAITING_COMMAND:
if(vad_tts_playback_finished_status == 1) {
printf("finished_status:%d, no_speech_timeout:%d\n", vad_tts_playback_finished_status, no_speech_timeout);
set_state(STATE_IDLE);
ql_rtos_task_sleep_ms(50);
if(no_speech_timeout == 0&&vad_leave_message_flag==0) {
vad_audio_trigger_record();
}
}
case STATE_WAITING_COMMAND:
if(vad_tts_playback_finished_status == 1) {
int leave_message_finished = vad_leave_message_flag != 0;
printf("finished_status:%d, no_speech_timeout:%d\n", vad_tts_playback_finished_status, no_speech_timeout);
set_state(STATE_IDLE);
ql_rtos_task_sleep_ms(50);
if(no_speech_timeout == 0&&vad_leave_message_flag==0) {
vad_audio_trigger_record();
}
if(leave_message_finished) {
vad_leave_message_flag = 0;
audio_packet_type = 0;
}
}
key_start_leave = 0;
break;
@@ -30,9 +30,9 @@
#define PCM_FRAME_LENGTH 1152
#define CLOUD_TTS_ERROR_URL_FORMAT \
"http://101.35.224.118:8080/assets/roles/banban/tts_error/tts_error%02u.mp3"
#define LOCAL_TTS_ERROR_PATH "U:/tts_error.mp3"
#define CLOUD_TTS_ERROR_URL_FORMAT \
"http://101.35.224.118:8080/assets/roles/banban/tts_error/tts_error%02u.mp3"
#define LOCAL_TTS_ERROR_PATH "U:/tts_error.mp3"
static int g_sim_ready = 0;
@@ -50,14 +50,14 @@ static uint8_t sender_buf[PCM_FRAME_LENGTH]={0};
static int16 mp3_buff[2*PCM_FRAME_LENGTH];
static int16 *ppbuff=mp3_buff;
static int onmessage_status = -1;
volatile int tts_playing = 0;
unsigned char wss_connect_start = 0;
static volatile int player_mq_couter = 0;
/* Set while a barge-in prompt bridges the old and new sessions. The old
* server response can still deliver URLs after its player queue was cleared. */
static volatile int barge_in_transition = 0;
static unsigned char audio_in_task_cancel = 0;
static int onmessage_status = -1;
volatile int tts_playing = 0;
unsigned char wss_connect_start = 0;
static volatile int player_mq_couter = 0;
/* Set while a barge-in prompt bridges the old and new sessions. The old
* server response can still deliver URLs after its player queue was cleared. */
static volatile int barge_in_transition = 0;
static volatile unsigned char audio_in_task_cancel = 0;
static unsigned char xiaoice_wss_connect_status = 0;
static unsigned char wss_connect_counter = 0;
@@ -66,22 +66,24 @@ int fota_start = 0;
int mqtt_task_run_flag = 0;
extern int sub_topic_status;
extern int mqtt_task_run_flag;
static unsigned char shine_init = 0;
extern int fisrt_net_start;
extern u8 power_down_status;
extern int aofei_mp3_player_get_result(void);
void sendAuthentication(void);
void vad_start_func(void);
void xiaoice_close_all_audio(void);
extern int mqtt_task_run_flag;
static unsigned char shine_init = 0;
extern int fisrt_net_start;
extern u8 power_down_status;
extern int aofei_mp3_player_get_result(void);
extern int vad_leave_message_is_active(void);
extern int vad_cancel_leave_message(void);
void sendAuthentication(void);
void vad_start_func(void);
void xiaoice_close_all_audio(void);
int audio_packet_type = 0;
typedef struct ice_work_item_s
{
int type; //0-tts_out 1-session_continue 2-session_end 3-云端超时提示(失败时播放本地兜底)
typedef struct ice_work_item_s
{
int type; //0-tts_out 1-session_continue 2-session_end 3-云端超时提示(失败时播放本地兜底)
int error;
int del_url;
void *data;
@@ -117,11 +119,11 @@ static librws_app_t app = &_app;
static ql_sem_t ws_connect_sem = NULL;
extern int fota_start;
#define OTA_AUDIO_STOP_TIMEOUT_MS 3000
#define OTA_AUDIO_STOP_POLL_MS 20
void xiaoice_clear_player_mq(void);
#define OTA_AUDIO_STOP_TIMEOUT_MS 3000
#define OTA_AUDIO_STOP_POLL_MS 20
void xiaoice_clear_player_mq(void);
static int tts_play_status = 0;//1播放中 2播放完成
@@ -204,12 +206,12 @@ static void remove_underscores(char *str) {
*dst = '\0'; // 添加字符串结束符
}
/* 生成 banban 角色的随机云端对话超时提示音 URL。 */
static int build_random_tts_error_url(char *url, size_t url_size)
{
static uint32_t random_sequence = 0;
static unsigned int last_random_index = 10;
uint32_t random_value;
/* 生成 banban 角色的随机云端对话超时提示音 URL。 */
static int build_random_tts_error_url(char *url, size_t url_size)
{
static uint32_t random_sequence = 0;
static unsigned int last_random_index = 10;
uint32_t random_value;
unsigned int random_index;
int written;
@@ -238,32 +240,32 @@ static int build_random_tts_error_url(char *url, size_t url_size)
return -1;
}
printf("random cloud tts error index:%02u, url:%s\n", random_index, url);
return 1;
}
/*
* 后台下发任意角色的对话超时 URL 时,统一替换成 banban 的随机云端资源。
* 非对话超时 URL 保持不变。
*/
static int randomize_tts_error_url(char *url, size_t url_size)
{
char *file_name;
if(url == NULL || url_size == 0){
return 0;
}
file_name = strrchr(url, '/');
file_name = (file_name == NULL) ? url : file_name + 1;
if(strncmp(file_name, "tts_error", strlen("tts_error")) != 0 &&
strncmp(file_name, "tts_err", strlen("tts_err")) != 0){
return 0;
}
return build_random_tts_error_url(url, url_size);
}
printf("random cloud tts error index:%02u, url:%s\n", random_index, url);
return 1;
}
/*
* 后台下发任意角色的对话超时 URL 时,统一替换成 banban 的随机云端资源。
* 非对话超时 URL 保持不变。
*/
static int randomize_tts_error_url(char *url, size_t url_size)
{
char *file_name;
if(url == NULL || url_size == 0){
return 0;
}
file_name = strrchr(url, '/');
file_name = (file_name == NULL) ? url : file_name + 1;
if(strncmp(file_name, "tts_error", strlen("tts_error")) != 0 &&
strncmp(file_name, "tts_err", strlen("tts_err")) != 0){
return 0;
}
return build_random_tts_error_url(url, url_size);
}
void add_file_to_play_player_queue(char *path, int type)
{
@@ -312,21 +314,21 @@ void add_file_to_play_player_queue(char *path, int type)
//如果录音后 超过10s 还没有收到信息 提示本轮对话结束
static void onmessage_text(rws_socket socket, const char *text, const unsigned int len)
{
if (fota_start == 1) {
printf("ignore websocket audio during OTA\r\n");
return;
}
/* The new session id is generated only when recording starts. Until
* then, every TTS message on this socket belongs to the interrupted
* session and must not be allowed to refill the cleared player queue. */
if (barge_in_transition) {
printf("drop stale websocket message during barge-in\r\n");
return;
}
static void onmessage_text(rws_socket socket, const char *text, const unsigned int len)
{
if (fota_start == 1) {
printf("ignore websocket audio during OTA\r\n");
return;
}
/* The new session id is generated only when recording starts. Until
* then, every TTS message on this socket belongs to the interrupted
* session and must not be allowed to refill the cleared player queue. */
if (barge_in_transition) {
printf("drop stale websocket message during barge-in\r\n");
return;
}
// printf("message(txt), %d(byte): %s ", len, text);
char url_buff[128]={0x00};
@@ -351,6 +353,7 @@ static void onmessage_text(rws_socket socket, const char *text, const unsigned i
char *ret_str2 = NULL;
size_t url_len = 0;
int is_tts_error_url = 0;
int is_message_ok_url = 0;
if(ret_str1 == NULL){
ret_str1 = strstr(text, "http://");
}
@@ -367,6 +370,14 @@ static void onmessage_text(rws_socket socket, const char *text, const unsigned i
}
memcpy(url_buff, ret_str1, url_len);
url_buff[url_len] = '\0';
is_message_ok_url = strstr(url_buff, "message_ok") != NULL;
/* A cancelled NFC transaction may still have a response in flight.
* Never let that acknowledgement enter a normal conversation. */
if(is_message_ok_url && !vad_leave_message_is_active()){
printf("drop stale leave message ack\r\n");
return;
}
/* 两个角色统一使用 banban 的 HTTP 随机超时提示音 */
is_tts_error_url = randomize_tts_error_url(url_buff, sizeof(url_buff));
@@ -391,7 +402,7 @@ static void onmessage_text(rws_socket socket, const char *text, const unsigned i
tts_play_status = 2;
}
if(is_tts_error_url == 1 || strstr(url_buff, "message_ok")!=NULL){
if(is_tts_error_url == 1 || is_message_ok_url){
printf("tts_error or message_ok\n");
vad_set_speech_timeout_flag(1);
}
@@ -400,8 +411,8 @@ static void onmessage_text(rws_socket socket, const char *text, const unsigned i
if(tts_playing==2) return;
/* 对话超时提示使用 type=3,云端播放失败时由播放线程切换到本地兜底。 */
add_file_to_play_player_queue(url_buff, is_tts_error_url == 1 ? 3 : 0);
/* 对话超时提示使用 type=3,云端播放失败时由播放线程切换到本地兜底。 */
add_file_to_play_player_queue(url_buff, is_tts_error_url == 1 ? 3 : 0);
}
}
@@ -772,12 +783,12 @@ void generateSessionId(void)
#if 1
void sendStartPacket(void) {
generateSessionId();
/* sessionId now identifies the new conversation. Late URLs from the old
* conversation will fail the existing session-id check below. */
barge_in_transition = 0;
generateDeviceId();
void sendStartPacket(void) {
generateSessionId();
/* sessionId now identifies the new conversation. Late URLs from the old
* conversation will fail the existing session-id check below. */
barge_in_transition = 0;
generateDeviceId();
gSequenceNumber = 0;
AudioPacket startPacket = {0};
@@ -894,23 +905,23 @@ void sendRecordingAudioPacket(AudioPacket *packet) {
extern const char *mp3_file[];
int app_play_tips_file(int index)
{
int file_valid_check = 0;
int access_ret = 0;
printf("xiaoice_play_index:%d\n", index);
//正在录音过程中不允许播放
// if(xiaoice_get_tts_play_status() || get_start_mic_flag()==1) return -1;
if(power_down_status!=1){
if(vad_audio_is_recording() || tts_playing!=0 || fota_start==1 || power_down_status==2 || get_sleep_mode()==1) {
printf("tips blocked:index=%d vad=%d tts=%d fota=%d power=%d sleep=%d\r\n",
index, vad_audio_is_recording(), tts_playing, fota_start,
power_down_status, get_sleep_mode());
return -1;
}
}
int app_play_tips_file(int index)
{
int file_valid_check = 0;
int access_ret = 0;
printf("xiaoice_play_index:%d\n", index);
//正在录音过程中不允许播放
// if(xiaoice_get_tts_play_status() || get_start_mic_flag()==1) return -1;
if(power_down_status!=1){
if(vad_audio_is_recording() || tts_playing!=0 || fota_start==1 || power_down_status==2 || get_sleep_mode()==1) {
printf("tips blocked:index=%d vad=%d tts=%d fota=%d power=%d sleep=%d\r\n",
index, vad_audio_is_recording(), tts_playing, fota_start,
power_down_status, get_sleep_mode());
return -1;
}
}
char filename_buff[64]={0x00};
@@ -919,18 +930,18 @@ int app_play_tips_file(int index)
if(index==0) {
file_valid_check = 0;
}else{
access_ret = ql_access(filename_buff, 0);
if(access_ret==0){
file_valid_check = 1;
}else{
printf("tips file missing:index=%d path=%s access=%d\r\n",
index, filename_buff, access_ret);
file_valid_check = 2;
}
access_ret = ql_access(filename_buff, 0);
if(access_ret==0){
file_valid_check = 1;
}else{
printf("tips file missing:index=%d path=%s access=%d\r\n",
index, filename_buff, access_ret);
file_valid_check = 2;
}
}
if(file_valid_check!=2){
if(index==18 || index==19){
if(index==18 || index==19){
add_file_to_play_player_queue(filename_buff, 2);
}else{
add_file_to_play_player_queue(filename_buff, 0);
@@ -942,53 +953,53 @@ int app_play_tips_file(int index)
return 0;
}
int app_play_ota_tips_file(void)
{
char filename_buff[64] = {0};
uint32_t timeout;
/* OTA owns the audio path from this point on. Stop the active conversation
* before queuing the prompt, otherwise recording/TTS rejects or masks it. */
if (vad_audio_is_recording()) {
vad_audio_stop_record();
}
if (get_xiaoice_connected_status() == 2) {
talkingq_ws_disconnect();
}
tts_playing = 2;
xiaoice_clear_player_mq();
timeout = hal_os_up_time_ms();
while (player_mq_couter > 0) {
ql_rtos_task_sleep_ms(OTA_AUDIO_STOP_POLL_MS);
if (hal_os_up_time_ms() - timeout > OTA_AUDIO_STOP_TIMEOUT_MS) {
printf("OTA wait previous audio timeout, counter:%d\r\n",
player_mq_couter);
return -3;
}
}
tts_play_status = 0;
tts_playing = 0;
/* Normal prompts are blocked while fota_start is set. Queue the dedicated
* OTA prompt directly after the old conversation has fully stopped. */
snprintf(filename_buff, sizeof(filename_buff), "U:/%s", mp3_file[3]);
printf("xiaoice_play_index:3\n");
if (ql_access(filename_buff, 0) != 0) {
return -2;
}
add_file_to_play_player_queue(filename_buff, 0);
return player_mq_couter > 0 ? 0 : -1;
}
void app_player_thread(void *arvg)
{
ice_work_item *player_item=NULL;
int play_ret;
int app_play_ota_tips_file(void)
{
char filename_buff[64] = {0};
uint32_t timeout;
/* OTA owns the audio path from this point on. Stop the active conversation
* before queuing the prompt, otherwise recording/TTS rejects or masks it. */
if (vad_audio_is_recording()) {
vad_audio_stop_record();
}
if (get_xiaoice_connected_status() == 2) {
talkingq_ws_disconnect();
}
tts_playing = 2;
xiaoice_clear_player_mq();
timeout = hal_os_up_time_ms();
while (player_mq_couter > 0) {
ql_rtos_task_sleep_ms(OTA_AUDIO_STOP_POLL_MS);
if (hal_os_up_time_ms() - timeout > OTA_AUDIO_STOP_TIMEOUT_MS) {
printf("OTA wait previous audio timeout, counter:%d\r\n",
player_mq_couter);
return -3;
}
}
tts_play_status = 0;
tts_playing = 0;
/* Normal prompts are blocked while fota_start is set. Queue the dedicated
* OTA prompt directly after the old conversation has fully stopped. */
snprintf(filename_buff, sizeof(filename_buff), "U:/%s", mp3_file[3]);
printf("xiaoice_play_index:3\n");
if (ql_access(filename_buff, 0) != 0) {
return -2;
}
add_file_to_play_player_queue(filename_buff, 0);
return player_mq_couter > 0 ? 0 : -1;
}
void app_player_thread(void *arvg)
{
ice_work_item *player_item=NULL;
int play_ret;
while(1){
ql_rtos_queue_wait(player_queue, (u8*)(&player_item), sizeof(ice_work_item *), 0xFFFFFFFF);
@@ -998,29 +1009,29 @@ void app_player_thread(void *arvg)
if(player_item->data){
// printf("xiaoice player! %s\n",player_item->data);
app_set_app_operation(1);
aofei_mp3_player(player_item->data);
play_ret = aofei_mp3_player_get_result();
app_set_app_operation(1);
if(player_mq_couter>0) player_mq_couter--;
/*
* type=3 只用于对话超时提示。云端连接、HTTP、读取或播放器初始化
* 失败时才排入本地兜底;如果用户已经开始下一轮录音,则丢弃旧提示。
*/
if(play_ret != 0 && player_item->type == 3 &&
!vad_audio_is_recording() && tts_playing_ret() != 2){
printf("cloud tts error play failed:%d, fallback:%s\r\n",
play_ret, LOCAL_TTS_ERROR_PATH);
if(ql_access(LOCAL_TTS_ERROR_PATH, 0) == 0){
add_file_to_play_player_queue(LOCAL_TTS_ERROR_PATH, 0);
}else{
printf("local tts error fallback missing:%s\r\n", LOCAL_TTS_ERROR_PATH);
}
}
if(player_mq_couter==0){
app_set_app_operation(1);
aofei_mp3_player(player_item->data);
play_ret = aofei_mp3_player_get_result();
app_set_app_operation(1);
if(player_mq_couter>0) player_mq_couter--;
/*
* type=3 只用于对话超时提示。云端连接、HTTP、读取或播放器初始化
* 失败时才排入本地兜底;如果用户已经开始下一轮录音,则丢弃旧提示。
*/
if(play_ret != 0 && player_item->type == 3 &&
!vad_audio_is_recording() && tts_playing_ret() != 2){
printf("cloud tts error play failed:%d, fallback:%s\r\n",
play_ret, LOCAL_TTS_ERROR_PATH);
if(ql_access(LOCAL_TTS_ERROR_PATH, 0) == 0){
add_file_to_play_player_queue(LOCAL_TTS_ERROR_PATH, 0);
}else{
printf("local tts error fallback missing:%s\r\n", LOCAL_TTS_ERROR_PATH);
}
}
if(player_mq_couter==0){
printf("player_mq_couter==0\r\n");
if(tts_play_status==2){
@@ -1060,46 +1071,69 @@ void app_player_thread(void *arvg)
int tts_play_interrupt_func(void)
{
int playback_interrupted = 0;
int playback_interrupted = 0;
int leave_message_cancelled = 0;
/* A short press explicitly starts a normal conversation. Cancel an NFC
* leave-message before the generic "already recording" guard, otherwise a
* pending/recording leave-message can leak packetType=4 into this session. */
leave_message_cancelled = vad_cancel_leave_message();
if(leave_message_cancelled){
uint64_t cancel_start;
/* Let the sender task discard buffered PCM and its encoder without
* emitting END. Only then close the old leave-message socket. */
audio_in_task_cancel = 1;
ql_rtos_semaphore_release(sender_sem);
cancel_start = hal_os_up_time_ms();
while(audio_in_task_cancel &&
hal_os_up_time_ms() - cancel_start < OTA_AUDIO_STOP_TIMEOUT_MS){
ql_rtos_task_sleep_ms(20);
}
if(audio_in_task_cancel){
printf("wait leave message sender cancel timeout\r\n");
}
talkingq_ws_disconnect();
}
//如果在vad状态,则直接返回
if(vad_audio_is_recording()||g_mic_working) return -1;
g_mic_working = 1;//等待转为vad状态就清0 或者拾音超时 或者ws没有正常
/* Remember whether this key press is interrupting TTS. tts_playing == 2
* is only a cancellation signal for the old player; it must not leak into
* the next conversation, otherwise prompts and error audio are discarded. */
playback_interrupted = (tts_playing != 0 || player_mq_couter > 0);
barge_in_transition = playback_interrupted;
xiaoice_close_all_audio();
/* Remember whether this key press is interrupting TTS. tts_playing == 2
* is only a cancellation signal for the old player; it must not leak into
* the next conversation, otherwise prompts and error audio are discarded. */
playback_interrupted = (tts_playing != 0 || player_mq_couter > 0);
barge_in_transition = playback_interrupted;
xiaoice_close_all_audio();
talkingq_ws_connect();
if(get_xiaoice_connected_status()!=2) {
/* Let the caller play its local network-error prompt. */
tts_play_status = 0;
tts_playing = 0;
barge_in_transition = 0;
g_mic_working = 0;
return -2;
}
if(playback_interrupted){
printf("barge_in_prompt\n");
/* The old player has finished consuming the cancellation state. */
tts_play_status = 0;
tts_playing = 0;
/* Use the same local-prompt flow as ai_active_zh.mp3. Queue type 2
* starts VAD after playback; fall back to VAD if the file is absent. */
if(app_play_tips_file(19) != 0){
vad_start_func();
}
if(get_xiaoice_connected_status()!=2) {
/* Let the caller play its local network-error prompt. */
tts_play_status = 0;
tts_playing = 0;
barge_in_transition = 0;
g_mic_working = 0;
return -2;
}
if(playback_interrupted){
printf("barge_in_prompt\n");
/* The old player has finished consuming the cancellation state. */
tts_play_status = 0;
tts_playing = 0;
/* Use the same local-prompt flow as ai_active_zh.mp3. Queue type 2
* starts VAD after playback; fall back to VAD if the file is absent. */
if(app_play_tips_file(19) != 0){
vad_start_func();
}
}else{
app_play_tips_file(18);
}
return 0;
return 0;
}
void stop_player_func(void)
@@ -1107,7 +1141,7 @@ void stop_player_func(void)
vad_audio_stop_record();
xiaoice_close_all_audio();
tts_play_status = 0;
tts_play_status = 0;
tts_playing = 0;
}
@@ -1227,19 +1261,19 @@ static void check_connect_thread_entry(void *parameter)
printf("tts_error_set_speech1\n");
vad_set_speech_timeout_flag(1);
/*
* 后台没有返回任何音频 URL 时,也先由设备生成随机云端提示音。
* type=3 会在 HTTP 下载或播放初始化失败时自动切换到本地音频。
*/
{
char cloud_tts_error_url[128] = {0};
if(build_random_tts_error_url(cloud_tts_error_url,
sizeof(cloud_tts_error_url)) == 1){
add_file_to_play_player_queue(cloud_tts_error_url, 3);
}else{
app_play_tips_file(9);
}
}
/*
* 后台没有返回任何音频 URL 时,也先由设备生成随机云端提示音。
* type=3 会在 HTTP 下载或播放初始化失败时自动切换到本地音频。
*/
{
char cloud_tts_error_url[128] = {0};
if(build_random_tts_error_url(cloud_tts_error_url,
sizeof(cloud_tts_error_url)) == 1){
add_file_to_play_player_queue(cloud_tts_error_url, 3);
}else{
app_play_tips_file(9);
}
}
}
#endif
@@ -1258,7 +1292,7 @@ void xiaoice_clear_player_mq(void)
free(player_item->data);
player_item->data = NULL;
}
if(player_mq_couter>0) player_mq_couter--;
if(player_mq_couter>0) player_mq_couter--;
free(player_item);
player_item = NULL;
}
@@ -1284,16 +1318,16 @@ void xiaoice_close_all_audio(void)
timeout = hal_os_up_time_ms();
/* The queue counter also includes the item currently owned by the
* player task. Wait for that item to observe tts_playing == 2 and
* finish cleanup before the cancellation state is reused/reset. */
while(player_mq_couter>0){
/* The queue counter also includes the item currently owned by the
* player task. Wait for that item to observe tts_playing == 2 and
* finish cleanup before the cancellation state is reused/reset. */
while(player_mq_couter>0){
ql_rtos_task_sleep_ms(20);
if(hal_os_up_time_ms()-timeout>OTA_AUDIO_STOP_TIMEOUT_MS){
printf("wait player stop timeout, counter:%d\r\n",
player_mq_couter);
break;
}
if(hal_os_up_time_ms()-timeout>OTA_AUDIO_STOP_TIMEOUT_MS){
printf("wait player stop timeout, counter:%d\r\n",
player_mq_couter);
break;
}
}
}
#endif
@@ -1313,10 +1347,35 @@ void app_sender_thread(void *arvg)
ql_rtos_semaphore_wait(sender_sem, 0xFFFFFFFF);
sender_loop:
if(audio_in_task_cancel){
left = rb_get_space_used(ring_sender);
while(left > 0){
int discard_len = left > PCM_FRAME_LENGTH ? PCM_FRAME_LENGTH : left;
hal_os_rb_read(ring_sender, sender_buf, discard_len, 0);
left = rb_get_space_used(ring_sender);
}
if(shine_init != 0){
shine_close(sh_shine);
sh_shine = NULL;
shine_init = 0;
}
audio_packet_type = 0;
audio_in_task_cancel = 0;
printf("leave message sender cancelled\r\n");
continue;
}
if(!app_get_data_call_net_status()) continue;
if(shine_init==0){
if(!app_get_data_call_net_status()) continue;
/* One recording can release the semaphore many times while the sender
* drains the ring buffer. A stale token must never create an empty
* session after the previous encoder has already been closed. */
left = rb_get_space_used(ring_sender);
if(shine_init == 0 && left <= 0){
continue;
}
if(shine_init==0){
shine_set_config_mpeg_defaults(&(sh_config.mpeg)); //--init sh_config struct
sh_config.wave.channels=PCM_MONO; //PCM_STEREO;//PCM_MONO;
sh_config.wave.samplerate=16000;//valid samplerates: 44100,48000,32000,22050,24000,16000,11025,12000,8000
@@ -1366,10 +1425,13 @@ sender_loop:
sh_shine = NULL;
}
shine_init = 0;
audio_packet_type = 0;
}
shine_init = 0;
audio_packet_type = 0;
/* Discard semaphore tokens already represented by the PCM that this
* session drained. Recording has stopped, so no new frames race here. */
while(ql_rtos_semaphore_wait(sender_sem, 0) == 0) {}
}
}
}
Binary file not shown.
@@ -61,12 +61,12 @@
{
"name": "customer_app",
"format": "raw",
"size": "460KiB"
"size": "384KiB"
},
{
"name": "customer_fs",
"format": "raw",
"size": "256KiB"
"size": "576KiB"
},
{
"name": "nvm",
@@ -56,7 +56,7 @@
{
"name": "customer_fs",
"format": "raw",
"size": "256KiB"
"size": "640KiB"
},
{
"name": "nvm",
+215
View File
@@ -0,0 +1,215 @@
# customer_fs.bin 双系统生成工具
本目录提供 `EG800AKCN_11LG` 项目的 `customer_fs.bin` 自动生成和校验工具,支持
Windows 与 macOS。
当前 `customer_fs` 分区大小为 576 KiB,设备 kernel 只支持 LittleFS 磁盘格式
`0x00020000`(disk 2.0)。所有生成入口都会在输出前执行兼容性检查,避免再次生成
kernel 无法挂载的 disk 2.1 镜像。
## 1. 源文件目录
文件系统的源文件统一存放在:
```text
audio_prompts/customer_fs/
```
当前目录中的 14 个音频文件,全部从已经过实机验证、能够正常挂载的 Windows
`customer_fs.bin` 中提取,并经过重新打包和逐文件回读验证。
设备代码从 `U:/` 根目录访问文件,因此所有文件必须直接放在上述目录中。例如:
```text
正确:audio_prompts/customer_fs/network_connect_zh.mp3
错误:audio_prompts/customer_fs/subdir/network_connect_zh.mp3
```
生成工具会拒绝隐藏文件和子目录,避免把 `.DS_Store` 等无关文件打进正式镜像。
## 2. 如何增删改文件
生成工具采用“根据源目录全量重建”的方式,不是在旧 bin 上追加内容。
- 添加:把新文件直接放进 `audio_prompts/customer_fs/`;
- 替换:用新文件覆盖同名文件;
- 编辑:使用音频工具修改原文件并保存到原位置;
- 删除:从源目录删除文件,下一次生成的 bin 中将不再包含该文件;
- 改名:直接修改源文件名,但必须同步确认业务代码使用的新文件名。
部分音频文件名已经写入业务代码。删除或改名后,如果代码仍然播放原文件,串口会打印:
```text
tips file missing
```
因此,生成工具允许删减文件,但删减前仍需确认业务代码不再使用它。
## 3. Windows 一键生成
在项目根目录执行:
```bat
tools\customer_fs\build_windows.bat
```
Windows 版本使用项目自带并已通过实机验证的工具:
```text
ql-cross-tool\win32\mklfs-v2.exe
```
同时使用 SDK 自带的 Python 执行兼容性校验,不需要另外安装 Python 依赖。
也可以指定自定义源目录和输出文件:
```bat
tools\customer_fs\build_windows.bat 源目录 输出文件
```
例如:
```bat
tools\customer_fs\build_windows.bat ^
audio_prompts\customer_fs ^
output\customer_fs\customer_fs.bin
```
## 4. macOS 一键生成
在项目根目录执行:
```bash
./tools/customer_fs/build_macos.sh
```
首次运行时,脚本会自动创建:
```text
.venv-customer-fs
```
并安装项目固定的依赖版本:
```text
littlefs-python==0.19.0
```
新版 `littlefs-python` 默认会生成 disk 2.1 镜像,不能直接使用它自带的命令行生成。
本项目的 `generate_macos.py` 会显式设置:
```text
disk_version = 0x00020000
```
从而生成当前设备 kernel 可以识别的 disk 2.0 镜像。
也可以指定自定义源目录和输出文件:
```bash
./tools/customer_fs/build_macos.sh 源目录 输出文件
```
例如:
```bash
./tools/customer_fs/build_macos.sh \
audio_prompts/customer_fs \
output/customer_fs/customer_fs.bin
```
## 5. 默认输出位置
Windows 和 macOS 默认都输出到:
```text
output/customer_fs/customer_fs.bin
```
生成时会先写入临时文件:
```text
customer_fs.bin.tmp
```
只有生成和全部校验成功后,临时文件才会替换正式输出。生成或校验失败时,已有正式输出
不会被覆盖。
## 6. 自动门禁检查
每次生成都会检查:
| 检查项 | 要求 |
| --- | ---: |
| 镜像大小 | 589824 字节 |
| LittleFS 磁盘格式 | `0x00020000` |
| Block Size | 4096 字节 |
| Block Count | 144 |
| 第一个 superblock | 必须存在且参数正确 |
| 第二个 superblock | 必须存在且参数正确 |
| 源目录 | 不能为空 |
| 源文件层级 | 只能是根目录文件 |
| 隐藏文件 | 不允许 |
| 文件名 | 必须能在生成镜像中找到 |
也可以单独校验一个镜像:
```bash
python3 tools/customer_fs/verify_customer_fs.py \
--image output/customer_fs/customer_fs.bin \
--source audio_prompts/customer_fs
```
校验成功会输出镜像大小、LittleFS 版本、块参数、源文件数量和 SHA-256。
## 7. 放入正式固件工程
生成并校验成功后,需要显式复制到 SDK 打包位置。
macOS:
```bash
cp output/customer_fs/customer_fs.bin \
ql-config/config/EG800AKCN_11LG/customer_fs.bin
```
Windows:
```bat
copy /Y output\customer_fs\customer_fs.bin ^
ql-config\config\EG800AKCN_11LG\customer_fs.bin
```
替换前建议备份原文件并记录新镜像的 SHA-256。
`customer_fs.bin` 不是当前 APP-only OTA 的升级内容。修改该镜像后,需要重新生成并烧录
包含分区表、kernel、customer_app 和 customer_fs 的完整工厂包,不能只下发 APP OTA。
## 8. 实机验收
Windows 生成工具已经过实机验证。macOS 工具已经通过结构校验、重新挂载和逐文件回读,
首次用于正式版本前仍应至少烧录一台设备验收。
启动日志应直接出现:
```text
[U] mount successfully
```
不能出现:
```text
[U] mount failed (-22), will format fs partition
```
还需要逐一验证镜像中的音频能够通过 `U:/文件名` 正常读取和播放,并记录本次发布使用的
`customer_fs.bin` SHA-256。
## 9. 注意事项
- 不要用十六进制工具把 disk 2.1 镜像的版本字段直接改成 disk 2.0;
- 不要直接使用 `python -m littlefs create`,它当前默认生成 disk 2.1;
- 不要把 `.venv-customer-fs`、`output` 或 `.DS_Store` 提交到仓库;
- Windows 和 macOS 生成镜像的整体 SHA-256 可能不同,这是块分配顺序不同造成的;
- 两个平台产物必须满足相同的 disk 2.0、4096 字节块和 144 块约束;
- 修改、删除或重命名业务音频前,必须同步检查代码中的文件名引用。
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
SOURCE_DIR=${1:-"$ROOT_DIR/audio_prompts/customer_fs"}
OUTPUT_IMAGE=${2:-"$ROOT_DIR/output/customer_fs/customer_fs.bin"}
VENV_DIR=${CUSTOMER_FS_VENV:-"$ROOT_DIR/.venv-customer-fs"}
PYTHON_BOOTSTRAP=${PYTHON3:-python3}
TEMP_IMAGE="${OUTPUT_IMAGE}.tmp"
cleanup() {
rm -f -- "$TEMP_IMAGE"
}
trap cleanup EXIT INT TERM
if [ ! -x "$VENV_DIR/bin/python" ]; then
"$PYTHON_BOOTSTRAP" -m venv "$VENV_DIR"
fi
if ! "$VENV_DIR/bin/python" -c 'import littlefs; raise SystemExit(0 if littlefs.__version__ == "0.19.0" else 1)' 2>/dev/null; then
"$VENV_DIR/bin/python" -m pip install --requirement "$SCRIPT_DIR/requirements.txt"
fi
mkdir -p -- "$(dirname -- "$OUTPUT_IMAGE")"
rm -f -- "$TEMP_IMAGE"
"$VENV_DIR/bin/python" "$SCRIPT_DIR/generate_macos.py" \
--source "$SOURCE_DIR" \
--output "$TEMP_IMAGE"
"$VENV_DIR/bin/python" "$SCRIPT_DIR/verify_customer_fs.py" \
--image "$TEMP_IMAGE" \
--source "$SOURCE_DIR"
mv -f -- "$TEMP_IMAGE" "$OUTPUT_IMAGE"
trap - EXIT INT TERM
printf 'customer_fs ready: %s\n' "$OUTPUT_IMAGE"
+56
View File
@@ -0,0 +1,56 @@
@echo off
setlocal EnableExtensions
set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..\..") do set "ROOT_DIR=%%~fI"
if "%~1"=="" (
set "SOURCE_DIR=%ROOT_DIR%\audio_prompts\customer_fs"
) else (
set "SOURCE_DIR=%~f1"
)
if "%~2"=="" (
set "OUTPUT_IMAGE=%ROOT_DIR%\output\customer_fs\customer_fs.bin"
) else (
set "OUTPUT_IMAGE=%~f2"
)
set "TEMP_IMAGE=%OUTPUT_IMAGE%.tmp"
set "MKLFS=%ROOT_DIR%\ql-cross-tool\win32\mklfs-v2.exe"
set "PYTHON=%ROOT_DIR%\ql-cross-tool\win32\owtoolchain\Python\python.exe"
if not exist "%MKLFS%" (
echo customer_fs generation failed: mklfs-v2.exe not found at "%MKLFS%"
exit /b 1
)
if not exist "%PYTHON%" (
echo customer_fs generation failed: bundled Python not found at "%PYTHON%"
exit /b 1
)
if not exist "%SOURCE_DIR%\" (
echo customer_fs generation failed: source directory not found at "%SOURCE_DIR%"
exit /b 1
)
for %%I in ("%OUTPUT_IMAGE%") do if not exist "%%~dpI" mkdir "%%~dpI"
if exist "%TEMP_IMAGE%" del /q "%TEMP_IMAGE%"
"%MKLFS%" -c "%SOURCE_DIR%" -s 589824 -i "%TEMP_IMAGE%"
if errorlevel 1 goto :failed
"%PYTHON%" "%SCRIPT_DIR%verify_customer_fs.py" ^
--image "%TEMP_IMAGE%" ^
--source "%SOURCE_DIR%"
if errorlevel 1 goto :failed
move /Y "%TEMP_IMAGE%" "%OUTPUT_IMAGE%" >nul
if errorlevel 1 goto :failed
echo customer_fs ready: "%OUTPUT_IMAGE%"
exit /b 0
:failed
if exist "%TEMP_IMAGE%" del /q "%TEMP_IMAGE%"
echo customer_fs generation failed; existing output was not changed.
exit /b 1
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Generate a disk-2.0 LittleFS customer_fs image on macOS."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from littlefs import LittleFS, UserContext
IMAGE_SIZE = 589824
BLOCK_SIZE = 4096
BLOCK_COUNT = 144
DISK_VERSION = 0x00020000
def fail(message: str) -> None:
raise SystemExit(f"customer_fs generation failed: {message}")
def collect_source_files(source: Path) -> list[Path]:
if not source.is_dir():
fail(f"source directory does not exist: {source}")
entries = sorted(source.iterdir(), key=lambda path: path.name.encode("utf-8"))
if not entries:
fail(f"source directory is empty: {source}")
invalid = [entry.name for entry in entries if not entry.is_file() or entry.name.startswith(".")]
if invalid:
fail(
"source must contain only visible root-level files; invalid entries: "
+ ", ".join(invalid)
)
return entries
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
files = collect_source_files(args.source)
context = UserContext(buffsize=IMAGE_SIZE)
fs = LittleFS(
context=context,
mount=False,
block_size=BLOCK_SIZE,
block_count=BLOCK_COUNT,
read_size=16,
prog_size=16,
cache_size=256,
lookahead_size=32,
block_cycles=500,
disk_version=DISK_VERSION,
)
fs.format()
fs.mount()
try:
for source_file in files:
with source_file.open("rb") as source_handle:
with fs.open("/" + source_file.name, "wb") as image_handle:
while chunk := source_handle.read(64 * 1024):
image_handle.write(chunk)
finally:
fs.unmount()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_bytes(context.buffer)
print(f"generated {args.output} from {len(files)} files")
return 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
littlefs-python==0.19.0
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Validate a customer_fs LittleFS image for the EG800AKCN_11LG kernel."""
from __future__ import annotations
import argparse
import hashlib
import struct
import sys
from pathlib import Path
DEFAULT_SIZE = 589824
DEFAULT_DISK_VERSION = 0x00020000
DEFAULT_BLOCK_SIZE = 4096
DEFAULT_BLOCK_COUNT = 144
SUPERBLOCK_MAGIC_OFFSETS = (8, DEFAULT_BLOCK_SIZE + 8)
def fail(message: str) -> None:
raise SystemExit(f"customer_fs verification failed: {message}")
def source_files(source: Path) -> list[Path]:
if not source.is_dir():
fail(f"source directory does not exist: {source}")
entries = sorted(source.iterdir(), key=lambda path: path.name.encode("utf-8"))
if not entries:
fail(f"source directory is empty: {source}")
invalid = [entry.name for entry in entries if not entry.is_file() or entry.name.startswith(".")]
if invalid:
fail(
"source must contain only visible root-level files; invalid entries: "
+ ", ".join(invalid)
)
return entries
def verify_superblock(
image: bytes,
magic_offset: int,
disk_version: int,
block_size: int,
block_count: int,
) -> None:
if image[magic_offset : magic_offset + 8] != b"littlefs":
fail(f"LittleFS magic is missing at 0x{magic_offset:X}")
actual_version, actual_block_size, actual_block_count = struct.unpack_from(
"<III", image, magic_offset + 12
)
if actual_version != disk_version:
fail(
f"disk version at 0x{magic_offset:X} is 0x{actual_version:08X}; "
f"expected 0x{disk_version:08X}"
)
if actual_block_size != block_size:
fail(
f"block size at 0x{magic_offset:X} is {actual_block_size}; "
f"expected {block_size}"
)
if actual_block_count != block_count:
fail(
f"block count at 0x{magic_offset:X} is {actual_block_count}; "
f"expected {block_count}"
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--image", required=True, type=Path)
parser.add_argument("--source", type=Path, help="also check root-level source filenames")
parser.add_argument("--size", type=int, default=DEFAULT_SIZE)
parser.add_argument(
"--disk-version", type=lambda value: int(value, 0), default=DEFAULT_DISK_VERSION
)
parser.add_argument("--block-size", type=int, default=DEFAULT_BLOCK_SIZE)
parser.add_argument("--block-count", type=int, default=DEFAULT_BLOCK_COUNT)
args = parser.parse_args()
if not args.image.is_file():
fail(f"image does not exist: {args.image}")
image = args.image.read_bytes()
if len(image) != args.size:
fail(f"image size is {len(image)}; expected {args.size}")
if args.block_size * args.block_count != args.size:
fail("block size multiplied by block count does not equal image size")
for magic_offset in (8, args.block_size + 8):
verify_superblock(
image,
magic_offset,
args.disk_version,
args.block_size,
args.block_count,
)
files: list[Path] = []
if args.source:
files = source_files(args.source)
missing = [path.name for path in files if path.name.encode("utf-8") not in image]
if missing:
fail("source filenames missing from image: " + ", ".join(missing))
digest = hashlib.sha256(image).hexdigest()
print("customer_fs verified")
print(f" image: {args.image}")
print(f" size: {len(image)}")
print(f" disk_version: 0x{args.disk_version:08X}")
print(f" block_size: {args.block_size}")
print(f" block_count: {args.block_count}")
if files:
print(f" source_files: {len(files)}")
print(f" sha256: {digest}")
return 0
if __name__ == "__main__":
sys.exit(main())
+53
View File
@@ -0,0 +1,53 @@
#include <stdio.h>
#include "ota_url_policy.h"
typedef struct {
const char *url;
int expected;
} url_case_t;
int main(void)
{
static const url_case_t cases[] = {
{"http://101.35.224.118:8080/assets/firmware/app.bin", 1},
{"http://101.35.224.118:8080/assets/firmware/release/app.bin?token=x", 1},
{"http://talkingq.com/app.bin", 1},
{"https://talkingq.com/releases/app.bin", 1},
{"https://ota.talkingq.com/releases/app.bin", 1},
{"https://cdn.cn.talkingq.com:8443/a/B.BIN?token=x", 1},
{"https://OTA.TALKINGQ.COM/app.bin", 1},
{"https://eviltalkingq.com/app.bin", 0},
{"https://talkingq.com.evil.example/app.bin", 0},
{"https://talkingq.com@evil.example/app.bin", 0},
{"https://evil.example@talkingq.com/app.bin", 0},
{"https://a..talkingq.com/app.bin", 0},
{"https://-ota.talkingq.com/app.bin", 0},
{"https://ota-.talkingq.com/app.bin", 0},
{"https://ota.talkingq.com:0/app.bin", 0},
{"https://ota.talkingq.com:65536/app.bin", 0},
{"https://ota.talkingq.com:abc/app.bin", 0},
{"ftp://ota.talkingq.com/app.bin", 0},
{"https://ota.talkingq.com/", 0},
{"https://ota.talkingq.com/app.zip", 0},
{"https://ota.talkingq.com/app.bin/extra", 0},
{"https://ota.talkingq.com/a/../app.bin", 0},
{"https://ota.talkingq.com/app.bin#fragment", 0},
{"http://101.35.224.118:8080/other/app.bin", 0},
{"http://192.168.1.1/app.bin", 0},
};
size_t i;
for (i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) {
int actual = banban_ota_url_is_allowed(cases[i].url);
if (actual != cases[i].expected) {
fprintf(stderr, "case %lu failed: expected=%d actual=%d url=%s\n",
(unsigned long)i, cases[i].expected, actual, cases[i].url);
return 1;
}
}
printf("OTA URL policy tests passed: %lu cases\n",
(unsigned long)(sizeof(cases) / sizeof(cases[0])));
return 0;
}
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Fail-closed checks for the EG800AKCN_11LG OTA-ready factory baseline."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import struct
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LAYOUT = ROOT / "ql-config/quec-project/aboot/config/partition/CRANELRH_QUEC_FLASH_LAYOUT_SS_04M_HX_GNSS.json"
FLASH_TYPE = ROOT / "ql-config/config/QuecFLashType.ini"
CHIP_PLATFORM = ROOT / "ql-config/config/CHIP_PLAT.ini"
LINKER = ROOT / "ql-application/threadx/config/common/app_linkscript.ld"
FS_IMAGE = ROOT / "ql-config/config/EG800AKCN_11LG/customer_fs.bin"
DSP_IMAGE = ROOT / "ql-config/soc_platform/CRANELRH/images/DSP_Reduce_SuLog_Buffer/dsp.bin"
RF_IMAGE = ROOT / "ql-config/config/EG800AKCN_11LG/rf.bin"
GNSS_BOOT_IMAGE = ROOT / "ql-config/config/EG800AKCN_11LG/gnss_boot.pkg"
GNSS_FIRM_IMAGE = ROOT / "ql-config/config/EG800AKCN_11LG/gnss_firm.pkg"
KERNEL_FLAGS = ROOT / "ql-kernel/threadx/config/common/default_dflags_1602.mk"
FOTA_HEADER = ROOT / "ql-application/threadx/common/include/ql_fota.h"
BASELINE_HEADER = ROOT / "ql-application/threadx/interface/custom_app/inc/ota_baseline.h"
BUILD_MACRO = ROOT / "ql-application/threadx/interface/custom_app/lib/hal/build_macro.h"
APP_MAKEFILE = ROOT / "ql-application/threadx/interface/custom_app/Makefile"
MQTT_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/mqtt.c"
FULLOTA2_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/ota_fullfota2.c"
URL_POLICY_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/ota_url_policy.c"
LEGACY_HTTP_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/http_aofei.c"
FLASH_SIZE = 0x400000
EXPECTED = {
"app_start": 0x002E5000,
"app_size": 0x00060000,
"fs_start": 0x00345000,
"fs_size": 0x00090000,
"xip_start": 0x802E5000,
}
FBF_HEADER_ALLOWANCE = 8192
MINIMUM_SYSTEM_MARGIN = 64 * 1024
EXPECTED_FS_SHA256 = "45350804d65fc6ded24caf9b215b71e89f9160d10d4998c5d57d4f4496d6000d"
def fail(message: str) -> None:
raise SystemExit(f"OTA-ready baseline verification failed: {message}")
def parse_size(value: str) -> int:
if value == "-1":
return -1
match = re.fullmatch(r"(\d+)(KiB|MiB)", value)
if not match:
fail(f"unsupported partition size {value!r}")
scale = 1024 if match.group(2) == "KiB" else 1024 * 1024
return int(match.group(1)) * scale
def flattened_tail() -> list[tuple[str, int]]:
data = json.loads(LAYOUT.read_text(encoding="utf-8"))
qspi = next(item for item in data["partitions"] if item["name"] == "qspi")
result: list[tuple[str, int]] = []
for item in qspi["partitions"]:
if item["name"] == "factory":
result.extend((child["name"], parse_size(child["size"])) for child in item["partitions"])
elif item["name"] in {"reserved", "customer_app", "customer_fs", "nvm", "erase_rd", "fota_param"}:
result.append((item["name"], parse_size(item["size"])))
return result
def verify_layout() -> None:
if CHIP_PLATFORM.read_text(encoding="utf-8").strip() != "1605":
fail("this baseline requires CHIP_PLAT.ini=1605")
if FLASH_TYPE.read_text(encoding="utf-8").strip() != "CC11XXW_GPS":
fail("this baseline requires QuecFLashType.ini=CC11XXW_GPS")
tail = flattened_tail()
sizes = dict(tail)
if sizes.get("customer_app") != EXPECTED["app_size"]:
fail("customer_app must be 384 KiB")
if sizes.get("customer_fs") != EXPECTED["fs_size"]:
fail("customer_fs must be 576 KiB")
suffix = 0
starts: dict[str, int] = {}
for name, size in reversed(tail):
if size < 0:
continue
suffix += size
starts[name] = FLASH_SIZE - suffix
if starts.get("customer_app") != EXPECTED["app_start"]:
fail(f"customer_app starts at 0x{starts.get('customer_app', 0):08X}, expected 0x{EXPECTED['app_start']:08X}")
if starts.get("customer_fs") != EXPECTED["fs_start"]:
fail(f"customer_fs starts at 0x{starts.get('customer_fs', 0):08X}, expected 0x{EXPECTED['fs_start']:08X}")
def verify_linker() -> None:
text = LINKER.read_text(encoding="utf-8")
rom = re.search(r"__ROM_START_ADDR\s*=\s*(0x[0-9A-Fa-f]+)", text)
maximum = re.search(r"__MAX_ROM_SIZE\s*=\s*(0x[0-9A-Fa-f]+)", text)
if not rom or int(rom.group(1), 16) != EXPECTED["xip_start"]:
fail("linker ROM address is not 0x802E5000")
if not maximum or int(maximum.group(1), 16) != EXPECTED["app_size"]:
fail("linker ROM limit is not the 384 KiB customer_app partition")
def verify_fs_image() -> None:
image = FS_IMAGE.read_bytes()
if len(image) != EXPECTED["fs_size"]:
fail(f"customer_fs.bin is {len(image)} bytes, expected {EXPECTED['fs_size']}")
digest = hashlib.sha256(image).hexdigest()
if digest != EXPECTED_FS_SHA256:
fail(f"customer_fs.bin SHA-256 is {digest}; expected {EXPECTED_FS_SHA256}")
magic_at = image.find(b"littlefs", 0, 64)
if magic_at < 0:
fail("customer_fs.bin has no LittleFS superblock")
# The global-state tag follows the magic; geometry starts 12 bytes later.
disk_version, block_size, block_count = struct.unpack_from("<III", image, magic_at + 12)
if disk_version >> 16 != 2:
fail(f"unsupported LittleFS disk version 0x{disk_version:08X}")
if block_size != 4096 or block_count != 144:
fail(f"LittleFS geometry is block_size={block_size}, block_count={block_count}; expected 4096/144")
def align_up(value: int, alignment: int) -> int:
return (value + alignment - 1) // alignment * alignment
def verify_system_fit(kernel_path: Path) -> None:
images = (kernel_path, DSP_IMAGE, RF_IMAGE, GNSS_BOOT_IMAGE, GNSS_FIRM_IMAGE)
for path in images:
if not path.is_file():
fail(f"system image does not exist: {path}")
# bootloader=88 KiB; system begins with ptable=4 KiB and rd=16 KiB.
system_end = (88 + 4 + 16) * 1024
for path in images:
system_end = align_up(system_end, 4096)
system_end += path.stat().st_size
system_end = align_up(system_end, 4096)
margin = EXPECTED["app_start"] - system_end
if margin < MINIMUM_SYSTEM_MARGIN:
fail(
f"system ends at 0x{system_end:08X}; margin before customer_app is "
f"{margin} bytes, expected at least {MINIMUM_SYSTEM_MARGIN}"
)
def verify_fota_contract() -> None:
flags = KERNEL_FLAGS.read_text(encoding="utf-8", errors="replace")
if "-DQUEC_APP_FULL_FOTA" not in flags:
fail("kernel no longer enables QUEC_APP_FULL_FOTA")
api = FOTA_HEADER.read_text(encoding="utf-8", errors="replace")
for symbol in (
"ql_fota_init",
"ql_fota_image_write",
"ql_fota_image_flush",
"ql_fota_image_verify",
"ql_fota_set_package_path",
"ql_fota_fixed_internal_flash_upgrade",
):
if symbol not in api:
fail(f"SDK FULLOTA2 API {symbol} is missing")
identity = BASELINE_HEADER.read_text(encoding="utf-8")
if "eg800akcn_11lg-hxgnss-fs576-app2e5000" not in identity:
fail("application layout identity is missing")
generated = BUILD_MACRO.read_text(encoding="utf-8", errors="replace")
if '#define CUSTOM_APP_OTA_LAYOUT_ID "eg800akcn_11lg-hxgnss-fs576-app2e5000"' not in generated:
fail("generated build macro has the wrong OTA layout identity")
if '#define CUSTOM_APP_OTA_BUILD_PROFILE "ota_ready_hxgnss_fs576"' not in generated:
fail("generated build macro has the wrong OTA build profile")
makefile = APP_MAKEFILE.read_text(encoding="utf-8", errors="replace")
if "ota_fullfota2.c" not in makefile:
fail("minimal FULLOTA2 implementation is not part of the application build")
if "ota_url_policy.c" not in makefile:
fail("OTA URL policy is not part of the application build")
mqtt = MQTT_SOURCE.read_text(encoding="utf-8", errors="replace")
if "banban_fullfota2_start" not in mqtt:
fail("MQTT OTA command does not start the local-staging FULLOTA2 path")
if "ql_fullfota_app_start(cmd->params.ota.url)" in mqtt:
fail("MQTT OTA command still invokes the legacy FULLOTA1 path")
if "banban_ota_url_is_allowed" not in mqtt:
fail("MQTT OTA command does not enforce the shared URL policy")
url_policy = URL_POLICY_SOURCE.read_text(encoding="utf-8", errors="replace")
if 'BANBAN_OTA_BASE_DOMAIN "talkingq.com"' not in url_policy:
fail("OTA URL policy no longer allows the talkingq.com domain family")
legacy_http = LEGACY_HTTP_SOURCE.read_text(encoding="utf-8", errors="replace")
if "return ql_fullfota_app_start(url)" in legacy_http:
fail("legacy HTTP polling path can still invoke FULLOTA1")
implementation = FULLOTA2_SOURCE.read_text(encoding="utf-8", errors="replace")
required_steps = (
"ql_fs_free_size('U')",
"ql_fota_fixed_internal_flash_upgrade(1)",
"ql_fota_set_package_path",
"ql_fota_init",
"ql_fota_image_write",
"ql_fota_image_flush",
"ql_stat",
"ql_fota_image_verify",
"ql_power_reset",
)
positions = [implementation.find(step) for step in required_steps]
if any(position < 0 for position in positions) or positions != sorted(positions):
fail("minimal FULLOTA2 prepare/verify/activate sequence is incomplete or reordered")
def verify_app(path: Path) -> None:
if not path.is_file():
fail(f"application binary does not exist: {path}")
size = path.stat().st_size
if size + FBF_HEADER_ALLOWANCE > EXPECTED["app_size"]:
fail(
f"app.bin ({size}) plus {FBF_HEADER_ALLOWANCE}-byte FBF allowance exceeds "
f"customer_app ({EXPECTED['app_size']})"
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--app-bin", type=Path, help="also enforce the application/FBF capacity gate")
parser.add_argument("--kernel-bin", type=Path, help="also enforce the GNSS system/customer_app margin")
args = parser.parse_args()
verify_layout()
verify_linker()
verify_fs_image()
verify_fota_contract()
if args.app_bin:
verify_app(args.app_bin if args.app_bin.is_absolute() else ROOT / args.app_bin)
if args.kernel_bin:
verify_system_fit(args.kernel_bin if args.kernel_bin.is_absolute() else ROOT / args.kernel_bin)
print("OTA-ready baseline verified: hxgnss fs576, app384@0x002E5000, FULLOTA2 available")
return 0
if __name__ == "__main__":
sys.exit(main())