Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02934b1d10 | ||
|
|
f24c3b9c8e | ||
|
|
38944a1287 | ||
|
|
5b4fdbe330 | ||
|
|
dcbc71e683 | ||
|
|
f4d2a23e8a | ||
|
|
55b53d5bb2 | ||
|
|
60fcfca4a0 | ||
|
|
996e06e2bc | ||
|
|
d324bc4a57 | ||
|
|
ac4b57e611 | ||
|
|
462b558bef | ||
|
|
9f9ad34a40 | ||
|
|
e48ac50ea1 | ||
|
|
83c59af1dd | ||
|
|
49bc9f9ec9 | ||
|
|
e802882fc1 | ||
|
|
5b11f2accb | ||
|
|
c1c803b497 | ||
|
|
8e10ca540e |
@@ -20,6 +20,7 @@
|
||||
#include "combo_button.h"
|
||||
#include "ota_update.h"
|
||||
#include "board_antenna.h"
|
||||
#include "battery.h"
|
||||
|
||||
#include "frame_client.h"
|
||||
|
||||
@@ -313,6 +314,35 @@ static bool json_extract_uint(const char *json, const char *key, uint32_t *out)
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Same as json_extract_uint(), but signed -- e.g. battery_percent's -1
|
||||
* ("no reading") sentinel. strtoul() would silently wrap a leading '-'
|
||||
* into a huge unsigned value instead of failing, so this needs its own
|
||||
* strtol()-based parse rather than reusing json_extract_uint(). */
|
||||
static bool json_extract_int(const char *json, const char *key, int *out)
|
||||
{
|
||||
char needle[48];
|
||||
snprintf(needle, sizeof(needle), "\"%s\"", key);
|
||||
const char *pos = strstr(json, needle);
|
||||
if (pos == NULL) {
|
||||
return false;
|
||||
}
|
||||
pos = strchr(pos, ':');
|
||||
if (pos == NULL) {
|
||||
return false;
|
||||
}
|
||||
pos++;
|
||||
while (*pos == ' ') {
|
||||
pos++;
|
||||
}
|
||||
char *end;
|
||||
long value = strtol(pos, &end, 10);
|
||||
if (end == pos) {
|
||||
return false;
|
||||
}
|
||||
*out = (int)value;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Finds the string value associated with "key" in a small, flat JSON
|
||||
* blob, e.g. "San Francisco, CA" in {"location": "San Francisco, CA"}.
|
||||
* Same rationale as json_extract_uint() -- not a general parser. Returns
|
||||
@@ -427,20 +457,26 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
|
||||
/* GETs the server's /frame/photo-info for the manage-button overlay:
|
||||
* location/taken_at text (left empty if the server didn't have them --
|
||||
* e.g. no GPS EXIF to geocode, or no capture date) and a share_url built
|
||||
* from the returned asset_id, same construction pattern as
|
||||
* run_fetch_cycle()'s management_url. Any failure (unreachable, no
|
||||
* current photo, etc.) just leaves all outputs empty -- the caller
|
||||
* e.g. no GPS EXIF to geocode, or no capture date), a share_url built
|
||||
* from the returned asset_id (same construction pattern as
|
||||
* run_fetch_cycle()'s management_url), and the last battery percent this
|
||||
* frame reported (-1 if none yet). The overlay uses that last-known
|
||||
* value rather than a fresh local reading -- it's needed before this
|
||||
* photo is composited and pushed to the panel, i.e. before this cycle's
|
||||
* own reading (taken later, right before it's reported -- see
|
||||
* frame_client_run()) even exists yet. Any failure (unreachable, no
|
||||
* current photo, etc.) just leaves all outputs empty/-1 -- the caller
|
||||
* treats that as "skip these optional overlay regions", not a hard
|
||||
* error, since the base "scan to manage" QR should still show. */
|
||||
static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, size_t location_line1_size,
|
||||
char *location_line2, size_t location_line2_size, char *taken_at, size_t taken_at_size,
|
||||
char *share_url, size_t share_url_size)
|
||||
char *share_url, size_t share_url_size, int *battery_percent)
|
||||
{
|
||||
location_line1[0] = '\0';
|
||||
location_line2[0] = '\0';
|
||||
taken_at[0] = '\0';
|
||||
share_url[0] = '\0';
|
||||
*battery_percent = -1;
|
||||
|
||||
char url[256];
|
||||
build_url(url, sizeof(url), cfg, "frame/photo-info");
|
||||
@@ -493,6 +529,7 @@ static void fetch_photo_info(const frame_config_t *cfg, char *location_line1, si
|
||||
json_extract_string(body, "location_line1", location_line1, location_line1_size);
|
||||
json_extract_string(body, "location_line2", location_line2, location_line2_size);
|
||||
json_extract_string(body, "taken_at", taken_at, taken_at_size);
|
||||
json_extract_int(body, "battery_percent", battery_percent);
|
||||
|
||||
char asset_id[48];
|
||||
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
|
||||
@@ -554,7 +591,12 @@ static int fetch_face_labels(const frame_config_t *cfg, manage_face_label_t *out
|
||||
|
||||
uint32_t count = 0;
|
||||
json_extract_uint(body, "count", &count);
|
||||
if ((int)count > max_labels) {
|
||||
/* Unsigned compare: casting count to int first let a server-supplied
|
||||
* value >= 2^31 (still a perfectly ordinary decimal in the JSON) go
|
||||
* negative, skipping this clamp entirely and driving the loop below
|
||||
* with the full attacker/server-controlled count -- out[found] is a
|
||||
* fixed MANAGE_FACE_LABELS_MAX-element caller stack array. */
|
||||
if (count > (uint32_t)max_labels) {
|
||||
count = (uint32_t)max_labels;
|
||||
}
|
||||
|
||||
@@ -757,8 +799,7 @@ static bool wait_for_button_press(uint32_t timeout_ms)
|
||||
* had that data); level 2 adds named-face labels on top. action only
|
||||
* applies at level 1 -- escalating to level 2 redisplays the same
|
||||
* photo, so it never re-advances/-backs. */
|
||||
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level,
|
||||
int battery_percent)
|
||||
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level)
|
||||
{
|
||||
char management_url[256];
|
||||
build_url(management_url, sizeof(management_url), cfg, "");
|
||||
@@ -774,8 +815,10 @@ static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t actio
|
||||
* overflow, but a truncated/dropped token still means the resulting
|
||||
* request just 401s with no obvious cause). */
|
||||
char share_url[320];
|
||||
int battery_percent;
|
||||
fetch_photo_info(cfg, location_line1, sizeof(location_line1), location_line2,
|
||||
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url));
|
||||
sizeof(location_line2), taken_at, sizeof(taken_at), share_url, sizeof(share_url),
|
||||
&battery_percent);
|
||||
|
||||
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
|
||||
int face_label_count = 0;
|
||||
@@ -816,10 +859,10 @@ static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t actio
|
||||
* that (escalating, or the final revert) are logged but don't count as
|
||||
* an overall failure -- something was already shown successfully, which
|
||||
* was the point of the button. */
|
||||
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action, int battery_percent)
|
||||
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
|
||||
{
|
||||
int level = 1;
|
||||
esp_err_t err = show_menu_level(cfg, action, level, battery_percent);
|
||||
esp_err_t err = show_menu_level(cfg, action, level);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
|
||||
return fetch_and_display(cfg, action, NULL);
|
||||
@@ -832,7 +875,7 @@ static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t a
|
||||
break; /* timeout at any level, or a press while already maxed out -- exit */
|
||||
}
|
||||
level++;
|
||||
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level, battery_percent);
|
||||
esp_err_t level_err = show_menu_level(cfg, FETCH_NORMAL, level);
|
||||
if (level_err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
|
||||
break;
|
||||
@@ -849,13 +892,12 @@ static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t a
|
||||
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
|
||||
* show_management_qr -- the escalating manage menu (see
|
||||
* run_management_menu()). */
|
||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent)
|
||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||
{
|
||||
if (!show_management_qr) {
|
||||
return fetch_and_display(cfg, action, NULL);
|
||||
}
|
||||
return run_management_menu(cfg, action, battery_percent);
|
||||
return run_management_menu(cfg, action);
|
||||
}
|
||||
|
||||
/* Reports the battery percent to the server (POST /frame/battery).
|
||||
@@ -899,8 +941,7 @@ static void report_battery(const frame_config_t *cfg, int percent)
|
||||
esp_http_client_cleanup(client);
|
||||
}
|
||||
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent)
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||
{
|
||||
esp_err_t epd_err = epd_init();
|
||||
bool have_display = (epd_err == ESP_OK);
|
||||
@@ -934,7 +975,7 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
* worth it to stop false-failing on the common case. */
|
||||
bool image_ok = true;
|
||||
if (have_display) {
|
||||
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr, battery_percent);
|
||||
esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr);
|
||||
image_ok = (fetch_err == ESP_OK);
|
||||
if (!image_ok) {
|
||||
/* epd_display_stream() never triggers a physical refresh on a
|
||||
@@ -969,6 +1010,18 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
* normal boot, not just the one right after an update). */
|
||||
esp_ota_mark_app_valid_cancel_rollback();
|
||||
|
||||
/* Read now, not at boot: the photo (and, if shown, the manage
|
||||
* overlay -- which gets its own battery number from the server's
|
||||
* last-known value, not a local reading, see fetch_photo_info())
|
||||
* is already on the panel, so there's no display deadline to beat.
|
||||
* Reading here instead of right after waking sidesteps taking the
|
||||
* ADC sample while the rail's still settling from whatever the
|
||||
* boot/reset just did, with no need to guess a settle delay --
|
||||
* the fetch/display work already done this cycle is the delay.
|
||||
* Still safe re: the battery/button pin sharing (battery.h) --
|
||||
* every button check main.c does happens well before this, at
|
||||
* the very start of boot. */
|
||||
int battery_percent = battery_read_percent();
|
||||
report_battery(cfg, battery_percent);
|
||||
frame_server_config_t server_cfg = fetch_frame_config(cfg);
|
||||
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
|
||||
|
||||
@@ -36,11 +36,13 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg);
|
||||
* displayed photo gets a small "scan to manage" QR overlay in the
|
||||
* top-right corner linking to the server's config page, held for 30
|
||||
* seconds (the device stays awake), then reverted back to the plain
|
||||
* photo before proceeding to the normal sleep-interval logic.
|
||||
* photo before proceeding to the normal sleep-interval logic. Its
|
||||
* battery indicator shows the server's last-known reading, not a fresh
|
||||
* one -- see fetch_photo_info() in frame_client.c.
|
||||
*
|
||||
* battery_percent (0-100, or -1 for "no reading" -- see
|
||||
* battery_read_percent()) is shown on the management menu overlay and
|
||||
* reported to the server after a successful fetch; -1 skips both.
|
||||
* Reads the battery (see battery_read_percent()) itself, once, after the
|
||||
* photo is already on the panel, and reports it to the server on a
|
||||
* successful fetch; a -1 reading ("no reading" -- on mains, disabled, or
|
||||
* implausible) skips the report.
|
||||
*/
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
||||
int battery_percent);
|
||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include "next_button.h"
|
||||
#include "back_button.h"
|
||||
#include "combo_button.h"
|
||||
#include "battery.h"
|
||||
|
||||
static const char *TAG = "main";
|
||||
|
||||
@@ -52,18 +51,12 @@ void app_main(void)
|
||||
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
||||
bool show_management_qr = combo_button_check();
|
||||
|
||||
/* Must come after the button checks: the battery pin is (by design,
|
||||
* on the XIAO board) shared with a button, and the ADC read briefly
|
||||
* takes the pin over -- see battery.h. -1 = no reading (disabled,
|
||||
* on mains, or implausible). */
|
||||
int battery_percent = battery_read_percent();
|
||||
|
||||
frame_config_t cfg;
|
||||
esp_err_t cfg_err = frame_config_load(&cfg);
|
||||
if (cfg_err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
|
||||
if (frame_wifi_connect_sta(&cfg) == ESP_OK) {
|
||||
frame_client_run(&cfg, action, show_management_qr, battery_percent);
|
||||
frame_client_run(&cfg, action, show_management_qr);
|
||||
return; /* frame_client_run currently never returns */
|
||||
}
|
||||
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
|
||||
|
||||
@@ -448,11 +448,17 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
|
||||
|
||||
/* The success page hands the browser off to the server's claim page,
|
||||
* carrying this device's id -- how a frame gets linked to a user
|
||||
* account. The ~7s delay covers the phone dropping this softAP (the
|
||||
* device reboots right after this response) and rejoining its normal
|
||||
* WiFi before the redirect fires; the visible link is the fallback
|
||||
* if the phone loses that race. Scheme handling matches
|
||||
* frame_client.c's build_url(): a bare host gets http://. */
|
||||
* account. This page is entirely self-contained (no external
|
||||
* resources) so it renders fully from what we send now, before the
|
||||
* softAP goes away -- a phone mid-load of a remote asset would just
|
||||
* time out once the AP drops. The visible countdown ticks down for
|
||||
* PROVISIONING_COUNTDOWN_S seconds and then redirects; the AP is kept
|
||||
* alive for one second longer than that (see the vTaskDelay below) so
|
||||
* the countdown always finishes, and the phone has that whole window
|
||||
* to rejoin its normal WiFi and let the redirect land on the real
|
||||
* server. "Redirect now" covers a phone that's already reconnected.
|
||||
* Scheme handling matches frame_client.c's build_url(): a bare host
|
||||
* gets http://. */
|
||||
char device_id[FRAME_DEVICE_ID_LEN + 1];
|
||||
frame_device_id_get(device_id, sizeof(device_id));
|
||||
|
||||
@@ -463,25 +469,40 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
|
||||
}
|
||||
snprintf(claim_url, sizeof(claim_url), "%s%s/claim?device_id=%s", scheme, cfg.toolsserver, device_id);
|
||||
|
||||
char resp[1024];
|
||||
#define PROVISIONING_COUNTDOWN_S 10
|
||||
|
||||
char resp[1536];
|
||||
snprintf(resp, sizeof(resp),
|
||||
"<!doctype html><html><head>"
|
||||
"<meta http-equiv=\"refresh\" content=\"7;url=%s\">"
|
||||
"<style>body{font-family:sans-serif;text-align:center;padding:2em}</style></head>"
|
||||
"<meta http-equiv=\"refresh\" content=\"%d;url=%s\">"
|
||||
"<style>body{font-family:sans-serif;text-align:center;padding:2em}"
|
||||
"#now{display:inline-block;margin-top:1em;padding:.6em 1.2em;"
|
||||
"background:#2563eb;color:#fff;text-decoration:none;border-radius:8px}</style></head>"
|
||||
"<body><h3>Saved — the frame is restarting</h3>"
|
||||
"<p>Reconnect to your normal WiFi. You'll be taken to the claim page "
|
||||
"in a few seconds…</p>"
|
||||
"<p><a href=\"%s\">Continue to claim your frame</a></p>"
|
||||
"<script>setTimeout(function(){location.href=%c%s%c},7000)</script>"
|
||||
"<p>Reconnect to your normal WiFi if it doesn't happen automatically.</p>"
|
||||
"<p>Redirecting you in <span id=\"n\">%d</span> seconds…</p>"
|
||||
"<p><a id=\"now\" href=\"%s\">Redirect now</a></p>"
|
||||
"<script>"
|
||||
"var n=%d,e=document.getElementById('n');"
|
||||
"var t=setInterval(function(){"
|
||||
"n--;if(e)e.textContent=n;"
|
||||
"if(n<=0){clearInterval(t);location.href='%s';}"
|
||||
"},1000);"
|
||||
"</script>"
|
||||
"</body></html>",
|
||||
claim_url, claim_url, '"', claim_url, '"');
|
||||
PROVISIONING_COUNTDOWN_S, claim_url, PROVISIONING_COUNTDOWN_S, claim_url,
|
||||
PROVISIONING_COUNTDOWN_S, claim_url);
|
||||
httpd_resp_set_type(req, "text/html");
|
||||
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
|
||||
|
||||
/* Let the response flush to the client before rebooting into STA mode. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
/* Keep the softAP up for the full visible countdown (plus a 1s margin
|
||||
* for the response to flush and the JS timer to fire) before tearing
|
||||
* it down -- see the comment above for why. */
|
||||
vTaskDelay(pdMS_TO_TICKS((PROVISIONING_COUNTDOWN_S + 1) * 1000));
|
||||
esp_restart();
|
||||
|
||||
#undef PROVISIONING_COUNTDOWN_S
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.2.0
|
||||
1.2.3
|
||||
|
||||
+43
-11
@@ -79,6 +79,18 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
view current + upcoming, "show next", advance, back -- nothing else.
|
||||
The share QR stays public (it creates a 30-minute Immich share link
|
||||
for exactly the photo shown).
|
||||
- **Email (optional).** An admin sets an SMTP server once (`/admin` --
|
||||
server, port, username/password, from address, STARTTLS on/off; a
|
||||
"send test email to myself" button, delivered to the admin's own
|
||||
email); each user sets their own email in Settings. Once both are in
|
||||
place: **"Forgot password?"** on the login page emails a one-hour
|
||||
reset link (a generic "check your email" response either way, so the
|
||||
endpoint can't be used to enumerate accounts), and a frame's
|
||||
Configuration tab can set a **battery-alert threshold** -- an email to
|
||||
the frame's owner the first time a report drops to or below it, not
|
||||
again until a recharge is detected and it crosses again. No SMTP
|
||||
configured, or no email on the relevant account, and both features
|
||||
silently no-op rather than erroring.
|
||||
|
||||
## Endpoints
|
||||
|
||||
@@ -117,7 +129,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
flat-scalar parser.
|
||||
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
|
||||
history (feeds the runtime estimate) plus a permanent per-frame
|
||||
battery log (the Stats chart). Only sent on battery power.
|
||||
battery log (the Stats chart). Only sent on battery power. Also where
|
||||
the battery-alert threshold (below) is checked and, at most once per
|
||||
discharge cycle, emailed to the owner.
|
||||
- `GET /frame/firmware` -- streams the frame's staged OTA image.
|
||||
|
||||
### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes)
|
||||
@@ -133,12 +147,26 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
- `GET .../albums` -- the owner's Immich albums.
|
||||
- `POST .../config` -- **partial** update: only provided fields change
|
||||
(`name`, `album_id` -- resets queue/history on change --, `order`,
|
||||
`refresh_interval_s`, `smart_crop_faces`, `queue_target_len`,
|
||||
`orientation` (composed logically then rotated server-side; the
|
||||
on-device manage overlay still renders native, a known limitation),
|
||||
`quiet_hours_*` + `timezone` (a pure server-side decision shaping
|
||||
what `refresh_interval_s` gets handed to the device),
|
||||
`firmware_update_repo_url`, `firmware_auto_update`).
|
||||
`refresh_interval_s`, `display_mode` (`crop_fill`/`crop_faces`/
|
||||
`stretch_fill`/`letterbox`, see `image_pipeline.DISPLAY_MODES`),
|
||||
`queue_target_len`, `orientation` (composed logically then rotated
|
||||
server-side; the on-device manage overlay still renders native, a
|
||||
known limitation), `quiet_hours_*` + `timezone` (a pure server-side
|
||||
decision shaping what `refresh_interval_s` gets handed to the
|
||||
device), `firmware_update_repo_url`, `firmware_auto_update`,
|
||||
`battery_alert_threshold_pct` -- percent, or `-1`/blank to disable --,
|
||||
`palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/
|
||||
blue/green order --, `palette_reset` -- `true` clears back to the
|
||||
default palette --, `color_boost`/`contrast_boost` -- PIL
|
||||
`ImageEnhance` factors, 0-2, 1 = unchanged --, `dither_strength` --
|
||||
0-1, blends toward a flat/undithered quantization before running
|
||||
Floyd-Steinberg, so 0 = no dithering texture and 1 = full strength).
|
||||
- `GET .../preview/original`, `GET .../preview/rendered` -- the
|
||||
before/after comparison on the Configuration tab: the current
|
||||
photo's Immich preview untouched (JPEG), and that same photo run
|
||||
through this frame's actual saved rendering pipeline (PNG, upright
|
||||
logical orientation, not packed device bytes) -- reflects saved
|
||||
settings, not unsaved slider positions.
|
||||
- `POST .../take-control` -- always succeeds for a linked user.
|
||||
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
|
||||
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
|
||||
@@ -178,10 +206,14 @@ 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.
|
||||
- The 6-color palette RGB values in `app/image_pipeline.py` are
|
||||
approximations, not measured values (Waveshare doesn't publish exact
|
||||
color primaries for this panel) -- tune them once you can compare a
|
||||
rendered test image against the real panel.
|
||||
- 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).
|
||||
Each frame's Configuration tab has an **Advanced configuration**
|
||||
section (collapsed by default) with a color picker per ink color --
|
||||
tune them once you can compare a rendered photo against the real
|
||||
panel, and "Reset to defaults" to go back. Different panel units can
|
||||
vary enough to be worth calibrating per frame.
|
||||
|
||||
## Deploying a pre-built image
|
||||
|
||||
|
||||
+37
-1
@@ -27,7 +27,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .migration import new_device_token, new_manage_token
|
||||
from .models import Frame, PendingClaim, User, UserFrame, UserSession
|
||||
from .models import Frame, PasswordResetToken, PendingClaim, ServerSettings, User, UserFrame, UserSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +35,7 @@ MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
||||
SESSION_COOKIE = "session"
|
||||
SESSION_LIFETIME_S = 30 * 86400
|
||||
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_S = 3600
|
||||
|
||||
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
|
||||
# and the parameters are baked into each stored hash so they can be
|
||||
@@ -128,6 +129,41 @@ def users_exist(db: Session) -> bool:
|
||||
return db.scalars(select(User).limit(1)).first() is not None
|
||||
|
||||
|
||||
def get_server_settings(db: Session) -> ServerSettings:
|
||||
"""The SMTP config singleton -- migration.py guarantees row id=1
|
||||
exists (created at startup if missing), so this is never None."""
|
||||
settings = db.get(ServerSettings, 1)
|
||||
assert settings is not None
|
||||
return settings
|
||||
|
||||
|
||||
def create_password_reset_token(db: Session, user: User) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
# Opportunistic prune, same pattern as sessions/pending claims.
|
||||
for stale in db.scalars(select(PasswordResetToken).where(PasswordResetToken.expires_at < now)):
|
||||
db.delete(stale)
|
||||
db.add(PasswordResetToken(
|
||||
token=token, user_id=user.id, created_at=now,
|
||||
expires_at=now + PASSWORD_RESET_TOKEN_LIFETIME_S,
|
||||
))
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def consume_password_reset_token(db: Session, token: str) -> User | None:
|
||||
"""Looks up the token and, if valid, deletes it (single-use) and
|
||||
returns the user it was issued for. None for an unknown/expired
|
||||
token -- callers show a generic error either way."""
|
||||
row = db.get(PasswordResetToken, token)
|
||||
if row is None or row.expires_at < time.time():
|
||||
return None
|
||||
user = db.get(User, row.user_id)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _csrf_ok(request: Request, session: UserSession) -> bool:
|
||||
supplied = request.headers.get("X-CSRF-Token") or ""
|
||||
return hmac.compare_digest(supplied, session.csrf_token)
|
||||
|
||||
+21
-25
@@ -15,12 +15,7 @@ import io
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from .image_pipeline import (
|
||||
_face_aware_crop_box,
|
||||
_plain_center_crop_box,
|
||||
logical_render_size,
|
||||
logical_to_native,
|
||||
)
|
||||
from .image_pipeline import _has_bounding_box, _placement_transform, logical_render_size, logical_to_native
|
||||
|
||||
# Small caps, not arbitrary: each label is its own malloc'd overlay
|
||||
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
|
||||
@@ -31,20 +26,21 @@ MAX_LABELED_FACES = 4
|
||||
NAME_MAX_LEN = 10
|
||||
|
||||
|
||||
def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool,
|
||||
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.
|
||||
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 smart_crop_faces/orientation must
|
||||
match the settings that were active then -- otherwise the crop box and
|
||||
rotation computed here won't match what's actually on screen.
|
||||
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.
|
||||
|
||||
The crop math runs in logical (pre-rotation) space, matching
|
||||
render_frame()'s composition step; each anchor is then rotated into
|
||||
native panel coordinates via logical_to_native(), since the firmware
|
||||
draws labels in native space.
|
||||
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.
|
||||
"""
|
||||
named = [face for face in faces if (face.get("person") or {}).get("name")]
|
||||
if not named:
|
||||
@@ -53,24 +49,24 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_face
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
|
||||
|
||||
if smart_crop_faces and faces:
|
||||
left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
|
||||
crop_w, crop_h = right - left, bottom - top
|
||||
else:
|
||||
left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, logical_w, logical_h)
|
||||
scale_x, scale_y, offset_x, offset_y = _placement_transform(
|
||||
fitted.width, fitted.height, logical_w, logical_h, display_mode, faces
|
||||
)
|
||||
|
||||
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
|
||||
scale_x = fitted.width / face_w
|
||||
scale_y = fitted.height / face_h
|
||||
img_scale_x = fitted.width / face_w
|
||||
img_scale_y = fitted.height / face_h
|
||||
|
||||
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x
|
||||
bottom_y = face["boundingBoxY2"] * scale_y
|
||||
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
|
||||
bottom_y = face["boundingBoxY2"] * img_scale_y
|
||||
|
||||
frame_x = (center_x - left) * (logical_w / crop_w)
|
||||
frame_y = (bottom_y - top) * (logical_h / crop_h)
|
||||
frame_x = center_x * scale_x + offset_x
|
||||
frame_y = bottom_y * scale_y + offset_y
|
||||
|
||||
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
|
||||
continue # this face got cropped out of the final frame entirely
|
||||
|
||||
+190
-49
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageOps
|
||||
|
||||
EPD_WIDTH = 800
|
||||
EPD_HEIGHT = 480
|
||||
@@ -45,39 +47,56 @@ def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
|
||||
return int(logical_h - 1 - y), int(x)
|
||||
return int(x), int(y)
|
||||
|
||||
# Approximate sRGB for each of the panel's 6 ink colors. These are
|
||||
# reasonable placeholders, not measured values -- Waveshare doesn't publish
|
||||
# exact color primaries for this panel. Tune them once you can compare a
|
||||
# rendered test image against the real panel.
|
||||
PALETTE_RGB = [
|
||||
# (0, 0, 0), # BLACK
|
||||
# (255, 255, 255), # WHITE
|
||||
# (255, 219, 0), # YELLOW
|
||||
# (207, 0, 15), # RED
|
||||
# (0, 39, 133), # BLUE
|
||||
# (0, 133, 55), # GREEN
|
||||
(0, 0, 0),
|
||||
(255, 255, 255),
|
||||
(255, 243, 56),
|
||||
(191, 0, 0),
|
||||
(100, 64, 255),
|
||||
(67, 138, 28)
|
||||
# Approximate sRGB for each of the panel's 6 ink colors -- reasonable
|
||||
# placeholders, not measured values (Waveshare doesn't publish exact
|
||||
# color primaries for this panel). This is the fallback for any frame
|
||||
# that hasn't tuned its own (Frame.palette_rgb, set from a frame's
|
||||
# Configuration tab -- "Advanced configuration" -- once you can compare
|
||||
# a rendered test image against the real panel; different panel units
|
||||
# can vary enough to be worth calibrating per frame).
|
||||
DEFAULT_PALETTE_RGB = [
|
||||
(0, 0, 0), # BLACK
|
||||
(255, 255, 255), # WHITE
|
||||
(255, 219, 0), # YELLOW
|
||||
(207, 0, 15), # RED
|
||||
(0, 39, 133), # BLUE
|
||||
(0, 133, 55), # GREEN
|
||||
]
|
||||
|
||||
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||
|
||||
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
||||
# in the same order as PALETTE_RGB. 0x4 is intentionally unused upstream.
|
||||
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
|
||||
# hardware protocol, never user-configurable. 0x4 is intentionally unused
|
||||
# upstream.
|
||||
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
||||
|
||||
|
||||
def _build_palette_image() -> Image.Image:
|
||||
def palette_to_hex(palette_rgb: list) -> list[str]:
|
||||
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
|
||||
configuration color pickers."""
|
||||
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
|
||||
|
||||
|
||||
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
|
||||
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
|
||||
a 6-hex-digit color (what <input type="color"> always sends, but a
|
||||
direct API call might not)."""
|
||||
hex_str = hex_str.strip().lstrip("#")
|
||||
if len(hex_str) != 6:
|
||||
return None
|
||||
try:
|
||||
return (int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _build_palette_image(palette_rgb: list) -> Image.Image:
|
||||
pal_img = Image.new("P", (1, 1))
|
||||
pal_img.putpalette([channel for rgb in PALETTE_RGB for channel in rgb])
|
||||
pal_img.putpalette([channel for rgb in palette_rgb for channel in rgb])
|
||||
return pal_img
|
||||
|
||||
|
||||
_PALETTE_IMAGE = _build_palette_image()
|
||||
|
||||
|
||||
def _plain_center_crop_box(
|
||||
img_width: int, img_height: int, target_width: int, target_height: int
|
||||
) -> tuple[float, float, int, int]:
|
||||
@@ -99,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]:
|
||||
@@ -119,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
|
||||
@@ -152,37 +183,98 @@ def _face_aware_crop_box(
|
||||
return (int(left), int(top), int(left) + crop_w, int(top) + crop_h)
|
||||
|
||||
|
||||
def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
orientation: str = "landscape") -> bytes:
|
||||
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
|
||||
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
|
||||
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
||||
# Display modes: how a photo's aspect ratio gets reconciled with the
|
||||
# panel's. "crop_faces" falls back to "crop_fill" behavior when no faces
|
||||
# were detected/passed. DEFAULT_DISPLAY_MODE matches this project's old
|
||||
# always-on smart_crop_faces=True default.
|
||||
DISPLAY_MODES = ["crop_fill", "crop_faces", "stretch_fill", "letterbox"]
|
||||
DISPLAY_MODE_LABELS = {
|
||||
"crop_fill": "Crop to fill",
|
||||
"crop_faces": "Crop to faces",
|
||||
"stretch_fill": "Stretch to fill",
|
||||
"letterbox": "Shrink to fit",
|
||||
}
|
||||
DEFAULT_DISPLAY_MODE = "crop_faces"
|
||||
LETTERBOX_BG = (255, 255, 255)
|
||||
|
||||
If `faces` (from ImmichClient.get_asset_faces) is non-empty, crops
|
||||
toward keeping them on screen instead of a plain center-crop.
|
||||
|
||||
`orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
|
||||
the frame physically hangs, then rotates into native panel space --
|
||||
the output byte layout is identical either way.
|
||||
"""
|
||||
def _placement_transform(
|
||||
img_width: int, img_height: int, target_w: int, target_h: int,
|
||||
display_mode: str, faces: list[dict] | None = None,
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Returns (scale_x, scale_y, offset_x, offset_y) mapping a point in
|
||||
source-image pixel space to a point in target logical space, for the
|
||||
given display_mode. Shared by render_frame (which also does the
|
||||
actual pixel crop/resize/pad) and face_labels.py (label position
|
||||
math) -- they must stay in exact agreement or overlay labels drift
|
||||
off the people they're meant to point at."""
|
||||
if display_mode == "stretch_fill":
|
||||
return target_w / img_width, target_h / img_height, 0.0, 0.0
|
||||
if display_mode == "letterbox":
|
||||
scale = min(target_w / img_width, target_h / img_height)
|
||||
return scale, scale, (target_w - img_width * scale) / 2, (target_h - img_height * scale) / 2
|
||||
if display_mode == "crop_faces" and faces:
|
||||
left, top, right, bottom = _face_aware_crop_box(img_width, img_height, target_w, target_h, faces)
|
||||
crop_w, crop_h = right - left, bottom - top
|
||||
else:
|
||||
left, top, crop_w, crop_h = _plain_center_crop_box(img_width, img_height, target_w, target_h)
|
||||
scale_x, scale_y = target_w / crop_w, target_h / crop_h
|
||||
return scale_x, scale_y, -left * scale_x, -top * scale_y
|
||||
|
||||
|
||||
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 faces:
|
||||
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)
|
||||
fitted = fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
|
||||
else:
|
||||
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
|
||||
|
||||
return _quantize_and_pack(fitted, orientation)
|
||||
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
|
||||
|
||||
|
||||
def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
|
||||
"""The shared back half of rendering: 6-color Floyd-Steinberg
|
||||
quantization, rotation into native panel space, and 2-pixels/byte
|
||||
packing. Takes an RGB image already composed at logical_render_size()
|
||||
for the orientation."""
|
||||
quantized = logical_img.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
|
||||
if color_boost != 1.0:
|
||||
img = ImageEnhance.Color(img).enhance(color_boost)
|
||||
if contrast_boost != 1.0:
|
||||
img = ImageEnhance.Contrast(img).enhance(contrast_boost)
|
||||
return img
|
||||
|
||||
|
||||
def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image:
|
||||
"""RGB -> palette-quantized P-mode image, same size/orientation as
|
||||
`img` (no rotation here). dither_strength blends `img` toward its own
|
||||
flat (undithered) quantization before running Floyd-Steinberg on the
|
||||
blend: at 0 there's zero quantization error left to diffuse (so the
|
||||
result IS the flat quantization, no dithering texture at all); at 1
|
||||
it's `img` unchanged (full-strength dithering, this project's
|
||||
original always-on behavior); values between give a smooth continuum
|
||||
of dithering intensity rather than an on/off toggle."""
|
||||
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB)
|
||||
if dither_strength >= 1.0:
|
||||
return img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
if dither_strength <= 0.0:
|
||||
return img.quantize(palette=palette_image, dither=Image.Dither.NONE)
|
||||
flat = img.quantize(palette=palette_image, dither=Image.Dither.NONE).convert("RGB")
|
||||
blended = Image.blend(flat, img, dither_strength)
|
||||
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
|
||||
|
||||
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
|
||||
"""Rotates a logical-space quantized image into native panel space
|
||||
and packs it 2 pixels/byte the way epd7in3e.c expects. Always
|
||||
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes."""
|
||||
transpose = ORIENTATION_TRANSPOSE.get(orientation)
|
||||
if transpose is not None:
|
||||
quantized = quantized.transpose(transpose)
|
||||
@@ -200,8 +292,56 @@ def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
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:
|
||||
"""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
|
||||
EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
||||
|
||||
`display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio
|
||||
is reconciled with the panel's: crop_fill (center-crop to fill,
|
||||
excess trimmed), crop_faces (as crop_fill, but shifts the crop to
|
||||
keep `faces` on screen -- falls back to crop_fill if none), stretch_fill
|
||||
(fills exactly, aspect ratio not preserved), letterbox (whole photo
|
||||
visible, letterboxed with LETTERBOX_BG where it doesn't fill).
|
||||
|
||||
`color_boost`/`contrast_boost` are PIL ImageEnhance factors (1.0 =
|
||||
unchanged, matching PIL's own convention); `dither_strength` is
|
||||
0.0-1.0 (see _quantize).
|
||||
|
||||
`orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
|
||||
the frame physically hangs, then rotates into native panel space --
|
||||
the output byte layout is identical either way.
|
||||
|
||||
`palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors,
|
||||
see Frame.palette_rgb) -- None uses the default.
|
||||
"""
|
||||
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
|
||||
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:
|
||||
"""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)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
orientation: str = "landscape") -> bytes:
|
||||
orientation: str = "landscape", palette_rgb: list | 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
|
||||
@@ -246,4 +386,5 @@ 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))
|
||||
|
||||
return _quantize_and_pack(img, orientation)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""SMTP email sending -- password resets and battery-threshold alerts.
|
||||
|
||||
Config lives in the server_settings singleton row (admin-configured via
|
||||
/admin, see routers/pages.py), not env vars -- it's operator
|
||||
infrastructure a household admin sets up once through the UI, same
|
||||
spirit as the rest of this project's "no separate config file" stance
|
||||
post-redesign. Uses stdlib smtplib; no new dependency.
|
||||
|
||||
send_email() never raises -- a broken mail server shouldn't 500 a
|
||||
password-reset request or a battery report; callers get a bool and log
|
||||
a warning on failure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import email.utils
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from .models import ServerSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SMTP_TIMEOUT_S = 10
|
||||
|
||||
|
||||
def send_email(settings: ServerSettings, to_address: str, subject: str, body: str) -> bool:
|
||||
if not settings.smtp_host or not to_address:
|
||||
return False
|
||||
|
||||
msg = MIMEText(body)
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = settings.smtp_from_address or settings.smtp_username or "noreply@localhost"
|
||||
msg["To"] = to_address
|
||||
# email.mime doesn't set either of these on its own -- and a missing
|
||||
# Message-ID in particular is enough for a strict content filter
|
||||
# (e.g. Amavis's header-sanity check) to quarantine an otherwise
|
||||
# cleanly SPF/DKIM/DMARC-passing message outright. Domain in the
|
||||
# generated id matches the From address so it's traceable back here.
|
||||
msg["Date"] = email.utils.formatdate(localtime=True)
|
||||
msg["Message-ID"] = email.utils.make_msgid(domain=msg["From"].rsplit("@", 1)[-1])
|
||||
|
||||
try:
|
||||
# "ssl" (implicit TLS, port 465 typically) needs a TLS socket from
|
||||
# the very first byte -- SMTP_SSL, not SMTP+starttls(). Connecting
|
||||
# a plaintext SMTP() to a TLS-only port fails outright (garbled
|
||||
# banner/timeout), it doesn't degrade gracefully, so this has to
|
||||
# be a real branch rather than "starttls() or not".
|
||||
if settings.smtp_encryption == "ssl":
|
||||
with smtplib.SMTP_SSL(
|
||||
settings.smtp_host, settings.smtp_port,
|
||||
timeout=SMTP_TIMEOUT_S, context=ssl.create_default_context(),
|
||||
) as smtp:
|
||||
if settings.smtp_username:
|
||||
smtp.login(settings.smtp_username, settings.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
else:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=SMTP_TIMEOUT_S) as smtp:
|
||||
if settings.smtp_encryption == "starttls":
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
if settings.smtp_username:
|
||||
smtp.login(settings.smtp_username, settings.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
return True
|
||||
except (OSError, smtplib.SMTPException, ssl.SSLError) as e:
|
||||
logger.warning("Failed to send email to %s: %s", to_address, e)
|
||||
return False
|
||||
+87
-13
@@ -19,7 +19,7 @@ from sqlalchemy import select, text
|
||||
|
||||
from . import config
|
||||
from .db import SessionLocal, engine
|
||||
from .models import Base, BatteryLog, Frame
|
||||
from .models import Base, BatteryLog, Frame, ServerSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,8 +28,65 @@ def _migration_1(conn) -> None:
|
||||
Base.metadata.create_all(bind=conn)
|
||||
|
||||
|
||||
def _migration_2(conn) -> None:
|
||||
"""Adds email (users) and battery-alert threshold (frames) columns,
|
||||
plus the new server_settings/password_reset_tokens tables. ALTER
|
||||
TABLE ADD COLUMN with a default is safe on SQLite against a live,
|
||||
already-populated database -- existing rows just get the default."""
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_sent INTEGER NOT NULL DEFAULT 0"))
|
||||
Base.metadata.create_all(bind=conn) # creates the two new tables only; existing ones untouched
|
||||
|
||||
|
||||
def _migration_3(conn) -> None:
|
||||
"""Replaces the STARTTLS-or-nothing smtp_use_tls boolean with a
|
||||
three-way smtp_encryption ("none"/"starttls"/"ssl") -- implicit TLS
|
||||
(port 465 typically) is a different handshake entirely, not just a
|
||||
skipped starttls() call, so it needs its own connection path in
|
||||
app/mail.py."""
|
||||
conn.execute(text("ALTER TABLE server_settings ADD COLUMN smtp_encryption TEXT NOT NULL DEFAULT 'starttls'"))
|
||||
conn.execute(text(
|
||||
"UPDATE server_settings SET smtp_encryption = CASE WHEN smtp_use_tls THEN 'starttls' ELSE 'none' END"
|
||||
))
|
||||
conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls"))
|
||||
|
||||
|
||||
def _migration_4(conn) -> None:
|
||||
"""Advanced configuration: a per-frame color palette override. NULL
|
||||
for every existing row -- exactly "use the default", no behavior
|
||||
change until a frame's Configuration tab sets one."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN palette_rgb TEXT"))
|
||||
|
||||
|
||||
def _migration_5(conn) -> None:
|
||||
"""Replaces the smart_crop_faces boolean with display_mode (see
|
||||
image_pipeline.DISPLAY_MODES) -- crop_faces/crop_fill are exactly
|
||||
the old True/False behavior, stretch_fill/letterbox are new."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN display_mode TEXT NOT NULL DEFAULT 'crop_faces'"))
|
||||
conn.execute(text(
|
||||
"UPDATE frames SET display_mode = CASE WHEN smart_crop_faces THEN 'crop_faces' ELSE 'crop_fill' END"
|
||||
))
|
||||
conn.execute(text("ALTER TABLE frames DROP COLUMN smart_crop_faces"))
|
||||
|
||||
|
||||
def _migration_6(conn) -> None:
|
||||
"""Advanced configuration: color/contrast enhancement + dithering
|
||||
strength (image_pipeline.render_frame). Defaults (1.0/1.0/1.0)
|
||||
reproduce the exact previous rendering -- no behavior change until a
|
||||
frame's Configuration tab adjusts one."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN color_boost REAL NOT NULL DEFAULT 1.0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN contrast_boost REAL NOT NULL DEFAULT 1.0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
(3, _migration_3),
|
||||
(4, _migration_4),
|
||||
(5, _migration_5),
|
||||
(6, _migration_6),
|
||||
]
|
||||
|
||||
|
||||
@@ -37,19 +94,26 @@ 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()
|
||||
|
||||
|
||||
def new_device_token() -> str:
|
||||
@@ -91,7 +155,7 @@ def _ensure_frame_one() -> None:
|
||||
quiet_hours_start=cfg.quiet_hours_start,
|
||||
quiet_hours_end=cfg.quiet_hours_end,
|
||||
timezone=cfg.timezone,
|
||||
smart_crop_faces=cfg.smart_crop_faces,
|
||||
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
|
||||
orientation=cfg.orientation,
|
||||
queue_target_len=cfg.queue_target_len,
|
||||
current_asset_id=cfg.current_asset_id,
|
||||
@@ -144,3 +208,13 @@ def _ensure_frame_one() -> None:
|
||||
)
|
||||
else:
|
||||
logger.info("Fresh install: created default frame #%d", frame.id)
|
||||
|
||||
|
||||
def _ensure_server_settings() -> None:
|
||||
"""The SMTP config singleton (id=1) -- created with everything blank
|
||||
(email sending disabled) the first time this runs; /admin edits it in
|
||||
place from then on."""
|
||||
with SessionLocal() as db:
|
||||
if db.get(ServerSettings, 1) is None:
|
||||
db.add(ServerSettings(id=1))
|
||||
db.commit()
|
||||
|
||||
+63
-1
@@ -44,6 +44,10 @@ class User(Base):
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
immich_url: Mapped[str] = mapped_column(String, default="")
|
||||
immich_api_key: Mapped[str] = mapped_column(String, default="")
|
||||
# Password-reset emails and battery-threshold alerts (frames.owner's
|
||||
# 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="")
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (
|
||||
@@ -117,9 +121,23 @@ class Frame(Base):
|
||||
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
|
||||
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
|
||||
timezone: Mapped[str] = mapped_column(String, default="UTC")
|
||||
smart_crop_faces: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
# How a photo's aspect ratio is reconciled with the panel's -- see
|
||||
# image_pipeline.DISPLAY_MODES.
|
||||
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
|
||||
orientation: Mapped[str] = mapped_column(String, default="landscape")
|
||||
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
||||
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
|
||||
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
|
||||
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
|
||||
# default -- most frames never touch this.
|
||||
palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
# Advanced configuration: PIL ImageEnhance factors, 1.0 = unchanged
|
||||
# (see image_pipeline.render_frame).
|
||||
color_boost: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
contrast_boost: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
|
||||
# original always-on full-strength Floyd-Steinberg dithering.
|
||||
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
|
||||
# -- state --
|
||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
||||
@@ -139,6 +157,13 @@ class Frame(Base):
|
||||
device_firmware_version: Mapped[str] = mapped_column(String, default="")
|
||||
device_board_variant: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
# Battery-low email alert (see routers/device.py's frame_battery).
|
||||
# -1 = disabled. Sent to the owner's email once per discharge cycle
|
||||
# (battery_alert_sent resets alongside battery_history whenever a
|
||||
# recharge is detected, same trigger as stats_recharge_cycles).
|
||||
battery_alert_threshold_pct: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
battery_alert_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
|
||||
firmware_available_version: Mapped[str] = mapped_column(String, default="")
|
||||
firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
|
||||
@@ -191,6 +216,43 @@ class PendingClaim(Base):
|
||||
expires_at: Mapped[float] = mapped_column(Float)
|
||||
|
||||
|
||||
class ServerSettings(Base):
|
||||
"""Singleton row (id always 1) holding operator-level SMTP config, set
|
||||
from /admin -- not env vars, since this is infrastructure a household
|
||||
admin configures once through the UI rather than at container
|
||||
deploy time. Used for password-reset emails and battery-threshold
|
||||
alerts (see app/mail.py). smtp_host empty = email sending disabled;
|
||||
every send site checks that and no-ops rather than erroring."""
|
||||
|
||||
__tablename__ = "server_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
smtp_host: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
|
||||
smtp_username: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_password: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_from_address: Mapped[str] = mapped_column(String, default="")
|
||||
# "none" (plaintext, port 25 typically), "starttls" (upgrades a
|
||||
# plaintext connection, port 587 typically), or "ssl" (TLS from the
|
||||
# first byte -- a different handshake entirely, not just starttls()
|
||||
# skipped; port 465 typically). See app/mail.py.
|
||||
smtp_encryption: Mapped[str] = mapped_column(String, default="starttls")
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
"""A single-use, time-limited "forgot password" link. token is the
|
||||
URL-safe secret itself (not hashed, like PendingClaim/manage_token --
|
||||
it's a short-lived bearer credential emailed once, not a long-lived
|
||||
session secret)."""
|
||||
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
token: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
expires_at: Mapped[float] = mapped_column(Float)
|
||||
|
||||
|
||||
class BatteryLog(Base):
|
||||
"""Every battery report ever, per frame -- the permanent record behind
|
||||
the battery history chart (was a 20k-entry JSON array in config.json)."""
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
@@ -28,11 +29,19 @@ from sqlalchemy.orm import Session
|
||||
from .. import 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 (
|
||||
DEFAULT_DISPLAY_MODE,
|
||||
DISPLAY_MODES,
|
||||
PALETTE_LABELS,
|
||||
hex_to_rgb,
|
||||
render_preview_png,
|
||||
)
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame
|
||||
from .common import (
|
||||
OVERDUE_FACTOR,
|
||||
battery_estimate_s,
|
||||
fetch_source_and_faces,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
@@ -51,6 +60,17 @@ MAX_QUEUE_TARGET_LEN = 5000
|
||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||
|
||||
|
||||
def _valid_repo_url(url: str) -> bool:
|
||||
"""The frame will periodically fetch from this URL on its own (see
|
||||
gitea_releases.py) and, with auto-update on, install whatever it
|
||||
finds -- unlike a one-off manual firmware upload, that's a standing
|
||||
trust relationship, so it's worth rejecting obviously-wrong input at
|
||||
save time rather than only failing later at fetch time. http(s) only
|
||||
-- no file://, no other schemes."""
|
||||
parsed = urlparse(url)
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/albums")
|
||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||
url, key = immich_creds(frame)
|
||||
@@ -69,7 +89,7 @@ def api_config_save(
|
||||
album_id: str | None = Form(None),
|
||||
order: str | None = Form(None),
|
||||
refresh_interval_s: int | None = Form(None),
|
||||
smart_crop_faces: bool | None = Form(None),
|
||||
display_mode: str | None = Form(None),
|
||||
queue_target_len: int | None = Form(None),
|
||||
orientation: str | None = Form(None),
|
||||
quiet_hours_enabled: bool | None = Form(None),
|
||||
@@ -78,6 +98,12 @@ def api_config_save(
|
||||
timezone: str | None = Form(None),
|
||||
firmware_update_repo_url: str | None = Form(None),
|
||||
firmware_auto_update: bool | None = Form(None),
|
||||
battery_alert_threshold_pct: int | None = Form(None),
|
||||
palette: list[str] | None = Form(None),
|
||||
palette_reset: bool | None = Form(None),
|
||||
color_boost: float | None = Form(None),
|
||||
contrast_boost: float | None = Form(None),
|
||||
dither_strength: float | None = Form(None),
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -100,8 +126,8 @@ def api_config_save(
|
||||
cfg.refresh_interval_s = max(
|
||||
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
|
||||
)
|
||||
if smart_crop_faces is not None:
|
||||
cfg.smart_crop_faces = smart_crop_faces
|
||||
if display_mode is not None:
|
||||
cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
|
||||
if queue_target_len is not None:
|
||||
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
||||
if orientation is not None:
|
||||
@@ -115,9 +141,32 @@ def api_config_save(
|
||||
if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
|
||||
cfg.timezone = timezone
|
||||
if firmware_update_repo_url is not None:
|
||||
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
||||
stripped = firmware_update_repo_url.strip()
|
||||
if stripped and not _valid_repo_url(stripped):
|
||||
raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
|
||||
cfg.firmware_update_repo_url = stripped
|
||||
if firmware_auto_update is not None:
|
||||
cfg.firmware_auto_update = firmware_auto_update
|
||||
if battery_alert_threshold_pct is not None:
|
||||
cfg.battery_alert_threshold_pct = max(-1, min(100, battery_alert_threshold_pct))
|
||||
# A changed threshold should be able to fire again immediately,
|
||||
# not stay suppressed by a flag set under the old value.
|
||||
cfg.battery_alert_sent = False
|
||||
if palette_reset:
|
||||
cfg.palette_rgb = None
|
||||
elif palette is not None:
|
||||
if len(palette) != len(PALETTE_LABELS):
|
||||
raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}")
|
||||
parsed = [hex_to_rgb(h) for h in palette]
|
||||
if any(rgb is None for rgb in parsed):
|
||||
raise HTTPException(400, "Palette colors must be #rrggbb hex values")
|
||||
cfg.palette_rgb = [list(rgb) for rgb in parsed]
|
||||
if color_boost is not None:
|
||||
cfg.color_boost = max(0.0, min(2.0, color_boost))
|
||||
if contrast_boost is not None:
|
||||
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))
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
|
||||
@@ -173,7 +222,6 @@ def api_queue(
|
||||
"firmware_available": cfg.firmware_available_version,
|
||||
"battery_percent": cfg.battery_percent,
|
||||
"battery_as_of": cfg.battery_as_of,
|
||||
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
|
||||
"battery_estimate_s": battery_estimate_s(cfg),
|
||||
"controller_id": cfg.controlled_by_user_id,
|
||||
"controller": (
|
||||
@@ -206,7 +254,6 @@ def api_queue(
|
||||
if snapshot["battery_percent"] >= 0
|
||||
else None
|
||||
),
|
||||
"on_battery_since": snapshot["on_battery_since"],
|
||||
"battery_estimate_s": snapshot["battery_estimate_s"],
|
||||
},
|
||||
}
|
||||
@@ -289,7 +336,14 @@ def api_queue_remove(
|
||||
|
||||
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
|
||||
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
||||
"""Scoped to what this frame is actually showing/queuing -- a user
|
||||
merely linked to view this frame shouldn't be able to pull thumbnails
|
||||
for arbitrary asset ids in the owner's Immich library, only the
|
||||
frame's own curated album. Same rule device.frame_share and
|
||||
manage.manage_thumbnail already enforce."""
|
||||
require_configured(frame)
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||
raise HTTPException(404, "Not on this frame")
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
content, content_type = client.download_asset_thumbnail(asset_id)
|
||||
@@ -298,6 +352,53 @@ def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
|
||||
def _current_asset_id(frame: Frame, db: Session) -> str:
|
||||
"""Same idempotent get_current() dance /api/frames/{id}/queue uses --
|
||||
picks a current photo if none is set yet, otherwise just reads it,
|
||||
never advances early."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
||||
asset_id = cfg.current_asset_id
|
||||
if not asset_id:
|
||||
raise HTTPException(404, "No current photo")
|
||||
return asset_id
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview/original")
|
||||
def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""The Immich preview image behind the currently-displayed photo,
|
||||
unprocessed -- the "now displaying" side of the Configuration tab's
|
||||
before/after comparison."""
|
||||
asset_id = _current_asset_id(frame, db)
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview/rendered")
|
||||
def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""The same photo run through this frame's actual saved rendering
|
||||
pipeline (display mode, palette, color/contrast/dithering) and
|
||||
exported as a PNG -- the "how it will look on the frame" side of the
|
||||
comparison. Not a live preview of unsaved slider values; reflects
|
||||
whatever's currently saved."""
|
||||
asset_id = _current_asset_id(frame, db)
|
||||
client = immich_client_for(frame)
|
||||
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
||||
png = render_preview_png(
|
||||
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,
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/firmware")
|
||||
def api_firmware_upload(
|
||||
file: UploadFile = File(...),
|
||||
@@ -356,15 +457,22 @@ def _apply_gitea_update(db: Session, frame: Frame) -> str:
|
||||
return version
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/firmware/check")
|
||||
@router.post("/api/frames/{frame_id}/firmware/check")
|
||||
def api_firmware_check(
|
||||
force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
||||
force: bool = False, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Throttled check of the configured Gitea repo's latest release
|
||||
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
|
||||
on and a newer version is found, applies it immediately; otherwise
|
||||
just reports it so the UI can offer the "Update frame" button.
|
||||
force=true (the "Check now" button) bypasses the throttle."""
|
||||
force=true (the "Check now" button) bypasses the throttle.
|
||||
|
||||
require_frame_control (not view), and POST (not GET): this can
|
||||
silently stage new firmware as a side effect (the auto-apply path
|
||||
below) exactly like /firmware/apply-latest, so it needs the same
|
||||
guard that route has -- a linked viewer without control shouldn't be
|
||||
able to trigger that, and as a GET it would've been exempt from the
|
||||
CSRF check require_user_api only applies to non-GET/HEAD/OPTIONS."""
|
||||
if not frame.firmware_update_repo_url:
|
||||
return {"enabled": False}
|
||||
|
||||
|
||||
@@ -69,14 +69,18 @@ def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
|
||||
return assets
|
||||
|
||||
|
||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
|
||||
def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) -> tuple[Image.Image, list[dict] | None]:
|
||||
"""The shared first half of rendering: download the Immich preview
|
||||
and (only if display_mode needs it) its detected faces. Used by both
|
||||
render_asset (device-facing) and the web UI's rendered-preview
|
||||
endpoint (routers/api_frames.py) so they can't drift apart."""
|
||||
try:
|
||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||
|
||||
faces = None
|
||||
if frame.smart_crop_faces:
|
||||
if frame.display_mode == "crop_faces":
|
||||
try:
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
@@ -84,8 +88,15 @@ def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
|
||||
# all -- just fall back to a plain center-crop this cycle.
|
||||
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
||||
|
||||
source = Image.open(io.BytesIO(jpeg_bytes))
|
||||
return render_frame(source, faces=faces, orientation=frame.orientation)
|
||||
return Image.open(io.BytesIO(jpeg_bytes)), faces
|
||||
|
||||
|
||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> 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)
|
||||
|
||||
|
||||
def battery_estimate_s(frame: Frame) -> int | None:
|
||||
|
||||
@@ -17,8 +17,8 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import photo_queue, quiet_hours
|
||||
from ..auth import require_device
|
||||
from .. import 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
|
||||
@@ -54,16 +54,19 @@ def _setup_placeholder(frame: Frame, request: Request) -> bytes:
|
||||
["This frame isn't claimed yet", "Scan to link it to your account:"],
|
||||
qr_url=claim_url,
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
)
|
||||
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,
|
||||
)
|
||||
return render_placeholder(
|
||||
["Almost there!", "Pick an album for this frame:", base],
|
||||
qr_url=base,
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
)
|
||||
|
||||
|
||||
@@ -194,14 +197,19 @@ def frame_battery(
|
||||
if not 0 <= body.percent <= 100:
|
||||
raise HTTPException(400, "percent must be 0-100")
|
||||
now = time.time()
|
||||
should_alert = False
|
||||
alert_email = ""
|
||||
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:
|
||||
# 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.
|
||||
# discharge-rate estimates never span a charge -- and let a
|
||||
# low-battery alert fire again next time it actually gets low.
|
||||
locked.battery_history = []
|
||||
locked.stats_recharge_cycles += 1
|
||||
locked.battery_alert_sent = False
|
||||
locked.battery_history.append([now, body.percent])
|
||||
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
|
||||
locked.battery_percent = body.percent
|
||||
@@ -216,6 +224,35 @@ def frame_battery(
|
||||
BatteryLog.ts
|
||||
).limit(count + 1 - BATTERY_LOG_MAX)
|
||||
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
|
||||
|
||||
# Once per discharge cycle (see the recharge reset above), not
|
||||
# once per report -- a frame idling at 4% would otherwise get an
|
||||
# email every wake.
|
||||
if (
|
||||
locked.battery_alert_threshold_pct >= 0
|
||||
and body.percent <= locked.battery_alert_threshold_pct
|
||||
and not locked.battery_alert_sent
|
||||
and locked.owner is not None
|
||||
and locked.owner.email
|
||||
):
|
||||
should_alert = True
|
||||
alert_email = locked.owner.email
|
||||
alert_frame_name = locked.name or f"Frame {locked.id}"
|
||||
|
||||
if should_alert:
|
||||
# Network I/O outside the lock, same convention as everywhere
|
||||
# else in this file -- then a short re-lock to record that it
|
||||
# went out, only on actual success (an SMTP hiccup should let
|
||||
# the next report's still-below-threshold reading try again
|
||||
# rather than silently giving up for the rest of the cycle).
|
||||
settings = get_server_settings(db)
|
||||
sent = mail.send_email(
|
||||
settings, alert_email, f"{alert_frame_name}: battery low",
|
||||
f"{alert_frame_name}'s battery is at {body.percent}%.",
|
||||
)
|
||||
if sent:
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.battery_alert_sent = True
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@@ -328,6 +365,13 @@ def frame_photo_info(frame: Frame = Depends(require_device), db: Session = Depen
|
||||
"location_line1": location[0] if location else None,
|
||||
"location_line2": location[1] if location and location[1] else None,
|
||||
"taken_at": _format_taken_at(exif),
|
||||
# Last value this frame itself reported (see /frame/battery) --
|
||||
# not a fresh reading. Good enough for a glance on the manage
|
||||
# overlay, and lets the device skip a synchronous ADC read (which
|
||||
# would otherwise need to happen before the overlay is composited,
|
||||
# i.e. before the photo it's part of is even pushed to the panel)
|
||||
# just to render this.
|
||||
"battery_percent": frame.battery_percent,
|
||||
}
|
||||
|
||||
|
||||
@@ -373,7 +417,7 @@ def frame_face_labels(frame: Frame = Depends(require_device), db: Session = Depe
|
||||
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
|
||||
smart_crop = locked.smart_crop_faces
|
||||
display_mode = locked.display_mode
|
||||
orientation = locked.orientation
|
||||
|
||||
if not asset_id:
|
||||
@@ -394,7 +438,7 @@ def frame_face_labels(frame: Frame = Depends(require_device), db: Session = Depe
|
||||
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, smart_crop, orientation)
|
||||
labels = compute_face_labels(preview_bytes, faces, display_mode, orientation)
|
||||
|
||||
result: dict[str, object] = {"count": len(labels)}
|
||||
for i, label in enumerate(labels):
|
||||
|
||||
@@ -12,6 +12,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import can_view_frame, current_user
|
||||
from ..db import get_db
|
||||
from ..image_pipeline import (
|
||||
DEFAULT_PALETTE_RGB,
|
||||
DISPLAY_MODE_LABELS,
|
||||
PALETTE_LABELS,
|
||||
palette_to_hex,
|
||||
)
|
||||
from ..models import Frame
|
||||
from ..quiet_hours import ALL_TIMEZONES
|
||||
from .common import shell_context
|
||||
@@ -40,7 +46,12 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get
|
||||
@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(
|
||||
request, db, frame_id, "frame_config.html", "config", timezones=ALL_TIMEZONES
|
||||
request, db, frame_id, "frame_config.html", "config",
|
||||
timezones=ALL_TIMEZONES,
|
||||
palette_labels=PALETTE_LABELS,
|
||||
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||
palette_to_hex=palette_to_hex,
|
||||
display_mode_labels=DISPLAY_MODE_LABELS,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+119
-1
@@ -18,19 +18,23 @@ from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import mail
|
||||
from ..auth import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_LIFETIME_S,
|
||||
consume_password_reset_token,
|
||||
create_password_reset_token,
|
||||
create_session,
|
||||
current_session,
|
||||
current_user,
|
||||
destroy_session,
|
||||
get_server_settings,
|
||||
hash_password,
|
||||
users_exist,
|
||||
verify_password,
|
||||
)
|
||||
from ..db import get_db
|
||||
from ..models import Frame, PendingClaim, User, UserFrame
|
||||
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -181,6 +185,72 @@ def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(g
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/forgot-password", response_class=HTMLResponse)
|
||||
def forgot_password_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"forgot_password.html", {"request": request, "sent": False, "error": None}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_class=HTMLResponse)
|
||||
def forgot_password_submit(
|
||||
request: Request, email: str = Form(...), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Always shows the same "check your email" result regardless of
|
||||
whether the address matches an account -- otherwise this endpoint
|
||||
would let anyone enumerate registered emails. Silently no-ops (same
|
||||
response) if SMTP isn't configured or the user has no email set."""
|
||||
email = email.strip().lower()
|
||||
user = db.scalars(select(User).where(User.email != "").where(User.email == email)).first()
|
||||
if user is not None:
|
||||
token = create_password_reset_token(db, user)
|
||||
reset_url = str(request.base_url).rstrip("/") + f"/reset-password/{token}"
|
||||
settings = get_server_settings(db)
|
||||
mail.send_email(
|
||||
settings, user.email, "Reset your ESPresso Frame password",
|
||||
f"Someone (hopefully you) asked to reset the password for '{user.username}'.\n\n"
|
||||
f"Reset it here (valid for 1 hour): {reset_url}\n\n"
|
||||
"If you didn't request this, ignore this email.",
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"forgot_password.html", {"request": request, "sent": True, "error": None}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/reset-password/{token}", response_class=HTMLResponse)
|
||||
def reset_password_page(token: str, request: Request, db: Session = Depends(get_db)):
|
||||
row = db.get(PasswordResetToken, token)
|
||||
valid = row is not None and row.expires_at > time.time()
|
||||
return templates.TemplateResponse(
|
||||
"reset_password.html", {"request": request, "token": token, "valid": valid, "error": None}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reset-password/{token}", response_class=HTMLResponse)
|
||||
def reset_password_submit(
|
||||
token: str, request: Request, password: str = Form(...), db: Session = Depends(get_db)
|
||||
):
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
return templates.TemplateResponse(
|
||||
"reset_password.html",
|
||||
{"request": request, "token": token, "valid": True,
|
||||
"error": f"Password must be at least {PASSWORD_MIN_LEN} characters."},
|
||||
)
|
||||
user = consume_password_reset_token(db, token)
|
||||
if user is None:
|
||||
return templates.TemplateResponse(
|
||||
"reset_password.html",
|
||||
{"request": request, "token": token, "valid": False, "error": None},
|
||||
)
|
||||
user.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
logger.info("Password reset via email link for user '%s'", user.username)
|
||||
cookie_value, _ = create_session(db, user)
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
_set_session_cookie(response, cookie_value)
|
||||
return response
|
||||
|
||||
|
||||
def _normalize_device_id(device_id: str) -> str:
|
||||
device_id = device_id.strip().lower()
|
||||
if len(device_id) != 12 or any(c not in "0123456789abcdef" for c in device_id):
|
||||
@@ -349,6 +419,7 @@ def settings_submit(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
display_name: str = Form(""),
|
||||
email: str = Form(""),
|
||||
immich_url: str = Form(""),
|
||||
immich_api_key: str = Form(""),
|
||||
current_password: str = Form(""),
|
||||
@@ -362,6 +433,7 @@ def settings_submit(
|
||||
|
||||
error = None
|
||||
user.display_name = display_name.strip() or user.username
|
||||
user.email = email.strip().lower()
|
||||
user.immich_url = immich_url.strip()
|
||||
# Blank API key field = keep the existing one (it's never echoed back
|
||||
# into the form -- a secret that round-trips through HTML is a secret
|
||||
@@ -406,6 +478,7 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
|
||||
"users": users,
|
||||
"frames": frames,
|
||||
"links_by_frame": links_by_frame,
|
||||
"smtp": get_server_settings(db),
|
||||
"notice": notice,
|
||||
"error": error,
|
||||
})
|
||||
@@ -535,6 +608,51 @@ def admin_end_legacy(
|
||||
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
|
||||
|
||||
|
||||
@router.post("/admin/smtp", response_class=HTMLResponse)
|
||||
def admin_smtp_save(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
smtp_host: str = Form(""),
|
||||
smtp_port: int = Form(587),
|
||||
smtp_username: str = Form(""),
|
||||
smtp_password: str = Form(""),
|
||||
smtp_from_address: str = Form(""),
|
||||
smtp_encryption: str = Form("starttls"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Saves the SMTP config used for password-reset emails and battery-
|
||||
threshold alerts. Blank password = keep the existing one, same
|
||||
round-trip-avoidance as the Immich API key field in /settings."""
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
settings = get_server_settings(db)
|
||||
settings.smtp_host = smtp_host.strip()
|
||||
settings.smtp_port = max(1, min(65535, smtp_port))
|
||||
settings.smtp_username = smtp_username.strip()
|
||||
if smtp_password.strip():
|
||||
settings.smtp_password = smtp_password.strip()
|
||||
settings.smtp_from_address = smtp_from_address.strip()
|
||||
settings.smtp_encryption = smtp_encryption if smtp_encryption in ("none", "starttls", "ssl") else "starttls"
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice="SMTP settings saved.")
|
||||
|
||||
|
||||
@router.post("/admin/smtp/test", response_class=HTMLResponse)
|
||||
def admin_smtp_test(request: Request, csrf_token: str = Form(""), db: Session = Depends(get_db)):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
if not admin.email:
|
||||
return _render_admin(request, db, admin, error="Set an email on your own account (Settings) to test SMTP.")
|
||||
settings = get_server_settings(db)
|
||||
ok = mail.send_email(
|
||||
settings, admin.email, "ESPresso Frame test email",
|
||||
"If you're reading this, SMTP is configured correctly.",
|
||||
)
|
||||
if ok:
|
||||
return _render_admin(request, db, admin, notice=f"Test email sent to {admin.email}.")
|
||||
return _render_admin(request, db, admin, error="Failed to send -- check the SMTP settings and server logs.")
|
||||
|
||||
|
||||
@router.post("/admin/frames/{frame_id}/delete", response_class=HTMLResponse)
|
||||
def admin_delete_frame(
|
||||
frame_id: int,
|
||||
|
||||
@@ -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);
|
||||
@@ -10,7 +10,7 @@ async function saveConfig() {
|
||||
order: document.getElementById('order').value,
|
||||
orientation: document.getElementById('orientation').value,
|
||||
refresh_interval_s: String(minutes * 60),
|
||||
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
|
||||
display_mode: document.getElementById('display_mode').value,
|
||||
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
||||
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
||||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
||||
@@ -66,6 +66,138 @@ async function loadControl() {
|
||||
|
||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||
|
||||
// ---- Advanced configuration: color palette ----
|
||||
//
|
||||
// Hex field and R/G/B number fields are kept in sync live, both
|
||||
// directions -- editing either updates the other plus the preview
|
||||
// swatch. Hex stays the field actually read at save time (it's what
|
||||
// the server already validates as #rrggbb); the R/G/B fields are purely
|
||||
// an alternate, more precise way to arrive at the same value than
|
||||
// eyeballing a color-picker swatch.
|
||||
|
||||
function paletteHexInputs() {
|
||||
return Array.from(document.querySelectorAll('.palette-hex'))
|
||||
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
|
||||
}
|
||||
|
||||
function hexFromRgb(r, g, b) {
|
||||
const clamp = (v) => Math.max(0, Math.min(255, Math.round(Number(v) || 0)));
|
||||
return '#' + [r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function rgbFromHex(hex) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim());
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function paletteFieldsFor(index) {
|
||||
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
|
||||
return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') };
|
||||
}
|
||||
|
||||
function syncPaletteFromHex(index) {
|
||||
const f = paletteFieldsFor(index);
|
||||
const rgb = rgbFromHex(f.hex.value);
|
||||
if (!rgb) return;
|
||||
[f.r.value, f.g.value, f.b.value] = rgb;
|
||||
f.swatch.style.background = f.hex.value;
|
||||
}
|
||||
|
||||
function syncPaletteFromRgb(index) {
|
||||
const f = paletteFieldsFor(index);
|
||||
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
|
||||
f.hex.value = hex;
|
||||
f.swatch.style.background = hex;
|
||||
}
|
||||
|
||||
const palettePickerCount = paletteHexInputs().length;
|
||||
for (let i = 0; i < palettePickerCount; i++) {
|
||||
const f = paletteFieldsFor(i);
|
||||
f.hex.addEventListener('input', () => syncPaletteFromHex(i));
|
||||
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
|
||||
}
|
||||
|
||||
// Sliders: live numeric readout next to each, no save until the button
|
||||
// below is clicked.
|
||||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
||||
const input = document.getElementById(id);
|
||||
const readout = document.getElementById(`${id}_value`);
|
||||
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
|
||||
});
|
||||
|
||||
async function savePalette(extra) {
|
||||
const body = new URLSearchParams(extra || {});
|
||||
for (const input of paletteHexInputs()) {
|
||||
body.append('palette', input.value);
|
||||
}
|
||||
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.');
|
||||
loadPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('palette-save').addEventListener('click', () => {
|
||||
savePalette({
|
||||
color_boost: document.getElementById('color_boost').value,
|
||||
contrast_boost: document.getElementById('contrast_boost').value,
|
||||
dither_strength: document.getElementById('dither_strength').value,
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('palette-reset').addEventListener('click', () => {
|
||||
const inputs = paletteHexInputs();
|
||||
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
||||
inputs[i].value = hex;
|
||||
syncPaletteFromHex(i);
|
||||
});
|
||||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
||||
document.getElementById(id).value = '1';
|
||||
document.getElementById(`${id}_value`).textContent = '1.00';
|
||||
});
|
||||
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
||||
});
|
||||
|
||||
// ---- Preview: current photo vs. how it renders with saved settings ----
|
||||
|
||||
function loadPreview() {
|
||||
const bust = Date.now(); // avoid a stale cached image after settings change
|
||||
document.getElementById('preview-original').src = `${window.FRAME_API}/preview/original?_=${bust}`;
|
||||
document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${bust}`;
|
||||
}
|
||||
|
||||
document.getElementById('preview-refresh').addEventListener('click', loadPreview);
|
||||
loadPreview();
|
||||
|
||||
// ---- Battery alerts card ----
|
||||
|
||||
document.getElementById('battery-alert-save').addEventListener('click', async () => {
|
||||
const raw = document.getElementById('battery_alert_threshold_pct').value.trim();
|
||||
const body = new URLSearchParams({
|
||||
battery_alert_threshold_pct: raw === '' ? '-1' : raw,
|
||||
});
|
||||
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.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Firmware card ----
|
||||
|
||||
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
||||
@@ -127,7 +259,7 @@ async function loadFirmwareCheck(force) {
|
||||
const btn = document.getElementById('firmware-update-btn');
|
||||
const boardEl = document.getElementById('firmware-board');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''));
|
||||
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''), { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
if (force) {
|
||||
showStatus(false, await apiError(resp));
|
||||
|
||||
@@ -1,58 +1,6 @@
|
||||
// Stats tab: device status, lifetime counters, battery history chart
|
||||
// (chart logic in battery_chart.js). window.FRAME_API set by template.
|
||||
|
||||
let lastDevice = null;
|
||||
|
||||
function renderDeviceStatus(device) {
|
||||
const el = document.getElementById('device-status');
|
||||
el.innerHTML = '';
|
||||
if (!device || !device.last_seen) {
|
||||
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
|
||||
return;
|
||||
}
|
||||
const now = Date.now() / 1000;
|
||||
const rows = [];
|
||||
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
||||
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
||||
if (device.firmware_version) {
|
||||
let fw = `v${device.firmware_version}`;
|
||||
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
|
||||
fw += ` (v${device.firmware_available} waiting)`;
|
||||
}
|
||||
rows.push(['Firmware', fw, false]);
|
||||
}
|
||||
if (device.battery) {
|
||||
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
||||
}
|
||||
if (device.on_battery_since) {
|
||||
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
|
||||
}
|
||||
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
|
||||
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
|
||||
}
|
||||
for (const [label, value, alert] of rows) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'sub';
|
||||
if (alert) {
|
||||
p.style.color = 'var(--danger-text)';
|
||||
p.style.fontWeight = '600';
|
||||
}
|
||||
p.textContent = `${label}: ${value}`;
|
||||
el.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevice() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
lastDevice = data.device;
|
||||
renderDeviceStatus(data.device);
|
||||
} catch (e) { /* retried on the next poll */ }
|
||||
}
|
||||
// Stats tab: lifetime counters + battery history chart (chart logic in
|
||||
// battery_chart.js). Device status now lives in the always-visible bar
|
||||
// (device_status_bar.js), not here. window.FRAME_API set by template.
|
||||
|
||||
function renderStats(stats) {
|
||||
const el = document.getElementById('stats-box');
|
||||
@@ -90,29 +38,14 @@ async function loadStats() {
|
||||
}
|
||||
}
|
||||
|
||||
loadDevice();
|
||||
loadStats();
|
||||
loadBatteryLog();
|
||||
|
||||
// Redraw the canvas chart (and the "Last seen" alert color) with the
|
||||
// new theme's colors as soon as the toggle is used -- canvas pixels
|
||||
// don't repaint themselves the way CSS does.
|
||||
// Redraw the canvas chart with the new theme's colors as soon as the
|
||||
// toggle is used -- canvas pixels don't repaint themselves the way CSS
|
||||
// does.
|
||||
document.addEventListener('themechange', () => {
|
||||
if (lastBatteryLog) {
|
||||
drawBatteryChart(lastBatteryLog);
|
||||
}
|
||||
if (lastDevice) {
|
||||
renderDeviceStatus(lastDevice);
|
||||
}
|
||||
});
|
||||
|
||||
// Fast tick: re-renders "Last seen"/"On battery for" from already-
|
||||
// fetched data every second so they count up smoothly without hitting
|
||||
// the server that often.
|
||||
setInterval(() => {
|
||||
if (lastDevice) {
|
||||
renderDeviceStatus(lastDevice);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setInterval(loadDevice, 10000);
|
||||
|
||||
@@ -168,6 +168,64 @@ summary.card-title { cursor: pointer; margin-bottom: 0; }
|
||||
details.card[open] summary.card-title { margin-bottom: 14px; }
|
||||
details.card .sub { margin-top: 8px; }
|
||||
|
||||
.palette-table-wrap { overflow-x: auto; margin-top: 14px; }
|
||||
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.palette-table th {
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0 8px 8px 0;
|
||||
}
|
||||
.palette-table td { padding: 4px 8px 4px 0; vertical-align: middle; }
|
||||
.palette-table input { margin-top: 0; }
|
||||
.palette-swatch-preview {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.palette-table input.palette-hex {
|
||||
width: 92px;
|
||||
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.palette-table input.palette-rgb {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
float: right;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
accent-color: var(--accent);
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.preview-compare {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-alt);
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
@@ -426,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>
|
||||
@@ -41,6 +41,42 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Email (SMTP)</h2>
|
||||
<p class="sub">Used for "forgot password" links and battery-low
|
||||
alerts (set per frame in its Configuration tab). Each user needs
|
||||
an email set in their own Settings for either to reach them.</p>
|
||||
<form method="post" action="/admin/smtp">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>SMTP server
|
||||
<input type="text" name="smtp_host" placeholder="smtp.example.com" value="{{ smtp.smtp_host }}">
|
||||
</label>
|
||||
<label>Port
|
||||
<input type="number" name="smtp_port" min="1" max="65535" value="{{ smtp.smtp_port }}">
|
||||
</label>
|
||||
<label>Encryption
|
||||
<select name="smtp_encryption">
|
||||
<option value="starttls" {% if smtp.smtp_encryption == "starttls" %}selected{% endif %}>STARTTLS (usually port 587)</option>
|
||||
<option value="ssl" {% if smtp.smtp_encryption == "ssl" %}selected{% endif %}>SSL/TLS (usually port 465)</option>
|
||||
<option value="none" {% if smtp.smtp_encryption == "none" %}selected{% endif %}>None (usually port 25)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Username
|
||||
<input type="text" name="smtp_username" autocomplete="off" value="{{ smtp.smtp_username }}">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="smtp_password" autocomplete="off"
|
||||
placeholder="{% if smtp.smtp_password %}(unchanged -- enter a new one to replace){% else %}smtp password{% endif %}">
|
||||
</label>
|
||||
<label>From address
|
||||
<input type="text" name="smtp_from_address" placeholder="[email protected]" value="{{ smtp.smtp_from_address }}">
|
||||
</label>
|
||||
<button type="submit">Save SMTP settings</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/smtp/test" style="margin-top: 8px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Send test email to myself</button>
|
||||
</form>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
@@ -34,7 +34,11 @@
|
||||
href="/frames/{{ f.id }}">
|
||||
<span class="frame-dot" aria-hidden="true"></span>
|
||||
{{ f.name or ("Frame " ~ f.id) }}
|
||||
{% if f.owner_user_id is none %}<span class="nav-sub">unclaimed</span>{% endif %}
|
||||
{% if f.owner_user_id is none %}
|
||||
<span class="nav-sub">unclaimed</span>
|
||||
{% elif f.battery_percent >= 0 %}
|
||||
<span class="nav-sub battery-badge" title="Battery">🔋{{ f.battery_percent }}%</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if not sidebar_frames %}
|
||||
@@ -68,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>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Reset your password</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Forgot password</h2>
|
||||
{% if sent %}
|
||||
<div class="status ok">If that email is on an account, a reset link is on its way.</div>
|
||||
<p class="sub" style="margin-top: 14px;">Nothing arriving? The server's
|
||||
SMTP settings may not be configured yet -- ask your admin.</p>
|
||||
{% else %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
<p class="sub">Enter the email on your account and we'll send a reset link.</p>
|
||||
<form method="post" action="/forgot-password">
|
||||
<label>Email
|
||||
<input type="email" name="email" required autofocus autocomplete="email">
|
||||
</label>
|
||||
<button type="submit">Send reset link</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<p class="sub" style="margin-top: 14px;"><a href="/login">Back to log in</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -37,10 +38,21 @@
|
||||
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
||||
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="smart_crop_faces" {% if frame.smart_crop_faces %}checked{% endif %}>
|
||||
<label for="smart_crop_faces">Center faces in crop</label>
|
||||
</div>
|
||||
<label>Display mode
|
||||
<select id="display_mode">
|
||||
{% for mode, label in display_mode_labels.items() %}
|
||||
<option value="{{ mode }}" {% if frame.display_mode == mode %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">How a photo's aspect ratio
|
||||
is reconciled with the panel's: <strong>Crop to fill</strong>
|
||||
trims the excess; <strong>Crop to faces</strong> does the same
|
||||
but shifts the crop to keep people on screen; <strong>Stretch to
|
||||
fill</strong> fills the panel exactly without cropping (photos
|
||||
not matching the panel's aspect ratio look stretched);
|
||||
<strong>Shrink to fit</strong> shows the whole photo, letterboxed
|
||||
if needed.</p>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
|
||||
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
|
||||
@@ -94,11 +106,96 @@
|
||||
<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>
|
||||
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery alerts</h2>
|
||||
<label>Email me when battery drops below (%)
|
||||
<input type="number" id="battery_alert_threshold_pct" min="0" max="100"
|
||||
value="{% if frame.battery_alert_threshold_pct >= 0 %}{{ frame.battery_alert_threshold_pct }}{% endif %}"
|
||||
placeholder="disabled">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Sent once per discharge cycle
|
||||
to the frame owner's email (set in Settings) -- clear the field to
|
||||
disable. Needs SMTP configured by an admin.</p>
|
||||
<button type="button" class="secondary" id="battery-alert-save">Save</button>
|
||||
</section>
|
||||
|
||||
<details class="card">
|
||||
<summary class="card-title">Advanced configuration</summary>
|
||||
<p class="sub">Color-quantization values used when dithering photos
|
||||
for this panel -- approximations by default, since exact primaries
|
||||
aren't published. Tune them by comparing a rendered photo against
|
||||
the physical panel; different panel units can vary enough to be
|
||||
worth calibrating per frame.</p>
|
||||
<div class="palette-table-wrap">
|
||||
<table class="palette-table">
|
||||
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
||||
<tbody>
|
||||
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
|
||||
{% set current_hex = palette_to_hex(current_palette) %}
|
||||
{% for label in palette_labels %}
|
||||
<tr>
|
||||
<td><span class="palette-swatch-preview" data-index="{{ loop.index0 }}"
|
||||
style="background: {{ current_hex[loop.index0] }};"></span></td>
|
||||
<td>{{ label }}</td>
|
||||
<td><input type="text" class="palette-hex" id="palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
|
||||
value="{{ current_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
|
||||
spellcheck="false" autocomplete="off"></td>
|
||||
<td><input type="number" class="palette-rgb palette-r" data-index="{{ loop.index0 }}"
|
||||
min="0" max="255" value="{{ current_palette[loop.index0][0] }}"></td>
|
||||
<td><input type="number" class="palette-rgb palette-g" data-index="{{ loop.index0 }}"
|
||||
min="0" max="255" value="{{ current_palette[loop.index0][1] }}"></td>
|
||||
<td><input type="number" class="palette-rgb palette-b" data-index="{{ loop.index0 }}"
|
||||
min="0" max="255" value="{{ current_palette[loop.index0][2] }}"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 20px;">Image adjustments</h2>
|
||||
<label>Color enhancement <span class="slider-value" id="color_boost_value">{{ "%.2f" | format(frame.color_boost) }}</span>
|
||||
<input type="range" id="color_boost" min="0" max="2" step="0.05" value="{{ frame.color_boost }}">
|
||||
</label>
|
||||
<label>Contrast <span class="slider-value" id="contrast_boost_value">{{ "%.2f" | format(frame.contrast_boost) }}</span>
|
||||
<input type="range" id="contrast_boost" min="0" max="2" step="0.05" value="{{ frame.contrast_boost }}">
|
||||
</label>
|
||||
<label>Dithering strength <span class="slider-value" id="dither_strength_value">{{ "%.2f" | format(frame.dither_strength) }}</span>
|
||||
<input type="range" id="dither_strength" min="0" max="1" step="0.05" value="{{ frame.dither_strength }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">1.00 is unchanged for color/
|
||||
contrast. Dithering strength trades noise texture for smoother
|
||||
gradients as it goes down; 0 is a flat, un-dithered quantization.
|
||||
Use the preview below to compare.</p>
|
||||
|
||||
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
|
||||
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
||||
</details>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">The current photo, and exactly how it renders on the
|
||||
panel with this frame's saved settings above.</p>
|
||||
<div class="preview-compare">
|
||||
<div class="preview-pane">
|
||||
<p class="sub">Now displaying</p>
|
||||
<img class="preview-img" id="preview-original" alt="Original photo">
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<p class="sub">How it will look on the frame</p>
|
||||
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,6 +203,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script>
|
||||
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
|
||||
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
|
||||
</script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/frame_config.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
|
||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -55,6 +56,7 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/queue.js"></script>
|
||||
<script src="/static/frame_photos.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,29 +3,19 @@
|
||||
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
|
||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery history</h2>
|
||||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery history</h2>
|
||||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Lifetime stats</h2>
|
||||
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Device</h2>
|
||||
<div id="device-status"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<section class="card">
|
||||
<h2 class="card-title">Lifetime stats</h2>
|
||||
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
@@ -33,5 +23,6 @@
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script src="/static/battery_chart.js"></script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/frame_stats.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
</label>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Forgot your password?</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Reset your password</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Set a new password</h2>
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
{% if valid %}
|
||||
<form method="post" action="/reset-password/{{ token }}">
|
||||
<label>New password
|
||||
<input type="password" name="password" minlength="8" required autofocus autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Set password</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="sub">This reset link is invalid or has expired -- links are
|
||||
only good for an hour.</p>
|
||||
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Request a new one</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -14,6 +14,12 @@
|
||||
<label>Display name
|
||||
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
|
||||
</label>
|
||||
<label>Email
|
||||
<input type="email" name="email" value="{{ user.email }}" placeholder="[email protected]">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Used for password-reset links
|
||||
and, for frames you own, battery-low alerts (set a threshold in a
|
||||
frame's Configuration tab).</p>
|
||||
<label>Immich URL
|
||||
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
|
||||
value="{{ user.immich_url }}">
|
||||
|
||||
Reference in New Issue
Block a user