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.
157 lines
6.4 KiB
Python
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
|