13 Commits
Author SHA1 Message Date
tfaour fc65b19cf2 Bump firmware to 1.2.4
Build and release firmware / build-and-release (push) Successful in 1m47s
Trimmed-mean battery ADC sampling to reduce noisy readings.
2026-07-22 16:57:34 -04:00
tfaour e1bca5a81a Fix false recharge-cycle detection from a single noisy battery reading
Build and push server image / build-and-push (push) Successful in 38s
A report was flagged as "the battery got recharged" (resetting
battery_history and stats_recharge_cycles, and re-arming the low-battery
alert) whenever it came in >= RECHARGE_JUMP_PCT above the single
immediately-previous report. That's exactly what a real recharge looks
like, but it's also exactly what a normal reading looks like right
after one noisy low report: e.g. 60, 59, 58, then a stray 53, then back
to a perfectly normal 58 -- 58 >= 53+5 falsely read as a recharge.

Now compared against the max of the last RECHARGE_LOOKBACK (3) reports
instead of just the one before it, so a lone stray reading doesn't get
to set the bar a normal reading then trips. A real recharge still needs
to clear all of them, so genuine recharges are still caught immediately
(verified: 18% -> 90% still triggers, history still resets).

Paired with the firmware-side battery.c change (trimmed-mean ADC
sampling) that reduces how often a stray reading like the 53 above
happens in the first place.
2026-07-22 16:51:37 -04:00
tfaour 845e4f9509 Reduce battery-reading noise with a trimmed-mean ADC sample
Sometimes a single reading came in noticeably off from the real trend
(a regulator/RF transient during sampling), and the next normal reading
would then look like a big jump relative to that bad one -- server-side,
enough to misfire the recharge-cycle heuristic (see the paired server
commit). Went from 8 raw-averaged samples to 16, sorted, with the 3
extreme samples on each end dropped before averaging the remaining 10 --
a handful of outliers can no longer skew the result the way a plain
average let them.
2026-07-22 16:51:28 -04:00
tfaour 02934b1d10 Bump firmware to 1.2.3
Build and release firmware / build-and-release (push) Successful in 1m48s
Fix stack buffer overflow in face-labels parsing.
2026-07-22 16:35:34 -04:00
tfaour f24c3b9c8e Gate firmware auto-check behind control+CSRF; skip faces with a null bounding box
Build and push server image / build-and-push (push) Successful in 39s
/api/frames/{id}/firmware/check could silently stage new firmware as a
side effect (the auto-apply path, when firmware_auto_update is on and
a newer release exists) but was gated by require_frame_view instead of
require_frame_control like its sibling firmware routes, and being a
GET, was exempt from the app's CSRF check (which only applies to
non-GET/HEAD/OPTIONS). A linked viewer without control -- or a
cross-site page riding a control-holding victim's session via a plain
GET -- could trigger an unreviewed firmware install. Now POST +
require_frame_control, matching /firmware/apply-latest; the frontend's
two callers (passive poll on page load, "Check now" button) both
already handle a 409 from a non-controller gracefully via the existing
apiError()/control-banner pattern, so this doesn't change UX for a
frame's actual controller.

Separately: Immich has been observed to return a face detection entry
with a null bounding-box field (a still-pending or otherwise
incomplete detection). Both places that do arithmetic on those fields
-- image_pipeline._face_aware_crop_box (crop_faces display mode) and
face_labels.compute_face_labels (manage-menu name labels) -- crashed
with an unhandled TypeError on such an entry, taking down that frame's
whole photo instead of the intended graceful fallback. Both now skip
any face missing a bounding-box field via a shared _has_bounding_box()
check; a face list with zero valid entries already degrades cleanly to
the plain center crop (the existing inf/-inf sentinel math already
handled "no faces" correctly, it just couldn't tell "none passed
Immich" apart from "one broken entry" before).
2026-07-22 16:21:37 -04:00
tfaour 38944a1287 Fix stack buffer overflow in face-labels parsing
fetch_face_labels() clamped the server-reported label count against
max_labels by casting the count to int first -- a value >= 2^31 (a
perfectly ordinary decimal in JSON) went negative under that cast, so
the comparison was always false and the clamp never fired. The loop
then ran with the full, unclamped count, writing past the caller's
fixed MANAGE_FACE_LABELS_MAX-element stack array on a crafted
/frame/face-labels response. Reachable by a compromised/malicious
tools server, or a MITM on the default plain-HTTP connection.

Fixed by comparing unsigned instead of casting to int.
2026-07-22 16:21:23 -04:00
tfaour 5b4fdbe330 Scope thumbnail access to the frame's own photos; validate Gitea repo URL
Build and push server image / build-and-push (push) Successful in 38s
Two fixes from a security pass over the server:

- /api/frames/{id}/thumbnail/{asset_id} accepted any asset id and
  fetched it via the frame owner's Immich credentials, unscoped to what
  that frame actually shows -- a user merely linked to view a frame
  could pull thumbnails for any asset in the owner's whole library, not
  just the frame's own album. Now scoped to current_asset_id/queue,
  matching the check device.frame_share and manage.manage_thumbnail
  already both apply.

- firmware_update_repo_url now has to be a plain http(s) URL. Unlike a
  one-off manual firmware upload (a deliberate, explicit act -- left
  alone), auto-update from a repo is a standing trust relationship: the
  frame keeps fetching from it and, with auto-update on, installs
  whatever it finds with nobody reviewing it first. Added a plain-
  language note next to the checkbox saying exactly that.
2026-07-22 12:57:11 -04:00
tfaour dcbc71e683 Show "Not enough data yet" instead of hiding the battery-estimate row
Build and push server image / build-and-push (push) Successful in 40s
Previously the row just disappeared whenever battery_estimate_s
couldn't be computed yet, which looked like the feature was gone.
Now it always shows once there's any battery reading at all, with a
placeholder until enough discharge history accumulates (matches the
"Not enough data yet." wording battery_chart.js already uses for the
same situation on the chart).
2026-07-22 11:35:38 -04:00
tfaour f4d2a23e8a Drop "On battery for", clarify the remaining-estimate label
Build and push server image / build-and-push (push) Successful in 38s
"On battery for" was clutter next to the actual number people care
about. Relabeled "Est. remaining" to "Est. battery life left" and
dropped the now-unused on_battery_since field from the /queue response.

battery_estimate_s itself is unchanged -- it still needs 2h of span and
a 2% drop within the current discharge cycle (reset on any 5%+ jump,
i.e. a recharge or reflash) before it'll show anything. A frame that's
been power-cycled/reflashed recently won't have an estimate yet; that's
expected, not a regression.
2026-07-22 11:29:17 -04:00
tfaour 55b53d5bb2 Fix migration runner crashing on a genuinely fresh database
Build and push server image / build-and-push (push) Successful in 39s
_migration_1() is Base.metadata.create_all() -- it already builds
today's full schema straight from models.py. Every migration after it
is an incremental ALTER/UPDATE meant to bring an *existing* install
forward from an older version; replaying them against a brand-new
database collided with columns create_all had already added ("duplicate
column name"), crashing on first boot.

Found while testing the device-status-bar change against a scratch DB.
Every real deployment has been migrating forward incrementally since
before this bug existed, so it never showed up in practice -- but any
brand-new install would have hit it. Fresh databases now jump straight
to the latest schema_version after create_all; existing databases keep
applying whichever migrations are still pending, same as before.
2026-07-22 10:48:36 -04:00
tfaour 60fcfca4a0 Make the device status card always visible, not just on Stats
Build and push server image / build-and-push (push) Successful in 39s
Moved out of the Stats tab's side column into a new horizontal bar
shared by every frame page (Photos/Configuration/Stats), sitting
between the page title and the tabs so it's on screen regardless of
which tab is active.

_device_status_bar.html is a new partial included via a device_status
block in app_base.html; device_status_bar.js is the fetch/render/poll
logic extracted from frame_stats.js and adapted to a wrapping row of
label/value pairs instead of a stacked list. frame_stats.html's Device
card and now-single-card .side-col are gone -- Battery history and
Lifetime stats just stack directly.
2026-07-22 10:46:05 -04:00
tfaour 996e06e2bc Bump firmware to 1.2.2
Build and release firmware / build-and-release (push) Successful in 1m51s
Read battery once, after the picture is pushed, instead of at boot.
2026-07-22 10:32:46 -04:00
tfaour d324bc4a57 Read battery once, after the picture is pushed, not at boot
Build and push server image / build-and-push (push) Successful in 40s
battery_read_percent() was called once at the very start of boot, before
WiFi even connects, and that value was reused both for the manage-menu
overlay and the server report. Taken right after a reset (e.g. the OTA
reboot that immediately precedes it), the rail may still be settling --
plausible source of noisy jumps in reported battery level.

Now there's a single read, in frame_client_run() right before
report_battery(), after the photo (and manage overlay, if shown) is
already on the panel -- the fetch/display work already done this cycle
is the settle time, no delay to guess. The manage overlay no longer
needs an early local reading at all: it shows the server's last-known
value instead, added to the /frame/photo-info response it already
fetches.
2026-07-22 10:32:20 -04:00
20 changed files with 335 additions and 153 deletions
+30 -7
View File
@@ -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;
+71 -18
View File
@@ -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;
+8 -6
View File
@@ -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);
+1 -8
View File
@@ -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
View File
@@ -1 +1 @@
1.2.1
1.2.4
+3 -1
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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()
+33 -6
View File
@@ -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}
+10 -1
View File
@@ -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
+14 -1
View File
@@ -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,
}
+80
View File
@@ -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);
+1 -1
View File
@@ -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));
+6 -73
View File
@@ -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);
+27
View File
@@ -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>
+1
View File
@@ -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>
+5
View File
@@ -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 %}
+2
View File
@@ -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 %}
+10 -19
View File
@@ -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 %}