Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc65b19cf2 | ||
|
|
e1bca5a81a | ||
|
|
845e4f9509 | ||
|
|
02934b1d10 | ||
|
|
f24c3b9c8e | ||
|
|
38944a1287 | ||
|
|
5b4fdbe330 | ||
|
|
dcbc71e683 | ||
|
|
f4d2a23e8a | ||
|
|
55b53d5bb2 | ||
|
|
60fcfca4a0 | ||
|
|
996e06e2bc | ||
|
|
d324bc4a57 |
+30
-7
@@ -1,3 +1,5 @@
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_adc/adc_cali_scheme.h"
|
||||
#include "esp_adc/adc_oneshot.h"
|
||||
@@ -11,7 +13,13 @@ static const char *TAG = "battery";
|
||||
#if CONFIG_FRAME_BATTERY_ADC_GPIO >= 0
|
||||
|
||||
#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
|
||||
* Seeed-documented XIAO wiring) so a full 4.2V cell reads ~2.1V at the
|
||||
* pin, inside the 12dB-attenuation ADC range. */
|
||||
@@ -34,6 +42,11 @@ static const struct {
|
||||
{ 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)
|
||||
{
|
||||
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");
|
||||
}
|
||||
|
||||
int mv_sum = 0;
|
||||
int mv_samples[BATTERY_SAMPLES];
|
||||
int samples = 0;
|
||||
for (int i = 0; i < BATTERY_SAMPLES; i++) {
|
||||
int value;
|
||||
if (calibrated) {
|
||||
if (adc_oneshot_get_calibrated_result(adc, cali, channel, &value) == ESP_OK) {
|
||||
mv_sum += value;
|
||||
samples++;
|
||||
mv_samples[samples++] = value;
|
||||
}
|
||||
} else {
|
||||
if (adc_oneshot_read(adc, channel, &value) == ESP_OK) {
|
||||
mv_sum += value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
|
||||
samples++;
|
||||
mv_samples[samples++] = value * 3300 / 4095; /* nominal 12-bit full scale at 12dB */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,7 +178,19 @@ int battery_read_percent(void)
|
||||
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) {
|
||||
ESP_LOGI(TAG, "Reading %dmV outside plausible battery range, ignoring", battery_mv);
|
||||
return -1;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "combo_button.h"
|
||||
#include "ota_update.h"
|
||||
#include "board_antenna.h"
|
||||
#include "battery.h"
|
||||
|
||||
#include "frame_client.h"
|
||||
|
||||
@@ -313,6 +314,35 @@ static bool json_extract_uint(const char *json, const char *key, uint32_t *out)
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Same as json_extract_uint(), but signed -- e.g. battery_percent's -1
|
||||
* ("no reading") sentinel. strtoul() would silently wrap a leading '-'
|
||||
* into a huge unsigned value instead of failing, so this needs its own
|
||||
* strtol()-based parse rather than reusing json_extract_uint(). */
|
||||
static bool json_extract_int(const char *json, const char *key, int *out)
|
||||
{
|
||||
char needle[48];
|
||||
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
||||
const char *pos = strstr(json, needle);
|
||||
if (pos == NULL) {
|
||||
return false;
|
||||
}
|
||||
pos = strchr(pos, ':');
|
||||
if (pos == NULL) {
|
||||
return false;
|
||||
}
|
||||
pos++;
|
||||
while (*pos == ' ') {
|
||||
pos++;
|
||||
}
|
||||
char *end;
|
||||
long value = strtol(pos, &end, 10);
|
||||
if (end == pos) {
|
||||
return false;
|
||||
}
|
||||
*out = (int)value;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Finds the string value associated with "key" in a small, flat JSON
|
||||
* blob, e.g. "San Francisco, CA" in {"location": "San Francisco, CA"}.
|
||||
* Same rationale as json_extract_uint() -- not a general parser. Returns
|
||||
@@ -427,20 +457,26 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
|
||||
/* GETs the server's /frame/photo-info for the manage-button overlay:
|
||||
* location/taken_at text (left empty if the server didn't have them --
|
||||
* e.g. no GPS EXIF to geocode, or no capture date) and a share_url built
|
||||
* from the returned asset_id, same construction pattern as
|
||||
* run_fetch_cycle()'s management_url. Any failure (unreachable, no
|
||||
* current photo, etc.) just leaves all outputs empty -- the caller
|
||||
* e.g. no GPS EXIF to geocode, or no capture date), a share_url built
|
||||
* from the returned asset_id (same construction pattern as
|
||||
* run_fetch_cycle()'s management_url), and the last battery percent this
|
||||
* frame reported (-1 if none yet). The overlay uses that last-known
|
||||
* value rather than a fresh local reading -- it's needed before this
|
||||
* photo is composited and pushed to the panel, i.e. before this cycle's
|
||||
* own reading (taken later, right before it's reported -- see
|
||||
* frame_client_run()) even exists yet. Any failure (unreachable, no
|
||||
* current photo, etc.) just leaves all outputs empty/-1 -- the caller
|
||||
* treats that as "skip these optional overlay regions", not a hard
|
||||
* error, since the base "scan to manage" QR should still show. */
|
||||
static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, size_t location_line1_size,
|
||||
char *location_line2, size_t location_line2_size, char *taken_at, size_t taken_at_size,
|
||||
char *share_url, size_t share_url_size)
|
||||
char *share_url, size_t share_url_size, int *battery_percent)
|
||||
{
|
||||
location_line1[0] = '\0';
|
||||
location_line2[0] = '\0';
|
||||
taken_at[0] = '\0';
|
||||
share_url[0] = '\0';
|
||||
*battery_percent = -1;
|
||||
|
||||
char url[256];
|
||||
build_url(url, sizeof(url), cfg, "frame/photo-info");
|
||||
@@ -493,6 +529,7 @@ static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, si
|
||||
json_extract_string(body, "location_line1", location_line1, location_line1_size);
|
||||
json_extract_string(body, "location_line2", location_line2, location_line2_size);
|
||||
json_extract_string(body, "taken_at", taken_at, taken_at_size);
|
||||
json_extract_int(body, "battery_percent", battery_percent);
|
||||
|
||||
char asset_id[48];
|
||||
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
|
||||
@@ -554,7 +591,12 @@ static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out
|
||||
|
||||
uint32_t count = 0;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -757,8 +799,7 @@ static bool wait_for_button_press(uint32_t timeout_ms)
|
||||
* had that data); level 2 adds named-face labels on top. action only
|
||||
* applies at level 1 -- escalating to level 2 redisplays the same
|
||||
* photo, so it never re-advances/-backs. */
|
||||
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level,
|
||||
int battery_percent)
|
||||
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level)
|
||||
{
|
||||
char management_url[256];
|
||||
build_url(management_url, sizeof(management_url), cfg, "");
|
||||
@@ -774,8 +815,10 @@ static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t actio
|
||||
* overflow, but a truncated/dropped token still means the resulting
|
||||
* request just 401s with no obvious cause). */
|
||||
char share_url[320];
|
||||
int battery_percent;
|
||||
fetch_photo_info(cfg, location_line1, sizeof(location_line1), location_line2,
|
||||
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url));
|
||||
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url),
|
||||
&battery_percent);
|
||||
|
||||
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
|
||||
int face_label_count = 0;
|
||||
@@ -816,10 +859,10 @@ static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t actio
|
||||
* that (escalating, or the final revert) are logged but don't count as
|
||||
* an overall failure -- something was already shown successfully, which
|
||||
* was the point of the button. */
|
||||
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action, int battery_percent)
|
||||
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
|
||||
{
|
||||
int level = 1;
|
||||
esp_err_t err = show_menu_level(cfg, action, level, battery_percent);
|
||||
esp_err_t err = show_menu_level(cfg, action, level);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
|
||||
return fetch_and_display(cfg, action, NULL);
|
||||
@@ -832,7 +875,7 @@ static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t a
|
||||
break; /* timeout at any level, or a press while already maxed out -- exit */
|
||||
}
|
||||
level++;
|
||||
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level, battery_percent);
|
||||
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level);
|
||||
if (level_err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
|
||||
break;
|
||||
@@ -849,13 +892,12 @@ static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t a
|
||||
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
|
||||
* show_management_qr -- the escalating manage menu (see
|
||||
* run_management_menu()). */
|
||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent)
|
||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||
{
|
||||
if (!show_management_qr) {
|
||||
return fetch_and_display(cfg, action, NULL);
|
||||
}
|
||||
return run_management_menu(cfg, action, battery_percent);
|
||||
return run_management_menu(cfg, action);
|
||||
}
|
||||
|
||||
/* Reports the battery percent to the server (POST /frame/battery).
|
||||
@@ -899,8 +941,7 @@ static void report_battery(const frame_config_t *cfg, int percent)
|
||||
esp_http_client_cleanup(client);
|
||||
}
|
||||
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent)
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||
{
|
||||
esp_err_t epd_err = epd_init();
|
||||
bool have_display = (epd_err == ESP_OK);
|
||||
@@ -934,7 +975,7 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
* worth it to stop false-failing on the common case. */
|
||||
bool image_ok = true;
|
||||
if (have_display) {
|
||||
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr, battery_percent);
|
||||
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr);
|
||||
image_ok = (fetch_err == ESP_OK);
|
||||
if (!image_ok) {
|
||||
/* epd_display_stream() never triggers a physical refresh on a
|
||||
@@ -969,6 +1010,18 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
* normal boot, not just the one right after an update). */
|
||||
esp_ota_mark_app_valid_cancel_rollback();
|
||||
|
||||
/* Read now, not at boot: the photo (and, if shown, the manage
|
||||
* overlay -- which gets its own battery number from the server's
|
||||
* last-known value, not a local reading, see fetch_photo_info())
|
||||
* is already on the panel, so there's no display deadline to beat.
|
||||
* Reading here instead of right after waking sidesteps taking the
|
||||
* ADC sample while the rail's still settling from whatever the
|
||||
* boot/reset just did, with no need to guess a settle delay --
|
||||
* the fetch/display work already done this cycle is the delay.
|
||||
* Still safe re: the battery/button pin sharing (battery.h) --
|
||||
* every button check main.c does happens well before this, at
|
||||
* the very start of boot. */
|
||||
int battery_percent = battery_read_percent();
|
||||
report_battery(cfg, battery_percent);
|
||||
frame_server_config_t server_cfg = fetch_frame_config(cfg);
|
||||
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
|
||||
|
||||
@@ -36,11 +36,13 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg);
|
||||
* displayed photo gets a small "scan to manage" QR overlay in the
|
||||
* top-right corner linking to the server's config page, held for 30
|
||||
* seconds (the device stays awake), then reverted back to the plain
|
||||
* photo before proceeding to the normal sleep-interval logic.
|
||||
* photo before proceeding to the normal sleep-interval logic. Its
|
||||
* battery indicator shows the server's last-known reading, not a fresh
|
||||
* one -- see fetch_photo_info() in frame_client.c.
|
||||
*
|
||||
* battery_percent (0-100, or -1 for "no reading" -- see
|
||||
* battery_read_percent()) is shown on the management menu overlay and
|
||||
* reported to the server after a successful fetch; -1 skips both.
|
||||
* Reads the battery (see battery_read_percent()) itself, once, after the
|
||||
* photo is already on the panel, and reports it to the server on a
|
||||
* successful fetch; a -1 reading ("no reading" -- on mains, disabled, or
|
||||
* implausible) skips the report.
|
||||
*/
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent);
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include "next_button.h"
|
||||
#include "back_button.h"
|
||||
#include "combo_button.h"
|
||||
#include "battery.h"
|
||||
|
||||
static const char *TAG = "main";
|
||||
|
||||
@@ -52,18 +51,12 @@ void app_main(void)
|
||||
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
||||
bool show_management_qr = combo_button_check();
|
||||
|
||||
/* Must come after the button checks: the battery pin is (by design,
|
||||
* on the XIAO board) shared with a button, and the ADC read briefly
|
||||
* takes the pin over -- see battery.h. -1 = no reading (disabled,
|
||||
* on mains, or implausible). */
|
||||
int battery_percent = battery_read_percent();
|
||||
|
||||
frame_config_t cfg;
|
||||
esp_err_t cfg_err = frame_config_load(&cfg);
|
||||
if (cfg_err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
|
||||
if (frame_wifi_connect_sta(&cfg) == ESP_OK) {
|
||||
frame_client_run(&cfg, action, show_management_qr, battery_percent);
|
||||
frame_client_run(&cfg, action, show_management_qr);
|
||||
return; /* frame_client_run currently never returns */
|
||||
}
|
||||
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.2.1
|
||||
1.2.4
|
||||
|
||||
@@ -15,7 +15,7 @@ import io
|
||||
|
||||
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
|
||||
# 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 = []
|
||||
for face in named[:MAX_LABELED_FACES]:
|
||||
if not _has_bounding_box(face):
|
||||
continue
|
||||
face_w = face.get("imageWidth") or fitted.width
|
||||
face_h = face.get("imageHeight") or fitted.height
|
||||
img_scale_x = fitted.width / face_w
|
||||
|
||||
@@ -118,6 +118,16 @@ def _plain_center_crop_box(
|
||||
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(
|
||||
img_width: int, img_height: int, target_width: int, target_height: int, faces: list[dict]
|
||||
) -> tuple[int, int, int, int]:
|
||||
@@ -138,6 +148,8 @@ def _face_aware_crop_box(
|
||||
min_x = min_y = float("inf")
|
||||
max_x = max_y = float("-inf")
|
||||
for face in faces:
|
||||
if not _has_bounding_box(face):
|
||||
continue
|
||||
face_w = face.get("imageWidth") or img_width
|
||||
face_h = face.get("imageHeight") or img_height
|
||||
scale_x = img_width / face_w
|
||||
|
||||
+17
-11
@@ -94,17 +94,23 @@ def run_migrations() -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
|
||||
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
||||
current = row[0] if row else 0
|
||||
for version, fn in MIGRATIONS:
|
||||
if version > current:
|
||||
logger.info("Applying schema migration %d", version)
|
||||
fn(conn)
|
||||
if row is None:
|
||||
conn.execute(
|
||||
text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": version}
|
||||
)
|
||||
row = (version,)
|
||||
else:
|
||||
if row is None:
|
||||
# Brand new database: _migration_1's create_all() already
|
||||
# produces today's full schema straight from models.py.
|
||||
# Every migration after it is an incremental ALTER/UPDATE
|
||||
# meant to bring an *existing* install forward -- replaying
|
||||
# those here would just collide with columns create_all
|
||||
# already added (e.g. "duplicate column name"). Jump
|
||||
# straight to the latest version instead.
|
||||
_migration_1(conn)
|
||||
latest = MIGRATIONS[-1][0]
|
||||
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})
|
||||
_ensure_frame_one()
|
||||
_ensure_server_settings()
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
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")
|
||||
|
||||
|
||||
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")
|
||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||
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:
|
||||
cfg.timezone = timezone
|
||||
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:
|
||||
cfg.firmware_auto_update = firmware_auto_update
|
||||
if battery_alert_threshold_pct is not None:
|
||||
@@ -207,7 +222,6 @@ def api_queue(
|
||||
"firmware_available": cfg.firmware_available_version,
|
||||
"battery_percent": cfg.battery_percent,
|
||||
"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),
|
||||
"controller_id": cfg.controlled_by_user_id,
|
||||
"controller": (
|
||||
@@ -240,7 +254,6 @@ def api_queue(
|
||||
if snapshot["battery_percent"] >= 0
|
||||
else None
|
||||
),
|
||||
"on_battery_since": snapshot["on_battery_since"],
|
||||
"battery_estimate_s": snapshot["battery_estimate_s"],
|
||||
},
|
||||
}
|
||||
@@ -323,7 +336,14 @@ def api_queue_remove(
|
||||
|
||||
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
|
||||
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)
|
||||
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)
|
||||
try:
|
||||
content, content_type = client.download_asset_thumbnail(asset_id)
|
||||
@@ -437,15 +457,22 @@ def _apply_gitea_update(db: Session, frame: Frame) -> str:
|
||||
return version
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/firmware/check")
|
||||
@router.post("/api/frames/{frame_id}/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
|
||||
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
|
||||
on and a newer version is found, applies it immediately; otherwise
|
||||
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:
|
||||
return {"enabled": False}
|
||||
|
||||
|
||||
@@ -21,7 +21,16 @@ logger = logging.getLogger(__name__)
|
||||
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
|
||||
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
|
||||
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_DROP_PCT = 2 # ...and this much observed drop before estimating
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from .common import (
|
||||
BATTERY_HISTORY_MAX,
|
||||
BATTERY_LOG_MAX,
|
||||
RECHARGE_JUMP_PCT,
|
||||
RECHARGE_LOOKBACK,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
@@ -202,7 +203,12 @@ def frame_battery(
|
||||
alert_frame_name = ""
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
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
|
||||
# (or swapped). Start a fresh discharge cycle so runtime and
|
||||
# discharge-rate estimates never span a charge -- and let a
|
||||
@@ -365,6 +371,13 @@ def frame_photo_info(frame: Frame = Depends(require_device), db: Session = Depen
|
||||
"location_line1": location[0] if location else None,
|
||||
"location_line2": location[1] if location and location[1] else None,
|
||||
"taken_at": _format_taken_at(exif),
|
||||
# Last value this frame itself reported (see /frame/battery) --
|
||||
# not a fresh reading. Good enough for a glance on the manage
|
||||
# overlay, and lets the device skip a synchronous ADC read (which
|
||||
# would otherwise need to happen before the overlay is composited,
|
||||
# i.e. before the photo it's part of is even pushed to the panel)
|
||||
# just to render this.
|
||||
"battery_percent": frame.battery_percent,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 boardEl = document.getElementById('firmware-board');
|
||||
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 (force) {
|
||||
showStatus(false, await apiError(resp));
|
||||
|
||||
@@ -1,58 +1,6 @@
|
||||
// Stats tab: device status, lifetime counters, battery history chart
|
||||
// (chart logic in battery_chart.js). 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 */ }
|
||||
}
|
||||
// Stats tab: lifetime counters + battery history chart (chart logic in
|
||||
// battery_chart.js). Device status now lives in the always-visible bar
|
||||
// (device_status_bar.js), not here. window.FRAME_API set by template.
|
||||
|
||||
function renderStats(stats) {
|
||||
const el = document.getElementById('stats-box');
|
||||
@@ -90,29 +38,14 @@ async function loadStats() {
|
||||
}
|
||||
}
|
||||
|
||||
loadDevice();
|
||||
loadStats();
|
||||
loadBatteryLog();
|
||||
|
||||
// Redraw the canvas chart (and the "Last seen" alert color) with the
|
||||
// new theme's colors as soon as the toggle is used -- canvas pixels
|
||||
// don't repaint themselves the way CSS does.
|
||||
// Redraw the canvas chart with the new theme's colors as soon as the
|
||||
// toggle is used -- canvas pixels don't repaint themselves the way CSS
|
||||
// does.
|
||||
document.addEventListener('themechange', () => {
|
||||
if (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; }
|
||||
|
||||
/* 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-bar { 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>
|
||||
</div>
|
||||
</div>
|
||||
{% block device_status %}{% endblock %}
|
||||
{% block tabs %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ frame.name or "Frame" }} · Configuration{% 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 content %}
|
||||
@@ -105,6 +106,9 @@
|
||||
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
|
||||
<label for="firmware_auto_update">Automatically apply updates</label>
|
||||
</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>
|
||||
<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>
|
||||
@@ -203,5 +207,6 @@
|
||||
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
||||
</script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/frame_config.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ frame.name or "Frame" }} · Photos{% 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 content %}
|
||||
@@ -55,6 +56,7 @@
|
||||
|
||||
{% block scripts %}
|
||||
<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/frame_photos.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,29 +3,19 @@
|
||||
{% block title %}{{ frame.name or "Frame" }} · Stats{% 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 content %}
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery history</h2>
|
||||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery history</h2>
|
||||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Lifetime stats</h2>
|
||||
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||||
</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>
|
||||
<section class="card">
|
||||
<h2 class="card-title">Lifetime stats</h2>
|
||||
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
@@ -33,5 +23,6 @@
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script src="/static/battery_chart.js"></script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/frame_stats.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user