fix(13e6): partition + SPI corruption + bootstrap stay-awake
Three problems surfaced during the first 13.3" end-to-end run: 1) LittleFS IntegerDivideByZero on 200 → write /img.bin. Cause: the ~3.5 MB SPIFFS in default_16MB.csv can't fit three 960 KB setup screens + a 960 KB cached image (~3.84 MB). Switching to a custom partitions_13e6.csv with 24 MB LittleFS on the 32 MB flash. 2) Yellow wash across the panel on long SPI bursts. Cause: SPI DMA from a PSRAM-backed scratch buffer hits a cache-coherency window — the CPU's writes hadn't reached PSRAM yet when DMA read it. Push each half in 8 KB chunks through an internal-SRAM (DMA-coherent) scratch, and drop the bus clock to 4 MHz to match the 7.3" production speed. 3) Bootstrap window (no image yet) was deep-sleeping for 15 s between polls — each cycle a ~5 s ROM-boot + Wi-Fi reconnect, so the user waited ~20 s × N retries between scanning the setup QR and seeing their first photo land. Now normal_operation_impl returns early during bootstrap and main.cpp's normal_operation loops with a 2 s delay, keeping Wi-Fi up. Once the first image arrives, the normal scheduled deep sleep takes over. Also fixes a related bug Matt called out: a transient TLS hiccup during bootstrap was hitting the 5xx fallback path and painting a full yellow fill over the green setup QR, leaving the user with no claim path. Criterion is now "does /img.bin exist?" (panel has something worth showing with a border) rather than "is currentImgId set?", so a fresh device with no cached image preserves the setup screen through transient network errors. Diagnostic prints in the panel driver + [op] start/code lines in normal_operation_impl that proved invaluable during bringup; leaving them in for now. Tests updated for the new bootstrap semantics (deep sleep no longer arms on bootstrap-cycle 204/404/5xx); 43/43 native tests pass, 7.3" production build stays byte-identical. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -165,4 +165,11 @@
|
||||
#ifndef FIRST_IMAGE_POLL_INTERVAL_MS
|
||||
#define FIRST_IMAGE_POLL_INTERVAL_MS 15000ULL
|
||||
#endif
|
||||
// During bootstrap (no image yet) the device stays awake and polls in a
|
||||
// tight loop — keeping WiFi up between requests so the user doesn't wait
|
||||
// through a ~5 s deep-sleep + reconnect on every retry. Once the first
|
||||
// image arrives the device enters the normal scheduled deep sleep.
|
||||
#ifndef BOOTSTRAP_RETRY_INTERVAL_MS
|
||||
#define BOOTSTRAP_RETRY_INTERVAL_MS 2000ULL
|
||||
#endif
|
||||
#define IMAGE_PATH "/img.bin"
|
||||
|
||||
+11
-4
@@ -288,10 +288,17 @@ static void normal_operation(const String& mac) {
|
||||
WiFiClientSecure client;
|
||||
client.setInsecure(); // V1: no cert pinning for personal-scale device
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(client, url);
|
||||
|
||||
normal_operation_impl(mac, http, url, prefs);
|
||||
// Bootstrap loop: normal_operation_impl deep-sleeps (never returns) once
|
||||
// we've received our first image. While in the pre-image window it
|
||||
// returns instead, so we keep WiFi up and retry on a short interval —
|
||||
// way faster end-to-end than waiting through a deep-sleep + reconnect
|
||||
// for every "no image yet" poll.
|
||||
while (true) {
|
||||
HTTPClient http;
|
||||
http.begin(client, url);
|
||||
normal_operation_impl(mac, http, url, prefs);
|
||||
delay(BOOTSTRAP_RETRY_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
+26
-17
@@ -107,6 +107,7 @@ inline bool check_reset_button() {
|
||||
|
||||
template<typename HTTP>
|
||||
void normal_operation_impl(const String& mac, HTTP& http, const String& url, Preferences& prefs) {
|
||||
Serial.println("[op] start GET " + url);
|
||||
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;
|
||||
@@ -162,6 +163,7 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
|
||||
const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id", "X-Image-Sha256", "X-Claimed" };
|
||||
http.collectHeaders(collectHeaders, 4);
|
||||
int code = http.GET();
|
||||
Serial.println("[op] GET code=" + String(code));
|
||||
|
||||
// Server confirmed we're claimed → flag clears, regardless of what
|
||||
// happened to the response body. Without this, every poll forever
|
||||
@@ -292,27 +294,31 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
|
||||
// previous cycle.
|
||||
http.end();
|
||||
} 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.
|
||||
// Sync failed (5xx, timeout, TLS handshake failure → code=-1, etc.).
|
||||
// Criterion is "does /img.bin exist?", not "is currentImgId >= 0?":
|
||||
// • file exists → a real photo is on the panel. Draw it with a
|
||||
// yellow border so the user sees a sync signal without losing it.
|
||||
// Per FR38, the last-good image must persist.
|
||||
// • no file → bootstrap window. Panel is showing the setup-claim
|
||||
// QR which the user still needs to scan. A transient TLS hiccup
|
||||
// must NOT wipe that to a yellow fill — it would leave the
|
||||
// recipient with no way to claim the frame until the next 200.
|
||||
http.end();
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) {
|
||||
Serial.println(String("[op] sync fail code=") + String(code) +
|
||||
" -> drawing image with yellow border");
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
epd_draw_image_with_border(r, COLOR_YELLOW, BORDER_THICKNESS_PX);
|
||||
r.close();
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
prefs.putInt(NVS_KEY_ERR_BORDER, 1);
|
||||
prefs.end();
|
||||
} else {
|
||||
Serial.println(String("[op] sync fail code=") + String(code) +
|
||||
" -> no cached image, falling back to full yellow fill");
|
||||
epd_fill(COLOR_YELLOW);
|
||||
" with no cached image; preserving setup screen");
|
||||
}
|
||||
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
|
||||
@@ -321,16 +327,19 @@ 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.
|
||||
// Bootstrap window: the device has never received an image — the user is
|
||||
// still mid-setup, looking at the green setup QR, waiting for the first
|
||||
// photo to land. Return WITHOUT arming deep sleep. The caller is expected
|
||||
// to re-invoke us on a short timer until imgIdAfter goes >= 0, keeping
|
||||
// WiFi up and the device responsive. The previous "15 s deep-sleep
|
||||
// between bootstrap polls" wasted ~5 s per cycle on flash boot + WiFi
|
||||
// reconnect, so end-user wait until first image was ~20 s × N retries.
|
||||
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;
|
||||
Serial.println("[op] bootstrap: no image yet, caller will retry");
|
||||
return;
|
||||
}
|
||||
|
||||
esp_sleep_enable_timer_wakeup(sleepMs * 1000ULL);
|
||||
|
||||
@@ -209,6 +209,9 @@ static void ensure_dma_scratch() {
|
||||
if (!g_dma_scratch) {
|
||||
g_dma_scratch = (uint8_t*)heap_caps_malloc(
|
||||
DMA_CHUNK, MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA);
|
||||
Serial.printf("[epd13e6] dma_scratch=%p (free internal=%u)\n",
|
||||
g_dma_scratch,
|
||||
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +238,12 @@ static void push_half(int cs_pin, const uint8_t* half_fb, size_t bytes) {
|
||||
static void push_full_frame(const uint8_t* fb) {
|
||||
constexpr size_t HALF_BYTES = (size_t)HALF_BYTES_ROW * H;
|
||||
uint8_t* slice = (uint8_t*)heap_caps_malloc(HALF_BYTES, MALLOC_CAP_SPIRAM);
|
||||
if (!slice) return;
|
||||
if (!slice) {
|
||||
Serial.printf("[epd13e6] push_full_frame: slice alloc FAILED (free PSRAM=%u)\n",
|
||||
(unsigned)ESP.getFreePsram());
|
||||
return;
|
||||
}
|
||||
Serial.println("[epd13e6] push_full_frame: pushing halves");
|
||||
|
||||
for (uint16_t y = 0; y < H; y++) {
|
||||
memcpy(slice + (size_t)y * HALF_BYTES_ROW,
|
||||
@@ -252,6 +260,7 @@ static void push_full_frame(const uint8_t* fb) {
|
||||
push_half(PIN_CS_S, slice, HALF_BYTES);
|
||||
|
||||
heap_caps_free(slice);
|
||||
Serial.println("[epd13e6] push_full_frame: done");
|
||||
}
|
||||
|
||||
// ── epd.h surface ──────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user