diff --git a/server/README.md b/server/README.md index c294764..8b8f5e9 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/app/config.py b/server/app/config.py index 0e4a168..6ad91c2 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -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: diff --git a/server/app/main.py b/server/app/main.py index 87d6625..163c099 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -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]) diff --git a/server/app/photo_queue.py b/server/app/photo_queue.py index d40df06..101195a 100644 --- a/server/app/photo_queue.py +++ b/server/app/photo_queue.py @@ -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 diff --git a/server/app/templates/base.html b/server/app/templates/base.html index f2d7309..180b7f2 100644 --- a/server/app/templates/base.html +++ b/server/app/templates/base.html @@ -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); diff --git a/server/app/templates/index.html b/server/app/templates/index.html index f75344e..1cebf45 100644 --- a/server/app/templates/index.html +++ b/server/app/templates/index.html @@ -105,6 +105,11 @@ + +
+ Stats +

Loading...

+
@@ -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 = '

Could not load.

'; + return; + } + renderStats(await resp.json()); + } catch (e) { + el.innerHTML = '

Could not load.

'; + } + } + 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 --