Add two physical buttons: factory-reset and next-photo
Build and push server image / build-and-push (push) Successful in 35s
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user