diff --git a/docs/architecture.md b/docs/architecture.md index ae1994a..ebe7622 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/hardware.md b/docs/hardware.md index a16fe3d..1a8e2f2 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -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: diff --git a/firmware/README.md b/firmware/README.md index eb84103..b6bfb1e 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -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: ``` diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 34aeb49..e046e68 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -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) diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild index 025daac..849c8d0 100644 --- a/firmware/main/Kconfig.projbuild +++ b/firmware/main/Kconfig.projbuild @@ -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 diff --git a/firmware/main/back_button.c b/firmware/main/back_button.c new file mode 100644 index 0000000..6a30f44 --- /dev/null +++ b/firmware/main/back_button.c @@ -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 diff --git a/firmware/main/back_button.h b/firmware/main/back_button.h new file mode 100644 index 0000000..e927bcd --- /dev/null +++ b/firmware/main/back_button.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +/** + * 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); diff --git a/firmware/main/combo_button.c b/firmware/main/combo_button.c new file mode 100644 index 0000000..bcd7b21 --- /dev/null +++ b/firmware/main/combo_button.c @@ -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 diff --git a/firmware/main/combo_button.h b/firmware/main/combo_button.h new file mode 100644 index 0000000..1f66300 --- /dev/null +++ b/firmware/main/combo_button.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +/** + * 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); diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index 1b8df32..8cef606 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -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 diff --git a/firmware/main/frame_client.h b/firmware/main/frame_client.h index f9086dc..3577db4 100644 --- a/firmware/main/frame_client.h +++ b/firmware/main/frame_client.h @@ -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); diff --git a/firmware/main/main.c b/firmware/main/main.c index 9a49a47..faf44e2 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -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", diff --git a/firmware/main/manage_button.c b/firmware/main/manage_button.c deleted file mode 100644 index 689fd3a..0000000 --- a/firmware/main/manage_button.c +++ /dev/null @@ -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 diff --git a/firmware/main/manage_button.h b/firmware/main/manage_button.h deleted file mode 100644 index 27eea00..0000000 --- a/firmware/main/manage_button.h +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include - -/** - * 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); diff --git a/firmware/main/next_button.c b/firmware/main/next_button.c index 54746de..997e747 100644 --- a/firmware/main/next_button.c +++ b/firmware/main/next_button.c @@ -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); } diff --git a/firmware/main/next_button.h b/firmware/main/next_button.h index 6aa944f..6a50123 100644 --- a/firmware/main/next_button.h +++ b/firmware/main/next_button.h @@ -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); diff --git a/firmware/main/reset_button.c b/firmware/main/reset_button.c deleted file mode 100644 index b14cb31..0000000 --- a/firmware/main/reset_button.c +++ /dev/null @@ -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 diff --git a/firmware/main/reset_button.h b/firmware/main/reset_button.h deleted file mode 100644 index 11c7e86..0000000 --- a/firmware/main/reset_button.h +++ /dev/null @@ -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); diff --git a/server/README.md b/server/README.md index 6131d87..a835ac4 100644 --- a/server/README.md +++ b/server/README.md @@ -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": ... | diff --git a/server/app/config.py b/server/app/config.py index 89b7043..e082342 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -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: diff --git a/server/app/main.py b/server/app/main.py index 8f4da5b..4ac6021 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -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 = { diff --git a/server/app/photo_queue.py b/server/app/photo_queue.py index 60359fb..c3ea914 100644 --- a/server/app/photo_queue.py +++ b/server/app/photo_queue.py @@ -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