Author SHA1 Message Date
chenjintao 7058f6eaa8 feat: add cross-platform customer fs generator 2026-09-29 16:12:04 +08:00
36 changed files with 625 additions and 1057 deletions
-1
View File
@@ -7,7 +7,6 @@
*.lib filter=lfs diff=lfs merge=lfs -text
*.a filter=lfs diff=lfs merge=lfs -text
*.bin filter=lfs diff=lfs merge=lfs -text
ql-config/config/EG800AKCN_11LG/customer_fs.bin -filter -diff -merge -text
*.img filter=lfs diff=lfs merge=lfs -text
*.axf filter=lfs diff=lfs merge=lfs -text
*.elf filter=lfs diff=lfs merge=lfs -text
+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/
-441
View File
@@ -1,441 +0,0 @@
# OTA 稳定性改造实施路线
## 1. 目标与原则
本路线的核心目标是:先利用现有 App OTA 发布一个风险尽可能小的桥接版本,建立可靠的升级控制、状态上报和灰度发布能力;随后验证系统级 OTA,修复 Kernel 和 Mini FOTA 中无法通过 App OTA 解决的问题;最后根据 Flash 空间和硬件条件决定是否实施 A/B 分区。
实施过程中遵循以下原则:
- 第一次桥接升级尽量小,不同时引入大量业务改动。
- 固件升级和音频资源更新相互独立,任何一方失败都不影响另一方。
- 每个阶段先小批量验证,通过验收门槛后再扩大范围。
- 所有升级必须可追踪、可暂停,并准备串口烧录救援方案。
- 在系统 OTA 和回滚能力验证完成前,不假设设备具备可靠的自动恢复能力。
## 2. 当前能力边界
当前生成的 `fota.bin` 只包含 `customer_app.bin`,属于 App-only OTA,可更新正常业务程序,但不能更新:
- Kernel;
- Mini FOTA 系统;
- Bootloader;
- 分区表;
- `customer_fs` 文件系统镜像;
- GNSS、DSP、RF 等底层固件。
当前设备没有明确的标准 A/B 应用分区。Mini FOTA 会下载并写入 `customer_app`,底层备份和回滚能力尚未得到完整验证。设备进入 FOTA 后,如果下载地址长期不可用,存在持续重启和无法进入正常业务程序的风险。
## 3. 推荐实施顺序
```text
阶段 0:建设发布基础设施和救援能力
↓
阶段 1:发布最小 App 桥接版本
↓
阶段 2:独立上线音频资源同步
↓
阶段 3:实验室验证系统级 OTA
↓
阶段 4:更新 Kernel 和 Mini FOTA
↓
阶段 5:建立启动健康确认和失败恢复
↓
阶段 6:评估并实施 A/B 或备份恢复方案
```
---
## 4. 阶段 0:发布基础设施和救援准备
### 目标
在不升级设备的前提下,降低第一次桥接 OTA 的发布风险。
### 工作内容
1. 固定 OTA 文件地址,发布后禁止覆盖、删除或改名。
2. 对固件下载服务器增加可用性、响应时间和错误率监控。
3. 为每个固件保存以下发布信息:
- 产品型号;
- 硬件版本范围;
- 软件版本;
- 构建时间;
- Git commit;
- 文件大小;
- SHA-256;
- 下载地址。
4. 服务端支持:
- 测试设备白名单;
- 分批灰度;
- 暂停下发;
- 防止重复下发;
- 成功率和失败率统计。
5. 准备专用测试设备和串口烧录救援流程。
6. 使用当前旧 FOTA 测试以下场景:
- 弱网和网络切换;
- DNS 失败;
- 下载前断网;
- 下载过程中断网;
- HTTP 404、500 和超时;
- 重复下发相同升级命令;
- 下载服务器临时不可用。
### 验收门槛
- 可以只向指定测试设备下发 OTA。
- 可以随时停止后续批次。
- 固件文件能够长期稳定访问。
- 能通过设备 ID、任务 ID 和版本号追踪升级结果。
- 已验证串口烧录恢复流程。
---
## 5. 阶段 1:最小 App 桥接版本
### 目标
整理正常 App 内的 OTA 控制逻辑,为后续系统 OTA、资源同步和结果追踪建立可靠入口。
### 5.1 统一 OTA 启动入口
- OTA 只允许由一个明确的业务入口启动。
- `parse_command()` 只解析和校验消息,不执行延时、删除文件、重启或调用 FOTA。
- 删除当前解析阶段和处理阶段可能重复启动 OTA 的设计。
- 检查并处理 `ql_fullfota_app_start()` 的返回值。
- OTA 准备期间拒绝或合并重复升级命令。
### 5.2 建立 OTA 状态机
建议至少包含以下状态:
```text
IDLE
RECEIVED
VALIDATED
ACCEPTED
PRECHECKING
STARTING
REBOOTING
FIRST_BOOT
SUCCEEDED
FAILED
```
状态变化和任务信息应持久化,避免重启后丢失上下文。
### 5.3 校验升级命令
升级前至少校验:
- OTA 任务 ID;
- 目标版本;
- 产品型号;
- 硬件版本;
- 固件类型;
- URL 协议;
- 域名白名单;
- 允许的端口和路径;
- 声明的文件大小;
- 声明的 SHA-256;
- 是否为重复升级;
- 是否属于未授权降级。
说明:正常 App 可以校验命令和服务器声明,但实际固件由重启后的旧 Mini FOTA 下载。在 Mini FOTA 支持 SHA-256 前,App 不能替代它对最终下载内容进行完整哈希校验。
### 5.4 OTA 前置检查
调用 FOTA 前检查:
- SIM 和网络注册状态;
- PDP 是否正常;
- DNS 是否能够解析;
- 下载地址是否可以建立连接;
- HTTP 是否返回 200;
- `Content-Length` 是否合理;
- 电池、电压或外部供电是否满足升级条件;
- 当前是否正在执行通话等关键业务。
推荐执行顺序:
```text
解析命令
→ 校验任务
→ 网络和 URL 预检查
→ 持久化任务信息
→ 向服务器上报已接受
→ 确认消息发送完成
→ 调用 FOTA 接口
→ 重启进入 Mini FOTA
```
预检查只能过滤明显的 URL、404 和服务器故障,不能保证重启后的 Mini FOTA 网络仍然正常。
### 5.5 升级结果补报
新版本第一次启动后读取持久化的 OTA 任务信息,上报:
- 任务 ID;
- 原版本;
- 目标版本;
- 当前实际版本;
- 开始和结束时间;
- 最终结果;
- 最后错误码;
- 是否发生异常重启。
暂时无法联网时,应保留结果并在下次连接后补报。
### 5.6 音频与 OTA 解耦
- 删除 OTA 前调用 `app_file_check_del()` 的行为。
- FBF 下载和 updater 安装期间不清空现有音频。
- 新 APP 启动、版本确认等于 OTA 目标且网络就绪后,才删除服务器管理的音频并立即补下载。
- `barge_in_cute.mp3` 纳入删除和补下载范围;发布前必须确认固定资源地址可用。
- 音频下载失败不得影响固件升级结果。
- 固件升级失败不得破坏原有可用音频。
### 第一版明确不做
- 不修改分区表;
- 不更新 Kernel 或 Mini FOTA;
- 不修改 Bootloader;
- 不替换整个 `customer_fs`;
- 不同时进行大规模业务重构;
- 不引入独立音频 manifest、SHA-256 或签名协议。
### 验收门槛
- 单条 OTA 命令只触发一次 FOTA。
- 无效 URL、错误型号和重复版本不会导致重启。
- 设备重启前能够上报已接受状态。
- 新版本启动后能够补报最终结果。
- OTA 下载失败时原有音频不被删除。
- 新版本确认成功后,服务器管理的音频被刷新且缺失文件能够在后续联网时继续补下载。
- 小批量设备升级成功率达到预定标准后才能扩大灰度。
---
## 6. 阶段 2:独立音频资源同步
### 目标
在不更新整个 `customer_fs.bin` 的情况下,安全地下发新增音频,并确保资源失败不会影响设备核心业务。
### 6.1 资源清单
每个资源至少记录:
- 资源版本;
- 文件名;
- 下载 URL;
- 文件大小;
- SHA-256;
- 是否必需;
- 最低 App 版本。
### 6.2 单文件下载流程
```text
检查 LittleFS 剩余空间
→ 下载到临时文件
→ 检查实际文件大小
→ 校验 SHA-256
→ 校验文件基本格式
→ 原子重命名为正式文件
→ 更新本地资源状态
```
### 6.3 可靠性要求
- 不能仅以“文件存在”判断下载成功。
- 下载中断后不能留下被当作有效资源的半文件。
- 新文件校验失败时保留原文件。
- 音频同步在后台低优先级执行,不阻塞 MQTT 上线。
- 每个文件独立记录重试次数,采用退避重试。
- 写入前计算 LittleFS 剩余空间并保留安全余量。
- 7 条超时提示音只从已校验有效的文件中随机选择。
- 新增资源全部不可用时,继续使用原 `tts_error.mp3` 作为兜底。
### 验收门槛
- 断网、空间不足或校验失败不会破坏现有音频。
- 半文件在重启后能够被识别并重新下载。
- 资源服务器不可用时设备仍能正常连接 MQTT。
- 新增 6 条音频全部写入后,LittleFS 仍保留规定的安全空间。
---
## 7. 阶段 3:实验室验证系统级 OTA
### 目标
确认 SDK 的系统 FOTA 是否能够安全更新 Kernel、Mini FOTA 和相关底层组件。
### 验证内容
1. 确认系统升级包的生成工具、输入文件和版本基线。
2. 确认升级包实际包含和修改的分区。
3. 确认是整包升级还是指定旧版本到新版本的差分升级。
4. 验证暂存空间、RAM 和 Flash 空间是否满足要求。
5. 验证以下异常场景:
- 下载前断网;
- 下载中断网;
- Flash 写入期间断电;
- URL 返回 404 或 500;
- 文件被截断;
- checksum 或签名错误;
- 固件型号不匹配;
- 版本基线不匹配;
- 新系统无法正常启动;
- 连续多次升级失败。
6. 确认失败后设备是否能恢复旧系统或进入明确的恢复模式。
此阶段可以与桥接版本研发并行,但系统 OTA 的正式上线必须在桥接版本稳定之后。
### 验收门槛
- 明确系统包的生成、签名、校验和发布流程。
- 明确升级会修改哪些分区。
- 关键断电和断网测试不会造成设备永久失联。
- 失败原因可以被持久化和上报。
- 已具备批量发布所需的恢复方案。
---
## 8. 阶段 4:更新 Kernel 和 Mini FOTA
### 目标
解决当前 App OTA 无法修复的底层下载、重试、恢复和安全问题。
### 工作内容
1. 持久化 FOTA 失败次数。
2. 设置单次启动和跨重启的最大重试次数。
3. 网络错误使用指数退避,不再主要依靠重启设备进行重试。
4. 区分并记录以下错误:
- 网络注册失败;
- PDP 激活失败;
- DNS 失败;
- TCP/TLS 连接失败;
- HTTP 非 200;
- 下载超时;
- 文件格式错误;
- Flash 擦除或写入失败;
- checksum 或签名失败。
5. 超过重试上限后清除 FOTA 状态,恢复进入旧 App。
6. 正常 App 启动后上报失败阶段和错误码。
7. 评估并启用可靠的断点续传。
8. 支持备用下载地址或可靠的服务端容灾。
9. 使用 HTTPS,并正确校验证书。
10. 对升级包进行数字签名验证。
11. 验证产品型号、硬件版本和升级基线。
“超过重试上限后恢复旧 App”应优先于 A/B 实施,用于直接解决当前可能无限重启的问题。
### 验收门槛
- 永久不可用的 URL 不会导致设备无限重启。
- 达到重试上限后能够恢复正常 App 或进入明确恢复模式。
- 异常原因能够被持久化并在联网后上报。
- 非法、损坏或型号不匹配的固件不会被安装。
---
## 9. 阶段 5:启动健康确认与失败恢复
### 目标
不再把“固件写入完成”直接等同于“升级成功”。
新版本启动后至少检查:
- 文件系统挂载成功;
- 关键配置读取成功;
- 关键线程正常运行;
- 看门狗状态正常;
- 网络能够注册;
- MQTT 能够连接;
- 设备版本和 OTA 任务可以正常上报;
- 没有连续崩溃或反复重启。
只有达到健康条件后才确认升级成功。
如果多次启动仍不能确认健康:
- 具备 A/B 时回滚到旧分区;
- 不具备 A/B 时恢复备份 App 或进入恢复模式。
---
## 10. 阶段 6:A/B 或备份恢复方案
### 优先方案:标准 A/B
```text
app_a
app_b
boot metadata
持久化升级状态
启动健康确认
自动回滚
```
A/B 实施前必须确认:
- Flash 是否能够容纳两个完整 App;
- Bootloader 是否支持分区选择和回滚;
- 分区表如何迁移;
- 现有设备是否能够在线完成分区变更;
- 工厂烧录包和售后工具是否同步支持新布局。
### 空间不足时的次优方案
```text
单 App 分区
+ 下载暂存区
+ 旧 App 备份区
+ 持久化失败计数
+ 启动健康确认
+ 明确恢复模式
```
如果现有设备必须修改分区表才能实现上述方案,不应默认可以通过当前 App OTA 安全完成。部分设备可能需要工厂重新烧录。
---
## 11. 灰度发布建议
每个阶段建议按以下顺序发布:
1. 开发板和可串口救援设备;
2. 内部测试设备;
3. 少量真实网络环境设备;
4. 1% 灰度;
5. 5% 灰度;
6. 20% 灰度;
7. 全量发布。
每一批次至少观察:
- 命令接收率;
- 开始升级率;
- 下载成功率;
- 平均重试次数;
- 最终版本到达率;
- 异常重启率;
- 设备重新上线时间;
- 音频和文件系统异常率。
出现以下任一情况应暂停后续批次:
- 设备持续重启;
- 升级后无法上线;
- 失败率超过预设阈值;
- 出现无法自动恢复的设备;
- 固件文件、下载服务或 MQTT 服务异常;
- 发现型号、版本或升级包对应错误。
## 12. 最终结论
当前最合适的路线不是立即实现 A/B,而是先发布 OTA-ready 工厂基线和最小 FULLOTA2,修复 OTA 入口、命令校验、预检查、状态持久化和结果补报。音频删除已从 OTA 前移到新 APP 版本确认成功且网络就绪之后,并由现有缺失文件下载器补回;后续再升级为带版本、SHA-256 和原子替换的独立资源清单。然后在实验设备上验证系统级 OTA,通过系统 OTA 修复 Mini FOTA 的恢复和安全校验问题;最后根据未来 Flash 空间决定是否采用标准 A/B。
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.
+3 -7
View File
@@ -70,7 +70,7 @@ ota_ready_hxgnss_fs576
- `customer_fs.bin`:从原 256 KiB LittleFS 中提取全部 14 个音频文件,以 4096 字节块、
144 个块重新生成 576 KiB LittleFS;
- 新文件系统镜像 SHA-256:
`51ad32f8196aeab36a62ed436ac65f495ce204436f8ef57efccafc87313b0d3c`。
`45350804d65fc6ded24caf9b215b71e89f9160d10d4998c5d57d4f4496d6000d`。
### 3.2 运行身份
@@ -127,12 +127,8 @@ ql-cross-tool\win32\owtoolchain\Python\python.exe tools\verify_ota_ready_baselin
任一步在设置升级标志前失败,都会关闭 HTTP/FOTA context、删除暂存包、撤销固定升级模式、
清除 pending 状态并把 `fota_start` 恢复为 0。旧 APP 未被擦写,可继续运行并接受下一次命令。
旧实现会在 OTA 下载前删除服务器管理的提示音,下载失败时会提前损失业务资源。当前实现
在下载和 updater 安装阶段保留全部音频;只有新 APP 启动、当前版本确认等于 OTA 目标版本且
数据网络就绪后,才删除服务器管理的提示音并立即调用缺失文件补下载。删除完成标志复用
pending OTA 状态中的 flags 保存,避免 MQTT 重连时反复删除。`barge_in_cute.mp3` 也参与该
刷新流程;固定资源地址必须在发布前确认返回 HTTP 200。若 FBF 暂存空间不足则直接拒绝
OTA,不再通过升级前删除音频强行腾出空间。
旧实现会在 OTA 前删除服务器管理的提示音。fs576 基线有足够空间后,这版不再主动删除
业务资源;若空间不足则明确失败,不通过牺牲业务文件强行升级。
`http_aofei.c` 中未被当前业务调用的旧 HTTP 轮询 OTA 包装函数原本仍可进入 FULLOTA1,
本基线将其显式禁用。生产固件只有 MQTT `version + url` 命令可以启动 OTA,避免两个入口
@@ -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"
@@ -182,12 +182,13 @@ void app_file_check_download(void)
int index = 1;
char url_buff[128]={0x00};
char filename_buff[64]={0x00};
int ret = 0;
int ret = 0, cnt = 0;
for(index=1;index<20;index++){
int cnt = 0;
if(index==4||index==5||index==6||index==7||index==8){
/* barge_in_cute.mp3 is shipped in customer_fs and is not hosted by the
* legacy prompt download service. */
if(index==4||index==5||index==6||index==7||index==8||index==19){
continue;
}
@@ -196,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;
@@ -212,7 +213,9 @@ void app_file_check_del(void)
for(index=1;index<20;index++){
if(index==4||index==5||index==6||index==7||index==8){
/* Keep the bundled barge-in prompt across app-only FOTA. Unlike the
* server-managed prompts, it cannot be restored by the downloader. */
if(index==4||index==5||index==6||index==7||index==8||index==19){
continue;
}
@@ -1,7 +0,0 @@
#ifndef BANBAN_HTTP_DOWNFILE_H
#define BANBAN_HTTP_DOWNFILE_H
void app_file_check_download(void);
void app_file_check_del(void);
#endif
@@ -1,11 +0,0 @@
#ifndef BANBAN_OTA_POST_UPDATE_H
#define BANBAN_OTA_POST_UPDATE_H
/*
* Prepare server-managed audio refresh only after the running APP version
* matches the persisted OTA target. Returns 1 when files were removed for
* refresh, 0 when no refresh is pending, and a negative value on error.
*/
int ota_prepare_post_update_audio_refresh(void);
#endif
@@ -21,9 +21,7 @@
#include "cJSON.h"
#include "build_macro.h"
#include "ql_log.h"
#include "http_downfile.h"
#include "ota_fullfota2.h"
#include "ota_post_update.h"
#include "ota_url_policy.h"
@@ -47,7 +45,6 @@
#define OTA_STATE_TMP_FILE "U:/ota_state.tmp"
#define OTA_STATE_MAGIC 0x4F544131U
#define OTA_STATE_FORMAT_VERSION 1U
#define OTA_STATE_FLAG_AUDIO_REFRESH_PREPARED 0x0001U
#define OTA_PROMPT_PLAY_TIMEOUT_MS 10000
#define OTA_PROMPT_POLL_MS 50
@@ -113,7 +110,7 @@ int xiaoice_get_play_counter(void);
typedef struct {
uint32_t magic;
uint16_t format_version;
uint16_t flags;
uint16_t reserved;
char previous_version[16];
char target_version[16];
uint32_t checksum;
@@ -150,56 +147,12 @@ static uint32_t ota_state_checksum(const ota_state_t *state)
return hash;
}
static int ota_state_write(const ota_state_t *state)
{
ota_state_t persisted;
QFILE *fp;
int ret;
if (state == NULL) {
return -1;
}
persisted = *state;
persisted.checksum = 0;
persisted.checksum = ota_state_checksum(&persisted);
fp = ql_fopen(OTA_STATE_TMP_FILE, "wb+");
if (fp == NULL) {
RLOGE("OTA state open failed: %s", OTA_STATE_TMP_FILE);
return -1;
}
ret = ql_fwrite(&persisted, sizeof(persisted), 1, fp);
if (ret != (int)sizeof(persisted)) {
RLOGE("OTA state write failed: %d", ret);
ql_fclose(fp);
ql_remove(OTA_STATE_TMP_FILE);
return -1;
}
ret = ql_fsync(fp);
ql_fclose(fp);
if (ret != 0) {
RLOGE("OTA state sync failed: %d", ret);
ql_remove(OTA_STATE_TMP_FILE);
return -1;
}
ret = ql_rename(OTA_STATE_TMP_FILE, OTA_STATE_FILE);
if (ret != 0) {
RLOGE("OTA state rename failed: %d", ret);
ql_remove(OTA_STATE_TMP_FILE);
return -1;
}
return 0;
}
static int ota_state_save(const char *previous_version, const char *target_version)
{
ota_state_t state;
QFILE *fp;
int ret;
if (previous_version == NULL || target_version == NULL) {
return -1;
@@ -210,7 +163,38 @@ static int ota_state_save(const char *previous_version, const char *target_versi
state.format_version = OTA_STATE_FORMAT_VERSION;
strncpy(state.previous_version, previous_version, sizeof(state.previous_version) - 1);
strncpy(state.target_version, target_version, sizeof(state.target_version) - 1);
return ota_state_write(&state);
state.checksum = ota_state_checksum(&state);
fp = ql_fopen(OTA_STATE_TMP_FILE, "wb+");
if (fp == NULL) {
RLOGE("OTA state open failed: %s", OTA_STATE_TMP_FILE);
return -1;
}
ret = ql_fwrite(&state, sizeof(state), 1, fp);
if (ret != (int)sizeof(state)) {
RLOGE("OTA state write failed: %d", ret);
ql_fclose(fp);
ql_remove(OTA_STATE_TMP_FILE);
return -1;
}
ret = ql_fsync(fp);
ql_fclose(fp);
if (ret != 0) {
RLOGE("OTA state sync failed: %d", ret);
ql_remove(OTA_STATE_TMP_FILE);
return -1;
}
ret = ql_rename(OTA_STATE_TMP_FILE, OTA_STATE_FILE);
if (ret != 0) {
RLOGE("OTA state rename failed: %d", ret);
ql_remove(OTA_STATE_TMP_FILE);
return -1;
}
return 0;
}
static int ota_state_load(ota_state_t *state)
@@ -312,39 +296,6 @@ static int ota_compare_version(const char *left, const char *right, int *result)
return 0;
}
int ota_prepare_post_update_audio_refresh(void)
{
ota_state_t state;
char current_version[64] = {0};
int compare_result;
if (ota_state_load(&state) != 0) {
return 0;
}
hal_os_get_build_ver(current_version);
if (ota_compare_version(current_version, state.target_version,
&compare_result) != 0 ||
compare_result != 0) {
return 0;
}
if ((state.flags & OTA_STATE_FLAG_AUDIO_REFRESH_PREPARED) != 0) {
return 0;
}
printf("OTA target APP is running; refreshing managed audio files\r\n");
app_file_check_del();
state.flags |= OTA_STATE_FLAG_AUDIO_REFRESH_PREPARED;
if (ota_state_write(&state) != 0) {
RLOGE("mark post-update audio refresh prepared failed");
return -1;
}
return 1;
}
/* ==================== 解析函数 ==================== */
@@ -19,8 +19,6 @@
#include "ql_ping.h"
#include "ql_nw.h"
#include "ql_atcmd.h"
#include "http_downfile.h"
#include "ota_post_update.h"
//#define NTP_SERVER_DOMAIN "ntp.aliyun.com"
@@ -106,7 +104,6 @@ static void ql_data_call_cb(int profile_idx, int nw_status)
void ql_datacall_demo_thread(void *arvg)
{
int ret = 0;
int refresh_ret = 0;
int file_valid = 0;
char ip4_addr_str[16] = {0};
struct ql_data_call_info info;
@@ -158,11 +155,7 @@ void ql_datacall_demo_thread(void *arvg)
ql_set_lwip_dns(1, "223.5.5.5","114.114.114.144");
ip4_addr = info.v4.addr.ip;
refresh_ret = ota_prepare_post_update_audio_refresh();
if(refresh_ret < 0){
printf("post-update audio refresh preparation failed\r\n");
}
app_file_check_download();
if(g_file_valid==0){
@@ -3,9 +3,8 @@
#include <string.h>
#include "ql_rtos.h"
#include "ql_application.h"
#include "ql_audio.h"
#include "ql_dev.h"
#include "ql_iic.h"
#include "ql_audio.h"
#include "ql_iic.h"
#include "ql_gpio.h"
#include "app_inc.h"
#include "ql_fs.h"
@@ -49,7 +48,6 @@ static volatile u32 g_nfc_tx_mode_deadline = 0;
#define NFC_TYPE2_READ_SIZE 16
#define NFC_TYPE2_MEMORY_SIZE 160
#define NFC_TYPE2_NDEF_OFFSET 16
#define NFC_IMEI_DIGITS 15
#define NFC_IRQ_TX 0x40
#define NFC_IRQ_RX 0x20
@@ -175,7 +173,6 @@ static void nfc_type2_build_ndef(void)
{
char device_id[32] = {0};
char serial_number[32] = {0};
char imei[32] = {0};
char json[128];
int json_len;
u8 *ndef;
@@ -187,17 +184,10 @@ static void nfc_type2_build_ndef(void)
if (hal_os_get_device_sn(serial_number) != 0) {
strcpy(serial_number, "unknown");
}
if (ql_dev_get_imei(imei, sizeof(imei)) != QL_DEV_SUCCESS ||
imei[0] == '\0') {
strcpy(imei, "unknown");
} else {
imei[NFC_IMEI_DIGITS] = '\0';
}
json_len = snprintf(json, sizeof(json),
"{\"device_id\":\"%s\",\"serial_number\":\"%s\","
"\"imei\":\"%s\"}",
device_id, serial_number, imei);
"{\"device_id\":\"%s\",\"serial_number\":\"%s\"}",
device_id, serial_number);
if (json_len < 0) {
json_len = 0;
} else if (json_len >= (int)sizeof(json)) {
@@ -43,9 +43,7 @@ static uint8_t vad_leave_message_flag = 0;//留言模式vad只做单次
static Fvad *fvad_inst = NULL;
extern struct ringbuffer *ring_sender;
extern ql_sem_t sender_sem;
extern void conversation_no_speech(void);
extern void conversation_record_begin(void);
extern void conversation_cancel_wait(void);
extern unsigned char wss_connect_start; //表示vad拾音结束 开始等待后台返回 如果超过5s没有返回 则报超时语音
// 配置
static int sample_rate = 16000; // FVAD 支持的采样率:8k/16k/32k/48k
@@ -91,12 +89,6 @@ void vad_tts_playback_finished()
vad_tts_playback_finished_status = 1;
}
void vad_reset_conversation_flags(void)
{
no_speech_timeout = 0;
vad_tts_playback_finished_status = 0;
}
void vad_set_speech_timeout_flag(uint8_t value)
{
no_speech_timeout = value;
@@ -224,7 +216,7 @@ int vad_audio_stop(void) {
ql_audio_record_close(record_hd, 1);
/* Reply timeout starts in the sender only after END is submitted. */
wss_connect_start = 1;//用于判断后台响应超时
key_start_leave = 0;
//ql_rtos_semaphore_release(sender_sem);
@@ -234,7 +226,6 @@ int vad_audio_stop(void) {
// 停止录音
int vad_audio_stop_no_wait_resp(void) {
conversation_cancel_wait();
printf("Audio recording stopped no_wait_resp, frames: %d, speech: %d",
frame_count, speech_frame_count);
@@ -243,7 +234,7 @@ int vad_audio_stop_no_wait_resp(void) {
ql_audio_record_close(record_hd, 1);
wss_connect_start = 0;
return 0;
}
@@ -251,7 +242,6 @@ int vad_audio_stop_no_wait_resp(void) {
// 触发录音(按键触发)
void vad_audio_trigger_record(void) {
conversation_record_begin();
g_triggered = true;
printf("Record triggered");
}
@@ -459,7 +449,6 @@ void vad_audio_process(void) {
if ((now - no_sound_start_time) > VAD_NO_SOUND_TIMEOUT_MS) {
printf("No speech for timeout, stopping");
no_speech_timeout = 1;
conversation_no_speech();
set_state(STATE_WAITING_COMMAND);
vad_audio_stop();
buffer_offset = 0;
@@ -52,62 +52,7 @@ static int16 *ppbuff=mp3_buff;
static int onmessage_status = -1;
volatile int tts_playing = 0;
/* Reply waits and no-speech prompts belong to one listening round. */
static ql_mutex_t conversation_mutex = NULL;
static volatile unsigned int conversation_round = 0;
static unsigned int wait_round = 0;
static unsigned int sender_round = 0;
static uint64_t reply_wait_start_ms = 0;
enum { WAIT_NONE, WAIT_NO_SPEECH, WAIT_REPLY, WAIT_SEND_FAILURE };
static int conversation_wait = WAIT_NONE;
static int timeout_action_busy = 0;
static int round_response_received = 0;
static unsigned int active_session_round = 0;
static int active_session_valid = 0;
static uint64_t startup_start_ms = 0;
static void conversation_lock(void)
{
ql_rtos_mutex_lock(conversation_mutex, 0xFFFFFFFF);
}
static void conversation_unlock(void)
{
ql_rtos_mutex_unlock(conversation_mutex);
}
/* Caller holds the mutex; ignored recording keys must not call this. */
static void conversation_begin_round(void)
{
conversation_round++;
conversation_wait = WAIT_NONE;
round_response_received = 0;
active_session_valid = 0;
startup_start_ms = hal_os_up_time_ms();
}
void conversation_record_begin(void)
{
conversation_lock();
if (!g_mic_working) conversation_begin_round();
g_mic_working = 1;
conversation_unlock();
}
void conversation_cancel_wait(void)
{
conversation_lock();
conversation_wait = WAIT_NONE;
conversation_unlock();
}
void conversation_no_speech(void)
{
conversation_lock();
wait_round = conversation_round;
conversation_wait = WAIT_NO_SPEECH;
conversation_unlock();
}
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. */
@@ -128,7 +73,6 @@ 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);
extern void vad_reset_conversation_flags(void);
void sendAuthentication(void);
void vad_start_func(void);
@@ -139,8 +83,7 @@ int audio_packet_type = 0;
typedef struct ice_work_item_s
{
int type; //0-tts_out 1-session_continue 2-session_end 3-云端超时提示(失败时播放本地兜底) 4-本地超时兜底
unsigned int timeout_round; /* Timeout items (type 3/4) use this cancellation token. */
int type; //0-tts_out 1-session_continue 2-session_end 3-云端超时提示(失败时播放本地兜底)
int error;
int del_url;
void *data;
@@ -324,38 +267,37 @@ static int randomize_tts_error_url(char *url, size_t url_size)
return build_random_tts_error_url(url, url_size);
}
int add_file_to_play_player_queue(char *path, int type)
void add_file_to_play_player_queue(char *path, int type)
{
ice_work_item *item = NULL;
// 安全修复:检查path参数有效性
if(path == NULL || strlen(path) == 0){
printf("add_file_to_play_player_queue: invalid path\n");
return -1;
return;
}
item = malloc(sizeof(ice_work_item));
if(item == NULL){
printf("add_file_to_play_player_queue: malloc item failed\n");
return -1;
return;
}
item->data = malloc(strlen(path)+1);
if(item->data == NULL){
printf("add_file_to_play_player_queue: malloc data failed\n");
free(item);
return -1;
return;
}
item->type = type;
item->timeout_round = conversation_round;
// 安全修复:使用strncpy替代sprintf防止溢出
snprintf((char *)item->data, strlen(path)+1, "%s", path);
printf("play file:%s\r\n", (char *)item->data);
if (ql_rtos_queue_release(player_queue, sizeof(item), &item, 0) != 0)
if (ql_rtos_queue_release(player_queue, sizeof(item), &item, 0xFFFFFFFF) != 0)
{
printf("player_mq rt_mq_send ERR\n");
free(item->data);
@@ -363,18 +305,16 @@ int add_file_to_play_player_queue(char *path, int type)
free(item);
item = NULL;
return -1;
}else{
player_mq_couter++;
printf("player_mq_couter:%d\r\n", player_mq_couter);
return 0;
}
}
//如果录音后 超过10s 还没有收到信息 提示本轮对话结束
static void onmessage_text_locked(rws_socket socket, const char *text, const unsigned int len)
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");
@@ -408,6 +348,7 @@ static void onmessage_text_locked(rws_socket socket, const char *text, const uns
}else if(strstr(text, "END_ACK")||strstr(text, "START_ACK")){
}else{//TTS_URL:audio_url
wss_connect_start = 0;
char *ret_str1 = strstr(text, "https://");
char *ret_str2 = NULL;
size_t url_len = 0;
@@ -461,11 +402,7 @@ static void onmessage_text_locked(rws_socket socket, const char *text, const uns
tts_play_status = 2;
}
conversation_wait = WAIT_NONE;
round_response_received = 1;
if(is_tts_error_url == 1 || is_message_ok_url){
if(is_tts_error_url == 1 || is_message_ok_url){
printf("tts_error or message_ok\n");
vad_set_speech_timeout_flag(1);
}
@@ -481,81 +418,6 @@ static void onmessage_text_locked(rws_socket socket, const char *text, const uns
}
/* Untagged error prompts cannot identify an old server request. At least
* reject them throughout local startup and before this round sends START. */
static int conversation_accept_voice_message(const char *text)
{
int voice = strstr(text, "TTS_") != NULL ||
strstr(text, "END_ACK") != NULL ||
strstr(text, "START_ACK") != NULL ||
strstr(text, "/tts_error") != NULL ||
strstr(text, "/tts_err") != NULL;
return !voice || (active_session_valid &&
active_session_round == conversation_round && !g_mic_working);
}
/* Recognize the device's 32-character session-id format, including URL
* underscores. This is a candidate check, not a generic protocol parser. */
static int conversation_find_session_id(const char *text, unsigned int len, char *id)
{
unsigned int i, j, n;
for (i = 0; i < len; i++) {
if (text[i] < '0' || text[i] > '9') continue;
n = 0;
for (j = i; j < len && n < 32; j++) {
char ch = text[j];
if (ch == '_') continue;
if (n < 14 ? (ch < '0' || ch > '9') :
!((ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'Z'))) break;
id[n++] = ch;
}
if (n == 32 && (j == len ||
!((text[j] >= '0' && text[j] <= '9') ||
(text[j] >= 'A' && text[j] <= 'Z') ||
(text[j] >= 'a' && text[j] <= 'z')))) {
id[32] = '\0';
return 1;
}
}
id[0] = '\0';
return 0;
}
static void conversation_log_error_message(const char *text, unsigned int len)
{
char candidate[33];
unsigned int offset;
int found = conversation_find_session_id(text, len, candidate);
int field = strstr(text, "sessionId") != NULL ||
strstr(text, "session_id") != NULL || strstr(text, "sessionID") != NULL;
printf("[WSS-ERR-ID] round=%u session_valid=%d last_session=%s field=%d candidate=%s match_last=%d len=%u\n",
conversation_round, active_session_valid, sessionId, field,
found ? candidate : "none", found && strcmp(candidate, sessionId) == 0, len);
/* Log before URL randomization and before rejecting stale replies. */
for (offset = 0; offset < len; offset += 128) {
unsigned int chunk = len - offset;
if (chunk > 128) chunk = 128;
printf("[WSS-ERR-RAW] offset=%u/%u %.*s\n", offset, len,
(int)chunk, text + offset);
}
}
static void onmessage_text(rws_socket socket, const char *text, const unsigned int len)
{
conversation_lock();
if (strstr(text, "tts_error") != NULL || strstr(text, "tts_err") != NULL ||
strstr(text, "PROMPT_SOUND_URL") != NULL) {
conversation_log_error_message(text, len);
}
if (!conversation_accept_voice_message(text)) {
printf("drop old voice reply:round=%u startup=%d\n",
conversation_round, g_mic_working);
} else {
onmessage_text_locked(socket, text, len);
}
conversation_unlock();
}
static void onmessage_bin(rws_socket socket, const void * data, const unsigned int len)
{
LOG_D("message(bin): %d ", len);
@@ -721,10 +583,12 @@ int talkingq_ws_send_bin(uint8_t *senddata, int len)
// LOG_D("talkingq_wss_send_bin, len = %d", len);
return rws_socket_send_bin(app->socket, senddata, len,2,rws_true) ? 0 : -1;
rws_socket_send_bin(app->socket, senddata, len,2,rws_true);
return 0;
}
int sendRecordingAudioPacket(AudioPacket *packet);
void sendRecordingAudioPacket(AudioPacket *packet);
/*串口AT指令做设置*/
void generateDeviceId(void) {
@@ -920,9 +784,7 @@ void generateSessionId(void)
#if 1
void sendStartPacket(void) {
conversation_lock();
generateSessionId();
active_session_round = conversation_round;
/* sessionId now identifies the new conversation. Late URLs from the old
* conversation will fail the existing session-id check below. */
barge_in_transition = 0;
@@ -934,8 +796,7 @@ void sendStartPacket(void) {
startPacket.packetType = 1;
startPacket.dataSize = 0;
active_session_valid = sendRecordingAudioPacket(&startPacket) == 0;
conversation_unlock();
sendRecordingAudioPacket(&startPacket);
// rt_kprintf("sendStartPacket, seq=%u\r\n", startPacket.sequenceNumber);
}
@@ -945,16 +806,7 @@ void sendEndPacket(void) {
endPacket.packetType = 2;
endPacket.dataSize = 0;
conversation_lock();
if (sender_round == conversation_round) {
int end_ret = sendRecordingAudioPacket(&endPacket);
if (!round_response_received) {
wait_round = sender_round;
reply_wait_start_ms = hal_os_up_time_ms();
conversation_wait = end_ret == 0 ? WAIT_REPLY : WAIT_SEND_FAILURE;
}
}
conversation_unlock();
sendRecordingAudioPacket(&endPacket);
// rt_kprintf("sendEndPacket, seq=%u\r\n", endPacket.sequenceNumber);
}
@@ -1010,13 +862,13 @@ void sendAuthentication()
cJSON_Delete(sendAuth);
}
int sendRecordingAudioPacket(AudioPacket *packet) {
void sendRecordingAudioPacket(AudioPacket *packet) {
uint8_t *sendBuffer=NULL;
sendBuffer = malloc(DEVICE_ID_SIZE + SESSION_ID_SIZE + sizeof(uint32_t) * 2 + sizeof(uint32_t) + packet->dataSize);
if(sendBuffer==NULL){
RLOGE("sendBuffer NULL");
return -1;
return;
}
size_t headerSize = DEVICE_ID_SIZE + SESSION_ID_SIZE + sizeof(packet->sequenceNumber)
@@ -1045,9 +897,8 @@ int sendRecordingAudioPacket(AudioPacket *packet) {
// memcpy(recorder->save_buf, (void *)sendBuffer, recorder->save_len);
//record_msg_send(recorder, RECORD_MSG_DATA, recorder->save_len);
int send_ret = talkingq_ws_send_bin((uint8_t*)sendBuffer, totalSize);
talkingq_ws_send_bin((uint8_t*)sendBuffer, totalSize);
free(sendBuffer);
return send_ret;
// rt_kprintf("sendRecordingAudioPacket - Device ID: %s, Session ID: %s\n", deviceId, sessionId);
}
#endif
@@ -1091,9 +942,9 @@ int app_play_tips_file(int index)
if(file_valid_check!=2){
if(index==18 || index==19){
return add_file_to_play_player_queue(filename_buff, 2);
add_file_to_play_player_queue(filename_buff, 2);
}else{
return add_file_to_play_player_queue(filename_buff, 0);
add_file_to_play_player_queue(filename_buff, 0);
}
}else{
return -2;
@@ -1145,20 +996,6 @@ int app_play_ota_tips_file(void)
return player_mq_couter > 0 ? 0 : -1;
}
static void conversation_finish_startup_prompt(unsigned int round, int result)
{
int start;
conversation_lock();
start = round == conversation_round && g_mic_working && tts_playing != 2;
if (start) startup_start_ms = hal_os_up_time_ms();
conversation_unlock();
if (start) {
printf("voice startup prompt finished:round=%u result=%d\n", round, result);
/* Queue fullness must not suppress this prompt's startup action. */
vad_start_func();
}
}
void app_player_thread(void *arvg)
{
ice_work_item *player_item=NULL;
@@ -1168,17 +1005,6 @@ void app_player_thread(void *arvg)
ql_rtos_queue_wait(player_queue, (u8*)(&player_item), sizeof(ice_work_item *), 0xFFFFFFFF);
if(player_item!=NULL){
int stale_timeout;
conversation_lock();
stale_timeout = (player_item->type == 2 || player_item->type == 3 || player_item->type == 4) &&
player_item->timeout_round != conversation_round;
conversation_unlock();
if (stale_timeout) {
if (player_mq_couter > 0) player_mq_couter--;
free(player_item->data);
free(player_item);
continue;
}
// if(player_item->type==0){//local mp3 file
if(player_item->data){
// printf("xiaoice player! %s\n",player_item->data);
@@ -1194,22 +1020,17 @@ void app_player_thread(void *arvg)
* type=3 只用于对话超时提示。云端连接、HTTP、读取或播放器初始化
* 失败时才排入本地兜底;如果用户已经开始下一轮录音,则丢弃旧提示。
*/
conversation_lock();
if(play_ret != 0 && player_item->type == 3 &&
!vad_audio_is_recording() && !g_mic_working &&
player_item->timeout_round == conversation_round &&
tts_playing_ret() != 2){
!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, 4);
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);
}
}
conversation_unlock();
if(player_mq_couter==0){
printf("player_mq_couter==0\r\n");
@@ -1231,11 +1052,11 @@ void app_player_thread(void *arvg)
if(player_item->type==1){
printf("vad_leave_message\r\n");
vad_leave_message();
}else if(player_item->type==2){
// vad_tts_playback_finished();
vad_start_func();
}
}
if (player_item->type == 2) {
conversation_finish_startup_prompt(player_item->timeout_round, play_ret);
}
// }
}
if(player_item->data){
@@ -1248,24 +1069,6 @@ void app_player_thread(void *arvg)
}
}
static int conversation_try_begin(void)
{
conversation_lock();
if(vad_audio_is_recording() || g_mic_working || shine_init || timeout_action_busy) {
printf("voice key ignored:round=%u vad=%d startup=%d sender=%d timeout=%d\n",
conversation_round, vad_audio_is_recording(), g_mic_working,
shine_init, timeout_action_busy);
conversation_unlock();
return -1;
}
conversation_begin_round();
vad_reset_conversation_flags();
g_mic_working = 1;
conversation_unlock();
return 0;
}
int tts_play_interrupt_func(void)
{
int playback_interrupted = 0;
@@ -1294,9 +1097,9 @@ int tts_play_interrupt_func(void)
}
//如果在vad状态,则直接返回
if (conversation_try_begin() != 0) return -1;
if(vad_audio_is_recording()||g_mic_working) return -1;
/* g_mic_working reserves startup until VAD enters recording. */
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
@@ -1327,13 +1130,7 @@ int tts_play_interrupt_func(void)
vad_start_func();
}
}else{
if (app_play_tips_file(18) != 0) {
conversation_lock();
g_mic_working = 0;
conversation_wait = WAIT_NONE;
conversation_unlock();
return -2;
}
app_play_tips_file(18);
}
return 0;
@@ -1356,65 +1153,9 @@ void vad_start_func(void)
vad_audio_trigger_record();
}
static void conversation_check_timeout(void)
{
int expired = WAIT_NONE;
unsigned int expired_round = 0;
conversation_lock();
/* A lost prompt/trigger must not leave all future keys permanently blocked.
* Never recover during an active player, upload or recording. */
if (g_mic_working && !vad_audio_is_recording() && !shine_init &&
player_mq_couter == 0 && !timeout_action_busy &&
hal_os_up_time_ms() - startup_start_ms > 30000) {
printf("recover stalled voice startup:round=%u\n", conversation_round);
g_mic_working = 0;
conversation_wait = WAIT_NONE;
active_session_valid = 0;
vad_reset_conversation_flags();
}
if (conversation_wait != WAIT_NONE &&
wait_round == conversation_round &&
!vad_audio_is_recording() && !g_mic_working &&
!timeout_action_busy && fota_start != 1 &&
(conversation_wait == WAIT_NO_SPEECH ||
conversation_wait == WAIT_SEND_FAILURE ||
hal_os_up_time_ms() - reply_wait_start_ms > 5000)) {
expired = conversation_wait;
expired_round = wait_round;
conversation_wait = WAIT_NONE;
timeout_action_busy = 1;
}
conversation_unlock();
if (expired != WAIT_NONE) {
char cloud_tts_error_url[128] = {0};
/* Reserve the transition, without holding a mutex while
* disconnect joins a socket thread/calls callbacks. */
if (expired != WAIT_NO_SPEECH) talkingq_ws_disconnect();
conversation_lock();
if (expired_round == conversation_round) {
printf("conversation timeout:round=%u reason=%s\n",
expired_round,
expired == WAIT_NO_SPEECH ? "no_speech" :
(expired == WAIT_REPLY ? "reply" : "send_failure"));
tts_playing = 0;
tts_play_status = 2;
audio_packet_type = 0;
vad_set_speech_timeout_flag(1);
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);
}
}
timeout_action_busy = 0;
conversation_unlock();
}
}
static void check_connect_thread_entry(void *parameter)
{
uint32 time_counter = 0;
uint32 cfun_recorder = 0;
int sim_status = 0;
int per_socket_status = 0;
@@ -1506,7 +1247,34 @@ static void check_connect_thread_entry(void *parameter)
//}
#endif
#if 1
conversation_check_timeout();
//如果后台10s以上没有反馈 则进行重连
if(wss_connect_start==1){
wss_connect_start = 2;
time_counter = ql_rtos_get_systicks();
}else if(wss_connect_start==2 && (ql_rtos_get_systicks()-time_counter)>5000/5){
wss_connect_start = 0;
tts_playing = 0;
audio_packet_type = 0;
talkingq_ws_disconnect();
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);
}
}
}
#endif
ql_rtos_task_sleep_ms(3000);
@@ -1540,9 +1308,9 @@ void xiaoice_close_all_audio(void)
#if 1
if(tts_playing){
tts_playing = 2;
sendInterruptPacket();
}
if (tts_playing || player_mq_couter > 0) tts_playing = 2;
if(player_mq_couter>0){
@@ -1617,9 +1385,6 @@ sender_loop:
sh_config.mpeg.bitr=16; //64kbps for 16k //32kbps for 8k samplerate
sh_shine=shine_initialise(&sh_config);
shine_init = 1;
conversation_lock();
sender_round = conversation_round;
conversation_unlock();
talkingq_ws_connect();
sendStartPacket();
}
@@ -1714,7 +1479,6 @@ void app_check_connect_sem_release(void)
int app_ws_talkingq_init()
{
if (ql_rtos_mutex_create(&conversation_mutex) != 0) return -1;
//one second = 640*50 = 32000
ring_sender = hal_os_rb_create(32000*5);
ql_rtos_semaphore_create(&sender_sem, 0);
+1 -1
View File
@@ -3,7 +3,7 @@ Number_of_Images = 1
1_Image_Enable = 1
1_Image_Image_ID = 0x30
1_Image_Path = customer_app.bin
1_Image_Flash_Entry_Address = 0x00002E5000
1_Image_Flash_Entry_Address = 0x0000322000
1_Image_ID_Name = 1
Binary file not shown.
+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())
-137
View File
@@ -1,137 +0,0 @@
"""Run firmware timeout/round functions on host with scripted RTOS and transport."""
from pathlib import Path
import subprocess
import tempfile
root = Path(__file__).resolve().parents[1]
source = (root / 'ql-application/threadx/interface/custom_app/ws_talkingq.c').read_text()
def function(name):
start = source.index(name + '(')
start = source.rfind('\n', 0, start) + 1
opening = source.index('{', start)
depth = 1
end = opening + 1
while depth:
depth += (source[end] == '{') - (source[end] == '}')
end += 1
return source[start:end] + '\n'
state_start = source.index('static ql_mutex_t conversation_mutex')
state_end = source.index('static volatile int player_mq_couter')
harness = r'''
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stddef.h>
#include <string.h>
typedef void *ql_mutex_t;
static int g_mic_working, recording, shine_init, fota_start;
static int tts_playing, tts_play_status, audio_packet_type, no_speech_timeout;
static int queued, disconnected, flags_reset, mutex_locked, player_mq_couter, vad_starts;
static uint64_t now;
static unsigned int gSequenceNumber;
static int end_result;
typedef struct { unsigned int sequenceNumber; int packetType, dataSize; } AudioPacket;
static int ql_rtos_mutex_lock(ql_mutex_t m, unsigned int t) {
(void)m; (void)t; assert(!mutex_locked); mutex_locked = 1; return 0;
}
static void ql_rtos_mutex_unlock(ql_mutex_t m) { (void)m; assert(mutex_locked); mutex_locked = 0; }
static int vad_audio_is_recording(void) { return recording; }
static uint64_t hal_os_up_time_ms(void) { return now; }
static void vad_set_speech_timeout_flag(int value) { no_speech_timeout = value; }
static void vad_reset_conversation_flags(void) { no_speech_timeout = 0; flags_reset++; }
static int build_random_tts_error_url(char *p, size_t n) { (void)p; (void)n; return 1; }
static void add_file_to_play_player_queue(char *p, int type) { (void)p; assert(type == 3); queued++; }
static void vad_start_func(void) { vad_starts++; recording = 1; g_mic_working = 0; }
static void app_play_tips_file(int n) { (void)n; assert(0); }
static int conversation_try_begin(void);
static void talkingq_ws_disconnect(void) {
assert(!mutex_locked); disconnected++;
/* A key racing a committed timeout cannot start inside disconnect. */
assert(conversation_try_begin() == -1);
}
static int sendRecordingAudioPacket(AudioPacket *p) { assert(p->packetType == 2); return end_result; }
'''
harness += source[state_start:state_end]
for name in ['conversation_try_begin', 'sendEndPacket', 'conversation_check_timeout', 'conversation_accept_voice_message', 'conversation_finish_startup_prompt', 'conversation_find_session_id']:
harness += function(name)
harness += r'''
static void reset(void) {
g_mic_working = recording = shine_init = fota_start = 0;
conversation_round = wait_round = sender_round = 0;
conversation_wait = WAIT_NONE; timeout_action_busy = round_response_received = 0;
queued = disconnected = flags_reset = no_speech_timeout = mutex_locked = 0;
now = 1000; end_result = 0; player_mq_couter = vad_starts = 0; active_session_valid = 0; startup_start_ms = 0; tts_playing = 0;
}
int main(void) {
char id[33];
const char *tagged = "PROMPT_SOUND_URL:http://host/26101309423304_EFGHIJKLMNOPQRSTUV.mp3";
const char *plain = "PROMPT_SOUND_URL:http://host/tts_error.mp3";
assert(conversation_find_session_id(tagged, (unsigned int)strlen(tagged), id));
assert(strcmp(id, "26101309423304EFGHIJKLMNOPQRSTUV") == 0);
assert(!conversation_find_session_id(plain, (unsigned int)strlen(plain), id));
assert(!conversation_find_session_id(tagged, 20, id));
/* No confirmed speech: prompt remains, without disconnect or reply wait. */
reset(); assert(conversation_try_begin() == 0); g_mic_working = 0;
conversation_no_speech(); conversation_check_timeout();
assert(queued == 1 && disconnected == 0 && no_speech_timeout == 1);
conversation_check_timeout(); assert(queued == 1);
/* New key cancels pending old no-speech prompt. */
reset(); conversation_no_speech(); assert(conversation_try_begin() == 0);
g_mic_working = 0; now += 20000; conversation_check_timeout();
assert(!queued && !disconnected && !no_speech_timeout);
/* Repeated keys during either waiting-for-speech or active speech are ignored. */
reset(); recording = 1; conversation_wait = WAIT_REPLY; wait_round = 0;
assert(conversation_try_begin() == -1);
assert(conversation_round == 0 && conversation_wait == WAIT_REPLY && !flags_reset);
/* END starts reply deadline; stale round and early URL cannot arm it. */
reset(); sendEndPacket(); assert(conversation_wait == WAIT_REPLY);
now += 5000; conversation_check_timeout(); assert(!queued);
now++; conversation_check_timeout(); assert(queued == 1 && disconnected == 1);
reset(); sendEndPacket(); assert(conversation_try_begin() == 0);
g_mic_working = 0; now += 20000; conversation_check_timeout(); assert(!queued && !disconnected);
reset(); conversation_round = 1; sendEndPacket(); assert(conversation_wait == WAIT_NONE);
reset(); round_response_received = 1; sendEndPacket(); assert(conversation_wait == WAIT_NONE);
/* END queue failure still produces an error instead of waiting forever. */
reset(); end_result = -1; sendEndPacket(); assert(conversation_wait == WAIT_SEND_FAILURE);
conversation_check_timeout(); assert(queued == 1 && disconnected == 1);
/* Prompt completion preserves the key-reserved round; automatic listening advances. */
reset(); assert(conversation_try_begin() == 0); conversation_record_begin(); assert(conversation_round == 1);
g_mic_working = 0; conversation_record_begin(); assert(conversation_round == 2);
/* Force-stop cancels waits; stale timeout owner cannot affect a new round. */
reset(); conversation_no_speech(); conversation_cancel_wait(); conversation_check_timeout(); assert(!queued);
reset(); conversation_wait = WAIT_REPLY; wait_round = 9; now += 20000;
conversation_check_timeout(); assert(!queued && !disconnected);
/* Reproduce ai_active followed by a late untagged backend error. */
reset(); active_session_valid = 1; active_session_round = 0;
assert(conversation_try_begin() == 0);
assert(!conversation_accept_voice_message("PROMPT_SOUND_URL:http://host/tts_error.mp3"));
assert(!conversation_accept_voice_message("TTS_START"));
assert(conversation_accept_voice_message("NFC_SOUND_URL:http://host/card.mp3"));
/* A following queue entry cannot consume the startup action. */
player_mq_couter = 1;
conversation_finish_startup_prompt(conversation_round, 0);
assert(vad_starts == 1 && recording && !g_mic_working);
assert(!conversation_accept_voice_message("PROMPT_SOUND_URL:http://host/tts_error.mp3"));
active_session_valid = 1; active_session_round = conversation_round;
assert(conversation_accept_voice_message("PROMPT_SOUND_URL:http://host/tts_error.mp3"));
/* Stale prompt completion must not start another round. */
reset(); assert(conversation_try_begin() == 0);
conversation_finish_startup_prompt(conversation_round - 1, 0); assert(!vad_starts);
conversation_finish_startup_prompt(conversation_round, -1); assert(vad_starts == 1);
/* Lost startup releases reservation; active work is never recovered. */
reset(); assert(conversation_try_begin() == 0); now += 30001;
conversation_check_timeout(); assert(!g_mic_working);
assert(conversation_try_begin() == 0);
now += 30001; player_mq_couter = 1; conversation_check_timeout(); assert(g_mic_working);
player_mq_couter = 0; recording = 1; conversation_check_timeout(); assert(g_mic_working);
puts("PASS: late backend error admission, prompt completion with queued audio, stale prompt, failed playback, stalled-startup recovery; no-speech prompt, new-round cancellation, ignored recording keys, END deadline, stale rounds, early reply, send failure, timeout/key race, auto-listening");
}
'''
with tempfile.TemporaryDirectory(prefix='banban-timeout-') as directory:
cfile = Path(directory) / 'timeout.c'
exe = Path(directory) / 'timeout-test'
cfile.write_text(harness)
subprocess.run(['cc', '-std=c99', '-Wall', '-Wextra', '-Werror', str(cfile), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
+1 -31
View File
@@ -24,13 +24,10 @@ 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"
FBF_CONFIG = ROOT / "ql-bootloader/updater/tool/config"
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"
NET_MGT_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/net_mgt.c"
HTTP_DOWNFILE_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/http_downfile.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"
@@ -45,7 +42,7 @@ EXPECTED = {
}
FBF_HEADER_ALLOWANCE = 8192
MINIMUM_SYSTEM_MARGIN = 64 * 1024
EXPECTED_FS_SHA256 = "51ad32f8196aeab36a62ed436ac65f495ce204436f8ef57efccafc87313b0d3c"
EXPECTED_FS_SHA256 = "45350804d65fc6ded24caf9b215b71e89f9160d10d4998c5d57d4f4496d6000d"
def fail(message: str) -> None:
@@ -169,22 +166,6 @@ def verify_fota_contract() -> None:
):
if symbol not in api:
fail(f"SDK FULLOTA2 API {symbol} is missing")
fbf_config = FBF_CONFIG.read_text(encoding="utf-8", errors="replace")
fbf_image_path = re.search(r"(?m)^1_Image_Path\s*=\s*(\S+)\s*$", fbf_config)
fbf_entry = re.search(
r"(?m)^1_Image_Flash_Entry_Address\s*=\s*(0x[0-9A-Fa-f]+)\s*$",
fbf_config,
)
if not fbf_image_path or fbf_image_path.group(1) != "customer_app.bin":
fail("FBF config does not package customer_app.bin")
if not fbf_entry or int(fbf_entry.group(1), 16) != EXPECTED["app_start"]:
actual = fbf_entry.group(1) if fbf_entry else "missing"
fail(
f"FBF target address is {actual}; expected "
f"0x{EXPECTED['app_start']:08X}"
)
identity = BASELINE_HEADER.read_text(encoding="utf-8")
if "eg800akcn_11lg-hxgnss-fs576-app2e5000" not in identity:
fail("application layout identity is missing")
@@ -206,17 +187,6 @@ def verify_fota_contract() -> None:
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")
if "OTA_STATE_FLAG_AUDIO_REFRESH_PREPARED" not in mqtt or \
"ota_prepare_post_update_audio_refresh" not in mqtt:
fail("post-update audio refresh is not tied to the verified OTA target version")
net_mgt = NET_MGT_SOURCE.read_text(encoding="utf-8", errors="replace")
refresh_position = net_mgt.find("ota_prepare_post_update_audio_refresh()")
download_position = net_mgt.find("app_file_check_download()")
if refresh_position < 0 or download_position < 0 or refresh_position > download_position:
fail("post-update audio deletion must run immediately before missing-file download")
audio_source = HTTP_DOWNFILE_SOURCE.read_text(encoding="utf-8", errors="replace")
if re.search(r"index\s*==\s*19", audio_source):
fail("barge_in_cute.mp3 is still excluded from managed audio refresh")
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")