Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
996e06e2bc | ||
|
|
d324bc4a57 | ||
|
|
ac4b57e611 | ||
|
|
462b558bef | ||
|
|
9f9ad34a40 | ||
|
|
e48ac50ea1 | ||
|
|
83c59af1dd | ||
|
|
49bc9f9ec9 | ||
|
|
e802882fc1 | ||
|
|
5b11f2accb | ||
|
|
c1c803b497 | ||
|
|
8e10ca540e |
@@ -20,6 +20,7 @@
|
|||||||
#include "combo_button.h"
|
#include "combo_button.h"
|
||||||
#include "ota_update.h"
|
#include "ota_update.h"
|
||||||
#include "board_antenna.h"
|
#include "board_antenna.h"
|
||||||
|
#include "battery.h"
|
||||||
|
|
||||||
#include "frame_client.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;
|
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
|
/* Finds the string value associated with "key" in a small, flat JSON
|
||||||
* blob, e.g. "San Francisco, CA" in {"location": "San Francisco, CA"}.
|
* blob, e.g. "San Francisco, CA" in {"location": "San Francisco, CA"}.
|
||||||
* Same rationale as json_extract_uint() -- not a general parser. Returns
|
* 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:
|
/* 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 --
|
* 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
|
* e.g. no GPS EXIF to geocode, or no capture date), a share_url built
|
||||||
* from the returned asset_id, same construction pattern as
|
* from the returned asset_id (same construction pattern as
|
||||||
* run_fetch_cycle()'s management_url. Any failure (unreachable, no
|
* run_fetch_cycle()'s management_url), and the last battery percent this
|
||||||
* current photo, etc.) just leaves all outputs empty -- the caller
|
* 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
|
* treats that as "skip these optional overlay regions", not a hard
|
||||||
* error, since the base "scan to manage" QR should still show. */
|
* 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,
|
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 *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_line1[0] = '\0';
|
||||||
location_line2[0] = '\0';
|
location_line2[0] = '\0';
|
||||||
taken_at[0] = '\0';
|
taken_at[0] = '\0';
|
||||||
share_url[0] = '\0';
|
share_url[0] = '\0';
|
||||||
|
*battery_percent = -1;
|
||||||
|
|
||||||
char url[256];
|
char url[256];
|
||||||
build_url(url, sizeof(url), cfg, "frame/photo-info");
|
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_line1", location_line1, location_line1_size);
|
||||||
json_extract_string(body, "location_line2", location_line2, location_line2_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_string(body, "taken_at", taken_at, taken_at_size);
|
||||||
|
json_extract_int(body, "battery_percent", battery_percent);
|
||||||
|
|
||||||
char asset_id[48];
|
char asset_id[48];
|
||||||
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
|
if (json_extract_string(body, "asset_id", asset_id, sizeof(asset_id))) {
|
||||||
@@ -757,8 +794,7 @@ static bool wait_for_button_press(uint32_t timeout_ms)
|
|||||||
* had that data); level 2 adds named-face labels on top. action only
|
* had that data); level 2 adds named-face labels on top. action only
|
||||||
* applies at level 1 -- escalating to level 2 redisplays the same
|
* applies at level 1 -- escalating to level 2 redisplays the same
|
||||||
* photo, so it never re-advances/-backs. */
|
* 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,
|
static esp_err_t show_menu_level(const frame_config_t *cfg, fetch_action_t action, int level)
|
||||||
int battery_percent)
|
|
||||||
{
|
{
|
||||||
char management_url[256];
|
char management_url[256];
|
||||||
build_url(management_url, sizeof(management_url), cfg, "");
|
build_url(management_url, sizeof(management_url), cfg, "");
|
||||||
@@ -774,8 +810,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
|
* overflow, but a truncated/dropped token still means the resulting
|
||||||
* request just 401s with no obvious cause). */
|
* request just 401s with no obvious cause). */
|
||||||
char share_url[320];
|
char share_url[320];
|
||||||
|
int battery_percent;
|
||||||
fetch_photo_info(cfg, location_line1, sizeof(location_line1), location_line2,
|
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];
|
manage_face_label_t face_labels[MANAGE_FACE_LABELS_MAX];
|
||||||
int face_label_count = 0;
|
int face_label_count = 0;
|
||||||
@@ -816,10 +854,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
|
* that (escalating, or the final revert) are logged but don't count as
|
||||||
* an overall failure -- something was already shown successfully, which
|
* an overall failure -- something was already shown successfully, which
|
||||||
* was the point of the button. */
|
* 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;
|
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) {
|
if (err != ESP_OK) {
|
||||||
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
|
ESP_LOGW(TAG, "Could not render management overlay (%s), showing photo normally", esp_err_to_name(err));
|
||||||
return fetch_and_display(cfg, action, NULL);
|
return fetch_and_display(cfg, action, NULL);
|
||||||
@@ -832,7 +870,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 */
|
break; /* timeout at any level, or a press while already maxed out -- exit */
|
||||||
}
|
}
|
||||||
level++;
|
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) {
|
if (level_err != ESP_OK) {
|
||||||
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
|
ESP_LOGW(TAG, "Could not render menu level %d (%s), reverting", level, esp_err_to_name(level_err));
|
||||||
break;
|
break;
|
||||||
@@ -849,13 +887,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
|
/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
|
||||||
* show_management_qr -- the escalating manage menu (see
|
* show_management_qr -- the escalating manage menu (see
|
||||||
* run_management_menu()). */
|
* run_management_menu()). */
|
||||||
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||||
int battery_percent)
|
|
||||||
{
|
{
|
||||||
if (!show_management_qr) {
|
if (!show_management_qr) {
|
||||||
return fetch_and_display(cfg, action, NULL);
|
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).
|
/* Reports the battery percent to the server (POST /frame/battery).
|
||||||
@@ -899,8 +936,7 @@ static void report_battery(const frame_config_t *cfg, int percent)
|
|||||||
esp_http_client_cleanup(client);
|
esp_http_client_cleanup(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr,
|
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
|
||||||
int battery_percent)
|
|
||||||
{
|
{
|
||||||
esp_err_t epd_err = epd_init();
|
esp_err_t epd_err = epd_init();
|
||||||
bool have_display = (epd_err == ESP_OK);
|
bool have_display = (epd_err == ESP_OK);
|
||||||
@@ -934,7 +970,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. */
|
* worth it to stop false-failing on the common case. */
|
||||||
bool image_ok = true;
|
bool image_ok = true;
|
||||||
if (have_display) {
|
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);
|
image_ok = (fetch_err == ESP_OK);
|
||||||
if (!image_ok) {
|
if (!image_ok) {
|
||||||
/* epd_display_stream() never triggers a physical refresh on a
|
/* epd_display_stream() never triggers a physical refresh on a
|
||||||
@@ -969,6 +1005,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). */
|
* normal boot, not just the one right after an update). */
|
||||||
esp_ota_mark_app_valid_cancel_rollback();
|
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);
|
report_battery(cfg, battery_percent);
|
||||||
frame_server_config_t server_cfg = fetch_frame_config(cfg);
|
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;
|
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
|
* 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
|
* top-right corner linking to the server's config page, held for 30
|
||||||
* seconds (the device stays awake), then reverted back to the plain
|
* 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
|
* Reads the battery (see battery_read_percent()) itself, once, after the
|
||||||
* battery_read_percent()) is shown on the management menu overlay and
|
* photo is already on the panel, and reports it to the server on a
|
||||||
* reported to the server after a successful fetch; -1 skips both.
|
* 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,
|
void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr);
|
||||||
int battery_percent);
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
#include "next_button.h"
|
#include "next_button.h"
|
||||||
#include "back_button.h"
|
#include "back_button.h"
|
||||||
#include "combo_button.h"
|
#include "combo_button.h"
|
||||||
#include "battery.h"
|
|
||||||
|
|
||||||
static const char *TAG = "main";
|
static const char *TAG = "main";
|
||||||
|
|
||||||
@@ -52,18 +51,12 @@ void app_main(void)
|
|||||||
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
||||||
bool show_management_qr = combo_button_check();
|
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;
|
frame_config_t cfg;
|
||||||
esp_err_t cfg_err = frame_config_load(&cfg);
|
esp_err_t cfg_err = frame_config_load(&cfg);
|
||||||
if (cfg_err == ESP_OK) {
|
if (cfg_err == ESP_OK) {
|
||||||
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
|
ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid);
|
||||||
if (frame_wifi_connect_sta(&cfg) == ESP_OK) {
|
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 */
|
return; /* frame_client_run currently never returns */
|
||||||
}
|
}
|
||||||
ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning",
|
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,
|
/* 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
|
* carrying this device's id -- how a frame gets linked to a user
|
||||||
* account. The ~7s delay covers the phone dropping this softAP (the
|
* account. This page is entirely self-contained (no external
|
||||||
* device reboots right after this response) and rejoining its normal
|
* resources) so it renders fully from what we send now, before the
|
||||||
* WiFi before the redirect fires; the visible link is the fallback
|
* softAP goes away -- a phone mid-load of a remote asset would just
|
||||||
* if the phone loses that race. Scheme handling matches
|
* time out once the AP drops. The visible countdown ticks down for
|
||||||
* frame_client.c's build_url(): a bare host gets http://. */
|
* 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];
|
char device_id[FRAME_DEVICE_ID_LEN + 1];
|
||||||
frame_device_id_get(device_id, sizeof(device_id));
|
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);
|
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),
|
snprintf(resp, sizeof(resp),
|
||||||
"<!doctype html><html><head>"
|
"<!doctype html><html><head>"
|
||||||
"<meta http-equiv=\"refresh\" content=\"7;url=%s\">"
|
"<meta http-equiv=\"refresh\" content=\"%d;url=%s\">"
|
||||||
"<style>body{font-family:sans-serif;text-align:center;padding:2em}</style></head>"
|
"<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>"
|
"<body><h3>Saved — the frame is restarting</h3>"
|
||||||
"<p>Reconnect to your normal WiFi. You'll be taken to the claim page "
|
"<p>Reconnect to your normal WiFi if it doesn't happen automatically.</p>"
|
||||||
"in a few seconds…</p>"
|
"<p>Redirecting you in <span id=\"n\">%d</span> seconds…</p>"
|
||||||
"<p><a href=\"%s\">Continue to claim your frame</a></p>"
|
"<p><a id=\"now\" href=\"%s\">Redirect now</a></p>"
|
||||||
"<script>setTimeout(function(){location.href=%c%s%c},7000)</script>"
|
"<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>",
|
"</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_set_type(req, "text/html");
|
||||||
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
|
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
|
||||||
|
|
||||||
/* Let the response flush to the client before rebooting into STA mode. */
|
/* Keep the softAP up for the full visible countdown (plus a 1s margin
|
||||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
* 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();
|
esp_restart();
|
||||||
|
|
||||||
|
#undef PROVISIONING_COUNTDOWN_S
|
||||||
|
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.2.0
|
1.2.2
|
||||||
|
|||||||
+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.
|
view current + upcoming, "show next", advance, back -- nothing else.
|
||||||
The share QR stays public (it creates a 30-minute Immich share link
|
The share QR stays public (it creates a 30-minute Immich share link
|
||||||
for exactly the photo shown).
|
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
|
## Endpoints
|
||||||
|
|
||||||
@@ -117,7 +129,9 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
|||||||
flat-scalar parser.
|
flat-scalar parser.
|
||||||
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
|
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
|
||||||
history (feeds the runtime estimate) plus a permanent per-frame
|
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.
|
- `GET /frame/firmware` -- streams the frame's staged OTA image.
|
||||||
|
|
||||||
### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes)
|
### 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.
|
- `GET .../albums` -- the owner's Immich albums.
|
||||||
- `POST .../config` -- **partial** update: only provided fields change
|
- `POST .../config` -- **partial** update: only provided fields change
|
||||||
(`name`, `album_id` -- resets queue/history on change --, `order`,
|
(`name`, `album_id` -- resets queue/history on change --, `order`,
|
||||||
`refresh_interval_s`, `smart_crop_faces`, `queue_target_len`,
|
`refresh_interval_s`, `display_mode` (`crop_fill`/`crop_faces`/
|
||||||
`orientation` (composed logically then rotated server-side; the
|
`stretch_fill`/`letterbox`, see `image_pipeline.DISPLAY_MODES`),
|
||||||
on-device manage overlay still renders native, a known limitation),
|
`queue_target_len`, `orientation` (composed logically then rotated
|
||||||
`quiet_hours_*` + `timezone` (a pure server-side decision shaping
|
server-side; the on-device manage overlay still renders native, a
|
||||||
what `refresh_interval_s` gets handed to the device),
|
known limitation), `quiet_hours_*` + `timezone` (a pure server-side
|
||||||
`firmware_update_repo_url`, `firmware_auto_update`).
|
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.
|
- `POST .../take-control` -- always succeeds for a linked user.
|
||||||
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
|
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
|
||||||
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
|
- `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
|
to photos this frame is actually showing or has queued, not any
|
||||||
Immich asset ID someone might guess -- a second layer a leaked device
|
Immich asset ID someone might guess -- a second layer a leaked device
|
||||||
token alone wouldn't bypass.
|
token alone wouldn't bypass.
|
||||||
- The 6-color palette RGB values in `app/image_pipeline.py` are
|
- The 6-color palette RGB values in `app/image_pipeline.py`
|
||||||
approximations, not measured values (Waveshare doesn't publish exact
|
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
||||||
color primaries for this panel) -- tune them once you can compare a
|
(Waveshare doesn't publish exact color primaries for this panel).
|
||||||
rendered test image against the real 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
|
## Deploying a pre-built image
|
||||||
|
|
||||||
|
|||||||
+37
-1
@@ -27,7 +27,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
from .migration import new_device_token, new_manage_token
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
|||||||
SESSION_COOKIE = "session"
|
SESSION_COOKIE = "session"
|
||||||
SESSION_LIFETIME_S = 30 * 86400
|
SESSION_LIFETIME_S = 30 * 86400
|
||||||
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
|
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,
|
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
|
||||||
# and the parameters are baked into each stored hash so they can be
|
# 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
|
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:
|
def _csrf_ok(request: Request, session: UserSession) -> bool:
|
||||||
supplied = request.headers.get("X-CSRF-Token") or ""
|
supplied = request.headers.get("X-CSRF-Token") or ""
|
||||||
return hmac.compare_digest(supplied, session.csrf_token)
|
return hmac.compare_digest(supplied, session.csrf_token)
|
||||||
|
|||||||
+19
-25
@@ -15,12 +15,7 @@ import io
|
|||||||
|
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
from .image_pipeline import (
|
from .image_pipeline import _placement_transform, logical_render_size, logical_to_native
|
||||||
_face_aware_crop_box,
|
|
||||||
_plain_center_crop_box,
|
|
||||||
logical_render_size,
|
|
||||||
logical_to_native,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Small caps, not arbitrary: each label is its own malloc'd overlay
|
# Small caps, not arbitrary: each label is its own malloc'd overlay
|
||||||
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
|
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
|
||||||
@@ -31,20 +26,21 @@ MAX_LABELED_FACES = 4
|
|||||||
NAME_MAX_LEN = 10
|
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]:
|
orientation: str = "landscape") -> list[dict]:
|
||||||
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
|
"""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.
|
800x480 panel pixel space at each named face's bottom-center point.
|
||||||
Faces without an Immich-identified person name are skipped entirely.
|
Faces without an Immich-identified person name are skipped entirely.
|
||||||
preview_bytes must be the same preview image render_frame() used for
|
preview_bytes must be the same preview image render_frame() used for
|
||||||
the currently-displayed frame, and smart_crop_faces/orientation must
|
the currently-displayed frame, and display_mode/orientation must
|
||||||
match the settings that were active then -- otherwise the crop box and
|
match the settings that were active then -- otherwise the placement
|
||||||
rotation computed here won't match what's actually on screen.
|
and rotation computed here won't match what's actually on screen.
|
||||||
|
|
||||||
The crop math runs in logical (pre-rotation) space, matching
|
The placement math runs in logical (pre-rotation) space, matching
|
||||||
render_frame()'s composition step; each anchor is then rotated into
|
render_frame()'s composition step (see image_pipeline._placement_transform,
|
||||||
native panel coordinates via logical_to_native(), since the firmware
|
shared so the two can't drift apart); each anchor is then rotated
|
||||||
draws labels in native space.
|
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")]
|
named = [face for face in faces if (face.get("person") or {}).get("name")]
|
||||||
if not named:
|
if not named:
|
||||||
@@ -53,24 +49,22 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_face
|
|||||||
logical_w, logical_h = logical_render_size(orientation)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
|
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
|
||||||
|
|
||||||
if smart_crop_faces and faces:
|
scale_x, scale_y, offset_x, offset_y = _placement_transform(
|
||||||
left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
|
fitted.width, fitted.height, logical_w, logical_h, display_mode, 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)
|
|
||||||
|
|
||||||
labels = []
|
labels = []
|
||||||
for face in named[:MAX_LABELED_FACES]:
|
for face in named[:MAX_LABELED_FACES]:
|
||||||
face_w = face.get("imageWidth") or fitted.width
|
face_w = face.get("imageWidth") or fitted.width
|
||||||
face_h = face.get("imageHeight") or fitted.height
|
face_h = face.get("imageHeight") or fitted.height
|
||||||
scale_x = fitted.width / face_w
|
img_scale_x = fitted.width / face_w
|
||||||
scale_y = fitted.height / face_h
|
img_scale_y = fitted.height / face_h
|
||||||
|
|
||||||
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x
|
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
|
||||||
bottom_y = face["boundingBoxY2"] * scale_y
|
bottom_y = face["boundingBoxY2"] * img_scale_y
|
||||||
|
|
||||||
frame_x = (center_x - left) * (logical_w / crop_w)
|
frame_x = center_x * scale_x + offset_x
|
||||||
frame_y = (bottom_y - top) * (logical_h / crop_h)
|
frame_y = bottom_y * scale_y + offset_y
|
||||||
|
|
||||||
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
|
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
|
||||||
continue # this face got cropped out of the final frame entirely
|
continue # this face got cropped out of the final frame entirely
|
||||||
|
|||||||
+178
-49
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PIL import Image, ImageOps
|
import io
|
||||||
|
|
||||||
|
from PIL import Image, ImageEnhance, ImageOps
|
||||||
|
|
||||||
EPD_WIDTH = 800
|
EPD_WIDTH = 800
|
||||||
EPD_HEIGHT = 480
|
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(logical_h - 1 - y), int(x)
|
||||||
return int(x), int(y)
|
return int(x), int(y)
|
||||||
|
|
||||||
# Approximate sRGB for each of the panel's 6 ink colors. These are
|
# Approximate sRGB for each of the panel's 6 ink colors -- reasonable
|
||||||
# reasonable placeholders, not measured values -- Waveshare doesn't publish
|
# placeholders, not measured values (Waveshare doesn't publish exact
|
||||||
# exact color primaries for this panel. Tune them once you can compare a
|
# color primaries for this panel). This is the fallback for any frame
|
||||||
# rendered test image against the real panel.
|
# that hasn't tuned its own (Frame.palette_rgb, set from a frame's
|
||||||
PALETTE_RGB = [
|
# Configuration tab -- "Advanced configuration" -- once you can compare
|
||||||
# (0, 0, 0), # BLACK
|
# a rendered test image against the real panel; different panel units
|
||||||
# (255, 255, 255), # WHITE
|
# can vary enough to be worth calibrating per frame).
|
||||||
# (255, 219, 0), # YELLOW
|
DEFAULT_PALETTE_RGB = [
|
||||||
# (207, 0, 15), # RED
|
(0, 0, 0), # BLACK
|
||||||
# (0, 39, 133), # BLUE
|
(255, 255, 255), # WHITE
|
||||||
# (0, 133, 55), # GREEN
|
(255, 219, 0), # YELLOW
|
||||||
(0, 0, 0),
|
(207, 0, 15), # RED
|
||||||
(255, 255, 255),
|
(0, 39, 133), # BLUE
|
||||||
(255, 243, 56),
|
(0, 133, 55), # GREEN
|
||||||
(191, 0, 0),
|
|
||||||
(100, 64, 255),
|
|
||||||
(67, 138, 28)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||||
|
|
||||||
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
# 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]
|
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 = 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
|
return pal_img
|
||||||
|
|
||||||
|
|
||||||
_PALETTE_IMAGE = _build_palette_image()
|
|
||||||
|
|
||||||
|
|
||||||
def _plain_center_crop_box(
|
def _plain_center_crop_box(
|
||||||
img_width: int, img_height: int, target_width: int, target_height: int
|
img_width: int, img_height: int, target_width: int, target_height: int
|
||||||
) -> tuple[float, float, int, int]:
|
) -> tuple[float, float, int, int]:
|
||||||
@@ -152,37 +171,98 @@ def _face_aware_crop_box(
|
|||||||
return (int(left), int(top), int(left) + crop_w, int(top) + crop_h)
|
return (int(left), int(top), int(left) + crop_w, int(top) + crop_h)
|
||||||
|
|
||||||
|
|
||||||
def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
# Display modes: how a photo's aspect ratio gets reconciled with the
|
||||||
orientation: str = "landscape") -> bytes:
|
# panel's. "crop_faces" falls back to "crop_fill" behavior when no faces
|
||||||
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
|
# were detected/passed. DEFAULT_DISPLAY_MODE matches this project's old
|
||||||
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
|
# always-on smart_crop_faces=True default.
|
||||||
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
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
|
def _placement_transform(
|
||||||
the frame physically hangs, then rotates into native panel space --
|
img_width: int, img_height: int, target_w: int, target_h: int,
|
||||||
the output byte layout is identical either way.
|
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)
|
logical_w, logical_h = logical_render_size(orientation)
|
||||||
fitted = ImageOps.exif_transpose(source.convert("RGB"))
|
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)
|
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)
|
return fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
|
||||||
else:
|
return ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
|
||||||
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
|
|
||||||
|
|
||||||
return _quantize_and_pack(fitted, orientation)
|
|
||||||
|
|
||||||
|
|
||||||
def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
|
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
|
||||||
"""The shared back half of rendering: 6-color Floyd-Steinberg
|
if color_boost != 1.0:
|
||||||
quantization, rotation into native panel space, and 2-pixels/byte
|
img = ImageEnhance.Color(img).enhance(color_boost)
|
||||||
packing. Takes an RGB image already composed at logical_render_size()
|
if contrast_boost != 1.0:
|
||||||
for the orientation."""
|
img = ImageEnhance.Contrast(img).enhance(contrast_boost)
|
||||||
quantized = logical_img.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
|
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)
|
transpose = ORIENTATION_TRANSPOSE.get(orientation)
|
||||||
if transpose is not None:
|
if transpose is not None:
|
||||||
quantized = quantized.transpose(transpose)
|
quantized = quantized.transpose(transpose)
|
||||||
@@ -200,8 +280,56 @@ def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
|
|||||||
return bytes(out)
|
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,
|
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
|
"""A readable full-panel message (plus an optional QR code) in the
|
||||||
same packed format as render_frame -- what /frame/image serves for a
|
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
|
frame that isn't claimed or configured yet, so a fresh device shows
|
||||||
@@ -246,4 +374,5 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|||||||
if qr_img:
|
if qr_img:
|
||||||
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
|
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
|
||||||
+70
-2
@@ -19,7 +19,7 @@ from sqlalchemy import select, text
|
|||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
from .db import SessionLocal, engine
|
from .db import SessionLocal, engine
|
||||||
from .models import Base, BatteryLog, Frame
|
from .models import Base, BatteryLog, Frame, ServerSettings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -28,8 +28,65 @@ def _migration_1(conn) -> None:
|
|||||||
Base.metadata.create_all(bind=conn)
|
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 = [
|
MIGRATIONS = [
|
||||||
(1, _migration_1),
|
(1, _migration_1),
|
||||||
|
(2, _migration_2),
|
||||||
|
(3, _migration_3),
|
||||||
|
(4, _migration_4),
|
||||||
|
(5, _migration_5),
|
||||||
|
(6, _migration_6),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +107,7 @@ def run_migrations() -> None:
|
|||||||
else:
|
else:
|
||||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||||
_ensure_frame_one()
|
_ensure_frame_one()
|
||||||
|
_ensure_server_settings()
|
||||||
|
|
||||||
|
|
||||||
def new_device_token() -> str:
|
def new_device_token() -> str:
|
||||||
@@ -91,7 +149,7 @@ def _ensure_frame_one() -> None:
|
|||||||
quiet_hours_start=cfg.quiet_hours_start,
|
quiet_hours_start=cfg.quiet_hours_start,
|
||||||
quiet_hours_end=cfg.quiet_hours_end,
|
quiet_hours_end=cfg.quiet_hours_end,
|
||||||
timezone=cfg.timezone,
|
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,
|
orientation=cfg.orientation,
|
||||||
queue_target_len=cfg.queue_target_len,
|
queue_target_len=cfg.queue_target_len,
|
||||||
current_asset_id=cfg.current_asset_id,
|
current_asset_id=cfg.current_asset_id,
|
||||||
@@ -144,3 +202,13 @@ def _ensure_frame_one() -> None:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Fresh install: created default frame #%d", frame.id)
|
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)
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
immich_url: Mapped[str] = mapped_column(String, default="")
|
immich_url: Mapped[str] = mapped_column(String, default="")
|
||||||
immich_api_key: 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)
|
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -117,9 +121,23 @@ class Frame(Base):
|
|||||||
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
|
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
|
||||||
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
|
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
|
||||||
timezone: Mapped[str] = mapped_column(String, default="UTC")
|
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")
|
orientation: Mapped[str] = mapped_column(String, default="landscape")
|
||||||
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
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 --
|
# -- state --
|
||||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
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_firmware_version: Mapped[str] = mapped_column(String, default="")
|
||||||
device_board_variant: 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 / OTA (per frame; image lives at /data/firmware/<id>.bin) --
|
||||||
firmware_available_version: Mapped[str] = mapped_column(String, default="")
|
firmware_available_version: Mapped[str] = mapped_column(String, default="")
|
||||||
firmware_update_repo_url: 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)
|
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):
|
class BatteryLog(Base):
|
||||||
"""Every battery report ever, per frame -- the permanent record behind
|
"""Every battery report ever, per frame -- the permanent record behind
|
||||||
the battery history chart (was a 20k-entry JSON array in config.json)."""
|
the battery history chart (was a 20k-entry JSON array in config.json)."""
|
||||||
|
|||||||
@@ -28,11 +28,19 @@ from sqlalchemy.orm import Session
|
|||||||
from .. import gitea_releases, photo_queue, quiet_hours
|
from .. import gitea_releases, photo_queue, quiet_hours
|
||||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db
|
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 ..firmware import firmware_path, parse_app_version
|
||||||
from ..models import BatteryLog, Frame
|
from ..models import BatteryLog, Frame
|
||||||
from .common import (
|
from .common import (
|
||||||
OVERDUE_FACTOR,
|
OVERDUE_FACTOR,
|
||||||
battery_estimate_s,
|
battery_estimate_s,
|
||||||
|
fetch_source_and_faces,
|
||||||
immich_client_for,
|
immich_client_for,
|
||||||
immich_creds,
|
immich_creds,
|
||||||
list_assets,
|
list_assets,
|
||||||
@@ -69,7 +77,7 @@ def api_config_save(
|
|||||||
album_id: str | None = Form(None),
|
album_id: str | None = Form(None),
|
||||||
order: str | None = Form(None),
|
order: str | None = Form(None),
|
||||||
refresh_interval_s: int | 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),
|
queue_target_len: int | None = Form(None),
|
||||||
orientation: str | None = Form(None),
|
orientation: str | None = Form(None),
|
||||||
quiet_hours_enabled: bool | None = Form(None),
|
quiet_hours_enabled: bool | None = Form(None),
|
||||||
@@ -78,6 +86,12 @@ def api_config_save(
|
|||||||
timezone: str | None = Form(None),
|
timezone: str | None = Form(None),
|
||||||
firmware_update_repo_url: str | None = Form(None),
|
firmware_update_repo_url: str | None = Form(None),
|
||||||
firmware_auto_update: bool | 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),
|
frame: Frame = Depends(require_frame_control),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -100,8 +114,8 @@ def api_config_save(
|
|||||||
cfg.refresh_interval_s = max(
|
cfg.refresh_interval_s = max(
|
||||||
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
|
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
|
||||||
)
|
)
|
||||||
if smart_crop_faces is not None:
|
if display_mode is not None:
|
||||||
cfg.smart_crop_faces = smart_crop_faces
|
cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
|
||||||
if queue_target_len is not None:
|
if queue_target_len is not None:
|
||||||
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
||||||
if orientation is not None:
|
if orientation is not None:
|
||||||
@@ -118,6 +132,26 @@ def api_config_save(
|
|||||||
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
||||||
if firmware_auto_update is not None:
|
if firmware_auto_update is not None:
|
||||||
cfg.firmware_auto_update = firmware_auto_update
|
cfg.firmware_auto_update = firmware_auto_update
|
||||||
|
if battery_alert_threshold_pct is not None:
|
||||||
|
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
|
cfg.stats_config_saves += 1
|
||||||
return {"status": "saved"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
@@ -298,6 +332,53 @@ def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
|||||||
return Response(content=content, media_type=content_type)
|
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")
|
@router.post("/api/frames/{frame_id}/firmware")
|
||||||
def api_firmware_upload(
|
def api_firmware_upload(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
|||||||
@@ -69,14 +69,18 @@ def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
|
|||||||
return assets
|
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:
|
try:
|
||||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||||
|
|
||||||
faces = None
|
faces = None
|
||||||
if frame.smart_crop_faces:
|
if frame.display_mode == "crop_faces":
|
||||||
try:
|
try:
|
||||||
faces = client.get_asset_faces(asset_id)
|
faces = client.get_asset_faces(asset_id)
|
||||||
except httpx.HTTPError as e:
|
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.
|
# all -- just fall back to a plain center-crop this cycle.
|
||||||
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
||||||
|
|
||||||
source = Image.open(io.BytesIO(jpeg_bytes))
|
return Image.open(io.BytesIO(jpeg_bytes)), faces
|
||||||
return render_frame(source, faces=faces, orientation=frame.orientation)
|
|
||||||
|
|
||||||
|
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:
|
def battery_estimate_s(frame: Frame) -> int | None:
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import photo_queue, quiet_hours
|
from .. import mail, photo_queue, quiet_hours
|
||||||
from ..auth import require_device
|
from ..auth import get_server_settings, require_device
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db
|
||||||
from ..face_labels import compute_face_labels
|
from ..face_labels import compute_face_labels
|
||||||
from ..firmware import firmware_path
|
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:"],
|
["This frame isn't claimed yet", "Scan to link it to your account:"],
|
||||||
qr_url=claim_url,
|
qr_url=claim_url,
|
||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
|
palette_rgb=frame.palette_rgb,
|
||||||
)
|
)
|
||||||
if frame.owner_user_id is None:
|
if frame.owner_user_id is None:
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
["Almost there!", f"Open {base} to finish setting up this frame."],
|
["Almost there!", f"Open {base} to finish setting up this frame."],
|
||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
|
palette_rgb=frame.palette_rgb,
|
||||||
)
|
)
|
||||||
return render_placeholder(
|
return render_placeholder(
|
||||||
["Almost there!", "Pick an album for this frame:", base],
|
["Almost there!", "Pick an album for this frame:", base],
|
||||||
qr_url=base,
|
qr_url=base,
|
||||||
orientation=frame.orientation,
|
orientation=frame.orientation,
|
||||||
|
palette_rgb=frame.palette_rgb,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -194,14 +197,19 @@ def frame_battery(
|
|||||||
if not 0 <= body.percent <= 100:
|
if not 0 <= body.percent <= 100:
|
||||||
raise HTTPException(400, "percent must be 0-100")
|
raise HTTPException(400, "percent must be 0-100")
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
should_alert = False
|
||||||
|
alert_email = ""
|
||||||
|
alert_frame_name = ""
|
||||||
with frame_locked(db, frame.id) as locked:
|
with frame_locked(db, frame.id) as locked:
|
||||||
locked.stats_battery_reports += 1
|
locked.stats_battery_reports += 1
|
||||||
if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT:
|
if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT:
|
||||||
# Percent jumped up meaningfully -- the battery was recharged
|
# Percent jumped up meaningfully -- the battery was recharged
|
||||||
# (or swapped). Start a fresh discharge cycle so runtime and
|
# (or swapped). Start a fresh discharge cycle so runtime and
|
||||||
# discharge-rate estimates never span a charge.
|
# 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.battery_history = []
|
||||||
locked.stats_recharge_cycles += 1
|
locked.stats_recharge_cycles += 1
|
||||||
|
locked.battery_alert_sent = False
|
||||||
locked.battery_history.append([now, body.percent])
|
locked.battery_history.append([now, body.percent])
|
||||||
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
|
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
|
||||||
locked.battery_percent = body.percent
|
locked.battery_percent = body.percent
|
||||||
@@ -216,6 +224,35 @@ def frame_battery(
|
|||||||
BatteryLog.ts
|
BatteryLog.ts
|
||||||
).limit(count + 1 - BATTERY_LOG_MAX)
|
).limit(count + 1 - BATTERY_LOG_MAX)
|
||||||
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
|
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"}
|
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_line1": location[0] if location else None,
|
||||||
"location_line2": location[1] if location and location[1] else None,
|
"location_line2": location[1] if location and location[1] else None,
|
||||||
"taken_at": _format_taken_at(exif),
|
"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:
|
with frame_locked(db, frame.id) as locked:
|
||||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||||
asset_id = locked.current_asset_id
|
asset_id = locked.current_asset_id
|
||||||
smart_crop = locked.smart_crop_faces
|
display_mode = locked.display_mode
|
||||||
orientation = locked.orientation
|
orientation = locked.orientation
|
||||||
|
|
||||||
if not asset_id:
|
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)
|
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
||||||
return {"count": 0}
|
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)}
|
result: dict[str, object] = {"count": len(labels)}
|
||||||
for i, label in enumerate(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 ..auth import can_view_frame, current_user
|
||||||
from ..db import get_db
|
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 ..models import Frame
|
||||||
from ..quiet_hours import ALL_TIMEZONES
|
from ..quiet_hours import ALL_TIMEZONES
|
||||||
from .common import shell_context
|
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)
|
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
||||||
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
return _frame_page(
|
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 import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import mail
|
||||||
from ..auth import (
|
from ..auth import (
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
SESSION_LIFETIME_S,
|
SESSION_LIFETIME_S,
|
||||||
|
consume_password_reset_token,
|
||||||
|
create_password_reset_token,
|
||||||
create_session,
|
create_session,
|
||||||
current_session,
|
current_session,
|
||||||
current_user,
|
current_user,
|
||||||
destroy_session,
|
destroy_session,
|
||||||
|
get_server_settings,
|
||||||
hash_password,
|
hash_password,
|
||||||
users_exist,
|
users_exist,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..models import Frame, PendingClaim, User, UserFrame
|
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -181,6 +185,72 @@ def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(g
|
|||||||
return response
|
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:
|
def _normalize_device_id(device_id: str) -> str:
|
||||||
device_id = device_id.strip().lower()
|
device_id = device_id.strip().lower()
|
||||||
if len(device_id) != 12 or any(c not in "0123456789abcdef" for c in device_id):
|
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,
|
request: Request,
|
||||||
csrf_token: str = Form(""),
|
csrf_token: str = Form(""),
|
||||||
display_name: str = Form(""),
|
display_name: str = Form(""),
|
||||||
|
email: str = Form(""),
|
||||||
immich_url: str = Form(""),
|
immich_url: str = Form(""),
|
||||||
immich_api_key: str = Form(""),
|
immich_api_key: str = Form(""),
|
||||||
current_password: str = Form(""),
|
current_password: str = Form(""),
|
||||||
@@ -362,6 +433,7 @@ def settings_submit(
|
|||||||
|
|
||||||
error = None
|
error = None
|
||||||
user.display_name = display_name.strip() or user.username
|
user.display_name = display_name.strip() or user.username
|
||||||
|
user.email = email.strip().lower()
|
||||||
user.immich_url = immich_url.strip()
|
user.immich_url = immich_url.strip()
|
||||||
# Blank API key field = keep the existing one (it's never echoed back
|
# 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
|
# 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,
|
"users": users,
|
||||||
"frames": frames,
|
"frames": frames,
|
||||||
"links_by_frame": links_by_frame,
|
"links_by_frame": links_by_frame,
|
||||||
|
"smtp": get_server_settings(db),
|
||||||
"notice": notice,
|
"notice": notice,
|
||||||
"error": error,
|
"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}.")
|
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)
|
@router.post("/admin/frames/{frame_id}/delete", response_class=HTMLResponse)
|
||||||
def admin_delete_frame(
|
def admin_delete_frame(
|
||||||
frame_id: int,
|
frame_id: int,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ async function saveConfig() {
|
|||||||
order: document.getElementById('order').value,
|
order: document.getElementById('order').value,
|
||||||
orientation: document.getElementById('orientation').value,
|
orientation: document.getElementById('orientation').value,
|
||||||
refresh_interval_s: String(minutes * 60),
|
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_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
||||||
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
||||||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07: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);
|
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 ----
|
// ---- Firmware card ----
|
||||||
|
|
||||||
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
||||||
|
|||||||
@@ -168,6 +168,64 @@ summary.card-title { cursor: pointer; margin-bottom: 0; }
|
|||||||
details.card[open] summary.card-title { margin-bottom: 14px; }
|
details.card[open] summary.card-title { margin-bottom: 14px; }
|
||||||
details.card .sub { margin-top: 8px; }
|
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 {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -41,6 +41,42 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
|
||||||
<form method="post" action="/admin/users">
|
<form method="post" action="/admin/users">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|||||||
@@ -34,7 +34,11 @@
|
|||||||
href="/frames/{{ f.id }}">
|
href="/frames/{{ f.id }}">
|
||||||
<span class="frame-dot" aria-hidden="true"></span>
|
<span class="frame-dot" aria-hidden="true"></span>
|
||||||
{{ f.name or ("Frame " ~ f.id) }}
|
{{ 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>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not sidebar_frames %}
|
{% if not sidebar_frames %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -37,10 +37,21 @@
|
|||||||
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
||||||
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
|
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
|
||||||
</label>
|
</label>
|
||||||
<div class="checkbox-row">
|
<label>Display mode
|
||||||
<input type="checkbox" id="smart_crop_faces" {% if frame.smart_crop_faces %}checked{% endif %}>
|
<select id="display_mode">
|
||||||
<label for="smart_crop_faces">Center faces in crop</label>
|
{% for mode, label in display_mode_labels.items() %}
|
||||||
</div>
|
<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">
|
<div class="checkbox-row">
|
||||||
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
|
<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>
|
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
|
||||||
@@ -99,6 +110,88 @@
|
|||||||
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
<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>
|
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,6 +199,9 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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/frame_config.js"></script>
|
<script src="/static/frame_config.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -20,5 +20,6 @@
|
|||||||
</label>
|
</label>
|
||||||
<button type="submit">Log in</button>
|
<button type="submit">Log in</button>
|
||||||
</form>
|
</form>
|
||||||
|
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Forgot your password?</a></p>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% 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
|
<label>Display name
|
||||||
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
|
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
|
||||||
</label>
|
</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
|
<label>Immich URL
|
||||||
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
|
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
|
||||||
value="{{ user.immich_url }}">
|
value="{{ user.immich_url }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user