diff --git a/docs/hardware.md b/docs/hardware.md index f6b01c7..a16fe3d 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -28,7 +28,7 @@ Configuration** if your wiring differs. | VCC | 3.3V | -- | | GND | GND | -- | -Optionally, two buttons, both wired the same way -- momentary push button +Optionally, three buttons, all wired the same way -- momentary push button between the GPIO and GND, no external resistor needed (the firmware enables each pin's internal pull-up, so it idles high and reads low when pressed): @@ -39,6 +39,10 @@ pressed): - **Next photo (GPIO2)**: a normal press skips immediately to the next photo; see [`firmware/README.md`](../firmware/README.md#skipping-to-the-next-photo). +- **Manage (GPIO1)**: a normal press overlays a "scan to manage" QR code + on the current photo for 30 seconds, linking to the server's config + page; see + [`firmware/README.md`](../firmware/README.md#scanning-to-manage-the-queue). Both pins were picked because they're within GPIO 0-7 -- the only pins the ESP32-C6 can wake from deep sleep on -- aren't strapping pins, and diff --git a/firmware/README.md b/firmware/README.md index 8b29726..fe56d77 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -47,6 +47,7 @@ Under **ESPresso Frame Configuration**: | `FRAME_RESET_BUTTON_GPIO` | 3 | Factory-reset button GPIO (-1 to disable). Must be 0-7 (ESP32-C6's deep-sleep-wakeup-capable pins) | | `FRAME_RESET_BUTTON_HOLD_MS` | 10000 | How long the button must be held to trigger a reset | | `FRAME_NEXT_BUTTON_GPIO` | 2 | Next-photo button GPIO (-1 to disable). Must be 0-7 | +| `FRAME_MANAGE_BUTTON_GPIO` | 1 | "Scan to manage" button GPIO (-1 to disable). Must be 0-7 | Under **E-Paper Display (epd7in3e) Configuration**: SPI/GPIO pin assignments and SPI clock speed -- see @@ -95,6 +96,21 @@ server decides when to advance based on its own clock (see [`server/README.md`](../server/README.md)), so an unplanned reboot just 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 device stays awake for the full 30 seconds (two physical refreshes, +one for the overlay and one to revert), so this costs meaningfully more +power than a normal wake -- expected for a deliberate, occasional action, +same tradeoff as the other two buttons. + ## Resetting to provisioning mode Wire a momentary push button between GPIO3 and GND (internal pull-up, diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 9c6b0e2..01caad0 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,3 +1,3 @@ -idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c reset_button.c next_button.c +idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c reset_button.c next_button.c manage_button.c manage_qr_overlay.c PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio EMBED_FILES root.html) diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild index 7cd3854..025daac 100644 --- a/firmware/main/Kconfig.projbuild +++ b/firmware/main/Kconfig.projbuild @@ -121,4 +121,19 @@ menu "ESPresso Frame Configuration" FRAME_RESET_BUTTON_GPIO above; defaults to a different pin than the reset button. Set to -1 to disable the feature. + config FRAME_MANAGE_BUTTON_GPIO + int "Manage button GPIO (-1 to disable)" + default 1 + range -1 7 + help + Button wired between this GPIO and GND (active-low, internal + pull-up enabled in firmware -- no external resistor needed). + Pressing it wakes the device (if asleep), displays the + current photo with a small "scan to manage" QR code overlaid + in the top-right corner (linking to the tools server's config + page) for 30 seconds, then reverts to the plain photo. Must + be GPIO 0-7 for the same deep-sleep-wakeup reason as + FRAME_RESET_BUTTON_GPIO above; defaults to a different pin + than the other two buttons. Set to -1 to disable the feature. + endmenu diff --git a/firmware/main/epd_draw.c b/firmware/main/epd_draw.c index 4b9dee3..cba4f35 100644 --- a/firmware/main/epd_draw.c +++ b/firmware/main/epd_draw.c @@ -4,20 +4,25 @@ #include "epd_draw.h" -void epd_draw_pixel(uint8_t *frame, int x, int y, epd_color_t color) +void epd_draw_pixel_ex(uint8_t *buf, int stride, int width, int height, int x, int y, epd_color_t color) { - if (x < 0 || x >= EPD_WIDTH || y < 0 || y >= EPD_HEIGHT) { + if (x < 0 || x >= width || y < 0 || y >= height) { return; } - size_t byte_index = (size_t)y * EPD_BYTES_PER_ROW + (size_t)x / 2; + size_t byte_index = (size_t)y * stride + (size_t)x / 2; uint8_t nibble = (uint8_t)color & 0x0F; if (x % 2 == 0) { - frame[byte_index] = (frame[byte_index] & 0x0F) | (nibble << 4); + buf[byte_index] = (buf[byte_index] & 0x0F) | (nibble << 4); } else { - frame[byte_index] = (frame[byte_index] & 0xF0) | nibble; + buf[byte_index] = (buf[byte_index] & 0xF0) | nibble; } } +void epd_draw_pixel(uint8_t *frame, int x, int y, epd_color_t color) +{ + epd_draw_pixel_ex(frame, EPD_BYTES_PER_ROW, EPD_WIDTH, EPD_HEIGHT, x, y, color); +} + /* fonts.h's sFONT tables cover the printable ASCII range starting at ' ' * (0x20), one glyph per character, MSB-first within each row (see * components/epaper_fonts). */ @@ -34,7 +39,8 @@ static bool font_pixel_set(const sFONT *font, char c, int row, int col) return (glyph[byte_idx] >> bit_in_byte) & 0x1; } -void epd_draw_text(uint8_t *frame, const sFONT *font, const char *text, int origin_x, int origin_y) +void epd_draw_text_ex(uint8_t *buf, int stride, int width, int height, const sFONT *font, const char *text, + int origin_x, int origin_y) { int cursor_x = origin_x; for (const char *p = text; *p != '\0'; p++) { @@ -42,7 +48,7 @@ void epd_draw_text(uint8_t *frame, const sFONT *font, const char *text, int orig for (int row = 0; row < font->Height; row++) { for (int col = 0; col < font->Width; col++) { if (font_pixel_set(font, c, row, col)) { - epd_draw_pixel(frame, cursor_x + col, origin_y + row, EPD_COLOR_BLACK); + epd_draw_pixel_ex(buf, stride, width, height, cursor_x + col, origin_y + row, EPD_COLOR_BLACK); } } } @@ -50,10 +56,21 @@ void epd_draw_text(uint8_t *frame, const sFONT *font, const char *text, int orig } } +void epd_draw_text(uint8_t *frame, const sFONT *font, const char *text, int origin_x, int origin_y) +{ + epd_draw_text_ex(frame, EPD_BYTES_PER_ROW, EPD_WIDTH, EPD_HEIGHT, font, text, origin_x, origin_y); +} + +void epd_draw_text_centered_ex(uint8_t *buf, int stride, int width, int height, const sFONT *font, const char *text, + int center_x, int y) +{ + int text_width = (int)strlen(text) * font->Width; + epd_draw_text_ex(buf, stride, width, height, font, text, center_x - text_width / 2, y); +} + void epd_draw_text_centered(uint8_t *frame, const sFONT *font, const char *text, int center_x, int y) { - int width = (int)strlen(text) * font->Width; - epd_draw_text(frame, font, text, center_x - width / 2, y); + epd_draw_text_centered_ex(frame, EPD_BYTES_PER_ROW, EPD_WIDTH, EPD_HEIGHT, font, text, center_x, y); } void epd_draw_line(uint8_t *frame, int x0, int y0, int x1, int y1, int thickness) diff --git a/firmware/main/epd_draw.h b/firmware/main/epd_draw.h index 4ba09a8..d20bb3e 100644 --- a/firmware/main/epd_draw.h +++ b/firmware/main/epd_draw.h @@ -18,6 +18,22 @@ void epd_draw_text(uint8_t *frame, const sFONT *font, const char *text, int orig /** Same as epd_draw_text(), but horizontally centered on center_x. */ void epd_draw_text_centered(uint8_t *frame, const sFONT *font, const char *text, int center_x, int y); +/** + * Same as epd_draw_pixel()/epd_draw_text()/epd_draw_text_centered(), but + * against an arbitrarily-sized buffer instead of a full EPD_FRAME_BYTES + * one -- stride is bytes per row (packed 2px/byte, so width/2), width and + * height are that buffer's pixel dimensions, used for bounds-checking + * instead of the panel's fixed EPD_WIDTH/EPD_HEIGHT. The non-_ex + * functions above are thin wrappers around these, passing + * EPD_BYTES_PER_ROW/EPD_WIDTH/EPD_HEIGHT -- existing callers are + * unaffected. + */ +void epd_draw_pixel_ex(uint8_t *buf, int stride, int width, int height, int x, int y, epd_color_t color); +void epd_draw_text_ex(uint8_t *buf, int stride, int width, int height, const sFONT *font, const char *text, + int origin_x, int origin_y); +void epd_draw_text_centered_ex(uint8_t *buf, int stride, int width, int height, const sFONT *font, const char *text, + int center_x, int y); + /** Draws a line of the given thickness (in pixels) between two points. */ void epd_draw_line(uint8_t *frame, int x0, int y0, int x1, int y1, int thickness); diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index af35159..cdade1e 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -13,6 +13,7 @@ #include "epd7in3e.h" #include "status_screen.h" +#include "manage_qr_overlay.h" #include "frame_client.h" @@ -200,25 +201,65 @@ static frame_server_config_t fetch_frame_config(const char *toolsserver) 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 */ } 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) +{ + int byte_x0 = overlay->x0 / 2; + int byte_w = overlay->w / 2; + size_t chunk_end = chunk_start + chunk_len; + + for (int row = overlay->y0; row < overlay->y0 + overlay->h; row++) { + size_t row_start = (size_t)row * EPD_BYTES_PER_ROW + (size_t)byte_x0; + size_t row_end = row_start + (size_t)byte_w; + + size_t lo = row_start > chunk_start ? row_start : chunk_start; + size_t hi = row_end < chunk_end ? row_end : chunk_end; + if (lo >= hi) { + continue; + } + + size_t 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); + } +} + /* Pulls the next chunk straight out of the in-progress HTTP response -- - * epd_display_stream() calls this to feed the panel without ever holding - * the full ~192KB frame in RAM. */ + * 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 + * set) as chunks pass through, so the panel driver never needs to know + * an overlay exists at all. */ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_) { http_read_ctx_t *ctx = (http_read_ctx_t *)ctx_; int n = esp_http_client_read(ctx->client, (char *)chunk, (int)chunk_size); - return n > 0 ? (size_t)n : 0; + if (n <= 0) { + return 0; + } + + if (ctx->overlay != NULL) { + splice_overlay(chunk, (size_t)n, ctx->stream_pos, ctx->overlay); + } + ctx->stream_pos += (size_t)n; + + return (size_t)n; } /* GETs /frame/image (or, if force_advance, POSTs /frame/advance to skip - * ahead immediately) and streams the response directly into the panel. - * Returning non-ESP_OK means the panel was never actually refreshed -- + * ahead immediately) and streams the response directly into the panel, + * splicing in overlay's pixels (if non-NULL) as it streams. Returning + * non-ESP_OK means the panel was never actually refreshed -- * epd_display_stream() (see epd7in3e.c) refuses to trigger a physical * refresh on a short/wrong-size stream, so a failure here always leaves * the visible screen exactly as it was. */ -static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance) +static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance, const manage_qr_overlay_t *overlay) { char url[160]; snprintf(url, sizeof(url), "http://%s/%s", cfg->toolsserver, force_advance ? "frame/advance" : "frame/image"); @@ -247,7 +288,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance } ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url); - http_read_ctx_t ctx = { .client = client }; + http_read_ctx_t ctx = { .client = client, .overlay = overlay }; uint32_t crc = 0; err = epd_write_frame(http_read_fn, &ctx, &crc); @@ -275,7 +316,50 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance return err; } -void frame_client_run(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. */ +static esp_err_t run_fetch_cycle(const frame_config_t *cfg, bool force_advance, bool show_management_qr) +{ + if (!show_management_qr) { + return fetch_and_display(cfg, force_advance, NULL); + } + + char management_url[160]; + snprintf(management_url, sizeof(management_url), "http://%s/", cfg->toolsserver); + + manage_qr_overlay_t overlay; + esp_err_t overlay_err = manage_qr_overlay_render(management_url, &overlay); + if (overlay_err != ESP_OK) { + ESP_LOGW(TAG, "Could not render management QR 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); + if (err != ESP_OK) { + return err; + } + + ESP_LOGI(TAG, "Showing management QR 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)); + } + + return ESP_OK; +} + +void frame_client_run(const frame_config_t *cfg, bool force_advance, bool show_management_qr) { esp_err_t epd_err = epd_init(); bool have_display = (epd_err == ESP_OK); @@ -309,7 +393,7 @@ void frame_client_run(const frame_config_t *cfg, bool force_advance) * worth it to stop false-failing on the common case. */ bool image_ok = true; if (have_display) { - esp_err_t fetch_err = fetch_and_display(cfg, force_advance); + esp_err_t fetch_err = run_fetch_cycle(cfg, force_advance, show_management_qr); image_ok = (fetch_err == ESP_OK); if (!image_ok) { /* epd_display_stream() never triggers a physical refresh on a diff --git a/firmware/main/frame_client.h b/firmware/main/frame_client.h index 516e774..f9086dc 100644 --- a/firmware/main/frame_client.h +++ b/firmware/main/frame_client.h @@ -23,5 +23,11 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg); * -- the server decides on its own whether to advance in the normal case, * based on its configured refresh interval, so a plain wake/reboot never * skips a photo just by asking. + * + * If show_management_qr is true (the manage button was held), the + * displayed photo gets a small "scan to manage" QR overlay in the + * top-right corner linking to the server's config page, held for 30 + * seconds (the device stays awake), then reverted back to the plain + * photo before proceeding to the normal sleep-interval logic. */ -void frame_client_run(const frame_config_t *cfg, bool force_advance); +void frame_client_run(const frame_config_t *cfg, bool force_advance, bool show_management_qr); diff --git a/firmware/main/main.c b/firmware/main/main.c index dcaa636..9a49a47 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -9,6 +9,7 @@ #include "frame_client.h" #include "reset_button.h" #include "next_button.h" +#include "manage_button.h" static const char *TAG = "main"; @@ -30,23 +31,25 @@ void app_main(void) } ESP_ERROR_CHECK(nvs_err); - /* Arms both buttons as deep-sleep wakeup sources (so holding one wakes - * the device promptly, not just during its brief awake windows), then - * checks them -- covers both "held while asleep, just woke us up" and - * "held while powering on" the same way, since both look identical - * from here: the pin is just low right now. */ + /* Arms all three buttons as deep-sleep wakeup sources (so holding one + * wakes the device promptly, not just during its brief awake + * windows), then checks them -- covers both "held while asleep, just + * woke us up" and "held while powering on" the same way, since both + * look identical from here: the pin is just low right now. */ reset_button_init(); next_button_init(); + manage_button_init(); reset_button_check(); /* clears config + restarts if held the full duration; never returns in that case */ bool force_advance = next_button_check(); + bool show_management_qr = manage_button_check(); frame_config_t cfg; esp_err_t cfg_err = frame_config_load(&cfg); if (cfg_err == ESP_OK) { ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid); if (frame_wifi_connect_sta(&cfg) == ESP_OK) { - frame_client_run(&cfg, force_advance); + frame_client_run(&cfg, force_advance, show_management_qr); return; /* frame_client_run currently never returns */ } ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning", diff --git a/firmware/main/manage_button.c b/firmware/main/manage_button.c new file mode 100644 index 0000000..e79fd95 --- /dev/null +++ b/firmware/main/manage_button.c @@ -0,0 +1,64 @@ +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_sleep.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "manage_button.h" + +static const char *TAG = "manage_button"; + +#if CONFIG_FRAME_MANAGE_BUTTON_GPIO >= 0 + +#define MANAGE_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_MANAGE_BUTTON_GPIO) +#define MANAGE_BUTTON_DEBOUNCE_MS 20 +#define MANAGE_BUTTON_DEBOUNCE_CHECKS 3 + +void manage_button_init(void) +{ + gpio_config_t io_conf = { + .pin_bit_mask = 1ULL << MANAGE_BUTTON_GPIO, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + }; + gpio_config(&io_conf); + + /* See reset_button.c for why this API (not ext1) -- it manages the + * pull resistor across the sleep transition itself, so the pin + * doesn't float and wake the device spuriously. */ + esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << MANAGE_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW); +} + +bool manage_button_check(void) +{ + /* See next_button.c for why the latched wakeup status is checked + * first: a quick tap can release before a live gpio_get_level() call + * this far into boot would still see it held, even though it's what + * woke the device. */ + if (esp_sleep_get_gpio_wakeup_status() & (1ULL << MANAGE_BUTTON_GPIO)) { + ESP_LOGI(TAG, "Manage button caused this wake, showing management QR"); + return true; + } + + if (gpio_get_level(MANAGE_BUTTON_GPIO) != 0) { + return false; + } + + for (int i = 0; i < MANAGE_BUTTON_DEBOUNCE_CHECKS; i++) { + vTaskDelay(pdMS_TO_TICKS(MANAGE_BUTTON_DEBOUNCE_MS)); + if (gpio_get_level(MANAGE_BUTTON_GPIO) != 0) { + return false; /* noise, not a real press */ + } + } + + ESP_LOGI(TAG, "Manage button held during power-on, showing management QR"); + return true; +} + +#else + +void manage_button_init(void) {} +bool manage_button_check(void) { return false; } + +#endif diff --git a/firmware/main/manage_button.h b/firmware/main/manage_button.h new file mode 100644 index 0000000..4f2bbeb --- /dev/null +++ b/firmware/main/manage_button.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +/** + * Configures the "manage" button GPIO (CONFIG_FRAME_MANAGE_BUTTON_GPIO, + * active-low with internal pull-up) and arms it as a deep-sleep wakeup + * source, same as reset_button_init()/next_button_init(). Call once, + * early in app_main(), before the device might enter deep sleep. + * + * A no-op if CONFIG_FRAME_MANAGE_BUTTON_GPIO is negative (button disabled). + */ +void manage_button_init(void); + +/** + * Returns whether the manage button is currently held, debounced with a + * couple of short re-checks to reject noise. Like the next-photo button, + * there's no long hold-to-confirm gate -- showing the management QR is + * low-stakes and should feel immediate. + */ +bool manage_button_check(void); diff --git a/firmware/main/manage_qr_overlay.c b/firmware/main/manage_qr_overlay.c new file mode 100644 index 0000000..88b9e72 --- /dev/null +++ b/firmware/main/manage_qr_overlay.c @@ -0,0 +1,100 @@ +#include +#include + +#include "esp_check.h" + +#include "epd7in3e.h" +#include "epd_draw.h" +#include "fonts.h" +#include "qrcodegen.h" + +#include "manage_qr_overlay.h" + +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. */ +#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. */ +#define PANEL_MARGIN 20 + +static void draw_qr(uint8_t *buf, int stride, int width, int height, const uint8_t *qrcode, int origin_x, + int origin_y) +{ + int size = qrcodegen_getSize(qrcode); + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + epd_color_t color = qrcodegen_getModule(qrcode, x, y) ? EPD_COLOR_BLACK : EPD_COLOR_WHITE; + for (int dy = 0; dy < QR_MODULE_PX; dy++) { + for (int dx = 0; dx < QR_MODULE_PX; dx++) { + epd_draw_pixel_ex(buf, stride, width, height, origin_x + x * QR_MODULE_PX + dx, + origin_y + y * QR_MODULE_PX + dy, color); + } + } + } + } +} + +esp_err_t manage_qr_overlay_render(const char *management_url, manage_qr_overlay_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, + 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); + + int qr_size = qrcodegen_getSize(qrcode); + int qr_px = qr_size * QR_MODULE_PX; + + /* 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; + + 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 w = content_w + PADDING * 2; + int h = content_h + PADDING * 2; + w += w % 2; /* keep byte-aligned (2px/byte) */ + + 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"); + 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); + + out->buf = buf; + out->w = w; + out->h = h; + out->x0 = EPD_WIDTH - PANEL_MARGIN - w; + out->y0 = PANEL_MARGIN; + + return ESP_OK; +} + +void manage_qr_overlay_free(manage_qr_overlay_t *overlay) +{ + free(overlay->buf); + overlay->buf = NULL; +} diff --git a/firmware/main/manage_qr_overlay.h b/firmware/main/manage_qr_overlay.h new file mode 100644 index 0000000..9ca10c4 --- /dev/null +++ b/firmware/main/manage_qr_overlay.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "esp_err.h" + +typedef struct { + uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; caller must free */ + 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; + +/** + * 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(). + */ +esp_err_t manage_qr_overlay_render(const char *management_url, manage_qr_overlay_t *out); + +void manage_qr_overlay_free(manage_qr_overlay_t *overlay);