Server: lifetime stats counters in a collapsed web UI section
Build and push server image / build-and-push (push) Successful in 34s

New FrameStats (first_seen, device_wakes, photos_displayed,
photos_removed, battery_reports, recharge_cycles, ota_updates_applied,
config_saves), persisted alongside everything else in config.json.
Incremented at the existing route/photo_queue.py call sites that already
own each event -- no new instrumentation plumbing, no behavior depends
on these, purely informational. GET /api/stats serves them; the web UI
renders them into a native <details> "Stats" card (collapsed by default,
no JS needed for the expand/collapse), fetched once on page load like
the battery-history chart.

Verified: TestClient run through /frame/config (wakes + first_seen +
OTA-applied detection), /frame/battery (reports + recharge detection),
/api/config (saves); direct photo_queue.py unit checks for
advance/back/remove covering the "did the current photo actually
change" distinction (removing a queued-but-not-current photo bumps
photos_removed but not photos_displayed).
This commit is contained in:
2026-07-21 00:00:37 -04:00
parent 1b11067da7
commit 475888306e
6 changed files with 86 additions and 1 deletions
+6
View File
@@ -142,6 +142,12 @@ algorithm itself -- it just streams the response straight to the panel.
- `GET /api/battery-log` -- `{"log": [[timestamp, percent], ...]}`, the
full permanent battery history above; used by the web UI's "Battery
history" chart
- `GET /api/stats` -- lifetime, never-reset counters: `first_seen`,
`device_wakes`, `photos_displayed`, `photos_removed`,
`battery_reports`, `recharge_cycles`, `ota_updates_applied`,
`config_saves` (see `FrameStats` in `app/config.py`). Purely
informational -- nothing else reads these back -- shown in a
collapsed "Stats" section in the web UI
- `POST /api/firmware` -- multipart upload (`file`) of a built
`espresso_frame.bin`. Parses the embedded `esp_app_desc_t` (rejects
anything that isn't a valid image for this project) and stores it as
+17
View File
@@ -19,6 +19,21 @@ CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json"))
_lock = RLock()
class FrameStats(BaseModel):
"""Cumulative, lifetime counters -- purely informational, never read
back to drive any behavior, so there's no harm in them being a little
approximate at the edges. Shown in a collapsed "Stats" section in the
web UI (GET /api/stats). Never reset except by deleting config.json."""
first_seen: float = 0.0 # first time this frame ever checked in
device_wakes: int = 0 # wake cycles, counted once each via GET /frame/config
photos_displayed: int = 0 # times the current photo actually changed (any cause)
photos_removed: int = 0 # times a photo was permanently excluded from rotation
battery_reports: int = 0 # POST /frame/battery calls
recharge_cycles: int = 0 # times a battery recharge was detected
ota_updates_applied: int = 0 # times the device's reported firmware version changed
config_saves: int = 0 # POST /api/config calls
class FrameConfig(BaseModel):
immich_url: str = ""
immich_api_key: str = ""
@@ -84,6 +99,8 @@ class FrameConfig(BaseModel):
# (POST /api/firmware); "" = none uploaded yet.
firmware_available_version: str = ""
stats: FrameStats = FrameStats()
def load() -> FrameConfig:
with _lock:
+13
View File
@@ -198,7 +198,12 @@ def frame_config(request: Request):
with config.locked():
cfg = config.load()
cfg.last_seen = time.time()
if cfg.stats.first_seen == 0:
cfg.stats.first_seen = cfg.last_seen
cfg.stats.device_wakes += 1
if reported_version:
if cfg.device_firmware_version and reported_version != cfg.device_firmware_version:
cfg.stats.ota_updates_applied += 1
cfg.device_firmware_version = reported_version
config.save(cfg)
return {
@@ -283,10 +288,16 @@ def api_config_save(
cfg.quiet_hours_end = quiet_hours_end
if timezone in ALL_TIMEZONES:
cfg.timezone = timezone
cfg.stats.config_saves += 1
config.save(cfg)
return {"status": "saved"}
@app.get("/api/stats", dependencies=[Depends(require_access_token)])
def api_stats():
return config.load().stats.model_dump()
def _require_configured(cfg: config.FrameConfig) -> None:
if not cfg.immich_url or not cfg.immich_api_key:
raise HTTPException(400, "Immich URL/API key not configured yet")
@@ -404,11 +415,13 @@ def frame_battery(body: BatteryReport):
now = time.time()
with config.locked():
cfg = config.load()
cfg.stats.battery_reports += 1
if cfg.battery_history and body.percent >= cfg.battery_history[-1][1] + RECHARGE_JUMP_PCT:
# Percent jumped up meaningfully -- the battery was recharged
# (or swapped). Start a fresh discharge cycle so runtime and
# discharge-rate estimates never span a charge.
cfg.battery_history = []
cfg.stats.recharge_cycles += 1
cfg.battery_history.append([now, body.percent])
cfg.battery_history = cfg.battery_history[-BATTERY_HISTORY_MAX:]
cfg.battery_log.append([now, body.percent])
+4
View File
@@ -105,6 +105,7 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
# only asset is already current) -- keep showing what we have.
cfg.current_asset_id = assets[0]["id"]
cfg.current_asset_set_at = time.time()
cfg.stats.photos_displayed += 1
# 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
@@ -131,6 +132,7 @@ def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
cfg.queue.insert(0, cfg.current_asset_id)
cfg.current_asset_id = previous_id
cfg.current_asset_set_at = time.time()
cfg.stats.photos_displayed += 1
return True
return False
@@ -147,6 +149,7 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
changed as a result."""
if asset_id not in cfg.excluded_asset_ids:
cfg.excluded_asset_ids.append(asset_id)
cfg.stats.photos_removed += 1
cfg.queue = [a for a in cfg.queue if a != asset_id]
cfg.history = [a for a in cfg.history if a != asset_id]
@@ -164,6 +167,7 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
remaining = [a["id"] for a in assets if a["id"] not in excluded_ids]
cfg.current_asset_id = remaining[0] if remaining else ""
cfg.current_asset_set_at = time.time()
cfg.stats.photos_displayed += 1
_top_up(cfg, assets)
return True
+4 -1
View File
@@ -145,7 +145,7 @@
.icon-btn:hover { background: var(--surface-alt); }
.icon-btn:active { transform: scale(0.94); }
h2.card-title {
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
margin: 0 0 14px;
@@ -153,6 +153,9 @@
text-transform: uppercase;
color: var(--text-muted);
}
summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.card {
background: var(--surface);
+42
View File
@@ -105,6 +105,11 @@
<input type="file" id="firmware-file" accept=".bin">
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
</section>
<details class="card">
<summary class="card-title">Stats</summary>
<div id="stats-box"><p class="sub">Loading...</p></div>
</details>
</div>
</div>
@@ -609,8 +614,45 @@
}
}
function renderStats(stats) {
const el = document.getElementById('stats-box');
el.innerHTML = '';
const now = Date.now() / 1000;
const rows = [
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
['Wake cycles', stats.device_wakes],
['Photos displayed', stats.photos_displayed],
['Photos removed from rotation', stats.photos_removed],
['Battery reports received', stats.battery_reports],
['Battery recharge cycles', stats.recharge_cycles],
['OTA updates applied', stats.ota_updates_applied],
['Settings saved', stats.config_saves],
];
for (const [label, value] of rows) {
const p = document.createElement('p');
p.className = 'sub';
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
async function loadStats() {
const el = document.getElementById('stats-box');
try {
const resp = await fetch('/api/stats');
if (!resp.ok) {
el.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
renderStats(await resp.json());
} catch (e) {
el.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadQueue();
loadBatteryLog();
loadStats();
// Redraw the canvas chart (and the "Last seen" alert color) with the
// new theme's colors as soon as the toggle in the header is used --