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
+4
View File
@@ -30,6 +30,10 @@
#define NVS_KEY_PASS "pass"
#define NVS_KEY_IMG_ID "img_id"
#define NVS_KEY_DRAW_NEEDED "draw"
#define NVS_KEY_ERR_BORDER "err" // set when display is showing a sync-fail border; force a clean redraw on next 200/304
// Width of the sync-fail / no-WiFi border, in pixels.
#define BORDER_THICKNESS_PX 16
// ── Network ──────────────────────────────────────────────────────────────────
#define APP_BASE_URL "https://pictureframe.edholm.me"
+35
View File
@@ -84,6 +84,41 @@ void epd_draw_image_from_file(fs::File& f) {
epd_refresh();
}
void epd_draw_image_with_border(fs::File& f, uint8_t color, int thickness) {
const size_t expected = (size_t)EPD_WIDTH * EPD_HEIGHT / 2;
if (f.size() != expected) {
epd_fill(color);
return;
}
const uint8_t pair = (color << 4) | color;
cmd(0x10);
for (int y = 0; y < EPD_HEIGHT; y++) {
f.read(s_row, sizeof(s_row));
if (y < thickness || y >= EPD_HEIGHT - thickness) {
// Top/bottom band — solid color across the row.
for (size_t i = 0; i < sizeof(s_row); i++) s_row[i] = pair;
} else {
// Middle band — overlay border on left/right edges.
for (int x = 0; x < thickness; x++) {
if (x & 1) s_row[x/2] = (s_row[x/2] & 0xF0) | color;
else s_row[x/2] = (s_row[x/2] & 0x0F) | (color << 4);
}
for (int x = EPD_WIDTH - thickness; x < EPD_WIDTH; x++) {
if (x & 1) s_row[x/2] = (s_row[x/2] & 0xF0) | color;
else s_row[x/2] = (s_row[x/2] & 0x0F) | (color << 4);
}
}
digitalWrite(PIN_DC, HIGH); digitalWrite(PIN_CS, LOW);
SPI.writeBytes(s_row, sizeof(s_row));
digitalWrite(PIN_CS, HIGH);
}
epd_refresh();
}
void epd_draw_qr(QRCode* qr, uint8_t cellPx, uint8_t bg, uint8_t fg) {
int qrPx = qr->size * cellPx;
int offX = (EPD_WIDTH - qrPx) / 2;
+5
View File
@@ -8,6 +8,11 @@ void epd_sleep();
void epd_fill(uint8_t color);
void epd_draw_image_from_file(fs::File& f);
// Draw the image streamed from `f` with a `thickness`-pixel border of `color`
// overlaid. If `f` is not a full-frame .bin (size mismatch), falls back to
// epd_fill(color) so callers don't have to pre-validate.
void epd_draw_image_with_border(fs::File& f, uint8_t color, int thickness);
// Draw a QR code centred on the display.
// bg/fg are EPD color nibbles (COLOR_WHITE / COLOR_BLACK).
struct QRCode;
+23 -5
View File
@@ -66,6 +66,7 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
prefs.begin(NVS_NAMESPACE, true);
int32_t currentImgId = prefs.getInt(NVS_KEY_IMG_ID, -1);
bool drawNeeded = prefs.getInt(NVS_KEY_DRAW_NEEDED, 0) != 0;
bool errBorder = prefs.getInt(NVS_KEY_ERR_BORDER, 0) != 0;
prefs.end();
if (currentImgId >= 0) {
@@ -107,16 +108,19 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
File r = LittleFS.open(IMAGE_PATH, "r");
if (r) { epd_draw_image_from_file(r); r.close(); }
// Draw complete — clear the pending flag.
// Draw complete — clear pending and error-border flags. The fresh
// image fully overwrites any prior border, so error state is gone.
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
prefs.putInt(NVS_KEY_ERR_BORDER, 0);
prefs.end();
} else if (code == 304) {
http.end();
// If a previous draw was interrupted (power loss mid-refresh), the image
// file is in LittleFS and the ID is correct in NVS — just re-draw it.
if (drawNeeded) {
// Redraw from LittleFS if either: a previous draw was interrupted
// (drawNeeded), or a sync-fail border is currently on screen and the
// server is healthy again (errBorder) — repaint clean to clear it.
if (drawNeeded || errBorder) {
File r = LittleFS.open(IMAGE_PATH, "r");
if (r) {
displayInitialized = true;
@@ -125,6 +129,7 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
r.close();
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
prefs.putInt(NVS_KEY_ERR_BORDER, 0);
prefs.end();
}
}
@@ -139,10 +144,23 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
epd_init();
show_setup_qr(mac);
} else {
// Sync failed (5xx, timeout, malformed). Per FR38, the last-good image
// must persist; only the border indicates the error. epd_draw_image_with_border
// falls back to a full fill if the cached file is missing or wrong size,
// so first-boot error still gets a visible signal.
http.end();
displayInitialized = true;
epd_init();
epd_fill(COLOR_YELLOW);
File r = LittleFS.open(IMAGE_PATH, "r");
if (r) {
epd_draw_image_with_border(r, COLOR_YELLOW, BORDER_THICKNESS_PX);
r.close();
} else {
epd_fill(COLOR_YELLOW);
}
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_ERR_BORDER, 1);
prefs.end();
}
// Only power off the display if it was initialized this cycle. Calling