mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
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>
149 lines
5.7 KiB
C
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;
|
|
}
|