Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc65b19cf2 | ||
|
|
e1bca5a81a | ||
|
|
845e4f9509 | ||
|
|
02934b1d10 | ||
|
|
f24c3b9c8e | ||
|
|
38944a1287 | ||
|
|
5b4fdbe330 | ||
|
|
dcbc71e683 | ||
|
|
f4d2a23e8a | ||
|
|
55b53d5bb2 | ||
|
|
60fcfca4a0 |
+30
-7
@@ -1,3 +1,5 @@
|
|||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
#include "driver/gpio.h"
|
#include "driver/gpio.h"
|
||||||
#include "esp_adc/adc_cali_scheme.h"
|
#include "esp_adc/adc_cali_scheme.h"
|
||||||
#include "esp_adc/adc_oneshot.h"
|
#include "esp_adc/adc_oneshot.h"
|
||||||
@@ -11,7 +13,13 @@ static const char *TAG = "battery";
|
|||||||
#if CONFIG_FRAME_BATTERY_ADC_GPIO >= 0
|
#if CONFIG_FRAME_BATTERY_ADC_GPIO >= 0
|
||||||
|
|
||||||
#define BATTERY_ADC_GPIO CONFIG_FRAME_BATTERY_ADC_GPIO
|
#define BATTERY_ADC_GPIO CONFIG_FRAME_BATTERY_ADC_GPIO
|
||||||
#define BATTERY_SAMPLES 8
|
#define BATTERY_SAMPLES 16
|
||||||
|
/* Trimmed mean: the extreme BATTERY_TRIM samples on each end (regulator/
|
||||||
|
* RF transients, not the true resting voltage) are dropped before
|
||||||
|
* averaging the rest -- a plain average lets even one or two of those
|
||||||
|
* skew the result enough to read as a real percent change downstream
|
||||||
|
* (see the recharge-jump handling in routers/device.py). */
|
||||||
|
#define BATTERY_TRIM 3
|
||||||
/* The external divider halves the battery voltage (2x200k, per the
|
/* The external divider halves the battery voltage (2x200k, per the
|
||||||
* Seeed-documented XIAO wiring) so a full 4.2V cell reads ~2.1V at the
|
* Seeed-documented XIAO wiring) so a full 4.2V cell reads ~2.1V at the
|
||||||
* pin, inside the 12dB-attenuation ADC range. */
|
* pin, inside the 12dB-attenuation ADC range. */
|
||||||
@@ -34,6 +42,11 @@ static const struct {
|
|||||||
{ 3300, 5 }, { 3000, 0 },
|
{ 3300, 5 }, { 3000, 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static int int_cmp(const void *a, const void *b)
|
||||||
|
{
|
||||||
|
return *(const int *)a - *(const int *)b;
|
||||||
|
}
|
||||||
|
|
||||||
static int mv_to_percent(int mv)
|
static int mv_to_percent(int mv)
|
||||||
{
|
{
|
||||||
int n = sizeof(LIPO_CURVE) / sizeof(LIPO_CURVE[0]);
|
int n = sizeof(LIPO_CURVE) / sizeof(LIPO_CURVE[0]);
|
||||||
@@ -139,19 +152,17 @@ int battery_read_percent(void)
|
|||||||
ESP_LOGW(TAG, "ADC calibration unavailable, using nominal scaling");
|
ESP_LOGW(TAG, "ADC calibration unavailable, using nominal scaling");
|
||||||
}
|
}
|
||||||
|
|
||||||
int mv_sum = 0;
|
int mv_samples[BATTERY_SAMPLES];
|
||||||
int samples = 0;
|
int samples = 0;
|
||||||
for (int i = 0; i < BATTERY_SAMPLES; i++) {
|
for (int i = 0; i < BATTERY_SAMPLES; i++) {
|
||||||
int value;
|
int value;
|
||||||
if (calibrated) {
|
if (calibrated) {
|
||||||
if (adc_oneshot_get_calibrated_result(adc, cali, channel, &value) == ESP_OK) {
|
if (adc_oneshot_get_calibrated_result(adc, cali, channel, &value) == ESP_OK) {
|
||||||
mv_sum += value;
|
mv_samples[samples++] = value;
|
||||||
samples++;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (adc_oneshot_read(adc, channel, &value) == ESP_OK) {
|
if (adc_oneshot_read(adc, channel, &value) == ESP_OK) {
|
||||||
mv_sum += value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
|
mv_samples[samples++] = value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
|
||||||
samples++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +178,19 @@ int battery_read_percent(void)
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
int battery_mv = (mv_sum / samples) * BATTERY_DIVIDER_RATIO;
|
/* Only trim if there's enough left afterward to still be a
|
||||||
|
* meaningful average -- falls back to a plain average of whatever
|
||||||
|
* came in on a wake where most reads failed. */
|
||||||
|
qsort(mv_samples, samples, sizeof(int), int_cmp);
|
||||||
|
int trim = (samples > 2 * BATTERY_TRIM) ? BATTERY_TRIM : 0;
|
||||||
|
int mv_sum = 0;
|
||||||
|
int kept = 0;
|
||||||
|
for (int i = trim; i < samples - trim; i++) {
|
||||||
|
mv_sum += mv_samples[i];
|
||||||
|
kept++;
|
||||||
|
}
|
||||||
|
|
||||||
|
int battery_mv = (mv_sum / kept) * BATTERY_DIVIDER_RATIO;
|
||||||
if (battery_mv < BATTERY_MV_MIN || battery_mv > BATTERY_MV_MAX) {
|
if (battery_mv < BATTERY_MV_MIN || battery_mv > BATTERY_MV_MAX) {
|
||||||
ESP_LOGI(TAG, "Reading %dmV outside plausible battery range, ignoring", battery_mv);
|
ESP_LOGI(TAG, "Reading %dmV outside plausible battery range, ignoring", battery_mv);
|
||||||
return -1;
|
return -1;
|
||||||
|
|||||||
@@ -591,7 +591,12 @@ static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out
|
|||||||
|
|
||||||
uint32_t count = 0;
|
uint32_t count = 0;
|
||||||
json_extract_uint(body, "count", &count);
|
json_extract_uint(body, "count", &count);
|
||||||
if ((int)count > max_labels) {
|
/* Unsigned compare: casting count to int first let a server-supplied
|
||||||
|
* value >= 2^31 (still a perfectly ordinary decimal in the JSON) go
|
||||||
|
* negative, skipping this clamp entirely and driving the loop below
|
||||||
|
* with the full attacker/server-controlled count -- out[found] is a
|
||||||
|
* fixed MANAGE_FACE_LABELS_MAX-element caller stack array. */
|
||||||
|
if (count > (uint32_t)max_labels) {
|
||||||
count = (uint32_t)max_labels;
|
count = (uint32_t)max_labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.2.2
|
1.2.4
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import io
|
|||||||
|
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
from .image_pipeline import _placement_transform, logical_render_size, logical_to_native
|
from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size, logical_to_native
|
||||||
|
|
||||||
# Small caps, not arbitrary: each label is its own malloc'd overlay
|
# Small caps, not arbitrary: each label is its own malloc'd overlay
|
||||||
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
|
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
|
||||||
@@ -55,6 +55,8 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
|
|||||||
|
|
||||||
labels = []
|
labels = []
|
||||||
for face in named[:MAX_LABELED_FACES]:
|
for face in named[:MAX_LABELED_FACES]:
|
||||||
|
if not _has_bounding_box(face):
|
||||||
|
continue
|
||||||
face_w = face.get("imageWidth") or fitted.width
|
face_w = face.get("imageWidth") or fitted.width
|
||||||
face_h = face.get("imageHeight") or fitted.height
|
face_h = face.get("imageHeight") or fitted.height
|
||||||
img_scale_x = fitted.width / face_w
|
img_scale_x = fitted.width / face_w
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ def _plain_center_crop_box(
|
|||||||
return left, top, crop_w, crop_h
|
return left, top, crop_w, crop_h
|
||||||
|
|
||||||
|
|
||||||
|
def _has_bounding_box(face: dict) -> bool:
|
||||||
|
"""Immich has occasionally been observed to return a face entry with
|
||||||
|
a still-pending or otherwise incomplete bounding box (a null field)
|
||||||
|
-- treat it as undetected rather than crash on arithmetic with None."""
|
||||||
|
return all(
|
||||||
|
face.get(k) is not None
|
||||||
|
for k in ("boundingBoxX1", "boundingBoxX2", "boundingBoxY1", "boundingBoxY2")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _face_aware_crop_box(
|
def _face_aware_crop_box(
|
||||||
img_width: int, img_height: int, target_width: int, target_height: int, faces: list[dict]
|
img_width: int, img_height: int, target_width: int, target_height: int, faces: list[dict]
|
||||||
) -> tuple[int, int, int, int]:
|
) -> tuple[int, int, int, int]:
|
||||||
@@ -138,6 +148,8 @@ def _face_aware_crop_box(
|
|||||||
min_x = min_y = float("inf")
|
min_x = min_y = float("inf")
|
||||||
max_x = max_y = float("-inf")
|
max_x = max_y = float("-inf")
|
||||||
for face in faces:
|
for face in faces:
|
||||||
|
if not _has_bounding_box(face):
|
||||||
|
continue
|
||||||
face_w = face.get("imageWidth") or img_width
|
face_w = face.get("imageWidth") or img_width
|
||||||
face_h = face.get("imageHeight") or img_height
|
face_h = face.get("imageHeight") or img_height
|
||||||
scale_x = img_width / face_w
|
scale_x = img_width / face_w
|
||||||
|
|||||||
+17
-11
@@ -94,17 +94,23 @@ def run_migrations() -> None:
|
|||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
|
conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
|
||||||
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
||||||
current = row[0] if row else 0
|
if row is None:
|
||||||
for version, fn in MIGRATIONS:
|
# Brand new database: _migration_1's create_all() already
|
||||||
if version > current:
|
# produces today's full schema straight from models.py.
|
||||||
logger.info("Applying schema migration %d", version)
|
# Every migration after it is an incremental ALTER/UPDATE
|
||||||
fn(conn)
|
# meant to bring an *existing* install forward -- replaying
|
||||||
if row is None:
|
# those here would just collide with columns create_all
|
||||||
conn.execute(
|
# already added (e.g. "duplicate column name"). Jump
|
||||||
text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": version}
|
# straight to the latest version instead.
|
||||||
)
|
_migration_1(conn)
|
||||||
row = (version,)
|
latest = MIGRATIONS[-1][0]
|
||||||
else:
|
conn.execute(text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": latest})
|
||||||
|
else:
|
||||||
|
current = row[0]
|
||||||
|
for version, fn in MIGRATIONS:
|
||||||
|
if version > current:
|
||||||
|
logger.info("Applying schema migration %d", version)
|
||||||
|
fn(conn)
|
||||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||||
_ensure_frame_one()
|
_ensure_frame_one()
|
||||||
_ensure_server_settings()
|
_ensure_server_settings()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||||
@@ -59,6 +60,17 @@ MAX_QUEUE_TARGET_LEN = 5000
|
|||||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_repo_url(url: str) -> bool:
|
||||||
|
"""The frame will periodically fetch from this URL on its own (see
|
||||||
|
gitea_releases.py) and, with auto-update on, install whatever it
|
||||||
|
finds -- unlike a one-off manual firmware upload, that's a standing
|
||||||
|
trust relationship, so it's worth rejecting obviously-wrong input at
|
||||||
|
save time rather than only failing later at fetch time. http(s) only
|
||||||
|
-- no file://, no other schemes."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/frames/{frame_id}/albums")
|
@router.get("/api/frames/{frame_id}/albums")
|
||||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||||
url, key = immich_creds(frame)
|
url, key = immich_creds(frame)
|
||||||
@@ -129,7 +141,10 @@ def api_config_save(
|
|||||||
if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
|
if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
|
||||||
cfg.timezone = timezone
|
cfg.timezone = timezone
|
||||||
if firmware_update_repo_url is not None:
|
if firmware_update_repo_url is not None:
|
||||||
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
stripped = firmware_update_repo_url.strip()
|
||||||
|
if stripped and not _valid_repo_url(stripped):
|
||||||
|
raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
|
||||||
|
cfg.firmware_update_repo_url = stripped
|
||||||
if firmware_auto_update is not None:
|
if firmware_auto_update is not None:
|
||||||
cfg.firmware_auto_update = firmware_auto_update
|
cfg.firmware_auto_update = firmware_auto_update
|
||||||
if battery_alert_threshold_pct is not None:
|
if battery_alert_threshold_pct is not None:
|
||||||
@@ -207,7 +222,6 @@ def api_queue(
|
|||||||
"firmware_available": cfg.firmware_available_version,
|
"firmware_available": cfg.firmware_available_version,
|
||||||
"battery_percent": cfg.battery_percent,
|
"battery_percent": cfg.battery_percent,
|
||||||
"battery_as_of": cfg.battery_as_of,
|
"battery_as_of": cfg.battery_as_of,
|
||||||
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
|
|
||||||
"battery_estimate_s": battery_estimate_s(cfg),
|
"battery_estimate_s": battery_estimate_s(cfg),
|
||||||
"controller_id": cfg.controlled_by_user_id,
|
"controller_id": cfg.controlled_by_user_id,
|
||||||
"controller": (
|
"controller": (
|
||||||
@@ -240,7 +254,6 @@ def api_queue(
|
|||||||
if snapshot["battery_percent"] >= 0
|
if snapshot["battery_percent"] >= 0
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"on_battery_since": snapshot["on_battery_since"],
|
|
||||||
"battery_estimate_s": snapshot["battery_estimate_s"],
|
"battery_estimate_s": snapshot["battery_estimate_s"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -323,7 +336,14 @@ def api_queue_remove(
|
|||||||
|
|
||||||
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
|
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
|
||||||
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
||||||
|
"""Scoped to what this frame is actually showing/queuing -- a user
|
||||||
|
merely linked to view this frame shouldn't be able to pull thumbnails
|
||||||
|
for arbitrary asset ids in the owner's Immich library, only the
|
||||||
|
frame's own curated album. Same rule device.frame_share and
|
||||||
|
manage.manage_thumbnail already enforce."""
|
||||||
require_configured(frame)
|
require_configured(frame)
|
||||||
|
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||||
|
raise HTTPException(404, "Not on this frame")
|
||||||
client = immich_client_for(frame)
|
client = immich_client_for(frame)
|
||||||
try:
|
try:
|
||||||
content, content_type = client.download_asset_thumbnail(asset_id)
|
content, content_type = client.download_asset_thumbnail(asset_id)
|
||||||
@@ -437,15 +457,22 @@ def _apply_gitea_update(db: Session, frame: Frame) -> str:
|
|||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/frames/{frame_id}/firmware/check")
|
@router.post("/api/frames/{frame_id}/firmware/check")
|
||||||
def api_firmware_check(
|
def api_firmware_check(
|
||||||
force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
force: bool = False, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Throttled check of the configured Gitea repo's latest release
|
"""Throttled check of the configured Gitea repo's latest release
|
||||||
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
|
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
|
||||||
on and a newer version is found, applies it immediately; otherwise
|
on and a newer version is found, applies it immediately; otherwise
|
||||||
just reports it so the UI can offer the "Update frame" button.
|
just reports it so the UI can offer the "Update frame" button.
|
||||||
force=true (the "Check now" button) bypasses the throttle."""
|
force=true (the "Check now" button) bypasses the throttle.
|
||||||
|
|
||||||
|
require_frame_control (not view), and POST (not GET): this can
|
||||||
|
silently stage new firmware as a side effect (the auto-apply path
|
||||||
|
below) exactly like /firmware/apply-latest, so it needs the same
|
||||||
|
guard that route has -- a linked viewer without control shouldn't be
|
||||||
|
able to trigger that, and as a GET it would've been exempt from the
|
||||||
|
CSRF check require_user_api only applies to non-GET/HEAD/OPTIONS."""
|
||||||
if not frame.firmware_update_repo_url:
|
if not frame.firmware_update_repo_url:
|
||||||
return {"enabled": False}
|
return {"enabled": False}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,16 @@ logger = logging.getLogger(__name__)
|
|||||||
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
|
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
|
||||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
||||||
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- cap on the battery_log table per frame
|
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- cap on the battery_log table per frame
|
||||||
RECHARGE_JUMP_PCT = 5 # a report this much above the previous one = battery was recharged
|
RECHARGE_JUMP_PCT = 5 # a report this much above the recent baseline = battery was recharged
|
||||||
|
# How many of the most recent reports make up that baseline. A lone noisy
|
||||||
|
# reading (ADC/regulator glitch -- see firmware/main/battery.c) can still
|
||||||
|
# dip or spike a single report; comparing against just the one immediately
|
||||||
|
# previous report meant that a normal reading right after a noisy dip
|
||||||
|
# looked like a 5%+ jump and falsely registered as a recharge. Comparing
|
||||||
|
# against the max of the last few reports instead means an actual recharge
|
||||||
|
# still needs to clear all of them, while a single stray low one doesn't
|
||||||
|
# get to set the bar.
|
||||||
|
RECHARGE_LOOKBACK = 3
|
||||||
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
|
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
|
||||||
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
|
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .common import (
|
|||||||
BATTERY_HISTORY_MAX,
|
BATTERY_HISTORY_MAX,
|
||||||
BATTERY_LOG_MAX,
|
BATTERY_LOG_MAX,
|
||||||
RECHARGE_JUMP_PCT,
|
RECHARGE_JUMP_PCT,
|
||||||
|
RECHARGE_LOOKBACK,
|
||||||
immich_client_for,
|
immich_client_for,
|
||||||
immich_creds,
|
immich_creds,
|
||||||
list_assets,
|
list_assets,
|
||||||
@@ -202,7 +203,12 @@ def frame_battery(
|
|||||||
alert_frame_name = ""
|
alert_frame_name = ""
|
||||||
with frame_locked(db, frame.id) as locked:
|
with frame_locked(db, frame.id) as locked:
|
||||||
locked.stats_battery_reports += 1
|
locked.stats_battery_reports += 1
|
||||||
if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT:
|
# See RECHARGE_LOOKBACK: compared against the max of the last few
|
||||||
|
# reports, not just the single previous one, so a lone noisy dip
|
||||||
|
# can't make the next normal reading look like a recharge.
|
||||||
|
recent = locked.battery_history[-RECHARGE_LOOKBACK:]
|
||||||
|
recent_max = max((pct for _, pct in recent), default=None)
|
||||||
|
if recent_max is not None and body.percent >= recent_max + 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 -- and let a
|
# discharge-rate estimates never span a charge -- and let a
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Device status bar: always-visible strip (below the page title, above
|
||||||
|
// the tabs -- see _device_status_bar.html) showing last-seen/firmware/
|
||||||
|
// battery, so it's not tucked away on just the Stats tab. Shared by
|
||||||
|
// every frame page; each sets window.FRAME_API before this loads.
|
||||||
|
|
||||||
|
let lastDeviceStatus = null;
|
||||||
|
|
||||||
|
function renderDeviceStatusBar(device) {
|
||||||
|
const el = document.getElementById('device-status');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (!device || !device.last_seen) {
|
||||||
|
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const rows = [];
|
||||||
|
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
||||||
|
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
||||||
|
if (device.firmware_version) {
|
||||||
|
let fw = `v${device.firmware_version}`;
|
||||||
|
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
|
||||||
|
fw += ` (v${device.firmware_available} waiting)`;
|
||||||
|
}
|
||||||
|
rows.push(['Firmware', fw, false]);
|
||||||
|
}
|
||||||
|
if (device.battery) {
|
||||||
|
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
||||||
|
// Shown as soon as there's any battery reading at all, even before
|
||||||
|
// battery_estimate_s can compute a rate (needs 2h+ span and a 2%+
|
||||||
|
// drop within the current discharge cycle -- see common.py) -- so
|
||||||
|
// it's clear the number is coming, not that the feature is broken.
|
||||||
|
const hasEstimate = device.battery_estimate_s !== null && device.battery_estimate_s !== undefined;
|
||||||
|
rows.push([
|
||||||
|
'Est. battery life left',
|
||||||
|
hasEstimate ? `~${formatDuration(device.battery_estimate_s)}` : 'Not enough data yet',
|
||||||
|
false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (const [label, value, alert] of rows) {
|
||||||
|
const stat = document.createElement('span');
|
||||||
|
stat.className = 'device-stat' + (alert ? ' alert' : '');
|
||||||
|
const labelPart = document.createTextNode(label + ': ');
|
||||||
|
const valuePart = document.createElement('strong');
|
||||||
|
valuePart.textContent = value;
|
||||||
|
stat.appendChild(labelPart);
|
||||||
|
stat.appendChild(valuePart);
|
||||||
|
el.appendChild(stat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDeviceStatusBar() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${window.FRAME_API}/queue`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
lastDeviceStatus = data.device;
|
||||||
|
renderDeviceStatusBar(data.device);
|
||||||
|
} catch (e) { /* retried on the next poll */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDeviceStatusBar();
|
||||||
|
|
||||||
|
document.addEventListener('themechange', () => {
|
||||||
|
if (lastDeviceStatus) {
|
||||||
|
renderDeviceStatusBar(lastDeviceStatus);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fast tick: re-renders "Last seen" from already-fetched data every
|
||||||
|
// second so it counts up smoothly without hitting the server that often.
|
||||||
|
setInterval(() => {
|
||||||
|
if (lastDeviceStatus) {
|
||||||
|
renderDeviceStatusBar(lastDeviceStatus);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
setInterval(loadDeviceStatusBar, 10000);
|
||||||
@@ -259,7 +259,7 @@ async function loadFirmwareCheck(force) {
|
|||||||
const btn = document.getElementById('firmware-update-btn');
|
const btn = document.getElementById('firmware-update-btn');
|
||||||
const boardEl = document.getElementById('firmware-board');
|
const boardEl = document.getElementById('firmware-board');
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''));
|
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''), { method: 'POST' });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (force) {
|
if (force) {
|
||||||
showStatus(false, await apiError(resp));
|
showStatus(false, await apiError(resp));
|
||||||
|
|||||||
@@ -1,58 +1,6 @@
|
|||||||
// Stats tab: device status, lifetime counters, battery history chart
|
// Stats tab: lifetime counters + battery history chart (chart logic in
|
||||||
// (chart logic in battery_chart.js). window.FRAME_API set by template.
|
// battery_chart.js). Device status now lives in the always-visible bar
|
||||||
|
// (device_status_bar.js), not here. window.FRAME_API set by template.
|
||||||
let lastDevice = null;
|
|
||||||
|
|
||||||
function renderDeviceStatus(device) {
|
|
||||||
const el = document.getElementById('device-status');
|
|
||||||
el.innerHTML = '';
|
|
||||||
if (!device || !device.last_seen) {
|
|
||||||
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = Date.now() / 1000;
|
|
||||||
const rows = [];
|
|
||||||
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
|
||||||
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
|
||||||
if (device.firmware_version) {
|
|
||||||
let fw = `v${device.firmware_version}`;
|
|
||||||
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
|
|
||||||
fw += ` (v${device.firmware_available} waiting)`;
|
|
||||||
}
|
|
||||||
rows.push(['Firmware', fw, false]);
|
|
||||||
}
|
|
||||||
if (device.battery) {
|
|
||||||
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
|
||||||
}
|
|
||||||
if (device.on_battery_since) {
|
|
||||||
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
|
|
||||||
}
|
|
||||||
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
|
|
||||||
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
|
|
||||||
}
|
|
||||||
for (const [label, value, alert] of rows) {
|
|
||||||
const p = document.createElement('p');
|
|
||||||
p.className = 'sub';
|
|
||||||
if (alert) {
|
|
||||||
p.style.color = 'var(--danger-text)';
|
|
||||||
p.style.fontWeight = '600';
|
|
||||||
}
|
|
||||||
p.textContent = `${label}: ${value}`;
|
|
||||||
el.appendChild(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDevice() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
|
||||||
if (!resp.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
lastDevice = data.device;
|
|
||||||
renderDeviceStatus(data.device);
|
|
||||||
} catch (e) { /* retried on the next poll */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStats(stats) {
|
function renderStats(stats) {
|
||||||
const el = document.getElementById('stats-box');
|
const el = document.getElementById('stats-box');
|
||||||
@@ -90,29 +38,14 @@ async function loadStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDevice();
|
|
||||||
loadStats();
|
loadStats();
|
||||||
loadBatteryLog();
|
loadBatteryLog();
|
||||||
|
|
||||||
// Redraw the canvas chart (and the "Last seen" alert color) with the
|
// Redraw the canvas chart with the new theme's colors as soon as the
|
||||||
// new theme's colors as soon as the toggle is used -- canvas pixels
|
// toggle is used -- canvas pixels don't repaint themselves the way CSS
|
||||||
// don't repaint themselves the way CSS does.
|
// does.
|
||||||
document.addEventListener('themechange', () => {
|
document.addEventListener('themechange', () => {
|
||||||
if (lastBatteryLog) {
|
if (lastBatteryLog) {
|
||||||
drawBatteryChart(lastBatteryLog);
|
drawBatteryChart(lastBatteryLog);
|
||||||
}
|
}
|
||||||
if (lastDevice) {
|
|
||||||
renderDeviceStatus(lastDevice);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fast tick: re-renders "Last seen"/"On battery for" from already-
|
|
||||||
// fetched data every second so they count up smoothly without hitting
|
|
||||||
// the server that often.
|
|
||||||
setInterval(() => {
|
|
||||||
if (lastDevice) {
|
|
||||||
renderDeviceStatus(lastDevice);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
setInterval(loadDevice, 10000);
|
|
||||||
|
|||||||
@@ -484,6 +484,33 @@ code {
|
|||||||
}
|
}
|
||||||
.control-banner button { margin: 0; }
|
.control-banner button { margin: 0; }
|
||||||
|
|
||||||
|
/* Always-visible device summary, sitting between the page title and the
|
||||||
|
tabs (see _device_status_bar.html) -- a compact horizontal row rather
|
||||||
|
than a full .card, since it has to fit above the tabs on every frame
|
||||||
|
page without pushing content down. */
|
||||||
|
.device-status-bar {
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.device-status-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
column-gap: 26px;
|
||||||
|
row-gap: 6px;
|
||||||
|
}
|
||||||
|
.device-status-row .sub { margin: 0; }
|
||||||
|
.device-stat {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.device-stat strong { color: var(--text); font-weight: 600; }
|
||||||
|
.device-stat.alert, .device-stat.alert strong { color: var(--danger-text); }
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.device-status-row { column-gap: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
|
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
|
||||||
.mobile-bar { display: none; }
|
.mobile-bar { display: none; }
|
||||||
.sidebar-backdrop { display: none; }
|
.sidebar-backdrop { display: none; }
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<section class="card device-status-bar" id="device-status-bar">
|
||||||
|
<div id="device-status" class="device-status-row"><p class="sub">Loading...</p></div>
|
||||||
|
</section>
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
|
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% block device_status %}{% endblock %}
|
||||||
{% block tabs %}{% endblock %}
|
{% block tabs %}{% endblock %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
||||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
||||||
|
|
||||||
|
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -105,6 +106,9 @@
|
|||||||
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
|
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
|
||||||
<label for="firmware_auto_update">Automatically apply updates</label>
|
<label for="firmware_auto_update">Automatically apply updates</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="sub" style="margin-top: 4px;">While on, this frame installs
|
||||||
|
whatever the repo above publishes next, with nobody reviewing it
|
||||||
|
first -- only point it at a repo you trust.</p>
|
||||||
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
||||||
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
||||||
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
||||||
@@ -203,5 +207,6 @@
|
|||||||
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||||
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/device_status_bar.js"></script>
|
||||||
<script src="/static/frame_config.js"></script>
|
<script src="/static/frame_config.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
|
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
|
||||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
||||||
|
|
||||||
|
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||||
|
<script src="/static/device_status_bar.js"></script>
|
||||||
<script src="/static/queue.js"></script>
|
<script src="/static/queue.js"></script>
|
||||||
<script src="/static/frame_photos.js"></script>
|
<script src="/static/frame_photos.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,29 +3,19 @@
|
|||||||
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
|
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
|
||||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
||||||
|
|
||||||
|
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="layout">
|
<section class="card">
|
||||||
<div class="main-col">
|
<h2 class="card-title">Battery history</h2>
|
||||||
<section class="card">
|
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||||
<h2 class="card-title">Battery history</h2>
|
</section>
|
||||||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2 class="card-title">Lifetime stats</h2>
|
<h2 class="card-title">Lifetime stats</h2>
|
||||||
<div id="stats-box"><p class="sub">Loading...</p></div>
|
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="side-col">
|
|
||||||
<section class="card">
|
|
||||||
<h2 class="card-title">Device</h2>
|
|
||||||
<div id="device-status"><p class="sub">Loading...</p></div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="result"></div>
|
<div id="result"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -33,5 +23,6 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||||
<script src="/static/battery_chart.js"></script>
|
<script src="/static/battery_chart.js"></script>
|
||||||
|
<script src="/static/device_status_bar.js"></script>
|
||||||
<script src="/static/frame_stats.js"></script>
|
<script src="/static/frame_stats.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user