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.
This commit is contained in:
2026-07-18 23:51:01 -04:00
parent d395cf3bb9
commit b9649c35ec
6 changed files with 184 additions and 60 deletions
+42 -19
View File
@@ -16,22 +16,28 @@ sequenceDiagram
Note over Frame: User scans WiFi QR, then config QR -> fills in<br/>home WiFi + "Tools Server" host:port Note over Frame: User scans WiFi QR, then config QR -> fills in<br/>home WiFi + "Tools Server" host:port
Frame->>Frame: Save config to NVS, reboot 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,<br/>or any other reboot)
Frame->>Frame: Connect to home WiFi Frame->>Frame: Connect to home WiFi
Frame->>Server: GET /frame/config alt next-photo button pressed
Server-->>Frame: {"refresh_interval_s": ...} Frame->>Server: POST /frame/advance
alt server unreachable Server->>Server: Force-advance to next queued photo, reset interval clock
Frame->>Frame: Show "SERVER: FAILED" status screen else normal wake
Frame->>Frame: Deep sleep (short retry interval)
else server reachable
Frame->>Server: GET /frame/image Frame->>Server: GET /frame/image
Server->>Server: Advance only if refresh_interval_s has elapsed<br/>since the current photo was set -- otherwise a no-op
end
Server->>Immich: List album assets / download preview / faces Server->>Immich: List album assets / download preview / faces
Immich-->>Server: JPEG + face bounding boxes Immich-->>Server: JPEG + face bounding boxes
Server->>Server: Crop (face-aware) + quantize (dither) + pack 4bpp Server->>Server: Crop (face-aware) + quantize (dither) + pack 4bpp
Server-->>Frame: 192,000 raw bytes, streamed Server-->>Frame: 192,000 raw bytes, streamed
Frame->>Frame: Stream straight to panel SPI, refresh Frame->>Frame: Write to panel SPI buffer, compute CRC32
Frame->>Frame: Deep sleep (server-configured interval) 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 end
Frame->>Server: GET /frame/config
Server-->>Frame: {"refresh_interval_s": ...}
Frame->>Frame: Deep sleep (server-configured interval, or a short<br/>retry interval on any failure)
``` ```
## Firmware boot flow ## Firmware boot flow
@@ -46,19 +52,36 @@ sequenceDiagram
2. **Stored config exists**: connect to the saved WiFi network (a few 2. **Stored config exists**: connect to the saved WiFi network (a few
retries before falling back to provisioning if it fails), then run the retries before falling back to provisioning if it fails), then run the
fetch cycle in `frame_client.c`: fetch cycle in `frame_client.c`:
- `GET /frame/config` on the configured tools server -- doubles as a - Check the next-photo button (`next_button_check()`) -- if it was
reachability check and the source of the refresh interval (a what woke the device (checked via the latched
Kconfig value is only used as a fallback). `esp_sleep_get_gpio_wakeup_status()`, not a live pin read, since a
- If reachable, `GET /frame/image` and stream the response directly quick tap can release before boot gets around to polling it) or is
into the panel over SPI (`epd_display_stream()`), never buffering currently held, the fetch below hits `POST /frame/advance` instead
the full ~192KB frame in RAM. of `GET /frame/image`, forcing the server to skip ahead immediately.
- The panel driver refuses to physically refresh unless the stream - Fetch the frame and write it into the panel's SPI buffer
supplied *exactly* the expected byte count -- a truncated or (`epd_write_frame()`), computing a CRC32 as it streams -- never
wrong-size response leaves the previous image on screen instead of buffering the full ~192KB frame in RAM. The panel driver refuses to
painting garbage. 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 - Deep sleep for the server-configured interval on success, or a
shorter retry interval on any failure. 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 See [`docs/hardware.md`](hardware.md) for wiring and
[`server/README.md`](../server/README.md) for the server side. [`server/README.md`](../server/README.md) for the server side.
+25 -9
View File
@@ -6,6 +6,7 @@
#include "freertos/task.h" #include "freertos/task.h"
#include "esp_check.h" #include "esp_check.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_rom_crc.h"
#include "epd7in3e.h" #include "epd7in3e.h"
@@ -95,7 +96,7 @@ static void epd_reset(void)
/* Power on, "second setting" registers, refresh, power off -- mirrors /* Power on, "second setting" registers, refresh, power off -- mirrors
* EPD_7IN3E_TurnOnDisplay() in the reference driver. */ * 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_CHECK(epd_send_command(0x04)); // POWER_ON
epd_wait_busy(); epd_wait_busy();
@@ -204,7 +205,7 @@ esp_err_t epd_init(void)
return ESP_OK; 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"); 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. */ * reentrant, but this driver only ever runs from one task at a time. */
static uint8_t chunk[EPD_SPI_CHUNK_SIZE]; static uint8_t chunk[EPD_SPI_CHUNK_SIZE];
size_t total = 0; size_t total = 0;
uint32_t crc = 0;
size_t n; size_t n;
esp_err_t err = ESP_OK; esp_err_t err = ESP_OK;
while ((n = read_fn(chunk, sizeof(chunk), ctx)) > 0) { 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) { if (err != ESP_OK) {
break; break;
} }
crc = esp_rom_crc32_le(crc, chunk, n);
total += 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) { if (total != EPD_FRAME_BYTES) {
/* Whatever was received has already been clocked into the panel's /* Whatever was received has already been clocked into the panel's
* internal RAM over SPI, but epd_turn_on_display() (the actual * internal RAM over SPI, but the physical refresh trigger hasn't
* physical refresh trigger) hasn't been called yet -- returning * been called -- returning here instead leaves the visible screen
* here instead leaves the visible screen exactly as it was, rather * exactly as it was, rather than refreshing onto a mostly-garbage
* than refreshing onto a mostly-garbage buffer. Confirmed on * buffer. Confirmed on hardware: a misdirected fetch that returned
* hardware: a misdirected fetch that returned a ~10KB error page * a ~10KB error page instead of a 192,000-byte frame still
* instead of a 192,000-byte frame still triggered a refresh before * triggered a refresh before this check existed, painting garbage
* this check existed, painting garbage over a previously-good image. */ * over a previously-good image. */
ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh", ESP_LOGE(TAG, "Stream supplied %u bytes, expected %u -- aborting refresh",
(unsigned)total, (unsigned)EPD_FRAME_BYTES); (unsigned)total, (unsigned)EPD_FRAME_BYTES);
return ESP_ERR_INVALID_SIZE; 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(); return epd_turn_on_display();
} }
@@ -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); 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. */ /** 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); esp_err_t epd_display_buffer(const uint8_t *frame, size_t len);
+51 -24
View File
@@ -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); ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url);
http_read_ctx_t ctx = { .client = client }; 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_close(client);
esp_http_client_cleanup(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; 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); /* The image fetch goes before the config check, not after. It has a
uint32_t sleep_seconds = server_cfg.refresh_interval_s; * far more generous timeout (CONFIG_FRAME_FETCH_TIMEOUT_MS, 15s by
* default, vs. the config check's 3s), so it comfortably absorbs the
if (!server_cfg.reachable) { * extra connection-setup latency that's common on the very first
/* Nothing's been drawn yet this cycle (aside from the optional * request after waking from a long deep sleep (stale ARP entries and
* PENDING screen above), so this is cheap diagnostics without * the like) -- confirmed on hardware: the config check's tight
* compounding flashing. */ * timeout was intermittently tripping on exactly that latency while
ESP_LOGW(TAG, "Tools server not reachable, retrying sooner"); * it went first, even though the image fetch right after it (on an
sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S; * already-warm connection) never had trouble. Trade-off: on a fully
if (have_display) { * down server, the device now waits up to the image fetch's longer
status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_FAILED); * timeout to notice, instead of the config check's shorter one --
} * worth it to stop false-failing on the common case. */
} else { bool image_ok = true;
if (first_connection && have_display) {
status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_OK);
}
if (have_display) { if (have_display) {
esp_err_t fetch_err = fetch_and_display(cfg, force_advance); esp_err_t fetch_err = fetch_and_display(cfg, force_advance);
if (fetch_err != ESP_OK) { image_ok = (fetch_err == ESP_OK);
/* epd_display_stream() never triggers a physical refresh on if (!image_ok) {
* a failed/short/wrong-size stream (see epd7in3e.c), so the /* epd_display_stream() never triggers a physical refresh on a
* visible screen is guaranteed untouched here -- always * failed/short/wrong-size stream (see epd7in3e.c), so the
* safe to show what went wrong instead of leaving stale * visible screen is guaranteed untouched here -- always safe
* content with no indication anything failed. */ * 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)); 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); status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_FAILED);
} else if (first_connection) {
status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_OK);
} }
} }
/* 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) { if (have_display) {
+23
View File
@@ -144,6 +144,29 @@ void frame_config_clear(void)
nvs_close(handle); 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) static void generate_ap_password(char *out, size_t out_size)
{ {
size_t len = MIN(FRAME_AP_PASSWORD_LEN, out_size - 1); size_t len = MIN(FRAME_AP_PASSWORD_LEN, out_size - 1);
+13 -1
View File
@@ -41,10 +41,22 @@ void frame_config_mark_connected_once(void);
* device falls back into provisioning on its next boot. Leaves the softAP * device falls back into provisioning on its next boot. Leaves the softAP
* identity (SSID/password) untouched, since that's tied to the device * identity (SSID/password) untouched, since that's tied to the device
* itself, not a particular home network -- regenerating it on every reset * 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); 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 * Returns this device's provisioning AP identity: a fixed SSID (from
* Kconfig) and a password that's generated once on first use and persisted * Kconfig) and a password that's generated once on first use and persisted