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
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:
+16
-6
@@ -99,12 +99,22 @@ redisplays whatever was already showing instead of skipping ahead.
|
|||||||
## Scanning to manage the queue
|
## Scanning to manage the queue
|
||||||
|
|
||||||
Wire a momentary push button between GPIO1 and GND (same wiring style as
|
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
|
the other two buttons). A press wakes the device and overlays several
|
||||||
code -- "SCAN TO MANAGE" -- in the top-right corner of whatever photo is
|
corners of whatever photo is currently showing, leaving the middle of
|
||||||
currently showing, linking to the server's config page. The rest of the
|
the photo visible and unchanged:
|
||||||
photo stays visible and unchanged. After 30 seconds it automatically
|
|
||||||
reverts to the plain photo. See `FRAME_MANAGE_BUTTON_GPIO` above to
|
- **Top-right**: a QR code -- "SCAN TO MANAGE" -- linking to the
|
||||||
change the pin or disable the feature.
|
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,
|
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
|
one for the overlay and one to revert), so this costs meaningfully more
|
||||||
|
|||||||
+156
-26
@@ -146,6 +146,48 @@ static bool json_extract_uint(const char *json, const char *key, uint32_t *out)
|
|||||||
return true;
|
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
|
/* GETs the server's /frame/config -- doubles as both the reachability
|
||||||
* check (any completed HTTP response means the socket-level connection
|
* check (any completed HTTP response means the socket-level connection
|
||||||
* succeeded) and the source of the server-configurable refresh interval. */
|
* 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;
|
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 {
|
typedef struct {
|
||||||
esp_http_client_handle_t client;
|
esp_http_client_handle_t client;
|
||||||
size_t stream_pos; /* running absolute offset into the frame, for overlay splicing */
|
size_t stream_pos; /* running absolute offset into the frame, for overlay splicing */
|
||||||
const manage_qr_overlay_t *overlay; /* NULL = no overlay this fetch */
|
const manage_overlay_set_t *overlay; /* NULL = no overlay this fetch */
|
||||||
} http_read_ctx_t;
|
} http_read_ctx_t;
|
||||||
|
|
||||||
/* Splices overlay pixels over the real photo bytes in chunk wherever
|
/* Splices one overlay region's pixels over the real photo bytes in chunk
|
||||||
* chunk's absolute byte range [chunk_start, chunk_start+chunk_len) within
|
* wherever chunk's absolute byte range [chunk_start, chunk_start+chunk_len)
|
||||||
* the full frame intersects the overlay's rectangle. Rows/chunks outside
|
* within the full frame intersects that region's rectangle. Rows/chunks
|
||||||
* the overlay's footprint are left completely untouched. overlay->x0 is
|
* outside the region's footprint are left completely untouched.
|
||||||
* always even (see manage_qr_overlay.h), so byte_x0 below is exact. */
|
* region->x0 is always even (see manage_qr_overlay.h), so byte_x0 below
|
||||||
static void splice_overlay(uint8_t *chunk, size_t chunk_len, size_t chunk_start, const manage_qr_overlay_t *overlay)
|
* 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_x0 = region->x0 / 2;
|
||||||
int byte_w = overlay->w / 2;
|
int byte_w = region->w / 2;
|
||||||
size_t chunk_end = chunk_start + chunk_len;
|
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_start = (size_t)row * EPD_BYTES_PER_ROW + (size_t)byte_x0;
|
||||||
size_t row_end = row_start + (size_t)byte_w;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t overlay_row_offset = (size_t)(row - overlay->y0) * (size_t)byte_w + (lo - row_start);
|
size_t region_row_offset = (size_t)(row - region->y0) * (size_t)byte_w + (lo - row_start);
|
||||||
memcpy(chunk + (lo - chunk_start), overlay->buf + overlay_row_offset, hi - lo);
|
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 --
|
/* Pulls the next chunk straight out of the in-progress HTTP response --
|
||||||
* epd_write_frame() calls this to feed the panel without ever holding
|
* 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
|
* set) as chunks pass through, so the panel driver never needs to know
|
||||||
* an overlay exists at all. */
|
* an overlay exists at all. */
|
||||||
static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
|
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
|
* epd_display_stream() (see epd7in3e.c) refuses to trigger a physical
|
||||||
* refresh on a short/wrong-size stream, so a failure here always leaves
|
* refresh on a short/wrong-size stream, so a failure here always leaves
|
||||||
* the visible screen exactly as it was. */
|
* 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];
|
char url[160];
|
||||||
snprintf(url, sizeof(url), "http://%s/%s", cfg->toolsserver, force_advance ? "frame/advance" : "frame/image");
|
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
|
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
|
||||||
* show_management_qr -- a fetch with the "scan to manage" QR overlay
|
* show_management_qr -- a fetch with the manage overlay spliced in (the
|
||||||
* spliced in, held on screen for 30s (device stays awake, doesn't sleep
|
* top-right "scan to manage" QR always, plus location/date-taken/
|
||||||
* the panel or the chip), then reverted with a second plain fetch.
|
* share-QR corners wherever the server had that data -- see
|
||||||
* Returns non-ESP_OK only if the FIRST fetch failed; a revert failure
|
* fetch_photo_info()), held on screen for 30s (device stays awake,
|
||||||
* afterward is logged but doesn't count as an overall failure -- the QR
|
* doesn't sleep the panel or the chip), then reverted with a second
|
||||||
* itself displayed fine, which was the point of the button. */
|
* 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)
|
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, bool force_advance, bool show_management_qr)
|
||||||
{
|
{
|
||||||
if (!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];
|
char management_url[160];
|
||||||
snprintf(management_url, sizeof(management_url), "http://%s/", cfg->toolsserver);
|
snprintf(management_url, sizeof(management_url), "http://%s/", cfg->toolsserver);
|
||||||
|
|
||||||
manage_qr_overlay_t overlay;
|
char location[64];
|
||||||
esp_err_t overlay_err = manage_qr_overlay_render(management_url, &overlay);
|
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) {
|
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));
|
esp_err_to_name(overlay_err));
|
||||||
return fetch_and_display(cfg, force_advance, NULL);
|
return fetch_and_display(cfg, force_advance, NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t err = fetch_and_display(cfg, force_advance, &overlay);
|
esp_err_t err = fetch_and_display(cfg, force_advance, &overlay);
|
||||||
manage_qr_overlay_free(&overlay);
|
manage_overlay_free(&overlay);
|
||||||
if (err != ESP_OK) {
|
if (err != ESP_OK) {
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Showing management QR for 30s");
|
ESP_LOGI(TAG, "Showing management overlay for 30s");
|
||||||
vTaskDelay(pdMS_TO_TICKS(30000));
|
vTaskDelay(pdMS_TO_TICKS(30000));
|
||||||
|
|
||||||
/* force_advance is always false here -- reverting shouldn't skip
|
/* force_advance is always false here -- reverting shouldn't skip
|
||||||
* ahead a second time. */
|
* ahead a second time. */
|
||||||
esp_err_t revert_err = fetch_and_display(cfg, false, NULL);
|
esp_err_t revert_err = fetch_and_display(cfg, false, NULL);
|
||||||
if (revert_err != ESP_OK) {
|
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;
|
return ESP_OK;
|
||||||
|
|||||||
@@ -14,18 +14,47 @@ static const char *TAG = "manage_qr_overlay";
|
|||||||
|
|
||||||
#define QR_MAX_VERSION 10
|
#define QR_MAX_VERSION 10
|
||||||
#define QR_BUFFER_LEN qrcodegen_BUFFER_LEN_FOR_VERSION(QR_MAX_VERSION)
|
#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
|
/* Smaller than qr_onboarding.c's QR_MODULE_PX (8) -- these are compact
|
||||||
* corner popup, not a full-screen setup step. */
|
* corner popups, not a full-screen setup step. */
|
||||||
#define QR_MODULE_PX 4
|
#define QR_MODULE_PX 4
|
||||||
#define PADDING 16
|
#define PADDING 16
|
||||||
#define QR_TEXT_GAP 8
|
#define QR_TEXT_GAP 8
|
||||||
#define LINE_GAP 4
|
#define LINE_GAP 4
|
||||||
/* Distance from the panel's top/right edges to the overlay box. Combined
|
/* Distance from the panel's edges to each overlay box. Combined with
|
||||||
* with EPD_WIDTH and the forced-even box width below, this guarantees
|
* EPD_WIDTH/EPD_HEIGHT and each region's forced-even width below, this
|
||||||
* x0 is always even -- required so the overlay's columns land on frame
|
* guarantees x0 is always even -- required so a region's columns land on
|
||||||
* byte boundaries (2px/byte) when spliced into the fetch stream. */
|
* frame byte boundaries (2px/byte) when spliced into the fetch stream. */
|
||||||
#define PANEL_MARGIN 20
|
#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,
|
static void draw_qr(uint8_t *buf, int stride, int width, int height, const uint8_t *qrcode, int origin_x,
|
||||||
int origin_y)
|
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 temp_buffer[QR_BUFFER_LEN];
|
||||||
uint8_t qrcode[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);
|
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_size = qrcodegen_getSize(qrcode);
|
||||||
int qr_px = qr_size * QR_MODULE_PX;
|
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
|
/* Font24 (32x41px uppercase glyphs) is the only font vendored into
|
||||||
* this project -- see components/epaper_fonts. "SCAN TO MANAGE" on
|
* this project -- see components/epaper_fonts. "SCAN TO MANAGE" on
|
||||||
* one line would be 448px wide, too wide for a compact corner box,
|
* one line would be 448px wide, too wide for a compact corner box,
|
||||||
* so it's wrapped across two lines here instead. */
|
* so it's passed in pre-wrapped across two lines instead. */
|
||||||
static const char *line1 = "SCAN TO";
|
int text_w = 0;
|
||||||
static const char *line2 = "MANAGE";
|
int text_h = 0;
|
||||||
int line1_w = (int)strlen(line1) * Font24.Width;
|
if (line1 != NULL) {
|
||||||
int line2_w = (int)strlen(line2) * Font24.Width;
|
int w1 = (int)strlen(line1) * Font24.Width;
|
||||||
int text_w = line1_w > line2_w ? line1_w : line2_w;
|
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_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 w = content_w + PADDING * 2;
|
||||||
int h = content_h + 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;
|
int stride = w / 2;
|
||||||
uint8_t *buf = malloc((size_t)stride * h);
|
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);
|
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
|
||||||
|
|
||||||
int center_x = w / 2;
|
int center_x = w / 2;
|
||||||
int y = PADDING;
|
int y = PADDING;
|
||||||
draw_qr(buf, stride, w, h, qrcode, center_x - qr_px / 2, y);
|
draw_qr(buf, stride, w, h, qrcode, center_x - qr_px / 2, y);
|
||||||
y += qr_px + QR_TEXT_GAP;
|
y += qr_px;
|
||||||
|
if (line1 != NULL) {
|
||||||
|
y += QR_TEXT_GAP;
|
||||||
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
|
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
|
||||||
y += Font24.Height + LINE_GAP;
|
y += Font24.Height;
|
||||||
|
}
|
||||||
|
if (line2 != NULL) {
|
||||||
|
y += LINE_GAP;
|
||||||
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
|
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
|
||||||
|
}
|
||||||
|
|
||||||
out->buf = buf;
|
out->buf = buf;
|
||||||
out->w = w;
|
out->w = w;
|
||||||
out->h = h;
|
out->h = h;
|
||||||
out->x0 = EPD_WIDTH - PANEL_MARGIN - w;
|
position_region(out, corner);
|
||||||
out->y0 = PANEL_MARGIN;
|
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;
|
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);
|
for (int i = 0; i < overlay->count; i++) {
|
||||||
overlay->buf = NULL;
|
free(overlay->regions[i].buf);
|
||||||
|
overlay->regions[i].buf = NULL;
|
||||||
|
}
|
||||||
|
overlay->count = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,38 @@
|
|||||||
|
|
||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
#define MANAGE_OVERLAY_MAX_REGIONS 4
|
||||||
|
|
||||||
typedef struct {
|
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 x0, y0; /* top-left corner, panel pixel coordinates (x0 is always even) */
|
||||||
int w, h; /* pixel dimensions (w 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
|
* Renders the manage-button overlay: always a "scan to manage" QR in the
|
||||||
* management_url plus a "SCAN TO" / "MANAGE" caption, on a white
|
* top-right corner, plus whichever of location/taken_at/share_url are
|
||||||
* padded background -- into a freshly allocated buffer sized just for
|
* non-NULL/non-empty in their own corners (top-left, bottom-right,
|
||||||
* the overlay itself (not a full EPD_FRAME_BYTES frame), positioned for
|
* bottom-left respectively). Each region is its own separately malloc'd
|
||||||
* the panel's top-right corner. Caller must call manage_qr_overlay_free().
|
* 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);
|
||||||
|
|||||||
+29
-5
@@ -48,11 +48,32 @@ algorithm itself -- it just streams the response straight to the panel.
|
|||||||
button (see `firmware/README.md`).
|
button (see `firmware/README.md`).
|
||||||
- `GET /frame/config` -- `{"refresh_interval_s": ...}`, polled by the frame
|
- `GET /frame/config` -- `{"refresh_interval_s": ...}`, polled by the frame
|
||||||
each wake alongside its reachability check
|
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
|
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...]}`, each
|
||||||
entry an asset id + thumbnail URL; used by the config UI
|
entry an asset id + thumbnail URL; used by the config UI
|
||||||
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
|
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
|
||||||
`{"queue": [asset_id, ...]}`, must be exactly a permutation of the
|
`{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having
|
||||||
current queue
|
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
|
- `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so
|
||||||
the browser never needs the Immich API key directly
|
the browser never needs the Immich API key directly
|
||||||
- `GET /health` -- liveness check
|
- `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 sequential or shuffle order per the Order setting. Dragging photos
|
||||||
in the web UI (or using "Show next") only rearranges what's already in
|
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.
|
that lookahead; it doesn't add or remove photos from the album.
|
||||||
- `/frame/image` and `/frame/advance` aren't authenticated yet. That's
|
- `/frame/image`, `/frame/advance`, `/frame/photo-info`, and
|
||||||
fine on a trusted home LAN for now, but worth revisiting once the ESP32
|
`/frame/share/{asset_id}` aren't authenticated yet. That's fine on a
|
||||||
side is wired up to send a shared device token.
|
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
|
- The 6-color palette RGB values in `app/image_pipeline.py` are
|
||||||
approximations, not measured values (Waveshare doesn't publish exact
|
approximations, not measured values (Waveshare doesn't publish exact
|
||||||
color primaries for this panel) -- tune them once you can compare a
|
color primaries for this panel) -- tune them once you can compare a
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
@@ -69,3 +71,36 @@ class ImmichClient:
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.content, resp.headers.get("content-type", "image/jpeg")
|
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}"
|
||||||
|
|||||||
+117
-4
@@ -5,10 +5,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException, Form, Request
|
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 fastapi.templating import Jinja2Templates
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from pydantic import BaseModel
|
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")
|
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")
|
@app.get("/api/queue")
|
||||||
def api_queue():
|
def api_queue():
|
||||||
cfg = config.load()
|
cfg = config.load()
|
||||||
@@ -191,10 +278,36 @@ class QueueReorderRequest(BaseModel):
|
|||||||
|
|
||||||
@app.post("/api/queue/reorder")
|
@app.post("/api/queue/reorder")
|
||||||
def api_queue_reorder(body: QueueReorderRequest):
|
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()
|
cfg = config.load()
|
||||||
if set(body.queue) != set(cfg.queue) or len(body.queue) != len(cfg.queue):
|
current_set = set(cfg.queue)
|
||||||
raise HTTPException(400, "Reordered queue must contain exactly the current queue's photos")
|
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
|
||||||
cfg.queue = body.queue
|
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)
|
config.save(cfg)
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|||||||
@@ -224,12 +224,21 @@
|
|||||||
persistOrder(items);
|
persistOrder(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNext(index) {
|
async function showNext(index) {
|
||||||
const items = upcomingItems.slice();
|
const assetId = upcomingItems[index].id;
|
||||||
const [moved] = items.splice(index, 1);
|
try {
|
||||||
items.unshift(moved);
|
const resp = await fetch('/api/queue/promote', {
|
||||||
renderUpcoming(items);
|
method: 'POST',
|
||||||
persistOrder(items);
|
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) {
|
async function persistOrder(items) {
|
||||||
|
|||||||
Reference in New Issue
Block a user