Server: lifetime stats counters in a collapsed web UI section
Build and push server image / build-and-push (push) Successful in 34s
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 --
|
||||
|
||||
Reference in New Issue
Block a user