Files
espresso_frame/firmware/main/frame_client.c
T
tfaour a358045cea
Build and push server image / build-and-push (push) Successful in 32s
Fix "Show next" staleness bug; add location/date/share-QR to manage overlay
Two changes, bundled since they landed in the same session and touch
overlapping files:

1. Fix: "Show next" sent the browser's full queue snapshot to
   POST /api/queue/reorder, which hard-rejected if the server's queue
   had shifted since the last fetch (e.g. right after a queue-length
   trim). New POST /api/queue/promote moves one photo to the front
   authoritatively, with no dependency on client staleness. /reorder
   itself is now tolerant too -- unrecognized IDs are dropped and
   missing ones appended, instead of rejecting the whole request.

2. Feature: the manage button's overlay now also shows the photo's
   location (top-left, only if Immich reverse-geocoded it from GPS
   EXIF), the date it was taken (bottom-right), and a QR code (bottom-
   left) linking to a 30-minute public Immich share link -- created
   lazily when someone actually scans it, not when the button's
   pressed. New server endpoints GET /frame/photo-info and
   GET /frame/share/{asset_id} (scoped to the frame's current/queued
   photos, not any arbitrary Immich asset). Firmware-side, the overlay
   mechanism generalizes from one spliced region to up to four
   (manage_qr_overlay.c), each its own small buffer, still never
   holding the full frame in RAM.
2026-07-19 01:28:25 -04:00

559 lines
21 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_sleep.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "epd7in3e.h"
#include "status_screen.h"
#include "manage_qr_overlay.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;
/* 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 char *toolsserver)
{
frame_server_config_t result = {
.reachable = false,
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
};
char url[160];
snprintf(url, sizeof(url), "http://%s/frame/config", toolsserver);
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
};
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", 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 three 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 char *toolsserver, char *location, size_t location_size, char *taken_at,
size_t taken_at_size, char *share_url, size_t share_url_size)
{
location[0] = '\0';
taken_at[0] = '\0';
share_url[0] = '\0';
char url[160];
snprintf(url, sizeof(url), "http://%s/frame/photo-info", toolsserver);
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
};
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", location, location_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))) {
snprintf(share_url, share_url_size, "http://%s/frame/share/%s", toolsserver, asset_id);
}
}
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 (or, if force_advance, POSTs /frame/advance to skip
* ahead immediately) 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, bool force_advance,
const manage_overlay_set_t *overlay)
{
char url[160];
snprintf(url, sizeof(url), "http://%s/%s", cfg->toolsserver, force_advance ? "frame/advance" : "frame/image");
esp_http_client_config_t config = {
.url = url,
.method = force_advance ? HTTP_METHOD_POST : HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
};
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;
}
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
* show_management_qr -- a fetch with the manage overlay spliced in (the
* top-right "scan to manage" QR always, plus location/date-taken/
* share-QR corners wherever the server had that data -- see
* fetch_photo_info()), held on screen for 30s (device stays awake,
* doesn't sleep the panel or the chip), then reverted with a second
* plain fetch. Returns non-ESP_OK only if the FIRST fetch failed; a
* revert failure afterward is logged but doesn't count as an overall
* failure -- the overlay itself displayed fine, which was the point of
* the button. */
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, bool force_advance, bool show_management_qr)
{
if (!show_management_qr) {
return fetch_and_display(cfg, force_advance, NULL);
}
char management_url[160];
snprintf(management_url, sizeof(management_url), "http://%s/", cfg->toolsserver);
char location[64];
char taken_at[32];
char share_url[160];
fetch_photo_info(cfg->toolsserver, location, sizeof(location), taken_at, sizeof(taken_at), share_url,
sizeof(share_url));
manage_overlay_content_t content = {
.management_url = management_url,
.location = location[0] != '\0' ? location : NULL,
.taken_at = taken_at[0] != '\0' ? taken_at : NULL,
.share_url = share_url[0] != '\0' ? share_url : NULL,
};
manage_overlay_set_t overlay;
esp_err_t overlay_err = manage_overlay_render(&content, &overlay);
if (overlay_err != ESP_OK) {
manage_overlay_free(&overlay);
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally",
esp_err_to_name(overlay_err));
return fetch_and_display(cfg, force_advance, NULL);
}
esp_err_t err = fetch_and_display(cfg, force_advance, &overlay);
manage_overlay_free(&overlay);
if (err != ESP_OK) {
return err;
}
ESP_LOGI(TAG, "Showing management overlay for 30s");
vTaskDelay(pdMS_TO_TICKS(30000));
/* force_advance is always false here -- reverting shouldn't skip
* ahead a second time. */
esp_err_t revert_err = fetch_and_display(cfg, false, NULL);
if (revert_err != ESP_OK) {
ESP_LOGW(TAG, "Failed to revert management overlay (%s)", esp_err_to_name(revert_err));
}
return ESP_OK;
}
void frame_client_run(const frame_config_t *cfg, bool force_advance, 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, force_advance, 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->toolsserver);
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();
}