From 2df2a14df6eeaf1bcd549eb04af5ba963e65b85c Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Fri, 8 May 2026 16:13:53 -0400 Subject: [PATCH] feat(operation): poll every 15s until first image lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freshly-claimed device on a noon-daily schedule would otherwise sit dark for up to 24 h after WiFi setup waiting for its first image. The schedule kicks in only AFTER an image has actually been displayed. Mechanism: at the bottom of normal_operation_impl, re-read NVS_KEY_IMG_ID to see whether any successful 200-with-integrity-OK persisted an image id this cycle (or any prior). If still -1, override sleepMs to FIRST_IMAGE_POLL_INTERVAL_MS (15 s) — bypassing the schedule and the clamp range, since SLEEP_CLAMP_MIN_MS is about runaway protection in steady state and the bootstrap window is naturally bounded by "first image arrives." Tests: - FW-FIRST-IMG-A: 204 with no img_id in NVS → 15s override fires even when server says 6 hours. - FW-FIRST-IMG-B: img_id pre-set, 200 cycle → server interval honored (override doesn't trap the device in 15s forever). - FW-FIRST-IMG-C: first 200 ever (img_id was -1, now persisted) → server interval applies starting THIS cycle, no extra 15s nap. Also patched FW-03 (304 sleep timing) to pre-set img_id so the test exercises what it claims; 304 in production only happens when the device already holds the image, so the override would never fire there. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config.h | 8 ++++ src/operation.h | 12 ++++++ test/mocks/config.h | 1 + test/test_normal_operation/test_main.cpp | 50 ++++++++++++++++++++++++ 4 files changed, 71 insertions(+) diff --git a/src/config.h b/src/config.h index 9190eef..e98ee3a 100644 --- a/src/config.h +++ b/src/config.h @@ -148,4 +148,12 @@ #ifndef SLEEP_CLAMP_MAX_MS #define SLEEP_CLAMP_MAX_MS (25ULL * 60ULL * 60ULL * 1000ULL) // 25 h — past 24h with DST slack #endif +// Until the device receives its first image (img_id still -1), poll fast +// so the user's first photo lands within ~15 s of finishing setup — +// regardless of what schedule the server's X-Interval-Ms encodes. Bypasses +// SLEEP_CLAMP_MIN_MS because that's about runaway protection in the +// steady state; the bootstrap window is bounded by "first image arrives." +#ifndef FIRST_IMAGE_POLL_INTERVAL_MS +#define FIRST_IMAGE_POLL_INTERVAL_MS 15000ULL +#endif #define IMAGE_PATH "/img.bin" diff --git a/src/operation.h b/src/operation.h index dc530e4..61ce69d 100644 --- a/src/operation.h +++ b/src/operation.h @@ -303,6 +303,18 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre // entire poll interval on every 304 response. if (displayInitialized) epd_sleep(); + // Bootstrap-fast polling: until we've ever displayed an image, ignore + // the schedule and poll every 15 s. Without this, a freshly-claimed + // device whose owner has wakeTimes set to e.g. noon would sit dark for + // up to 24 h before the first photo lands. We re-read img_id from NVS + // because the 200 path persists it AFTER the local var was captured. + prefs.begin(NVS_NAMESPACE, true); + int32_t imgIdAfter = prefs.getInt(NVS_KEY_IMG_ID, -1); + prefs.end(); + if (imgIdAfter < 0) { + sleepMs = FIRST_IMAGE_POLL_INTERVAL_MS; + } + esp_sleep_enable_timer_wakeup(sleepMs * 1000ULL); esp_deep_sleep_start(); } diff --git a/test/mocks/config.h b/test/mocks/config.h index 4bedbbf..cc2d9ac 100644 --- a/test/mocks/config.h +++ b/test/mocks/config.h @@ -12,6 +12,7 @@ #define FETCH_INTERVAL_MS_FALLBACK 60000ULL #define SLEEP_CLAMP_MIN_MS 30000ULL #define SLEEP_CLAMP_MAX_MS (25ULL * 60ULL * 60ULL * 1000ULL) +#define FIRST_IMAGE_POLL_INTERVAL_MS 15000ULL #define WIFI_TIMEOUT_MS 30000 #define RESET_HOLD_MS 5000 #define AP_IP "192.168.4.1" diff --git a/test/test_normal_operation/test_main.cpp b/test/test_normal_operation/test_main.cpp index e5337bf..c24d932 100644 --- a/test/test_normal_operation/test_main.cpp +++ b/test/test_normal_operation/test_main.cpp @@ -112,6 +112,11 @@ void test_fw03_304_no_redraw() { g_http_get_code = 304; // 30 s — at SLEEP_CLAMP_MIN_MS, well under MAX, so honored as-is g_http_response_headers["X-Interval-Ms"] = "30000"; + // 304 only ever happens when the device already holds the image, so + // pre-set img_id to match what the server would have served. Without + // this the FIRST_IMAGE_POLL bootstrap override would (correctly) + // shorten sleep to 15 s — and that's not what this test exercises. + prefs.ints[NVS_KEY_IMG_ID] = 1; normal_operation_impl(String("mac"), http, String("url"), prefs); @@ -347,6 +352,48 @@ void test_fw_X_Claimed_response_clears_flag() { TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_JUST_PROVISIONED, -1)); } +// FW-FIRST-IMG-A: device has never received an image (img_id = -1) AND the +// poll didn't deliver one (e.g. 204 because no images approved yet). Sleep +// must be the 15s bootstrap interval, NOT whatever the server's schedule +// says. Without this, a fresh device on a noon-daily schedule sits dark +// for up to 24 h before the first photo lands. +void test_fw_first_image_bootstrap_polls_at_15s_when_no_image_yet() { + g_http_get_code = 204; + // Server tries to set a 6-hour interval for the user's noon-daily schedule. + g_http_response_headers["X-Interval-Ms"] = String((unsigned long)(6ULL * 60 * 60 * 1000)).c_str(); + // No img_id in NVS → device has never seen an image. + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL_UINT64(FIRST_IMAGE_POLL_INTERVAL_MS * 1000ULL, g_sleep_us); +} + +// FW-FIRST-IMG-B: once we've persisted an image (200 path wrote img_id), the +// server's schedule wins again. Without this assertion the override would +// trap the device in 15s polling forever and burn the battery. +void test_fw_first_image_bootstrap_clears_after_200() { + // Pre-set NVS as if we'd received an image previously. + prefs.ints[NVS_KEY_IMG_ID] = 42; + g_http_get_code = 200; + g_http_response_headers["X-Image-Id"] = "42"; + g_http_response_headers["X-Interval-Ms"] = "300000"; // 5 min (well above 15s) + g_http_body = "BINDATA"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL_UINT64(300000ULL * 1000ULL, g_sleep_us); +} + +// FW-FIRST-IMG-C: receiving the FIRST image (200 with previously -1 img_id) +// must let the server's schedule take over starting from this very poll — +// no extra "one more 15s sleep" cycle. +void test_fw_first_image_just_arrived_uses_server_interval() { + // img_id starts at -1 (default, no prior image). + g_http_get_code = 200; + g_http_response_headers["X-Image-Id"] = "1"; + g_http_response_headers["X-Interval-Ms"] = "300000"; + g_http_body = "BINDATA"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + // The 200 path persists img_id=1 BEFORE sleep is computed. + TEST_ASSERT_EQUAL_UINT64(300000ULL * 1000ULL, g_sleep_us); +} + // FW-PROV-D: server omits X-Claimed (e.g. stale-binding 204) → flag stays // set so the device keeps signaling "I'm freshly provisioned" until a // later poll lands on a fresh-binding response. @@ -476,6 +523,9 @@ int main(int argc, char** argv) { RUN_TEST(test_fw_no_flag_means_no_header); RUN_TEST(test_fw_X_Claimed_response_clears_flag); RUN_TEST(test_fw_no_X_Claimed_response_keeps_flag); + RUN_TEST(test_fw_first_image_bootstrap_polls_at_15s_when_no_image_yet); + RUN_TEST(test_fw_first_image_bootstrap_clears_after_200); + RUN_TEST(test_fw_first_image_just_arrived_uses_server_interval); 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);