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
|
- `GET /api/battery-log` -- `{"log": [[timestamp, percent], ...]}`, the
|
||||||
full permanent battery history above; used by the web UI's "Battery
|
full permanent battery history above; used by the web UI's "Battery
|
||||||
history" chart
|
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
|
- `POST /api/firmware` -- multipart upload (`file`) of a built
|
||||||
`espresso_frame.bin`. Parses the embedded `esp_app_desc_t` (rejects
|
`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
|
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()
|
_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):
|
class FrameConfig(BaseModel):
|
||||||
immich_url: str = ""
|
immich_url: str = ""
|
||||||
immich_api_key: str = ""
|
immich_api_key: str = ""
|
||||||
@@ -84,6 +99,8 @@ class FrameConfig(BaseModel):
|
|||||||
# (POST /api/firmware); "" = none uploaded yet.
|
# (POST /api/firmware); "" = none uploaded yet.
|
||||||
firmware_available_version: str = ""
|
firmware_available_version: str = ""
|
||||||
|
|
||||||
|
stats: FrameStats = FrameStats()
|
||||||
|
|
||||||
|
|
||||||
def load() -> FrameConfig:
|
def load() -> FrameConfig:
|
||||||
with _lock:
|
with _lock:
|
||||||
|
|||||||
@@ -198,7 +198,12 @@ def frame_config(request: Request):
|
|||||||
with config.locked():
|
with config.locked():
|
||||||
cfg = config.load()
|
cfg = config.load()
|
||||||
cfg.last_seen = time.time()
|
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 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
|
cfg.device_firmware_version = reported_version
|
||||||
config.save(cfg)
|
config.save(cfg)
|
||||||
return {
|
return {
|
||||||
@@ -283,10 +288,16 @@ def api_config_save(
|
|||||||
cfg.quiet_hours_end = quiet_hours_end
|
cfg.quiet_hours_end = quiet_hours_end
|
||||||
if timezone in ALL_TIMEZONES:
|
if timezone in ALL_TIMEZONES:
|
||||||
cfg.timezone = timezone
|
cfg.timezone = timezone
|
||||||
|
cfg.stats.config_saves += 1
|
||||||
config.save(cfg)
|
config.save(cfg)
|
||||||
return {"status": "saved"}
|
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:
|
def _require_configured(cfg: config.FrameConfig) -> None:
|
||||||
if not cfg.immich_url or not cfg.immich_api_key:
|
if not cfg.immich_url or not cfg.immich_api_key:
|
||||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||||
@@ -404,11 +415,13 @@ def frame_battery(body: BatteryReport):
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
with config.locked():
|
with config.locked():
|
||||||
cfg = config.load()
|
cfg = config.load()
|
||||||
|
cfg.stats.battery_reports += 1
|
||||||
if cfg.battery_history and body.percent >= cfg.battery_history[-1][1] + RECHARGE_JUMP_PCT:
|
if cfg.battery_history and body.percent >= cfg.battery_history[-1][1] + RECHARGE_JUMP_PCT:
|
||||||
# Percent jumped up meaningfully -- the battery was recharged
|
# Percent jumped up meaningfully -- the battery was recharged
|
||||||
# (or swapped). Start a fresh discharge cycle so runtime and
|
# (or swapped). Start a fresh discharge cycle so runtime and
|
||||||
# discharge-rate estimates never span a charge.
|
# discharge-rate estimates never span a charge.
|
||||||
cfg.battery_history = []
|
cfg.battery_history = []
|
||||||
|
cfg.stats.recharge_cycles += 1
|
||||||
cfg.battery_history.append([now, body.percent])
|
cfg.battery_history.append([now, body.percent])
|
||||||
cfg.battery_history = cfg.battery_history[-BATTERY_HISTORY_MAX:]
|
cfg.battery_history = cfg.battery_history[-BATTERY_HISTORY_MAX:]
|
||||||
cfg.battery_log.append([now, body.percent])
|
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.
|
# only asset is already current) -- keep showing what we have.
|
||||||
cfg.current_asset_id = assets[0]["id"]
|
cfg.current_asset_id = assets[0]["id"]
|
||||||
cfg.current_asset_set_at = time.time()
|
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
|
# Refill back up to queue_target_len now that current_asset_id has
|
||||||
# changed -- otherwise the queue is left one short until the *next*
|
# changed -- otherwise the queue is left one short until the *next*
|
||||||
# advance, since the pop above consumes one of the items _top_up just
|
# 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.queue.insert(0, cfg.current_asset_id)
|
||||||
cfg.current_asset_id = previous_id
|
cfg.current_asset_id = previous_id
|
||||||
cfg.current_asset_set_at = time.time()
|
cfg.current_asset_set_at = time.time()
|
||||||
|
cfg.stats.photos_displayed += 1
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -147,6 +149,7 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
|
|||||||
changed as a result."""
|
changed as a result."""
|
||||||
if asset_id not in cfg.excluded_asset_ids:
|
if asset_id not in cfg.excluded_asset_ids:
|
||||||
cfg.excluded_asset_ids.append(asset_id)
|
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.queue = [a for a in cfg.queue if a != asset_id]
|
||||||
cfg.history = [a for a in cfg.history 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]
|
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_id = remaining[0] if remaining else ""
|
||||||
cfg.current_asset_set_at = time.time()
|
cfg.current_asset_set_at = time.time()
|
||||||
|
cfg.stats.photos_displayed += 1
|
||||||
_top_up(cfg, assets)
|
_top_up(cfg, assets)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@
|
|||||||
.icon-btn:hover { background: var(--surface-alt); }
|
.icon-btn:hover { background: var(--surface-alt); }
|
||||||
.icon-btn:active { transform: scale(0.94); }
|
.icon-btn:active { transform: scale(0.94); }
|
||||||
|
|
||||||
h2.card-title {
|
h2.card-title, summary.card-title {
|
||||||
font-size: 14.5px;
|
font-size: 14.5px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
@@ -153,6 +153,9 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-muted);
|
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 {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
@@ -105,6 +105,11 @@
|
|||||||
<input type="file" id="firmware-file" accept=".bin">
|
<input type="file" id="firmware-file" accept=".bin">
|
||||||
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<details class="card">
|
||||||
|
<summary class="card-title">Stats</summary>
|
||||||
|
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</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();
|
loadQueue();
|
||||||
loadBatteryLog();
|
loadBatteryLog();
|
||||||
|
loadStats();
|
||||||
|
|
||||||
// Redraw the canvas chart (and the "Last seen" alert color) with the
|
// 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 --
|
// new theme's colors as soon as the toggle in the header is used --
|
||||||
|
|||||||
Reference in New Issue
Block a user