#include #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_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; /* The exact CA certificate the tools server's reverse proxy presents, * embedded verbatim (not sourced from ESP-IDF's built-in root bundle -- * that does an exact byte-level match against its compiled-in table, * and a semantically-identical "same name, same public key" root * re-issued under a different serial/signature, as Google did for GTS * Root R4, doesn't match it; confirmed on hardware, see * firmware/main/certs/tools_server_ca.pem for how this was extracted). * If the proxy's CA ever changes, re-extract with: * openssl s_client -connect :443 -showcerts 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, .cert_pem = tools_server_ca_pem_start, }; 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, .cert_pem = tools_server_ca_pem_start, }; 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, .cert_pem = tools_server_ca_pem_start, }; 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, .cert_pem = tools_server_ca_pem_start, }; 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(); }