From bbd5e84db03c5704ac2fa01d1e5367cba773329f Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Fri, 8 May 2026 12:18:32 -0400 Subject: [PATCH] feat(operation): send X-Boot-Reason so power-cycle is a force-resync Distinguish a cold-boot poll (UNDEFINED wakeup cause = power-on, hard reset, plug-cycle) from a normal timer wake. Encoded as the X-Boot-Reason request header; server uses it to deliberately bypass the schedule and rotate. Matches how users actually use the device: unplug-and-replug as a manual refresh. Tests: two new native cases asserting the header is "cold" on UNDEFINED wakeup and "timer" on TIMER wakeup. esp_sleep mock now exposes a settable wakeup_cause global. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/operation.h | 9 +++++++++ test/mocks/esp_sleep.h | 14 +++++++++++++ test/test_normal_operation/test_main.cpp | 25 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/src/operation.h b/src/operation.h index b4fd74e..dea0e28 100644 --- a/src/operation.h +++ b/src/operation.h @@ -127,6 +127,15 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre http.addHeader("X-Current-Image-Id", String(currentImgId)); } + // Tell the server how we got here. The server uses this to honor a + // power-cycle as a deliberate "force resync" — a poll that arrives with + // X-Boot-Reason: cold gets a fresh rotation even outside configured wake + // times, so unplugging and replugging the frame works as a manual refresh. + // Timer wakes (the normal case) keep their schedule-gated semantics. + const esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); + http.addHeader("X-Boot-Reason", + cause == ESP_SLEEP_WAKEUP_TIMER ? "timer" : "cold"); + const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id", "X-Image-Sha256" }; http.collectHeaders(collectHeaders, 3); int code = http.GET(); diff --git a/test/mocks/esp_sleep.h b/test/mocks/esp_sleep.h index 5f4188a..df00c54 100644 --- a/test/mocks/esp_sleep.h +++ b/test/mocks/esp_sleep.h @@ -4,5 +4,19 @@ extern uint64_t g_sleep_us; extern bool g_deep_sleep_started; +// Mirror of the ESP-IDF wakeup-cause enum that the firmware actually checks. +// Tests set g_wakeup_cause directly to simulate cold-boot vs timer-wake. +typedef enum { + ESP_SLEEP_WAKEUP_UNDEFINED = 0, // cold boot / power-on / hard reset + ESP_SLEEP_WAKEUP_EXT0 = 2, + ESP_SLEEP_WAKEUP_EXT1 = 3, + ESP_SLEEP_WAKEUP_TIMER = 4, // returned to userland after deep-sleep timer + ESP_SLEEP_WAKEUP_TOUCHPAD = 5, + ESP_SLEEP_WAKEUP_ULP = 6, +} esp_sleep_wakeup_cause_t; + +extern esp_sleep_wakeup_cause_t g_wakeup_cause; + inline void esp_sleep_enable_timer_wakeup(uint64_t us) { g_sleep_us = us; } inline void esp_deep_sleep_start() { g_deep_sleep_started = true; } +inline esp_sleep_wakeup_cause_t esp_sleep_get_wakeup_cause() { return g_wakeup_cause; } diff --git a/test/test_normal_operation/test_main.cpp b/test/test_normal_operation/test_main.cpp index 7224457..d0b74b7 100644 --- a/test/test_normal_operation/test_main.cpp +++ b/test/test_normal_operation/test_main.cpp @@ -34,6 +34,7 @@ int g_epd_draw_border_count, g_epd_draw_border_last_color, g_epd_draw_border_las uint64_t g_sleep_us; bool g_deep_sleep_started; +esp_sleep_wakeup_cause_t g_wakeup_cause; // Globals for new mocks int g_show_setup_qr_count; @@ -66,6 +67,7 @@ void reset_state() { g_epd_draw_border_count = g_epd_draw_border_last_color = g_epd_draw_border_last_thickness = 0; g_sleep_us = 0; g_deep_sleep_started = false; + g_wakeup_cause = ESP_SLEEP_WAKEUP_TIMER; // default to timer wake unless a test sets cold g_show_setup_qr_count = 0; g_millis_value = 0; g_digital_read_value = HIGH; // button not pressed by default @@ -291,6 +293,27 @@ void test_fw10b_server_interval_clamped_to_min() { TEST_ASSERT_EQUAL_UINT64(SLEEP_CLAMP_MIN_MS * 1000ULL, g_sleep_us); } +// FW-FORCE-RESYNC: a wakeup cause of UNDEFINED (cold boot, hard reset, +// power-cycle) MUST surface to the server as X-Boot-Reason: cold so that +// the server can force a rotation regardless of the wakeTimes schedule. +// This is what makes "unplug → replug" a manual refresh feature for users. +void test_fw_cold_boot_sends_X_Boot_Reason_cold() { + g_wakeup_cause = ESP_SLEEP_WAKEUP_UNDEFINED; + g_http_response_headers["X-Image-Id"] = "1"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL_STRING("cold", g_http_request_headers["X-Boot-Reason"].c_str()); +} + +// Inverse of FW-FORCE-RESYNC: scheduled timer wakeups must not pretend to be +// power-cycles, or every poll would force a rotation and the schedule gating +// would be useless. +void test_fw_timer_wake_sends_X_Boot_Reason_timer() { + g_wakeup_cause = ESP_SLEEP_WAKEUP_TIMER; + g_http_response_headers["X-Image-Id"] = "1"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL_STRING("timer", g_http_request_headers["X-Boot-Reason"].c_str()); +} + // FW-11: no X-Interval-Ms → fallback used. Should not happen in normal // operation but guards against rolling deploys / hand-crafted responses. void test_fw11_fallback_used_when_header_absent() { @@ -403,6 +426,8 @@ int main(int argc, char** argv) { RUN_TEST(test_fw10_server_interval_clamped_to_max); RUN_TEST(test_fw10b_server_interval_clamped_to_min); RUN_TEST(test_fw11_fallback_used_when_header_absent); + RUN_TEST(test_fw_cold_boot_sends_X_Boot_Reason_cold); + RUN_TEST(test_fw_timer_wake_sends_X_Boot_Reason_timer); RUN_TEST(test_fw12_ap_ssid_from_mac_aabbcc); RUN_TEST(test_fw13_ap_ssid_from_real_mac); RUN_TEST(test_fw14_304_skips_epd_sleep);