Author SHA1 Message Date
chenjintao 5f8c4d048e feat(nfc): emit example.com URI for native phone reading 2026-10-13 16:18:00 +08:00
chenjintao c53ab581d0 fix: queue sleep prompt before entering sleep mode 2026-10-13 16:10:00 +08:00
chenjintao 23983ccdc8 fix(audio): defer and coalesce role switch notices 2026-10-13 15:56:00 +08:00
chenjintao 109a95497a fix(audio): reject stale replies during voice startup 2026-10-13 10:04:31 +08:00
chenjintao 00e9509dd0 fix(audio): isolate conversation timeouts by round 2026-10-12 18:40:20 +08:00
chenjintao 84ce54eeb0 feat: refresh managed audio after OTA activation 2026-10-12 16:47:58 +08:00
chenjintao 886ea58419 fix: align FBF target with OTA-ready layout 2026-10-12 11:05:38 +08:00
chenjintao 65029594a3 revert: restore domain audio download endpoint 2026-10-08 11:09:26 +08:00
chenjintao 869f5887de chore: store customer filesystem outside LFS 2026-10-08 11:06:37 +08:00
chenjintao bbb77137bb Merge branch 'feature/ota-ready-factory-baseline' of https://git.talkingq.com/chenjintao/banban into feature/ota-ready-factory-baseline 2026-10-08 10:58:16 +08:00
chenjintao f3691c8267 feat(nfc): include IMEI in emulated tag payload 2026-09-29 11:33:02 +08:00
chenjintao 5399921c3d fix(audio): rebalance bundled prompt volume 2026-09-29 10:59:21 +08:00
chenjintao a0796f9a1d fix: use SDK-compatible customer filesystem image 2026-09-28 17:10:36 +08:00
chenjintao 05522c929f fix: restore IP audio download endpoint 2026-09-28 15:00:08 +08:00
chenjintao 79c154579c fix: rebalance HX GNSS OTA partitions 2026-09-28 14:19:42 +08:00
chenjintao 6cdd88e9f4 fix: resize active GNSS customer fs partition 2026-09-28 12:18:26 +08:00
chenjintao 20e5872a75 feat: allow talkingq OTA domains 2026-09-28 11:54:51 +08:00
25 changed files with 1634 additions and 230 deletions
+1
View File
@@ -7,6 +7,7 @@
*.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
+441
View File
@@ -0,0 +1,441 @@
# 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。
+3 -3
View File
@@ -4,8 +4,8 @@ for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMddHHmmss"
set appbuildrev=NULL
set appbuildmodel=EG800AKCN_11LG
set appbuildver=beta1
set otalayoutid=eg800akcn_11lg-fs640-app2c2000
set otabuildprofile=ota_ready_fs640
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" == "" (
@@ -37,7 +37,7 @@ 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
"%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
+45 -29
View File
@@ -8,9 +8,9 @@ OTA 所依赖的底层条件。
本基线完成:
- 将 `customer_fs` 从 256 KiB 扩大到 640 KiB;
- 保持 `customer_app` 为 460 KiB;
- 将 APP 物理/XIP 地址固定为 `0x002C2000` / `0x802C2000`;
- 将 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;
- 为设备和构建产物加入唯一布局身份;
@@ -34,22 +34,22 @@ OTA 所依赖的底层条件。
设备布局身份:
```text
eg800akcn_11lg-fs640-app2c2000
eg800akcn_11lg-hxgnss-fs576-app2e5000
```
构建 profile:
```text
ota_ready_fs640
ota_ready_hxgnss_fs576
```
4 MiB 内部 Flash 的尾部分区固定如下:
| 分区 | 起始地址 | 结束地址 | 大小 |
| --- | ---: | ---: | ---: |
| reserved | `0x00287000` | `0x002C2000` | 236 KiB |
| customer_app | `0x002C2000` | `0x00335000` | 460 KiB |
| customer_fs | `0x00335000` | `0x003D5000` | 640 KiB |
| 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 |
@@ -62,34 +62,38 @@ ota_ready_fs640
### 3.1 分区和链接地址
- `CRANELRH_QUEC_FLASH_LAYOUT_SS_04M_OPEN.json`:`customer_fs = 640KiB`;
- `app_linkscript.ld`:ROM 起始地址改为 `0x802C2000`,最大 ROM 大小收紧为 `0x73000`;
- `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 字节块、
160 个块重新生成 640 KiB LittleFS;
144 个块重新生成 576 KiB LittleFS;
- 新文件系统镜像 SHA-256:
`19bd849dbcc6816f6dfffd1e585645a3474a2265f96a926b5dd073f0affb69f9`。
`51ad32f8196aeab36a62ed436ac65f495ce204436f8ef57efccafc87313b0d3c`。
### 3.2 运行身份
`ota_baseline.h` 固定布局身份、APP/FS 地址和分区大小。应用启动时打印:
```text
[OTA-BASELINE] layout=eg800akcn_11lg-fs640-app2c2000 ... slot=single
[OTA-BASELINE] layout=eg800akcn_11lg-hxgnss-fs576-app2e5000 ... slot=single
[OTA-BASELINE] U: total=... free=... fota2_reserve=65536
```
该日志用于产线和联调确认设备没有混入旧的 fs256 基线,不改变任何业务状态。
该日志用于产线和联调确认设备没有混入旧的 fs256 或错误的 fs640 OPEN 基线,不改变任何业务状态。
### 3.3 构建保护
`build_rel.bat` 在构建前运行源码校验,在生成 APP 后运行容量校验。任何一项不满足都会中止:
- `customer_fs` 必须为 640 KiB;
- APP 地址必须为 `0x002C2000`;
- LittleFS 必须为 4096 字节块、160 块且镜像哈希一致;
- 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` 不得超过 460 KiB。
- `app.bin + 8192` 不得超过 384 KiB。
- MQTT OTA 入口必须调用 `banban_fullfota2_start()`,不得回退到
`ql_fullfota_app_start()`。
@@ -98,7 +102,8 @@ ota_ready_fs640
```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
--app-bin ql-application\threadx\build\app.bin `
--kernel-bin ql-kernel\threadx\build\release_kernel.bin
```
### 3.4 最小 FULLOTA2 执行路径
@@ -110,7 +115,7 @@ ql-cross-tool\win32\owtoolchain\Python\python.exe tools\verify_ota_ready_baselin
1. 删除上一次残留的 `U:/FotaFile.bin`;
2. HTTP GET 必须返回 200 和非零 `Content-Length`;
3. FBF 必须大于 8 KiB 且不超过 460 KiB;
3. FBF 必须大于 8 KiB 且不超过 384 KiB;
4. `U:` 空闲空间必须不小于 `Content-Length + 64 KiB`;
5. 设置内部 Flash 固定暂存路径,初始化 FOTA context;
6. 以 4096 字节缓冲顺序读取并调用 `ql_fota_image_write()`;
@@ -122,8 +127,12 @@ ql-cross-tool\win32\owtoolchain\Python\python.exe tools\verify_ota_ready_baselin
任一步在设置升级标志前失败,都会关闭 HTTP/FOTA context、删除暂存包、撤销固定升级模式、
清除 pending 状态并把 `fota_start` 恢复为 0。旧 APP 未被擦写,可继续运行并接受下一次命令。
旧实现会在 OTA 前删除服务器管理的提示音。fs640 基线有足够空间后,这版不再主动删除
业务资源;若空间不足则明确失败,不通过牺牲业务文件强行升级。
旧实现会在 OTA 下载前删除服务器管理的提示音,下载失败时会提前损失业务资源。当前实现
在下载和 updater 安装阶段保留全部音频;只有新 APP 启动、当前版本确认等于 OTA 目标版本且
数据网络就绪后,才删除服务器管理的提示音并立即调用缺失文件补下载。删除完成标志复用
pending OTA 状态中的 flags 保存,避免 MQTT 重连时反复删除。`barge_in_cute.mp3` 也参与该
刷新流程;固定资源地址必须在发布前确认返回 HTTP 200。若 FBF 暂存空间不足则直接拒绝
OTA,不再通过升级前删除音频强行腾出空间。
`http_aofei.c` 中未被当前业务调用的旧 HTTP 轮询 OTA 包装函数原本仍可进入 FULLOTA1,
本基线将其显式禁用。生产固件只有 MQTT `version + url` 命令可以启动 OTA,避免两个入口
@@ -158,10 +167,11 @@ ASR1605/CRANELRH,但量产包必须由 `EG800AKCN_11LG` 配置生成。
### 5.1 静态和产物测试
1. 运行 `verify_ota_ready_baseline.py`,结果必须通过。
2. 解包工厂 ZIP,确认其中 `customer_fs.bin` 为 655360 字节且哈希一致。
2. 解包工厂 ZIP,确认其中 `customer_fs.bin` 为 589824 字节且哈希一致。
3. 使用 Aboot 工具检查 `partition.bin`,确认 APP/FS 地址与本文件一致。
4. 确认 `customer_app.bin + 8192 <= 471040`。
5. 确认 FBF 的目标物理地址是 `0x002C2000`,不能是旧地址 `0x00322000`。
4. 确认 `customer_app.bin + 8192 <= 393216`。
5. 确认 FBF 的目标物理地址是 `0x002E5000`,不能是旧地址 `0x00322000`、
也不能是错误基线地址 `0x002C2000`。
### 5.2 全擦工厂烧录测试
@@ -169,7 +179,7 @@ ASR1605/CRANELRH,但量产包必须由 `EG800AKCN_11LG` 配置生成。
1. 下载器无越界、校验或 partition 错误。
2. USB 重新枚举,`AT`/`ATI` 正常。
3. 串口打印正确 layout、地址和 640 KiB 文件系统容量。
3. 串口打印正确 layout、地址和 576 KiB 文件系统容量。
4. 14 个预置音频均可读取和播放。
5. IMEI、SIM、驻网、数据连接和 GNSS 正常。
6. MQTT 注册、命令收发、心跳和状态上报正常。
@@ -223,7 +233,7 @@ ASR1605/CRANELRH,但量产包必须由 `EG800AKCN_11LG` 配置生成。
```json
{
"product": "EG800AKCN_11LG",
"layout_id": "eg800akcn_11lg-fs640-app2c2000",
"layout_id": "eg800akcn_11lg-hxgnss-fs576-app2e5000",
"version": "1.9.0",
"release_seq": 10900,
"min_release_seq": 10800,
@@ -236,6 +246,12 @@ ASR1605/CRANELRH,但量产包必须由 `EG800AKCN_11LG` 配置生成。
设备校验顺序:产品、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`:命令和布局校验通过;
@@ -253,8 +269,8 @@ ASR1605/CRANELRH,但量产包必须由 `EG800AKCN_11LG` 配置生成。
## 8. 发布和回退策略
- 新生产设备只允许使用 fs640 工厂包和 `0x002C2000` APP/FBF。
- 禁止向 fs640 设备发布旧地址 `0x00322000` 的 FBF。
- 新生产设备只允许使用 HX_GNSS fs576 工厂包和 `0x002E5000` APP/FBF。
- 禁止向 fs576 设备发布旧地址 `0x00322000` 或错误地址 `0x002C2000` 的 FBF。
- 如生产前验证失败,可回退源码分支和工厂包重新全擦烧录;不得混烧新旧布局制品。
- 量产后若只是业务问题,可通过 APP OTA 修复;若分区或 kernel 基线有问题,需要返厂或有线
完整烧录,不能假定业务 APP OTA 能安全修改分区表。
@@ -1,6 +1,6 @@
__ROM_START_ADDR = 0x802C2000;
__MAX_ROM_SIZE = 0x73000;
__ROM_START_ADDR = 0x802E5000;
__MAX_ROM_SIZE = 0x60000;
__RAM_START_ADDR = 0x7e040800;
__MAX_RAM_SIZE = 0x100000;
@@ -87,6 +87,7 @@ SRC_FILES+= \
battery.c \
gnss.c \
ota_fullfota2.c \
ota_url_policy.c \
#-------------------------------------------------------------------------------
@@ -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://banban.api.talkingq.com:8080/assets/roles/banban/tts_error.mp3
//#define PCM_FILE "http://47.113.99.138:7050/output_16k.pcm"
@@ -182,13 +182,12 @@ void app_file_check_download(void)
int index = 1;
char url_buff[128]={0x00};
char filename_buff[64]={0x00};
int ret = 0, cnt = 0;
int ret = 0;
for(index=1;index<20;index++){
int cnt = 0;
/* 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){
if(index==4||index==5||index==6||index==7||index==8){
continue;
}
@@ -197,7 +196,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://banban.api.talkingq.com:8080/assets/roles/banban/%s", mp3_file[index]);
ret = http_file_download(url_buff);
if(ret!=0&&cnt++<3) goto down_loop;
@@ -213,9 +212,7 @@ void app_file_check_del(void)
for(index=1;index<20;index++){
/* 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){
if(index==4||index==5||index==6||index==7||index==8){
continue;
}
@@ -0,0 +1,7 @@
#ifndef BANBAN_HTTP_DOWNFILE_H
#define BANBAN_HTTP_DOWNFILE_H
void app_file_check_download(void);
void app_file_check_del(void);
#endif
@@ -4,14 +4,14 @@
#include <stdint.h>
#include "build_macro.h"
#define BANBAN_OTA_EXPECTED_LAYOUT_ID "eg800akcn_11lg-fs640-app2c2000"
#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(0x002C2000)
#define BANBAN_OTA_APP_XIP_ADDRESS UINT32_C(0x802C2000)
#define BANBAN_OTA_APP_PARTITION_SIZE UINT32_C(0x00073000)
#define BANBAN_OTA_FS_PHYSICAL_ADDRESS UINT32_C(0x00335000)
#define BANBAN_OTA_FS_PARTITION_SIZE UINT32_C(0x000A0000)
#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)
@@ -0,0 +1,11 @@
#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
@@ -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,6 +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-fs640-app2c2000"
#define CUSTOM_APP_OTA_BUILD_PROFILE "ota_ready_fs640"
#define CUSTOM_APP_OTA_LAYOUT_ID "eg800akcn_11lg-hxgnss-fs576-app2e5000"
#define CUSTOM_APP_OTA_BUILD_PROFILE "ota_ready_hxgnss_fs576"
#endif
@@ -21,7 +21,10 @@
#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"
@@ -44,9 +47,9 @@
#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
#define OTA_ALLOWED_URL_PREFIX "http://101.35.224.118:8080/assets/firmware/"
static ql_timer_t mqtt_event_task_timer = NULL;
@@ -105,12 +108,13 @@ static char payload[MQTT_PAYLOAD_SIZE] = {0};
extern int fota_start;
int app_play_ota_tips_file(void);
int app_role_notice_handle_url(const char *url);
int xiaoice_get_play_counter(void);
typedef struct {
uint32_t magic;
uint16_t format_version;
uint16_t reserved;
uint16_t flags;
char previous_version[16];
char target_version[16];
uint32_t checksum;
@@ -147,12 +151,56 @@ 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;
@@ -163,38 +211,7 @@ 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);
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;
return ota_state_write(&state);
}
static int ota_state_load(ota_state_t *state)
@@ -296,25 +313,40 @@ 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;
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;
}
/* ==================== 解析函数 ==================== */
/**
@@ -1318,7 +1350,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;
}
@@ -1479,7 +1511,9 @@ void handle_command(command_t *cmd)
app_mqtt_publish(resp, strlen(resp), 0);
free(resp);
add_file_to_play_player_queue(cmd->params.nfc.url, 0);
if (!app_role_notice_handle_url(cmd->params.nfc.url)) {
add_file_to_play_player_queue(cmd->params.nfc.url, 0);
}
break;
}
@@ -19,6 +19,8 @@
#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"
@@ -104,6 +106,7 @@ 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;
@@ -155,7 +158,11 @@ 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,8 +3,9 @@
#include <string.h>
#include "ql_rtos.h"
#include "ql_application.h"
#include "ql_audio.h"
#include "ql_iic.h"
#include "ql_audio.h"
#include "ql_dev.h"
#include "ql_iic.h"
#include "ql_gpio.h"
#include "app_inc.h"
#include "ql_fs.h"
@@ -171,29 +172,11 @@ static u8 nfc_card_emulation_send_frame(const u8 *frame, u8 length)
static void nfc_type2_build_ndef(void)
{
char device_id[32] = {0};
char serial_number[32] = {0};
char json[128];
int json_len;
static const char uri_suffix[] = "example.com/";
size_t uri_len = sizeof(uri_suffix) - 1;
u8 *ndef;
u8 record_len;
if (hal_os_get_device_id(device_id) != 0) {
strcpy(device_id, "unknown");
}
if (hal_os_get_device_sn(serial_number) != 0) {
strcpy(serial_number, "unknown");
}
json_len = snprintf(json, sizeof(json),
"{\"device_id\":\"%s\",\"serial_number\":\"%s\"}",
device_id, serial_number);
if (json_len < 0) {
json_len = 0;
} else if (json_len >= (int)sizeof(json)) {
json_len = sizeof(json) - 1;
}
memset(nfc_type2_memory, 0, sizeof(nfc_type2_memory));
/* Static UID/manufacturer pages followed by a read-only Type 2 CC. */
@@ -207,20 +190,18 @@ static void nfc_type2_build_ndef(void)
nfc_type2_memory[15] = 0x0F; /* Read-only access. */
ndef = &nfc_type2_memory[NFC_TYPE2_NDEF_OFFSET];
record_len = (u8)(7 + json_len);
record_len = (u8)(5 + uri_len);
ndef[0] = 0x03; /* NDEF Message TLV. */
ndef[1] = record_len;
ndef[2] = 0xD1; /* MB, ME, SR, TNF well-known. */
ndef[3] = 0x01; /* Type length. */
ndef[4] = (u8)(3 + json_len); /* Text status/lang plus JSON. */
ndef[5] = 'T';
ndef[6] = 0x02; /* UTF-8, language length 2. */
ndef[7] = 'e';
ndef[8] = 'n';
memcpy(&ndef[9], json, json_len);
ndef[9 + json_len] = 0xFE; /* Terminator TLV. */
ndef[4] = (u8)(1 + uri_len); /* URI prefix code plus suffix. */
ndef[5] = 'U'; /* NFC Forum URI record. */
ndef[6] = 0x04; /* URI prefix: https:// */
memcpy(&ndef[7], uri_suffix, uri_len);
ndef[7 + uri_len] = 0xFE; /* Terminator TLV. */
printf("NFC Type 2 NDEF: %s\r\n", json);
printf("NFC Type 2 NDEF URI: https://%s\r\n", uri_suffix);
}
static void nfc_type2_read_pages(u8 start_page, u8 *response)
@@ -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);
}
@@ -102,9 +102,13 @@ void enter_sleep_mode(void)
if (g_in_sleep_mode) return;
printf("enter_sleep_mode\r\n");
/* 先排入休眠提示音,再设置交互休眠标志;提示音入口会拦截休眠状态。
* 播放线程会唤醒音频芯片,播放结束后由原有空闲计时进入低功耗。 */
int prompt_ret = app_play_tips_file(12);
g_in_sleep_mode = 1;
app_play_tips_file(12);
if (prompt_ret != 0) {
printf("sleep prompt queue failed:ret=%d\r\n", prompt_ret);
}
}
/**
@@ -43,7 +43,10 @@ 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 unsigned char wss_connect_start; //表示vad拾音结束 开始等待后台返回 如果超过5s没有返回 则报超时语音
extern void conversation_no_speech(void);
extern void conversation_record_begin(void);
extern void conversation_cancel_wait(void);
extern int app_role_notice_before_finish(void);
// 配置
static int sample_rate = 16000; // FVAD 支持的采样率:8k/16k/32k/48k
@@ -89,6 +92,12 @@ 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;
@@ -216,7 +225,7 @@ int vad_audio_stop(void) {
ql_audio_record_close(record_hd, 1);
wss_connect_start = 1;//用于判断后台响应超时
/* Reply timeout starts in the sender only after END is submitted. */
key_start_leave = 0;
//ql_rtos_semaphore_release(sender_sem);
@@ -226,6 +235,7 @@ 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);
@@ -234,7 +244,7 @@ int vad_audio_stop_no_wait_resp(void) {
ql_audio_record_close(record_hd, 1);
wss_connect_start = 0;
return 0;
}
@@ -242,6 +252,7 @@ int vad_audio_stop_no_wait_resp(void) {
// 触发录音(按键触发)
void vad_audio_trigger_record(void) {
conversation_record_begin();
g_triggered = true;
printf("Record triggered");
}
@@ -449,6 +460,7 @@ 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;
@@ -491,6 +503,7 @@ void vad_audio_process(void) {
case STATE_WAITING_COMMAND:
if(vad_tts_playback_finished_status == 1) {
if (app_role_notice_before_finish()) break;
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);
@@ -52,7 +52,74 @@ static int16 *ppbuff=mp3_buff;
static int onmessage_status = -1;
volatile int tts_playing = 0;
unsigned char wss_connect_start = 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;
#define ROLE_NOTICE_TYPE 5
#define ROLE_NOTICE_URL_SIZE 256
static char role_notice_url[ROLE_NOTICE_URL_SIZE];
static int role_notice_pending = 0;
static int role_notice_playing = 0;
static int role_notice_consumed = 0;
static unsigned int role_notice_round = 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();
/* Accepted restart cancels the preceding round's deferred notice. */
role_notice_pending = role_notice_playing = role_notice_consumed = 0;
role_notice_url[0] = '\0';
}
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;
role_notice_pending = role_notice_playing = role_notice_consumed = 0;
role_notice_url[0] = '\0';
conversation_unlock();
}
void conversation_no_speech(void)
{
conversation_lock();
wait_round = conversation_round;
conversation_wait = WAIT_NO_SPEECH;
conversation_unlock();
}
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. */
@@ -73,6 +140,8 @@ 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);
extern system_state_t vad_audio_get_state(void);
void sendAuthentication(void);
void vad_start_func(void);
@@ -83,7 +152,8 @@ int audio_packet_type = 0;
typedef struct ice_work_item_s
{
int type; //0-tts_out 1-session_continue 2-session_end 3-云端超时提示(失败时播放本地兜底)
int type; //0-tts_out 1-session_continue 2-session_end 3-云端超时提示(失败时播放本地兜底) 4-本地超时兜底 5-角色切换提示
unsigned int timeout_round; /* Startup, timeout and role notices use this round token. */
int error;
int del_url;
void *data;
@@ -267,37 +337,38 @@ static int randomize_tts_error_url(char *url, size_t url_size)
return build_random_tts_error_url(url, url_size);
}
void add_file_to_play_player_queue(char *path, int type)
int 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;
return -1;
}
item = malloc(sizeof(ice_work_item));
if(item == NULL){
printf("add_file_to_play_player_queue: malloc item failed\n");
return;
return -1;
}
item->data = malloc(strlen(path)+1);
if(item->data == NULL){
printf("add_file_to_play_player_queue: malloc data failed\n");
free(item);
return;
return -1;
}
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, 0xFFFFFFFF) != 0)
if (ql_rtos_queue_release(player_queue, sizeof(item), &item, 0) != 0)
{
printf("player_mq rt_mq_send ERR\n");
free(item->data);
@@ -305,16 +376,111 @@ void 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;
}
}
/* Only this resource is a role-change notice. Other NFC notices keep their
* existing immediate queue path, including binding and card audio. */
static int role_notice_is_url(const char *url)
{
const char *host, *path;
const char resource[] = "/assets/audio/switch_role.mp3";
size_t n = sizeof(resource) - 1;
if (!url) return 0;
host = strstr(url, "://");
if (!host) return 0;
path = strchr(host + 3, '/');
return path && strncmp(path, resource, n) == 0 &&
(path[n] == '\0' || path[n] == '?' || path[n] == '#');
}
/* Caller holds conversation_mutex. A full queue/failed download must not
* prevent the original conversation completion from proceeding. */
static int role_notice_start_locked(void)
{
role_notice_pending = 0;
role_notice_consumed = 1;
role_notice_playing = 1;
role_notice_round = conversation_round;
if (add_file_to_play_player_queue(role_notice_url, ROLE_NOTICE_TYPE) != 0) {
role_notice_playing = 0;
printf("role notice queue failed:round=%u\n", conversation_round);
return 0;
}
printf("role notice queued once:round=%u\n", conversation_round);
return 1;
}
/* Called only after the normal VAD playback-finished signal, or while idle.
* The original finished flag/no-speech flag stays untouched during the notice. */
static int role_notice_poll_locked(int finishing)
{
if (fota_start || power_down_status || get_sleep_mode()) {
role_notice_pending = role_notice_playing = 0;
return 0;
}
if (role_notice_playing) return 1;
if (!role_notice_pending) return 0;
if (role_notice_round != conversation_round) {
role_notice_pending = 0;
return 0;
}
if (vad_audio_is_recording() || g_mic_working || shine_init ||
timeout_action_busy || player_mq_couter > 0 || tts_playing != 0 ||
conversation_wait != WAIT_NONE) return 1;
if (!finishing && vad_audio_get_state() != STATE_IDLE) return 1;
return role_notice_start_locked();
}
int app_role_notice_handle_url(const char *url)
{
if (!role_notice_is_url(url)) return 0;
conversation_lock();
if (strlen(url) >= sizeof(role_notice_url)) {
printf("role notice URL too long\n");
} else if (role_notice_playing ||
(role_notice_consumed && vad_audio_get_state() != STATE_IDLE)) {
printf("role notice coalesced:round=%u\n", conversation_round);
} else {
/* Repeated commands before playback replace one pending slot. */
snprintf(role_notice_url, sizeof(role_notice_url), "%s", url);
role_notice_pending = 1;
role_notice_round = conversation_round;
printf("role notice pending:round=%u\n", conversation_round);
role_notice_poll_locked(0);
}
conversation_unlock();
return 1;
}
int app_role_notice_before_finish(void)
{
int wait;
conversation_lock();
wait = role_notice_poll_locked(1);
conversation_unlock();
return wait;
}
static void role_notice_finished(unsigned int round, int result)
{
conversation_lock();
if (role_notice_round == round && round == conversation_round) {
role_notice_playing = 0;
printf("role notice finished:round=%u result=%d\n", round, result);
}
conversation_unlock();
}
//如果录音后 超过10s 还没有收到信息 提示本轮对话结束
static void onmessage_text(rws_socket socket, const char *text, const unsigned int len)
static void onmessage_text_locked(rws_socket socket, const char *text, const unsigned int len)
{
if (fota_start == 1) {
printf("ignore websocket audio during OTA\r\n");
@@ -348,7 +514,6 @@ static void onmessage_text(rws_socket socket, const char *text, const unsigned i
}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;
@@ -402,7 +567,11 @@ static void onmessage_text(rws_socket socket, const char *text, const unsigned i
tts_play_status = 2;
}
if(is_tts_error_url == 1 || is_message_ok_url){
conversation_wait = WAIT_NONE;
round_response_received = 1;
if(is_tts_error_url == 1 || is_message_ok_url){
printf("tts_error or message_ok\n");
vad_set_speech_timeout_flag(1);
}
@@ -418,6 +587,81 @@ static void onmessage_text(rws_socket socket, const char *text, const unsigned i
}
/* 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);
@@ -583,12 +827,10 @@ int talkingq_ws_send_bin(uint8_t *senddata, int len)
// LOG_D("talkingq_wss_send_bin, len = %d", len);
rws_socket_send_bin(app->socket, senddata, len,2,rws_true);
return 0;
return rws_socket_send_bin(app->socket, senddata, len,2,rws_true) ? 0 : -1;
}
void sendRecordingAudioPacket(AudioPacket *packet);
int sendRecordingAudioPacket(AudioPacket *packet);
/*串口AT指令做设置*/
void generateDeviceId(void) {
@@ -784,7 +1026,9 @@ 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;
@@ -796,7 +1040,8 @@ void sendStartPacket(void) {
startPacket.packetType = 1;
startPacket.dataSize = 0;
sendRecordingAudioPacket(&startPacket);
active_session_valid = sendRecordingAudioPacket(&startPacket) == 0;
conversation_unlock();
// rt_kprintf("sendStartPacket, seq=%u\r\n", startPacket.sequenceNumber);
}
@@ -806,7 +1051,16 @@ void sendEndPacket(void) {
endPacket.packetType = 2;
endPacket.dataSize = 0;
sendRecordingAudioPacket(&endPacket);
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();
// rt_kprintf("sendEndPacket, seq=%u\r\n", endPacket.sequenceNumber);
}
@@ -862,13 +1116,13 @@ void sendAuthentication()
cJSON_Delete(sendAuth);
}
void sendRecordingAudioPacket(AudioPacket *packet) {
int 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;
return -1;
}
size_t headerSize = DEVICE_ID_SIZE + SESSION_ID_SIZE + sizeof(packet->sequenceNumber)
@@ -897,8 +1151,9 @@ void sendRecordingAudioPacket(AudioPacket *packet) {
// memcpy(recorder->save_buf, (void *)sendBuffer, recorder->save_len);
//record_msg_send(recorder, RECORD_MSG_DATA, recorder->save_len);
talkingq_ws_send_bin((uint8_t*)sendBuffer, totalSize);
int send_ret = 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
@@ -942,9 +1197,9 @@ int app_play_tips_file(int index)
if(file_valid_check!=2){
if(index==18 || index==19){
add_file_to_play_player_queue(filename_buff, 2);
return add_file_to_play_player_queue(filename_buff, 2);
}else{
add_file_to_play_player_queue(filename_buff, 0);
return add_file_to_play_player_queue(filename_buff, 0);
}
}else{
return -2;
@@ -955,6 +1210,9 @@ int app_play_tips_file(int index)
int app_play_ota_tips_file(void)
{
conversation_lock();
role_notice_pending = role_notice_playing = role_notice_consumed = 0;
conversation_unlock();
char filename_buff[64] = {0};
uint32_t timeout;
@@ -996,6 +1254,20 @@ 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;
@@ -1005,6 +1277,19 @@ 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->type == ROLE_NOTICE_TYPE) &&
player_item->timeout_round != conversation_round) ||
(player_item->type == ROLE_NOTICE_TYPE && !role_notice_playing);
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);
@@ -1020,18 +1305,23 @@ void app_player_thread(void *arvg)
* type=3 只用于对话超时提示。云端连接、HTTP、读取或播放器初始化
* 失败时才排入本地兜底;如果用户已经开始下一轮录音,则丢弃旧提示。
*/
conversation_lock();
if(play_ret != 0 && player_item->type == 3 &&
!vad_audio_is_recording() && tts_playing_ret() != 2){
!vad_audio_is_recording() && !g_mic_working &&
player_item->timeout_round == conversation_round &&
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);
add_file_to_play_player_queue(LOCAL_TTS_ERROR_PATH, 4);
}else{
printf("local tts error fallback missing:%s\r\n", LOCAL_TTS_ERROR_PATH);
}
}
if(player_mq_couter==0){
conversation_unlock();
if(player_mq_couter==0 && player_item->type != ROLE_NOTICE_TYPE){
printf("player_mq_couter==0\r\n");
if(tts_play_status==2){
@@ -1052,11 +1342,14 @@ 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 == ROLE_NOTICE_TYPE) {
role_notice_finished(player_item->timeout_round, play_ret);
}
if (player_item->type == 2) {
conversation_finish_startup_prompt(player_item->timeout_round, play_ret);
}
// }
}
if(player_item->data){
@@ -1069,6 +1362,24 @@ 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;
@@ -1097,9 +1408,9 @@ int tts_play_interrupt_func(void)
}
//如果在vad状态,则直接返回
if(vad_audio_is_recording()||g_mic_working) return -1;
if (conversation_try_begin() != 0) return -1;
g_mic_working = 1;//等待转为vad状态就清0 或者拾音超时 或者ws没有正常
/* g_mic_working reserves startup until VAD enters recording. */
/* 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
@@ -1130,7 +1441,13 @@ int tts_play_interrupt_func(void)
vad_start_func();
}
}else{
app_play_tips_file(18);
if (app_play_tips_file(18) != 0) {
conversation_lock();
g_mic_working = 0;
conversation_wait = WAIT_NONE;
conversation_unlock();
return -2;
}
}
return 0;
@@ -1153,9 +1470,65 @@ 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;
@@ -1247,34 +1620,10 @@ static void check_connect_thread_entry(void *parameter)
//}
#endif
#if 1
//如果后台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);
}
}
}
conversation_check_timeout();
conversation_lock();
role_notice_poll_locked(0);
conversation_unlock();
#endif
ql_rtos_task_sleep_ms(3000);
@@ -1308,9 +1657,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){
@@ -1385,6 +1734,9 @@ 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();
}
@@ -1479,6 +1831,7 @@ 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 = 0x0000322000
1_Image_Flash_Entry_Address = 0x00002E5000
1_Image_ID_Name = 1
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",
+218
View File
@@ -0,0 +1,218 @@
"""Run timeout, round and role-notice scheduling 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;
enum { STATE_IDLE, STATE_RECORDING_WAIT_SOUND, STATE_RECORDING_ACTIVE, STATE_WAITING_COMMAND };
static int tts_playing, tts_play_status, audio_packet_type, no_speech_timeout;
static int power_down_status, sleeping, vad_state;
static int queue_failure, last_queue_type;
static char last_queue_url[256];
static int get_sleep_mode(void) { return sleeping; }
static int vad_audio_get_state(void) { return vad_state; }
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 int add_file_to_play_player_queue(char *p, int type) {
assert(type == 3 || type == 5);
if (queue_failure) return -1;
queued++; player_mq_couter++; last_queue_type = type;
snprintf(last_queue_url, sizeof(last_queue_url), "%s", p);
return 0;
}
static void vad_start_func(void) { vad_starts++; recording = 1; vad_state = STATE_RECORDING_WAIT_SOUND; 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', 'role_notice_is_url', 'role_notice_start_locked', 'role_notice_poll_locked', 'app_role_notice_handle_url', 'app_role_notice_before_finish', 'role_notice_finished']:
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;
power_down_status = sleeping = queue_failure = 0; vad_state = STATE_IDLE;
role_notice_pending = role_notice_playing = role_notice_consumed = 0;
role_notice_round = 0; last_queue_type = 0; last_queue_url[0] = 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);
/* The exact resource alone is routed; unrelated notices retain original path. */
reset();
assert(!app_role_notice_handle_url("http://host/assets/audio/card.mp3"));
assert(!app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3.extra"));
assert(!role_notice_is_url("http://host/other/switch_role.mp3"));
assert(!queued);
/* No speech: retain one notice until tts_error + optional local fallback ends. */
reset(); recording = 1; vad_state = STATE_RECORDING_WAIT_SOUND;
assert(app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3"));
assert(!queued && role_notice_pending);
recording = 0; vad_state = STATE_WAITING_COMMAND; conversation_wait = WAIT_NO_SPEECH;
assert(app_role_notice_before_finish() && !queued);
conversation_wait = WAIT_NONE; player_mq_couter = 1; no_speech_timeout = 1;
assert(app_role_notice_before_finish() && !queued);
player_mq_couter = 0;
assert(app_role_notice_before_finish() && queued == 1 && last_queue_type == 5);
assert(no_speech_timeout == 1); /* Error flag never becomes auto-listening. */
assert(app_role_notice_before_finish() && queued == 1);
player_mq_couter = 0; role_notice_finished(conversation_round, 0);
assert(!app_role_notice_before_finish() && no_speech_timeout == 1);
/* Speech: zero insertion while uploading/waiting or between reply segments. */
reset(); recording = 1; vad_state = STATE_RECORDING_ACTIVE;
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
recording = 0; vad_state = STATE_WAITING_COMMAND; conversation_wait = WAIT_REPLY;
assert(app_role_notice_before_finish() && !queued);
conversation_wait = WAIT_NONE; tts_playing = 1;
assert(app_role_notice_before_finish() && !queued);
tts_playing = 0; player_mq_couter = 2;
assert(app_role_notice_before_finish() && !queued);
player_mq_couter = 0;
assert(app_role_notice_before_finish() && queued == 1);
role_notice_finished(conversation_round, 0); player_mq_couter = 0;
assert(!app_role_notice_before_finish() && no_speech_timeout == 0);
/* Repeated switches produce one latest URL at the end, never six entries. */
reset(); vad_state = STATE_WAITING_COMMAND; tts_playing = 1;
for (int n = 0; n < 6; n++) {
char url[128]; snprintf(url, sizeof(url), "http://host/assets/audio/switch_role.mp3?v=%d", n);
assert(app_role_notice_handle_url(url)); assert(!queued);
}
tts_playing = 0;
assert(app_role_notice_before_finish() && queued == 1);
assert(strstr(last_queue_url, "v=5"));
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
assert(queued == 1 && !role_notice_pending);
role_notice_finished(conversation_round, 0); player_mq_couter = 0;
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
assert(queued == 1 && !app_role_notice_before_finish());
/* Idle plays promptly; playback/queue failure releases completion too. */
reset(); app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
assert(queued == 1); player_mq_couter = 0;
role_notice_finished(conversation_round, -1); assert(!app_role_notice_before_finish());
reset(); queue_failure = 1;
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
assert(!queued && !app_role_notice_before_finish());
/* Accepted restart/force-stop cancels notice; no replay into next round. */
reset(); vad_state = STATE_WAITING_COMMAND; tts_playing = 1;
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
assert(conversation_try_begin() == 0 && !role_notice_pending);
reset(); recording = 1; app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
conversation_cancel_wait(); assert(!role_notice_pending && !role_notice_playing);
for (int mode = 0; mode < 3; mode++) {
reset(); vad_state = STATE_WAITING_COMMAND;
app_role_notice_handle_url("http://host/assets/audio/switch_role.mp3");
fota_start = mode == 0; power_down_status = mode == 1; sleeping = mode == 2;
assert(!app_role_notice_before_finish() && !role_notice_pending && !queued);
}
puts("PASS: role notices: silent/speech end, latest-only coalescing, idle, failure, restart/OTA/shutdown/sleep isolation; 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)
+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;
}
+95 -23
View File
@@ -13,29 +13,39 @@ 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_OPEN.json"
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"
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"
FLASH_SIZE = 0x400000
EXPECTED = {
"app_start": 0x002C2000,
"app_size": 0x00073000,
"fs_start": 0x00335000,
"fs_size": 0x000A0000,
"reserved_size": 0x0003B000,
"xip_start": 0x802C2000,
"app_start": 0x002E5000,
"app_size": 0x00060000,
"fs_start": 0x00345000,
"fs_size": 0x00090000,
"xip_start": 0x802E5000,
}
FBF_HEADER_ALLOWANCE = 8192
EXPECTED_FS_SHA256 = "19bd849dbcc6816f6dfffd1e585645a3474a2265f96a926b5dd073f0affb69f9"
MINIMUM_SYSTEM_MARGIN = 64 * 1024
EXPECTED_FS_SHA256 = "51ad32f8196aeab36a62ed436ac65f495ce204436f8ef57efccafc87313b0d3c"
def fail(message: str) -> None:
@@ -65,12 +75,17 @@ def flattened_tail() -> list[tuple[str, int]]:
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 460 KiB")
fail("customer_app must be 384 KiB")
if sizes.get("customer_fs") != EXPECTED["fs_size"]:
fail("customer_fs must be 640 KiB")
fail("customer_fs must be 576 KiB")
suffix = 0
starts: dict[str, int] = {}
@@ -85,20 +100,15 @@ def verify_layout() -> None:
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}")
# The system image ends at 0x287000 for this accepted 4 MiB CRANELRH baseline.
reserved = EXPECTED["app_start"] - 0x00287000
if reserved != EXPECTED["reserved_size"]:
fail("reserved partition calculation changed")
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 0x802C2000")
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 460 KiB customer_app partition")
fail("linker ROM limit is not the 384 KiB customer_app partition")
def verify_fs_image() -> None:
@@ -115,8 +125,33 @@ def verify_fs_image() -> None:
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 != 160:
fail(f"LittleFS geometry is block_size={block_size}, block_count={block_count}; expected 4096/160")
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:
@@ -134,23 +169,57 @@ 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-fs640-app2c2000" not in identity:
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-fs640-app2c2000"' not in generated:
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_fs640"' not in generated:
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")
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")
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")
@@ -185,6 +254,7 @@ def verify_app(path: Path) -> None:
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()
@@ -192,7 +262,9 @@ def main() -> int:
verify_fota_contract()
if args.app_bin:
verify_app(args.app_bin if args.app_bin.is_absolute() else ROOT / args.app_bin)
print("OTA-ready baseline verified: fs640, app@0x002C2000, FULLOTA2 available")
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