Files
espresso_frame/firmware/main/frame_client.c
T
tfaour 5588ce3e1b
Build and push server image / build-and-push (push) Successful in 33s
HTTPS trust: use the public CA bundle + one missing root, not a pinned cert
Root-caused the earlier "No matching trusted root certificate found"
failure properly this time by reading ESP-IDF's actual bundle-matching
code (esp_crt_bundle.c): it looks up a trusted root by the ISSUER name
of whatever certificate it can't otherwise validate, not by matching
the presented certificate itself. The live server's chain ends in a
GTS Root R4 certificate cross-signed by the old GlobalSign Root CA R1
(common Cloudflare/Google Trust Services practice, for compatibility
with older/embedded clients) -- and ESP-IDF's current bundle snapshot
has dropped that old GlobalSign root entirely, so the lookup came up
empty. This was a general gap, not something specific to this one
deployment's cert.

Fix: keep the standard public CA bundle (esp_crt_bundle_attach) as the
trust mechanism -- so any normal reverse-proxy cert (Let's Encrypt,
etc.) works out of the box -- and add the one missing root on top via
ESP-IDF's CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE mechanism
(sdkconfig.defaults), which appends a project-supplied cert file to the
bundle at build time. Fetched GlobalSign's official Root CA R1 cert and
cryptographically verified (openssl verify) it actually validates the
live server's certificate before embedding it -- see
firmware/main/certs/additional_root_ca.pem (replaces the old
tools_server_ca.pem, which pinned one exact certificate directly and
would've broken for anyone else's reverse proxy). Confirmed working
against the real deployment on hardware.
2026-07-19 17:38:39 -04:00

756 lines
28 KiB
C

#include <string.h>
#include "esp_event.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_wifi_default.h"
#include "esp_netif.h"
#include "esp_http_client.h"
#include "esp_crt_bundle.h"
#include "esp_sleep.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "epd7in3e.h"
#include "status_screen.h"
#include "manage_qr_overlay.h"
#include "combo_button.h"
#include "frame_client.h"
static const char *TAG = "frame_client";
#define STA_CONNECTED_BIT BIT0
#define STA_FAILED_BIT BIT1
static EventGroupHandle_t s_sta_event_group;
/* Builds a full URL from cfg->toolsserver + a path (no leading slash),
* appending cfg->access_token as ?token= if one's set. toolsserver is
* normally a bare "host:port", defaulting to plain http; it may instead
* carry an explicit "http://" or "https://" prefix to pick the scheme,
* e.g. "https://frame.example.com" if a reverse proxy is terminating
* TLS in front of the tools server. The token, once the server has
* MANAGEMENT_TOKEN set, is required on every request the server
* receives (device-facing endpoints included, not just the web UI) --
* this is the one chokepoint all of them go through, so every caller
* gets it for free instead of needing to remember to add it. */
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
{
const char *toolsserver = cfg->toolsserver;
size_t len;
if (strncmp(toolsserver, "http://", 7) == 0 || strncmp(toolsserver, "https://", 8) == 0) {
len = (size_t)snprintf(out, out_size, "%s/%s", toolsserver, path);
} else {
len = (size_t)snprintf(out, out_size, "http://%s/%s", toolsserver, path);
}
if (cfg->access_token[0] != '\0' && len < out_size) {
snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
}
}
/* wifi_sta_config_t's ssid/password fields are fixed-size byte arrays, not
* necessarily null-terminated (a full 32-char SSID fills the field exactly).
* snprintf() flags that as a possible truncation at -Werror, so copy by
* hand instead. */
static void copy_wifi_field(uint8_t *dst, size_t dst_size, const char *src)
{
size_t len = strnlen(src, dst_size);
memcpy(dst, src, len);
if (len < dst_size) {
dst[len] = '\0';
}
}
static void sta_event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
ESP_LOGW(TAG, "Disconnected from home WiFi");
xEventGroupSetBits(s_sta_event_group, STA_FAILED_BIT);
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
xEventGroupSetBits(s_sta_event_group, STA_CONNECTED_BIT);
}
}
esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
{
s_sta_event_group = xEventGroupCreate();
esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();
wifi_init_config_t init_cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&init_cfg));
esp_event_handler_instance_t wifi_handler;
esp_event_handler_instance_t ip_handler;
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &sta_event_handler, NULL, &wifi_handler));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &sta_event_handler, NULL, &ip_handler));
wifi_config_t wifi_config = {0};
copy_wifi_field(wifi_config.sta.ssid, sizeof(wifi_config.sta.ssid), cfg->sta_ssid);
copy_wifi_field(wifi_config.sta.password, sizeof(wifi_config.sta.password), cfg->sta_password);
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
esp_err_t result = ESP_FAIL;
for (int attempt = 1; attempt <= CONFIG_FRAME_STA_CONNECT_MAX_RETRIES; attempt++) {
ESP_LOGI(TAG, "Connecting to '%s' (attempt %d/%d)", cfg->sta_ssid, attempt,
CONFIG_FRAME_STA_CONNECT_MAX_RETRIES);
xEventGroupClearBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT);
esp_wifi_connect();
EventBits_t bits = xEventGroupWaitBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT,
pdTRUE, pdFALSE,
pdMS_TO_TICKS(CONFIG_FRAME_STA_CONNECT_TIMEOUT_MS));
if (bits & STA_CONNECTED_BIT) {
result = ESP_OK;
break;
}
ESP_LOGW(TAG, "Attempt %d/%d failed", attempt, CONFIG_FRAME_STA_CONNECT_MAX_RETRIES);
}
esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_handler);
esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, ip_handler);
vEventGroupDelete(s_sta_event_group);
s_sta_event_group = NULL;
if (result != ESP_OK) {
/* Fully tear the WiFi driver back down on failure -- the caller
* falls back to provisioning, which calls esp_wifi_init() again
* for AP mode. Leaving the driver merely stopped (rather than
* deinitialized) made that second esp_wifi_init() call fail with
* ESP_ERR_INVALID_STATE and abort, confirmed on hardware. */
esp_wifi_stop();
esp_wifi_deinit();
esp_netif_destroy_default_wifi(sta_netif);
}
return result;
}
typedef struct {
bool reachable;
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
} frame_server_config_t;
/* Finds the first integer value associated with "key" in a small JSON
* blob, e.g. 3600 in {"refresh_interval_s": 3600}. Not a general JSON
* parser -- just enough for this project's small, flat config response,
* to avoid pulling in a JSON library for one scalar field. */
static bool json_extract_uint(const char *json, const char *key, uint32_t *out)
{
char needle[48];
snprintf(needle, sizeof(needle), "\"%s\"", key);
const char *pos = strstr(json, needle);
if (pos == NULL) {
return false;
}
pos = strchr(pos, ':');
if (pos == NULL) {
return false;
}
pos++;
while (*pos == ' ') {
pos++;
}
char *end;
unsigned long value = strtoul(pos, &end, 10);
if (end == pos) {
return false;
}
*out = (uint32_t)value;
return true;
}
/* Finds the string value associated with "key" in a small, flat JSON
* blob, e.g. "San Francisco, CA" in {"location": "San Francisco, CA"}.
* Same rationale as json_extract_uint() -- not a general parser. Returns
* false if the key is missing or its value is JSON null. Only unescapes
* \" -- values from this server need nothing fancier. */
static bool json_extract_string(const char *json, const char *key, char *out, size_t out_size)
{
char needle[48];
snprintf(needle, sizeof(needle), "\"%s\"", key);
const char *pos = strstr(json, needle);
if (pos == NULL) {
return false;
}
pos = strchr(pos, ':');
if (pos == NULL) {
return false;
}
pos++;
while (*pos == ' ') {
pos++;
}
if (strncmp(pos, "null", 4) == 0) {
return false;
}
if (*pos != '"') {
return false;
}
pos++;
size_t i = 0;
while (*pos != '\0' && *pos != '"' && i + 1 < out_size) {
if (pos[0] == '\\' && pos[1] == '"') {
out[i++] = '"';
pos += 2;
} else {
out[i++] = *pos++;
}
}
out[i] = '\0';
return true;
}
/* GETs the server's /frame/config -- doubles as both the reachability
* check (any completed HTTP response means the socket-level connection
* succeeded) and the source of the server-configurable refresh interval. */
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,
};
char url[256];
build_url(url, sizeof(url), cfg, "frame/config");
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Server '%s' not reachable: %s", cfg->toolsserver, esp_err_to_name(err));
esp_http_client_cleanup(client);
return result;
}
esp_http_client_fetch_headers(client);
result.reachable = true;
char body[256];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
uint32_t interval;
if (json_extract_uint(body, "refresh_interval_s", &interval)) {
result.refresh_interval_s = interval;
} else {
ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
(int)result.refresh_interval_s);
}
return result;
}
/* GETs the server's /frame/photo-info for the manage-button overlay:
* location/taken_at text (left empty if the server didn't have them --
* e.g. no GPS EXIF to geocode, or no capture date) and a share_url built
* from the returned asset_id, same construction pattern as
* run_fetch_cycle()'s management_url. Any failure (unreachable, no
* current photo, etc.) just leaves all outputs empty -- the caller
* treats that as "skip these optional overlay regions", not a hard
* error, since the base "scan to manage" QR should still show. */
static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, size_t location_line1_size,
char *location_line2, size_t location_line2_size, char *taken_at, size_t taken_at_size,
char *share_url, size_t share_url_size)
{
location_line1[0] = '\0';
location_line2[0] = '\0';
taken_at[0] = '\0';
share_url[0] = '\0';
char url[256];
build_url(url, sizeof(url), cfg, "frame/photo-info");
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return;
}
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
if (status != 200) {
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return;
}
char body[384];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
json_extract_string(body, "location_line1", location_line1, location_line1_size);
json_extract_string(body, "location_line2", location_line2, location_line2_size);
json_extract_string(body, "taken_at", taken_at, taken_at_size);
char asset_id[48];
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
char path[80];
snprintf(path, sizeof(path), "frame/share/%s", asset_id);
build_url(share_url, share_url_size, cfg, path);
}
}
/* GETs the server's /frame/face-labels for the manage-button's escalated
* "level 2" menu -- named-face positions, if Immich has any for the
* current photo. Response is a flattened, fixed-slot shape ("count",
* then name_0/x_0/y_0, name_1/x_1/y_1, ...) rather than a real JSON
* array, read with the same flat-scalar helpers as everywhere else in
* this file instead of needing an actual array parser. Any failure
* (unreachable, malformed response, etc.) just returns 0 -- named faces
* are a "nice to have" addition to the menu, not worth failing it over. */
static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out, int max_labels)
{
char url[256];
build_url(url, sizeof(url), cfg, "frame/face-labels");
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return 0;
}
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
if (status != 200) {
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return 0;
}
char body[768];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
uint32_t count = 0;
json_extract_uint(body, "count", &count);
if ((int)count > max_labels) {
count = (uint32_t)max_labels;
}
int found = 0;
for (uint32_t i = 0; i < count; i++) {
char key[16];
snprintf(key, sizeof(key), "name_%u", (unsigned)i);
if (!json_extract_string(body, key, out[found].name, sizeof(out[found].name))) {
continue;
}
snprintf(key, sizeof(key), "x_%u", (unsigned)i);
uint32_t x;
if (!json_extract_uint(body, key, &x)) {
continue;
}
snprintf(key, sizeof(key), "y_%u", (unsigned)i);
uint32_t y;
if (!json_extract_uint(body, key, &y)) {
continue;
}
out[found].x = (int)x;
out[found].y = (int)y;
found++;
}
return found;
}
typedef struct {
esp_http_client_handle_t client;
size_t stream_pos; /* running absolute offset into the frame, for overlay splicing */
const manage_overlay_set_t *overlay; /* NULL = no overlay this fetch */
} http_read_ctx_t;
/* Splices one overlay region's pixels over the real photo bytes in chunk
* wherever chunk's absolute byte range [chunk_start, chunk_start+chunk_len)
* within the full frame intersects that region's rectangle. Rows/chunks
* outside the region's footprint are left completely untouched.
* region->x0 is always even (see manage_qr_overlay.h), so byte_x0 below
* is exact. */
static void splice_overlay_region(uint8_t *chunk, size_t chunk_len, size_t chunk_start,
const manage_overlay_region_t *region)
{
int byte_x0 = region->x0 / 2;
int byte_w = region->w / 2;
size_t chunk_end = chunk_start + chunk_len;
for (int row = region->y0; row < region->y0 + region->h; row++) {
size_t row_start = (size_t)row * EPD_BYTES_PER_ROW + (size_t)byte_x0;
size_t row_end = row_start + (size_t)byte_w;
size_t lo = row_start > chunk_start ? row_start : chunk_start;
size_t hi = row_end < chunk_end ? row_end : chunk_end;
if (lo >= hi) {
continue;
}
size_t region_row_offset = (size_t)(row - region->y0) * (size_t)byte_w + (lo - row_start);
memcpy(chunk + (lo - chunk_start), region->buf + region_row_offset, hi - lo);
}
}
static void splice_overlay(uint8_t *chunk, size_t chunk_len, size_t chunk_start, const manage_overlay_set_t *overlay)
{
for (int i = 0; i < overlay->count; i++) {
splice_overlay_region(chunk, chunk_len, chunk_start, &overlay->regions[i]);
}
}
/* Pulls the next chunk straight out of the in-progress HTTP response --
* epd_write_frame() calls this to feed the panel without ever holding
* the full ~192KB frame in RAM. Splices in ctx->overlay's regions (if
* set) as chunks pass through, so the panel driver never needs to know
* an overlay exists at all. */
static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
http_read_ctx_t *ctx = (http_read_ctx_t *)ctx_;
int n = esp_http_client_read(ctx->client, (char *)chunk, (int)chunk_size);
if (n <= 0) {
return 0;
}
if (ctx->overlay != NULL) {
splice_overlay(chunk, (size_t)n, ctx->stream_pos, ctx->overlay);
}
ctx->stream_pos += (size_t)n;
return (size_t)n;
}
/* 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), and streams the
* response directly into the panel, splicing in overlay's pixels (if
* non-NULL) as it streams. 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 exactly as it
* was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action,
const manage_overlay_set_t *overlay)
{
const char *path = "frame/image";
if (action == FETCH_ADVANCE) {
path = "frame/advance";
} else if (action == FETCH_BACK) {
path = "frame/back";
}
char url[256];
build_url(url, sizeof(url), cfg, path);
esp_http_client_config_t config = {
.url = url,
.method = action == FETCH_NORMAL ? HTTP_METHOD_GET : HTTP_METHOD_POST,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to open '%s': %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return err;
}
int content_length = esp_http_client_fetch_headers(client);
int status = esp_http_client_get_status_code(client);
if (status != 200) {
ESP_LOGE(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return ESP_FAIL;
}
ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url);
http_read_ctx_t ctx = { .client = client, .overlay = overlay };
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;
}
#define MANAGE_MENU_MAX_LEVEL 2
#define MANAGE_MENU_LEVEL_TIMEOUT_MS 30000
#define MANAGE_MENU_POLL_MS 150
#define MANAGE_MENU_DEBOUNCE_MS 30
/* Polls the manage button for up to timeout_ms for a new press. On
* detecting one, waits for release before returning true, so a single
* physical press-and-release is always exactly one event to the caller
* -- without that, a press held across multiple poll intervals would
* register as multiple escalations. Returns false if timeout_ms elapses
* with no press. */
static bool wait_for_button_press(uint32_t timeout_ms)
{
uint32_t elapsed_ms = 0;
while (elapsed_ms < timeout_ms) {
vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_POLL_MS));
elapsed_ms += MANAGE_MENU_POLL_MS;
if (!combo_button_is_pressed()) {
continue;
}
vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_DEBOUNCE_MS));
if (!combo_button_is_pressed()) {
continue; /* noise, not a real press */
}
while (combo_button_is_pressed()) {
vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_POLL_MS));
}
return true;
}
return false;
}
/* Builds and shows one level of the manage menu: level 1 is the base
* overlay (management QR + location/date/share-QR wherever the server
* had that data); level 2 adds named-face labels on top. action only
* applies at level 1 -- escalating to level 2 redisplays the same
* photo, so it never re-advances/-backs. */
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level)
{
char management_url[256];
build_url(management_url, sizeof(management_url), cfg, "");
char location_line1[32];
char location_line2[32];
char taken_at[32];
/* Wider than the other URL buffers in this file: unlike a fixed path,
* this one stacks toolsserver (up to 128) + "/frame/share/" + an
* asset_id (up to 47) + "?token=" + an access_token (up to 64) --
* worst case ~266 bytes, which a 256-byte buffer could silently
* truncate the token off of (build_url()'s bounds check avoids an
* overflow, but a truncated/dropped token still means the resulting
* request just 401s with no obvious cause). */
char share_url[320];
fetch_photo_info(cfg, location_line1, sizeof(location_line1), location_line2,
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url));
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
int face_label_count = 0;
if (level >= 2) {
face_label_count = fetch_face_labels(cfg, face_labels, MANAGE_FACE_LABELS_MAX);
}
manage_overlay_content_t content = {
.management_url = management_url,
.location_line1 = location_line1[0] != '\0' ? location_line1 : NULL,
.location_line2 = location_line2[0] != '\0' ? location_line2 : NULL,
.taken_at = taken_at[0] != '\0' ? taken_at : NULL,
.share_url = share_url[0] != '\0' ? share_url : NULL,
.face_labels = face_labels,
.face_label_count = face_label_count,
};
manage_overlay_set_t overlay;
esp_err_t err = manage_overlay_render(&content, &overlay);
if (err != ESP_OK) {
manage_overlay_free(&overlay);
return err;
}
err = fetch_and_display(cfg, action, &overlay);
manage_overlay_free(&overlay);
return err;
}
/* Runs the manage-button menu: level 1 (the base overlay) shows first;
* from there, each further press within 30s escalates one level (up to
* MANAGE_MENU_MAX_LEVEL, which adds named-face labels), and a press once
* already at the max level exits immediately instead of escalating
* further. A 30s timeout at any level also exits. Device stays awake
* throughout (doesn't sleep the panel or the chip). Returns non-ESP_OK
* only if the very first (level 1) render/fetch failed; failures after
* that (escalating, or the final revert) are logged but don't count as
* an overall failure -- something was already shown successfully, which
* was the point of the button. */
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
{
int level = 1;
esp_err_t err = show_menu_level(cfg, action, level);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
return fetch_and_display(cfg, action, NULL);
}
for (;;) {
ESP_LOGI(TAG, "Showing management menu level %d, waiting up to 30s", level);
bool pressed = wait_for_button_press(MANAGE_MENU_LEVEL_TIMEOUT_MS);
if (!pressed || level >= MANAGE_MENU_MAX_LEVEL) {
break; /* timeout at any level, or a press while already maxed out -- exit */
}
level++;
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level);
if (level_err != ESP_OK) {
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
break;
}
}
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, NULL);
if (revert_err != ESP_OK) {
ESP_LOGW(TAG, "Failed to revert management overlay (%s)", esp_err_to_name(revert_err));
}
return ESP_OK;
}
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
* show_management_qr -- the escalating manage menu (see
* run_management_menu()). */
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
if (!show_management_qr) {
return fetch_and_display(cfg, action, NULL);
}
return run_management_menu(cfg, action);
}
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
esp_err_t epd_err = epd_init();
bool have_display = (epd_err == ESP_OK);
if (!have_display) {
ESP_LOGW(TAG, "EPD init failed (%s), continuing without display", esp_err_to_name(epd_err));
}
/* Always show the status screen on the first successful connection
* after (re)provisioning, regardless of outcome -- confirms the
* connection worked. Skipped on later wakes to save a refresh, except
* when something's actually wrong (handled below). */
bool first_connection = !frame_config_has_connected_once();
if (first_connection) {
frame_config_mark_connected_once();
if (have_display) {
status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_PENDING);
}
}
/* 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 = run_fetch_cycle(cfg, action, show_management_qr);
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) {
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);
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
}
if (have_display) {
epd_sleep();
}
ESP_LOGI(TAG, "Deep sleeping for %u seconds", (unsigned)sleep_seconds);
esp_sleep_enable_timer_wakeup((uint64_t)sleep_seconds * 1000000ULL);
esp_deep_sleep_start();
}