Add back-photo button; consolidate reset/manage onto one hold-duration button
Build and push server image / build-and-push (push) Successful in 32s
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:
@@ -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
@@ -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
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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": ... |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user