Files
RuView/firmware/esp32-csi-node/test/test_ota_health.c
T
Mathew Beaneandclaude-flow f6a3a3579f fix(firmware): confirm OTA'd images with a bounded health check
Rollback-enabled nodes (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y) boot an
OTA'd image as PENDING_VERIFY, but nothing called
esp_ota_mark_app_valid_cancel_rollback(), so every OTA reverted at the next
reset, a second OTA was refused until then, and a bad image was never
deliberately rolled back.

- main/ota_health.{c,h}: at boot, if the running image is PENDING_VERIFY,
  poll for STA got-IP and >=1 CSI frame accepted by the stream sender.
  Confirm after CONFIG_OTA_HEALTH_MIN_UPTIME_S (30 s) once both are in;
  roll back via esp_ota_mark_app_invalid_rollback_and_reboot() if they are
  not in by CONFIG_OTA_HEALTH_TIMEOUT_S (120 s from boot). VALID, UNDEFINED,
  NEW and no-otadata/factory images are left alone. The decision is a pure
  header function, host-tested in test/test_ota_health.c (make host_tests).
- CONFIG_OTA_HEALTH_FORCE_FAIL (test only) rolls back where it would have
  confirmed, for proving rollback on hardware.
- GET /ota/status reports ota_state so durability is visible remotely.
- RUNBOOK: replace the nonexistent ota_rollback_boot_check() claim and
  document the behaviour and the hardware check (2.1).

Hardware-verified on an ESP32-S3 (push, confirm, hard reset, forced
rollback) and an ESP32-C6 (push, confirm, hard reset); see ADR-379, next in
this series. Host tests: make host_tests passes, including run_ota_health.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:07 -04:00

149 lines
5.7 KiB
C

/**
* @file test_ota_health.c
* @brief Pins the OTA first-boot health check decision (ADR-379).
*
* Exercises `ota_health_step()` from ../main/ota_health.h directly -- the same
* function the device build runs, not a copy -- so the test and the firmware
* cannot drift apart.
*
* The regression that motivated this: nothing ever called
* esp_ota_mark_app_valid_cancel_rollback(), so on rollback-enabled nodes every
* OTA'd image reverted at the next reset, and a bad image was never caught.
*
* Config under test: timeout 120 s, minimum uptime 30 s.
*/
#include <stdio.h>
#include <string.h>
#include "ota_health.h"
#define TIMEOUT_MS 120000u
#define MIN_UP_MS 30000u
static int g_failures = 0;
static const char *name(ota_health_verdict_t v)
{
switch (v) {
case OTA_HEALTH_WAIT: return "WAIT";
case OTA_HEALTH_MARK_VALID: return "MARK_VALID";
case OTA_HEALTH_ROLLBACK: return "ROLLBACK";
}
return "?";
}
static void expect(ota_health_verdict_t got, ota_health_verdict_t want,
const char *why)
{
if (got != want) {
printf(" FAIL: got %s, expected %s\n %s\n", name(got), name(want), why);
g_failures++;
}
}
static void expect_reason(const ota_health_sm_t *sm, const char *needle,
const char *why)
{
if (strstr(sm->reason, needle) == NULL) {
printf(" FAIL: reason \"%s\" lacks \"%s\"\n %s\n",
sm->reason, needle, why);
g_failures++;
}
}
static const ota_health_cfg_t CFG = { TIMEOUT_MS, MIN_UP_MS, false };
int main(void)
{
ota_health_sm_t sm;
printf("ota health check (timeout %u ms, min uptime %u ms)\n",
TIMEOUT_MS, MIN_UP_MS);
/* Healthy boot: IP at 4 s, first CSI frame at 6 s, confirmed at 30 s. */
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &CFG, false, false, 1000), OTA_HEALTH_WAIT,
"nothing yet, well inside the deadline");
expect(ota_health_step(&sm, &CFG, true, false, 4000), OTA_HEALTH_WAIT,
"IP alone is not enough");
expect(ota_health_step(&sm, &CFG, false, true, 6000), OTA_HEALTH_WAIT,
"both signals in, but below the minimum uptime");
expect(ota_health_step(&sm, &CFG, false, false, 29999), OTA_HEALTH_WAIT,
"one ms short of the minimum uptime");
expect(ota_health_step(&sm, &CFG, false, false, 30000), OTA_HEALTH_MARK_VALID,
"latched IP + CSI at the minimum uptime must confirm the image");
expect_reason(&sm, "IP", "the log names the signals that passed");
/* Terminal: a verdict never changes, even past the deadline. */
expect(ota_health_step(&sm, &CFG, false, false, 500000), OTA_HEALTH_MARK_VALID,
"a confirmed image must not later be rolled back by the same check");
/* Signals arriving after the minimum uptime confirm immediately. */
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &CFG, true, true, 45000), OTA_HEALTH_MARK_VALID,
"both signals after min uptime -> confirm now");
/* No IP by the deadline. */
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &CFG, false, false, 119999), OTA_HEALTH_WAIT,
"one ms before the deadline");
expect(ota_health_step(&sm, &CFG, false, false, 120000), OTA_HEALTH_ROLLBACK,
"no signals at the deadline -> roll back");
expect_reason(&sm, "no IP and no CSI", "both missing");
/* IP but the CSI pipeline never delivers. */
ota_health_sm_init(&sm);
ota_health_step(&sm, &CFG, true, false, 5000);
expect(ota_health_step(&sm, &CFG, false, false, 120000), OTA_HEALTH_ROLLBACK,
"associated but no CSI frame -> roll back");
expect_reason(&sm, "no CSI frame", "names the missing CSI signal");
/* CSI sent (can happen only with IP in practice) but no IP event seen. */
ota_health_sm_init(&sm);
ota_health_step(&sm, &CFG, false, true, 5000);
expect(ota_health_step(&sm, &CFG, false, false, 130000), OTA_HEALTH_ROLLBACK,
"no IP -> roll back");
expect_reason(&sm, "no IP", "names the missing IP signal");
/* Rollback is terminal too. */
expect(ota_health_step(&sm, &CFG, true, true, 131000), OTA_HEALTH_ROLLBACK,
"late signals must not resurrect a rolled-back verdict");
/* Force-fail hook rolls back exactly where it would have confirmed. */
{
const ota_health_cfg_t ff = { TIMEOUT_MS, MIN_UP_MS, true };
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &ff, true, true, 10000), OTA_HEALTH_WAIT,
"force-fail still waits for the minimum uptime");
expect(ota_health_step(&sm, &ff, false, false, 30000), OTA_HEALTH_ROLLBACK,
"force-fail must roll back instead of confirming");
expect_reason(&sm, "FORCE_FAIL", "names the test hook");
}
/* Minimum uptime above the timeout is clamped, not a guaranteed rollback. */
{
const ota_health_cfg_t bad = { 60000u, 600000u, false };
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &bad, true, true, 59999), OTA_HEALTH_WAIT,
"clamped min uptime: still waiting just before it");
expect(ota_health_step(&sm, &bad, false, false, 60000), OTA_HEALTH_MARK_VALID,
"healthy at the deadline must confirm, not roll back");
}
/* Zero minimum uptime confirms on the first step with both signals. */
{
const ota_health_cfg_t zero = { TIMEOUT_MS, 0u, false };
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &zero, true, true, 0), OTA_HEALTH_MARK_VALID,
"no soak configured -> confirm immediately");
}
if (g_failures) {
printf("FAILED: %d case(s)\n", g_failures);
return 1;
}
printf("OK: all OTA health cases pass\n");
return 0;
}