Files
espresso_frame/server/app/photo_queue.py
T
tfaour 3868d357ff
Build and push server image / build-and-push (push) Successful in 32s
Add back-photo button; consolidate reset/manage onto one hold-duration button
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.
2026-07-19 12:53:26 -04:00

157 lines
6.4 KiB
Python

"""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` --
a no-op otherwise, so an unplanned device reboot just redisplays the same
photo instead of silently skipping ahead) or an explicit forced advance
(`advance_forced`, called from `POST /frame/advance` -- the next-photo
button -- ignoring elapsed time).
`queue` is a small reorderable lookahead the web UI can preview and
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
import random
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}
cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids]
target = cfg.queue_target_len
if len(cfg.queue) > target:
# Target was lowered since this queue was built -- shrink it
# immediately rather than waiting for enough advances to consume
# the excess naturally.
cfg.queue = cfg.queue[:target]
return
needed = target - len(cfg.queue)
if needed <= 0 or not assets:
return
excluded = set(cfg.queue)
if cfg.current_asset_id:
excluded.add(cfg.current_asset_id)
if cfg.order == "shuffle":
candidates = [a["id"] for a in assets if a["id"] not in excluded]
cfg.queue.extend(random.sample(candidates, min(needed, len(candidates))))
return
# Sequential: walk the album starting at queue_cursor, at most one full
# pass, wrapping around. queue_cursor resumes right after wherever this
# pass stopped, whether or not it filled the queue (e.g. a small album
# where everything's already queued/current -- next call is then a
# cheap no-op scan until something's consumed).
n = len(assets)
cfg.queue_cursor %= n
added = 0
i = 0
for i in range(n):
if added >= needed:
break
asset_id = assets[(cfg.queue_cursor + i) % n]["id"]
if asset_id not in excluded:
cfg.queue.append(asset_id)
excluded.add(asset_id)
added += 1
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
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 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)
elif assets:
# Queue still empty after top-up (e.g. a single-photo album whose
# only asset is already current) -- keep showing what we have.
cfg.current_asset_id = assets[0]["id"]
cfg.current_asset_set_at = time.time()
# Refill back up to queue_target_len now that current_asset_id has
# changed -- otherwise the queue is left one short until the *next*
# advance, since the pop above consumes one of the items _top_up just
# added.
_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
change to the "upcoming photos to show" setting takes effect on page
load rather than waiting for the next natural advance."""
_top_up(cfg, assets)
def get_current(cfg: FrameConfig, assets: list[dict]) -> bool:
"""Time-based, idempotent path used by GET /frame/image. Advances only
if the current photo is unset/invalid or refresh_interval_s has
elapsed since it was set. Returns whether it changed anything, so the
caller knows whether to persist. Calling this repeatedly well within
the interval is a no-op both times -- what makes an unplanned device
reboot safe: it just re-reads the current photo instead of skipping
ahead, while a wake that lands after the interval has elapsed still
advances exactly once, even after a long time offline."""
valid_ids = {a["id"] for a in assets}
stale = (
not cfg.current_asset_id
or cfg.current_asset_id not in valid_ids
or (time.time() - cfg.current_asset_set_at) >= cfg.refresh_interval_s
)
if not stale:
return False
advance_forced(cfg, assets)
return True