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:
+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