feat: add local-staging FULLOTA2 flow

This commit is contained in:
chenjintao
2026-09-27 18:28:05 +08:00
parent b726fe192a
commit f607d8b9f7
7 changed files with 407 additions and 34 deletions
+58 -28
View File
@@ -12,22 +12,22 @@ OTA 所依赖的底层条件。
- 保持 `customer_app` 为 460 KiB;
- 将 APP 物理/XIP 地址固定为 `0x002C2000` / `0x802C2000`;
- 保留 SDK 已有的 `QUEC_APP_FULL_FOTA` 与 FULLOTA2 公共 API;
- 将现有 OTA 命令切换为最小本地暂存式 FULLOTA2;
- 为设备和构建产物加入唯一布局身份;
- 在构建前后执行 fail-closed 校验。
本基线暂不完成:
- 不把当前 `ql_fullfota_app_start()` 在线升级流程切换成 FULLOTA2;
- 不增加签名 manifest、SHA-256 下载校验、断点续传或后台灰度;
- 不提供 A/B APP 分区,也不承诺自动版本回滚;
- 不在线更新 kernel、分区表或 `customer_fs`。
这样安排是为了先冻结量产后难以改变的 Flash 布局,同时避免在生产前仓促替换业务 OTA
状态机。后续 FULLOTA2 逻辑只需通过 `customer_app` 版本迭代即可加入。
这版保留现有后端的 `version + url` 命令格式,只改变设备端执行方式。网络包会完整写入
`U:/FotaFile.bin`,通过 SDK FBF 校验并设置升级标志后才重启。
> 生产门禁:从本工厂基线开始量产,到 FULLOTA2 业务 APP 完成真机验收并正式发布之前,
> 后端必须暂停向生产设备下发 OTA 命令。当前 `ql_fullfota_app_start()` 入口仍是原有
> FULLOTA1 流程,新的分区基线不会自动消除其覆盖下载风险。
> 生产门禁:完成本文的真机 FULLOTA2 正向、失败注入和断电测试前,后端不得向生产设备
> 下发 OTA 命令。验收通过后可继续使用原协议下发;这条门禁针对测试状态,不再是因为
> 设备仍调用 FULLOTA1。
## 2. 固定身份与 Flash 布局
@@ -55,7 +55,7 @@ ota_ready_fs640
| fota_param | `0x003F7000` | `0x003FA000` | 12 KiB |
| factory_a | `0x003FA000` | `0x00400000` | 24 KiB |
`customer_app` 是单槽。FULLOTA2 能保护下载阶段:文件未下载完整、哈希错误或 FBF 校验
`customer_app` 是单槽。FULLOTA2 能保护下载阶段:文件未下载完整或 SDK FBF 校验
失败时不会设置升级标志;但 updater 开始擦写 `customer_app` 后,没有旧 APP 槽可供回滚。
## 3. 本次修改
@@ -90,6 +90,8 @@ ota_ready_fs640
- kernel 必须继续定义 `QUEC_APP_FULL_FOTA`;
- FULLOTA2 所需的 SDK API 必须存在;
- `app.bin + 8192` 不得超过 460 KiB。
- MQTT OTA 入口必须调用 `banban_fullfota2_start()`,不得回退到
`ql_fullfota_app_start()`。
可独立执行:
@@ -99,6 +101,34 @@ ql-cross-tool\win32\owtoolchain\Python\python.exe tools\verify_ota_ready_baselin
--app-bin ql-application\threadx\build\app.bin
```
### 3.4 最小 FULLOTA2 执行路径
`ota_fullfota2.c` 使用独立任务完成同步 HTTP 下载。现有 MQTT 工作线程只负责参数校验、
保存目标版本、回复 `accepted` 和启动任务,不会承担长时间网络读取。
执行顺序:
1. 删除上一次残留的 `U:/FotaFile.bin`;
2. HTTP GET 必须返回 200 和非零 `Content-Length`;
3. FBF 必须大于 8 KiB 且不超过 460 KiB;
4. `U:` 空闲空间必须不小于 `Content-Length + 64 KiB`;
5. 设置内部 Flash 固定暂存路径,初始化 FOTA context;
6. 以 4096 字节缓冲顺序读取并调用 `ql_fota_image_write()`;
7. 实际接收字节数必须严格等于 `Content-Length`;
8. 调用 `ql_fota_image_flush()`,再用 `ql_stat()` 复核落盘文件长度;
9. 调用 `ql_fota_image_verify()` 校验 FBF 并设置升级标志;
10. deinit 后重启,由 updater 写入单槽 `customer_app`。
任一步在设置升级标志前失败,都会关闭 HTTP/FOTA context、删除暂存包、撤销固定升级模式、
清除 pending 状态并把 `fota_start` 恢复为 0。旧 APP 未被擦写,可继续运行并接受下一次命令。
旧实现会在 OTA 前删除服务器管理的提示音。fs640 基线有足够空间后,这版不再主动删除
业务资源;若空间不足则明确失败,不通过牺牲业务文件强行升级。
`http_aofei.c` 中未被当前业务调用的旧 HTTP 轮询 OTA 包装函数原本仍可进入 FULLOTA1,
本基线将其显式禁用。生产固件只有 MQTT `version + url` 命令可以启动 OTA,避免两个入口
使用不同的升级语义。
## 4. 构建和生产烧录
在 Windows SDK 构建环境中执行:
@@ -156,39 +186,39 @@ ASR1605/CRANELRH,但量产包必须由 `EG800AKCN_11LG` 配置生成。
4. 在写入 10%、50%、90% 时分别断电,重启后确认业务 APP 仍可运行并可清理残留文件。
5. 验证正常业务文件占用后,仍满足 `FBF size + 64 KiB` 空闲门槛。
### 5.4 最小 FULLOTA2 冒烟测试
### 5.4 最小 FULLOTA2 验收测试
正式量产前至少做一次 A 到 B 的本地暂存升级。B 可以只修改版本号:
1. 将 B 版 `customer_app.fbf` 顺序写入 `U:/FotaFile.bin`;
2. 调用 `ql_fota_fixed_internal_flash_upgrade(1)`;
3. 调用 `ql_fota_set_package_path()`、`ql_fota_init()`、`ql_fota_image_write()`、
`ql_fota_image_flush()` 和 `ql_fota_image_verify()`;
4. 重启,确认 updater 安装 B,且 `U:/FotaFile.bin` 被正确处理;
5. 确认 NVM、设备身份、业务配置和预置音频未损坏;
6. 下载损坏、截断和旧地址 FBF,确认均不能设置有效升级标志。
1. 后端按现有协议下发 B 版 `version + url`;
2. 串口必须看到 HTTP 长度、下载进度、FBF verify 成功和重启日志;
3. 重启后确认 updater 安装 B,且 `U:/FotaFile.bin` 被正确处理;
4. 确认 NVM、设备身份、业务配置和 14 个预置音频未损坏;
5. 分别测试 404、无 Content-Length、空间不足、传输中断、截断 FBF、损坏 FBF 和旧地址
FBF,确认旧 APP 保持可运行且下一次 OTA 可以重新开始;
6. 在下载 10%、50%、90% 时断电,确认旧 APP 启动后清理残留状态并可再次升级;
7. 在 `ql_fota_image_verify()` 成功后的 updater 擦写阶段断电,记录底层 updater 的实际恢复
行为;当前单槽设计不能承诺该阶段自动回滚。
## 6. 后续 FULLOTA2 业务实现
## 6. 后续 OTA 加固
后续 APP 版本建议按以下顺序加入,不再改变本基线分区:
后续 APP 版本建议按以下顺序加固,不再改变本基线分区:
1. 下载所有权收敛到单一 OTA task,其他任务只投递数据或事件。
2. 下载前停止对话、录音、播放和非必要 Flash 写入,保持 watchdog 正常喂狗。
3. 删除确认不再使用的旧暂存文件。
4. 检查 `free >= manifest.size + 65536`。
5. 从偏移 0 顺序写入 `U:/FotaFile.bin`;首版不要宣称支持断点续传。
6. 同时计算 SHA-256,并在下载完成后重新打开本地文件复核大小和 SHA-256。
7. 在设置升级标志前持久化 `PENDING_REBOOT` 状态。
8. 只有 `ql_fota_image_verify()` 成功后才重启。
9. 新 APP 启动后核对版本、release sequence 和 layout,再上报健康状态。
10. 失败时 deinit、删除残留文件、恢复业务,并上报明确错误码。
1. 后端增加 size、SHA-256、layout ID 和单调递增 release sequence;
2. 下载过程中计算 SHA-256,完成后重新打开本地文件复核;
3. manifest 增加数字签名、公钥轮换和允许的 HTTPS host;
4. 将单 pending 文件升级为双时隙、CRC 和递增 generation;
5. 增加下载进度、稳定失败码和升级阶段上报;
6. 增加按设备灰度、暂停和失败率熔断;
7. 若未来硬件 Flash 扩容,再评估真正 A/B APP。
建议使用两个带 CRC 和递增 generation 的状态文件,例如 `U:/ota_state.a`、
`U:/ota_state.b`。这能抵抗状态文件写入中断,但不构成固件 A/B 回滚。
## 7. 后端联调契约
现有 OTA 命令仅有 URL 和版本不足以可靠激活 FULLOTA2。建议后端增加:
最小 FULLOTA2 继续兼容现有 URL 和版本协议,不要求后端立即修改。完成首轮生产验收后,
建议后端增加:
```json
{
@@ -86,6 +86,7 @@ SRC_FILES+= \
sleep_wake.c \
battery.c \
gnss.c \
ota_fullfota2.c \
#-------------------------------------------------------------------------------
@@ -517,7 +517,9 @@ exit:
int http_ota_fw_download(char *url)
{
return ql_fullfota_app_start(url);
(void)url;
RLOGE("legacy HTTP polling FULLOTA1 is disabled; use MQTT FULLOTA2 command\r\n");
return -1;
}
typedef struct recv_buf
@@ -0,0 +1,25 @@
#ifndef BANBAN_OTA_FULLOTA2_H
#define BANBAN_OTA_FULLOTA2_H
typedef enum {
BANBAN_FULLOTA2_OK = 0,
BANBAN_FULLOTA2_ERR_ARGUMENT = -1,
BANBAN_FULLOTA2_ERR_BUSY = -2,
BANBAN_FULLOTA2_ERR_TASK = -3,
BANBAN_FULLOTA2_ERR_HTTP = -4,
BANBAN_FULLOTA2_ERR_LENGTH = -5,
BANBAN_FULLOTA2_ERR_SPACE = -6,
BANBAN_FULLOTA2_ERR_INIT = -7,
BANBAN_FULLOTA2_ERR_WRITE = -8,
BANBAN_FULLOTA2_ERR_FLUSH = -9,
BANBAN_FULLOTA2_ERR_VERIFY = -10
} banban_fullfota2_result_t;
typedef void (*banban_fullfota2_finished_cb_t)(int result);
int banban_fullfota2_start(const char *url,
banban_fullfota2_finished_cb_t finished_cb);
int banban_fullfota2_is_running(void);
void banban_fullfota2_discard_staged_package(void);
#endif
@@ -21,6 +21,7 @@
#include "cJSON.h"
#include "build_macro.h"
#include "ql_log.h"
#include "ota_fullfota2.h"
@@ -242,6 +243,17 @@ static void ota_state_clear(void)
}
}
static void ota_fullfota2_finished(int result)
{
if (result == BANBAN_FULLOTA2_OK) {
return;
}
RLOGE("FULLOTA2 failed before activation: %d", result);
ota_state_clear();
fota_start = 0;
}
static int ota_parse_version(const char *version, unsigned int parts[3])
{
char tail;
@@ -1246,6 +1258,8 @@ static void ota_report_pending_success(void)
compare_result != 0) {
RLOGE("OTA pending state version mismatch: current=%s target=%s",
current_version, state.target_version);
banban_fullfota2_discard_staged_package();
ota_state_clear();
return;
}
@@ -1369,12 +1383,10 @@ static void handle_ota_upgrade(command_t *cmd)
}
}
/* Product requirement: keep deleting the configured audio files before OTA. */
app_file_check_del();
ret = ql_fullfota_app_start(cmd->params.ota.url);
ret = banban_fullfota2_start(cmd->params.ota.url,
ota_fullfota2_finished);
if (ret != 0) {
RLOGE("start OTA failed: %d", ret);
RLOGE("start FULLOTA2 task failed: %d", ret);
fota_start = 0;
ota_state_clear();
}
@@ -0,0 +1,272 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ql_fota.h"
#include "ql_fs.h"
#include "ql_power.h"
#include "ql_rtos.h"
#include "webclient.h"
#include "ota_baseline.h"
#include "ota_fullfota2.h"
#define BANBAN_FULLOTA2_PACKAGE_PATH "U:/FotaFile.bin"
#define BANBAN_FULLOTA2_URL_MAX 256U
#define BANBAN_FULLOTA2_READ_BUFFER_SIZE 4096U
#define BANBAN_FULLOTA2_TASK_STACK_SIZE (12U * 1024U)
#define BANBAN_FULLOTA2_TASK_PRIORITY 100U
#define BANBAN_FULLOTA2_HTTP_TIMEOUT_MS 30000
#define BANBAN_FULLOTA2_FBF_OVERHEAD 8192U
typedef struct {
char url[BANBAN_FULLOTA2_URL_MAX];
banban_fullfota2_finished_cb_t finished_cb;
} banban_fullfota2_request_t;
static ql_task_t g_fullfota2_task = NULL;
static volatile int g_fullfota2_running = 0;
static banban_fullfota2_request_t g_fullfota2_request;
static void banban_fullfota2_finish(int result,
struct webclient_session *session,
uint8_t *buffer,
qlFotaImgProcCtxPtr fota)
{
banban_fullfota2_finished_cb_t finished_cb =
g_fullfota2_request.finished_cb;
if (session != NULL) {
webclient_close(session);
}
if (buffer != NULL) {
web_free(buffer);
}
if (fota != NULL) {
ql_fota_deinit(fota);
}
if (result != BANBAN_FULLOTA2_OK) {
ql_fota_fixed_internal_flash_upgrade(0);
if (ql_access(BANBAN_FULLOTA2_PACKAGE_PATH, 0) == 0) {
ql_remove(BANBAN_FULLOTA2_PACKAGE_PATH);
}
}
memset(&g_fullfota2_request, 0, sizeof(g_fullfota2_request));
g_fullfota2_running = 0;
g_fullfota2_task = NULL;
if (finished_cb != NULL) {
finished_cb(result);
}
}
static void banban_fullfota2_task(void *argument)
{
struct webclient_session *session = NULL;
qlFotaImgProcCtxPtr fota = NULL;
uint8_t *buffer = NULL;
int status;
int content_length;
int free_size;
int read_length;
int written = 0;
int next_progress = 10;
int result = BANBAN_FULLOTA2_ERR_HTTP;
struct lfs_info staged_info;
(void)argument;
printf("[FULLOTA2] start url=%s path=%s\r\n",
g_fullfota2_request.url,
BANBAN_FULLOTA2_PACKAGE_PATH);
if (ql_access(BANBAN_FULLOTA2_PACKAGE_PATH, 0) == 0) {
ql_remove(BANBAN_FULLOTA2_PACKAGE_PATH);
}
session = webclient_session_create(2048);
if (session == NULL) {
result = BANBAN_FULLOTA2_ERR_HTTP;
goto failed;
}
webclient_set_timeout(session, BANBAN_FULLOTA2_HTTP_TIMEOUT_MS);
status = webclient_get(session, g_fullfota2_request.url);
if (status != 200) {
printf("[FULLOTA2] HTTP GET failed status=%d\r\n", status);
result = BANBAN_FULLOTA2_ERR_HTTP;
goto failed;
}
content_length = session->content_length;
if (content_length <= (int)BANBAN_FULLOTA2_FBF_OVERHEAD ||
content_length > (int)BANBAN_OTA_APP_PARTITION_SIZE) {
printf("[FULLOTA2] invalid Content-Length=%d\r\n", content_length);
result = BANBAN_FULLOTA2_ERR_LENGTH;
goto failed;
}
free_size = ql_fs_free_size('U');
if (free_size < 0 ||
(uint32_t)free_size <
(uint32_t)content_length + BANBAN_OTA_STAGING_RESERVE_BYTES) {
printf("[FULLOTA2] insufficient space free=%d required=%lu\r\n",
free_size,
(unsigned long)((uint32_t)content_length +
BANBAN_OTA_STAGING_RESERVE_BYTES));
result = BANBAN_FULLOTA2_ERR_SPACE;
goto failed;
}
ql_fota_fixed_internal_flash_upgrade(1);
if (ql_fota_set_package_path(BANBAN_FULLOTA2_PACKAGE_PATH) != 0) {
printf("[FULLOTA2] set package path failed\r\n");
result = BANBAN_FULLOTA2_ERR_INIT;
goto failed;
}
fota = ql_fota_init();
if (fota == NULL) {
printf("[FULLOTA2] ql_fota_init failed\r\n");
result = BANBAN_FULLOTA2_ERR_INIT;
goto failed;
}
buffer = web_malloc(BANBAN_FULLOTA2_READ_BUFFER_SIZE);
if (buffer == NULL) {
printf("[FULLOTA2] allocate read buffer failed\r\n");
result = BANBAN_FULLOTA2_ERR_INIT;
goto failed;
}
while (written < content_length) {
int request_length = content_length - written;
int progress;
if (request_length > (int)BANBAN_FULLOTA2_READ_BUFFER_SIZE) {
request_length = BANBAN_FULLOTA2_READ_BUFFER_SIZE;
}
read_length = webclient_read(session, buffer, request_length);
if (read_length <= 0) {
printf("[FULLOTA2] body read failed ret=%d received=%d expected=%d\r\n",
read_length, written, content_length);
result = BANBAN_FULLOTA2_ERR_HTTP;
goto failed;
}
if (ql_fota_image_write(fota, buffer, read_length) != 0) {
printf("[FULLOTA2] image write failed received=%d chunk=%d\r\n",
written, read_length);
result = BANBAN_FULLOTA2_ERR_WRITE;
goto failed;
}
written += read_length;
progress = (written * 100) / content_length;
if (progress >= next_progress || written == content_length) {
printf("[FULLOTA2] download progress=%d%% bytes=%d/%d\r\n",
progress, written, content_length);
while (next_progress <= progress) {
next_progress += 10;
}
}
}
if (written != content_length) {
result = BANBAN_FULLOTA2_ERR_LENGTH;
goto failed;
}
if (ql_fota_image_flush(fota) != 0) {
printf("[FULLOTA2] image flush failed\r\n");
result = BANBAN_FULLOTA2_ERR_FLUSH;
goto failed;
}
memset(&staged_info, 0, sizeof(staged_info));
if (ql_stat(BANBAN_FULLOTA2_PACKAGE_PATH, &staged_info) != 0 ||
staged_info.size != (uint32_t)content_length) {
printf("[FULLOTA2] staged length mismatch file=%lu expected=%d\r\n",
(unsigned long)staged_info.size, content_length);
result = BANBAN_FULLOTA2_ERR_LENGTH;
goto failed;
}
if (ql_fota_image_verify(fota) != 0) {
printf("[FULLOTA2] FBF verify/setflag failed\r\n");
result = BANBAN_FULLOTA2_ERR_VERIFY;
goto failed;
}
printf("[FULLOTA2] package verified; rebooting into updater\r\n");
banban_fullfota2_finish(BANBAN_FULLOTA2_OK, session, buffer, fota);
ql_rtos_task_sleep_ms(200);
ql_power_reset();
/* ql_power_reset() is not expected to return. Keep the task quiesced if it does. */
while (1) {
ql_rtos_task_sleep_s(60);
}
failed:
printf("[FULLOTA2] failed result=%d\r\n", result);
banban_fullfota2_finish(result, session, buffer, fota);
ql_rtos_task_delete(NULL);
}
int banban_fullfota2_start(const char *url,
banban_fullfota2_finished_cb_t finished_cb)
{
int ret;
size_t url_length;
if (url == NULL || url[0] == '\0') {
return BANBAN_FULLOTA2_ERR_ARGUMENT;
}
url_length = strlen(url);
if (url_length >= sizeof(g_fullfota2_request.url)) {
return BANBAN_FULLOTA2_ERR_ARGUMENT;
}
if (g_fullfota2_running) {
return BANBAN_FULLOTA2_ERR_BUSY;
}
memset(&g_fullfota2_request, 0, sizeof(g_fullfota2_request));
memcpy(g_fullfota2_request.url, url, url_length + 1);
g_fullfota2_request.finished_cb = finished_cb;
g_fullfota2_running = 1;
ret = ql_rtos_task_create(&g_fullfota2_task,
BANBAN_FULLOTA2_TASK_STACK_SIZE,
BANBAN_FULLOTA2_TASK_PRIORITY,
"ota_fullfota2",
banban_fullfota2_task,
NULL);
if (ret != 0) {
memset(&g_fullfota2_request, 0, sizeof(g_fullfota2_request));
g_fullfota2_running = 0;
g_fullfota2_task = NULL;
return BANBAN_FULLOTA2_ERR_TASK;
}
return BANBAN_FULLOTA2_OK;
}
int banban_fullfota2_is_running(void)
{
return g_fullfota2_running ? 1 : 0;
}
void banban_fullfota2_discard_staged_package(void)
{
if (!g_fullfota2_running &&
ql_access(BANBAN_FULLOTA2_PACKAGE_PATH, 0) == 0) {
ql_remove(BANBAN_FULLOTA2_PACKAGE_PATH);
}
}
+31
View File
@@ -20,6 +20,10 @@ KERNEL_FLAGS = ROOT / "ql-kernel/threadx/config/common/default_dflags_1602.mk"
FOTA_HEADER = ROOT / "ql-application/threadx/common/include/ql_fota.h"
BASELINE_HEADER = ROOT / "ql-application/threadx/interface/custom_app/inc/ota_baseline.h"
BUILD_MACRO = ROOT / "ql-application/threadx/interface/custom_app/lib/hal/build_macro.h"
APP_MAKEFILE = ROOT / "ql-application/threadx/interface/custom_app/Makefile"
MQTT_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/mqtt.c"
FULLOTA2_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/ota_fullfota2.c"
LEGACY_HTTP_SOURCE = ROOT / "ql-application/threadx/interface/custom_app/http_aofei.c"
FLASH_SIZE = 0x400000
EXPECTED = {
@@ -139,6 +143,33 @@ def verify_fota_contract() -> None:
if '#define CUSTOM_APP_OTA_BUILD_PROFILE "ota_ready_fs640"' 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")
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")
legacy_http = LEGACY_HTTP_SOURCE.read_text(encoding="utf-8", errors="replace")
if "return ql_fullfota_app_start(url)" in legacy_http:
fail("legacy HTTP polling path can still invoke FULLOTA1")
implementation = FULLOTA2_SOURCE.read_text(encoding="utf-8", errors="replace")
required_steps = (
"ql_fs_free_size('U')",
"ql_fota_fixed_internal_flash_upgrade(1)",
"ql_fota_set_package_path",
"ql_fota_init",
"ql_fota_image_write",
"ql_fota_image_flush",
"ql_stat",
"ql_fota_image_verify",
"ql_power_reset",
)
positions = [implementation.find(step) for step in required_steps]
if any(position < 0 for position in positions) or positions != sorted(positions):
fail("minimal FULLOTA2 prepare/verify/activate sequence is incomplete or reordered")
def verify_app(path: Path) -> None:
if not path.is_file():