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
+16 -6
View File
@@ -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
+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);