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
+46 -23
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
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
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<br/>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<br/>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.
+25 -9
View File
@@ -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();
}
@@ -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);
+54 -27
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);
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) {
+23
View File
@@ -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);
+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
* 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