fix: preserve last image and overlay yellow border on sync failure

Previously a 5xx / timeout / malformed response fired epd_fill(COLOR_YELLOW),
which writes the yellow nibble across the entire 800×480 framebuffer and
destroys the last good image — exactly what FR38 forbids ("Last image
persists ... yellow border signals state"). The device then got stuck on a
blank yellow screen because the next 304 didn't redraw.

Changes:

- New epd_draw_image_with_border streams the cached .bin row-by-row,
  overwrites border-region pixels in the row buffer, and pushes a single
  composited framebuffer (same pattern as the existing setup-QR overlay).
- normal_operation_impl else-branch now redraws the cached image with a
  yellow border, falling back to epd_fill only when no cache exists
  (first-boot error). Sets a new NVS_KEY_ERR_BORDER flag.
- 200 and 304 paths clear NVS_KEY_ERR_BORDER. The 304 branch now
  triggers a clean repaint when the err flag is set, so the device
  recovers from the stuck-yellow state on the next healthy poll
  without waiting for rotation to advance.
- LittleFS read mock now returns invalid File when the file doesn't
  exist (matches real LittleFS), so the no-cache fallback path is
  actually exercisable in tests.

Tests:

- Replaces the old test_fw06_error_fills_yellow (which locked in the
  buggy fill behavior) with FW-06a..e covering: error+cache draws
  border (no fill), error+no-cache falls back to fill, 304 after
  error repaints clean, steady-state 304 touches nothing (the
  regression the user flagged), 200 after error clears the flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 13:30:04 -04:00
parent ae00994499
commit cbdcad3154
7 changed files with 171 additions and 11 deletions
+16 -3
View File
@@ -29,11 +29,24 @@ struct LittleFSClass {
File open(const char* path, const char* mode, bool create = false) {
File f;
f._valid = true;
f._write = (mode[0] == 'w');
f._buf = &files[path];
if (f._write) f._buf->clear();
f._pos = 0;
if (f._write) {
f._buf = &files[path];
f._buf->clear();
f._valid = true;
} else {
// Read mode: behave like real LittleFS — return invalid when file
// doesn't exist (do NOT create an empty entry via operator[]).
auto it = files.find(path);
if (it == files.end()) {
f._valid = false;
f._buf = nullptr;
} else {
f._buf = &it->second;
f._valid = true;
}
}
return f;
}
} LittleFS;
+8
View File
@@ -12,6 +12,9 @@ extern int g_epd_draw_image_count;
extern int g_epd_fill_count;
extern int g_epd_fill_last_color;
extern int g_epd_draw_setup_count;
extern int g_epd_draw_border_count;
extern int g_epd_draw_border_last_color;
extern int g_epd_draw_border_last_thickness;
inline void epd_init() { g_epd_init_count++; }
inline void epd_sleep() { g_epd_sleep_count++; }
@@ -21,5 +24,10 @@ inline void epd_draw_image_from_file(File& f) {
g_call_seq++;
}
inline void epd_fill(int color) { g_epd_fill_count++; g_epd_fill_last_color = color; }
inline void epd_draw_image_with_border(File& f, int color, int thickness) {
g_epd_draw_border_count++;
g_epd_draw_border_last_color = color;
g_epd_draw_border_last_thickness = thickness;
}
inline void epd_draw_ap_screen(void*) {}
inline void epd_draw_setup_screen(void*) { g_epd_draw_setup_count++; }
+80 -3
View File
@@ -30,6 +30,7 @@ std::string g_http_body;
int g_epd_init_count, g_epd_sleep_count, g_epd_draw_image_count;
int g_epd_fill_count, g_epd_fill_last_color, g_epd_draw_setup_count;
int g_epd_draw_border_count, g_epd_draw_border_last_color, g_epd_draw_border_last_thickness;
uint64_t g_sleep_us;
bool g_deep_sleep_started;
@@ -62,6 +63,7 @@ void reset_state() {
g_http_body = "TESTDATA";
g_epd_init_count = g_epd_sleep_count = g_epd_draw_image_count = 0;
g_epd_fill_count = g_epd_fill_last_color = g_epd_draw_setup_count = 0;
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_show_setup_qr_count = 0;
@@ -133,12 +135,83 @@ void test_fw05_404_shows_setup_qr() {
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
}
// FW-06: other error — epd_fill yellow
void test_fw06_error_fills_yellow() {
// FW-06a: 5xx error WITH a cached image → preserve last image and overlay a
// yellow BORDER (per FR38). MUST NOT fill the screen with yellow — that would
// destroy the last good image. Sets the err_border NVS flag so the next
// healthy response repaints clean.
void test_fw06a_error_with_cache_draws_border_not_fill() {
g_http_get_code = 500;
LittleFS.files[IMAGE_PATH] = "IMGDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL_MESSAGE(0, g_epd_fill_count,
"epd_fill must NOT be called when a cached image exists — it would obliterate the photo");
TEST_ASSERT_EQUAL(1, g_epd_draw_border_count);
TEST_ASSERT_EQUAL(COLOR_YELLOW, g_epd_draw_border_last_color);
TEST_ASSERT_EQUAL(BORDER_THICKNESS_PX, g_epd_draw_border_last_thickness);
TEST_ASSERT_EQUAL(1, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-06b: 5xx error with NO cached image → fall back to full yellow fill so
// the user still sees a sync-fail signal on a fresh device.
void test_fw06b_error_without_cache_falls_back_to_fill() {
g_http_get_code = 500;
// LittleFS has no IMAGE_PATH entry
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(0, g_epd_draw_border_count);
TEST_ASSERT_EQUAL(1, g_epd_fill_count);
TEST_ASSERT_EQUAL(COLOR_YELLOW, g_epd_fill_last_color);
TEST_ASSERT_EQUAL(1, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-06c: 304 with err_border flag set (sync recovered after a previous
// failure) → repaint the cached image clean and clear the flag.
void test_fw06c_304_after_error_repaints_clean() {
g_http_get_code = 304;
prefs.ints[NVS_KEY_ERR_BORDER] = 1;
prefs.ints[NVS_KEY_IMG_ID] = 7;
LittleFS.files[IMAGE_PATH] = "IMGDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_init_count);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL_MESSAGE(0, g_epd_draw_border_count,
"304 with err flag must redraw clean — no border on the recovery frame");
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-06d: 304 (same image) with NO error or pending state must NOT touch the
// display. Specifically must not invoke any yellow path. Locks down the
// regression the user reported: 304 was suspected of triggering yellow fill.
void test_fw06d_304_steady_state_does_not_fill_yellow() {
g_http_get_code = 304;
prefs.ints[NVS_KEY_IMG_ID] = 7;
LittleFS.files[IMAGE_PATH] = "IMGDATA";
// err_border = 0, draw_needed = 0 (defaults)
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(0, g_epd_fill_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_border_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(0, g_epd_init_count);
}
// FW-06e: 200 response after a previous error border → fresh image fully
// overwrites the framebuffer, err_border flag cleared.
void test_fw06e_200_after_error_clears_flag() {
g_http_response_headers["X-Image-Id"] = "8";
g_http_body = "BINDATA";
prefs.ints[NVS_KEY_ERR_BORDER] = 1;
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-07: NVS has saved img_id → X-Current-Image-Id header sent
@@ -233,7 +306,11 @@ int main(int argc, char** argv) {
RUN_TEST(test_fw03_304_no_redraw);
RUN_TEST(test_fw04_204_shows_setup_qr);
RUN_TEST(test_fw05_404_shows_setup_qr);
RUN_TEST(test_fw06_error_fills_yellow);
RUN_TEST(test_fw06a_error_with_cache_draws_border_not_fill);
RUN_TEST(test_fw06b_error_without_cache_falls_back_to_fill);
RUN_TEST(test_fw06c_304_after_error_repaints_clean);
RUN_TEST(test_fw06d_304_steady_state_does_not_fill_yellow);
RUN_TEST(test_fw06e_200_after_error_clears_flag);
RUN_TEST(test_fw07_current_image_id_sent_when_saved);
RUN_TEST(test_fw08_no_current_image_id_when_default);
RUN_TEST(test_fw09_server_interval_honored);