From d395cf3bb99ada6012b3469ce3d072242e6ce738 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Sat, 18 Jul 2026 23:28:36 -0400 Subject: [PATCH] Add two physical buttons: factory-reset and next-photo Factory-reset (GPIO3, hold 10s): clears stored WiFi/server config and restarts into provisioning -- the deliberate, USB-free replacement for the earlier reverted RST-based auto-reprovisioning idea. Next-photo (GPIO2, tap): wakes the device and forces the server to advance immediately via a new POST /frame/advance, instead of waiting for the refresh interval. Both buttons arm themselves as deep-sleep GPIO wakeup sources so a press is noticed promptly even while asleep. Also makes GET /frame/image side-effect-free: it now only advances once refresh_interval_s has elapsed since the current photo was set (tracked server-side), so a device reboot for any reason just redisplays the current photo instead of silently skipping ahead. The server maintains a small reorderable upcoming-photos queue, viewable and rearrangeable from the web UI. --- docs/hardware.md | 16 ++++ firmware/README.md | 33 +++++++- firmware/main/CMakeLists.txt | 4 +- firmware/main/Kconfig.projbuild | 38 ++++++++++ firmware/main/frame_client.c | 13 ++-- firmware/main/frame_client.h | 13 +++- firmware/main/main.c | 17 ++++- firmware/main/next_button.c | 69 +++++++++++++++++ firmware/main/next_button.h | 21 ++++++ firmware/main/reset_button.c | 68 +++++++++++++++++ firmware/main/reset_button.h | 22 ++++++ firmware/main/wifi_provisioning.c | 14 ++++ firmware/main/wifi_provisioning.h | 9 +++ server/README.md | 40 +++++++--- server/app/config.py | 9 ++- server/app/immich_client.py | 14 ++++ server/app/main.py | 120 ++++++++++++++++++++++++------ server/app/photo_queue.py | 100 +++++++++++++++++++++++++ server/app/templates/index.html | 95 +++++++++++++++++++++++ 19 files changed, 668 insertions(+), 47 deletions(-) create mode 100644 firmware/main/next_button.c create mode 100644 firmware/main/next_button.h create mode 100644 firmware/main/reset_button.c create mode 100644 firmware/main/reset_button.h create mode 100644 server/app/photo_queue.py diff --git a/docs/hardware.md b/docs/hardware.md index 9e53d3e..f6b01c7 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -28,6 +28,22 @@ Configuration** if your wiring differs. | VCC | 3.3V | -- | | GND | GND | -- | +Optionally, two buttons, both 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): + +- **Factory-reset (GPIO3)**: held for 10 seconds, clears the stored + WiFi/server config; see + [`firmware/README.md`](../firmware/README.md#resetting-to-provisioning-mode). +- **Next photo (GPIO2)**: a normal press skips immediately to the next + photo; see + [`firmware/README.md`](../firmware/README.md#skipping-to-the-next-photo). + +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 +aren't already used by the panel wiring above. + A couple of things worth knowing if you pick different pins: - **Avoid the ESP32-C6's strapping pins** (GPIO 4, 5, 8, 9, 15) and the diff --git a/firmware/README.md b/firmware/README.md index b7eb388..8b29726 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -44,6 +44,9 @@ Under **ESPresso Frame Configuration**: | `FRAME_FETCH_TIMEOUT_MS` | 15000 | Timeout for `GET /frame/image` | | `FRAME_SLEEP_INTERVAL_S` | 3600 | **Fallback only** -- the refresh interval is normally set server-side; see below | | `FRAME_RETRY_INTERVAL_S` | 300 | Sleep duration after a failed cycle, before retrying | +| `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 | Under **E-Paper Display (epd7in3e) Configuration**: SPI/GPIO pin assignments and SPI clock speed -- see @@ -77,11 +80,35 @@ Server" address (`host:port` of the [server](../server/) -- **not** your Immich server). Saving reboots the device, which then connects to your home network and starts its normal fetch/sleep cycle. +## Skipping to the next photo + +Wire a momentary push button between GPIO2 and GND (internal pull-up, +active-low, same wiring style as the reset button). A press wakes the +device (if asleep) and tells the server to advance to the next photo +right away, regardless of the configured refresh interval -- no long hold +needed, unlike the factory-reset button, since advancing is easily +reversible by pressing again. See `FRAME_NEXT_BUTTON_GPIO` above to +change the pin or disable the feature. + +Normal wakes and reboots never advance the photo on their own -- the +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. + ## Resetting to provisioning mode -There's currently no in-field way to force the device back into -provisioning (a future addition) -- reconfiguring means erasing its NVS -partition over USB: +Wire a momentary push button between GPIO3 and GND (internal pull-up, +active-low -- no external resistor needed). Hold it for 10 seconds (from +either power-on or while the device is deep-asleep -- GPIO3 is armed as a +wakeup source) and it clears the stored WiFi/server config and restarts +into provisioning. Releasing it early is a no-op; nothing happens until +the full hold duration elapses, so a brief accidental bump won't +reprovision the device. See `FRAME_RESET_BUTTON_GPIO`/ +`FRAME_RESET_BUTTON_HOLD_MS` above to change the pin or hold duration, or +disable the feature. + +Without the button wired up (or with `FRAME_RESET_BUTTON_GPIO` set to +`-1`), reconfiguring still works by erasing the NVS partition over USB: ``` python -m esptool --chip esp32c6 -p PORT erase-region 0x9000 0x6000 diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 635665c..9c6b0e2 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 - PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client dns_server epd7in3e qrcode epaper_fonts +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 + 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 0fb7d5a..7cd3854 100644 --- a/firmware/main/Kconfig.projbuild +++ b/firmware/main/Kconfig.projbuild @@ -83,4 +83,42 @@ menu "ESPresso Frame Configuration" server was unreachable or the fetch/display failed, instead of waiting the full FRAME_SLEEP_INTERVAL_S. + config FRAME_RESET_BUTTON_GPIO + int "Factory-reset button GPIO (-1 to disable)" + default 3 + range -1 7 + help + Button wired between this GPIO and GND (active-low, internal + pull-up enabled in firmware -- no external resistor needed). + Holding it for FRAME_RESET_BUTTON_HOLD_MS clears the stored + WiFi/server config and restarts the device into provisioning + mode. Must be GPIO 0-7 -- the only pins the ESP32-C6 can use as + an EXT1 deep-sleep wakeup source, which is what lets a press + wake the device promptly instead of only being noticed during + its brief awake windows. Set to -1 to disable the feature + entirely. + + config FRAME_RESET_BUTTON_HOLD_MS + int "Factory-reset button hold duration (ms)" + default 10000 + depends on FRAME_RESET_BUTTON_GPIO >= 0 + help + How long the reset button must be held continuously before the + device clears its stored config and reboots into provisioning. + Long enough that it won't trigger by accident. + + config FRAME_NEXT_BUTTON_GPIO + int "Next-photo button GPIO (-1 to disable)" + default 2 + 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), forces the server to + advance to the next photo immediately (POST /frame/advance) + regardless of the configured refresh interval, and displays + it. 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 reset button. Set to -1 to disable the feature. + endmenu diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index 9d0e77a..491b6a6 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -212,19 +212,20 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_) return n > 0 ? (size_t)n : 0; } -/* GETs /frame/image and streams the response directly into the panel. +/* 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 -- * 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) +static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance) { char url[160]; - snprintf(url, sizeof(url), "http://%s/frame/image", cfg->toolsserver); + snprintf(url, sizeof(url), "http://%s/%s", cfg->toolsserver, force_advance ? "frame/advance" : "frame/image"); esp_http_client_config_t config = { .url = url, - .method = HTTP_METHOD_GET, + .method = force_advance ? HTTP_METHOD_POST : HTTP_METHOD_GET, .timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS, }; esp_http_client_handle_t client = esp_http_client_init(&config); @@ -255,7 +256,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg) return err; } -void frame_client_run(const frame_config_t *cfg) +void frame_client_run(const frame_config_t *cfg, bool force_advance) { esp_err_t epd_err = epd_init(); bool have_display = (epd_err == ESP_OK); @@ -292,7 +293,7 @@ void frame_client_run(const frame_config_t *cfg) status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_OK); } if (have_display) { - esp_err_t fetch_err = fetch_and_display(cfg); + esp_err_t fetch_err = fetch_and_display(cfg, force_advance); if (fetch_err != ESP_OK) { /* epd_display_stream() never triggers a physical refresh on * a failed/short/wrong-size stream (see epd7in3e.c), so the diff --git a/firmware/main/frame_client.h b/firmware/main/frame_client.h index 77915f8..516e774 100644 --- a/firmware/main/frame_client.h +++ b/firmware/main/frame_client.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esp_err.h" #include "wifi_provisioning.h" @@ -15,8 +17,11 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg); * Runs the frame's normal-operation cycle: fetch the current image from * cfg->toolsserver, display it, and deep-sleep until the next refresh. * - * TODO(step 6): implement the HTTP fetch + EPD display + esp_deep_sleep - * cycle once the display driver (step 4) and server (step 3) exist. For - * now this just confirms STA connectivity survives a reboot. + * If force_advance is true (the next-photo button was held), the fetch + * forces the server to skip ahead to the next photo immediately + * (POST /frame/advance) instead of the normal idempotent GET /frame/image + * -- 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. */ -void frame_client_run(const frame_config_t *cfg); +void frame_client_run(const frame_config_t *cfg, bool force_advance); diff --git a/firmware/main/main.c b/firmware/main/main.c index 5e03290..dcaa636 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -1,3 +1,5 @@ +#include + #include "esp_event.h" #include "esp_log.h" #include "nvs_flash.h" @@ -5,6 +7,8 @@ #include "wifi_provisioning.h" #include "frame_client.h" +#include "reset_button.h" +#include "next_button.h" static const char *TAG = "main"; @@ -26,12 +30,23 @@ 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. */ + reset_button_init(); + next_button_init(); + reset_button_check(); /* clears config + restarts if held the full duration; never returns in that case */ + + bool force_advance = next_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); + frame_client_run(&cfg, force_advance); 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/next_button.c b/firmware/main/next_button.c new file mode 100644 index 0000000..8850e53 --- /dev/null +++ b/firmware/main/next_button.c @@ -0,0 +1,69 @@ +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_sleep.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "next_button.h" + +static const char *TAG = "next_button"; + +#if CONFIG_FRAME_NEXT_BUTTON_GPIO >= 0 + +#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO) +#define NEXT_BUTTON_DEBOUNCE_MS 20 +#define NEXT_BUTTON_DEBOUNCE_CHECKS 3 + +void next_button_init(void) +{ + gpio_config_t io_conf = { + .pin_bit_mask = 1ULL << NEXT_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 << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW); +} + +bool next_button_check(void) +{ + /* A quick tap can easily release before this runs (~0.4-0.5s into + * boot, confirmed on hardware -- a live gpio_get_level() check here + * missed real presses that had already woken the device). The wakeup + * status register is latched at the moment of waking and isn't + * cleared until the next sleep entry, so it reliably reflects a tap + * regardless of how quickly it was released. */ + if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) { + ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance"); + return true; + } + + /* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh + * power-on/reflash) -- fall back to a live, debounced level check so + * holding the button down while powering on also works. */ + if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) { + return false; + } + + for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) { + vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS)); + if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) { + return false; /* noise, not a real press */ + } + } + + ESP_LOGI(TAG, "Next-photo button held during power-on, forcing advance"); + return true; +} + +#else + +void next_button_init(void) {} +bool next_button_check(void) { return false; } + +#endif diff --git a/firmware/main/next_button.h b/firmware/main/next_button.h new file mode 100644 index 0000000..6aa944f --- /dev/null +++ b/firmware/main/next_button.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +/** + * Configures the next-photo button GPIO (CONFIG_FRAME_NEXT_BUTTON_GPIO, + * active-low with internal pull-up) and arms it as a deep-sleep wakeup + * source, same as reset_button_init(). Call once, early in app_main(), + * before the device might enter deep sleep. + * + * A no-op if CONFIG_FRAME_NEXT_BUTTON_GPIO is negative (button disabled). + */ +void next_button_init(void); + +/** + * Returns whether the next-photo button is currently held, debounced with + * a couple of short re-checks to reject noise. Unlike the reset button, + * there's no long hold-to-confirm gate -- advancing a photo is low-stakes + * and should feel immediate, so this returns right away either way. + */ +bool next_button_check(void); diff --git a/firmware/main/reset_button.c b/firmware/main/reset_button.c new file mode 100644 index 0000000..a4738f7 --- /dev/null +++ b/firmware/main/reset_button.c @@ -0,0 +1,68 @@ +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_sleep.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "wifi_provisioning.h" + +#include "reset_button.h" + +static const char *TAG = "reset_button"; + +#if CONFIG_FRAME_RESET_BUTTON_GPIO >= 0 + +#define RESET_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_RESET_BUTTON_GPIO) +#define RESET_BUTTON_POLL_MS 100 + +void reset_button_init(void) +{ + gpio_config_t io_conf = { + .pin_bit_mask = 1ULL << RESET_BUTTON_GPIO, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + }; + gpio_config(&io_conf); + + /* Not esp_sleep_enable_ext1_wakeup_io(): its internal pull resistors + * don't hold once the RTC_PERIPH domain powers down for deep sleep, so + * the pin floats and reads spuriously low, waking the device instantly + * on every sleep entry (confirmed on hardware -- boot-looped every + * ~27s, the length of one fetch/display cycle, instead of sleeping for + * the configured interval). This GPIO-wakeup variant manages the pull + * resistor itself across the sleep transition. */ + esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << RESET_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW); +} + +void reset_button_check(void) +{ + if (gpio_get_level(RESET_BUTTON_GPIO) != 0) { + return; /* not pressed */ + } + + ESP_LOGI(TAG, "Reset button held -- hold for %dms to clear config and reprovision", + CONFIG_FRAME_RESET_BUTTON_HOLD_MS); + + int elapsed_ms = 0; + while (elapsed_ms < CONFIG_FRAME_RESET_BUTTON_HOLD_MS) { + vTaskDelay(pdMS_TO_TICKS(RESET_BUTTON_POLL_MS)); + elapsed_ms += RESET_BUTTON_POLL_MS; + if (gpio_get_level(RESET_BUTTON_GPIO) != 0) { + ESP_LOGI(TAG, "Reset button released early, continuing normal boot"); + return; + } + } + + ESP_LOGW(TAG, "Reset button held for %dms, clearing config and restarting into provisioning", + CONFIG_FRAME_RESET_BUTTON_HOLD_MS); + frame_config_clear(); + esp_restart(); +} + +#else + +void reset_button_init(void) {} +void reset_button_check(void) {} + +#endif diff --git a/firmware/main/reset_button.h b/firmware/main/reset_button.h new file mode 100644 index 0000000..11c7e86 --- /dev/null +++ b/firmware/main/reset_button.h @@ -0,0 +1,22 @@ +#pragma once + +/** + * Configures the factory-reset button GPIO (CONFIG_FRAME_RESET_BUTTON_GPIO, + * active-low with internal pull-up) and arms it as a deep-sleep wakeup + * source, so holding it wakes the device promptly even while it's asleep + * rather than only being noticed during its brief awake windows. Call once, + * early in app_main(), before the device might enter deep sleep. + * + * A no-op if CONFIG_FRAME_RESET_BUTTON_GPIO is negative (button disabled). + */ +void reset_button_init(void); + +/** + * Checks whether the reset button is currently held. If it's held + * continuously for CONFIG_FRAME_RESET_BUTTON_HOLD_MS, clears the stored + * WiFi/server config and restarts (does not return in that case) so the + * device comes back up in provisioning mode. Returns immediately if the + * button isn't pressed, or as soon as it's released before the hold + * duration elapses -- normal boot continues either way. + */ +void reset_button_check(void); diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c index feae0a9..89962c6 100644 --- a/firmware/main/wifi_provisioning.c +++ b/firmware/main/wifi_provisioning.c @@ -130,6 +130,20 @@ void frame_config_mark_connected_once(void) nvs_close(handle); } +void frame_config_clear(void) +{ + nvs_handle_t handle; + if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) { + return; + } + nvs_erase_key(handle, "sta_ssid"); + nvs_erase_key(handle, "sta_pass"); + nvs_erase_key(handle, "toolsserver"); + nvs_erase_key(handle, "connected_once"); + nvs_commit(handle); + nvs_close(handle); +} + static void generate_ap_password(char *out, size_t out_size) { size_t len = MIN(FRAME_AP_PASSWORD_LEN, out_size - 1); diff --git a/firmware/main/wifi_provisioning.h b/firmware/main/wifi_provisioning.h index 6e1798e..67c1c6b 100644 --- a/firmware/main/wifi_provisioning.h +++ b/firmware/main/wifi_provisioning.h @@ -36,6 +36,15 @@ bool frame_config_has_connected_once(void); /** Marks the status screen as having been shown for the current WiFi config. */ void frame_config_mark_connected_once(void); +/** + * Erases the stored home-network config (SSID/password/tools server) so the + * device falls back into provisioning on its next boot. Leaves the softAP + * identity (SSID/password) untouched, since that's tied to the device + * itself, not a particular home network -- regenerating it on every reset + * would force re-scanning the join QR code for no reason. + */ +void frame_config_clear(void); + /** * Returns this device's provisioning AP identity: a fixed SSID (from * Kconfig) and a password that's generated once on first use and persisted diff --git a/server/README.md b/server/README.md index e946f6a..2fef658 100644 --- a/server/README.md +++ b/server/README.md @@ -31,25 +31,47 @@ algorithm itself -- it just streams the response straight to the panel. ## Endpoints - `GET /` -- config UI (album, order, refresh interval, face-aware crop - toggle -- not Immich URL/API key, see Setup above) + toggle, now-displaying + reorderable upcoming photos -- not Immich + URL/API key, see Setup above) - `GET /api/albums` -- lists Immich albums (used by the config UI) - `POST /api/config` -- saves album/order/refresh_interval_s/smart_crop_faces - `GET /frame/image` -- returns the current photo pre-processed into the panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format - (`application/octet-stream`, exactly 192,000 bytes) + (`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free** + by default: it only actually advances to the next photo once + `refresh_interval_s` has elapsed since the current one was set, so + calling it repeatedly (e.g. the device rebooting unexpectedly) just + redisplays the same photo instead of skipping ahead. +- `POST /frame/advance` -- forces an immediate advance to the next photo, + ignoring `refresh_interval_s`, and resets the interval clock from now. + Same response shape as `/frame/image`. Used by the device's next-photo + button (see `firmware/README.md`). - `GET /frame/config` -- `{"refresh_interval_s": ...}`, polled by the frame each wake alongside its reachability check +- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...]}`, each + entry an asset id + thumbnail URL; used by the config UI +- `POST /api/queue/reorder` -- reorders the upcoming queue; body is + `{"queue": [asset_id, ...]}`, must be exactly a permutation of the + current queue +- `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so + the browser never needs the Immich API key directly - `GET /health` -- liveness check ## Notes -- Album/order/refresh-interval/etc. are stored in `./data/config.json` on - the host via the compose volume mount. Immich URL/API key are too if set - via the web UI, but `IMMICH_URL`/`IMMICH_API_KEY` env vars (see Setup - above) always take precedence when present. -- `/frame/image` isn't authenticated yet. That's fine on a trusted home - LAN for now, but worth revisiting once the ESP32 side is wired up to - send a shared device token. +- Album/order/refresh-interval/current photo/upcoming queue/etc. are + stored in `./data/config.json` on the host via the compose volume + mount. Immich URL/API key are too if set via the web UI, but + `IMMICH_URL`/`IMMICH_API_KEY` env vars (see Setup above) always take + precedence when present. +- The upcoming queue is a bounded lookahead (10 photos), not the whole + album -- it's topped up automatically as photos are consumed + (`app/photo_queue.py`), in sequential or shuffle order per the Order + setting. Reordering only rearranges those 10; it doesn't add or remove + photos from the album. +- `/frame/image` and `/frame/advance` aren't authenticated yet. That's + fine on a trusted home LAN for now, but worth revisiting once the ESP32 + side is wired up to send a shared device token. - The 6-color palette RGB values in `app/image_pipeline.py` are approximations, not measured values (Waveshare doesn't publish exact color primaries for this panel) -- tune them once you can compare a diff --git a/server/app/config.py b/server/app/config.py index a1dfd04..e109c1c 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -20,10 +20,17 @@ class FrameConfig(BaseModel): immich_api_key: str = "" album_id: str = "" order: str = "sequential" # or "shuffle" - cursor: int = 0 refresh_interval_s: int = 3600 smart_crop_faces: bool = True + # Current photo + upcoming queue (see app/photo_queue.py). current_asset_set_at + # is what lets the server decide "has it been long enough to advance" on its + # own clock, independent of how/why the device asked for a photo. + current_asset_id: str = "" + current_asset_set_at: float = 0.0 + queue: list[str] = [] + queue_cursor: int = 0 # internal bookkeeping for sequential queue top-up; not user-facing + def load() -> FrameConfig: with _lock: diff --git a/server/app/immich_client.py b/server/app/immich_client.py index 1586be6..c51215e 100644 --- a/server/app/immich_client.py +++ b/server/app/immich_client.py @@ -55,3 +55,17 @@ class ImmichClient: ) resp.raise_for_status() return resp.content + + def download_asset_thumbnail(self, asset_id: str) -> tuple[bytes, str]: + """Smaller than download_asset_preview -- used for the web UI's + upcoming-photos list, not the actual rendered frame. Returns + (content, content_type) since this one gets proxied straight to a + browser tag and needs a correct Content-Type header.""" + resp = httpx.get( + f"{self.base_url}/api/assets/{asset_id}/thumbnail", + params={"size": "thumbnail"}, + headers=self._headers, + timeout=30, + ) + resp.raise_for_status() + return resp.content, resp.headers.get("content-type", "image/jpeg") diff --git a/server/app/main.py b/server/app/main.py index c9414fd..35e7058 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -5,15 +5,15 @@ from __future__ import annotations import io import logging -import random import httpx -from fastapi import FastAPI, Form, HTTPException, Request +from fastapi import FastAPI, HTTPException, Form, Request from fastapi.responses import HTMLResponse, Response from fastapi.templating import Jinja2Templates from PIL import Image +from pydantic import BaseModel -from . import config +from . import config, photo_queue from .image_pipeline import render_frame from .immich_client import ImmichClient @@ -72,7 +72,12 @@ def api_config_save( # so there's nothing here that could overwrite or clear them. cfg = config.load() if album_id != cfg.album_id: - cfg.cursor = 0 # restart from the top of a newly selected album + # A newly selected album starts clean -- the old current photo and + # queue don't mean anything in the new album's context. + cfg.current_asset_id = "" + cfg.current_asset_set_at = 0.0 + cfg.queue = [] + cfg.queue_cursor = 0 cfg.album_id = album_id cfg.order = order if order in ("sequential", "shuffle") else "sequential" cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)) @@ -81,46 +86,119 @@ def api_config_save( return {"status": "saved"} -@app.get("/frame/image") -def frame_image(): - cfg = config.load() +def _require_configured(cfg: config.FrameConfig) -> None: if not cfg.immich_url or not cfg.immich_api_key: raise HTTPException(400, "Immich URL/API key not configured yet") if not cfg.album_id: raise HTTPException(400, "No album configured yet") - client = ImmichClient(cfg.immich_url, cfg.immich_api_key) + +def _list_assets(client: ImmichClient, cfg: config.FrameConfig) -> list[dict]: try: assets = client.list_album_assets(cfg.album_id) except httpx.HTTPError as e: raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e - if not assets: raise HTTPException(404, "Album has no photos") + return assets - if cfg.order == "shuffle": - asset = random.choice(assets) - else: - index = cfg.cursor % len(assets) - asset = assets[index] - cfg.cursor = (index + 1) % len(assets) - config.save(cfg) +def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str) -> bytes: try: - jpeg_bytes = client.download_asset_preview(asset["id"]) + jpeg_bytes = client.download_asset_preview(asset_id) except httpx.HTTPError as e: raise HTTPException(502, f"Could not download asset from Immich: {e}") from e faces = None if cfg.smart_crop_faces: try: - faces = client.get_asset_faces(asset["id"]) + faces = client.get_asset_faces(asset_id) except httpx.HTTPError as e: # A faces lookup hiccup shouldn't block showing a photo at # all -- just fall back to a plain center-crop this cycle. - logger.warning("Could not fetch faces for asset %s: %s", asset["id"], e) + logger.warning("Could not fetch faces for asset %s: %s", asset_id, e) source = Image.open(io.BytesIO(jpeg_bytes)) - frame_bytes = render_frame(source, faces=faces) + return render_frame(source, faces=faces) - return Response(content=frame_bytes, media_type="application/octet-stream") + +@app.get("/frame/image") +def frame_image(): + """Returns the current photo. Idempotent: only actually advances to + the next photo once refresh_interval_s has elapsed since the current + one was set (see app/photo_queue.py) -- safe to call as often as the + device wants, including after an unplanned reboot, without skipping + ahead in the album.""" + 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) + + return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream") + + +@app.post("/frame/advance") +def frame_advance(): + """Forces an immediate advance to the next photo, ignoring + refresh_interval_s, and resets the interval clock from now. Used by + the device's next-photo button.""" + cfg = config.load() + _require_configured(cfg) + + client = ImmichClient(cfg.immich_url, cfg.immich_api_key) + assets = _list_assets(client, cfg) + + photo_queue.advance_forced(cfg, assets) + config.save(cfg) + + return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream") + + +@app.get("/api/queue") +def api_queue(): + 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) + + def entry(asset_id: str) -> dict: + return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"} + + return { + "current": entry(cfg.current_asset_id) if cfg.current_asset_id else None, + "upcoming": [entry(asset_id) for asset_id in cfg.queue], + } + + +class QueueReorderRequest(BaseModel): + queue: list[str] + + +@app.post("/api/queue/reorder") +def api_queue_reorder(body: QueueReorderRequest): + cfg = config.load() + if set(body.queue) != set(cfg.queue) or len(body.queue) != len(cfg.queue): + raise HTTPException(400, "Reordered queue must contain exactly the current queue's photos") + cfg.queue = body.queue + config.save(cfg) + return {"status": "saved"} + + +@app.get("/api/photo-thumbnail/{asset_id}") +def api_photo_thumbnail(asset_id: str): + cfg = config.load() + _require_configured(cfg) + client = ImmichClient(cfg.immich_url, cfg.immich_api_key) + try: + content, content_type = client.download_asset_thumbnail(asset_id) + except httpx.HTTPError as e: + raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e + return Response(content=content, media_type=content_type) diff --git a/server/app/photo_queue.py b/server/app/photo_queue.py new file mode 100644 index 0000000..172e56d --- /dev/null +++ b/server/app/photo_queue.py @@ -0,0 +1,100 @@ +"""Tracks which photo is currently displayed and what's queued up next. + +`current_asset_id` only ever changes two ways: the configured refresh +interval elapsing (`get_current`, called on every `GET /frame/image` -- +a no-op otherwise, so an unplanned device reboot just redisplays the same +photo instead of silently skipping ahead) or an explicit forced advance +(`advance_forced`, called from `POST /frame/advance` -- the next-photo +button -- ignoring elapsed time). + +`queue` is a small reorderable lookahead the web UI can preview and +rearrange, topped up automatically from the album as it's consumed. +`queue_cursor` is separate, internal-only bookkeeping for where sequential +top-up resumes in the album -- not shown or reordered in the UI. +""" + +from __future__ import annotations + +import random +import time + +from .config import FrameConfig + +QUEUE_TARGET_LEN = 10 + + +def _top_up(cfg: FrameConfig, assets: list[dict]) -> None: + valid_ids = {a["id"] for a in assets} + cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids] + + needed = QUEUE_TARGET_LEN - len(cfg.queue) + if needed <= 0 or not assets: + return + + excluded = set(cfg.queue) + if cfg.current_asset_id: + excluded.add(cfg.current_asset_id) + + if cfg.order == "shuffle": + candidates = [a["id"] for a in assets if a["id"] not in excluded] + cfg.queue.extend(random.sample(candidates, min(needed, len(candidates)))) + return + + # Sequential: walk the album starting at queue_cursor, at most one full + # pass, wrapping around. queue_cursor resumes right after wherever this + # pass stopped, whether or not it filled the queue (e.g. a small album + # where everything's already queued/current -- next call is then a + # cheap no-op scan until something's consumed). + n = len(assets) + cfg.queue_cursor %= n + added = 0 + i = 0 + for i in range(n): + if added >= needed: + break + asset_id = assets[(cfg.queue_cursor + i) % n]["id"] + if asset_id not in excluded: + cfg.queue.append(asset_id) + excluded.add(asset_id) + added += 1 + cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n + + +def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None: + """Unconditionally moves to the next photo, ignoring elapsed time, and + resets the interval clock from now. Used only by the explicit + next-photo action (POST /frame/advance) -- always mutates cfg.""" + _top_up(cfg, assets) + if cfg.queue: + cfg.current_asset_id = cfg.queue.pop(0) + elif assets: + # Queue still empty after top-up (e.g. a single-photo album whose + # only asset is already current) -- keep showing what we have. + cfg.current_asset_id = assets[0]["id"] + cfg.current_asset_set_at = time.time() + # Refill back up to QUEUE_TARGET_LEN now that current_asset_id has + # changed -- otherwise the queue is left one short until the *next* + # advance, since the pop above consumes one of the items _top_up just + # added. + _top_up(cfg, assets) + + +def get_current(cfg: FrameConfig, assets: list[dict]) -> bool: + """Time-based, idempotent path used by GET /frame/image. Advances only + if the current photo is unset/invalid or refresh_interval_s has + elapsed since it was set. Returns whether it changed anything, so the + caller knows whether to persist. Calling this repeatedly well within + the interval is a no-op both times -- what makes an unplanned device + reboot safe: it just re-reads the current photo instead of skipping + ahead, while a wake that lands after the interval has elapsed still + advances exactly once, even after a long time offline.""" + valid_ids = {a["id"] for a in assets} + stale = ( + not cfg.current_asset_id + or cfg.current_asset_id not in valid_ids + or (time.time() - cfg.current_asset_set_at) >= cfg.refresh_interval_s + ) + if not stale: + return False + advance_forced(cfg, assets) + return True diff --git a/server/app/templates/index.html b/server/app/templates/index.html index c470f0e..e74bc3f 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -22,6 +22,14 @@ .info-box { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 13px; background: #f3f4f6; color: #444; } .info-box.warn { background: #fef9c3; color: #854d0e; } code { background: #f3f4f6; padding: 2px 5px; border-radius: 3px; } + h2.section { font-size: 16px; margin-top: 28px; margin-bottom: 8px; } + .thumb { width: 160px; max-width: 100%; border-radius: 4px; display: block; } + #upcoming-list { list-style: none; padding: 0; margin: 0; } + .queue-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid #eee; } + .queue-item img { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; } + .queue-item .spacer { flex: 1; } + .queue-item button { margin: 0; padding: 4px 10px; font-size: 13px; background: #6b7280; } + .queue-item button:disabled { opacity: 0.35; cursor: default; } @@ -63,6 +71,12 @@
+

Now displaying

+

Loading...

+ +

Upcoming

+
    +