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 @@
+
+ Loading...Stats
+
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 --