#include #include "esp_app_desc.h" #include "esp_event.h" #include "esp_ota_ops.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 "epd_board.h" #include "status_screen.h" #include "combo_button.h" #include "ota_update.h" #include "board_antenna.h" #include "battery.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; /* Passed as the event-handler arg during frame_wifi_connect_sta so it can * apply the cached static IP (fast_cache != NULL) exactly once, on the * fast-connect attempt's own WIFI_EVENT_STA_CONNECTED -- fallback attempts * leave fast_cache NULL and get normal DHCP. */ typedef struct { esp_netif_t *netif; const frame_wifi_cache_t *fast_cache; } sta_connect_ctx_t; /* Applies a cached static IP right after L2 link-up, skipping DHCP. * esp_netif_set_ip_info() only posts IP_EVENT_STA_GOT_IP (which is what * unblocks the connect-attempt wait below) once the netif is already * "up" -- true by this point, since the internal netif-glue's own * WIFI_EVENT_STA_CONNECTED subscriber (registered earlier, in * esp_netif_create_default_wifi_sta()) runs before this one and brings * the netif up first. Confirmed against ESP-IDF's own * examples/protocols/static_ip. */ static void apply_fast_ip(esp_netif_t *netif, const frame_wifi_cache_t *cache) { if (esp_netif_dhcpc_stop(netif) != ESP_OK) { return; } esp_netif_ip_info_t ip_info = { .ip.addr = cache->ip, .netmask.addr = cache->netmask, .gw.addr = cache->gateway, }; if (esp_netif_set_ip_info(netif, &ip_info) != ESP_OK) { return; } if (cache->dns != 0) { esp_netif_dns_info_t dns_info = { .ip.type = ESP_IPADDR_TYPE_V4 }; dns_info.ip.u_addr.ip4.addr = cache->dns; esp_netif_set_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns_info); } } /* Records BSSID/channel/IP/netmask/gateway/DNS from a connection that just * succeeded (fast path or normal), for the next wake's fast-connect * attempt. Best-effort: any lookup failing here just means next wake * falls back to a normal scan+DHCP, not a hard error. */ static void save_wifi_cache(esp_netif_t *netif) { wifi_ap_record_t ap_info; if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK) { return; } esp_netif_ip_info_t ip_info; if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK) { return; } esp_netif_dns_info_t dns_info = {0}; esp_netif_get_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns_info); frame_wifi_cache_t cache = {0}; memcpy(cache.bssid, ap_info.bssid, sizeof(cache.bssid)); cache.channel = ap_info.primary; cache.ip = ip_info.ip.addr; cache.netmask = ip_info.netmask.addr; cache.gateway = ip_info.gw.addr; cache.dns = dns_info.ip.u_addr.ip4.addr; frame_wifi_cache_save(&cache); } /* Builds a full URL from cfg->toolsserver + a path (no leading slash), * appending cfg->device_token as &token= once one's been delivered. * 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. Every URL carries * ?id= (the device's MAC-derived identity -- how a multi-frame server * tells frames apart and how an unknown frame self-registers) plus * &token=. This is the one chokepoint all requests go through, so every * caller gets both for free instead of needing to remember to add them. */ 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); } char device_id[FRAME_DEVICE_ID_LEN + 1]; frame_device_id_get(device_id, sizeof(device_id)); if (len < out_size) { len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id); } if (cfg->device_token[0] != '\0' && len < out_size) { snprintf(out + len, out_size - len, "&token=%s", cfg->device_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) { sta_connect_ctx_t *ctx = (sta_connect_ctx_t *)arg; 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_CONNECTED) { if (ctx->fast_cache != NULL) { apply_fast_ip(ctx->netif, ctx->fast_cache); } } 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) { board_antenna_select_onboard(); 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)); sta_connect_ctx_t ctx = { .netif = sta_netif, .fast_cache = NULL }; 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, &ctx, &wifi_handler)); ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &sta_event_handler, &ctx, &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); frame_wifi_cache_t cache; bool have_cache = frame_wifi_cache_load(&cache); wifi_config_t start_config = wifi_config; if (have_cache) { /* Known BSSID/channel -- skips the all-channel scan. The IP side * of the fast path (skipping DHCP) happens in apply_fast_ip once * WIFI_EVENT_STA_CONNECTED confirms this specific AP answered. */ start_config.sta.bssid_set = true; memcpy(start_config.sta.bssid, cache.bssid, sizeof(cache.bssid)); start_config.sta.channel = cache.channel; start_config.sta.scan_method = WIFI_FAST_SCAN; } ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &start_config)); ESP_ERROR_CHECK(esp_wifi_start()); esp_err_t result = ESP_FAIL; if (have_cache) { ESP_LOGI(TAG, "Connecting to '%s' (fast path: cached BSSID/channel + static IP)", cfg->sta_ssid); ctx.fast_cache = &cache; 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)); ctx.fast_cache = NULL; if (bits & STA_CONNECTED_BIT) { result = ESP_OK; } else { ESP_LOGW(TAG, "Fast-connect attempt failed, falling back to a full scan"); frame_wifi_cache_clear(); /* apply_fast_ip's esp_netif_dhcpc_stop() leaves the netif's * internal DHCP state STOPPED rather than the default INIT -- * left alone, the next WIFI_EVENT_STA_CONNECTED would make * esp-netif's own glue silently re-post the stale cached IP * instead of actually running DHCP (see * esp_netif_action_connected in esp_netif_handlers.c). Calling * this now (netif is down, mid-retry) just resets that state * back to INIT -- confirmed against esp_netif_dhcpc_start's * source, doesn't yet touch the network. */ esp_netif_dhcpc_start(sta_netif); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); } } for (int attempt = 1; result != ESP_OK && 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) { save_wifi_cache(sta_netif); } else { /* 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 */ /* 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 * frame_config_set_device_token() and used by build_url() from the * next request on. */ char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; } 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), the source of the server-configurable refresh interval, * and (via the X-Frame-Version/X-Frame-Board request headers and * firmware_version response field) the device's OTA update check -- * piggybacked on a request already made every wake, no extra round * trip. X-Frame-Board lets the server learn which board this device is * (CONFIG_FRAME_BOARD_NAME) so it can pick the right Gitea release * asset itself, instead of a user manually selecting a board in the * web UI. */ 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'; 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_http_client_set_header(client, "X-Frame-Version", esp_app_get_description()->version); esp_http_client_set_header(client, "X-Frame-Board", CONFIG_FRAME_BOARD_NAME); 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; /* 512 (was 256): the response also carries "device_token" during the * one-time identity handshake -- worst case is still well under half * of this, the rest is headroom for future fields. */ char body[512]; 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); } 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)); return result; } typedef struct { esp_http_client_handle_t client; } http_read_ctx_t; /* 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. Just a plain relay: the manage overlay * (scan-to-manage QR, battery, location/date, share-QR, named face * labels) is composited server-side now (see server/app/manage_overlay.py), * baked into the same image bytes as any other render -- this function, * like the rest of this file, has no idea an overlay exists. */ 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); return n > 0 ? (size_t)n : 0; } /* 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 the active EPD driver component, main/epd_board.h) 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, bool manage) { const char *path = "frame/image"; if (action == FETCH_ADVANCE) { 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]; build_url(url, sizeof(url), cfg, path); if (manage) { size_t len = strlen(url); if (len + strlen("&manage=1") < sizeof(url)) { strcpy(url + len, "&manage=1"); } } 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 }; 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_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; } /* Runs the manage-button view: fetches once with manage=1 (the server * bakes its whole overlay -- scan-to-manage QR, battery, location/date/ * share-QR, every named face label, no more RAM-driven cap on how many -- * into the response), shows it, then waits up to 30s for either another * press or the timeout before reverting to a plain fetch. Device stays * awake throughout (doesn't sleep the panel or the chip). Returns * non-ESP_OK only if the manage fetch itself failed; a revert failure * after that is logged but doesn'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) { esp_err_t err = fetch_and_display(cfg, action, true); if (err != ESP_OK) { ESP_LOGW(TAG, "Could not fetch manage view (%s), showing photo normally", esp_err_to_name(err)); return fetch_and_display(cfg, action, false); } ESP_LOGI(TAG, "Showing manage view, waiting up to 30s"); wait_for_button_press(MANAGE_MENU_TIMEOUT_MS); esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, false); if (revert_err != ESP_OK) { ESP_LOGW(TAG, "Failed to revert manage view (%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 manage view (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, false); } return run_management_menu(cfg, action); } /* Reports the battery percent to the server (POST /frame/battery). * Best-effort only: a battery report must never fail a photo cycle, so * every failure here is just a warning. No-op for percent < 0. */ static void report_battery(const frame_config_t *cfg, int percent) { if (percent < 0) { return; } char url[256]; build_url(url, sizeof(url), cfg, "frame/battery"); char body[48]; int body_len = snprintf(body, sizeof(body), "{\"percent\": %d}", percent); esp_http_client_config_t config = { .url = url, .method = HTTP_METHOD_POST, .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_http_client_set_header(client, "Content-Type", "application/json"); esp_err_t err = esp_http_client_open(client, body_len); if (err != ESP_OK) { ESP_LOGW(TAG, "Battery report failed to connect: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return; } esp_http_client_write(client, body, body_len); int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1; if (status != 200) { ESP_LOGW(TAG, "Battery report returned HTTP %d", status); } else { ESP_LOGI(TAG, "Reported battery %d%% to server", percent); } esp_http_client_close(client); esp_http_client_cleanup(client); } 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 the active EPD driver * component, main/epd_board.h), 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)); /* Covers the fast-connect cache's blind spot: WiFi can report * a successful connection (cached static IP "worked" at the * link layer) while the cached IP is actually stale/dead at * the network layer -- this is the first real evidence of * that, since it's the first thing that actually talks to the * server. Clearing here means next wake gets a clean scan + * DHCP instead of repeating the same silent failure. */ frame_wifi_cache_clear(); 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) { /* A full fetch/display cycle just succeeded -- exactly the proof * of life needed to confirm a freshly-OTA'd image is good. * No-op if this image was already marked valid (i.e. every * normal boot, not just the one right after an update). */ esp_ota_mark_app_valid_cancel_rollback(); /* Read now, not at boot: the photo (and, if shown, the manage * overlay -- entirely server-composited now, using the server's * own last-known battery value, not a local reading, see * server/app/manage_overlay.py) is already on the panel, so * there's no display deadline to beat. * Reading here instead of right after waking sidesteps taking the * ADC sample while the rail's still settling from whatever the * boot/reset just did, with no need to guess a settle delay -- * the fetch/display work already done this cycle is the delay. * Still safe re: the battery/button pin sharing (battery.h) -- * every button check main.c does happens well before this, at * the very start of boot. */ int battery_percent = battery_read_percent(); 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 * and use it immediately (the OTA below is part of this same * cycle) via a local working copy -- cfg itself is const. */ frame_config_t updated_cfg; if (server_cfg.device_token[0] != '\0' && strcmp(server_cfg.device_token, cfg->device_token) != 0) { frame_config_set_device_token(server_cfg.device_token); updated_cfg = *cfg; snprintf(updated_cfg.device_token, sizeof(updated_cfg.device_token), "%s", server_cfg.device_token); cfg = &updated_cfg; } /* Last, deliberately -- the photo's already on screen and the * battery report already sent, so a reboot here (whether OTA * succeeds or the device is mid-update) never loses either. */ ota_update_if_available(cfg, server_cfg.firmware_version); } 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(); }