From b9649c35ec73ed852194964e7a1af80ef8df7df2 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Sat, 18 Jul 2026 23:51:01 -0400 Subject: [PATCH] Skip redundant panel refreshes and fetch the image before the config check The panel driver now splits writing a frame into its SPI buffer (epd_write_frame(), which also computes a CRC32 as it streams) from actually triggering the physical refresh (epd_turn_on_display()). frame_client.c compares the new CRC against the last one that was actually refreshed (persisted in NVS) and skips the refresh entirely when they match -- e.g. a reboot redisplaying the same photo before the server's refresh interval elapsed no longer causes a visible flash for no visual change. Also reorders the per-wake fetch cycle: the image fetch (15s timeout) now goes before the config check (3s timeout), instead of after. The config check's tighter timeout was intermittently tripping on connection-setup latency that's common on the first request after waking from a long deep sleep (e.g. stale ARP); putting the more tolerant request first absorbs that latency, and the config check then rides the connection it already warmed up. --- docs/architecture.md | 69 ++++++++++------ firmware/components/epd7in3e/epd7in3e.c | 34 +++++--- .../components/epd7in3e/include/epd7in3e.h | 23 ++++++ firmware/main/frame_client.c | 81 ++++++++++++------- firmware/main/wifi_provisioning.c | 23 ++++++ firmware/main/wifi_provisioning.h | 14 +++- 6 files changed, 184 insertions(+), 60 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ed8e7c3..ae1994a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,22 +16,28 @@ sequenceDiagram Note over Frame: User scans WiFi QR, then config QR -> fills in
home WiFi + "Tools Server" host:port Frame->>Frame: Save config to NVS, reboot - Note over Frame: Every wake (deep sleep timer) + Note over Frame: Every wake (deep sleep timer, next-photo button,
or any other reboot) Frame->>Frame: Connect to home WiFi + alt next-photo button pressed + Frame->>Server: POST /frame/advance + Server->>Server: Force-advance to next queued photo, reset interval clock + else normal wake + Frame->>Server: GET /frame/image + Server->>Server: Advance only if refresh_interval_s has elapsed
since the current photo was set -- otherwise a no-op + end + Server->>Immich: List album assets / download preview / faces + Immich-->>Server: JPEG + face bounding boxes + Server->>Server: Crop (face-aware) + quantize (dither) + pack 4bpp + Server-->>Frame: 192,000 raw bytes, streamed + Frame->>Frame: Write to panel SPI buffer, compute CRC32 + alt CRC unchanged since last physical refresh + Frame->>Frame: Skip refresh (nothing visually changed) + else CRC changed + Frame->>Frame: Trigger physical refresh, store new CRC + end Frame->>Server: GET /frame/config Server-->>Frame: {"refresh_interval_s": ...} - alt server unreachable - Frame->>Frame: Show "SERVER: FAILED" status screen - Frame->>Frame: Deep sleep (short retry interval) - else server reachable - Frame->>Server: GET /frame/image - Server->>Immich: List album assets / download preview / faces - Immich-->>Server: JPEG + face bounding boxes - Server->>Server: Crop (face-aware) + quantize (dither) + pack 4bpp - Server-->>Frame: 192,000 raw bytes, streamed - Frame->>Frame: Stream straight to panel SPI, refresh - Frame->>Frame: Deep sleep (server-configured interval) - end + Frame->>Frame: Deep sleep (server-configured interval, or a short
retry interval on any failure) ``` ## Firmware boot flow @@ -46,19 +52,36 @@ sequenceDiagram 2. **Stored config exists**: connect to the saved WiFi network (a few retries before falling back to provisioning if it fails), then run the fetch cycle in `frame_client.c`: - - `GET /frame/config` on the configured tools server -- doubles as a - reachability check and the source of the refresh interval (a - Kconfig value is only used as a fallback). - - If reachable, `GET /frame/image` and stream the response directly - into the panel over SPI (`epd_display_stream()`), never buffering - the full ~192KB frame in RAM. - - The panel driver refuses to physically refresh unless the stream - supplied *exactly* the expected byte count -- a truncated or - wrong-size response leaves the previous image on screen instead of - painting garbage. + - Check the next-photo button (`next_button_check()`) -- if it was + what woke the device (checked via the latched + `esp_sleep_get_gpio_wakeup_status()`, not a live pin read, since a + quick tap can release before boot gets around to polling it) or is + currently held, the fetch below hits `POST /frame/advance` instead + of `GET /frame/image`, forcing the server to skip ahead immediately. + - Fetch the frame and write it into the panel's SPI buffer + (`epd_write_frame()`), computing a CRC32 as it streams -- never + buffering the full ~192KB frame in RAM. The panel driver refuses to + write a short/wrong-size response into the buffer at all, so a + truncated fetch can't corrupt what's already there. + - Compare the new CRC32 against the last one that was actually + refreshed onto the panel (persisted in NVS). If it matches -- the + same photo is already visibly on screen, e.g. the device rebooted + before the server's refresh interval elapsed -- skip the physical + refresh entirely (`epd_turn_on_display()`), avoiding its visible + flash and 15-30s duration for no visual change. Otherwise trigger + the refresh and store the new CRC. + - `GET /frame/config` for the refresh interval, used to set the deep + sleep duration -- deliberately fetched *after* the image, not + before: its timeout is much tighter (3s vs. the image fetch's 15s), + and fetching second lets it ride the connection the image fetch just + warmed up rather than eating the latency spike common on the first + request after waking from a long sleep. - Deep sleep for the server-configured interval on success, or a shorter retry interval on any failure. + The factory-reset button (hold 10s) is checked earlier, before any of + this -- see [`firmware/README.md`](../firmware/README.md#resetting-to-provisioning-mode). + See [`docs/hardware.md`](hardware.md) for wiring and [`server/README.md`](../server/README.md) for the server side. diff --git a/firmware/components/epd7in3e/epd7in3e.c b/firmware/components/epd7in3e/epd7in3e.c index 0ed3596..ff3db7b 100644 --- a/firmware/components/epd7in3e/epd7in3e.c +++ b/firmware/components/epd7in3e/epd7in3e.c @@ -6,6 +6,7 @@ #include "freertos/task.h" #include "esp_check.h" #include "esp_log.h" +#include "esp_rom_crc.h" #include "epd7in3e.h" @@ -95,7 +96,7 @@ static void epd_reset(void) /* Power on, "second setting" registers, refresh, power off -- mirrors * EPD_7IN3E_TurnOnDisplay() in the reference driver. */ -static esp_err_t epd_turn_on_display(void) +esp_err_t epd_turn_on_display(void) { EPD_CHECK(epd_send_command(0x04)); // POWER_ON epd_wait_busy(); @@ -204,7 +205,7 @@ esp_err_t epd_init(void) return ESP_OK; } -esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx) +esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32) { ESP_RETURN_ON_FALSE(read_fn != NULL, ESP_ERR_INVALID_ARG, TAG, "read_fn required"); @@ -220,6 +221,7 @@ esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx) * reentrant, but this driver only ever runs from one task at a time. */ static uint8_t chunk[EPD_SPI_CHUNK_SIZE]; size_t total = 0; + uint32_t crc = 0; size_t n; esp_err_t err = ESP_OK; while ((n = read_fn(chunk, sizeof(chunk), ctx)) > 0) { @@ -227,6 +229,7 @@ esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx) if (err != ESP_OK) { break; } + crc = esp_rom_crc32_le(crc, chunk, n); total += n; } @@ -235,18 +238,31 @@ esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx) if (total != EPD_FRAME_BYTES) { /* Whatever was received has already been clocked into the panel's - * internal RAM over SPI, but epd_turn_on_display() (the actual - * physical refresh trigger) hasn't been called yet -- returning - * here instead leaves the visible screen exactly as it was, rather - * than refreshing onto a mostly-garbage buffer. Confirmed on - * hardware: a misdirected fetch that returned a ~10KB error page - * instead of a 192,000-byte frame still triggered a refresh before - * this check existed, painting garbage over a previously-good image. */ + * internal RAM over SPI, but the physical refresh trigger hasn't + * been called -- returning here instead leaves the visible screen + * exactly as it was, rather than refreshing onto a mostly-garbage + * buffer. Confirmed on hardware: a misdirected fetch that returned + * a ~10KB error page instead of a 192,000-byte frame still + * triggered a refresh before this check existed, painting garbage + * over a previously-good image. */ ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh", (unsigned)total, (unsigned)EPD_FRAME_BYTES); return ESP_ERR_INVALID_SIZE; } + if (out_crc32 != NULL) { + *out_crc32 = crc; + } + + return ESP_OK; +} + +esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx) +{ + esp_err_t err = epd_write_frame(read_fn, ctx, NULL); + if (err != ESP_OK) { + return err; + } return epd_turn_on_display(); } diff --git a/firmware/components/epd7in3e/include/epd7in3e.h b/firmware/components/epd7in3e/include/epd7in3e.h index 5148c85..411f373 100644 --- a/firmware/components/epd7in3e/include/epd7in3e.h +++ b/firmware/components/epd7in3e/include/epd7in3e.h @@ -40,6 +40,29 @@ typedef size_t (*epd_read_fn_t)(uint8_t *chunk, size_t chunk_size, void *ctx); */ esp_err_t epd_display_stream(epd_read_fn_t read_fn, void *ctx); +/** + * Like epd_display_stream(), but writes the frame into the panel's + * internal buffer over SPI WITHOUT triggering the physical refresh (the + * visible flash/flicker, which also takes 15-30+ seconds) -- call + * epd_turn_on_display() separately to make it visible. Returns + * ESP_ERR_INVALID_SIZE if read_fn didn't supply exactly EPD_FRAME_BYTES, + * same as epd_display_stream(); either way nothing is refreshed, so the + * visible screen is left untouched on error. + * + * If out_crc32 is non-NULL, it's set to a CRC32 of the bytes written -- + * lets a caller compare against the last-displayed frame's CRC and skip + * the refresh entirely when nothing actually changed (e.g. redisplaying + * the same photo after a reboot). + */ +esp_err_t epd_write_frame(epd_read_fn_t read_fn, void *ctx, uint32_t *out_crc32); + +/** + * Triggers the panel's physical refresh cycle (power on, refresh, power + * off) -- the visible flash/flicker sequence, 15-30+ seconds. Call after + * epd_write_frame() to make the written buffer visible. + */ +esp_err_t epd_turn_on_display(void); + /** Convenience wrapper around epd_display_stream() for an in-memory frame buffer. */ esp_err_t epd_display_buffer(const uint8_t *frame, size_t len); diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index 491b6a6..af35159 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -248,11 +248,30 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url); http_read_ctx_t ctx = { .client = client }; - err = epd_display_stream(http_read_fn, &ctx); + uint32_t crc = 0; + err = epd_write_frame(http_read_fn, &ctx, &crc); esp_http_client_close(client); esp_http_client_cleanup(client); + if (err != ESP_OK) { + return err; + } + + uint32_t previous_crc; + if (frame_config_get_last_display_crc32(&previous_crc) == ESP_OK && previous_crc == crc) { + /* Same photo already on screen (e.g. redisplayed after a reboot, + * before the refresh interval elapsed server-side) -- skip the + * physical refresh, avoiding its visible flash and 15-30s + * duration for no visual change. */ + ESP_LOGI(TAG, "Frame unchanged since last display, skipping refresh"); + return ESP_OK; + } + + err = epd_turn_on_display(); + if (err == ESP_OK) { + frame_config_set_last_display_crc32(crc); + } return err; } @@ -276,35 +295,43 @@ void frame_client_run(const frame_config_t *cfg, bool force_advance) } } - frame_server_config_t server_cfg = fetch_frame_config(cfg->toolsserver); - uint32_t sleep_seconds = server_cfg.refresh_interval_s; - - if (!server_cfg.reachable) { - /* Nothing's been drawn yet this cycle (aside from the optional - * PENDING screen above), so this is cheap diagnostics without - * compounding flashing. */ - ESP_LOGW(TAG, "Tools server not reachable, retrying sooner"); - sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S; - if (have_display) { + /* The image fetch goes before the config check, not after. It has a + * far more generous timeout (CONFIG_FRAME_FETCH_TIMEOUT_MS, 15s by + * default, vs. the config check's 3s), so it comfortably absorbs the + * extra connection-setup latency that's common on the very first + * request after waking from a long deep sleep (stale ARP entries and + * the like) -- confirmed on hardware: the config check's tight + * timeout was intermittently tripping on exactly that latency while + * it went first, even though the image fetch right after it (on an + * already-warm connection) never had trouble. Trade-off: on a fully + * down server, the device now waits up to the image fetch's longer + * timeout to notice, instead of the config check's shorter one -- + * worth it to stop false-failing on the common case. */ + bool image_ok = true; + if (have_display) { + esp_err_t fetch_err = fetch_and_display(cfg, force_advance); + image_ok = (fetch_err == ESP_OK); + if (!image_ok) { + /* epd_display_stream() never triggers a physical refresh on a + * failed/short/wrong-size stream (see epd7in3e.c), so the + * visible screen is guaranteed untouched here -- always safe + * to show what went wrong instead of leaving stale content + * with no indication anything failed. */ + ESP_LOGW(TAG, "Fetch/display failed (%s), retrying sooner", esp_err_to_name(fetch_err)); status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_FAILED); - } - } else { - if (first_connection && have_display) { + } else if (first_connection) { status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_OK); } - if (have_display) { - esp_err_t fetch_err = fetch_and_display(cfg, force_advance); - if (fetch_err != ESP_OK) { - /* epd_display_stream() never triggers a physical refresh on - * a failed/short/wrong-size stream (see epd7in3e.c), so the - * visible screen is guaranteed untouched here -- always - * safe to show what went wrong instead of leaving stale - * content with no indication anything failed. */ - ESP_LOGW(TAG, "Fetch/display failed (%s), retrying sooner", esp_err_to_name(fetch_err)); - sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S; - status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_FAILED); - } - } + } + + /* Only worth asking for the refresh interval if the image fetch + * actually worked -- a failed fetch already means CONFIG_FRAME_RETRY_INTERVAL_S, + * so there's nothing to gain from a config request whose result would + * just be discarded. */ + uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S; + if (image_ok) { + frame_server_config_t server_cfg = fetch_frame_config(cfg->toolsserver); + sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S; } if (have_display) { diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c index 89962c6..792280e 100644 --- a/firmware/main/wifi_provisioning.c +++ b/firmware/main/wifi_provisioning.c @@ -144,6 +144,29 @@ void frame_config_clear(void) nvs_close(handle); } +esp_err_t frame_config_get_last_display_crc32(uint32_t *out) +{ + nvs_handle_t handle; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle); + if (err != ESP_OK) { + return err; + } + err = nvs_get_u32(handle, "last_crc32", out); + nvs_close(handle); + return err; +} + +void frame_config_set_last_display_crc32(uint32_t crc32) +{ + nvs_handle_t handle; + if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) { + return; + } + nvs_set_u32(handle, "last_crc32", crc32); + nvs_commit(handle); + nvs_close(handle); +} + static void generate_ap_password(char *out, size_t out_size) { size_t len = MIN(FRAME_AP_PASSWORD_LEN, out_size - 1); diff --git a/firmware/main/wifi_provisioning.h b/firmware/main/wifi_provisioning.h index 67c1c6b..45654d2 100644 --- a/firmware/main/wifi_provisioning.h +++ b/firmware/main/wifi_provisioning.h @@ -41,10 +41,22 @@ void frame_config_mark_connected_once(void); * device falls back into provisioning on its next boot. Leaves the softAP * identity (SSID/password) untouched, since that's tied to the device * itself, not a particular home network -- regenerating it on every reset - * would force re-scanning the join QR code for no reason. + * would force re-scanning the join QR code for no reason. Also leaves the + * last-displayed-photo CRC (below) untouched -- it describes what's + * physically on screen, not network config, and stays valid regardless. */ void frame_config_clear(void); +/** + * Returns the CRC32 of the last frame actually written to the panel via a + * physical refresh. Returns ESP_ERR_NVS_NOT_FOUND if nothing's been + * displayed yet. + */ +esp_err_t frame_config_get_last_display_crc32(uint32_t *out); + +/** Records the CRC32 of the frame just displayed, for next time. */ +void frame_config_set_last_display_crc32(uint32_t crc32); + /** * Returns this device's provisioning AP identity: a fixed SSID (from * Kconfig) and a password that's generated once on first use and persisted