文件
phonerail/firmware/test/test_ota.c
T
zhou yang和Claude Opus 5.5 ae1aad3b7c 固件 C2:OTA 双槽 + 失败回滚(已上板实测正常 / 哈希错 / 回滚三条路径)
- 分区重排(16 MB,nvs 0x9000 不动):ota_0 / ota_1 各 3 MB、otadata 0x610000、evlog 挪到 0x612000;
  flash 大小改正为 16 MB;开 CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
- ota.{h,c}(宿主机可测):URL / sha256 校验、判定(下载中 busy、待验证拒、同镜像不重下)、健康判据
- ota_esp.{h,c}:t_ota 任务经 HTTP 下载、边下边算 sha256、核 project_name 后切启动分区;
  新固件 120 s 内连不上 broker 就回滚(t_link 判 + esp_timer 兜底);重启前等回应发出、事件落盘
- 新命令 ota = 13(两条链路都收,reboot 默认 false:复位 = 五台手机断电一次);status 加 ota 一块;
  新事件 LOG_EV_OTA = 18;PROJECT_VER 取 PHONERAIL_FW_VERSION
- test_ota.c 13 例,全工程 442 例全绿;版本 0.1.0-c2
- 服务器:nginx 7765 只放行 /fw/<16 hex>.bin(不过门户,路由器转发已加);tools/ota_push.py 推送;
  rack protocol.py 加事件 18 / 命令 13(已部署)
- 板 B:推送到自证健康 19 s;错哈希不切分区;删掉 broker 账号后 120 s 回滚,bad 报出被回滚的版本

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 14:03:13 +08:00

386 行
15 KiB
C
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* C2:OTA 的平台无关部分(ota.h)+ `ota` 命令。
下载 / 写 flash / 切分区是 ESP 胶水,这里测不到;测的是会写错而上板不容易查出来的那几件:
· 参数校验:URL 只收 http、哈希只收 64 位小写十六进制、size 不许超过一个槽;
· 状态判定:下载中拒第二条、**待验证的固件不许再 OTA**(备用槽里是回滚用的旧版)、
同一个镜像已就绪时不重下;
· 判重:QoS 1 重投的同一条 ota 不会开工两次;
· 回滚判据:该通的那一层网络没通就不算健康,超时判回滚。 */
#include <stdio.h>
#include <string.h>
#include "cmd.h"
#include "evlog.h"
#include "ota.h"
#include "sim_hal.h"
#include "unity.h"
static char s_out[CMD_OUT_MAX];
#define SHA_A "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
#define SHA_B "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
#define URL_A "http://home.example:7765/fw/0011223344556677.bin"
/* 假胶水:记下收到的请求、可以让开工失败。 */
static int s_starts;
static int s_reboots;
static ota_req_t s_last;
static hal_err_t s_start_err;
static hal_err_t fake_start(void *ctx, const ota_req_t *req)
{
(void)ctx;
if (s_start_err != HAL_OK) {
return s_start_err;
}
s_last = *req;
s_starts++;
return HAL_OK;
}
static void fake_reboot(void *ctx)
{
(void)ctx;
s_reboots++;
}
void setUp(void)
{
sim_hal_reset();
s_starts = 0;
s_reboots = 0;
s_start_err = HAL_OK;
memset(&s_last, 0, sizeof(s_last));
ota_init(fake_start, fake_reboot, NULL);
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
cmd_init(NULL);
}
void tearDown(void) { TEST_ASSERT_EQUAL_INT(0, sim_crit_depth()); }
static const char *send_on(cmd_link_t link, const char *line)
{
cmd_handle_line(link, line, s_out, sizeof(s_out));
return s_out;
}
static const char *send(const char *line) { return send_on(CMD_LINK_WIFI, line); }
static void assert_has_at(const char *resp, const char *needle, int line)
{
UNITY_TEST_ASSERT_NOT_NULL(strstr(resp, needle), line, resp);
}
#define ASSERT_HAS(r, n) assert_has_at((r), (n), __LINE__)
static int count_rejects(void)
{
evlog_rec_t rec[64];
(void)evlog_flush(0);
const uint32_t n = evlog_read(0, rec, 64);
int k = 0;
for (uint32_t i = 0; i < n; i++) {
k += rec[i].type == LOG_EV_CMD_REJECT;
}
return k;
}
static void set_phase(ota_phase_t p, const char *sha_hex, bool pending)
{
ota_status_t st;
ota_status_get(&st);
st.phase = p;
st.pending = pending;
if (sha_hex != NULL) {
TEST_ASSERT_TRUE(ota_parse_sha256(sha_hex, st.sha256));
}
ota_status_set(&st);
}
/* ---- 校验 ---------------------------------------------------------------- */
static void test_url_rules(void)
{
TEST_ASSERT_TRUE(ota_url_ok(URL_A));
TEST_ASSERT_TRUE(ota_url_ok("http://10.5.5.13/a.bin"));
TEST_ASSERT_FALSE(ota_url_ok("https://home.example/a.bin")); /* 板子上没有下载服务器的 CA */
TEST_ASSERT_FALSE(ota_url_ok("ftp://x/a.bin"));
TEST_ASSERT_FALSE(ota_url_ok("http://"));
TEST_ASSERT_FALSE(ota_url_ok("http://a b/c.bin"));
TEST_ASSERT_FALSE(ota_url_ok("http://a/\"x"));
TEST_ASSERT_FALSE(ota_url_ok(NULL));
char long_url[OTA_URL_MAX + 2];
memset(long_url, 'a', sizeof(long_url));
memcpy(long_url, "http://", 7);
long_url[OTA_URL_MAX] = '\0';
TEST_ASSERT_TRUE(ota_url_ok(long_url)); /* 正好 64 */
long_url[OTA_URL_MAX] = 'a';
long_url[OTA_URL_MAX + 1] = '\0';
TEST_ASSERT_FALSE(ota_url_ok(long_url));
}
static void test_sha256_parse_is_strict_and_round_trips(void)
{
uint8_t sha[32];
TEST_ASSERT_TRUE(ota_parse_sha256(SHA_A, sha));
TEST_ASSERT_EQUAL_HEX8(0x00, sha[0]);
TEST_ASSERT_EQUAL_HEX8(0xff, sha[31]);
char hex[65];
ota_sha256_hex(sha, hex);
TEST_ASSERT_EQUAL_STRING(SHA_A, hex);
/* 大写不收:「同一个镜像」只许一种写法(READY 时靠它判是不是同一个) */
TEST_ASSERT_FALSE(ota_parse_sha256(
"00112233445566778899AABBCCDDEEFF00112233445566778899aabbccddeeff", sha));
TEST_ASSERT_FALSE(ota_parse_sha256(SHA_A "0", sha));
TEST_ASSERT_FALSE(ota_parse_sha256("0011", sha));
TEST_ASSERT_FALSE(ota_parse_sha256(
"g0112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", sha));
/* 半截错的不许写进 out */
uint8_t keep[32];
memset(keep, 0x5A, sizeof(keep));
TEST_ASSERT_FALSE(ota_parse_sha256(
"001122334455667788990000000000000000000000000000000000000000000z", keep));
TEST_ASSERT_EACH_EQUAL_HEX8(0x5A, keep, sizeof(keep));
}
/* ---- 判定 ---------------------------------------------------------------- */
static void test_plan(void)
{
ota_status_t st;
memset(&st, 0, sizeof(st));
ota_req_t r;
memset(&r, 0, sizeof(r));
TEST_ASSERT_TRUE(ota_parse_sha256(SHA_A, r.sha256));
TEST_ASSERT_EQUAL_INT(OTA_ACT_START, ota_plan(&st, &r));
st.phase = OTA_DL;
TEST_ASSERT_EQUAL_INT(OTA_ACT_BUSY, ota_plan(&st, &r));
st.phase = OTA_READY;
memcpy(st.sha256, r.sha256, 32);
TEST_ASSERT_EQUAL_INT(OTA_ACT_NOTHING, ota_plan(&st, &r));
r.reboot = true;
TEST_ASSERT_EQUAL_INT(OTA_ACT_REBOOT, ota_plan(&st, &r));
st.sha256[5] ^= 1; /* 已就绪的是另一个镜像 ⇒ 重下 */
TEST_ASSERT_EQUAL_INT(OTA_ACT_START, ota_plan(&st, &r));
/* ⭐ 待验证:备用槽里是回滚用的旧版,覆盖了就没有退路 */
st.phase = OTA_IDLE;
st.pending = true;
TEST_ASSERT_EQUAL_INT(OTA_ACT_PENDING, ota_plan(&st, &r));
}
/* ---- 命令 ---------------------------------------------------------------- */
/* ⭐ 正常开工:请求原样交给胶水,status 立刻看得到「在下载」,默认不重启。 */
static void test_ota_command_starts_a_download(void)
{
const char *r = send("{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":987654}");
ASSERT_HAS(r, "\"ok\":true");
ASSERT_HAS(r, "\"act\":\"start\"");
TEST_ASSERT_EQUAL_INT(1, s_starts);
TEST_ASSERT_EQUAL_STRING(URL_A, s_last.url);
TEST_ASSERT_EQUAL_UINT32(987654, s_last.size);
TEST_ASSERT_FALSE(s_last.reboot); /* ⚠️ 默认不重启:重启 = 五路全断一次 */
char hex[65];
ota_sha256_hex(s_last.sha256, hex);
TEST_ASSERT_EQUAL_STRING(SHA_A, hex);
r = send("{\"cmd\":\"status\"}");
ASSERT_HAS(r, "\"ota\":{\"st\":\"dl\",\"got\":0,\"size\":987654,\"err\":null,\"pend\":false,"
"\"bad\":null}");
/* 下载中再来一条:busy,不开第二个 */
r = send("{\"seq\":2,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_B
"\",\"size\":1000,\"reboot\":true}");
ASSERT_HAS(r, "\"err\":\"busy\"");
TEST_ASSERT_EQUAL_INT(1, s_starts);
}
/* ⭐ QoS 1 重投:同一个 seq 的 ota 只开工一次,回第一次的结论。 */
static void test_redelivered_ota_starts_once(void)
{
const char *line = "{\"seq\":7,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000,\"reboot\":true}";
ASSERT_HAS(send(line), "\"ok\":true");
ASSERT_HAS(send(line), "\"dup\":true");
TEST_ASSERT_EQUAL_INT(1, s_starts);
TEST_ASSERT_TRUE(s_last.reboot);
}
static void test_bad_arguments(void)
{
/* 缺字段 / 类型不对 / 格式不对:arg,不记事件 */
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"size\":1000}"),
"\"err\":\"arg\"");
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"ota\",\"url\":\"https://x/a\",\"sha256\":\"" SHA_A
"\",\"size\":1000}"),
"\"err\":\"arg\"");
ASSERT_HAS(send("{\"seq\":3,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":\"1000\"}"),
"\"err\":\"arg\"");
ASSERT_HAS(send("{\"seq\":4,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000,\"reboot\":1}"),
"\"err\":\"arg\""); /* reboot 是 true/false,不是 0/1 */
TEST_ASSERT_EQUAL_INT(0, count_rejects());
/* size 越界:range,记事件 */
ASSERT_HAS(send("{\"seq\":5,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":0}"),
"\"err\":\"range\"");
char line[256];
snprintf(line, sizeof(line),
"{\"seq\":6,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A "\",\"size\":%lu}",
(unsigned long)OTA_SLOT_BYTES + 1u);
ASSERT_HAS(send(line), "\"err\":\"range\"");
TEST_ASSERT_EQUAL_INT(2, count_rejects());
/* 没 seq:会动东西的命令必须带 */
ASSERT_HAS(send("{\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A "\",\"size\":10}"),
"\"err\":\"no_seq\"");
TEST_ASSERT_EQUAL_INT(0, s_starts);
}
/* ⭐ 待验证中的固件不许再 OTA:state + 记事件;胶水一次都不碰。 */
static void test_pending_image_refuses_ota(void)
{
set_phase(OTA_IDLE, NULL, true);
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000}"),
"\"err\":\"state\"");
TEST_ASSERT_EQUAL_INT(0, s_starts);
TEST_ASSERT_EQUAL_INT(1, count_rejects());
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"pend\":true");
}
/* 同一个镜像已就绪:带 reboot 就直接重启、不重下;不带就什么都不做。 */
static void test_ready_image_is_not_downloaded_again(void)
{
set_phase(OTA_READY, SHA_A, false);
const char *r = send("{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000}");
ASSERT_HAS(r, "\"act\":\"ready\"");
TEST_ASSERT_EQUAL_INT(0, s_reboots);
r = send("{\"seq\":2,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000,\"reboot\":true}");
ASSERT_HAS(r, "\"act\":\"reboot\"");
TEST_ASSERT_EQUAL_INT(1, s_reboots);
TEST_ASSERT_EQUAL_INT(0, s_starts);
/* 换了个镜像:重下 */
r = send("{\"seq\":3,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_B
"\",\"size\":1000}");
ASSERT_HAS(r, "\"act\":\"start\"");
TEST_ASSERT_EQUAL_INT(1, s_starts);
}
/* 没有 OTA 能力(胶水没登记 / 任务起不来):state,状态回到 idle,不卡在 dl。 */
static void test_no_ota_capability(void)
{
s_start_err = HAL_ERR_STATE;
ASSERT_HAS(send("{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000}"),
"\"err\":\"state\"");
ASSERT_HAS(send("{\"cmd\":\"status\"}"), "\"ota\":{\"st\":\"idle\"");
ota_init(NULL, NULL, NULL);
ASSERT_HAS(send("{\"seq\":2,\"cmd\":\"ota\",\"url\":\"" URL_A "\",\"sha256\":\"" SHA_A
"\",\"size\":1000}"),
"\"err\":\"state\"");
}
/* 经 USB 来的 ota 也收(下载走 WiFi,命令从哪来都行)。 */
static void test_ota_over_usb_is_accepted(void)
{
ASSERT_HAS(send_on(CMD_LINK_USB, "{\"seq\":1,\"cmd\":\"ota\",\"url\":\"" URL_A
"\",\"sha256\":\"" SHA_A "\",\"size\":1000}"),
"\"ok\":true");
TEST_ASSERT_EQUAL_INT(1, s_starts);
}
/* status 的 ota 一块:失败原因与回滚掉的版本号;版本号里的怪字符不许破坏 JSON。 */
static void test_status_reports_failure_and_rolled_back_version(void)
{
ota_status_t st;
ota_status_get(&st);
st.err = OTA_E_HASH;
st.size = 1234;
snprintf(st.bad, sizeof(st.bad), "0.1.0-\"x");
ota_status_set(&st);
const char *r = send("{\"cmd\":\"status\"}");
ASSERT_HAS(r, "\"ota\":{\"st\":\"idle\",\"got\":0,\"size\":1234,\"err\":\"hash\",\"pend\":false,"
"\"bad\":\"0.1.0-?x\"}");
}
/* ---- 健康判据 ------------------------------------------------------------ */
static void test_health(void)
{
ota_health_in_t in = { .pending = false };
TEST_ASSERT_EQUAL_INT(OTA_H_NONE, ota_health(&in));
in = (ota_health_in_t){ .pending = true, .now_ms = 5000, .boot_done = true, .wifi_set = true,
.wifi_up = true, .mqtt_set = true, .mqtt_up = false };
/* ⭐ 配了 broker:WiFi 通了不算数,MQTT 才是 OTA 的通道 */
TEST_ASSERT_EQUAL_INT(OTA_H_WAIT, ota_health(&in));
in.mqtt_up = true;
TEST_ASSERT_EQUAL_INT(OTA_H_VALID, ota_health(&in));
/* 启动没走完不算健康 */
in.boot_done = false;
TEST_ASSERT_EQUAL_INT(OTA_H_WAIT, ota_health(&in));
/* 超时:正好 120 s 判回滚,差 1 ms 不判 */
in = (ota_health_in_t){ .pending = true, .now_ms = OTA_HEALTH_MS - 1, .boot_done = true,
.wifi_set = true, .wifi_up = true, .mqtt_set = true };
TEST_ASSERT_EQUAL_INT(OTA_H_WAIT, ota_health(&in));
in.now_ms = OTA_HEALTH_MS;
TEST_ASSERT_EQUAL_INT(OTA_H_ROLLBACK, ota_health(&in));
/* 超时之后才连上:也算健康(胶水那一圈先问的是这个) */
in.mqtt_up = true;
TEST_ASSERT_EQUAL_INT(OTA_H_VALID, ota_health(&in));
/* 只配了 WiFi:WiFi 通就行 */
in = (ota_health_in_t){ .pending = true, .now_ms = 1000, .boot_done = true, .wifi_set = true };
TEST_ASSERT_EQUAL_INT(OTA_H_WAIT, ota_health(&in));
in.wifi_up = true;
TEST_ASSERT_EQUAL_INT(OTA_H_VALID, ota_health(&in));
/* 都没配(只经 USB 管的板子):启动走完就行,不会因为「没网」被误回滚 */
in = (ota_health_in_t){ .pending = true, .now_ms = 1000, .boot_done = true };
TEST_ASSERT_EQUAL_INT(OTA_H_VALID, ota_health(&in));
}
static void test_names(void)
{
TEST_ASSERT_EQUAL_STRING("ota", cmd_name(CMD_OTA));
TEST_ASSERT_EQUAL_STRING("hash", ota_err_str(OTA_E_HASH));
TEST_ASSERT_EQUAL_STRING("noslot", ota_err_str(OTA_E_NOSLOT));
TEST_ASSERT_EQUAL_STRING("?", ota_err_str(OTA_E_COUNT));
TEST_ASSERT_EQUAL_STRING("ready", ota_phase_str(OTA_READY));
/* 进 flash 的数字(LOG_EV_OTA 的 arg / bus_mv)钉死 */
TEST_ASSERT_EQUAL_INT(1, OTA_EV_START);
TEST_ASSERT_EQUAL_INT(6, OTA_EV_REBOOT);
TEST_ASSERT_EQUAL_INT(3, OTA_E_HASH);
TEST_ASSERT_EQUAL_INT(7, OTA_E_NOSLOT);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_url_rules);
RUN_TEST(test_sha256_parse_is_strict_and_round_trips);
RUN_TEST(test_plan);
RUN_TEST(test_ota_command_starts_a_download);
RUN_TEST(test_redelivered_ota_starts_once);
RUN_TEST(test_bad_arguments);
RUN_TEST(test_pending_image_refuses_ota);
RUN_TEST(test_ready_image_is_not_downloaded_again);
RUN_TEST(test_no_ota_capability);
RUN_TEST(test_ota_over_usb_is_accepted);
RUN_TEST(test_status_reports_failure_and_rolled_back_version);
RUN_TEST(test_health);
RUN_TEST(test_names);
return UNITY_END();
}