Add two physical buttons: factory-reset and next-photo
Build and push server image / build-and-push (push) Successful in 35s

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.
This commit is contained in:
2026-07-18 23:28:36 -04:00
parent 7013311249
commit d395cf3bb9
19 changed files with 668 additions and 47 deletions
+16
View File
@@ -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
+30 -3
View File
@@ -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
+2 -2
View File
@@ -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)
+38
View File
@@ -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
+7 -6
View File
@@ -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
+9 -4
View File
@@ -1,5 +1,7 @@
#pragma once
#include <stdbool.h>
#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);
+16 -1
View File
@@ -1,3 +1,5 @@
#include <stdbool.h>
#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",
+69
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <stdbool.h>
/**
* 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);
+68
View File
@@ -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
+22
View File
@@ -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);
+14
View File
@@ -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);
+9
View File
@@ -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
+31 -9
View File
@@ -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
+8 -1
View File
@@ -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:
+14
View File
@@ -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 <img> 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")
+99 -21
View File
@@ -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)
+100
View File
@@ -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
+95
View File
@@ -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; }
</style>
</head>
<body>
@@ -63,6 +71,12 @@
</form>
<div id="result"></div>
<h2 class="section">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
<h2 class="section">Upcoming</h2>
<ul id="upcoming-list"></ul>
<script>
const resultEl = document.getElementById('result');
@@ -117,10 +131,91 @@
try {
await saveConfig();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
let upcomingItems = [];
function renderUpcoming(items) {
upcomingItems = items;
const list = document.getElementById('upcoming-list');
list.innerHTML = '';
items.forEach((item, i) => {
const li = document.createElement('li');
li.className = 'queue-item';
const img = document.createElement('img');
img.src = item.thumbnail_url;
li.appendChild(img);
const spacer = document.createElement('span');
spacer.className = 'spacer';
li.appendChild(spacer);
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.textContent = '↑';
upBtn.disabled = i === 0;
upBtn.addEventListener('click', () => moveItem(i, -1));
li.appendChild(upBtn);
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.textContent = '↓';
downBtn.disabled = i === items.length - 1;
downBtn.addEventListener('click', () => moveItem(i, 1));
li.appendChild(downBtn);
list.appendChild(li);
});
}
async function moveItem(index, delta) {
const newIndex = index + delta;
if (newIndex < 0 || newIndex >= upcomingItems.length) {
return;
}
const items = upcomingItems.slice();
[items[index], items[newIndex]] = [items[newIndex], items[index]];
renderUpcoming(items);
try {
const resp = await fetch('/api/queue/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queue: items.map((item) => item.id) }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
loadQueue();
}
}
async function loadQueue() {
const currentEl = document.getElementById('current-thumb');
try {
const resp = await fetch('/api/queue');
if (!resp.ok) {
currentEl.innerHTML = '<p class="sub">Not available yet -- configure Immich and an album first.</p>';
renderUpcoming([]);
return;
}
const data = await resp.json();
currentEl.innerHTML = data.current
? `<img class="thumb" src="${data.current.thumbnail_url}">`
: '<p class="sub">Nothing displayed yet.</p>';
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadQueue();
</script>
</body>
</html>