From a358045cea5b50423d24c69f014b63dd3b8289d3 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Sun, 19 Jul 2026 01:28:25 -0400 Subject: [PATCH] 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. --- firmware/README.md | 22 +++- firmware/main/frame_client.c | 184 +++++++++++++++++++++++++----- firmware/main/manage_qr_overlay.c | 152 +++++++++++++++++++----- firmware/main/manage_qr_overlay.h | 37 ++++-- server/README.md | 34 +++++- server/app/immich_client.py | 35 ++++++ server/app/main.py | 121 +++++++++++++++++++- server/app/templates/index.html | 21 +++- 8 files changed, 523 insertions(+), 83 deletions(-) diff --git a/firmware/README.md b/firmware/README.md index fe56d77..b19c51a 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -99,12 +99,22 @@ redisplays whatever was already showing instead of skipping ahead. ## Scanning to manage the queue Wire a momentary push button between GPIO1 and GND (same wiring style as -the other two buttons). A press wakes the device and overlays a small QR -code -- "SCAN TO MANAGE" -- in the top-right corner of whatever photo is -currently showing, linking to the server's config page. The rest of the -photo stays visible and unchanged. After 30 seconds it automatically -reverts to the plain photo. See `FRAME_MANAGE_BUTTON_GPIO` above to -change the pin or disable the feature. +the other two buttons). A press wakes the device and overlays several +corners of whatever photo is currently showing, leaving the middle of +the photo visible and unchanged: + +- **Top-right**: a QR code -- "SCAN TO MANAGE" -- linking to the + server's config page. +- **Top-left**: the photo's location, if Immich reverse-geocoded it from + GPS EXIF (skipped entirely if not). +- **Bottom-right**: the date the photo was taken, if known. +- **Bottom-left**: a QR code linking to a public, view-only Immich share + link for that exact photo. The link is only created once someone + actually scans it, and expires 30 minutes after that. + +After 30 seconds it automatically reverts to the plain photo. See +`FRAME_MANAGE_BUTTON_GPIO` above to change the pin or disable the +feature. The device stays awake for the full 30 seconds (two physical refreshes, one for the overlay and one to revert), so this costs meaningfully more diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index cdade1e..5f84c3e 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -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; diff --git a/firmware/main/manage_qr_overlay.c b/firmware/main/manage_qr_overlay.c index 88b9e72..8689eef 100644 --- a/firmware/main/manage_qr_overlay.c +++ b/firmware/main/manage_qr_overlay.c @@ -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; } diff --git a/firmware/main/manage_qr_overlay.h b/firmware/main/manage_qr_overlay.h index 9ca10c4..fbaff80 100644 --- a/firmware/main/manage_qr_overlay.h +++ b/firmware/main/manage_qr_overlay.h @@ -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); diff --git a/server/README.md b/server/README.md index b44afcf..bb6bcf7 100644 --- a/server/README.md +++ b/server/README.md @@ -48,11 +48,32 @@ algorithm itself -- it just streams the response straight to the panel. button (see `firmware/README.md`). - `GET /frame/config` -- `{"refresh_interval_s": ...}`, polled by the frame each wake alongside its reachability check +- `GET /frame/photo-info` -- `{"asset_id": ..., "location": ... | null, + "taken_at": ... | null}` for the current photo (same idempotent + current-photo semantics as `/frame/image`). `location` is `city, state` + (or `city, country`, or just `city`) if Immich reverse-geocoded the + photo's GPS EXIF, else `null`; `taken_at` is `MM/DD/YY` from the + photo's EXIF capture date, else `null`. Used by the device's manage + button to build its overlay text +- `GET /frame/share/{asset_id}` -- creates a 30-minute public, view-only + Immich share link for `asset_id` and redirects (302) to it. Only works + for the photo currently showing or in the upcoming queue on this frame + -- not any arbitrary Immich asset. The link is created on first hit + (i.e. when someone actually scans the manage overlay's share QR), not + when the button's pressed, so the 30-minute window starts when it's + actually used - `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...]}`, each entry an asset id + thumbnail URL; used by the config UI - `POST /api/queue/reorder` -- reorders the upcoming queue; body is - `{"queue": [asset_id, ...]}`, must be exactly a permutation of the - current queue + `{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having + changed server-side since the client's last fetch (e.g. a top-up/trim) + -- unrecognized IDs in the body are dropped, and any currently-queued + photo missing from the body is appended rather than lost, instead of + rejecting the whole request +- `POST /api/queue/promote` -- moves one photo to the front of the queue; + body is `{"asset_id": "..."}`. Used by "Show next" in the web UI -- + unlike `/reorder`, doesn't depend on the client knowing the queue's + full current order, so it can't fail from staleness - `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so the browser never needs the Immich API key directly - `GET /health` -- liveness check @@ -72,9 +93,12 @@ algorithm itself -- it just streams the response straight to the panel. in sequential or shuffle order per the Order setting. Dragging photos in the web UI (or using "Show next") only rearranges what's already in that lookahead; it doesn't add or remove photos from the album. -- `/frame/image` and `/frame/advance` aren't authenticated yet. That's - fine on a trusted home LAN for now, but worth revisiting once the ESP32 - side is wired up to send a shared device token. +- `/frame/image`, `/frame/advance`, `/frame/photo-info`, and + `/frame/share/{asset_id}` aren't authenticated yet. That's fine on a + trusted home LAN for now, but worth revisiting once the ESP32 side is + wired up to send a shared device token. `/frame/share` at least is + scoped to only ever create a link for a photo this frame is actually + showing or has queued, not any Immich asset ID someone might guess. - The 6-color palette RGB values in `app/image_pipeline.py` are approximations, not measured values (Waveshare doesn't publish exact color primaries for this panel) -- tune them once you can compare a diff --git a/server/app/immich_client.py b/server/app/immich_client.py index c51215e..dc35e24 100644 --- a/server/app/immich_client.py +++ b/server/app/immich_client.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import datetime, timedelta, timezone + import httpx @@ -69,3 +71,36 @@ class ImmichClient: ) resp.raise_for_status() return resp.content, resp.headers.get("content-type", "image/jpeg") + + def get_asset(self, asset_id: str) -> dict: + """Full asset metadata, including embedded exifInfo (location, + capture date) -- used for the manage-button overlay's location/ + date-taken text.""" + resp = httpx.get(f"{self.base_url}/api/assets/{asset_id}", headers=self._headers, timeout=10) + resp.raise_for_status() + return resp.json() + + def create_share_link(self, asset_id: str, expires_in_s: int) -> str: + """Creates a public, view-only Immich share link for a single + asset, expiring expires_in_s seconds from now, and returns its + public URL. Used by the manage-button overlay's share QR -- + created lazily (only when someone actually scans it), not when + the button's pressed, so the expiry clock starts when it's + actually used.""" + expires_at = (datetime.now(timezone.utc) + timedelta(seconds=expires_in_s)).isoformat() + resp = httpx.post( + f"{self.base_url}/api/shared-links", + headers=self._headers, + json={ + "type": "INDIVIDUAL", + "assetIds": [asset_id], + "expiresAt": expires_at, + "allowUpload": False, + "allowDownload": True, + "showMetadata": True, + }, + timeout=10, + ) + resp.raise_for_status() + key = resp.json()["key"] + return f"{self.base_url}/share/{key}" diff --git a/server/app/main.py b/server/app/main.py index fc098a5..05c0bf0 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -5,10 +5,11 @@ from __future__ import annotations import io import logging +from datetime import datetime import httpx from fastapi import FastAPI, HTTPException, Form, Request -from fastapi.responses import HTMLResponse, Response +from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates from PIL import Image from pydantic import BaseModel @@ -162,6 +163,92 @@ def frame_advance(): return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream") +LOCATION_MAX_LEN = 14 + + +def _format_location(exif: dict) -> str | None: + city = exif.get("city") + if not city: + return None + state = exif.get("state") + country = exif.get("country") + if state: + location = f"{city}, {state}" + elif country: + location = f"{city}, {country}" + else: + location = city + if len(location) > LOCATION_MAX_LEN: + location = location[: LOCATION_MAX_LEN - 3] + "..." + return location + + +def _format_taken_at(exif: dict) -> str | None: + raw = exif.get("dateTimeOriginal") + if not raw: + return None + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y") + except ValueError: + return None + + +@app.get("/frame/photo-info") +def frame_photo_info(): + """Location/date-taken text for the manage-button overlay, plus the + asset id used to build the share-QR's target URL. Read-only, same + idempotent current-photo semantics as /frame/image -- doesn't advance + anything.""" + cfg = config.load() + _require_configured(cfg) + + client = ImmichClient(cfg.immich_url, cfg.immich_api_key) + assets = _list_assets(client, cfg) + + if photo_queue.get_current(cfg, assets): + config.save(cfg) + + if not cfg.current_asset_id: + raise HTTPException(404, "No current photo") + + try: + asset = client.get_asset(cfg.current_asset_id) + except httpx.HTTPError as e: + raise HTTPException(502, f"Could not reach Immich: {e}") from e + + exif = asset.get("exifInfo") or {} + return { + "asset_id": cfg.current_asset_id, + "location": _format_location(exif), + "taken_at": _format_taken_at(exif), + } + + +@app.get("/frame/share/{asset_id}") +def frame_share(asset_id: str): + """Creates a 30-minute public Immich share link for asset_id and + redirects to it -- what the manage overlay's bottom-left QR code + points to. The link is created lazily, when this actually gets hit + (i.e. when someone scans it), not when the manage button was + pressed, so the 30-minute window starts when it's actually used. + Scoped to the photo currently showing or queued -- not any arbitrary + Immich asset id -- since this is otherwise an unauthenticated + endpoint (see server/README.md).""" + cfg = config.load() + _require_configured(cfg) + + if asset_id != cfg.current_asset_id and asset_id not in cfg.queue: + raise HTTPException(404, "That photo isn't currently showing or queued on this frame") + + client = ImmichClient(cfg.immich_url, cfg.immich_api_key) + try: + share_url = client.create_share_link(asset_id, expires_in_s=1800) + except httpx.HTTPError as e: + raise HTTPException(502, f"Could not create share link: {e}") from e + + return RedirectResponse(share_url) + + @app.get("/api/queue") def api_queue(): cfg = config.load() @@ -191,10 +278,36 @@ class QueueReorderRequest(BaseModel): @app.post("/api/queue/reorder") def api_queue_reorder(body: QueueReorderRequest): + """Applies the client's requested order, tolerating drift between the + browser's last-fetched snapshot and the server's current queue (e.g. + a top-up/trim landed in between) instead of hard-rejecting: any ID + the client sent that's no longer actually queued is dropped, and any + ID the server has that the client didn't know about is appended + rather than lost.""" cfg = config.load() - if set(body.queue) != set(cfg.queue) or len(body.queue) != len(cfg.queue): - raise HTTPException(400, "Reordered queue must contain exactly the current queue's photos") - cfg.queue = body.queue + current_set = set(cfg.queue) + reordered = [asset_id for asset_id in body.queue if asset_id in current_set] + reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)] + cfg.queue = reordered + config.save(cfg) + return {"status": "saved"} + + +class QueuePromoteRequest(BaseModel): + asset_id: str + + +@app.post("/api/queue/promote") +def api_queue_promote(body: QueuePromoteRequest): + """Moves a single photo to the front of the queue -- "Show next" in + the web UI. Unlike /api/queue/reorder, this doesn't depend on the + client supplying a full, exactly-current snapshot of the queue at + all, so it can't fail due to the queue having shifted server-side + since the browser's last fetch.""" + cfg = config.load() + if body.asset_id not in cfg.queue: + raise HTTPException(400, "That photo is no longer in the upcoming queue") + cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id] config.save(cfg) return {"status": "saved"} diff --git a/server/app/templates/index.html b/server/app/templates/index.html index 386fb82..e31d6af 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -224,12 +224,21 @@ persistOrder(items); } - function showNext(index) { - const items = upcomingItems.slice(); - const [moved] = items.splice(index, 1); - items.unshift(moved); - renderUpcoming(items); - persistOrder(items); + async function showNext(index) { + const assetId = upcomingItems[index].id; + try { + const resp = await fetch('/api/queue/promote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ asset_id: assetId }), + }); + if (!resp.ok) { + throw new Error(await resp.text()); + } + } catch (e) { + showStatus(false, e.message); + } + loadQueue(); // always refetch the authoritative order rather than guessing locally } async function persistOrder(items) {