16 Commits
Author SHA1 Message Date
tfaour 716776c3f2 Bump firmware to 1.3.0
Build and release firmware / build-and-release (push) Successful in 1m48s
Manage overlay now composited server-side.
2026-07-22 19:07:29 -04:00
tfaour 0f98d96d25 Untrack .claude/ (editor tooling, not project content) 2026-07-22 19:07:08 -04:00
tfaour c007acde75 Add calendar frame mode + server-side manage overlay (server)
Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds,
render agenda/week/month views. manage_overlay.py: composites the
manage-button overlay server-side (QR, battery, location/date,
share-QR, face labels), reused by every render mode. device.py/common.py
wire both together: mode dispatch for /frame/image+advance+back, and
the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings
calendar URL field) and the icalendar/recurring-ical-events deps.
2026-07-22 19:06:49 -04:00
tfaour 15e37c77cd Simplify firmware: manage overlay now composited server-side
Deletes manage_qr_overlay.c/.h; frame_client.c's manage-button flow is
now one fetch with &manage=1 instead of on-device QR/text generation
plus separate photo-info/face-labels requests.
2026-07-22 19:06:49 -04:00
tfaour 1fa1c68478 Add calendar frame mode data model (migration 7)
New columns, all ADD COLUMN with inert defaults -- no existing frame's
behavior changes until mode is explicitly switched to "calendar":

- users.calendar_ics_url: one personal iCal/CalDAV subscription per
  user, same shape as the existing per-user immich_url/immich_api_key.
- user_frames.calendar_included: explicit per-(user,frame) opt-in,
  default off. Being linked to a frame does not by itself contribute
  your calendar to it -- each person's calendar is their own data to
  share, not something a frame's controller decides on their behalf.
- frames.calendar_view/calendar_photo_inlay/calendar_browse_offset:
  per-frame display settings and NEXT/BACK navigation state.
- frames.calendar_checked_at/calendar_cached_events/calendar_fetch_summary:
  the throttled merge-fetch cache, same shape as the existing
  firmware_update_checked_at/firmware_gitea_latest_version pattern.
2026-07-22 19:05:02 -04:00
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
33 changed files with 1665 additions and 1142 deletions
+1
View File
@@ -27,3 +27,4 @@ server/docker-compose.yml
.idea/
*.swp
.DS_Store
.claude/
+1 -1
View File
@@ -1,3 +1,3 @@
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c manage_qr_overlay.c battery.c ota_update.c board_antenna.c
idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c qr_onboarding.c status_screen.c epd_draw.c next_button.c back_button.c combo_button.c battery.c ota_update.c board_antenna.c
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server esp_http_client mbedtls dns_server epd7in3e qrcode epaper_fonts esp_driver_gpio esp_adc esp_https_ota app_update esp_app_format
EMBED_FILES root.html)
+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;
+46 -346
View File
@@ -16,7 +16,6 @@
#include "epd7in3e.h"
#include "status_screen.h"
#include "manage_qr_overlay.h"
#include "combo_button.h"
#include "ota_update.h"
#include "board_antenna.h"
@@ -314,35 +313,6 @@ 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
@@ -455,243 +425,35 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
return result;
}
/* 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), 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, 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");
/* CONFIG_FRAME_FETCH_TIMEOUT_MS, not the shorter SERVER_CHECK one:
* unlike fetch_frame_config() (always called after the image fetch
* has already warmed the connection, see frame_client_run()), this
* is the *first* network call of the wake cycle whenever the manage
* menu is opened -- same cold-connection latency spike that made
* the short timeout unreliable for /frame/config before, now worse
* with a real TLS handshake on top. Confirmed on hardware: this
* timed out under CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS while the
* rest of the cycle (a fresh connection, but not the *first* one)
* succeeded fine. */
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return;
}
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
if (status != 200) {
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return;
}
char body[384];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
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))) {
char path[80];
snprintf(path, sizeof(path), "frame/share/%s", asset_id);
build_url(share_url, share_url_size, cfg, path);
}
}
/* GETs the server's /frame/face-labels for the manage-button's escalated
* "level 2" menu -- named-face positions, if Immich has any for the
* current photo. Response is a flattened, fixed-slot shape ("count",
* then name_0/x_0/y_0, name_1/x_1/y_1, ...) rather than a real JSON
* array, read with the same flat-scalar helpers as everywhere else in
* this file instead of needing an actual array parser. Any failure
* (unreachable, malformed response, etc.) just returns 0 -- named faces
* are a "nice to have" addition to the menu, not worth failing it over. */
static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out, int max_labels)
{
char url[256];
build_url(url, sizeof(url), cfg, "frame/face-labels");
/* Same reasoning as fetch_photo_info() -- this is a manage-menu
* request too, not a warmed-connection reachability check. */
esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "'%s' not reachable: %s", url, esp_err_to_name(err));
esp_http_client_cleanup(client);
return 0;
}
int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
if (status != 200) {
ESP_LOGW(TAG, "'%s' returned HTTP %d", url, status);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return 0;
}
char body[768];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
(n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
total += n;
}
body[total] = '\0';
esp_http_client_close(client);
esp_http_client_cleanup(client);
uint32_t count = 0;
json_extract_uint(body, "count", &count);
if ((int)count > max_labels) {
count = (uint32_t)max_labels;
}
int found = 0;
for (uint32_t i = 0; i < count; i++) {
char key[16];
snprintf(key, sizeof(key), "name_%u", (unsigned)i);
if (!json_extract_string(body, key, out[found].name, sizeof(out[found].name))) {
continue;
}
snprintf(key, sizeof(key), "x_%u", (unsigned)i);
uint32_t x;
if (!json_extract_uint(body, key, &x)) {
continue;
}
snprintf(key, sizeof(key), "y_%u", (unsigned)i);
uint32_t y;
if (!json_extract_uint(body, key, &y)) {
continue;
}
out[found].x = (int)x;
out[found].y = (int)y;
found++;
}
return found;
}
typedef struct {
esp_http_client_handle_t client;
size_t stream_pos; /* running absolute offset into the frame, for overlay splicing */
const manage_overlay_set_t *overlay; /* NULL = no overlay this fetch */
} http_read_ctx_t;
/* Splices one overlay region's pixels over the real photo bytes in chunk
* wherever chunk's absolute byte range [chunk_start, chunk_start+chunk_len)
* within the full frame intersects that region's rectangle. Rows/chunks
* outside the region's footprint are left completely untouched.
* region->x0 is always even (see manage_qr_overlay.h), so byte_x0 below
* is exact. */
static void splice_overlay_region(uint8_t *chunk, size_t chunk_len, size_t chunk_start,
const manage_overlay_region_t *region)
{
int byte_x0 = region->x0 / 2;
int byte_w = region->w / 2;
size_t chunk_end = chunk_start + chunk_len;
for (int row = region->y0; row < region->y0 + region->h; row++) {
size_t row_start = (size_t)row * EPD_BYTES_PER_ROW + (size_t)byte_x0;
size_t row_end = row_start + (size_t)byte_w;
size_t lo = row_start > chunk_start ? row_start : chunk_start;
size_t hi = row_end < chunk_end ? row_end : chunk_end;
if (lo >= hi) {
continue;
}
size_t region_row_offset = (size_t)(row - region->y0) * (size_t)byte_w + (lo - row_start);
memcpy(chunk + (lo - chunk_start), region->buf + region_row_offset, hi - lo);
}
}
static void splice_overlay(uint8_t *chunk, size_t chunk_len, size_t chunk_start, const manage_overlay_set_t *overlay)
{
for (int i = 0; i < overlay->count; i++) {
splice_overlay_region(chunk, chunk_len, chunk_start, &overlay->regions[i]);
}
}
/* Pulls the next chunk straight out of the in-progress HTTP response --
* epd_write_frame() calls this to feed the panel without ever holding
* the full ~192KB frame in RAM. Splices in ctx->overlay's regions (if
* set) as chunks pass through, so the panel driver never needs to know
* an overlay exists at all. */
* the full ~192KB frame in RAM. Just a plain relay: the manage overlay
* (scan-to-manage QR, battery, location/date, share-QR, named face
* labels) is composited server-side now (see server/app/manage_overlay.py),
* baked into the same image bytes as any other render -- this function,
* like the rest of this file, has no idea an overlay exists. */
static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
http_read_ctx_t *ctx = (http_read_ctx_t *)ctx_;
int n = esp_http_client_read(ctx->client, (char *)chunk, (int)chunk_size);
if (n <= 0) {
return 0;
}
if (ctx->overlay != NULL) {
splice_overlay(chunk, (size_t)n, ctx->stream_pos, ctx->overlay);
}
ctx->stream_pos += (size_t)n;
return (size_t)n;
return n > 0 ? (size_t)n : 0;
}
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
* /frame/back to force a move in either direction (FETCH_ADVANCE /
* FETCH_BACK -- the next-photo / back-photo buttons), and streams the
* response directly into the panel, splicing in overlay's pixels (if
* non-NULL) as it streams. Returning non-ESP_OK means the panel was
* never actually refreshed -- epd_display_stream() (see epd7in3e.c)
* refuses to trigger a physical refresh on a short/wrong-size stream,
* so a failure here always leaves the visible screen exactly as it
* was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action,
const manage_overlay_set_t *overlay)
* FETCH_BACK -- the next-photo / back-photo buttons). manage=true (the
* manage button) appends &manage=1, telling the server to bake its
* overlay into this same response instead of returning the bare
* content -- see server/app/routers/device.py. Returning non-ESP_OK
* means the panel was never actually refreshed -- epd_display_stream()
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/
* wrong-size stream, so a failure here always leaves the visible screen
* exactly as it was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action, bool manage)
{
const char *path = "frame/image";
if (action == FETCH_ADVANCE) {
@@ -702,6 +464,12 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
char url[256];
build_url(url, sizeof(url), cfg, path);
if (manage) {
size_t len = strlen(url);
if (len + strlen("&manage=1") < sizeof(url)) {
strcpy(url + len, "&manage=1");
}
}
esp_http_client_config_t config = {
.url = url,
@@ -728,7 +496,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
}
ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url);
http_read_ctx_t ctx = { .client = client, .overlay = overlay };
http_read_ctx_t ctx = { .client = client };
uint32_t crc = 0;
err = epd_write_frame(http_read_fn, &ctx, &crc);
@@ -756,8 +524,7 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
return err;
}
#define MANAGE_MENU_MAX_LEVEL 2
#define MANAGE_MENU_LEVEL_TIMEOUT_MS 30000
#define MANAGE_MENU_TIMEOUT_MS 30000
#define MANAGE_MENU_POLL_MS 150
#define MANAGE_MENU_DEBOUNCE_MS 30
@@ -789,108 +556,40 @@ static bool wait_for_button_press(uint32_t timeout_ms)
return false;
}
/* Builds and shows one level of the manage menu: level 1 is the base
* overlay (management QR + location/date/share-QR wherever the server
* 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)
{
char management_url[256];
build_url(management_url, sizeof(management_url), cfg, "");
char location_line1[32];
char location_line2[32];
char taken_at[32];
/* Wider than the other URL buffers in this file: unlike a fixed path,
* this one stacks toolsserver (up to 128) + "/frame/share/" + an
* asset_id (up to 47) + "?token=" + an access_token (up to 64) --
* worst case ~266 bytes, which a 256-byte buffer could silently
* truncate the token off of (build_url()'s bounds check avoids an
* 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),
&battery_percent);
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
int face_label_count = 0;
if (level >= 2) {
face_label_count = fetch_face_labels(cfg, face_labels, MANAGE_FACE_LABELS_MAX);
}
manage_overlay_content_t content = {
.management_url = management_url,
.location_line1 = location_line1[0] != '\0' ? location_line1 : NULL,
.location_line2 = location_line2[0] != '\0' ? location_line2 : NULL,
.taken_at = taken_at[0] != '\0' ? taken_at : NULL,
.share_url = share_url[0] != '\0' ? share_url : NULL,
.face_labels = face_labels,
.face_label_count = face_label_count,
.battery_percent = battery_percent,
};
manage_overlay_set_t overlay;
esp_err_t err = manage_overlay_render(&content, &overlay);
if (err != ESP_OK) {
manage_overlay_free(&overlay);
return err;
}
err = fetch_and_display(cfg, action, &overlay);
manage_overlay_free(&overlay);
return err;
}
/* Runs the manage-button menu: level 1 (the base overlay) shows first;
* from there, each further press within 30s escalates one level (up to
* MANAGE_MENU_MAX_LEVEL, which adds named-face labels), and a press once
* already at the max level exits immediately instead of escalating
* further. A 30s timeout at any level also exits. Device stays awake
* throughout (doesn't sleep the panel or the chip). Returns non-ESP_OK
* only if the very first (level 1) render/fetch failed; failures after
* 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. */
/* Runs the manage-button view: fetches once with manage=1 (the server
* bakes its whole overlay -- scan-to-manage QR, battery, location/date/
* share-QR, every named face label, no more RAM-driven cap on how many --
* into the response), shows it, then waits up to 30s for either another
* press or the timeout before reverting to a plain fetch. Device stays
* awake throughout (doesn't sleep the panel or the chip). Returns
* non-ESP_OK only if the manage fetch itself failed; a revert failure
* after that is logged but doesn'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 level = 1;
esp_err_t err = show_menu_level(cfg, action, level);
esp_err_t err = fetch_and_display(cfg, action, true);
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);
ESP_LOGW(TAG, "Could not fetch manage view (%s), showing photo normally", esp_err_to_name(err));
return fetch_and_display(cfg, action, false);
}
for (;;) {
ESP_LOGI(TAG, "Showing management menu level %d, waiting up to 30s", level);
bool pressed = wait_for_button_press(MANAGE_MENU_LEVEL_TIMEOUT_MS);
if (!pressed || level >= MANAGE_MENU_MAX_LEVEL) {
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);
if (level_err != ESP_OK) {
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
break;
}
}
ESP_LOGI(TAG, "Showing manage view, waiting up to 30s");
wait_for_button_press(MANAGE_MENU_TIMEOUT_MS);
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, NULL);
esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, false);
if (revert_err != ESP_OK) {
ESP_LOGW(TAG, "Failed to revert management overlay (%s)", esp_err_to_name(revert_err));
ESP_LOGW(TAG, "Failed to revert manage view (%s)", esp_err_to_name(revert_err));
}
return ESP_OK;
}
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
* show_management_qr -- the escalating manage menu (see
* run_management_menu()). */
* show_management_qr -- the manage view (see run_management_menu()). */
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 fetch_and_display(cfg, action, false);
}
return run_management_menu(cfg, action);
}
@@ -1006,9 +705,10 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
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.
* overlay -- entirely server-composited now, using the server's
* own last-known battery value, not a local reading, see
* server/app/manage_overlay.py) 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 --
+7 -6
View File
@@ -33,12 +33,13 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg);
* deep-sleep until the next refresh.
*
* If show_management_qr is true (the manage button was held), the
* 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. Its
* battery indicator shows the server's last-known reading, not a fresh
* one -- see fetch_photo_info() in frame_client.c.
* request for that cycle carries &manage=1, and the server bakes its
* whole manage overlay (scan-to-manage QR, battery, location/date,
* share-QR, named face labels) directly into the image it returns --
* see server/app/manage_overlay.py; this device is otherwise unaware
* any of that exists, it just displays whatever comes back. Held for 30
* seconds (the device stays awake), then reverted back to a plain fetch
* before proceeding to the normal sleep-interval logic.
*
* 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
-356
View File
@@ -1,356 +0,0 @@
#include <stdlib.h>
#include <string.h>
#include "esp_check.h"
#include "epd7in3e.h"
#include "epd_draw.h"
#include "fonts.h"
#include "qrcodegen.h"
#include "manage_qr_overlay.h"
static const char *TAG = "manage_qr_overlay";
#define QR_MAX_VERSION 10
#define QR_BUFFER_LEN qrcodegen_BUFFER_LEN_FOR_VERSION(QR_MAX_VERSION)
/* Smaller than qr_onboarding.c's QR_MODULE_PX (8) -- these are compact
* corner popups, not a full-screen setup step. */
#define QR_MODULE_PX 4
#define PADDING 16
#define QR_TEXT_GAP 8
#define LINE_GAP 4
/* Distance from the panel's edges to each overlay box. Combined with
* EPD_WIDTH/EPD_HEIGHT and each region's forced-even width below, this
* guarantees x0 is always even -- required so a region's columns land on
* frame byte boundaries (2px/byte) when spliced into the fetch stream. */
#define PANEL_MARGIN 20
typedef enum {
CORNER_TOP_LEFT,
CORNER_TOP_RIGHT,
CORNER_BOTTOM_LEFT,
CORNER_BOTTOM_RIGHT,
} overlay_corner_t;
static void position_region(manage_overlay_region_t *region, overlay_corner_t corner)
{
switch (corner) {
case CORNER_TOP_LEFT:
region->x0 = PANEL_MARGIN;
region->y0 = PANEL_MARGIN;
break;
case CORNER_TOP_RIGHT:
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
region->y0 = PANEL_MARGIN;
break;
case CORNER_BOTTOM_LEFT:
region->x0 = PANEL_MARGIN;
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
break;
case CORNER_BOTTOM_RIGHT:
region->x0 = EPD_WIDTH - PANEL_MARGIN - region->w;
region->y0 = EPD_HEIGHT - PANEL_MARGIN - region->h;
break;
}
}
static void draw_qr(uint8_t *buf, int stride, int width, int height, const uint8_t *qrcode, int origin_x,
int origin_y)
{
int size = qrcodegen_getSize(qrcode);
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
epd_color_t color = qrcodegen_getModule(qrcode, x, y) ? EPD_COLOR_BLACK : EPD_COLOR_WHITE;
for (int dy = 0; dy < QR_MODULE_PX; dy++) {
for (int dx = 0; dx < QR_MODULE_PX; dx++) {
epd_draw_pixel_ex(buf, stride, width, height, origin_x + x * QR_MODULE_PX + dx,
origin_y + y * QR_MODULE_PX + dy, color);
}
}
}
}
}
/* White-padded box with a QR code encoding payload, plus zero, one, or
* two centered caption lines beneath it (either may be NULL). Used for
* both the top-right "scan to manage" box (two lines) and the
* bottom-left share-link box (no lines). */
static esp_err_t render_qr_region(const char *payload, const char *line1, const char *line2, overlay_corner_t corner,
manage_overlay_region_t *out)
{
uint8_t temp_buffer[QR_BUFFER_LEN];
uint8_t qrcode[QR_BUFFER_LEN];
bool ok = qrcodegen_encodeText(payload, temp_buffer, qrcode, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN,
QR_MAX_VERSION, qrcodegen_Mask_AUTO, true);
ESP_RETURN_ON_FALSE(ok, ESP_FAIL, TAG, "QR encoding failed for '%s' (too long for max version)", payload);
int qr_size = qrcodegen_getSize(qrcode);
int qr_px = qr_size * QR_MODULE_PX;
/* Font24 (32x41px uppercase glyphs) is the only font vendored into
* this project -- see components/epaper_fonts. "SCAN TO MANAGE" on
* one line would be 448px wide, too wide for a compact corner box,
* so it's passed in pre-wrapped across two lines instead. */
int text_w = 0;
int text_h = 0;
if (line1 != NULL) {
int w1 = (int)strlen(line1) * Font24.Width;
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
text_w = w1 > w2 ? w1 : w2;
text_h = QR_TEXT_GAP + Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
}
int content_w = qr_px > text_w ? qr_px : text_w;
int content_h = qr_px + text_h;
int w = content_w + PADDING * 2;
int h = content_h + PADDING * 2;
w += w % 2; /* keep byte-aligned (2px/byte) */
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
int center_x = w / 2;
int y = PADDING;
draw_qr(buf, stride, w, h, qrcode, center_x - qr_px / 2, y);
y += qr_px;
if (line1 != NULL) {
y += QR_TEXT_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
y += Font24.Height;
}
if (line2 != NULL) {
y += LINE_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
}
out->buf = buf;
out->w = w;
out->h = h;
position_region(out, corner);
return ESP_OK;
}
/* White-padded box with one or two centered lines of text (line2 may be
* NULL) -- used for the top-left location (city + state/country, two
* lines rather than cramming both onto one to keep the box from
* threatening to overlap the top-right QR box) and the bottom-right
* date-taken label (one line). */
static esp_err_t render_text_region(const char *line1, const char *line2, overlay_corner_t corner,
manage_overlay_region_t *out)
{
int w1 = (int)strlen(line1) * Font24.Width;
int w2 = line2 != NULL ? (int)strlen(line2) * Font24.Width : 0;
int text_w = w1 > w2 ? w1 : w2;
int text_h = Font24.Height + (line2 != NULL ? LINE_GAP + Font24.Height : 0);
int w = text_w + PADDING * 2;
int h = text_h + PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
int center_x = w / 2;
int y = PADDING;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line1, center_x, y);
if (line2 != NULL) {
y += Font24.Height + LINE_GAP;
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, line2, center_x, y);
}
out->buf = buf;
out->w = w;
out->h = h;
position_region(out, corner);
return ESP_OK;
}
/* Deliberately tighter than PADDING (used for the fixed QR/text corner
* boxes) -- these labels sit right next to a face rather than needing
* generous QR-scanning margin, and there can be several of them
* simultaneously (see MANAGE_FACE_LABELS_MAX's memory-budget note in
* the header). */
#define FACE_LABEL_PADDING 8
#define FACE_LABEL_GAP 4 /* distance from the face's anchor point to the label box */
/* White-padded single-line name label positioned near an arbitrary
* (anchor_x, anchor_y) face position, rather than a fixed corner --
* unlike the four corner regions (always in-bounds by construction),
* this needs real clamping since a face can be anywhere, including near
* an edge. Centered horizontally on the face, placed just below it by
* default, flipped above if there's no room below. */
static esp_err_t render_face_label_region(const char *name, int anchor_x, int anchor_y, manage_overlay_region_t *out)
{
int text_w = (int)strlen(name) * Font24.Width;
int w = text_w + FACE_LABEL_PADDING * 2;
int h = Font24.Height + FACE_LABEL_PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
epd_draw_text_centered_ex(buf, stride, w, h, &Font24, name, w / 2, FACE_LABEL_PADDING);
int x0 = anchor_x - w / 2;
int y0 = anchor_y + FACE_LABEL_GAP;
if (y0 + h > EPD_HEIGHT) {
y0 = anchor_y - FACE_LABEL_GAP - h; /* no room below -- place above the face instead */
}
if (x0 < 0) {
x0 = 0;
} else if (x0 + w > EPD_WIDTH) {
x0 = EPD_WIDTH - w;
}
if (y0 < 0) {
y0 = 0;
} else if (y0 + h > EPD_HEIGHT) {
y0 = EPD_HEIGHT - h;
}
x0 -= x0 % 2; /* keep byte-aligned (2px/byte) */
out->buf = buf;
out->w = w;
out->h = h;
out->x0 = x0;
out->y0 = y0;
return ESP_OK;
}
/* Battery glyph dimensions -- a static outline (body rectangle + small
* terminal nub on the right), deliberately NOT a fill-level graphic. */
#define BATTERY_ICON_W 44
#define BATTERY_ICON_H 24
#define BATTERY_ICON_STROKE 2
#define BATTERY_NUB_W 6
#define BATTERY_NUB_H 12
#define BATTERY_ICON_TEXT_GAP 8
#define BATTERY_REGION_GAP 8 /* vertical gap below the manage QR box */
static void draw_battery_icon(uint8_t *buf, int stride, int width, int height, int x0, int y0)
{
for (int y = 0; y < BATTERY_ICON_H; y++) {
for (int x = 0; x < BATTERY_ICON_W; x++) {
bool edge = x < BATTERY_ICON_STROKE || x >= BATTERY_ICON_W - BATTERY_ICON_STROKE ||
y < BATTERY_ICON_STROKE || y >= BATTERY_ICON_H - BATTERY_ICON_STROKE;
if (edge) {
epd_draw_pixel_ex(buf, stride, width, height, x0 + x, y0 + y, EPD_COLOR_BLACK);
}
}
}
int nub_y = y0 + (BATTERY_ICON_H - BATTERY_NUB_H) / 2;
for (int y = 0; y < BATTERY_NUB_H; y++) {
for (int x = 0; x < BATTERY_NUB_W; x++) {
epd_draw_pixel_ex(buf, stride, width, height, x0 + BATTERY_ICON_W + x, nub_y + y, EPD_COLOR_BLACK);
}
}
}
/* White-padded box with the battery glyph and "NN%" beside it, placed
* directly below an already-positioned anchor region (the top-right
* manage QR box), right-aligned to the anchor's right edge. */
static esp_err_t render_battery_region(int percent, const manage_overlay_region_t *anchor,
manage_overlay_region_t *out)
{
char text[8];
snprintf(text, sizeof(text), "%d%%", percent);
int icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W;
int text_w = (int)strlen(text) * Font24.Width;
int content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w;
int content_h = Font24.Height > BATTERY_ICON_H ? Font24.Height : BATTERY_ICON_H;
int w = content_w + PADDING * 2;
int h = content_h + PADDING * 2;
w += w % 2;
int stride = w / 2;
uint8_t *buf = malloc((size_t)stride * h);
ESP_RETURN_ON_FALSE(buf != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate overlay region");
memset(buf, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, (size_t)stride * h);
draw_battery_icon(buf, stride, w, h, PADDING, PADDING + (content_h - BATTERY_ICON_H) / 2);
epd_draw_text_ex(buf, stride, w, h, &Font24, text, PADDING + icon_total_w + BATTERY_ICON_TEXT_GAP,
PADDING + (content_h - Font24.Height) / 2);
out->buf = buf;
out->w = w;
out->h = h;
out->x0 = anchor->x0 + anchor->w - w;
out->x0 -= out->x0 % 2; /* keep byte-aligned (2px/byte) */
out->y0 = anchor->y0 + anchor->h + BATTERY_REGION_GAP;
return ESP_OK;
}
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out)
{
out->count = 0;
esp_err_t err = render_qr_region(content->management_url, "SCAN TO", "MANAGE", CORNER_TOP_RIGHT,
&out->regions[out->count]);
if (err != ESP_OK) {
return err;
}
out->count++;
if (content->battery_percent >= 0 && content->battery_percent <= 100) {
/* Anchored below the manage QR box just rendered (regions[0]). */
if (render_battery_region(content->battery_percent, &out->regions[0], &out->regions[out->count]) ==
ESP_OK) {
out->count++;
}
}
if (content->location_line1 != NULL && content->location_line1[0] != '\0') {
const char *line2 =
(content->location_line2 != NULL && content->location_line2[0] != '\0') ? content->location_line2 : NULL;
if (render_text_region(content->location_line1, line2, CORNER_TOP_LEFT, &out->regions[out->count]) ==
ESP_OK) {
out->count++;
}
}
if (content->taken_at != NULL && content->taken_at[0] != '\0') {
if (render_text_region(content->taken_at, NULL, CORNER_BOTTOM_RIGHT, &out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
if (content->share_url != NULL && content->share_url[0] != '\0') {
if (render_qr_region(content->share_url, "SCAN TO", "DOWNLOAD", CORNER_BOTTOM_LEFT,
&out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
int face_count = content->face_label_count;
if (face_count > MANAGE_FACE_LABELS_MAX) {
face_count = MANAGE_FACE_LABELS_MAX;
}
for (int i = 0; content->face_labels != NULL && i < face_count; i++) {
const manage_face_label_t *label = &content->face_labels[i];
if (label->name[0] == '\0') {
continue;
}
if (render_face_label_region(label->name, label->x, label->y, &out->regions[out->count]) == ESP_OK) {
out->count++;
}
}
return ESP_OK;
}
void manage_overlay_free(manage_overlay_set_t *overlay)
{
for (int i = 0; i < overlay->count; i++) {
free(overlay->regions[i].buf);
overlay->regions[i].buf = NULL;
}
overlay->count = 0;
}
-61
View File
@@ -1,61 +0,0 @@
#pragma once
#include <stdint.h>
#include "esp_err.h"
/* 5 fixed regions (manage QR, battery indicator, location, date, share
* QR) plus up to MANAGE_FACE_LABELS_MAX arbitrary-position named-face
* labels (see manage_face_label_t below). MANAGE_FACE_LABELS_MAX is
* capped small deliberately, not arbitrarily -- each label is its own
* malloc'd buffer, and the fixed regions alone already use a meaningful
* chunk of the ESP32-C6's limited RAM; this keeps worst-case overlay
* memory well clear of what the WiFi/HTTP stack needs alongside it. */
#define MANAGE_FACE_LABELS_MAX 4
#define MANAGE_OVERLAY_MAX_REGIONS (5 + MANAGE_FACE_LABELS_MAX)
typedef struct {
uint8_t *buf; /* malloc'd (w/2)*h bytes, packed 2px/byte; owned by the region */
int x0, y0; /* top-left corner, panel pixel coordinates (x0 is always even) */
int w, h; /* pixel dimensions (w is always even) */
} manage_overlay_region_t;
typedef struct {
manage_overlay_region_t regions[MANAGE_OVERLAY_MAX_REGIONS];
int count;
} manage_overlay_set_t;
typedef struct {
char name[16];
int x, y; /* anchor point (bottom-center of the face), panel pixel coordinates */
} manage_face_label_t;
typedef struct {
const char *management_url; /* top-right QR + "SCAN TO"/"MANAGE" caption -- always shown */
const char *location_line1; /* top-left text, line 1 (city); NULL/empty skips this region */
const char *location_line2; /* top-left text, line 2 (state/country); NULL/empty is fine if line1 is set */
const char *taken_at; /* bottom-right text; NULL/empty skips this region */
const char *share_url; /* bottom-left QR + "SCAN TO"/"DOWNLOAD" caption; NULL/empty skips this region */
const manage_face_label_t *face_labels; /* named-face labels ("level 2" menu); NULL/empty count skips these */
int face_label_count; /* clamped to MANAGE_FACE_LABELS_MAX internally */
int battery_percent; /* 0-100 shows an icon + percent below the manage QR; -1 skips it */
} manage_overlay_content_t;
/**
* Renders the manage-button overlay: always a "scan to manage" QR in the
* top-right corner, plus whichever of location_line1/taken_at/share_url
* are non-NULL/non-empty in their own corners (top-left, bottom-right,
* bottom-left respectively), plus one region per entry in face_labels
* (positioned near that face rather than a fixed corner -- see
* render_face_label_region() in the .c file for the clamping logic).
* Each region is its own separately malloc'd small buffer (not a full
* EPD_FRAME_BYTES frame). A failure rendering the top-right region fails
* the whole call; a failure rendering any other region just skips that
* region and keeps going. Caller must call manage_overlay_free() on out
* regardless of the return value (out->count reflects however many
* regions were actually populated).
*/
esp_err_t manage_overlay_render(const manage_overlay_content_t *content, manage_overlay_set_t *out);
/** Frees every populated region's buffer in overlay. */
void manage_overlay_free(manage_overlay_set_t *overlay);
+1 -1
View File
@@ -1 +1 @@
1.2.2
1.3.0
+7
View File
@@ -206,6 +206,13 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
to photos this frame is actually showing or has queued, not any
Immich asset ID someone might guess -- a second layer a leaked device
token alone wouldn't bypass.
- Calendar frame mode (`app/calendar_feed.py`) expands recurring events
(RRULE/EXDATE/DST) via [`recurring-ical-events`](https://pypi.org/project/recurring-ical-events/),
which is LGPL-3.0-or-later -- the only non-permissively-licensed
dependency here. It's used as an ordinary `pip install` runtime import,
never vendored or modified, so this project's own code stays under its
own license; LGPL's copyleft terms apply to that library itself, not
to code that merely links against it dynamically.
- The 6-color palette RGB values in `app/image_pipeline.py`
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
(Waveshare doesn't publish exact color primaries for this panel).
+110
View File
@@ -0,0 +1,110 @@
"""Fetch, parse, and merge per-user ICS calendar feeds for calendar frame
mode (see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
get_or_refresh_calendar_events) supply plain (owner_display_name, url)
pairs, not ORM objects, so this module stays testable against fixture .ics
text with no database or app involved.
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
to get right (see its own docs), not worth reinventing. It's LGPL-3.0 (an
ordinary runtime pip dependency, never vendored/modified -- see the
server README's Notes section for why that doesn't put this project's own
code under LGPL terms).
"""
from __future__ import annotations
from datetime import date, datetime
import httpx
import icalendar
import recurring_ical_events
HTTP_TIMEOUT_S = 15.0
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
CHECK_INTERVAL_S = 20 * 60 # don't refetch/reparse any feed more often than this
# How far back/forward each merge-fetch expands recurring events. Households
# look back far less than they plan ahead, hence the asymmetry. Browsing
# outside this window (calendar_browse_offset) just yields an empty view,
# not an error -- self-heals on the next normal wake regardless.
EXPAND_WINDOW_PAST_DAYS = 30
EXPAND_WINDOW_FUTURE_DAYS = 200
class CalendarFetchError(Exception):
"""One feed was unreachable, not valid ICS, or too large. Raised by
fetch_source_events(); merge_events() is what catches this per-source
so one broken feed can't blank out another's events."""
def fetch_source_events(url: str, window_start: date, window_end: date) -> list[dict]:
"""One feed: download, parse, expand recurrences within
[window_start, window_end]. Raises CalendarFetchError on any problem
-- network, malformed ICS, or an oversized response."""
try:
with httpx.stream("GET", url, timeout=HTTP_TIMEOUT_S, follow_redirects=True) as resp:
resp.raise_for_status()
chunks = []
total = 0
for chunk in resp.iter_bytes():
total += len(chunk)
if total > FETCH_MAX_BYTES:
raise CalendarFetchError(f"Feed exceeds {FETCH_MAX_BYTES} bytes")
chunks.append(chunk)
body = b"".join(chunks)
except httpx.HTTPError as e:
raise CalendarFetchError(str(e)) from e
try:
cal = icalendar.Calendar.from_ical(body)
occurrences = recurring_ical_events.of(cal).between(window_start, window_end)
except Exception as e: # icalendar/recurring_ical_events raise a mix of ValueError-family exceptions
raise CalendarFetchError(f"Could not parse ICS feed: {e}") from e
events = []
for occ in occurrences:
dtstart = occ.get("DTSTART")
dtend = occ.get("DTEND")
if dtstart is None:
continue
start_dt = dtstart.dt
end_dt = dtend.dt if dtend is not None else start_dt
all_day = not isinstance(start_dt, datetime) # date, not datetime -- VALUE=DATE
events.append({
"summary": str(occ.get("SUMMARY") or "(untitled)"),
"start": start_dt.isoformat(),
"end": end_dt.isoformat(),
"all_day": all_day,
})
return events
def merge_events(
sources: list[tuple[str, str]], window_start: date, window_end: date
) -> tuple[list[dict], str]:
"""sources: [(owner_display_name, ics_url), ...]. Fetches each
independently -- one broken feed never blanks another's events.
Returns (merged_time_sorted_events, fetch_summary); fetch_summary is
"" when every source succeeded, else "N of M calendars unavailable"
(never *which* source -- naming whose feed is down to everyone who
looks at a shared household display is a bigger overshare than the
outage itself)."""
merged: list[dict] = []
failures = 0
for owner_display_name, url in sources:
try:
events = fetch_source_events(url, window_start, window_end)
except CalendarFetchError:
failures += 1
continue
for event in events:
event["owner_display_name"] = owner_display_name
merged.append(event)
merged.sort(key=lambda e: e["start"])
summary = f"{failures} of {len(sources)} calendars unavailable" if failures else ""
return merged, summary
+292
View File
@@ -0,0 +1,292 @@
"""Renders calendar frame mode's three views (agenda/week/month) into the
panel's packed format, following image_pipeline.render_placeholder's own
precedent: build an RGB canvas with ImageDraw/ImageFont, then the same
_quantize/_transpose_and_pack every other renderer ends on.
Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
(ISO 8601 strings), "all_day", "owner_display_name"}.
"""
from __future__ import annotations
import calendar as calendar_module
import io
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import (
DEFAULT_PALETTE_RGB,
_apply_manage_overlay,
_quantize,
_transpose_and_pack,
compose_into,
logical_render_size,
)
CALENDAR_VIEWS = ["agenda", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "week": "Week", "month": "Month"}
MARGIN = 20
BG = (255, 255, 255)
FG = (0, 0, 0)
MUTED = (110, 110, 110)
RULE = (200, 200, 200)
# Cycled per distinct owner_display_name so a merged multi-person calendar
# can visually tell whose event is whose -- the panel's own non-black/
# white ink colors, skipping black/white (index 0/1 in DEFAULT_PALETTE_RGB)
# since those are already the page's text/background.
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
def _owner_color(owner_display_name: str, owners_seen: list[str]) -> tuple[int, int, int]:
if owner_display_name not in owners_seen:
owners_seen.append(owner_display_name)
return OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)]
def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
"""Parses event["start"] and, for timed events, converts to `tz` --
calendar_feed.py stores whatever timezone each source event carried
(often UTC), but display/bucketing needs to happen in the frame's own
timezone."""
dt = datetime.fromisoformat(event["start"])
if event["all_day"]:
return dt if isinstance(dt, date) and not isinstance(dt, datetime) else dt.date()
return dt.astimezone(tz)
def _events_on_day(events: list[dict], day: date, tz: ZoneInfo) -> list[dict]:
on_day = [e for e in events if _local_date(e, tz) == day]
on_day.sort(key=lambda e: (not e["all_day"], e["start"]))
return on_day
def _local_date(event: dict, tz: ZoneInfo) -> date:
start = _event_start(event, tz)
return start if isinstance(start, date) and not isinstance(start, datetime) else start.date()
def _add_months(d: date, months: int) -> date:
total = d.month - 1 + months
year = d.year + total // 12
month = total % 12 + 1
day = min(d.day, calendar_module.monthrange(year, month)[1])
return date(year, month, day)
def _fmt_time(dt: datetime) -> str:
text = dt.strftime("%I:%M %p").lstrip("0")
return text if text else "12:00 AM"
def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str:
"""Pixel-width-aware truncation (unlike device.py's char-count
_truncate, tuned for a fixed firmware font at a fixed size) -- this
module draws at several different sizes, so truncation has to
measure the actual font/size in play."""
if draw.textlength(text, font=font) <= max_width:
return text
ellipsis = "..."
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if draw.textlength(text[:mid] + ellipsis, font=font) <= max_width:
lo = mid
else:
hi = mid - 1
return text[:lo] + ellipsis if lo else ellipsis
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
photo_inlay: Image.Image | None) -> Image.Image:
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
text_x0 = MARGIN
text_w = logical_w - MARGIN * 2
if photo_inlay is not None:
# Long axis split: landscape splits left/right, portrait top/bottom.
if logical_w >= logical_h:
photo_w = logical_w // 2
photo = compose_into(photo_inlay, None, photo_w, logical_h, "crop_fill")
img.paste(photo, (0, 0))
text_x0 = photo_w + MARGIN
text_w = logical_w - photo_w - MARGIN * 2
else:
photo_h = logical_h // 2
photo = compose_into(photo_inlay, None, logical_w, photo_h, "crop_fill")
img.paste(photo, (0, 0))
draw = ImageDraw.Draw(img)
# Smaller title when the inlay halves the available width -- "Wednesday,
# July 22" at full size doesn't fit ~360px, and a *narrower* column is
# exactly when a smaller font (rather than truncating to "Wednesday...")
# keeps it actually informative.
title_font = ImageFont.load_default(size=34 if photo_inlay is None else 24)
body_font = ImageFont.load_default(size=22)
text_y0 = MARGIN if photo_inlay is None or logical_w >= logical_h else logical_h // 2 + MARGIN
day = datetime.now(tz).date() + timedelta(days=browse_offset)
header = day.strftime("%A, %B ") + str(day.day)
draw.text((text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), fill=FG, font=title_font)
y = text_y0 + title_font.size + 12
draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
y += 12
day_events = _events_on_day(events, day, tz)
owners_seen: list[str] = []
row_h = body_font.size + 14
max_rows = max(0, (logical_h - MARGIN - y) // row_h)
if not day_events:
draw.text((text_x0, y), "Nothing scheduled", fill=MUTED, font=body_font)
for i, event in enumerate(day_events):
if i >= max_rows:
draw.text((text_x0, y), f"+{len(day_events) - max_rows} more", fill=MUTED, font=body_font)
break
color = _owner_color(event["owner_display_name"], owners_seen)
draw.rectangle([text_x0, y + 3, text_x0 + 6, y + row_h - 8], fill=color)
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
line = f"{time_str} {event['summary']}"
draw.text((text_x0 + 16, y), _truncate_to_width(draw, line, body_font, text_w - 16), fill=FG, font=body_font)
y += row_h
return img
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo) -> Image.Image:
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
draw = ImageDraw.Draw(img)
header_font = ImageFont.load_default(size=18)
chip_font = ImageFont.load_default(size=14)
today = datetime.now(tz).date()
week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=browse_offset)
col_w = (logical_w - MARGIN * 2) // 7
header_h = 44
owners_seen: list[str] = []
for col in range(7):
day = week_start + timedelta(days=col)
x0 = MARGIN + col * col_w
if col > 0:
draw.line([(x0, MARGIN), (x0, logical_h - MARGIN)], fill=RULE)
label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
draw.text((x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), fill=FG, font=header_font)
y = MARGIN + header_h
row_h = chip_font.size + 10
max_rows = max(0, (logical_h - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz)
for i, event in enumerate(day_events):
if i >= max_rows:
draw.text((x0 + 6, y), f"+{len(day_events) - max_rows}", fill=MUTED, font=chip_font)
break
color = _owner_color(event["owner_display_name"], owners_seen)
draw.rectangle([x0 + 4, y + 2, x0 + 8, y + row_h - 6], fill=color)
text = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
draw.text((x0 + 14, y), _truncate_to_width(draw, text, chip_font, col_w - 18), fill=FG, font=chip_font)
y += row_h
return img
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo) -> Image.Image:
"""Density dots per day, not literal event text -- real text at
typical month-cell size (~100x70px) is close to unreadable on a
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), BG)
draw = ImageDraw.Draw(img)
header_font = ImageFont.load_default(size=16)
day_font = ImageFont.load_default(size=18)
today = datetime.now(tz).date()
target_month = _add_months(date(today.year, today.month, 1), browse_offset)
weeks = list(calendar_module.Calendar(firstweekday=0).monthdatescalendar(target_month.year, target_month.month))
col_w = (logical_w - MARGIN * 2) // 7
header_h = 28
grid_top = MARGIN + header_h
row_h = (logical_h - MARGIN - grid_top) // len(weeks)
for col, name in enumerate(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]):
draw.text((MARGIN + col * col_w + 6, MARGIN), name, fill=MUTED, font=header_font)
owners_seen: list[str] = []
dot_r = 4
for row, week in enumerate(weeks):
for col, day in enumerate(week):
x0 = MARGIN + col * col_w
y0 = grid_top + row * row_h
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
in_month = day.month == target_month.month
color = FG if in_month else MUTED
if day == today:
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
draw.text((x0 + 6, y0 + 4), str(day.day), fill=color, font=day_font)
day_events = _events_on_day(events, day, tz)
dot_x = x0 + 8
dot_y = y0 + row_h - 14
for i, event in enumerate(day_events[:4]):
color = _owner_color(event["owner_display_name"], owners_seen)
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=color)
dot_x += dot_r * 2 + 4
if len(day_events) > 4:
draw.text((dot_x, dot_y - 4), f"+{len(day_events) - 4}", fill=MUTED, font=header_font)
return img
_BUILDERS = {"agenda": _build_agenda, "week": _build_week, "month": _build_month}
def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str,
photo_inlay: Image.Image | None, fetch_summary: str) -> Image.Image:
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
builder = _BUILDERS.get(view, _build_agenda)
if builder is _build_agenda:
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay)
else:
img = builder(events, browse_offset, orientation, tz)
if fetch_summary:
draw = ImageDraw.Draw(img)
font = ImageFont.load_default(size=14)
logical_w, logical_h = img.size
draw.text((MARGIN, logical_h - MARGIN - font.size), fetch_summary, fill=MUTED, font=font)
return img
def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None,
fetch_summary: str = "", manage: dict | None = None) -> bytes:
"""Renders one of CALENDAR_VIEWS to the panel's packed format. Always
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same invariant every
other renderer honors."""
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str,
palette_rgb: list | None, timezone: str, photo_inlay: Image.Image | None = None,
fetch_summary: str = "", manage: dict | None = None) -> bytes:
"""Same pipeline as render_calendar, but a normal browser-viewable
PNG in logical (upright) orientation -- mirrors
image_pipeline.render_preview_png's relationship to render_frame."""
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary)
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
return buf.getvalue()
+21 -24
View File
@@ -1,6 +1,6 @@
"""Maps named faces (from Immich's own face recognition/People feature)
onto their position in the final rendered 800x480 frame, for the
manage-button overlay's escalated "who's in this photo" menu level.
onto their position in the final rendered frame, for the manage-button
overlay's named-face labels (see manage_overlay.py, which draws them).
No face detection or recognition happens here or anywhere else in this
project -- Immich's GET /api/faces?id={assetId} already returns each
@@ -15,32 +15,32 @@ 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
# 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
# four existing fixed corner regions already use a meaningful chunk of
# the ESP32-C6's limited RAM. Capping at 4 short names keeps the total
# overlay memory budget well clear of the WiFi/HTTP stack's own needs.
MAX_LABELED_FACES = 4
NAME_MAX_LEN = 10
# Not a memory constraint anymore (the overlay renders server-side now,
# not malloc'd per-label on the device) -- purely a legibility cap. A
# photo with a dozen named people would just be visual clutter regardless
# of what's rendering it.
MAX_LABELED_FACES = 6
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
orientation: str = "landscape") -> list[dict]:
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
800x480 panel pixel space at each named face's bottom-center point.
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in
logical (pre-rotation) frame space at each named face's bottom-center
point -- manage_overlay.compose() draws these directly onto the
logical-space image before it's rotated into native panel space, so
no rotation happens here (contrast with the old firmware-side
version, which drew post-rotation and needed logical_to_native).
Faces without an Immich-identified person name are skipped entirely.
preview_bytes must be the same preview image render_frame() used for
the currently-displayed frame, and display_mode/orientation must
match the settings that were active then -- otherwise the placement
and rotation computed here won't match what's actually on screen.
computed here won't match what's actually on screen.
The placement math runs in logical (pre-rotation) space, matching
render_frame()'s composition step (see image_pipeline._placement_transform,
shared so the two can't drift apart); each anchor is then rotated
into native panel coordinates via logical_to_native(), since the
firmware draws labels in native space.
The placement math matches render_frame()'s own composition step
exactly (see image_pipeline._placement_transform, shared so the two
can't drift apart).
"""
named = [face for face in faces if (face.get("person") or {}).get("name")]
if not named:
@@ -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
@@ -69,11 +71,6 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: s
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
continue # this face got cropped out of the final frame entirely
name = face["person"]["name"]
if len(name) > NAME_MAX_LEN:
name = name[: NAME_MAX_LEN - 3] + "..."
native_x, native_y = logical_to_native(frame_x, frame_y, orientation)
labels.append({"name": name, "x": native_x, "y": native_y})
labels.append({"name": face["person"]["name"], "x": int(frame_x), "y": int(frame_y)})
return labels
+74 -20
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
@@ -210,26 +222,37 @@ def _placement_transform(
return scale_x, scale_y, -left * scale_x, -top * scale_y
def compose_into(source: Image.Image, faces: list[dict] | None, target_w: int, target_h: int,
display_mode: str) -> Image.Image:
"""Crop/resize/letterbox `source` per display_mode into an arbitrary
target_w x target_h box -- returns an RGB image, before enhancement or
quantization. See render_frame for what each display_mode does.
_compose() is the common case of this (target = the full panel, at
logical_render_size(orientation)); this more general form also backs
calendar_render.py's agenda photo-inlay, which composes into just a
sub-region of the panel instead of the whole thing."""
fitted = ImageOps.exif_transpose(source.convert("RGB"))
if display_mode == "stretch_fill":
return fitted.resize((target_w, target_h), Image.LANCZOS)
if display_mode == "letterbox":
scale = min(target_w / fitted.width, target_h / fitted.height)
new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale))
resized = fitted.resize((new_w, new_h), Image.LANCZOS)
canvas = Image.new("RGB", (target_w, target_h), LETTERBOX_BG)
canvas.paste(resized, ((target_w - new_w) // 2, (target_h - new_h) // 2))
return canvas
if display_mode == "crop_faces" and faces:
box = _face_aware_crop_box(fitted.width, fitted.height, target_w, target_h, faces)
return fitted.crop(box).resize((target_w, target_h), Image.LANCZOS)
return ImageOps.fit(fitted, (target_w, target_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str) -> Image.Image:
"""Crop/resize/letterbox `source` per display_mode -- returns an RGB
image at logical_render_size(orientation), before enhancement or
quantization. See render_frame for what each display_mode does."""
logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(source.convert("RGB"))
if display_mode == "stretch_fill":
return fitted.resize((logical_w, logical_h), Image.LANCZOS)
if display_mode == "letterbox":
scale = min(logical_w / fitted.width, logical_h / fitted.height)
new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale))
resized = fitted.resize((new_w, new_h), Image.LANCZOS)
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
canvas.paste(resized, ((logical_w - new_w) // 2, (logical_h - new_h) // 2))
return canvas
if display_mode == "crop_faces" and faces:
box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
return fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
return ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
return compose_into(source, faces, *logical_render_size(orientation), display_mode)
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
@@ -280,10 +303,25 @@ def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
return bytes(out)
def _apply_manage_overlay(img: Image.Image, manage: dict | None) -> Image.Image:
"""Composites the manage-button overlay (scan-to-manage QR, battery,
location/date/share-QR, named face labels) onto an already-composed,
already-enhanced image, if requested -- see manage_overlay.compose().
Local import: manage_overlay is an optional, occasionally-used
concern (only /frame/*?manage=1 requests need it), same reasoning
render_placeholder already applies to its own `import qrcode`."""
if manage is None:
return img
from . import manage_overlay
return manage_overlay.compose(img, **manage)
def render_frame(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes:
contrast_boost: float = 1.0, dither_strength: float = 1.0,
manage: dict | None = None) -> bytes:
"""Fits `source` to the panel's resolution, applies color/contrast
enhancement, quantizes it to the 6-color palette, and packs 2
pixels/byte the way epd7in3e.c expects. Always returns exactly
@@ -306,8 +344,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
`palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors,
see Frame.palette_rgb) -- None uses the default.
`manage` is a dict of manage_overlay.compose()'s kwargs (management_url,
battery_percent, location_lines, taken_at, share_url, face_labels), or
None to skip it -- see routers/device.py's build_manage_content(),
which callers pass this straight through from. Applied after
enhancement, before quantization, so the overlay's pure black/white
graphics aren't affected by color/contrast boost.
"""
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
return _transpose_and_pack(quantized, orientation)
@@ -315,13 +361,15 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes:
contrast_boost: float = 1.0, dither_strength: float = 1.0,
manage: dict | None = None) -> bytes:
"""Identical composition/enhancement/quantization pipeline as
render_frame, but returned as a normal browser-viewable PNG in
logical (upright, as-the-frame-actually-hangs) orientation rather
than packed native-panel bytes and rotation -- what the web UI's
"how it will look on the frame" preview shows."""
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
buf = io.BytesIO()
quantized.convert("RGB").save(buf, format="PNG")
@@ -329,11 +377,16 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape", palette_rgb: list | None = None) -> bytes:
orientation: str = "landscape", palette_rgb: list | None = None,
manage: dict | None = None) -> bytes:
"""A readable full-panel message (plus an optional QR code) in the
same packed format as render_frame -- what /frame/image serves for a
frame that isn't claimed or configured yet, so a fresh device shows
instructions instead of an error screen and never error-loops."""
instructions instead of an error screen and never error-loops.
`manage`, same as render_frame's -- lets the manage button still work
(at minimum, the scan-to-manage QR) on a frame that isn't configured
yet."""
from PIL import ImageDraw, ImageFont
logical_w, logical_h = logical_render_size(orientation)
@@ -374,5 +427,6 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
if qr_img:
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _transpose_and_pack(quantized, orientation)
+212
View File
@@ -0,0 +1,212 @@
"""Composites the manage-button overlay -- "scan to manage" QR, battery,
location/date-taken, share-QR, named face labels -- server-side, onto an
already-composed image (any mode: a photo, or a calendar view), before
quantization. Replaces what used to be firmware/main/manage_qr_overlay.c
generating and positioning all of this on-device.
Corner/spacing constants below are plain Python now, not a protocol
contract with firmware -- adjustable here without touching anything else.
Uses the same toolkit image_pipeline.render_placeholder already does
(PIL ImageDraw/ImageFont, the qrcode library), just doing more with it.
"""
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
PADDING = 16
QR_TEXT_GAP = 8
LINE_GAP = 4
PANEL_MARGIN = 20
QR_TARGET_PX = 180
TITLE_FONT_SIZE = 22
BODY_FONT_SIZE = 20
BATTERY_ICON_W = 40
BATTERY_ICON_H = 22
BATTERY_ICON_STROKE = 2
BATTERY_NUB_W = 5
BATTERY_NUB_H = 10
BATTERY_ICON_TEXT_GAP = 8
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
FACE_LABEL_PADDING = 8
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
def _font(size: int) -> ImageFont.ImageFont:
return ImageFont.load_default(size=size)
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
import qrcode
qr = qrcode.QRCode(border=1, box_size=1)
qr.add_data(url)
qr.make(fit=True)
raw = qr.make_image().get_image().convert("RGB")
scale = max(1, target_px // raw.width)
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont) -> tuple[int, int]:
"""(width, height) of `lines` stacked with LINE_GAP between them, at
`font` -- the box _draw_text_box below will need."""
w = 0
h = 0
for i, line in enumerate(lines):
bbox = draw.textbbox((0, 0), line, font=font)
w = max(w, bbox[2] - bbox[0])
h += (bbox[3] - bbox[1]) + (LINE_GAP if i else 0)
return w, h
def _draw_centered_lines(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
center_x: int, top: int) -> None:
y = top
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
w = bbox[2] - bbox[0]
draw.text((center_x - w // 2, y), line, fill=(0, 0, 0), font=font)
y += (bbox[3] - bbox[1]) + LINE_GAP
def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption: list[str],
corner: str) -> tuple[int, int, int, int]:
"""White-padded box with a QR code and centered caption lines below
it, placed in one of the panel's four corners. Returns (x0, y0, w, h)
-- callers that need to anchor something else relative to this box
(the battery, below the manage QR) use it instead of recomputing the
same geometry a second time."""
qr_img = _qr_image(url)
text_w, text_h = _text_box(draw, caption, _font(TITLE_FONT_SIZE)) if caption else (0, 0)
content_w = max(qr_img.width, text_w)
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0)
w = content_w + PADDING * 2
h = content_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
center_x = x0 + w // 2
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
if caption:
_draw_centered_lines(draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
return x0, y0, w, h
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None:
"""White-padded box with centered text lines, placed in one of the
panel's four corners."""
font = _font(BODY_FONT_SIZE)
text_w, text_h = _text_box(draw, lines, font)
w = text_w + PADDING * 2
h = text_h + PADDING * 2
x0, y0 = _corner_origin(img.size, (w, h), corner)
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
_draw_centered_lines(draw, lines, font, x0 + w // 2, y0 + PADDING)
def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]:
img_w, img_h = img_size
box_w, box_h = box_size
if corner == "top-left":
return PANEL_MARGIN, PANEL_MARGIN
if corner == "top-right":
return img_w - PANEL_MARGIN - box_w, PANEL_MARGIN
if corner == "bottom-left":
return PANEL_MARGIN, img_h - PANEL_MARGIN - box_h
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
anchor_w: int, anchor_h: int) -> None:
"""Battery glyph + "NN%" text, right-aligned under the given anchor
box (the manage QR box) -- a sensible default position, not a
constraint anything else has to route around; move this call site's
arguments to place it anywhere else instead."""
font = _font(BODY_FONT_SIZE)
text = f"{percent}%"
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
text_w = draw.textlength(text, font=font)
content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w
content_h = max(font.size, BATTERY_ICON_H)
w = int(content_w + PADDING * 2)
h = int(content_h + PADDING * 2)
x0 = anchor_x0 + anchor_w - w
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
icon_x = x0 + PADDING
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
width=BATTERY_ICON_STROKE)
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
fill=(0, 0, 0))
draw.text((icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
text, fill=(0, 0, 0), font=font)
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
"""White-padded name label centered under an arbitrary (anchor_x,
anchor_y) point, flipped above if there's no room below, clamped to
stay fully on-panel -- unlike the four corner boxes (always in-bounds
by construction), a face can be anywhere, including near an edge."""
font = _font(BODY_FONT_SIZE)
text_w = draw.textlength(name, font=font)
bbox = draw.textbbox((0, 0), name, font=font)
text_h = bbox[3] - bbox[1]
w = int(text_w + FACE_LABEL_PADDING * 2)
h = int(text_h + FACE_LABEL_PADDING * 2)
img_w, img_h = img.size
x0 = anchor_x - w // 2
y0 = anchor_y + FACE_LABEL_GAP
if y0 + h > img_h:
y0 = anchor_y - FACE_LABEL_GAP - h # no room below -- place above instead
x0 = max(0, min(x0, img_w - w))
y0 = max(0, min(y0, img_h - h))
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
draw.text((x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, fill=(0, 0, 0), font=font)
def compose(image: Image.Image, management_url: str, battery_percent: int | None = None,
location_lines: tuple[str, str] | None = None, taken_at: str | None = None,
share_url: str | None = None, face_labels: list[dict] | None = None) -> Image.Image:
"""Draws the manage overlay onto a copy of `image` (RGB, any mode's
already-composed/enhanced logical-space canvas) and returns it.
management_url's "scan to manage" box always shows; everything else
is optional and simply omitted when not given -- battery_percent
None or out of 0-100 skips the battery box, location_lines/taken_at/
share_url empty/None skip their own box, face_labels empty skips
those."""
img = image.copy()
draw = ImageDraw.Draw(img)
qr_x0, qr_y0, qr_w, qr_h = _draw_qr_box(img, draw, management_url, ["SCAN TO", "MANAGE"], "top-right")
if battery_percent is not None and 0 <= battery_percent <= 100:
_draw_battery(img, draw, battery_percent, qr_x0, qr_y0, qr_w, qr_h)
if location_lines and location_lines[0]:
lines = [line for line in location_lines if line]
_draw_text_box(img, draw, lines, "top-left")
if taken_at:
_draw_text_box(img, draw, [taken_at], "bottom-right")
if share_url:
_draw_qr_box(img, draw, share_url, ["SCAN TO", "DOWNLOAD"], "bottom-left")
for label in face_labels or []:
if label.get("name"):
_draw_face_label(img, draw, label["name"], label["x"], label["y"])
return img
+38 -11
View File
@@ -80,6 +80,26 @@ def _migration_6(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0"))
def _migration_7(conn) -> None:
"""Calendar frame mode: a personal ICS subscription per user
(users.calendar_ics_url), an explicit per-(user,frame) opt-in into a
frame's merged calendar (user_frames.calendar_included, default off
-- linking to a frame does not auto-include your calendar there),
and the frame-level view/inlay/browse-offset/cache settings calendar
mode needs (see calendar_feed.py, calendar_render.py,
routers/device.py's RENDERERS["calendar"]). Every new column has a
behavior-preserving default -- no existing frame's behavior changes
until its mode is actually switched to "calendar"."""
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_ics_url TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE user_frames ADD COLUMN calendar_included INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_view TEXT NOT NULL DEFAULT 'agenda'"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_photo_inlay INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_browse_offset INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_checked_at REAL NOT NULL DEFAULT 0.0"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_cached_events TEXT"))
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_fetch_summary TEXT NOT NULL DEFAULT ''"))
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -87,6 +107,7 @@ MIGRATIONS = [
(4, _migration_4),
(5, _migration_5),
(6, _migration_6),
(7, _migration_7),
]
@@ -94,17 +115,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()
+39
View File
@@ -48,6 +48,12 @@ class User(Base):
# email -- see routers/device.py's frame_battery) go here; blank = no
# email configured, both features silently no-op for this user.
email: Mapped[str] = mapped_column(String, default="")
# Personal iCal/CalDAV .ics subscription URL (no OAuth) for calendar
# frame mode -- see calendar_feed.py. Setting this alone shows up
# nowhere: a linked frame only pulls this user's events in once
# they've also opted in on that frame's own Configuration -> Calendar
# card (UserFrame.calendar_included below).
calendar_ics_url: Mapped[str] = mapped_column(String, default="")
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
@@ -139,6 +145,31 @@ class Frame(Base):
# original always-on full-strength Floyd-Steinberg dithering.
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
# -- calendar mode (see calendar_feed.py, calendar_render.py,
# routers/device.py's RENDERERS["calendar"]) --
calendar_view: Mapped[str] = mapped_column(String, default="agenda") # "agenda" | "week" | "month"
# Agenda view only; reuses this frame's existing photos-mode album/
# queue, not a separate photo setup.
calendar_photo_inlay: Mapped[bool] = mapped_column(Boolean, default=False)
# How many periods (unit depends on calendar_view: days/weeks/months)
# NEXT/BACK have browsed from "today". Reset to 0 by the next normal
# (non-button) /frame/image request, and whenever calendar_view
# itself changes -- a stale offset means something different in a
# different view's units.
calendar_browse_offset: Mapped[int] = mapped_column(Integer, default=0)
# Throttled merge-fetch cache (see routers/common.py's
# get_or_refresh_calendar_events) -- same shape as the
# firmware_update_checked_at/firmware_gitea_latest_version pattern
# below. One shared cache for every included user's merged events,
# not per-user.
calendar_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
calendar_cached_events: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
# "" when the last merge-fetch fully succeeded, else e.g. "1 of 2
# calendars unavailable" -- never names which user's feed failed, a
# shared household display shouldn't call out a specific person's
# outage to everyone who looks at it.
calendar_fetch_summary: Mapped[str] = mapped_column(String, default="")
# -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
@@ -200,6 +231,14 @@ class UserFrame(Base):
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
# Explicit per-(user,frame) opt-in for calendar frame mode -- being
# linked to a frame does NOT by itself contribute this user's
# calendar to it (deliberate choice, not an oversight: each person's
# calendar is their own data to share or not, not something a
# frame's controller decides on their behalf). Meaningless if the
# user has no calendar_ics_url set. See routers/api_frames.py's
# api_calendar_included.
calendar_included: Mapped[bool] = mapped_column(Boolean, default=False)
class PendingClaim(Base):
+9 -1
View File
@@ -5,7 +5,7 @@ Frame ORM model satisfy it."""
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo, available_timezones
# Populated once from the OS's zoneinfo database (installed via the
@@ -33,6 +33,14 @@ def _zoneinfo(name: str) -> ZoneInfo:
return ZoneInfo("UTC")
def local_date(cfg) -> date:
"""`date.today()` in cfg.timezone (falls back to UTC for an
unrecognized zone, same as _zoneinfo) -- what calendar mode's "today"
anchor and browse-offset both key off of, so every part of that
feature agrees on what day it is for a given frame."""
return datetime.now(_zoneinfo(cfg.timezone)).date()
def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]:
"""Whether `now` falls inside the quiet-hours window, and the next
boundary: if inside, when it ends; if outside, when it next starts.
+117 -8
View File
@@ -25,7 +25,7 @@ from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, photo_queue, quiet_hours
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..image_pipeline import (
@@ -36,15 +36,19 @@ from ..image_pipeline import (
render_preview_png,
)
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame
from ..models import BatteryLog, Frame, UserFrame
from .common import (
FRAME_MODES,
OVERDUE_FACTOR,
battery_estimate_s,
calendar_sources_for_frame,
fetch_source_and_faces,
get_or_refresh_calendar_events,
immich_client_for,
immich_creds,
list_assets,
require_configured,
valid_http_url,
)
logger = logging.getLogger(__name__)
@@ -92,6 +96,9 @@ def api_config_save(
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
mode: str | None = Form(None),
calendar_view: str | None = Form(None),
calendar_photo_inlay: bool | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
@@ -129,7 +136,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_http_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:
@@ -152,6 +162,18 @@ def api_config_save(
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
if mode is not None:
cfg.mode = mode if mode in FRAME_MODES else "photos"
if calendar_view is not None:
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
if new_view != cfg.calendar_view:
# A stale offset means something different in a different
# view's units (days vs. weeks vs. months) -- same
# reasoning as album_id's reset above.
cfg.calendar_browse_offset = 0
cfg.calendar_view = new_view
if calendar_photo_inlay is not None:
cfg.calendar_photo_inlay = calendar_photo_inlay
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -207,7 +229,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 +261,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 +343,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)
@@ -379,6 +406,81 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
return Response(content=png, media_type="image/png")
class CalendarIncludedRequest(BaseModel):
included: bool
@router.post("/api/frames/{frame_id}/calendar-included")
def api_calendar_included(
body: CalendarIncludedRequest,
request: Request,
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
db: Session = Depends(get_db),
):
"""A user's own opt-in into this frame's merged calendar (see
UserFrame.calendar_included). Deliberately not require_frame_control:
this is the toggling user's own data-sharing preference about their
own calendar, not a frame setting its controller manages on someone
else's behalf -- there's no target user_id in the request body by
design, it always toggles the calling session's own row."""
user = require_user_api(request, db)
row = db.get(UserFrame, (user.id, frame.id))
if row is None:
raise HTTPException(404, "Not linked to this frame")
row.calendar_included = body.included
# Force this frame's merged cache to pick up the change promptly
# rather than waiting out the throttle.
frame.calendar_checked_at = 0.0
db.commit()
return {"status": "saved", "included": row.calendar_included}
def _calendar_photo_inlay(frame: Frame, db: Session):
"""The agenda view's optional photo-inlay source image, or None if
inlay is off, not agenda view, or the frame's photos-mode album isn't
configured. Shared shape between the live render (routers/device.py's
_render_calendar_mode) and this preview endpoint; small enough that
duplicating rather than factoring out is fine, since the two call
sites differ slightly in error handling."""
if not (frame.calendar_view == "agenda" and frame.calendar_photo_inlay):
return None
url, key = immich_creds(frame)
if not (url and key and frame.album_id):
return None
try:
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if not asset_id:
return None
import io
from PIL import Image
return Image.open(io.BytesIO(client.download_asset_preview(asset_id)))
except HTTPException:
return None
@router.get("/api/frames/{frame_id}/preview/calendar")
def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The same merged, cached event set a live device render would use
-- not a live preview of an unsaved calendar_view choice, same
"reflects what's currently saved" convention as preview/rendered."""
if not calendar_sources_for_frame(db, frame):
raise HTTPException(400, "No calendars included on this frame yet")
events, summary = get_or_refresh_calendar_events(db, frame)
photo_inlay = _calendar_photo_inlay(frame, db)
view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
png = calendar_render.render_calendar_preview_png(
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
)
return Response(content=png, media_type="image/png")
@router.post("/api/frames/{frame_id}/firmware")
def api_firmware_upload(
file: UploadFile = File(...),
@@ -437,15 +539,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}
+199 -4
View File
@@ -5,6 +5,9 @@ from __future__ import annotations
import io
import logging
import os
import time
from datetime import datetime, timedelta
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException
@@ -12,16 +15,29 @@ from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import calendar_feed, quiet_hours
from ..db import frame_locked
from ..image_pipeline import render_frame
from ..immich_client import ImmichClient
from ..models import Frame
from ..models import Frame, User, UserFrame
logger = logging.getLogger(__name__)
FRAME_MODES = ("photos", "calendar")
# 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
@@ -91,12 +107,12 @@ def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) ->
return Image.open(io.BytesIO(jpeg_bytes)), faces
def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
source, faces = fetch_source_and_faces(client, frame, asset_id)
return render_frame(source, faces=faces, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
dither_strength=frame.dither_strength)
dither_strength=frame.dither_strength, manage=manage)
def battery_estimate_s(frame: Frame) -> int | None:
@@ -143,3 +159,182 @@ def shell_context(request, db: Session, user, active_frame: Frame | None = None,
"active_frame": active_frame,
"active_nav": active_nav,
}
def valid_http_url(url: str) -> bool:
"""http(s)-only URL check -- generalized from what was api_frames.py's
frame-specific _valid_repo_url, now shared by two call sites (the
Gitea firmware repo URL, and a user's personal calendar ICS URL)."""
parsed = urlparse(url)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
# --- Location/date-taken text for the manage overlay (see build_manage_content) ---
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC",
}
CA_PROVINCE_ABBR = {
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
"saskatchewan": "SK", "yukon": "YT",
}
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def _format_location(exif: dict) -> tuple[str, str] | None:
"""Returns (city_line, region_line), each independently truncated to
fit its own corner-overlay line, or None if Immich hasn't geocoded
this photo. region_line is the abbreviated state/province for US/CAN
locations (e.g. "CA", "ON"), else the full country name."""
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
country_key = (country or "").strip().lower()
if state and country_key in US_COUNTRY_NAMES:
region = US_STATE_ABBR.get(state.strip().lower(), state)
elif state and country_key in CA_COUNTRY_NAMES:
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
elif country:
region = country
elif state:
region = state
else:
region = ""
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
return None
def _manage_content_asset_id(frame: Frame) -> str | None:
"""Whether frame.current_asset_id refers to a photo actually visible
right now, for whichever mode is active -- always true in photos
mode; only true in calendar mode when the agenda view's photo inlay
is on (otherwise current_asset_id could be stale, left over from
whenever photos mode last ran, and showing its location/date/share
info on a manage overlay over a view with no visible photo at all
would be actively misleading, not just unhelpful)."""
relevant = frame.mode != "calendar" or (frame.calendar_view == "agenda" and frame.calendar_photo_inlay)
return frame.current_asset_id if relevant and frame.current_asset_id else None
def build_manage_content(db: Session, frame: Frame, request) -> dict:
"""Gathers everything manage_overlay.compose() needs -- what used to
be two separate device-facing endpoints (/frame/photo-info,
/frame/face-labels, both removed -- see the module docstring in
manage_overlay.py) are now just internal calls made here, once,
server-side, since compositing itself also moved server-side.
management_url and battery_percent always apply; location/date/
share-URL/face-labels only when there's a real current photo (see
_manage_content_asset_id) -- absent otherwise, which
manage_overlay.compose() already treats as "skip that region",
exactly the graceful-degradation behavior the old firmware-fetched
version had."""
base = str(request.base_url).rstrip("/")
content: dict = {
"management_url": f"{base}/m/{frame.manage_token}",
"battery_percent": frame.battery_percent,
}
asset_id = _manage_content_asset_id(frame)
if not asset_id:
return content
client = immich_client_for(frame)
try:
asset = client.get_asset(asset_id)
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch manage-overlay photo info for asset %s: %s", asset_id, e)
return content
exif = asset.get("exifInfo") or {}
content["location_lines"] = _format_location(exif)
content["taken_at"] = _format_taken_at(exif)
content["share_url"] = f"{base}/frame/share/{asset_id}"
if any((face.get("person") or {}).get("name") for face in faces):
try:
preview_bytes = client.download_asset_preview(asset_id)
from ..face_labels import compute_face_labels
content["face_labels"] = compute_face_labels(preview_bytes, faces, frame.display_mode, frame.orientation)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
return content
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[tuple[str, str]]:
"""Every user linked to this frame with BOTH a calendar URL set AND
explicit per-frame opt-in (UserFrame.calendar_included) -- the exact
set calendar_feed.merge_events needs. [(display_name-or-username,
ics_url), ...]."""
rows = db.execute(
select(User)
.join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame.id, UserFrame.calendar_included == True, # noqa: E712
User.calendar_ics_url != "")
).scalars().all()
return [(u.display_name or u.username, u.calendar_ics_url) for u in rows]
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
"""Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
-- same shape as the Gitea release-check throttle in api_frames.py's
api_firmware_check. One shared cache for the whole merged result
(every included user's events together), not per-user -- ICS feeds
are small and this refetches at most every ~20 minutes regardless of
how many are included, so per-user cache columns would add
bookkeeping for a marginal benefit."""
now = time.time()
if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S:
return frame.calendar_cached_events, frame.calendar_fetch_summary
sources = calendar_sources_for_frame(db, frame)
today = quiet_hours.local_date(frame)
events, summary = calendar_feed.merge_events(
sources,
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
)
with frame_locked(db, frame.id) as locked:
locked.calendar_cached_events = events
locked.calendar_fetch_summary = summary
locked.calendar_checked_at = now
return events, summary
+151 -200
View File
@@ -2,13 +2,18 @@
into deployed firmware -- so multi-frame support changes only how the
calling frame is resolved (see auth.require_device), never the paths or
response key names the deployed flat parser depends on
("refresh_interval_s", "firmware_version")."""
("refresh_interval_s", "firmware_version").
manage=1 is the one addition: appended by firmware's manage button to
whichever of these three GET/POST requests it was already about to make
(see firmware/main/frame_client.c's fetch_and_display -- it no longer
does its own overlay fetching/compositing, that's all server-side now,
see manage_overlay.py and common.build_manage_content)."""
from __future__ import annotations
import logging
import time
from datetime import datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -17,10 +22,9 @@ from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from .. import mail, photo_queue, quiet_hours
from .. import calendar_render, mail, photo_queue, quiet_hours
from ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db
from ..face_labels import compute_face_labels
from ..firmware import firmware_path
from ..image_pipeline import render_placeholder
from ..models import BatteryLog, Frame
@@ -28,6 +32,9 @@ from .common import (
BATTERY_HISTORY_MAX,
BATTERY_LOG_MAX,
RECHARGE_JUMP_PCT,
RECHARGE_LOOKBACK,
build_manage_content,
get_or_refresh_calendar_events,
immich_client_for,
immich_creds,
list_assets,
@@ -40,7 +47,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request) -> bytes:
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
"""What an unclaimed or not-yet-configured frame displays instead of a
photo -- instructions with a QR, rendered at 200 so the device treats
it as a perfectly normal image and never error-loops. The URLs are
@@ -55,18 +62,21 @@ def _setup_placeholder(frame: Frame, request: Request) -> bytes:
qr_url=claim_url,
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
)
if frame.owner_user_id is None:
return render_placeholder(
["Almost there!", f"Open {base} to finish setting up this frame."],
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
)
return render_placeholder(
["Almost there!", "Pick an album for this frame:", base],
qr_url=base,
orientation=frame.orientation,
palette_rgb=frame.palette_rgb,
manage=manage,
)
@@ -75,11 +85,12 @@ def _frame_configured(frame: Frame) -> bool:
return bool(url and key and frame.album_id)
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
# /frame/image looks up the frame's mode here. Only photos exists today.
def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
# --- photos mode ---
def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
is_normal_wake: bool) -> bytes:
if not _frame_configured(frame):
return _setup_placeholder(frame, request)
return _setup_placeholder(frame, request, manage=manage)
client = immich_client_for(frame)
assets = list_assets(client, frame)
@@ -87,11 +98,108 @@ def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id)
return render_asset(client, frame, asset_id, manage=manage)
def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.advance_forced(locked, assets)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.back_forced(locked, assets)
asset_id = locked.current_asset_id
return render_asset(client, frame, asset_id, manage=manage)
# --- calendar mode ---
def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
is_normal_wake: bool) -> bytes:
from .common import calendar_sources_for_frame
if not calendar_sources_for_frame(db, frame):
return render_placeholder(
["This frame's calendar isn't set up yet",
"Add a calendar in Settings, then include it on",
"this frame's Configuration -> Calendar card."],
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
)
with frame_locked(db, frame.id) as locked:
if is_normal_wake and locked.calendar_browse_offset != 0:
locked.calendar_browse_offset = 0
browse_offset = locked.calendar_browse_offset
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
inlay_wanted = locked.calendar_photo_inlay and view == "agenda"
events, summary = get_or_refresh_calendar_events(db, frame)
photo_inlay = None
if inlay_wanted and _frame_configured(frame):
try:
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if asset_id:
jpeg_bytes = client.download_asset_preview(asset_id)
import io
from PIL import Image
photo_inlay = Image.open(io.BytesIO(jpeg_bytes))
except HTTPException:
pass # inlay is nice-to-have -- an Immich hiccup shouldn't blank the whole agenda
return calendar_render.render_calendar(
events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage,
)
def _advance_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
"""NEXT in calendar mode: moves the displayed period forward one step
(day for agenda, week for week view, month for month view) from
wherever it currently is -- not from "today" -- so repeated presses
walk further forward. See Frame.calendar_browse_offset."""
with frame_locked(db, frame.id) as locked:
locked.calendar_browse_offset += 1
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
with frame_locked(db, frame.id) as locked:
locked.calendar_browse_offset -= 1
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
RENDERERS = {
"photos": _render_photos_mode,
"calendar": _render_calendar_mode,
}
ADVANCE_RENDERERS = {
"photos": _advance_photos_mode,
"calendar": _advance_calendar_mode,
}
BACK_RENDERERS = {
"photos": _back_photos_mode,
"calendar": _back_calendar_mode,
}
@@ -130,6 +238,10 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
return response
def _manage_flag(request: Request) -> bool:
return request.query_params.get("manage") == "1"
@router.get("/frame/image")
def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
@@ -140,44 +252,36 @@ def frame_image(
safe to call as often as the device wants, including after an
unplanned reboot, without skipping ahead in the album. An unclaimed/
unconfigured frame gets a rendered instruction placeholder (200, not
an error) so a fresh device never error-loops."""
an error) so a fresh device never error-loops.
?manage=1 (the manage button) composites the manage overlay onto
whatever this would have returned anyway -- see build_manage_content.
For calendar mode, this is also the "normal wake" that resets
calendar_browse_offset back to 0 (see _render_calendar_mode)."""
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
return Response(content=renderer(db, frame, request), media_type="application/octet-stream")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = renderer(db, frame, request, manage, True)
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/advance")
def frame_advance(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Forces an immediate advance to the next photo, ignoring
refresh_interval_s, and resets the interval clock from now. Used by
the device's next-photo button."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.advance_forced(locked, assets)
asset_id = locked.current_asset_id
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Forces an immediate move forward -- the next photo in photos mode,
or the next day/week/month in calendar mode -- ignoring
refresh_interval_s. Used by the device's next-photo button."""
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode)
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
@router.post("/frame/back")
def frame_back(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Returns to the previously-current photo (the mirror image of
/frame/advance -- see photo_queue.back_forced()), and resets the
interval clock from now. A no-op (still 200, current photo
unchanged) if there's no history to go back to -- same "always
returns something displayable" contract as /frame/advance, rather
than erroring. Used by the device's back-photo button."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.back_forced(locked, assets)
asset_id = locked.current_asset_id
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""The mirror of /frame/advance -- back a photo in photos mode, back
a period in calendar mode. A no-op (still 200, unchanged) if there's
nothing to go back to. Used by the device's back-photo button."""
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode)
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
class BatteryReport(BaseModel):
@@ -202,7 +306,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
@@ -266,115 +375,6 @@ def frame_firmware(frame: Frame = Depends(require_device)):
return FileResponse(path, media_type="application/octet-stream")
LOCATION_LINE_MAX_LEN = 14
US_STATE_ABBR = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC",
}
CA_PROVINCE_ABBR = {
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
"saskatchewan": "SK", "yukon": "YT",
}
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
CA_COUNTRY_NAMES = {"canada"}
def _truncate(text: str, max_len: int) -> str:
if len(text) <= max_len:
return text
return text[: max_len - 3] + "..."
def _format_location(exif: dict) -> tuple[str, str] | None:
"""Returns (city_line, region_line), each independently truncated to
fit its own corner-overlay line, or None if Immich hasn't geocoded
this photo. region_line is the abbreviated state/province for US/CAN
locations (e.g. "CA", "ON"), else the full country name."""
city = exif.get("city")
if not city:
return None
state = exif.get("state")
country = exif.get("country")
country_key = (country or "").strip().lower()
if state and country_key in US_COUNTRY_NAMES:
region = US_STATE_ABBR.get(state.strip().lower(), state)
elif state and country_key in CA_COUNTRY_NAMES:
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
elif country:
region = country
elif state:
region = state
else:
region = ""
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
def _format_taken_at(exif: dict) -> str | None:
raw = exif.get("dateTimeOriginal")
if not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
except ValueError:
return None
@router.get("/frame/photo-info")
def frame_photo_info(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Location/date-taken text for the manage-button overlay, plus the
asset id used to build the share-QR's target URL. Read-only, same
idempotent current-photo semantics as /frame/image -- doesn't advance
anything."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
try:
asset = client.get_asset(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not reach Immich: {e}") from e
exif = asset.get("exifInfo") or {}
location = _format_location(exif)
return {
"asset_id": asset_id,
"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,
}
@router.get("/frame/share/{asset_id}")
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
"""Creates a 30-minute public Immich share link for asset_id and
@@ -397,52 +397,3 @@ def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
raise HTTPException(502, f"Could not create share link: {e}") from e
return RedirectResponse(share_url)
@router.get("/frame/face-labels")
def frame_face_labels(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Named-face positions for the manage button's escalated "level 2"
menu -- who's in the current photo, per Immich's own face
recognition (no detection/recognition happens here, see
app/face_labels.py). Response is a flattened, fixed-slot shape
(name_0/x_0/y_0, ...) rather than a JSON array, so the device's
hand-rolled parser can read it with the same flat-scalar helpers it
already has. Empty (count: 0) if no faces are named, or if anything
about fetching them fails -- this is a "nice to have" addition to
the overlay, not worth failing the whole menu over."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
with frame_locked(db, frame.id) as locked:
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
asset_id = locked.current_asset_id
display_mode = locked.display_mode
orientation = locked.orientation
if not asset_id:
return {"count": 0}
try:
faces = client.get_asset_faces(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
return {"count": 0}
if not any((face.get("person") or {}).get("name") for face in faces):
return {"count": 0} # skip the extra preview download in the common no-named-faces case
try:
preview_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
return {"count": 0}
labels = compute_face_labels(preview_bytes, faces, display_mode, orientation)
result: dict[str, object] = {"count": len(labels)}
for i, label in enumerate(labels):
result[f"name_{i}"] = label["name"]
result[f"x_{i}"] = label["x"]
result[f"y_{i}"] = label["y"]
return result
+24 -1
View File
@@ -8,9 +8,11 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
from ..image_pipeline import (
DEFAULT_PALETTE_RGB,
@@ -18,7 +20,7 @@ from ..image_pipeline import (
PALETTE_LABELS,
palette_to_hex,
)
from ..models import Frame
from ..models import Frame, User, UserFrame
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context
@@ -43,6 +45,25 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
def _calendar_users_for_frame(db: Session, frame_id: int) -> list[dict]:
"""Every user linked to this frame, their calendar opt-in state, and
whether they even have a calendar URL set -- what the Configuration
tab's "Included calendars" list needs. Whether a given row is *this*
viewer's own (and therefore editable) is decided in the template,
using the `user` shell_context already provides."""
rows = db.execute(
select(User, UserFrame.calendar_included)
.join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id)
.order_by(User.username)
).all()
return [
{"user_id": u.id, "display_name": u.display_name or u.username,
"has_url": bool(u.calendar_ics_url), "included": included}
for u, included in rows
]
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(
@@ -52,6 +73,8 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
display_mode_labels=DISPLAY_MODE_LABELS,
calendar_views=CALENDAR_VIEW_LABELS,
calendar_users=_calendar_users_for_frame(db, frame_id),
)
+13 -2
View File
@@ -1,6 +1,6 @@
"""HTML page routes: first-run setup, login/logout, user settings, and
the admin panel. The frame pages themselves stay in main.py (Phase A's
single-frame index) until the Phase D restructure.
the admin panel. The per-frame pages (Photos/Configuration/Stats) live in
routers/frame_pages.py.
All POSTs here are plain HTML forms, so CSRF rides a hidden form field
(checked explicitly) rather than the X-CSRF-Token header the JSON API
@@ -35,6 +35,7 @@ from ..auth import (
)
from ..db import get_db
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
from .common import valid_http_url
logger = logging.getLogger(__name__)
@@ -422,6 +423,7 @@ def settings_submit(
email: str = Form(""),
immich_url: str = Form(""),
immich_api_key: str = Form(""),
calendar_ics_url: str = Form(""),
current_password: str = Form(""),
new_password: str = Form(""),
db: Session = Depends(get_db),
@@ -441,6 +443,15 @@ def settings_submit(
if immich_api_key.strip():
user.immich_api_key = immich_api_key.strip()
# Unlike the API key, this isn't a secret -- it round-trips visibly in
# the form, so blank means an explicit clear (there needs to be some
# way to actually remove a linked calendar), not "keep existing".
stripped_ics = calendar_ics_url.strip()
if stripped_ics and not valid_http_url(stripped_ics):
error = "Calendar URL must be a plain http:// or https:// URL."
else:
user.calendar_ics_url = stripped_ics
if new_password:
if not user.password_hash or not verify_password(current_password, user.password_hash):
error = "Current password is wrong -- password not changed."
+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);
+65 -1
View File
@@ -6,6 +6,7 @@
async function saveConfig() {
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
const body = new URLSearchParams({
mode: document.getElementById('frame_mode').value,
name: document.getElementById('frame_name').value || '',
order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
@@ -36,6 +37,69 @@ document.getElementById('config-form').addEventListener('submit', async (e) => {
}
});
// ---- Calendar card: mode/view toggling, its own save, self opt-in, preview ----
const calendarCard = document.getElementById('calendar-card');
if (calendarCard) {
document.getElementById('frame_mode').addEventListener('change', () => {
calendarCard.style.display = document.getElementById('frame_mode').value === 'calendar' ? 'block' : 'none';
});
const inlayRow = document.getElementById('calendar-inlay-row');
const inlayHint = document.getElementById('calendar-inlay-hint');
document.getElementById('calendar_view').addEventListener('change', () => {
const isAgenda = document.getElementById('calendar_view').value === 'agenda';
inlayRow.style.display = isAgenda ? 'flex' : 'none';
inlayHint.style.display = isAgenda ? 'block' : 'none';
});
document.getElementById('calendar-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_view: document.getElementById('calendar_view').value,
calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked),
});
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
// Each person's own opt-in -- auto-saves on toggle, not batched into
// the form above, since it's the toggling user's own preference (see
// api_frames.py's /calendar-included), not a frame-wide setting.
document.querySelectorAll('.calendar-self-toggle').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/calendar-included`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ included: el.checked }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, el.checked ? 'Your calendar is included on this frame.' : 'Your calendar removed from this frame.');
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
});
function loadCalendarPreview() {
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
}
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
}
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
@@ -259,7 +323,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>
+64
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 %}
@@ -16,6 +17,12 @@
<section class="card">
<h2 class="card-title">Display settings</h2>
<form id="config-form">
<label>Frame mode
<select id="frame_mode">
<option value="photos" {% if frame.mode == "photos" %}selected{% endif %}>Photos</option>
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
</select>
</label>
<label>Frame name
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
</label>
@@ -76,6 +83,59 @@
<button type="submit">Save</button>
</form>
</section>
<section class="card" id="calendar-card" style="{% if frame.mode != 'calendar' %}display: none;{% endif %}">
<h2 class="card-title">Calendar</h2>
<form id="calendar-config-form">
<label>View
<select id="calendar_view">
{% for value, label in calendar_views.items() %}
<option value="{{ value }}" {% if frame.calendar_view == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<div class="checkbox-row" id="calendar-inlay-row" style="{% if frame.calendar_view != 'agenda' %}display: none;{% endif %}">
<input type="checkbox" id="calendar_photo_inlay" {% if frame.calendar_photo_inlay %}checked{% endif %}>
<label for="calendar_photo_inlay">Show a photo alongside today's agenda</label>
</div>
<p class="sub" id="calendar-inlay-hint" style="margin-top: 4px; {% if frame.calendar_view != 'agenda' %}display: none;{% endif %}">
Uses the same album configured on the Photos tab -- nothing extra to set up.</p>
<button type="submit">Save</button>
</form>
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
<p class="sub">Each linked person decides whether their own calendar
contributes to this frame -- being linked here doesn't include it
automatically.</p>
<ul class="calendar-user-list">
{% for u in calendar_users %}
<li>
{% if u.user_id == user.id %}
{% if u.has_url %}
<label class="checkbox-row" style="margin-top: 6px;">
<input type="checkbox" class="calendar-self-toggle" {% if u.included %}checked{% endif %}>
{{ u.display_name }} (you)
</label>
{% else %}
<p class="sub" style="margin-top: 6px;">{{ u.display_name }} (you) -- no calendar set, add one in <a href="/settings">Settings</a>.</p>
{% endif %}
{% else %}
<p class="sub" style="margin-top: 6px;">{{ u.display_name }}:
{% if not u.has_url %}no calendar set{% elif u.included %}included{% else %}not included{% endif %}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% if frame.calendar_fetch_summary %}
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ frame.calendar_fetch_summary }}</p>
{% endif %}
<h2 class="card-title" style="margin-top: 20px;">Preview</h2>
<p class="sub">How this frame's calendar currently renders.</p>
<img class="preview-img" id="calendar-preview" alt="Calendar preview">
<button type="button" class="secondary" id="calendar-preview-refresh">Refresh preview</button>
</section>
</div>
<div class="side-col">
@@ -105,6 +165,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 +266,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 %}
+13
View File
@@ -32,6 +32,19 @@
this Immich library. The key needs read access to albums/assets/faces
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
<h2 class="card-title" style="margin-top: 24px;">Calendar</h2>
<label>Calendar URL (iCal/CalDAV .ics feed)
<input type="text" name="calendar_ics_url" placeholder="https://calendar.example.com/you.ics"
value="{{ user.calendar_ics_url }}">
</label>
<p class="sub" style="margin-top: 8px;">Your personal calendar
subscription link (no login needed -- e.g. Google Calendar's
Settings &rarr; "Secret address in iCal format", or Apple/Outlook/
Nextcloud's equivalent). Setting it here doesn't show it anywhere
by itself -- include it on any frame you're linked to from that
frame's Configuration &rarr; Calendar card, so a frame only shows
calendars people have actually chosen to share with it.</p>
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
<label>Current password
<input type="password" name="current_password" autocomplete="current-password">
+2
View File
@@ -7,3 +7,5 @@ python-multipart==0.0.20
jinja2==3.1.5
sqlalchemy==2.0.51
qrcode==8.2
icalendar==7.2.2
recurring-ical-events==3.8.2