Fix "Show next" staleness bug; add location/date/share-QR to manage overlay
Build and push server image / build-and-push (push) Successful in 32s

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.
This commit is contained in:
2026-07-19 01:28:25 -04:00
parent 42d7c09f97
commit a358045cea
8 changed files with 523 additions and 83 deletions
+157 -27
View File
@@ -146,6 +146,48 @@ static bool json_extract_uint(const char *json, const char *key, uint32_t *out)
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. */
@@ -199,24 +241,87 @@ static frame_server_config_t fetch_frame_config(const char *toolsserver)
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_qr_overlay_t *overlay; /* NULL = no overlay this fetch */
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 overlay 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 the overlay's rectangle. Rows/chunks outside
* the overlay's footprint are left completely untouched. overlay->x0 is
* always even (see manage_qr_overlay.h), so byte_x0 below is exact. */
static void splice_overlay(uint8_t *chunk, size_t chunk_len, size_t chunk_start, const manage_qr_overlay_t *overlay)
/* 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 = overlay->x0 / 2;
int byte_w = overlay->w / 2;
int byte_x0 = region->x0 / 2;
int byte_w = region->w / 2;
size_t chunk_end = chunk_start + chunk_len;
for (int row = overlay->y0; row < overlay->y0 + overlay->h; row++) {
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;
@@ -226,14 +331,21 @@ static void splice_overlay(uint8_t *chunk, size_t chunk_len, size_t chunk_start,
continue;
}
size_t overlay_row_offset = (size_t)(row - overlay->y0) * (size_t)byte_w + (lo - row_start);
memcpy(chunk + (lo - chunk_start), overlay->buf + overlay_row_offset, hi - lo);
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 pixels (if
* 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_)
@@ -259,7 +371,8 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
* 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_qr_overlay_t *overlay)
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");
@@ -317,12 +430,15 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance
}
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
* show_management_qr -- a fetch with the "scan to manage" QR overlay
* spliced in, 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 QR
* itself displayed fine, which was the point of the button. */
* 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) {
@@ -332,28 +448,42 @@ static esp_err_t run_fetch_cycle(const frame_config_t *cfg, bool force_advance,
char management_url[160];
snprintf(management_url, sizeof(management_url), "http://%s/", cfg->toolsserver);
manage_qr_overlay_t overlay;
esp_err_t overlay_err = manage_qr_overlay_render(management_url, &overlay);
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) {
ESP_LOGW(TAG, "Could not render management QR overlay (%s), showing photo normally",
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_qr_overlay_free(&overlay);
manage_overlay_free(&overlay);
if (err != ESP_OK) {
return err;
}
ESP_LOGI(TAG, "Showing management QR for 30s");
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 QR overlay (%s)", esp_err_to_name(revert_err));
ESP_LOGW(TAG, "Failed to revert management overlay (%s)", esp_err_to_name(revert_err));
}
return ESP_OK;
+126 -26
View File
@@ -14,18 +14,47 @@ static const char *TAG = "manage_qr_overlay";
#define QR_MAX_VERSION 10
#define QR_BUFFER_LEN qrcodegen_BUFFER_LEN_FOR_VERSION(QR_MAX_VERSION)
/* Smaller than qr_onboarding.c's QR_MODULE_PX (8) -- this is a compact
* corner popup, not a full-screen setup step. */
/* Smaller than qr_onboarding.c's QR_MODULE_PX (8) -- these are compact
* corner popups, not a full-screen setup step. */
#define QR_MODULE_PX 4
#define PADDING 16
#define QR_TEXT_GAP 8
#define LINE_GAP 4
/* Distance from the panel's top/right edges to the overlay box. Combined
* with EPD_WIDTH and the forced-even box width below, this guarantees
* x0 is always even -- required so the overlay's columns land on frame
* byte boundaries (2px/byte) when spliced into the fetch stream. */
/* Distance from the panel's edges to each overlay box. Combined with
* EPD_WIDTH/EPD_HEIGHT and each region's forced-even width below, this
* guarantees x0 is always even -- required so a region's columns land on
* frame byte boundaries (2px/byte) when spliced into the fetch stream. */
#define PANEL_MARGIN 20
typedef enum {
CORNER_TOP_LEFT,
CORNER_TOP_RIGHT,
CORNER_BOTTOM_LEFT,
CORNER_BOTTOM_RIGHT,
} overlay_corner_t;
static void position_region(manage_overlay_region_t *region, overlay_corner_t corner)
{
switch (corner) {
case CORNER_TOP_LEFT:
region->x0 = PANEL_MARGIN;
region->y0 = PANEL_MARGIN;
break;
case CORNER_TOP_RIGHT:
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
region->y0 = PANEL_MARGIN;
break;
case CORNER_BOTTOM_LEFT:
region->x0 = PANEL_MARGIN;
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
break;
case CORNER_BOTTOM_RIGHT:
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
break;
}
}
static void draw_qr(uint8_t *buf, int stride, int width, int height, const uint8_t *qrcode, int origin_x,
int origin_y)
{
@@ -43,13 +72,18 @@ static void draw_qr(uint8_t *buf, int stride, int width, int height, const uint8
}
}
esp_err_t manage_qr_overlay_render(const char *management_url, manage_qr_overlay_t *out)
/* White-padded box with a QR code encoding payload, plus zero, one, or
* two centered caption lines beneath it (either may be NULL). Used for
* both the top-right "scan to manage" box (two lines) and the
* bottom-left share-link box (no lines). */
static esp_err_t render_qr_region(const char *payload, const char *line1, const char *line2, overlay_corner_t corner,
manage_overlay_region_t *out)
{
uint8_t temp_buffer[QR_BUFFER_LEN];
uint8_t qrcode[QR_BUFFER_LEN];
bool ok = qrcodegen_encodeText(management_url, temp_buffer, qrcode, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN,
bool ok = qrcodegen_encodeText(payload, temp_buffer, qrcode, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN,
QR_MAX_VERSION, qrcodegen_Mask_AUTO, true);
ESP_RETURN_ON_FALSE(ok, ESP_FAIL, TAG, "QR encoding failed for '%s' (too long for max version)", management_url);
ESP_RETURN_ON_FALSE(ok, ESP_FAIL, TAG, "QR encoding failed for '%s' (too long for max version)", payload);
int qr_size = qrcodegen_getSize(qrcode);
int qr_px = qr_size * QR_MODULE_PX;
@@ -57,15 +91,18 @@ esp_err_t manage_qr_overlay_render(const char *management_url, manage_qr_overlay
/* Font24 (32x41px uppercase glyphs) is the only font vendored into
* this project -- see components/epaper_fonts. "SCAN TO MANAGE" on
* one line would be 448px wide, too wide for a compact corner box,
* so it's wrapped across two lines here instead. */
static const char *line1 = "SCAN TO";
static const char *line2 = "MANAGE";
int line1_w = (int)strlen(line1) * Font24.Width;
int line2_w = (int)strlen(line2) * Font24.Width;
int text_w = line1_w > line2_w ? line1_w : line2_w;
* so it's passed in pre-wrapped across two lines instead. */
int text_w = 0;
int text_h = 0;
if (line1 != NULL) {
int w1 = (int)strlen(line1) * Font24.Width;
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
text_w = w1 > w2 ? w1 : w2;
text_h = QR_TEXT_GAP + Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
}
int content_w = qr_px > text_w ? qr_px : text_w;
int content_h = qr_px + QR_TEXT_GAP + Font24.Height + LINE_GAP + Font24.Height;
int content_h = qr_px + text_h;
int w = content_w + PADDING * 2;
int h = content_h + PADDING * 2;
@@ -73,28 +110,91 @@ esp_err_t manage_qr_overlay_render(const char *management_url, manage_qr_overlay
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay buffer");
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
int center_x = w / 2;
int y = PADDING;
draw_qr(buf, stride, w, h, qrcode, center_x - qr_px / 2, y);
y += qr_px + QR_TEXT_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
y += Font24.Height + LINE_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
y += qr_px;
if (line1 != NULL) {
y += QR_TEXT_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
y += Font24.Height;
}
if (line2 != NULL) {
y += LINE_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
}
out->buf = buf;
out->w = w;
out->h = h;
out->x0 = EPD_WIDTH - PANEL_MARGIN - w;
out->y0 = PANEL_MARGIN;
position_region(out, corner);
return ESP_OK;
}
/* White-padded box with a single centered line of text -- used for the
* top-left location and bottom-right date-taken labels. */
static esp_err_t render_text_region(const char *text, overlay_corner_t corner, manage_overlay_region_t *out)
{
int text_w = (int)strlen(text) * Font24.Width;
int w = text_w + PADDING * 2;
int h = Font24.Height + PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, text, w / 2, PADDING);
out->buf = buf;
out->w = w;
out->h = h;
position_region(out, corner);
return ESP_OK;
}
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out)
{
out->count = 0;
esp_err_t err = render_qr_region(content->management_url, "SCAN TO", "MANAGE", CORNER_TOP_RIGHT,
&out->regions[out->count]);
if (err != ESP_OK) {
return err;
}
out->count++;
if (content->location != NULL && content->location[0] != '\0') {
if (render_text_region(content->location, CORNER_TOP_LEFT, &out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
if (content->taken_at != NULL && content->taken_at[0] != '\0') {
if (render_text_region(content->taken_at, CORNER_BOTTOM_RIGHT, &out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
if (content->share_url != NULL && content->share_url[0] != '\0') {
if (render_qr_region(content->share_url, NULL, NULL, CORNER_BOTTOM_LEFT, &out->regions[out->count]) ==
ESP_OK) {
out->count++;
}
}
return ESP_OK;
}
void manage_qr_overlay_free(manage_qr_overlay_t *overlay)
void manage_overlay_free(manage_overlay_set_t *overlay)
{
free(overlay->buf);
overlay->buf = NULL;
for (int i = 0; i < overlay->count; i++) {
free(overlay->regions[i].buf);
overlay->regions[i].buf = NULL;
}
overlay->count = 0;
}
+28 -9
View File
@@ -4,19 +4,38 @@
#include "esp_err.h"
#define MANAGE_OVERLAY_MAX_REGIONS 4
typedef struct {
uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; caller must free */
uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; owned by the region */
int x0, y0; /* top-left corner, panel pixel coordinates (x0 is always even) */
int w, h; /* pixel dimensions (w is always even) */
} manage_qr_overlay_t;
} manage_overlay_region_t;
typedef struct {
manage_overlay_region_t regions[MANAGE_OVERLAY_MAX_REGIONS];
int count;
} manage_overlay_set_t;
typedef struct {
const char *management_url; /* top-right QR + "SCAN TO"/"MANAGE" caption -- always shown */
const char *location; /* top-left text; NULL/empty skips this region */
const char *taken_at; /* bottom-right text; NULL/empty skips this region */
const char *share_url; /* bottom-left QR (no caption); NULL/empty skips this region */
} manage_overlay_content_t;
/**
* Renders a small "scan to manage" overlay -- a QR code encoding
* management_url plus a "SCAN TO" / "MANAGE" caption, on a white
* padded background -- into a freshly allocated buffer sized just for
* the overlay itself (not a full EPD_FRAME_BYTES frame), positioned for
* the panel's top-right corner. Caller must call manage_qr_overlay_free().
* Renders the manage-button overlay: always a "scan to manage" QR in the
* top-right corner, plus whichever of location/taken_at/share_url are
* non-NULL/non-empty in their own corners (top-left, bottom-right,
* bottom-left respectively). Each region is its own separately malloc'd
* small buffer (not a full EPD_FRAME_BYTES frame). A failure rendering
* the top-right region fails the whole call; a failure rendering one of
* the optional regions just skips that region and keeps going. Caller
* must call manage_overlay_free() on out regardless of the return value
* (out->count reflects however many regions were actually populated).
*/
esp_err_t manage_qr_overlay_render(const char *management_url, manage_qr_overlay_t *out);
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out);
void manage_qr_overlay_free(manage_qr_overlay_t *overlay);
/** Frees every populated region's buffer in overlay. */
void manage_overlay_free(manage_overlay_set_t *overlay);