/* C2:OTA 的平台无关部分(ota.h)+ `ota` 命令。 下载 / 写 flash / 切分区是 ESP 胶水,这里测不到;测的是会写错而上板不容易查出来的那几件: · 参数校验:URL 只收 http、哈希只收 64 位小写十六进制、size 不许超过一个槽; · 状态判定:下载中拒第二条、**待验证的固件不许再 OTA**(备用槽里是回滚用的旧版)、 同一个镜像已就绪时不重下; · 判重:QoS 1 重投的同一条 ota 不会开工两次; · 回滚判据:该通的那一层网络没通就不算健康,超时判回滚。 */ #include #include #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(); }