Files
espresso_frame/firmware/main/frame_client.c
T
tfaour 4f4b2844e6 Firmware: WiFi fast-connect cache (skip scan + DHCP on the next wake)
After a successful home-WiFi connection, caches BSSID/channel and
IP/netmask/gateway/DNS in NVS. The next wake's first connect attempt
uses the cached BSSID/channel (skips the all-channel scan) and applies
the cached IP directly once the link comes up (skips DHCP) -- a couple
fewer seconds of radio-on time per wake, free every wake since nothing
about the network actually needs renegotiating most of the time.

Falls back to a normal scan+DHCP attempt, and clears the cache, if: the
fast attempt itself fails, or it "succeeds" at the WiFi layer but the
full fetch cycle then fails anyway (a stale cached IP/DNS/gateway that
associates but can't actually reach the server). Also cleared on
(re)provisioning and factory reset, since a new network shouldn't try
to reuse the old one's cache.

The static-IP path needed care to get right without touching untested
territory: esp_netif_set_ip_info() only posts IP_EVENT_STA_GOT_IP (what
the existing connect-wait logic blocks on) once the netif is already
up, which the internal netif-glue's own WIFI_EVENT_STA_CONNECTED
handler guarantees by running first (registered earlier, in
esp_netif_create_default_wifi_sta()) -- confirmed against ESP-IDF's own
static_ip example and esp_netif_handlers.c source rather than assumed.
Falling back after a failed fast attempt also needed an explicit
esp_netif_dhcpc_start() first: esp_netif_dhcpc_stop() leaves the netif's
DHCP status STOPPED rather than resetting to INIT, and left alone the
glue would silently re-post the stale cached IP on the next connect
instead of actually running DHCP (esp_netif_action_connected).

Version bumped to 1.1.0 (real feature, not just a fix); build-verified
clean on both board configs (devkit 8MB, XIAO 4MB), no new warnings.
2026-07-20 23:46:57 -04:00

964 lines
38 KiB
C

#include <string.h>
#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 "epd7in3e.h"
#include "status_screen.h"
#include "manage_qr_overlay.h"
#include "combo_button.h"
#include "ota_update.h"
#include "board_antenna.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->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)
{
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 */
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
} 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 request header / firmware_version
* response field) the device's OTA update check -- piggybacked on a
* request already made every wake, no extra round trip. */
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,
};
result.firmware_version[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_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);
}
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
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");
/* CONFIG_FRAME_FETCH_TIMEOUT_MS, not the shorter SERVER_CHECK one:
* unlike fetch_frame_config() (always called after the image fetch
* has already warmed the connection, see frame_client_run()), this
* is the *first* network call of the wake cycle whenever the manage
* menu is opened -- same cold-connection latency spike that made
* the short timeout unreliable for /frame/config before, now worse
* with a real TLS handshake on top. Confirmed on hardware: this
* timed out under CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS while the
* rest of the cycle (a fresh connection, but not the *first* one)
* succeeded fine. */
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.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_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");
/* Same reasoning as fetch_photo_info() -- this is a manage-menu
* request too, not a warmed-connection reachability check. */
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.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_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,
int battery_percent)
{
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,
.battery_percent = battery_percent,
};
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 battery_percent)
{
int level = 1;
esp_err_t err = show_menu_level(cfg, action, level, battery_percent);
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, battery_percent);
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,
int battery_percent)
{
if (!show_management_qr) {
return fetch_and_display(cfg, action, NULL);
}
return run_management_menu(cfg, action, battery_percent);
}
/* 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,
int battery_percent)
{
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, battery_percent);
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));
/* 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();
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;
/* 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();
}