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

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

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

Fixed a fast-tap bug in combo_button_check() before shipping: it only
did a live gpio_get_level() read to decide whether the button was
pressed at all, so a press fast enough to already be released by the
time boot reached that check was missed entirely (treated as "never
pressed" rather than "quick press"). Added the same latched
esp_sleep_get_gpio_wakeup_status() check the other buttons already use
for exactly this reason.
This commit is contained in:
2026-07-19 12:53:26 -04:00
parent bb4f473bfa
commit 3868d357ff
22 changed files with 521 additions and 351 deletions
+44 -3
View File
@@ -1,4 +1,5 @@
"""Tracks which photo is currently displayed and what's queued up next.
"""Tracks which photo is currently displayed, what's queued up next, and
what's already been shown.
`current_asset_id` only ever changes two ways: the configured refresh
interval elapsing (`get_current`, called on every `GET /frame/image` --
@@ -12,6 +13,13 @@ rearrange, topped up (or trimmed) automatically to match
`cfg.queue_target_len` (user-configurable from the web UI) as it's
consumed. `queue_cursor` is separate, internal-only bookkeeping for where
sequential top-up resumes in the album -- not shown or reordered in the UI.
`history` is the mirror image of `queue`: every time `advance_forced`
actually changes `current_asset_id`, the old one is pushed onto
`history`. `back_forced` (the back-photo button) is the exact reverse of
`advance_forced` -- it pops `history` back into `current_asset_id` and
pushes the photo it's replacing onto the *front* of `queue`, so pressing
next afterwards lands you right back where you were.
"""
from __future__ import annotations
@@ -21,6 +29,8 @@ import time
from .config import FrameConfig
HISTORY_MAX_LEN = 20
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
valid_ids = {a["id"] for a in assets}
@@ -69,8 +79,16 @@ def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
"""Unconditionally moves to the next photo, ignoring elapsed time, and
resets the interval clock from now. Used only by the explicit
next-photo action (POST /frame/advance) -- always mutates cfg."""
resets the interval clock from now. Used by the explicit next-photo
action (POST /frame/advance) and by get_current() once the refresh
interval has elapsed -- always mutates cfg."""
if cfg.current_asset_id:
# Recorded regardless of *why* this advance happened (a manual
# next-press or the timer just elapsing) -- back should be able
# to undo either kind.
cfg.history.append(cfg.current_asset_id)
cfg.history = cfg.history[-HISTORY_MAX_LEN:]
_top_up(cfg, assets)
if cfg.queue:
cfg.current_asset_id = cfg.queue.pop(0)
@@ -86,6 +104,29 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
_top_up(cfg, assets)
def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
"""Unconditionally moves to the previously-current photo, the mirror
image of advance_forced() -- pops the most recent entry off history,
pushes the photo it's replacing onto the front of queue (so pressing
next afterwards returns to it), and resets the interval clock from
now. Skips over any history entries no longer in the album (deleted
since). Returns whether it actually moved -- False (history empty or
entirely stale) is a no-op, callers should still just display
whatever's current rather than treating it as an error. Used by the
back-photo button (POST /frame/back)."""
valid_ids = {a["id"] for a in assets}
while cfg.history:
previous_id = cfg.history.pop()
if previous_id not in valid_ids:
continue
if cfg.current_asset_id:
cfg.queue.insert(0, cfg.current_asset_id)
cfg.current_asset_id = previous_id
cfg.current_asset_set_at = time.time()
return True
return False
def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
"""Tops up or trims cfg.queue to match cfg.queue_target_len without
otherwise touching current_asset_id. Used by GET /api/queue so a