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 | -- |
|
| VCC | 3.3V | -- |
|
||||||
| GND | GND | -- |
|
| 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:
|
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
|
- **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_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_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_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
|
Under **E-Paper Display (epd7in3e) Configuration**: SPI/GPIO pin
|
||||||
assignments and SPI clock speed -- see
|
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
|
Immich server). Saving reboots the device, which then connects to your
|
||||||
home network and starts its normal fetch/sleep cycle.
|
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
|
## Resetting to provisioning mode
|
||||||
|
|
||||||
There's currently no in-field way to force the device back into
|
Wire a momentary push button between GPIO3 and GND (internal pull-up,
|
||||||
provisioning (a future addition) -- reconfiguring means erasing its NVS
|
active-low -- no external resistor needed). Hold it for 10 seconds (from
|
||||||
partition over USB:
|
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
|
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
|
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
|
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)
|
EMBED_FILES root.html)
|
||||||
|
|||||||
@@ -83,4 +83,42 @@ menu "ESPresso Frame Configuration"
|
|||||||
server was unreachable or the fetch/display failed, instead of
|
server was unreachable or the fetch/display failed, instead of
|
||||||
waiting the full FRAME_SLEEP_INTERVAL_S.
|
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
|
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;
|
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 --
|
* Returning non-ESP_OK means the panel was never actually refreshed --
|
||||||
* epd_display_stream() (see epd7in3e.c) refuses to trigger a physical
|
* epd_display_stream() (see epd7in3e.c) refuses to trigger a physical
|
||||||
* refresh on a short/wrong-size stream, so a failure here always leaves
|
* refresh on a short/wrong-size stream, so a failure here always leaves
|
||||||
* the visible screen exactly as it was. */
|
* 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];
|
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 = {
|
esp_http_client_config_t config = {
|
||||||
.url = url,
|
.url = url,
|
||||||
.method = HTTP_METHOD_GET,
|
.method = force_advance ? HTTP_METHOD_POST : HTTP_METHOD_GET,
|
||||||
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
|
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
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;
|
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();
|
esp_err_t epd_err = epd_init();
|
||||||
bool have_display = (epd_err == ESP_OK);
|
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);
|
status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_OK);
|
||||||
}
|
}
|
||||||
if (have_display) {
|
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) {
|
if (fetch_err != ESP_OK) {
|
||||||
/* epd_display_stream() never triggers a physical refresh on
|
/* epd_display_stream() never triggers a physical refresh on
|
||||||
* a failed/short/wrong-size stream (see epd7in3e.c), so the
|
* a failed/short/wrong-size stream (see epd7in3e.c), so the
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
#include "wifi_provisioning.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
|
* Runs the frame's normal-operation cycle: fetch the current image from
|
||||||
* cfg->toolsserver, display it, and deep-sleep until the next refresh.
|
* cfg->toolsserver, display it, and deep-sleep until the next refresh.
|
||||||
*
|
*
|
||||||
* TODO(step 6): implement the HTTP fetch + EPD display + esp_deep_sleep
|
* If force_advance is true (the next-photo button was held), the fetch
|
||||||
* cycle once the display driver (step 4) and server (step 3) exist. For
|
* forces the server to skip ahead to the next photo immediately
|
||||||
* now this just confirms STA connectivity survives a reboot.
|
* (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_event.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "nvs_flash.h"
|
#include "nvs_flash.h"
|
||||||
@@ -5,6 +7,8 @@
|
|||||||
|
|
||||||
#include "wifi_provisioning.h"
|
#include "wifi_provisioning.h"
|
||||||
#include "frame_client.h"
|
#include "frame_client.h"
|
||||||
|
#include "reset_button.h"
|
||||||
|
#include "next_button.h"
|
||||||
|
|
||||||
static const char *TAG = "main";
|
static const char *TAG = "main";
|
||||||
|
|
||||||
@@ -26,12 +30,23 @@ void app_main(void)
|
|||||||
}
|
}
|
||||||
ESP_ERROR_CHECK(nvs_err);
|
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;
|
frame_config_t cfg;
|
||||||
esp_err_t cfg_err = frame_config_load(&cfg);
|
esp_err_t cfg_err = frame_config_load(&cfg);
|
||||||
if (cfg_err == ESP_OK) {
|
if (cfg_err == ESP_OK) {
|
||||||
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
|
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
|
||||||
if (frame_wifi_connect_sta(&cfg) == ESP_OK) {
|
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 */
|
return; /* frame_client_run currently never returns */
|
||||||
}
|
}
|
||||||
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
|
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);
|
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)
|
static void generate_ap_password(char *out, size_t out_size)
|
||||||
{
|
{
|
||||||
size_t len = MIN(FRAME_AP_PASSWORD_LEN, out_size - 1);
|
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. */
|
/** Marks the status screen as having been shown for the current WiFi config. */
|
||||||
void frame_config_mark_connected_once(void);
|
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
|
* Returns this device's provisioning AP identity: a fixed SSID (from
|
||||||
* Kconfig) and a password that's generated once on first use and persisted
|
* 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
|
## Endpoints
|
||||||
|
|
||||||
- `GET /` -- config UI (album, order, refresh interval, face-aware crop
|
- `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)
|
- `GET /api/albums` -- lists Immich albums (used by the config UI)
|
||||||
- `POST /api/config` -- saves album/order/refresh_interval_s/smart_crop_faces
|
- `POST /api/config` -- saves album/order/refresh_interval_s/smart_crop_faces
|
||||||
- `GET /frame/image` -- returns the current photo pre-processed into the
|
- `GET /frame/image` -- returns the current photo pre-processed into the
|
||||||
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
|
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
|
- `GET /frame/config` -- `{"refresh_interval_s": ...}`, polled by the frame
|
||||||
each wake alongside its reachability check
|
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
|
- `GET /health` -- liveness check
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Album/order/refresh-interval/etc. are stored in `./data/config.json` on
|
- Album/order/refresh-interval/current photo/upcoming queue/etc. are
|
||||||
the host via the compose volume mount. Immich URL/API key are too if set
|
stored in `./data/config.json` on the host via the compose volume
|
||||||
via the web UI, but `IMMICH_URL`/`IMMICH_API_KEY` env vars (see Setup
|
mount. Immich URL/API key are too if set via the web UI, but
|
||||||
above) always take precedence when present.
|
`IMMICH_URL`/`IMMICH_API_KEY` env vars (see Setup above) always take
|
||||||
- `/frame/image` isn't authenticated yet. That's fine on a trusted home
|
precedence when present.
|
||||||
LAN for now, but worth revisiting once the ESP32 side is wired up to
|
- The upcoming queue is a bounded lookahead (10 photos), not the whole
|
||||||
send a shared device token.
|
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
|
- The 6-color palette RGB values in `app/image_pipeline.py` are
|
||||||
approximations, not measured values (Waveshare doesn't publish exact
|
approximations, not measured values (Waveshare doesn't publish exact
|
||||||
color primaries for this panel) -- tune them once you can compare a
|
color primaries for this panel) -- tune them once you can compare a
|
||||||
|
|||||||
@@ -20,10 +20,17 @@ class FrameConfig(BaseModel):
|
|||||||
immich_api_key: str = ""
|
immich_api_key: str = ""
|
||||||
album_id: str = ""
|
album_id: str = ""
|
||||||
order: str = "sequential" # or "shuffle"
|
order: str = "sequential" # or "shuffle"
|
||||||
cursor: int = 0
|
|
||||||
refresh_interval_s: int = 3600
|
refresh_interval_s: int = 3600
|
||||||
smart_crop_faces: bool = True
|
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:
|
def load() -> FrameConfig:
|
||||||
with _lock:
|
with _lock:
|
||||||
|
|||||||
@@ -55,3 +55,17 @@ class ImmichClient:
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.content
|
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 io
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Form, Request
|
||||||
from fastapi.responses import HTMLResponse, Response
|
from fastapi.responses import HTMLResponse, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from . import config
|
from . import config, photo_queue
|
||||||
from .image_pipeline import render_frame
|
from .image_pipeline import render_frame
|
||||||
from .immich_client import ImmichClient
|
from .immich_client import ImmichClient
|
||||||
|
|
||||||
@@ -72,7 +72,12 @@ def api_config_save(
|
|||||||
# so there's nothing here that could overwrite or clear them.
|
# so there's nothing here that could overwrite or clear them.
|
||||||
cfg = config.load()
|
cfg = config.load()
|
||||||
if album_id != cfg.album_id:
|
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.album_id = album_id
|
||||||
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
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))
|
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"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/frame/image")
|
def _require_configured(cfg: config.FrameConfig) -> None:
|
||||||
def frame_image():
|
|
||||||
cfg = config.load()
|
|
||||||
if not cfg.immich_url or not cfg.immich_api_key:
|
if not cfg.immich_url or not cfg.immich_api_key:
|
||||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||||
if not cfg.album_id:
|
if not cfg.album_id:
|
||||||
raise HTTPException(400, "No album configured yet")
|
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:
|
try:
|
||||||
assets = client.list_album_assets(cfg.album_id)
|
assets = client.list_album_assets(cfg.album_id)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
|
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
|
||||||
|
|
||||||
if not assets:
|
if not assets:
|
||||||
raise HTTPException(404, "Album has no photos")
|
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:
|
try:
|
||||||
jpeg_bytes = client.download_asset_preview(asset["id"])
|
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||||
|
|
||||||
faces = None
|
faces = None
|
||||||
if cfg.smart_crop_faces:
|
if cfg.smart_crop_faces:
|
||||||
try:
|
try:
|
||||||
faces = client.get_asset_faces(asset["id"])
|
faces = client.get_asset_faces(asset_id)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
# A faces lookup hiccup shouldn't block showing a photo at
|
# A faces lookup hiccup shouldn't block showing a photo at
|
||||||
# all -- just fall back to a plain center-crop this cycle.
|
# 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))
|
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 { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 13px; background: #f3f4f6; color: #444; }
|
||||||
.info-box.warn { background: #fef9c3; color: #854d0e; }
|
.info-box.warn { background: #fef9c3; color: #854d0e; }
|
||||||
code { background: #f3f4f6; padding: 2px 5px; border-radius: 3px; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -63,6 +71,12 @@
|
|||||||
</form>
|
</form>
|
||||||
<div id="result"></div>
|
<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>
|
<script>
|
||||||
const resultEl = document.getElementById('result');
|
const resultEl = document.getElementById('result');
|
||||||
|
|
||||||
@@ -117,10 +131,91 @@
|
|||||||
try {
|
try {
|
||||||
await saveConfig();
|
await saveConfig();
|
||||||
showStatus(true, 'Saved.');
|
showStatus(true, 'Saved.');
|
||||||
|
loadQueue();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showStatus(false, e.message);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user