Add back-photo button; consolidate reset/manage onto one hold-duration button
Build and push server image / build-and-push (push) Successful in 32s

Back button (new GPIO0, POST /frame/back): the server now tracks a
bounded history of previously-current photos (photo_queue.py), pushed
to on every advance (auto or forced) and popped by back_forced() --
symmetric with advance, so pressing next afterwards returns to right
where you were. frame_client.c's force_advance bool becomes a 3-way
fetch_action_t (NORMAL/ADVANCE/BACK) threaded through the whole fetch
path.

Also folds the separate reset and manage buttons onto one pin
(combo_button.c, replacing reset_button.c/manage_button.c entirely),
disambiguated by hold duration: quick press shows the management menu
(unchanged), ~3s hold-then-release soft-resets (esp_restart(), config
kept -- new), ~15s hold factory-resets (today's old reset behavior,
extended from 10s for clearer tier separation). Driven by a production
board (Seeed XIAO ESP32-C6) exposing only 3 of the ESP32-C6's 8
deep-sleep-wakeup-capable GPIOs -- next/back keep their own dedicated
pins where instant response matters most, everything else shares the
third pin via timing instead of needing its own. Same three-pin layout
now works on both the dev board and the production board.

Fixed a fast-tap bug in combo_button_check() before shipping: it only
did a live gpio_get_level() read to decide whether the button was
pressed at all, so a press fast enough to already be released by the
time boot reached that check was missed entirely (treated as "never
pressed" rather than "quick press"). Added the same latched
esp_sleep_get_gpio_wakeup_status() check the other buttons already use
for exactly this reason.
This commit is contained in:
2026-07-19 12:53:26 -04:00
parent bb4f473bfa
commit 3868d357ff
22 changed files with 521 additions and 351 deletions
+3 -2
View File
@@ -79,8 +79,9 @@ sequenceDiagram
- Deep sleep for the server-configured interval on success, or a
shorter retry interval on any failure.
The factory-reset button (hold 10s) is checked earlier, before any of
this -- see [`firmware/README.md`](../firmware/README.md#resetting-to-provisioning-mode).
The menu/reset button's soft-reset and factory-reset tiers (held ~3s
or ~15s) are handled earlier, before any of this, and never return --
see [`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
See [`docs/hardware.md`](hardware.md) for wiring and
[`server/README.md`](../server/README.md) for the server side.
+20 -10
View File
@@ -33,20 +33,30 @@ 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).
- **Manage (GPIO1)**: a normal press overlays a "scan to manage" QR code
on the current photo for 30 seconds, linking to the server's config
page; see
[`firmware/README.md`](../firmware/README.md#scanning-to-manage-the-queue).
- **Back photo (GPIO0)**: a normal press returns to the previously-shown
photo; see
[`firmware/README.md`](../firmware/README.md#going-back-to-the-previous-photo).
- **Menu / reset (GPIO1)**: one button, three actions by hold duration --
a quick press overlays a "scan to manage" QR code on the current photo
for 30 seconds; holding ~3s then releasing soft-resets the device
(config kept); holding ~15s factory-resets it (clears WiFi/server
config, reprovisions); see
[`firmware/README.md`](../firmware/README.md#managing-the-queue-soft-resetting-and-factory-resetting).
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.
All three 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. Also, not
incidentally, GPIO 0-7 is *all* the deep-sleep-wakeup-capable pins this
chip has (`SOC_RTCIO_PIN_COUNT` is 8) -- worth knowing if you're
targeting a compact board like the Seeed XIAO ESP32-C6, which only
breaks out 3 of them (GPIO0/1/2). These three buttons were deliberately
designed to fit exactly that budget: two dedicated pins for the
actions where instant, unambiguous response matters most (next, back),
and everything else folded onto the third pin via hold duration instead
of needing its own pin.
A couple of things worth knowing if you pick different pins:
+52 -30
View File
@@ -44,10 +44,11 @@ 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 |
| `FRAME_MANAGE_BUTTON_GPIO` | 1 | "Scan to manage" button GPIO (-1 to disable). Must be 0-7 |
| `FRAME_NEXT_BUTTON_GPIO` | 2 | Next-photo button GPIO (-1 to disable). Must be 0-7 (ESP32-C6's deep-sleep-wakeup-capable pins) |
| `FRAME_BACK_BUTTON_GPIO` | 0 | Back-photo button GPIO (-1 to disable). Must be 0-7 |
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset |
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
Under **E-Paper Display (epd7in3e) Configuration**: SPI/GPIO pin
assignments and SPI clock speed -- see
@@ -128,24 +129,41 @@ trusted-LAN behavior from before.
## 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
active-low, same wiring style as the other buttons). 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.
needed, 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.
## Scanning to manage the queue
## Going back to the previous photo
Wire a momentary push button between GPIO1 and GND (same wiring style as
the other two buttons). A press wakes the device and overlays several
corners of whatever photo is currently showing, leaving the middle of
the photo visible and unchanged:
Wire a momentary push button between GPIO0 and GND (same wiring style as
the other buttons). A press wakes the device (if asleep) and tells the
server to return to whichever photo was showing immediately before the
current one, regardless of whether it got there by the normal timer or
the next-photo button. Pressing next afterwards returns to where you
were before pressing back -- it's a real undo, not a separate "recently
shown" list. See `FRAME_BACK_BUTTON_GPIO` above to change the pin or
disable the feature.
If there's nothing to go back to yet (freshly provisioned, or you've
already gone back as far as there is history), it's a no-op -- the
current photo stays exactly as it was, no flash on the panel.
## Managing the queue, soft-resetting, and factory-resetting
One more button, wired between GPIO1 and GND (same wiring style as the
other buttons), covers three actions -- disambiguated purely by how
long it's held:
**A quick press** wakes the device and overlays several corners of
whatever photo is currently showing, leaving the middle of the photo
visible and unchanged:
- **Top-right**: a QR code -- "SCAN TO MANAGE" -- linking to the
server's config page.
@@ -163,27 +181,31 @@ has identified labeled right next to their face in the photo (skipped
for faces Immich hasn't been told a name for; no face detection happens
on the device or the server, this is entirely Immich's own People
feature). A third press exits immediately rather than waiting out the
30-second timer. See `FRAME_MANAGE_BUTTON_GPIO` above to change the pin
or disable the feature.
30-second timer. Holding the button during this stage doesn't trigger
either reset tier below -- the hold-duration read only ever happens
once, right when the device first wakes, before any menu is shown.
The device stays awake the whole time (up to three physical refreshes:
the base overlay, the escalated one, and reverting), so this costs
meaningfully more power than a normal wake -- expected for a deliberate,
occasional action, same tradeoff as the other two buttons.
The device stays awake for the whole menu interaction (up to three
physical refreshes: the base overlay, the escalated one, and
reverting), so this costs meaningfully more power than a normal wake --
expected for a deliberate, occasional action, same tradeoff as the
other buttons.
## Resetting to provisioning mode
**Holding it ~3 seconds, then releasing** soft-resets the device --
`esp_restart()`, keeping the stored WiFi/server config. Useful for
recovering a hung device without losing setup.
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.
**Holding it ~15 seconds** (whether or not you're still holding it --
this fires immediately, it doesn't wait for release) clears the stored
WiFi/server config and restarts into provisioning. From either power-on
or while the device is deep-asleep, since this GPIO is armed as a
wakeup source.
Without the button wired up (or with `FRAME_RESET_BUTTON_GPIO` set to
See `FRAME_COMBO_BUTTON_GPIO`, `FRAME_COMBO_SOFT_RESET_HOLD_MS`, and
`FRAME_COMBO_FACTORY_RESET_HOLD_MS` above to change the pin or hold
durations, or disable all three actions.
Without the button wired up (or with `FRAME_COMBO_BUTTON_GPIO` set to
`-1`), reconfiguring still works by erasing the NVS partition over USB:
```
+1 -1
View File
@@ -1,4 +1,4 @@
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c reset_button.c next_button.c manage_button.c manage_qr_overlay.c
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio
EMBED_FILES root.html
EMBED_TXTFILES certs/tools_server_ca.pem)
+55 -36
View File
@@ -83,30 +83,6 @@ 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
@@ -117,23 +93,66 @@ menu "ESPresso Frame Configuration"
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.
it. Must be GPIO 0-7 -- the only pins the ESP32-C6 can use as
a deep-sleep GPIO 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.
config FRAME_MANAGE_BUTTON_GPIO
int "Manage button GPIO (-1 to disable)"
config FRAME_BACK_BUTTON_GPIO
int "Back-photo button GPIO (-1 to disable)"
default 0
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 return to the previously-current photo immediately
(POST /frame/back), and displays it. Pressing next
afterwards returns to where you were before pressing back.
Must be GPIO 0-7 for the same deep-sleep-wakeup reason as
FRAME_NEXT_BUTTON_GPIO above; defaults to a different pin
than the other buttons. Set to -1 to disable the feature.
config FRAME_COMBO_BUTTON_GPIO
int "Menu/reset button GPIO (-1 to disable)"
default 1
range -1 7
help
Button wired between this GPIO and GND (active-low, internal
pull-up enabled in firmware -- no external resistor needed).
Pressing it wakes the device (if asleep), displays the
current photo with a small "scan to manage" QR code overlaid
in the top-right corner (linking to the tools server's config
page) for 30 seconds, then reverts to the plain photo. Must
be GPIO 0-7 for the same deep-sleep-wakeup reason as
FRAME_RESET_BUTTON_GPIO above; defaults to a different pin
than the other two buttons. Set to -1 to disable the feature.
One pin, three actions depending on how long it's held:
a quick press shows the management menu (same as before);
holding it FRAME_COMBO_SOFT_RESET_HOLD_MS then releasing
soft-resets the device (reboots, keeps the stored WiFi/
server config); holding it all the way to
FRAME_COMBO_FACTORY_RESET_HOLD_MS clears the stored config
and restarts into provisioning, regardless of whether it's
released yet. Must be GPIO 0-7 for the same deep-sleep-
wakeup reason as FRAME_NEXT_BUTTON_GPIO above; defaults to
a different pin than the other buttons. Set to -1 to
disable the feature entirely (also disables the management
menu, both reset tiers, and factory-reset-via-button --
reconfiguring then only works by erasing NVS over USB, see
firmware/README.md).
config FRAME_COMBO_SOFT_RESET_HOLD_MS
int "Soft-reset hold duration (ms)"
default 3000
depends on FRAME_COMBO_BUTTON_GPIO >= 0
help
How long the menu/reset button must be held before releasing
it triggers a soft reset (reboot, config kept). Long enough
to be clearly distinct from a quick menu-opening press.
config FRAME_COMBO_FACTORY_RESET_HOLD_MS
int "Factory-reset hold duration (ms)"
default 15000
depends on FRAME_COMBO_BUTTON_GPIO >= 0
help
How long the menu/reset button must be held continuously
before the device clears its stored config and reboots into
provisioning, regardless of release. Comfortably longer than
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
confused for each other.
endmenu
+75
View File
@@ -0,0 +1,75 @@
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "back_button.h"
static const char *TAG = "back_button";
#if CONFIG_FRAME_BACK_BUTTON_GPIO >= 0
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
#define BACK_BUTTON_DEBOUNCE_MS 20
#define BACK_BUTTON_DEBOUNCE_CHECKS 3
void back_button_init(void)
{
/* See next_button.c's next_button_init() for why this has to run
* before gpio_config() -- a deep sleep with this pin armed as a
* wakeup source leaves it "held," and nothing un-holds it on wake
* except explicitly asking. */
gpio_hold_dis(BACK_BUTTON_GPIO);
gpio_config_t io_conf = {
.pin_bit_mask = 1ULL << BACK_BUTTON_GPIO,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
};
gpio_config(&io_conf);
/* See next_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 << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
bool back_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 << BACK_BUTTON_GPIO)) {
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
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(BACK_BUTTON_GPIO) != 0) {
return false;
}
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */
}
}
ESP_LOGI(TAG, "Back-photo button held during power-on, going back");
return true;
}
#else
void back_button_init(void) {}
bool back_button_check(void) { return false; }
#endif
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <stdbool.h>
/**
* Configures the back-photo button GPIO (CONFIG_FRAME_BACK_BUTTON_GPIO,
* active-low with internal pull-up) and arms it as a deep-sleep wakeup
* source, same as next_button_init(). Call once, early in app_main(),
* before the device might enter deep sleep.
*
* A no-op if CONFIG_FRAME_BACK_BUTTON_GPIO is negative (button disabled).
*/
void back_button_init(void);
/**
* Returns whether the back-photo button is currently held, debounced with
* a couple of short re-checks to reject noise. Same immediate-response
* reasoning as the next-photo button -- no long hold-to-confirm gate.
*/
bool back_button_check(void);
+93
View File
@@ -0,0 +1,93 @@
#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 "combo_button.h"
static const char *TAG = "combo_button";
#if CONFIG_FRAME_COMBO_BUTTON_GPIO >= 0
#define COMBO_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_COMBO_BUTTON_GPIO)
#define COMBO_BUTTON_POLL_MS 100
void combo_button_init(void)
{
/* See next_button.c's next_button_init() for why this has to run
* before gpio_config() -- a deep sleep with this pin armed as a
* wakeup source leaves it "held," and nothing un-holds it on wake
* except explicitly asking. */
gpio_hold_dis(COMBO_BUTTON_GPIO);
gpio_config_t io_conf = {
.pin_bit_mask = 1ULL << COMBO_BUTTON_GPIO,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
};
gpio_config(&io_conf);
/* See next_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 << COMBO_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
bool combo_button_check(void)
{
/* A quick tap can easily release before we get here (~tens to
* hundreds of ms into boot, same issue the other buttons already
* hit and fixed -- confirmed on hardware: a fast press was being
* missed entirely with just a live level check here). The wakeup
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it still confirms this pin
* caused the wake even if it's since been released -- in which case
* the poll loop below simply measures 0ms held, correctly resolving
* to a quick press rather than "not pressed at all." */
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << COMBO_BUTTON_GPIO);
if (!caused_wake && gpio_get_level(COMBO_BUTTON_GPIO) != 0) {
return false; /* not pressed, and didn't cause this wake either */
}
ESP_LOGI(TAG, "Combo button held -- quick press for menu, %dms for soft reset, %dms for factory reset",
CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS, CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
int elapsed_ms = 0;
while (gpio_get_level(COMBO_BUTTON_GPIO) == 0) {
vTaskDelay(pdMS_TO_TICKS(COMBO_BUTTON_POLL_MS));
elapsed_ms += COMBO_BUTTON_POLL_MS;
if (elapsed_ms >= CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS) {
/* Fires immediately, doesn't wait for release -- same
* convention as the old dedicated reset button. */
ESP_LOGW(TAG, "Held %dms, clearing config and restarting into provisioning",
CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS);
frame_config_clear();
esp_restart();
}
}
if (elapsed_ms >= CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS) {
ESP_LOGW(TAG, "Held %dms and released, soft-restarting (config kept)", elapsed_ms);
esp_restart();
}
ESP_LOGI(TAG, "Quick press (%dms), showing management menu", elapsed_ms);
return true;
}
bool combo_button_is_pressed(void)
{
return gpio_get_level(COMBO_BUTTON_GPIO) == 0;
}
#else
void combo_button_init(void) {}
bool combo_button_check(void) { return false; }
bool combo_button_is_pressed(void) { return false; }
#endif
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <stdbool.h>
/**
* Configures the combined menu/reset button GPIO
* (CONFIG_FRAME_COMBO_BUTTON_GPIO, active-low with internal pull-up) and
* arms it as a deep-sleep wakeup source, same as the other buttons. Call
* once, early in app_main(), before the device might enter deep sleep.
*
* A no-op if CONFIG_FRAME_COMBO_BUTTON_GPIO is negative (button disabled).
*/
void combo_button_init(void);
/**
* Checks the combined menu/reset button and acts on how long it was
* held, evaluated once per wake:
* - Not pressed: returns false immediately.
* - Released before CONFIG_FRAME_COMBO_SOFT_RESET_HOLD_MS (a quick
* press): returns true -- caller should show the management menu.
* - Released between the soft-reset and factory-reset thresholds: a
* soft reset (esp_restart(), stored WiFi/server config kept) --
* never returns.
* - Held through CONFIG_FRAME_COMBO_FACTORY_RESET_HOLD_MS: a factory
* reset (frame_config_clear() + esp_restart(), fires immediately
* without waiting for release) -- never returns.
*/
bool combo_button_check(void);
/**
* Returns whether the combined button is currently held, with no
* debounce and no hold-duration interpretation -- a bare live level
* check. Used only by the already-shipped escalating-menu polling in
* frame_client.c (wait_for_button_press()) to detect a further press
* while the menu is already showing; deliberately never reused for
* reset detection, so holding the button too long while escalating the
* menu can't be misread as a reset request.
*/
bool combo_button_is_pressed(void);
+38 -29
View File
@@ -14,7 +14,7 @@
#include "epd7in3e.h"
#include "status_screen.h"
#include "manage_qr_overlay.h"
#include "manage_button.h"
#include "combo_button.h"
#include "frame_client.h"
@@ -487,22 +487,31 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
return (size_t)n;
}
/* GETs /frame/image (or, if force_advance, POSTs /frame/advance to skip
* ahead immediately) and streams the response directly into the panel,
* splicing in overlay's pixels (if non-NULL) as it streams. Returning
* non-ESP_OK means the panel was never actually refreshed --
* epd_display_stream() (see epd7in3e.c) refuses to trigger a physical
* refresh on a short/wrong-size stream, so a failure here always leaves
* the visible screen exactly as it was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, bool force_advance,
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
* /frame/back to force a move in either direction (FETCH_ADVANCE /
* FETCH_BACK -- the next-photo / back-photo buttons), and streams the
* response directly into the panel, splicing in overlay's pixels (if
* non-NULL) as it streams. Returning non-ESP_OK means the panel was
* never actually refreshed -- epd_display_stream() (see epd7in3e.c)
* refuses to trigger a physical refresh on a short/wrong-size stream,
* so a failure here always leaves the visible screen exactly as it
* was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action,
const manage_overlay_set_t *overlay)
{
const char *path = "frame/image";
if (action == FETCH_ADVANCE) {
path = "frame/advance";
} else if (action == FETCH_BACK) {
path = "frame/back";
}
char url[256];
build_url(url, sizeof(url), cfg, force_advance ? "frame/advance" : "frame/image");
build_url(url, sizeof(url), cfg, path);
esp_http_client_config_t config = {
.url = url,
.method = force_advance ? HTTP_METHOD_POST : HTTP_METHOD_GET,
.method = action == FETCH_NORMAL ? HTTP_METHOD_GET : HTTP_METHOD_POST,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
.cert_pem = tools_server_ca_pem_start,
};
@@ -571,14 +580,14 @@ static bool wait_for_button_press(uint32_t timeout_ms)
vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_POLL_MS));
elapsed_ms += MANAGE_MENU_POLL_MS;
if (!manage_button_is_pressed()) {
if (!combo_button_is_pressed()) {
continue;
}
vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_DEBOUNCE_MS));
if (!manage_button_is_pressed()) {
if (!combo_button_is_pressed()) {
continue; /* noise, not a real press */
}
while (manage_button_is_pressed()) {
while (combo_button_is_pressed()) {
vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_POLL_MS));
}
return true;
@@ -588,10 +597,10 @@ static bool wait_for_button_press(uint32_t timeout_ms)
/* Builds and shows one level of the manage menu: level 1 is the base
* overlay (management QR + location/date/share-QR wherever the server
* had that data); level 2 adds named-face labels on top. force_advance
* only applies at level 1 -- escalating to level 2 redisplays the same
* photo, so it never re-advances. */
static esp_err_t show_menu_level(const frame_config_t *cfg, bool force_advance, int level)
* had that data); level 2 adds named-face labels on top. action only
* applies at level 1 -- escalating to level 2 redisplays the same
* photo, so it never re-advances/-backs. */
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level)
{
char management_url[256];
build_url(management_url, sizeof(management_url), cfg, "");
@@ -626,7 +635,7 @@ static esp_err_t show_menu_level(const frame_config_t *cfg, bool force_advance,
return err;
}
err = fetch_and_display(cfg, force_advance, &overlay);
err = fetch_and_display(cfg, action, &overlay);
manage_overlay_free(&overlay);
return err;
}
@@ -641,13 +650,13 @@ static esp_err_t show_menu_level(const frame_config_t *cfg, bool force_advance,
* that (escalating, or the final revert) are logged but don't count as
* an overall failure -- something was already shown successfully, which
* was the point of the button. */
static esp_err_t run_management_menu(const frame_config_t *cfg, bool force_advance)
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
{
int level = 1;
esp_err_t err = show_menu_level(cfg, force_advance, level);
esp_err_t err = show_menu_level(cfg, action, level);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
return fetch_and_display(cfg, force_advance, NULL);
return fetch_and_display(cfg, action, NULL);
}
for (;;) {
@@ -657,14 +666,14 @@ static esp_err_t run_management_menu(const frame_config_t *cfg, bool force_advan
break; /* timeout at any level, or a press while already maxed out -- exit */
}
level++;
esp_err_t level_err = show_menu_level(cfg, false, level);
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level);
if (level_err != ESP_OK) {
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
break;
}
}
esp_err_t revert_err = fetch_and_display(cfg, false, NULL);
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, NULL);
if (revert_err != ESP_OK) {
ESP_LOGW(TAG, "Failed to revert management overlay (%s)", esp_err_to_name(revert_err));
}
@@ -674,15 +683,15 @@ static esp_err_t run_management_menu(const frame_config_t *cfg, bool force_advan
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
* show_management_qr -- the escalating manage menu (see
* run_management_menu()). */
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, bool force_advance, bool show_management_qr)
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
if (!show_management_qr) {
return fetch_and_display(cfg, force_advance, NULL);
return fetch_and_display(cfg, action, NULL);
}
return run_management_menu(cfg, force_advance);
return run_management_menu(cfg, action);
}
void frame_client_run(const frame_config_t *cfg, bool force_advance, bool show_management_qr)
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
esp_err_t epd_err = epd_init();
bool have_display = (epd_err == ESP_OK);
@@ -716,7 +725,7 @@ void frame_client_run(const frame_config_t *cfg, bool force_advance, bool show_m
* worth it to stop false-failing on the common case. */
bool image_ok = true;
if (have_display) {
esp_err_t fetch_err = run_fetch_cycle(cfg, force_advance, show_management_qr);
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr);
image_ok = (fetch_err == ESP_OK);
if (!image_ok) {
/* epd_display_stream() never triggers a physical refresh on a
+17 -9
View File
@@ -5,6 +5,20 @@
#include "esp_err.h"
#include "wifi_provisioning.h"
/**
* Which photo-fetch behavior this wake cycle should use -- normally the
* idempotent GET /frame/image (the server decides on its own whether to
* advance, based on its configured refresh interval, so a plain
* wake/reboot never skips a photo just by asking), or POST
* /frame/advance / POST /frame/back to force a move in either direction
* (the next-photo / back-photo buttons).
*/
typedef enum {
FETCH_NORMAL,
FETCH_ADVANCE,
FETCH_BACK,
} fetch_action_t;
/**
* Connects to the home WiFi network described by cfg, retrying up to
* CONFIG_FRAME_STA_CONNECT_MAX_RETRIES times with a per-attempt timeout of
@@ -15,14 +29,8 @@ 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.
*
* 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.
* cfg->toolsserver per `action` (see fetch_action_t), display it, and
* deep-sleep until the next refresh.
*
* If show_management_qr is true (the manage button was held), the
* displayed photo gets a small "scan to manage" QR overlay in the
@@ -30,4 +38,4 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg);
* seconds (the device stays awake), then reverted back to the plain
* photo before proceeding to the normal sleep-interval logic.
*/
void frame_client_run(const frame_config_t *cfg, bool force_advance, bool show_management_qr);
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr);
+15 -8
View File
@@ -7,9 +7,9 @@
#include "wifi_provisioning.h"
#include "frame_client.h"
#include "reset_button.h"
#include "next_button.h"
#include "manage_button.h"
#include "back_button.h"
#include "combo_button.h"
static const char *TAG = "main";
@@ -36,20 +36,27 @@ void app_main(void)
* windows), then checks them -- covers both "held while asleep, just
* woke us up" and "held while powering on" the same way, since both
* look identical from here: the pin is just low right now. */
reset_button_init();
next_button_init();
manage_button_init();
reset_button_check(); /* clears config + restarts if held the full duration; never returns in that case */
back_button_init();
combo_button_init();
bool force_advance = next_button_check();
bool show_management_qr = manage_button_check();
bool next_pressed = next_button_check();
bool back_pressed = back_button_check();
/* Next takes priority over back if somehow both read pressed at once
* (e.g. both held through a power-on) -- an arbitrary but
* deterministic tie-break, not expected to matter in practice. */
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL;
/* Soft-resets or clears config + restarts internally for a medium/
* long hold and never returns in those cases -- only returns here
* for "not pressed" (false) or "quick press" (true, show the menu). */
bool show_management_qr = combo_button_check();
frame_config_t cfg;
esp_err_t cfg_err = frame_config_load(&cfg);
if (cfg_err == ESP_OK) {
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
if (frame_wifi_connect_sta(&cfg) == ESP_OK) {
frame_client_run(&cfg, force_advance, show_management_qr);
frame_client_run(&cfg, action, show_management_qr);
return; /* frame_client_run currently never returns */
}
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
-82
View File
@@ -1,82 +0,0 @@
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "manage_button.h"
static const char *TAG = "manage_button";
#if CONFIG_FRAME_MANAGE_BUTTON_GPIO >= 0
#define MANAGE_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_MANAGE_BUTTON_GPIO)
#define MANAGE_BUTTON_DEBOUNCE_MS 20
#define MANAGE_BUTTON_DEBOUNCE_CHECKS 3
void manage_button_init(void)
{
/* Every deep sleep with this pin armed as a wakeup source leaves it
* "held" (ESP-IDF locks the pin's pull/config across the sleep
* transition -- see esp_sleep_gpio_wakeup_prepare_on_hp_periph_powerdown()
* in sleep_modes.c) and, confirmed on hardware, never un-holds it on
* wake -- the application has to. Left held, live gpio_get_level()
* reads stay frozen at whatever level the pin had the moment sleep
* began (idle/unpressed, almost always), which looks identical to
* "not pressed" and silently breaks any live poll for a NEW press
* later in the same session -- exactly what the manage menu's
* escalation needs. Must run before gpio_config() below. */
gpio_hold_dis(MANAGE_BUTTON_GPIO);
gpio_config_t io_conf = {
.pin_bit_mask = 1ULL << MANAGE_BUTTON_GPIO,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
};
gpio_config(&io_conf);
/* See reset_button.c for why this API (not ext1) -- it manages the
* pull resistor across the sleep transition itself, so the pin
* doesn't float and wake the device spuriously. */
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << MANAGE_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
bool manage_button_check(void)
{
/* See next_button.c for why the latched wakeup status is checked
* first: a quick tap can release before a live gpio_get_level() call
* this far into boot would still see it held, even though it's what
* woke the device. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << MANAGE_BUTTON_GPIO)) {
ESP_LOGI(TAG, "Manage button caused this wake, showing management QR");
return true;
}
if (gpio_get_level(MANAGE_BUTTON_GPIO) != 0) {
return false;
}
for (int i = 0; i < MANAGE_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(MANAGE_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(MANAGE_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */
}
}
ESP_LOGI(TAG, "Manage button held during power-on, showing management QR");
return true;
}
bool manage_button_is_pressed(void)
{
return gpio_get_level(MANAGE_BUTTON_GPIO) == 0;
}
#else
void manage_button_init(void) {}
bool manage_button_check(void) { return false; }
bool manage_button_is_pressed(void) { return false; }
#endif
-32
View File
@@ -1,32 +0,0 @@
#pragma once
#include <stdbool.h>
/**
* Configures the "manage" button GPIO (CONFIG_FRAME_MANAGE_BUTTON_GPIO,
* active-low with internal pull-up) and arms it as a deep-sleep wakeup
* source, same as reset_button_init()/next_button_init(). Call once,
* early in app_main(), before the device might enter deep sleep.
*
* A no-op if CONFIG_FRAME_MANAGE_BUTTON_GPIO is negative (button disabled).
*/
void manage_button_init(void);
/**
* Returns whether the manage button is currently held, debounced with a
* couple of short re-checks to reject noise. Like the next-photo button,
* there's no long hold-to-confirm gate -- showing the management QR is
* low-stakes and should feel immediate.
*/
bool manage_button_check(void);
/**
* Bare, undebounced "is the pin low right now" read -- unlike
* manage_button_check(), this doesn't consult the latched deep-sleep
* wakeup status (only meaningful once, immediately after waking from
* sleep) and isn't meant to detect what woke the device. Used for
* polling for a subsequent press while already awake and the manage
* overlay is up (see frame_client.c's wait_for_button_press()), which
* does its own debounce/release-wait around repeated calls to this.
*/
bool manage_button_is_pressed(void);
+11 -7
View File
@@ -17,10 +17,11 @@ static const char *TAG = "next_button";
void next_button_init(void)
{
/* See manage_button.c's manage_button_init() for why this has to run
* before gpio_config() -- a deep sleep with this pin armed as a
* wakeup source leaves it "held," and nothing un-holds it on wake
* except explicitly asking. */
/* Every deep sleep with this pin armed as a wakeup source leaves it
* "held" (ESP-IDF locks the pin's pull/config across the sleep
* transition -- see esp_sleep_gpio_wakeup_prepare_on_hp_periph_powerdown()
* in sleep_modes.c) and, confirmed on hardware, never un-holds it on
* wake -- the application has to. Must run before gpio_config() below. */
gpio_hold_dis(NEXT_BUTTON_GPIO);
gpio_config_t io_conf = {
@@ -30,9 +31,12 @@ void next_button_init(void)
};
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. */
/* 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). This GPIO-wakeup
* variant manages the pull resistor itself across the sleep
* transition. */
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
+5 -5
View File
@@ -5,8 +5,8 @@
/**
* 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.
* source. 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).
*/
@@ -14,8 +14,8 @@ 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.
* a couple of short re-checks to reject noise. 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);
-74
View File
@@ -1,74 +0,0 @@
#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)
{
/* See manage_button.c's manage_button_init() for why this has to run
* before gpio_config() -- a deep sleep with this pin armed as a
* wakeup source leaves it "held," and nothing un-holds it on wake
* except explicitly asking. */
gpio_hold_dis(RESET_BUTTON_GPIO);
gpio_config_t io_conf = {
.pin_bit_mask = 1ULL << RESET_BUTTON_GPIO,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
};
gpio_config(&io_conf);
/* Not esp_sleep_enable_ext1_wakeup_io(): its internal pull resistors
* don't hold once the RTC_PERIPH domain powers down for deep sleep, so
* the pin floats and reads spuriously low, waking the device instantly
* on every sleep entry (confirmed on hardware -- boot-looped every
* ~27s, the length of one fetch/display cycle, instead of sleeping for
* the configured interval). This GPIO-wakeup variant manages the pull
* resistor itself across the sleep transition. */
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << RESET_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
void reset_button_check(void)
{
if (gpio_get_level(RESET_BUTTON_GPIO) != 0) {
return; /* not pressed */
}
ESP_LOGI(TAG, "Reset button held -- hold for %dms to clear config and reprovision",
CONFIG_FRAME_RESET_BUTTON_HOLD_MS);
int elapsed_ms = 0;
while (elapsed_ms < CONFIG_FRAME_RESET_BUTTON_HOLD_MS) {
vTaskDelay(pdMS_TO_TICKS(RESET_BUTTON_POLL_MS));
elapsed_ms += RESET_BUTTON_POLL_MS;
if (gpio_get_level(RESET_BUTTON_GPIO) != 0) {
ESP_LOGI(TAG, "Reset button released early, continuing normal boot");
return;
}
}
ESP_LOGW(TAG, "Reset button held for %dms, clearing config and restarting into provisioning",
CONFIG_FRAME_RESET_BUTTON_HOLD_MS);
frame_config_clear();
esp_restart();
}
#else
void reset_button_init(void) {}
void reset_button_check(void) {}
#endif
-22
View File
@@ -1,22 +0,0 @@
#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);
+11 -1
View File
@@ -62,7 +62,17 @@ algorithm itself -- it just streams the response straight to the panel.
- `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`).
button (see `firmware/README.md`). Every photo actually displayed this
way (or via the normal timer-based advance) is pushed onto a bounded
history (`app/photo_queue.py`, last 20) that `/frame/back` below can
return to.
- `POST /frame/back` -- returns to the previously-current photo (the
exact mirror of `/frame/advance`), and resets the interval clock from
now. A no-op (still 200, same photo) if there's no history yet.
Pressing advance afterwards returns to where you were before going
back -- it displaces the current photo onto the front of the upcoming
queue rather than discarding it. Same response shape as
`/frame/image`. Used by the device's back-photo button.
- `GET /frame/config` -- `{"refresh_interval_s": ...}`, polled by the frame
each wake alongside its reachability check
- `GET /frame/photo-info` -- `{"asset_id": ..., "location_line1": ... |
+1
View File
@@ -32,6 +32,7 @@ class FrameConfig(BaseModel):
queue: list[str] = []
queue_cursor: int = 0 # internal bookkeeping for sequential queue top-up; not user-facing
queue_target_len: int = 20 # how many upcoming photos to keep queued/shown in the web UI
history: list[str] = [] # bounded stack of previously-current asset ids, most recent last
def load() -> FrameConfig:
+21
View File
@@ -130,6 +130,7 @@ def api_config_save(
cfg.current_asset_set_at = 0.0
cfg.queue = []
cfg.queue_cursor = 0
cfg.history = []
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))
@@ -211,6 +212,26 @@ def frame_advance():
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
@app.post("/frame/back", dependencies=[Depends(require_access_token)])
def frame_back():
"""Returns to the previously-current photo (the mirror image of
/frame/advance -- see photo_queue.back_forced()), and resets the
interval clock from now. A no-op (still 200, current photo
unchanged) if there's no history to go back to -- same "always
returns something displayable" contract as /frame/advance, rather
than erroring. Used by the device's back-photo button."""
cfg = config.load()
_require_configured(cfg)
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
assets = _list_assets(client, cfg)
photo_queue.back_forced(cfg, assets)
config.save(cfg)
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
+44 -3
View File
@@ -1,4 +1,5 @@
"""Tracks which photo is currently displayed and what's queued up next.
"""Tracks which photo is currently displayed, what's queued up next, and
what's already been shown.
`current_asset_id` only ever changes two ways: the configured refresh
interval elapsing (`get_current`, called on every `GET /frame/image` --
@@ -12,6 +13,13 @@ rearrange, topped up (or trimmed) automatically to match
`cfg.queue_target_len` (user-configurable from the web UI) 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.
`history` is the mirror image of `queue`: every time `advance_forced`
actually changes `current_asset_id`, the old one is pushed onto
`history`. `back_forced` (the back-photo button) is the exact reverse of
`advance_forced` -- it pops `history` back into `current_asset_id` and
pushes the photo it's replacing onto the *front* of `queue`, so pressing
next afterwards lands you right back where you were.
"""
from __future__ import annotations
@@ -21,6 +29,8 @@ import time
from .config import FrameConfig
HISTORY_MAX_LEN = 20
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
valid_ids = {a["id"] for a in assets}
@@ -69,8 +79,16 @@ def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
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."""
resets the interval clock from now. Used by the explicit next-photo
action (POST /frame/advance) and by get_current() once the refresh
interval has elapsed -- always mutates cfg."""
if cfg.current_asset_id:
# Recorded regardless of *why* this advance happened (a manual
# next-press or the timer just elapsing) -- back should be able
# to undo either kind.
cfg.history.append(cfg.current_asset_id)
cfg.history = cfg.history[-HISTORY_MAX_LEN:]
_top_up(cfg, assets)
if cfg.queue:
cfg.current_asset_id = cfg.queue.pop(0)
@@ -86,6 +104,29 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
_top_up(cfg, assets)
def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
"""Unconditionally moves to the previously-current photo, the mirror
image of advance_forced() -- pops the most recent entry off history,
pushes the photo it's replacing onto the front of queue (so pressing
next afterwards returns to it), and resets the interval clock from
now. Skips over any history entries no longer in the album (deleted
since). Returns whether it actually moved -- False (history empty or
entirely stale) is a no-op, callers should still just display
whatever's current rather than treating it as an error. Used by the
back-photo button (POST /frame/back)."""
valid_ids = {a["id"] for a in assets}
while cfg.history:
previous_id = cfg.history.pop()
if previous_id not in valid_ids:
continue
if cfg.current_asset_id:
cfg.queue.insert(0, cfg.current_asset_id)
cfg.current_asset_id = previous_id
cfg.current_asset_set_at = time.time()
return True
return False
def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
"""Tops up or trims cfg.queue to match cfg.queue_target_len without
otherwise touching current_asset_id. Used by GET /api/queue so a