Move button actions to per-widget config, add hold-for-global-action
Build and push server image / test (push) Successful in 36s
Firmware build check / build-check (push) Successful in 2m4s
Build and push server image / build-and-push (push) Successful in 3m12s
Build and push server image / deploy (push) Successful in 58s

Next/back button assignment moves from a frame-level "Button
assignments" card into each widget's own gear-icon dialog, prefilled
with a sane default at creation (photos/calendar -> advance/back,
whiteboard/weather -> check_now, others -> none). At most one binding
per (widget, button) now -- cross-widget execution order never
mattered since each widget's action only touches its own state.

New firmware capability: holding NEXT or BACK past a configurable
duration (min 3s, server-side default) triggers a frame-wide action
instead of the per-widget short-press one -- cycling saved layouts,
refreshing all widgets, or freezing/unfreezing every photo widget (see
app/global_actions.py). Firmware next/back checks gain the same
hold-duration polling the combo button already had; the threshold
comes from the previous wake's /frame/config fetch (persisted in NVS),
since this wake's button decision happens before that request.

Not done here: firmware/version.txt is intentionally left unbumped --
this hasn't been built or hardware-tested (no ESP-IDF toolchain in this
environment), so no firmware release build should be triggered yet.
This commit is contained in:
2026-07-27 22:09:33 +00:00
parent 9911151d8d
commit fcf3aec4c0
47 changed files with 1351 additions and 602 deletions
+33 -1
View File
@@ -68,6 +68,7 @@ Under **ESPresso Frame Configuration**:
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset |
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
| `FRAME_HOLD_ACTION_MS` | 3000 | **Fallback only** -- how long NEXT/BACK must be held to trigger a global action instead of a short press; see below |
| `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below |
| `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains |
@@ -84,6 +85,34 @@ reflashing. The Kconfig value only applies before the device has ever
successfully reached a configured server, or if the response doesn't
include a valid interval.
### Holding NEXT/BACK for a global action
Past `FRAME_HOLD_ACTION_MS`, holding NEXT or BACK stops meaning "advance/
back this widget" and instead triggers whatever frame-wide action (if
any) is configured for that button's hold on the server's Configuration
tab -- e.g. cycling through saved layouts (see
`server/app/global_actions.py`). Fires immediately at the threshold,
without waiting for release -- same convention as the combo button's
factory-reset tier below.
Same "fallback only" caveat as `FRAME_SLEEP_INTERVAL_S` above, but with
one more wrinkle: the server's actual `hold_duration_ms` (set on the
Configuration tab, `GET /frame/config`'s response) can't be used for
*this* wake's button decision -- that decision happens in `main.c`
before WiFi even connects, but `/frame/config` isn't fetched until near
the end of the wake cycle (after the image fetch, deliberately -- see
`frame_client_run`'s own comment on why). So the device always acts on
whatever value the *previous* wake fetched (persisted in NVS via
`frame_config_set_hold_duration_ms`), falling back to
`FRAME_HOLD_ACTION_MS` only before it's ever successfully fetched one.
In practice this means changing the duration on the Configuration tab
takes effect starting with the wake *after* the next one, not
immediately.
Holding a button through the poll loop keeps the device awake and
connected longer than a normal short-press wake -- the same tradeoff
already accepted for the combo button's menu/reset holds below.
### WiFi fast-connect
After a successful home-WiFi connection, the device caches the AP's
@@ -204,6 +233,8 @@ device (if asleep) and tells the server to advance to the next photo
right away, regardless of the configured refresh interval -- no long hold
needed, since advancing is easily reversible by pressing again. See
`FRAME_NEXT_BUTTON_GPIO` above to change the pin or disable the feature.
Holding it past `FRAME_HOLD_ACTION_MS` instead means something else
entirely -- see "Holding NEXT/BACK for a global action" above.
Normal wakes and reboots never advance the photo on their own -- the
server decides when to advance based on its own clock (see
@@ -223,7 +254,8 @@ disable the feature.
If there's nothing to go back to yet (freshly provisioned, or you've
already gone back as far as there is history), it's a no-op -- the
current photo stays exactly as it was, no flash on the panel.
current photo stays exactly as it was, no flash on the panel. Same
long-hold caveat as the next-photo button above.
## Battery (XIAO ESP32-C6)
+17
View File
@@ -188,6 +188,23 @@ menu "ESPresso Frame Configuration"
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
confused for each other.
config FRAME_HOLD_ACTION_MS
int "Next/back hold-for-global-action duration (ms)"
default 3000
range 3000 10000
help
How long the NEXT or BACK button must be held before it
triggers a frame-wide action (see server/app/global_actions.py
-- e.g. cycling saved layouts) instead of that button's normal
short-press behavior. Only a first-boot/never-connected
fallback: once the device has fetched GET /frame/config at
least once, the server's own Frame.hold_duration_ms (set on
the Configuration tab) overrides this on every later boot --
see wifi_provisioning.h's frame_config_get_hold_duration_ms.
Floor matches the server's own minimum, so a long-held button
never means something different depending on which value
happened to apply.
config FRAME_BATTERY_ADC_GPIO
int "Battery voltage-divider ADC GPIO (-1 to disable)"
default -1
+40 -18
View File
@@ -1,3 +1,5 @@
#include <stdint.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
@@ -5,6 +7,8 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "wifi_provisioning.h"
#include "back_button.h"
static const char *TAG = "back_button";
@@ -14,6 +18,7 @@ static const char *TAG = "back_button";
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
#define BACK_BUTTON_DEBOUNCE_MS 20
#define BACK_BUTTON_DEBOUNCE_CHECKS 3
#define BACK_BUTTON_POLL_MS 100
void back_button_init(void)
{
@@ -36,7 +41,7 @@ void back_button_init(void)
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
bool back_button_check(void)
back_button_result_t back_button_check(void)
{
/* A quick tap can easily release before this runs (~0.4-0.5s into
* boot, confirmed on hardware -- a live gpio_get_level() check here
@@ -44,32 +49,49 @@ bool back_button_check(void)
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO)) {
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
return true;
}
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
* power-on/reflash) -- fall back to a live, debounced level check so
* holding the button down while powering on also works. */
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return false;
}
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* fresh power-on/reflash) -- fall back to a live, debounced level
* check so holding the button down while powering on also
* works. */
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */
return BACK_BUTTON_NOT_PRESSED;
}
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return BACK_BUTTON_NOT_PRESSED; /* noise, not a real press */
}
}
}
ESP_LOGI(TAG, "Back-photo button held during power-on, going back");
return true;
/* Confirmed pressed -- measure how long, same reasoning/pattern as
* next_button_check(). */
uint32_t hold_threshold_ms;
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
}
uint32_t elapsed_ms = 0;
while (gpio_get_level(BACK_BUTTON_GPIO) == 0) {
if (elapsed_ms >= hold_threshold_ms) {
ESP_LOGI(TAG, "Back button held past %ums, triggering global hold action",
(unsigned)hold_threshold_ms);
return BACK_BUTTON_HOLD;
}
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_POLL_MS));
elapsed_ms += BACK_BUTTON_POLL_MS;
}
ESP_LOGI(TAG, "Back-photo button short press (%ums), going back", (unsigned)elapsed_ms);
return BACK_BUTTON_SHORT_PRESS;
}
#else
void back_button_init(void) {}
bool back_button_check(void) { return false; }
back_button_result_t back_button_check(void) { return BACK_BUTTON_NOT_PRESSED; }
#endif
+18 -4
View File
@@ -12,9 +12,23 @@
*/
void back_button_init(void);
typedef enum {
BACK_BUTTON_NOT_PRESSED,
/** A short press -- same immediate-response reasoning as the
* next-photo button. */
BACK_BUTTON_SHORT_PRESS,
/** Held past the configured hold duration (see
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
* triggers a frame-wide action instead (see
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_BACK).
* Fires immediately at the threshold, without waiting for release. */
BACK_BUTTON_HOLD,
} back_button_result_t;
/**
* Returns whether the back-photo button is currently held, debounced with
* a couple of short re-checks to reject noise. Same immediate-response
* reasoning as the next-photo button -- no long hold-to-confirm gate.
* Checks the back-photo button and, if it's pressed at all, blocks
* polling its level until either it's released (BACK_BUTTON_SHORT_PRESS)
* or the hold duration elapses (BACK_BUTTON_HOLD) -- same pattern as
* next_button_check(). Evaluated once per wake.
*/
bool back_button_check(void);
back_button_result_t back_button_check(void);
+29 -6
View File
@@ -276,6 +276,14 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
typedef struct {
bool reachable;
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
/* How long NEXT/BACK must be held to trigger a global action instead
* of a short press (see next_button.h/back_button.h) --
* CONFIG_FRAME_HOLD_ACTION_MS if absent/unparseable (older server) or
* unreachable. Persisted via frame_config_set_hold_duration_ms() for
* the *next* boot's button-hold decision -- this fetch happens too
* late in the cycle for its own boot's decision, see that function's
* own doc comment. */
uint32_t hold_duration_ms;
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
/* Per-frame token the server pushes until this device has
* authenticated with it once; empty when absent. Persisted via
@@ -370,6 +378,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
frame_server_config_t result = {
.reachable = false,
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
.hold_duration_ms = CONFIG_FRAME_HOLD_ACTION_MS,
};
result.firmware_version[0] = '\0';
result.device_token[0] = '\0';
@@ -419,6 +428,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
(int)result.refresh_interval_s);
}
uint32_t hold_ms;
if (json_extract_uint(body, "hold_duration_ms", &hold_ms)) {
result.hold_duration_ms = hold_ms;
}
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
@@ -443,12 +456,13 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
return n > 0 ? (size_t)n : 0;
}
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
* /frame/back to force a move in either direction (FETCH_ADVANCE /
* FETCH_BACK -- the next-photo / back-photo buttons). manage=true (the
* manage button) appends &manage=1, telling the server to bake its
* overlay into this same response instead of returning the bare
* content -- see server/app/routers/device.py. Returning non-ESP_OK
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance, /frame/back,
* /frame/global-next, or /frame/global-back to force a move/action
* (FETCH_ADVANCE / FETCH_BACK -- a short press; FETCH_GLOBAL_NEXT /
* FETCH_GLOBAL_BACK -- a held press, see next_button.h/back_button.h).
* manage=true (the manage button) appends &manage=1, telling the server
* to bake its overlay into this same response instead of returning the
* bare content -- see server/app/routers/device.py. Returning non-ESP_OK
* means the panel was never actually refreshed -- epd_display_stream()
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/
* wrong-size stream, so a failure here always leaves the visible screen
@@ -460,6 +474,10 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
path = "frame/advance";
} else if (action == FETCH_BACK) {
path = "frame/back";
} else if (action == FETCH_GLOBAL_NEXT) {
path = "frame/global-next";
} else if (action == FETCH_GLOBAL_BACK) {
path = "frame/global-back";
}
char url[256];
@@ -720,6 +738,11 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
report_battery(cfg, battery_percent);
frame_server_config_t server_cfg = fetch_frame_config(cfg);
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
if (server_cfg.reachable) {
/* For next boot's button-hold decision, not this one -- see
* frame_config_get_hold_duration_ms()'s own doc comment. */
frame_config_set_hold_duration_ms(server_cfg.hold_duration_ms);
}
/* One-time identity handshake: the server pushes this frame's
* own token until we've authenticated with it once. Persist it
+8 -3
View File
@@ -9,14 +9,19 @@
* Which photo-fetch behavior this wake cycle should use -- normally the
* idempotent GET /frame/image (the server decides on its own whether to
* advance, based on its configured refresh interval, so a plain
* wake/reboot never skips a photo just by asking), or POST
* /frame/advance / POST /frame/back to force a move in either direction
* (the next-photo / back-photo buttons).
* wake/reboot never skips a photo just by asking), POST /frame/advance /
* POST /frame/back to force a move in either direction (a short press of
* the next-photo / back-photo buttons), or POST /frame/global-next /
* POST /frame/global-back to run whatever frame-wide action (if any) is
* configured for a held press (see next_button.h/back_button.h's
* *_HOLD result and app/global_actions.py server-side).
*/
typedef enum {
FETCH_NORMAL,
FETCH_ADVANCE,
FETCH_BACK,
FETCH_GLOBAL_NEXT,
FETCH_GLOBAL_BACK,
} fetch_action_t;
/**
+13 -4
View File
@@ -40,12 +40,21 @@ void app_main(void)
back_button_init();
combo_button_init();
bool next_pressed = next_button_check();
bool back_pressed = back_button_check();
next_button_result_t next_result = next_button_check();
back_button_result_t back_result = back_button_check();
/* Next takes priority over back if somehow both read pressed at once
* (e.g. both held through a power-on) -- an arbitrary but
* deterministic tie-break, not expected to matter in practice. */
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL;
* deterministic tie-break, not expected to matter in practice. Same
* priority applies whether the winning button resolved to a short
* press or a hold. */
fetch_action_t action;
if (next_result != NEXT_BUTTON_NOT_PRESSED) {
action = (next_result == NEXT_BUTTON_HOLD) ? FETCH_GLOBAL_NEXT : FETCH_ADVANCE;
} else if (back_result != BACK_BUTTON_NOT_PRESSED) {
action = (back_result == BACK_BUTTON_HOLD) ? FETCH_GLOBAL_BACK : FETCH_BACK;
} else {
action = FETCH_NORMAL;
}
/* Soft-resets or clears config + restarts internally for a medium/
* long hold and never returns in those cases -- only returns here
* for "not pressed" (false) or "quick press" (true, show the menu). */
+45 -18
View File
@@ -1,3 +1,5 @@
#include <stdint.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
@@ -5,6 +7,8 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "wifi_provisioning.h"
#include "next_button.h"
static const char *TAG = "next_button";
@@ -14,6 +18,7 @@ static const char *TAG = "next_button";
#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
#define NEXT_BUTTON_DEBOUNCE_MS 20
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3
#define NEXT_BUTTON_POLL_MS 100
void next_button_init(void)
{
@@ -40,7 +45,7 @@ void next_button_init(void)
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
bool next_button_check(void)
next_button_result_t next_button_check(void)
{
/* A quick tap can easily release before this runs (~0.4-0.5s into
* boot, confirmed on hardware -- a live gpio_get_level() check here
@@ -48,32 +53,54 @@ bool next_button_check(void)
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) {
ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance");
return true;
}
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
* power-on/reflash) -- fall back to a live, debounced level check so
* holding the button down while powering on also works. */
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return false;
}
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* fresh power-on/reflash) -- fall back to a live, debounced level
* check so holding the button down while powering on also
* works. */
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */
return NEXT_BUTTON_NOT_PRESSED;
}
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return NEXT_BUTTON_NOT_PRESSED; /* noise, not a real press */
}
}
}
ESP_LOGI(TAG, "Next-photo button held during power-on, forcing advance");
return true;
/* Confirmed pressed (either the wake cause, or debounced during
* power-on) -- measure how long, same polling pattern as
* combo_button.c's own hold-tier detection. Reads the last hold
* duration the server reported (persisted from a previous cycle,
* see frame_config_get_hold_duration_ms's own doc comment), falling
* back to the Kconfig default before the device has ever fetched
* one. */
uint32_t hold_threshold_ms;
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
}
uint32_t elapsed_ms = 0;
while (gpio_get_level(NEXT_BUTTON_GPIO) == 0) {
if (elapsed_ms >= hold_threshold_ms) {
ESP_LOGI(TAG, "Next button held past %ums, triggering global hold action",
(unsigned)hold_threshold_ms);
return NEXT_BUTTON_HOLD;
}
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_POLL_MS));
elapsed_ms += NEXT_BUTTON_POLL_MS;
}
ESP_LOGI(TAG, "Next-photo button short press (%ums), forcing advance", (unsigned)elapsed_ms);
return NEXT_BUTTON_SHORT_PRESS;
}
#else
void next_button_init(void) {}
bool next_button_check(void) { return false; }
next_button_result_t next_button_check(void) { return NEXT_BUTTON_NOT_PRESSED; }
#endif
+22 -5
View File
@@ -12,10 +12,27 @@
*/
void next_button_init(void);
typedef enum {
NEXT_BUTTON_NOT_PRESSED,
/** A short press -- advancing a photo is low-stakes and should feel
* immediate, so this fires the moment the button releases (or right
* away for a wake-triggered press, once it's confirmed not a hold). */
NEXT_BUTTON_SHORT_PRESS,
/** Held past the configured hold duration (see
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
* triggers a frame-wide action instead (see
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_NEXT).
* Fires immediately at the threshold, without waiting for release --
* same convention as combo_button.c's factory-reset tier. */
NEXT_BUTTON_HOLD,
} next_button_result_t;
/**
* Returns whether the next-photo button is currently held, debounced with
* a couple of short re-checks to reject noise. No long hold-to-confirm
* gate -- advancing a photo is low-stakes and should feel immediate, so
* this returns right away either way.
* Checks the next-photo button and, if it's pressed at all (either what
* caused this wake, per the latched wakeup-status register, or held
* through a debounced power-on check), blocks polling its level until
* either it's released (NEXT_BUTTON_SHORT_PRESS) or the hold duration
* elapses (NEXT_BUTTON_HOLD, returned immediately, not waiting for
* release). Evaluated once per wake.
*/
bool next_button_check(void);
next_button_result_t next_button_check(void);
+23
View File
@@ -234,6 +234,29 @@ void frame_config_invalidate_last_display_crc32(void)
nvs_close(handle);
}
esp_err_t frame_config_get_hold_duration_ms(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, "hold_ms", out);
nvs_close(handle);
return err;
}
void frame_config_set_hold_duration_ms(uint32_t ms)
{
nvs_handle_t handle;
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
return;
}
nvs_set_u32(handle, "hold_ms", ms);
nvs_commit(handle);
nvs_close(handle);
}
/* ------------------------------------------------------------------------
* WiFi fast-connect cache
* ---------------------------------------------------------------------- */
+21
View File
@@ -94,6 +94,27 @@ void frame_config_set_last_display_crc32(uint32_t crc32);
*/
void frame_config_invalidate_last_display_crc32(void);
/**
* Returns the hold_duration_ms the server most recently reported via GET
* /frame/config (see frame_client.c's fetch_frame_config/frame_client_run)
* -- how long NEXT/BACK must be held before next_button_check()/
* back_button_check() treat it as a hold-for-global-action instead of a
* short press. Returns ESP_ERR_NVS_NOT_FOUND if the device has never
* fetched one yet (fresh install/factory reset); caller should fall back
* to CONFIG_FRAME_HOLD_ACTION_MS in that case.
*
* Deliberately a *previous* cycle's value: this cycle's own button
* decision happens in main.c before WiFi even connects, but
* /frame/config isn't fetched until near the end of frame_client_run
* (after the image fetch, for connection-warmth/timeout reasons -- see
* its own comment) -- so there's no same-cycle fresh value to use yet.
*/
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out);
/** Persists the hold duration reported by the server, for the *next*
* boot's button-hold decision to use. */
void frame_config_set_hold_duration_ms(uint32_t ms);
/**
* Returns this device's provisioning AP identity: a fixed SSID (from
* Kconfig) and a password that's generated once on first use and persisted