Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac4b57e611 | ||
|
|
462b558bef | ||
|
|
9f9ad34a40 | ||
|
|
e48ac50ea1 | ||
|
|
83c59af1dd | ||
|
|
49bc9f9ec9 | ||
|
|
e802882fc1 | ||
|
|
5b11f2accb | ||
|
|
c1c803b497 | ||
|
|
8e10ca540e | ||
|
|
a45444ab4b | ||
|
|
8ac3fc0de3 | ||
|
|
683e3881b1 | ||
|
|
1e8d6803ac | ||
|
|
9fbbb8ed2b | ||
|
|
6a0072e383 |
+18
-11
@@ -121,8 +121,14 @@ two-step setup screen:
|
||||
The config page asks for your home WiFi SSID/password, the "Tools
|
||||
Server" address (`host:port` of the [server](../server/) -- **not** your
|
||||
Immich server; see below for the `https://` form), and an optional
|
||||
"Access Token" (see below). Saving reboots the device, which then
|
||||
connects to your home network and starts its normal fetch/sleep cycle.
|
||||
"Access Token" (see below -- usually blank). Saving hands your browser
|
||||
off to the server's claim page (after ~7 seconds, giving your phone
|
||||
time to rejoin its normal WiFi while the device reboots) so the frame
|
||||
gets linked to your account; the device meanwhile connects to your home
|
||||
network and starts its normal fetch/sleep cycle. The frame identifies
|
||||
itself to the server by `?id=` (derived from its WiFi MAC) on every
|
||||
request, and the server issues it a private per-frame token on first
|
||||
contact -- no manual token handling involved.
|
||||
|
||||
## HTTP vs HTTPS
|
||||
|
||||
@@ -179,15 +185,16 @@ perfectly valid cert for a different name.
|
||||
|
||||
## Access token
|
||||
|
||||
If the server has `MANAGEMENT_TOKEN` set (see
|
||||
[`server/README.md`](../server/README.md)), it requires that same value
|
||||
on every request -- the web UI *and* every device-facing request the
|
||||
frame itself makes. Paste it into the captive portal's "Access Token"
|
||||
field and the device sends it (`?token=...`) on every request
|
||||
automatically, and bakes it into the manage-menu/share QR codes so
|
||||
scanning them just works too. Leave it blank if the server has no
|
||||
`MANAGEMENT_TOKEN` configured -- the default, unauthenticated-on-a-
|
||||
trusted-LAN behavior from before.
|
||||
Usually blank. Current servers issue each frame its own private token
|
||||
automatically on first contact (delivered via `GET /frame/config`,
|
||||
persisted in NVS, preferred by `build_url()` from then on -- and baked
|
||||
into the manage-menu/share QR codes so scanning them just works). The
|
||||
captive portal's "Access Token" field only matters when pointing this
|
||||
firmware at an *older* (pre-multi-frame) server whose `MANAGEMENT_TOKEN`
|
||||
is set: paste that shared value and the device sends it (`?token=...`)
|
||||
until a newer server replaces it with a per-frame one. Re-provisioning
|
||||
clears any stored per-frame token -- a fresh identity handshake with
|
||||
whatever server you point it at next.
|
||||
|
||||
## Skipping to the next photo
|
||||
|
||||
|
||||
@@ -99,11 +99,14 @@ static void save_wifi_cache(esp_netif_t *netif)
|
||||
* normally a bare "host:port", defaulting to plain http; it may instead
|
||||
* carry an explicit "http://" or "https://" prefix to pick the scheme,
|
||||
* e.g. "https://frame.example.com" if a reverse proxy is terminating
|
||||
* TLS in front of the tools server. The token, once the server has
|
||||
* MANAGEMENT_TOKEN set, is required on every request the server
|
||||
* receives (device-facing endpoints included, not just the web UI) --
|
||||
* this is the one chokepoint all of them go through, so every caller
|
||||
* gets it for free instead of needing to remember to add it. */
|
||||
* TLS in front of the tools server. Every URL carries ?id= (the device's
|
||||
* MAC-derived identity -- how a multi-frame server tells frames apart
|
||||
* and how an unknown frame self-registers) plus &token=: the server-
|
||||
* issued per-frame device token once one has been delivered via
|
||||
* /frame/config, else the provisioned access token (the legacy shared
|
||||
* secret, also what a pre-multi-frame server still expects). This is
|
||||
* the one chokepoint all requests go through, so every caller gets both
|
||||
* for free instead of needing to remember to add them. */
|
||||
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
|
||||
{
|
||||
const char *toolsserver = cfg->toolsserver;
|
||||
@@ -113,8 +116,16 @@ static void build_url(char *out, size_t out_size, const frame_config_t *cfg, con
|
||||
} else {
|
||||
len = (size_t)snprintf(out, out_size, "http://%s/%s", toolsserver, path);
|
||||
}
|
||||
if (cfg->access_token[0] != '\0' && len < out_size) {
|
||||
snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
|
||||
|
||||
char device_id[FRAME_DEVICE_ID_LEN + 1];
|
||||
frame_device_id_get(device_id, sizeof(device_id));
|
||||
if (len < out_size) {
|
||||
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
|
||||
}
|
||||
|
||||
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
|
||||
if (token[0] != '\0' && len < out_size) {
|
||||
snprintf(out + len, out_size - len, "&token=%s", token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +277,11 @@ typedef struct {
|
||||
bool reachable;
|
||||
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
|
||||
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
|
||||
/* Per-frame token the server pushes until this device has
|
||||
* authenticated with it once; empty when absent. Persisted via
|
||||
* frame_config_set_device_token() and used by build_url() from the
|
||||
* next request on. */
|
||||
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
|
||||
} frame_server_config_t;
|
||||
|
||||
/* Finds the first integer value associated with "key" in a small JSON
|
||||
@@ -356,6 +372,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
|
||||
};
|
||||
result.firmware_version[0] = '\0';
|
||||
result.device_token[0] = '\0';
|
||||
|
||||
char url[256];
|
||||
build_url(url, sizeof(url), cfg, "frame/config");
|
||||
@@ -380,7 +397,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
esp_http_client_fetch_headers(client);
|
||||
result.reachable = true;
|
||||
|
||||
char body[256];
|
||||
/* 512 (was 256): the response also carries "device_token" during the
|
||||
* one-time identity handshake -- worst case is still well under half
|
||||
* of this, the rest is headroom for future fields. */
|
||||
char body[512];
|
||||
int total = 0;
|
||||
int n;
|
||||
while (total < (int)sizeof(body) - 1 &&
|
||||
@@ -400,6 +420,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
(int)result.refresh_interval_s);
|
||||
}
|
||||
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
|
||||
json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -952,6 +973,20 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
|
||||
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;
|
||||
|
||||
/* One-time identity handshake: the server pushes this frame's
|
||||
* own token until we've authenticated with it once. Persist it
|
||||
* and use it immediately (the OTA below is part of this same
|
||||
* cycle) via a local working copy -- cfg itself is const. */
|
||||
frame_config_t updated_cfg;
|
||||
if (server_cfg.device_token[0] != '\0' &&
|
||||
strcmp(server_cfg.device_token, cfg->device_token) != 0) {
|
||||
frame_config_set_device_token(server_cfg.device_token);
|
||||
updated_cfg = *cfg;
|
||||
snprintf(updated_cfg.device_token, sizeof(updated_cfg.device_token), "%s",
|
||||
server_cfg.device_token);
|
||||
cfg = &updated_cfg;
|
||||
}
|
||||
|
||||
/* Last, deliberately -- the photo's already on screen and the
|
||||
* battery report already sent, so a reboot here (whether OTA
|
||||
* succeeds or the device is mid-update) never loses either. */
|
||||
|
||||
@@ -17,7 +17,7 @@ static const char *TAG = "ota_update";
|
||||
#define OTA_HTTP_TIMEOUT_MS 30000
|
||||
|
||||
/* Built the same way as every other tools-server URL -- scheme/cert/
|
||||
* token handling all come from build_url()'s conventions. Duplicated
|
||||
* id/token handling all come from build_url()'s conventions. Duplicated
|
||||
* tiny helper rather than exporting frame_client.c's static build_url();
|
||||
* kept byte-identical in behavior (see frame_client.c). */
|
||||
static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
|
||||
@@ -29,8 +29,16 @@ static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
|
||||
} else {
|
||||
len = (size_t)snprintf(out, out_size, "http://%s/frame/firmware", toolsserver);
|
||||
}
|
||||
if (cfg->access_token[0] != '\0' && len < out_size) {
|
||||
snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
|
||||
|
||||
char device_id[FRAME_DEVICE_ID_LEN + 1];
|
||||
frame_device_id_get(device_id, sizeof(device_id));
|
||||
if (len < out_size) {
|
||||
len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
|
||||
}
|
||||
|
||||
const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
|
||||
if (token[0] != '\0' && len < out_size) {
|
||||
snprintf(out + len, out_size - len, "&token=%s", token);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,10 +98,14 @@
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="access_token">Access Token (optional)</label>
|
||||
<input type="text" id="access_token" name="access_token" placeholder="only if the server's MANAGEMENT_TOKEN is set" maxlength="64">
|
||||
<label for="access_token">Access Token (optional — only for older servers)</label>
|
||||
<input type="text" id="access_token" name="access_token" placeholder="usually blank; current servers issue one automatically" maxlength="64">
|
||||
</div>
|
||||
|
||||
<p style="font-size: 13px; color: #555;">After saving, this page will
|
||||
take you to the server to claim your frame — reconnect to
|
||||
your normal WiFi if it doesn't happen automatically.</p>
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -83,10 +83,39 @@ esp_err_t frame_config_load(frame_config_t *out)
|
||||
return token_err;
|
||||
}
|
||||
|
||||
/* Optional: absent until the server has pushed a per-frame token
|
||||
* (see frame_config_set_device_token). */
|
||||
len = sizeof(out->device_token);
|
||||
token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
|
||||
if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
|
||||
nvs_close(handle);
|
||||
return token_err;
|
||||
}
|
||||
|
||||
nvs_close(handle);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void frame_device_id_get(char *out, size_t out_size)
|
||||
{
|
||||
uint8_t mac[6] = {0};
|
||||
ESP_ERROR_CHECK(esp_read_mac(mac, ESP_MAC_WIFI_STA));
|
||||
snprintf(out, out_size, "%02x%02x%02x%02x%02x%02x",
|
||||
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
}
|
||||
|
||||
void frame_config_set_device_token(const char *token)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
|
||||
return;
|
||||
}
|
||||
nvs_set_str(handle, "device_token", token);
|
||||
nvs_commit(handle);
|
||||
nvs_close(handle);
|
||||
ESP_LOGI(TAG, "Stored server-issued device token");
|
||||
}
|
||||
|
||||
esp_err_t frame_config_save(const frame_config_t *cfg)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
@@ -106,6 +135,10 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
|
||||
err = nvs_set_str(handle, "access_token", cfg->access_token);
|
||||
}
|
||||
if (err == ESP_OK) {
|
||||
/* Re-provisioning restarts the identity handshake: the server
|
||||
* (possibly a different one now) re-issues a device token when
|
||||
* the frame next introduces itself. */
|
||||
nvs_erase_key(handle, "device_token");
|
||||
/* Fresh (re)provisioning -- the next successful connection should
|
||||
* show the status screen again. */
|
||||
err = nvs_set_u8(handle, "connected_once", 0);
|
||||
@@ -159,6 +192,7 @@ void frame_config_clear(void)
|
||||
nvs_erase_key(handle, "sta_pass");
|
||||
nvs_erase_key(handle, "toolsserver");
|
||||
nvs_erase_key(handle, "access_token");
|
||||
nvs_erase_key(handle, "device_token");
|
||||
nvs_erase_key(handle, "connected_once");
|
||||
nvs_commit(handle);
|
||||
nvs_close(handle);
|
||||
@@ -412,15 +446,63 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
|
||||
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver,
|
||||
strlen(cfg.access_token) ? "set" : "none");
|
||||
|
||||
static const char resp[] =
|
||||
"<html><body><h3>Saved. Restarting and connecting to your WiFi...</h3></body></html>";
|
||||
/* The success page hands the browser off to the server's claim page,
|
||||
* carrying this device's id -- how a frame gets linked to a user
|
||||
* account. This page is entirely self-contained (no external
|
||||
* resources) so it renders fully from what we send now, before the
|
||||
* softAP goes away -- a phone mid-load of a remote asset would just
|
||||
* time out once the AP drops. The visible countdown ticks down for
|
||||
* PROVISIONING_COUNTDOWN_S seconds and then redirects; the AP is kept
|
||||
* alive for one second longer than that (see the vTaskDelay below) so
|
||||
* the countdown always finishes, and the phone has that whole window
|
||||
* to rejoin its normal WiFi and let the redirect land on the real
|
||||
* server. "Redirect now" covers a phone that's already reconnected.
|
||||
* Scheme handling matches frame_client.c's build_url(): a bare host
|
||||
* gets http://. */
|
||||
char device_id[FRAME_DEVICE_ID_LEN + 1];
|
||||
frame_device_id_get(device_id, sizeof(device_id));
|
||||
|
||||
char claim_url[FRAME_CFG_SERVER_MAX_LEN + 64];
|
||||
const char *scheme = "";
|
||||
if (strncmp(cfg.toolsserver, "http://", 7) != 0 && strncmp(cfg.toolsserver, "https://", 8) != 0) {
|
||||
scheme = "http://";
|
||||
}
|
||||
snprintf(claim_url, sizeof(claim_url), "%s%s/claim?device_id=%s", scheme, cfg.toolsserver, device_id);
|
||||
|
||||
#define PROVISIONING_COUNTDOWN_S 10
|
||||
|
||||
char resp[1536];
|
||||
snprintf(resp, sizeof(resp),
|
||||
"<!doctype html><html><head>"
|
||||
"<meta http-equiv=\"refresh\" content=\"%d;url=%s\">"
|
||||
"<style>body{font-family:sans-serif;text-align:center;padding:2em}"
|
||||
"#now{display:inline-block;margin-top:1em;padding:.6em 1.2em;"
|
||||
"background:#2563eb;color:#fff;text-decoration:none;border-radius:8px}</style></head>"
|
||||
"<body><h3>Saved — the frame is restarting</h3>"
|
||||
"<p>Reconnect to your normal WiFi if it doesn't happen automatically.</p>"
|
||||
"<p>Redirecting you in <span id=\"n\">%d</span> seconds…</p>"
|
||||
"<p><a id=\"now\" href=\"%s\">Redirect now</a></p>"
|
||||
"<script>"
|
||||
"var n=%d,e=document.getElementById('n');"
|
||||
"var t=setInterval(function(){"
|
||||
"n--;if(e)e.textContent=n;"
|
||||
"if(n<=0){clearInterval(t);location.href='%s';}"
|
||||
"},1000);"
|
||||
"</script>"
|
||||
"</body></html>",
|
||||
PROVISIONING_COUNTDOWN_S, claim_url, PROVISIONING_COUNTDOWN_S, claim_url,
|
||||
PROVISIONING_COUNTDOWN_S, claim_url);
|
||||
httpd_resp_set_type(req, "text/html");
|
||||
httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN);
|
||||
|
||||
/* Let the response flush to the client before rebooting into STA mode. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
/* Keep the softAP up for the full visible countdown (plus a 1s margin
|
||||
* for the response to flush and the JS timer to fire) before tearing
|
||||
* it down -- see the comment above for why. */
|
||||
vTaskDelay(pdMS_TO_TICKS((PROVISIONING_COUNTDOWN_S + 1) * 1000));
|
||||
esp_restart();
|
||||
|
||||
#undef PROVISIONING_COUNTDOWN_S
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,37 @@
|
||||
#define FRAME_CFG_TOKEN_MAX_LEN 64
|
||||
#define FRAME_AP_PASSWORD_LEN 10
|
||||
|
||||
#define FRAME_DEVICE_ID_LEN 12 /* 6-byte STA MAC as lowercase hex */
|
||||
|
||||
typedef struct {
|
||||
char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1];
|
||||
char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1];
|
||||
char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1];
|
||||
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; matches the server's MANAGEMENT_TOKEN */
|
||||
char access_token[FRAME_CFG_TOKEN_MAX_LEN + 1]; /* optional; legacy shared MANAGEMENT_TOKEN */
|
||||
/* Per-frame token issued by the server via GET /frame/config after
|
||||
* this device first introduces itself by id -- preferred over
|
||||
* access_token once present (see frame_client.c's build_url). Not
|
||||
* set at the captive portal; empty until the server pushes one. */
|
||||
char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
|
||||
} frame_config_t;
|
||||
|
||||
/**
|
||||
* This device's stable identity as reported to the server (?id= on every
|
||||
* request): the full 6-byte STA MAC as 12 lowercase hex chars. Derived
|
||||
* from the same MAC the provisioning AP SSID suffix comes from; never
|
||||
* stored. out must hold at least FRAME_DEVICE_ID_LEN + 1 bytes.
|
||||
*/
|
||||
void frame_device_id_get(char *out, size_t out_size);
|
||||
|
||||
/**
|
||||
* Persists (only) the server-issued per-frame device token -- called
|
||||
* from the wake cycle when GET /frame/config delivers one. Deliberately
|
||||
* touches nothing else: unlike frame_config_save() it must not reset
|
||||
* the connected-once flag or invalidate the WiFi fast-connect cache,
|
||||
* since nothing about the network changed.
|
||||
*/
|
||||
void frame_config_set_device_token(const char *token);
|
||||
|
||||
/**
|
||||
* Loads the saved home-network config from NVS.
|
||||
* Returns ESP_ERR_NVS_NOT_FOUND if the device has never been provisioned.
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.1.2a
|
||||
1.2.1
|
||||
|
||||
+182
-216
@@ -8,225 +8,189 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Get an Immich API key**: in Immich, go to Account Settings -> API Keys
|
||||
-> New API Key. Needs read access to albums/assets/faces, plus
|
||||
`sharedLink.create` (for the manage overlay's "scan to download" QR,
|
||||
which creates a temporary public share link) -- a plain read-only key
|
||||
will 403 on that one specific feature while everything else works.
|
||||
2. **Copy the compose file and fill in your Immich details**:
|
||||
1. **Copy the compose file and run the server**:
|
||||
```
|
||||
cp docker-compose.yml.example docker-compose.yml
|
||||
```
|
||||
Edit `docker-compose.yml` and set `IMMICH_URL`/`IMMICH_API_KEY` under
|
||||
`environment:`. `docker-compose.yml` is gitignored (it'll hold your real
|
||||
API key) -- `docker-compose.yml.example` is the one that's committed.
|
||||
3. **Run the server**:
|
||||
```
|
||||
docker compose up -d
|
||||
```
|
||||
4. Open `http://<this-machine>:8420/` in a browser, click **Load Albums**,
|
||||
pick one, and **Save**. (The Immich URL/API key fields will already be
|
||||
populated from the environment; changing them in the UI has no effect
|
||||
as long as the env vars are set -- they win on every load.)
|
||||
5. On the ESP32's captive portal setup form, set the **Tools Server** field
|
||||
to `<this-machine>:8420`. This server always speaks plain HTTP itself --
|
||||
for HTTPS, put a TLS-terminating reverse proxy (e.g. nginx) in front of
|
||||
it and enter the proxy's `https://` address instead (see
|
||||
`firmware/README.md`'s HTTPS section for what the ESP32 side needs).
|
||||
6. **Optional: set `MANAGEMENT_TOKEN`** in `docker-compose.yml` to gate
|
||||
the *entire server* -- the web UI (`/`, `/api/*`) and every
|
||||
device-facing `/frame/*` endpoint -- behind a shared secret (leave
|
||||
unset to keep it all open, the previous default -- fine on a trusted
|
||||
LAN). If set, paste the same value into the ESP32's captive portal
|
||||
setup form's **Access Token** field: the device then sends it on
|
||||
every request it makes, and the manage-menu/share QR codes embed it
|
||||
automatically (`?token=...`) so scanning them just works. Visiting
|
||||
the web UI without a valid token in the URL shows a plain token-entry
|
||||
prompt instead of the config UI; `/health` stays open regardless
|
||||
(pure liveness, nothing sensitive in it).
|
||||
7. **Optional: auto-update firmware from Gitea releases.** If you're
|
||||
2. **First-run setup**: open `http://<this-machine>:8420/` -- you'll be
|
||||
walked through creating the admin account. Every user has their own
|
||||
login; the admin can enroll more from the Admin page (family members
|
||||
can also self-enroll through the frame-claim flow, below).
|
||||
3. **Connect your Immich library** (per user, in Settings): your Immich
|
||||
URL and an API key. The key needs read access to
|
||||
albums/assets/faces, plus `sharedLink.create` (for the on-frame
|
||||
"scan to download" QR, which creates a temporary public share link)
|
||||
-- a plain read-only key will 403 on that one feature while
|
||||
everything else works. Frames you own pull from *your* library.
|
||||
(`IMMICH_URL`/`IMMICH_API_KEY` env vars in `docker-compose.yml` still
|
||||
work as an operator-level fallback and seed the first admin's
|
||||
settings when migrating an older deployment.)
|
||||
4. **Provision a frame**: power it on, join its `ESPRESSO_XXXXXX` WiFi
|
||||
(instructions show on the panel), fill in your WiFi details and this
|
||||
server's address (**Tools Server**, e.g. `<this-machine>:8420`).
|
||||
After saving, your browser is redirected to this server's claim page
|
||||
and the frame links to your account -- creating an account on the
|
||||
spot if you don't have one (a valid frame is the invitation). The
|
||||
server speaks plain HTTP itself -- for HTTPS, put a TLS-terminating
|
||||
reverse proxy in front and enter the proxy's `https://` address
|
||||
instead (see `firmware/README.md`'s HTTPS section).
|
||||
5. **Each frame gets its own device token automatically** -- the server
|
||||
issues it on the frame's first check-in, so there's nothing to
|
||||
configure. The captive portal's **Access Token** field only matters
|
||||
when pointing new firmware at an old (pre-multi-frame) server.
|
||||
`MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the
|
||||
*migration* credential: a frame flashed with pre-multi-frame
|
||||
firmware authenticates with it until it's updated and bound (the
|
||||
Admin page shows the migration state per frame and a "Close legacy
|
||||
window" button for when it's done).
|
||||
6. **Optional: auto-update firmware from Gitea releases.** If you're
|
||||
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
|
||||
builds both supported boards and publishes them as release assets
|
||||
(`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt`
|
||||
changes on `main`. In the web UI's "Firmware update" card, set the
|
||||
**Gitea repo URL** to that repo (e.g. `https://git.example.com/owner/repo`);
|
||||
if the repo is private, also set `GITEA_FIRMWARE_TOKEN` (a read-only
|
||||
PAT) in `docker-compose.yml`. Which board's build to fetch is learned
|
||||
from the frame itself (its `X-Frame-Board` header, `CONFIG_FRAME_BOARD_NAME`
|
||||
on the firmware side) -- nothing to pick by hand, though the frame
|
||||
does need to have checked in at least once first. The server then
|
||||
changes on `main`. In a frame's **Configuration** tab, set the
|
||||
**Gitea repo URL**; if the repo is private, also set
|
||||
`GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`.
|
||||
Which board's build to fetch is learned from the frame itself (its
|
||||
`X-Frame-Board` header) -- nothing to pick by hand. The server then
|
||||
periodically checks for a newer release and either shows an "Update
|
||||
frame" button or, with **Automatically apply updates** checked,
|
||||
stages it itself -- either way the frame only actually updates on its
|
||||
own next wake (see `POST /api/firmware` above).
|
||||
stages it itself -- either way the frame only actually updates on
|
||||
its own next wake.
|
||||
|
||||
## Users, frames, and control
|
||||
|
||||
- **Users** log in with a session cookie; passwords are scrypt-hashed;
|
||||
mutating requests are CSRF-protected. Sign-up paths: first-run setup
|
||||
(admin #1), admin enrollment (Admin page), or the claim flow (a valid
|
||||
unclaimed frame's `device_id` gates self-service signup).
|
||||
- **Frames** identify themselves by `?id=` (MAC-derived) on every
|
||||
request and authenticate with a per-frame device token the server
|
||||
issues at first check-in. Unknown frames self-register as unclaimed;
|
||||
claiming (via `/claim?device_id=...`) sets the owner -- whose Immich
|
||||
library the frame renders from -- and links the account. Admins can
|
||||
link additional users to any frame; every linked user sees it in
|
||||
their sidebar.
|
||||
- **Control** is a soft lock per frame: everyone linked can *view*;
|
||||
changing settings/queue requires holding control, and "Take control"
|
||||
always succeeds (the 409 error names the current holder). The
|
||||
physical buttons on the frame ignore all of this.
|
||||
- **The on-frame manage QR** opens a limited no-login page (`/m/<token>`):
|
||||
view current + upcoming, "show next", advance, back -- nothing else.
|
||||
The share QR stays public (it creates a 30-minute Immich share link
|
||||
for exactly the photo shown).
|
||||
- **Email (optional).** An admin sets an SMTP server once (`/admin` --
|
||||
server, port, username/password, from address, STARTTLS on/off; a
|
||||
"send test email to myself" button, delivered to the admin's own
|
||||
email); each user sets their own email in Settings. Once both are in
|
||||
place: **"Forgot password?"** on the login page emails a one-hour
|
||||
reset link (a generic "check your email" response either way, so the
|
||||
endpoint can't be used to enumerate accounts), and a frame's
|
||||
Configuration tab can set a **battery-alert threshold** -- an email to
|
||||
the frame's owner the first time a report drops to or below it, not
|
||||
again until a recharge is detected and it crosses again. No SMTP
|
||||
configured, or no email on the relevant account, and both features
|
||||
silently no-op rather than erroring.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /` -- config UI (album, order, refresh interval, face-aware crop
|
||||
toggle, upcoming-photos count, now-displaying + drag-to-reorder
|
||||
upcoming grid -- not Immich URL/API key, see Setup above)
|
||||
- `GET /api/albums` -- lists Immich albums (used by the config UI)
|
||||
- `POST /api/config` -- saves
|
||||
album/order/orientation/refresh_interval_s/smart_crop_faces/queue_target_len/quiet_hours_*/timezone.
|
||||
`orientation` (`landscape`, `portrait`, `landscape_flipped`,
|
||||
`portrait_flipped`) matches how the frame is physically hung: photos
|
||||
are composed/cropped for that shape (portrait crops at 480x800), then
|
||||
rotated into the panel's native 800x480 byte layout server-side --
|
||||
the device never knows. Note the device-side manage-menu overlay
|
||||
(QRs, text, battery indicator, face labels) still renders in native
|
||||
panel orientation, so on a portrait-hung frame it appears rotated
|
||||
90° to the viewer -- QR codes scan fine at any rotation, but the text
|
||||
reads sideways. A known limitation, not planned to change soon.
|
||||
`quiet_hours_enabled`/`quiet_hours_start`/`quiet_hours_end`
|
||||
(`"HH:MM"`, may wrap past midnight, e.g. `22:00`-`07:00`) don't touch
|
||||
the device at all -- purely a server decision about what
|
||||
`refresh_interval_s` to hand back from `GET /frame/config` below,
|
||||
computed in `_effective_refresh_interval_s`. Interpreted in the
|
||||
`timezone` set from the web UI's "Timezone" dropdown (an IANA zone
|
||||
name, e.g. `America/New_York`; defaults to `UTC`) -- no
|
||||
docker-compose.yml edit or container restart needed to change it. The
|
||||
device can still land one wake right
|
||||
at the start of the window (nothing server-side can prevent that
|
||||
without touching the firmware, since the device doesn't know wall-clock
|
||||
time), but from that wake on it's told to sleep exactly until the
|
||||
window ends. The "overdue" indicator in `/api/queue`'s `device` object
|
||||
also accounts for this -- it won't falsely flag a device that's
|
||||
legitimately sleeping through a long quiet-hours window
|
||||
- `GET /frame/image` -- returns the current photo pre-processed into the
|
||||
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
|
||||
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
`/admin`, `/frames/{id}` (Photos), `/frames/{id}/config`,
|
||||
`/frames/{id}/stats`, `/m/{manage_token}`.
|
||||
|
||||
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
|
||||
|
||||
- `GET /frame/image` -- the frame's current image, pre-processed into
|
||||
the panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
|
||||
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free**
|
||||
by default: it only actually advances to the next photo once
|
||||
`refresh_interval_s` has elapsed since the current one was set, so
|
||||
calling it repeatedly (e.g. the device rebooting unexpectedly) just
|
||||
redisplays the same photo instead of skipping ahead.
|
||||
- `POST /frame/advance` -- forces an immediate advance to the next photo,
|
||||
ignoring `refresh_interval_s`, and resets the interval clock from now.
|
||||
Same response shape as `/frame/image`. Used by the device's next-photo
|
||||
button (see `firmware/README.md`). Every photo actually displayed this
|
||||
way (or via the normal timer-based advance) is pushed onto a bounded
|
||||
history (`app/photo_queue.py`, last 20) that `/frame/back` below can
|
||||
return to.
|
||||
- `POST /frame/back` -- returns to the previously-current photo (the
|
||||
exact mirror of `/frame/advance`), and resets the interval clock from
|
||||
now. A no-op (still 200, same photo) if there's no history yet.
|
||||
Pressing advance afterwards returns to where you were before going
|
||||
back -- it displaces the current photo onto the front of the upcoming
|
||||
queue rather than discarding it. Same response shape as
|
||||
`/frame/image`. Used by the device's back-photo button.
|
||||
- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version": "1.2.3" | null}`,
|
||||
polled by the frame each wake alongside its reachability check.
|
||||
`firmware_version` is whatever's currently uploaded via
|
||||
`POST /api/firmware` below (`null` if nothing's been uploaded) -- the
|
||||
device compares it against its own running version
|
||||
(`esp_app_get_description()->version`, sent as an `X-Frame-Version`
|
||||
request header, stored as `device_firmware_version`) to decide whether
|
||||
to OTA. The device also sends an `X-Frame-Board` header
|
||||
(`CONFIG_FRAME_BOARD_NAME`, e.g. `"xiao"`), stored as
|
||||
`device_board_variant` -- how the Gitea auto-update feature below
|
||||
learns which board to fetch a release for, instead of a user picking
|
||||
it
|
||||
- `GET /frame/photo-info` -- `{"asset_id": ..., "location_line1": ... |
|
||||
null, "location_line2": ... | null, "taken_at": ... | null}` for the
|
||||
current photo (same idempotent current-photo semantics as
|
||||
`/frame/image`). `location_line1`/`location_line2` are `city` /
|
||||
`state-or-country` if Immich reverse-geocoded the photo's GPS EXIF
|
||||
(both `null` if not) -- for US/Canada, the region line is the
|
||||
abbreviated state/province (`"CA"`, `"ON"`); elsewhere it's the full
|
||||
country name. `taken_at` is `MM/DD/YY` from the photo's EXIF capture
|
||||
date, else `null`. Used by the device's manage button to build its
|
||||
overlay text
|
||||
- `GET /frame/share/{asset_id}` -- creates a 30-minute public, view-only
|
||||
Immich share link for `asset_id` and redirects (302) to it. Only works
|
||||
for the photo currently showing or in the upcoming queue on this frame
|
||||
-- not any arbitrary Immich asset. The link is created on first hit
|
||||
(i.e. when someone actually scans the manage overlay's share QR), not
|
||||
when the button's pressed, so the 30-minute window starts when it's
|
||||
actually used
|
||||
- `GET /frame/face-labels` -- `{"count": N, "name_0": ..., "x_0": ...,
|
||||
"y_0": ..., ...}` (up to 4 slots) -- named people from Immich's face
|
||||
recognition, positioned in final 800x480 frame pixel space. Only faces
|
||||
Immich already has an identified name for are included (no face
|
||||
detection/recognition happens in this project, see
|
||||
`app/face_labels.py`); `count: 0` if none are named. Used by the
|
||||
device manage button's escalated second menu level
|
||||
- `POST /frame/battery` -- `{"percent": 0-100}`; the device's last
|
||||
battery reading, stored with a timestamp plus two histories: a
|
||||
per-discharge-cycle one (reset whenever a report jumps up enough to
|
||||
look like a recharge) feeding the "on battery for"/estimate numbers,
|
||||
and a permanent, never-reset log (capped at `BATTERY_LOG_MAX`, ~2
|
||||
years at hourly reports) feeding the web UI's battery graph. Only sent
|
||||
when the device is actually running on battery (see
|
||||
`firmware/README.md`'s Battery section) -- a frame on mains power
|
||||
never reports
|
||||
- `GET /api/battery-log` -- `{"log": [[timestamp, percent], ...]}`, the
|
||||
full permanent battery history above; used by the web UI's "Battery
|
||||
history" chart
|
||||
- `GET /api/stats` -- lifetime, never-reset counters: `first_seen`,
|
||||
`device_wakes`, `photos_displayed`, `photos_removed`,
|
||||
`battery_reports`, `recharge_cycles`, `ota_updates_applied`,
|
||||
`config_saves` (see `FrameStats` in `app/config.py`). Purely
|
||||
informational -- nothing else reads these back -- shown in a
|
||||
collapsed "Stats" section in the web UI
|
||||
- `POST /api/firmware` -- multipart upload (`file`) of a built
|
||||
`espresso_frame.bin`. Parses the embedded `esp_app_desc_t` (rejects
|
||||
anything that isn't a valid image for this project) and stores it as
|
||||
the available firmware; devices pick it up via `GET /frame/config`
|
||||
above on their next wake
|
||||
- `GET /frame/firmware` -- streams back whatever was last uploaded via
|
||||
`POST /api/firmware`, for the device's OTA fetch. 404 if nothing's
|
||||
been uploaded yet
|
||||
- `GET /api/firmware/check` -- throttled (`gitea_releases.UPDATE_CHECK_INTERVAL_S`,
|
||||
15 min) check of the configured Gitea repo's latest release for the
|
||||
frame's board variant (learned from the device, see `device_board_variant`
|
||||
below -- not user-configured). `{"enabled": false}` if no repo URL is
|
||||
configured; otherwise `{"enabled": true, "board": "xiao" | null,
|
||||
"latest_version": "1.2.3" | null, "staged_version": "1.2.2" | null,
|
||||
"update_available": bool}`. `update_available` stays false until the
|
||||
board is known, regardless of what Gitea has. If "Automatically apply
|
||||
updates" is on and a newer release is found, this call also stages it
|
||||
immediately (same effect as a manual upload) -- otherwise the web UI
|
||||
shows an "Update frame" button
|
||||
- `POST /api/firmware/apply-latest` -- the "Update frame" button: pulls
|
||||
and stages the latest Gitea release right now, bypassing the check
|
||||
throttle. 400 if no repo is configured or no device has checked in
|
||||
yet (board unknown); 404 if the repo has no releases, or the latest
|
||||
release has no asset for the frame's board
|
||||
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...],
|
||||
"device": {"last_seen": ts | null, "overdue": bool,
|
||||
"firmware_version": "1.2.3" | null, "firmware_available": "1.2.4" | null,
|
||||
"battery": {"percent": N, "as_of": ts} | null, "on_battery_since": ts | null,
|
||||
"battery_estimate_s": N | null}}`, each queue entry an asset id +
|
||||
thumbnail URL; used by the config UI's "Device" panel
|
||||
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
|
||||
`{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having
|
||||
changed server-side since the client's last fetch (e.g. a top-up/trim)
|
||||
-- unrecognized IDs in the body are dropped, and any currently-queued
|
||||
photo missing from the body is appended rather than lost, instead of
|
||||
rejecting the whole request
|
||||
- `POST /api/queue/promote` -- moves one photo to the front of the queue;
|
||||
body is `{"asset_id": "..."}`. Used by "Show next" in the web UI --
|
||||
unlike `/reorder`, doesn't depend on the client knowing the queue's
|
||||
full current order, so it can't fail from staleness
|
||||
- `POST /api/queue/remove` -- permanently excludes a photo from this
|
||||
frame's rotation; body is `{"asset_id": "..."}`. Doesn't touch Immich
|
||||
or the album -- the photo just stops being selected by this frame
|
||||
again (`app/photo_queue.py`'s `excluded_asset_ids`/`remove_from_rotation()`).
|
||||
Works on the current photo too, in which case it immediately advances
|
||||
to a different one (without recording the removed photo in history --
|
||||
going back to a photo you just removed wouldn't make sense). Used by
|
||||
the "×" button in the web UI on both the current-photo thumbnail and
|
||||
each upcoming card
|
||||
- `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so
|
||||
the browser never needs the Immich API key directly
|
||||
- `GET /health` -- liveness check
|
||||
by default: it only actually advances once `refresh_interval_s` has
|
||||
elapsed since the current photo was set, so an unexpected reboot just
|
||||
redisplays the same photo. An unclaimed or not-yet-configured frame
|
||||
gets a rendered instruction placeholder (with a claim QR) instead of
|
||||
an error, so a fresh device never error-loops.
|
||||
- `POST /frame/advance` / `POST /frame/back` -- the next/back photo
|
||||
buttons: force an immediate move (mirror images of each other; back
|
||||
pops a bounded 20-entry history and pushes the displaced photo onto
|
||||
the front of the queue). Same response shape as `/frame/image`.
|
||||
- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version":
|
||||
... | null, "device_token": ...?}`, polled each wake. Captures the
|
||||
`X-Frame-Version`/`X-Frame-Board` headers (running firmware + board
|
||||
variant). `device_token` appears only during the one-time identity
|
||||
handshake -- until the device authenticates with its issued token
|
||||
once -- and the flat firmware parser's 512-byte buffer bounds how big
|
||||
this response may grow.
|
||||
- `GET /frame/photo-info` -- location/date overlay text for the manage
|
||||
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
|
||||
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich
|
||||
share link and 302s to it; scoped to the photo currently showing or
|
||||
queued on *this* frame only.
|
||||
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
|
||||
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
|
||||
flat-scalar parser.
|
||||
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
|
||||
history (feeds the runtime estimate) plus a permanent per-frame
|
||||
battery log (the Stats chart). Only sent on battery power. Also where
|
||||
the battery-alert threshold (below) is checked and, at most once per
|
||||
discharge cycle, emailed to the owner.
|
||||
- `GET /frame/firmware` -- streams the frame's staged OTA image.
|
||||
|
||||
### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes)
|
||||
|
||||
- `GET .../queue` -- current + upcoming (each entry id + thumbnail
|
||||
URL), the control state (`{"controller": name, "you": bool}`), and
|
||||
the device telemetry block (`last_seen`, `overdue` -- quiet-hours
|
||||
aware -- firmware versions, battery + runtime estimate).
|
||||
- `POST .../queue/reorder|promote|remove` -- reorder is drift-tolerant
|
||||
(stale ids dropped, missing ids appended); promote is "Show next";
|
||||
remove permanently excludes from this frame's rotation (never touches
|
||||
Immich) and advances if it was current.
|
||||
- `GET .../albums` -- the owner's Immich albums.
|
||||
- `POST .../config` -- **partial** update: only provided fields change
|
||||
(`name`, `album_id` -- resets queue/history on change --, `order`,
|
||||
`refresh_interval_s`, `display_mode` (`crop_fill`/`crop_faces`/
|
||||
`stretch_fill`/`letterbox`, see `image_pipeline.DISPLAY_MODES`),
|
||||
`queue_target_len`, `orientation` (composed logically then rotated
|
||||
server-side; the on-device manage overlay still renders native, a
|
||||
known limitation), `quiet_hours_*` + `timezone` (a pure server-side
|
||||
decision shaping what `refresh_interval_s` gets handed to the
|
||||
device), `firmware_update_repo_url`, `firmware_auto_update`,
|
||||
`battery_alert_threshold_pct` -- percent, or `-1`/blank to disable --,
|
||||
`palette` -- exactly 6 `#rrggbb` values in black/white/yellow/red/
|
||||
blue/green order --, `palette_reset` -- `true` clears back to the
|
||||
default palette --, `color_boost`/`contrast_boost` -- PIL
|
||||
`ImageEnhance` factors, 0-2, 1 = unchanged --, `dither_strength` --
|
||||
0-1, blends toward a flat/undithered quantization before running
|
||||
Floyd-Steinberg, so 0 = no dithering texture and 1 = full strength).
|
||||
- `GET .../preview/original`, `GET .../preview/rendered` -- the
|
||||
before/after comparison on the Configuration tab: the current
|
||||
photo's Immich preview untouched (JPEG), and that same photo run
|
||||
through this frame's actual saved rendering pipeline (PNG, upright
|
||||
logical orientation, not packed device bytes) -- reflects saved
|
||||
settings, not unsaved slider positions.
|
||||
- `POST .../take-control` -- always succeeds for a linked user.
|
||||
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
|
||||
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
|
||||
`GET .../firmware/check` (throttled 15 min; `?force=true` bypasses),
|
||||
`POST .../firmware/apply-latest`.
|
||||
|
||||
### Manage-QR API (`/api/m/{manage_token}/...` -- token in path, no login)
|
||||
|
||||
- `GET queue`, `POST promote`, `POST advance`, `POST back`,
|
||||
`GET thumbnail/{asset_id}` (scoped to this frame's current/queued
|
||||
photos). Nothing else.
|
||||
|
||||
- `GET /health` -- liveness check, always open.
|
||||
|
||||
## Notes
|
||||
|
||||
- Album/order/refresh-interval/current photo/upcoming queue/etc. are
|
||||
stored in `./data/config.json` on the host via the compose volume
|
||||
mount. Immich URL/API key are too if set via the web UI, but
|
||||
`IMMICH_URL`/`IMMICH_API_KEY` env vars (see Setup above) always take
|
||||
precedence when present.
|
||||
- All state (settings, current photo, upcoming queue, battery history,
|
||||
stats) lives in a SQLite database at `./data/espresso.db` on the host
|
||||
via the compose volume mount (`DATABASE_URL` env var to override --
|
||||
any SQLAlchemy URL works, so a future move to Postgres is a config
|
||||
change). A pre-database deployment's `./data/config.json` is imported
|
||||
automatically on first boot (it becomes frame #1) and left untouched
|
||||
afterwards as the rollback path. `IMMICH_URL`/`IMMICH_API_KEY` env
|
||||
vars (see Setup above) still take precedence when present.
|
||||
- The upcoming queue is a bounded lookahead, not the whole album --
|
||||
"Upcoming photos to show" in the config UI (`queue_target_len`, 5-50,
|
||||
default 20) controls its size and takes effect immediately (the queue
|
||||
@@ -235,19 +199,21 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
in sequential or shuffle order per the Order setting. Dragging photos
|
||||
in the web UI (or using "Show next") only rearranges what's already in
|
||||
that lookahead; it doesn't add or remove photos from the album.
|
||||
- Every endpoint except `/` and `/health` -- the web UI's `/api/*` and
|
||||
every device-facing `/frame/*` -- requires `?token=` (or the
|
||||
`mgmt_token` cookie the web UI sets after a valid one) once
|
||||
`MANAGEMENT_TOKEN` is set (see Setup above); unset, everything stays
|
||||
open like before, which is still fine on a trusted home LAN. `/frame/share`
|
||||
additionally stays scoped to only ever create a link for a photo this
|
||||
frame is actually showing or has queued, not any Immich asset ID
|
||||
someone might guess -- a second layer a leaked token alone wouldn't
|
||||
bypass.
|
||||
- The 6-color palette RGB values in `app/image_pipeline.py` are
|
||||
approximations, not measured values (Waveshare doesn't publish exact
|
||||
color primaries for this panel) -- tune them once you can compare a
|
||||
rendered test image against the real panel.
|
||||
- Auth in one breath: browsers use sessions (+CSRF), devices use
|
||||
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own
|
||||
limited token, and `MANAGEMENT_TOKEN` survives only as the migration
|
||||
credential for pre-multi-frame firmware. `/frame/share` stays scoped
|
||||
to photos this frame is actually showing or has queued, not any
|
||||
Immich asset ID someone might guess -- a second layer a leaked device
|
||||
token alone wouldn't bypass.
|
||||
- The 6-color palette RGB values in `app/image_pipeline.py`
|
||||
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
||||
(Waveshare doesn't publish exact color primaries for this panel).
|
||||
Each frame's Configuration tab has an **Advanced configuration**
|
||||
section (collapsed by default) with a color picker per ink color --
|
||||
tune them once you can compare a rendered photo against the real
|
||||
panel, and "Reset to defaults" to go back. Different panel units can
|
||||
vary enough to be worth calibrating per frame.
|
||||
|
||||
## Deploying a pre-built image
|
||||
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Authentication: password hashing, user sessions + CSRF, the legacy
|
||||
shared-token gate, and device resolution.
|
||||
|
||||
Three independent credential classes:
|
||||
- User sessions (cookie "session", server-side sessions table, per-
|
||||
session CSRF token required on mutating requests) -- humans.
|
||||
- The legacy shared MANAGEMENT_TOKEN (env-only). Still accepted on
|
||||
browser routes so the deployed frame's on-panel manage QR (which
|
||||
embeds ?token=) keeps working until Phase C replaces it with the
|
||||
limited /m/ page; CSRF doesn't apply to it (it's explicit per-request
|
||||
credential, not an ambient cookie a cross-site request could ride).
|
||||
- Device credentials (?id= + ?token=, see require_device below).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .migration import new_device_token, new_manage_token
|
||||
from .models import Frame, PasswordResetToken, PendingClaim, ServerSettings, User, UserFrame, UserSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
||||
SESSION_COOKIE = "session"
|
||||
SESSION_LIFETIME_S = 30 * 86400
|
||||
SESSION_REFRESH_BELOW_S = 15 * 86400 # rolling expiry: extend when under this much left
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_S = 3600
|
||||
|
||||
# stdlib scrypt instead of a passlib/argon2 dependency: zero new deps,
|
||||
# and the parameters are baked into each stored hash so they can be
|
||||
# raised later without invalidating existing ones.
|
||||
_SCRYPT_N = 16384
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(16)
|
||||
digest = hashlib.scrypt(
|
||||
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P
|
||||
)
|
||||
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
scheme, n, r, p, salt_hex, hash_hex = stored.split("$")
|
||||
if scheme != "scrypt":
|
||||
return False
|
||||
digest = hashlib.scrypt(
|
||||
password.encode(), salt=bytes.fromhex(salt_hex), n=int(n), r=int(r), p=int(p)
|
||||
)
|
||||
return hmac.compare_digest(digest.hex(), hash_hex)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def _hash_session_token(value: str) -> str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_session(db: Session, user: User) -> tuple[str, UserSession]:
|
||||
"""Returns (cookie_value, session row). Only the sha256 of the cookie
|
||||
value is stored, so a leaked database doesn't yield usable cookies."""
|
||||
cookie_value = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
session = UserSession(
|
||||
token_hash=_hash_session_token(cookie_value),
|
||||
user_id=user.id,
|
||||
csrf_token=secrets.token_urlsafe(32),
|
||||
created_at=now,
|
||||
expires_at=now + SESSION_LIFETIME_S,
|
||||
)
|
||||
db.add(session)
|
||||
# Opportunistic prune -- no background scheduler in this project.
|
||||
for stale in db.scalars(select(UserSession).where(UserSession.expires_at < now)):
|
||||
db.delete(stale)
|
||||
db.commit()
|
||||
return cookie_value, session
|
||||
|
||||
|
||||
def destroy_session(db: Session, request: Request) -> None:
|
||||
cookie_value = request.cookies.get(SESSION_COOKIE)
|
||||
if not cookie_value:
|
||||
return
|
||||
session = db.scalars(
|
||||
select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
|
||||
).first()
|
||||
if session is not None:
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
|
||||
|
||||
def current_session(request: Request, db: Session) -> UserSession | None:
|
||||
cookie_value = request.cookies.get(SESSION_COOKIE)
|
||||
if not cookie_value:
|
||||
return None
|
||||
session = db.scalars(
|
||||
select(UserSession).where(UserSession.token_hash == _hash_session_token(cookie_value))
|
||||
).first()
|
||||
now = time.time()
|
||||
if session is None or session.expires_at < now:
|
||||
return None
|
||||
if session.expires_at - now < SESSION_REFRESH_BELOW_S:
|
||||
session.expires_at = now + SESSION_LIFETIME_S
|
||||
db.commit()
|
||||
return session
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session) -> User | None:
|
||||
session = current_session(request, db)
|
||||
if session is None:
|
||||
return None
|
||||
return db.get(User, session.user_id)
|
||||
|
||||
|
||||
def users_exist(db: Session) -> bool:
|
||||
return db.scalars(select(User).limit(1)).first() is not None
|
||||
|
||||
|
||||
def get_server_settings(db: Session) -> ServerSettings:
|
||||
"""The SMTP config singleton -- migration.py guarantees row id=1
|
||||
exists (created at startup if missing), so this is never None."""
|
||||
settings = db.get(ServerSettings, 1)
|
||||
assert settings is not None
|
||||
return settings
|
||||
|
||||
|
||||
def create_password_reset_token(db: Session, user: User) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
# Opportunistic prune, same pattern as sessions/pending claims.
|
||||
for stale in db.scalars(select(PasswordResetToken).where(PasswordResetToken.expires_at < now)):
|
||||
db.delete(stale)
|
||||
db.add(PasswordResetToken(
|
||||
token=token, user_id=user.id, created_at=now,
|
||||
expires_at=now + PASSWORD_RESET_TOKEN_LIFETIME_S,
|
||||
))
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def consume_password_reset_token(db: Session, token: str) -> User | None:
|
||||
"""Looks up the token and, if valid, deletes it (single-use) and
|
||||
returns the user it was issued for. None for an unknown/expired
|
||||
token -- callers show a generic error either way."""
|
||||
row = db.get(PasswordResetToken, token)
|
||||
if row is None or row.expires_at < time.time():
|
||||
return None
|
||||
user = db.get(User, row.user_id)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _csrf_ok(request: Request, session: UserSession) -> bool:
|
||||
supplied = request.headers.get("X-CSRF-Token") or ""
|
||||
return hmac.compare_digest(supplied, session.csrf_token)
|
||||
|
||||
|
||||
def require_user_api(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
"""JSON-API dependency: a logged-in user, with CSRF enforced on
|
||||
mutating methods (the session rides an ambient cookie; the CSRF
|
||||
header is what proves the request came from our own JS, not a
|
||||
cross-site form)."""
|
||||
session = current_session(request, db)
|
||||
if session is None:
|
||||
raise HTTPException(401, "Not logged in")
|
||||
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
|
||||
raise HTTPException(403, "Missing or invalid CSRF token")
|
||||
user = db.get(User, session.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(401, "Not logged in")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin_api(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user = require_user_api(request, db)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
return user
|
||||
|
||||
|
||||
def user_frames(db: Session, user: User) -> list[Frame]:
|
||||
"""The frames this user sees in their sidebar: linked ones, or all of
|
||||
them for an admin (admins are the household operators -- they see
|
||||
unclaimed/new frames too, that's how those get adopted)."""
|
||||
if user.is_admin:
|
||||
return list(db.scalars(select(Frame).order_by(Frame.id)))
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Frame)
|
||||
.join(UserFrame, UserFrame.frame_id == Frame.id)
|
||||
.where(UserFrame.user_id == user.id)
|
||||
.order_by(Frame.id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def can_view_frame(db: Session, user: User, frame: Frame) -> bool:
|
||||
return user.is_admin or db.get(UserFrame, (user.id, frame.id)) is not None
|
||||
|
||||
|
||||
def require_frame_view(
|
||||
frame_id: int, request: Request, db: Session = Depends(get_db)
|
||||
) -> Frame:
|
||||
"""JSON-API dependency: a logged-in user who is linked to this frame
|
||||
(or an admin). 404 -- not 403 -- for frames outside the user's view,
|
||||
so the API doesn't confirm which frame ids exist."""
|
||||
user = require_user_api(request, db)
|
||||
frame = db.get(Frame, frame_id)
|
||||
if frame is None or not can_view_frame(db, user, frame):
|
||||
raise HTTPException(404, "No such frame")
|
||||
return frame
|
||||
|
||||
|
||||
def require_frame_control(
|
||||
frame_id: int, request: Request, db: Session = Depends(get_db)
|
||||
) -> Frame:
|
||||
"""View access plus the soft control lock: only the user currently
|
||||
holding control may mutate settings/queue. The 409 payload names the
|
||||
holder so the UI can offer "take control" instead of a dead end.
|
||||
Physical device buttons don't go through this -- device actions are
|
||||
device actions."""
|
||||
user = require_user_api(request, db)
|
||||
frame = db.get(Frame, frame_id)
|
||||
if frame is None or not can_view_frame(db, user, frame):
|
||||
raise HTTPException(404, "No such frame")
|
||||
if frame.controlled_by_user_id != user.id:
|
||||
holder = frame.controlled_by
|
||||
raise HTTPException(
|
||||
409,
|
||||
{
|
||||
"error": "not_controller",
|
||||
"holder": (holder.display_name or holder.username) if holder else None,
|
||||
},
|
||||
)
|
||||
return frame
|
||||
|
||||
|
||||
def management_token() -> str:
|
||||
"""The legacy shared secret. Env-only, never stored -- same as the old
|
||||
server, where the env var overrode anything on disk on every load."""
|
||||
return os.environ.get("MANAGEMENT_TOKEN", "")
|
||||
|
||||
|
||||
def browser_token_valid(request: Request) -> bool:
|
||||
"""The legacy shared-token check. No MANAGEMENT_TOKEN configured means
|
||||
token-holders don't exist -- but unlike Phase A this no longer means
|
||||
"open": once users exist, sessions are the primary gate and this is
|
||||
only the compatibility path for the deployed frame's manage QR
|
||||
(?token=) until Phase C. Empty token => not valid (sessions rule)."""
|
||||
token = management_token()
|
||||
if not token:
|
||||
return False
|
||||
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
|
||||
return supplied is not None and supplied == token
|
||||
|
||||
|
||||
def require_browser(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||
"""Dependency for the web UI's /api/* routes: a real user session
|
||||
(CSRF-checked on mutations, returns the User), or the legacy shared
|
||||
token (returns None -- token bearers act as an anonymous operator,
|
||||
exactly the pre-user model). While NO users exist yet (fresh install
|
||||
or freshly migrated, before /setup has been run) the API stays open
|
||||
if no MANAGEMENT_TOKEN is set -- the Phase A/legacy behavior --
|
||||
since there's nobody to log in as yet."""
|
||||
session = current_session(request, db)
|
||||
if session is not None:
|
||||
if request.method not in ("GET", "HEAD", "OPTIONS") and not _csrf_ok(request, session):
|
||||
raise HTTPException(403, "Missing or invalid CSRF token")
|
||||
user = db.get(User, session.user_id)
|
||||
if user is not None:
|
||||
return user
|
||||
if browser_token_valid(request):
|
||||
return None
|
||||
if not users_exist(db) and not management_token():
|
||||
return None
|
||||
raise HTTPException(401, "Not logged in")
|
||||
|
||||
|
||||
def _register_frame(db: Session, device_id: str) -> Frame:
|
||||
"""A device id we've never seen: self-register it as an unclaimed
|
||||
frame (this fires from ANY /frame/* route -- the wake cycle hits
|
||||
/frame/image before /frame/config). If a user already submitted a
|
||||
claim for this id (they beat the device to the server after
|
||||
provisioning), attach it now."""
|
||||
frame = Frame(
|
||||
name=f"Frame {device_id[-6:]}",
|
||||
device_id=device_id,
|
||||
device_token=new_device_token(),
|
||||
manage_token=new_manage_token(),
|
||||
created_at=time.time(),
|
||||
)
|
||||
db.add(frame)
|
||||
db.flush()
|
||||
|
||||
now = time.time()
|
||||
# Opportunistically prune expired claims while we're here.
|
||||
for stale in db.scalars(select(PendingClaim).where(PendingClaim.expires_at < now)):
|
||||
db.delete(stale)
|
||||
|
||||
pending = db.get(PendingClaim, device_id)
|
||||
if pending is not None and pending.expires_at >= now:
|
||||
frame.owner_user_id = pending.user_id
|
||||
frame.claimed_at = now
|
||||
db.add(UserFrame(user_id=pending.user_id, frame_id=frame.id))
|
||||
db.delete(pending)
|
||||
logger.info("Frame %s self-registered and attached pending claim by user %d",
|
||||
device_id, pending.user_id)
|
||||
else:
|
||||
logger.info("Frame %s self-registered (unclaimed)", device_id)
|
||||
return frame
|
||||
|
||||
|
||||
def require_device(request: Request, db: Session = Depends(get_db)) -> Frame:
|
||||
"""Resolves and authenticates the frame behind a /frame/* request.
|
||||
|
||||
New firmware sends ?id=<12-hex-mac>&token=<per-frame device token>.
|
||||
Deployed legacy firmware sends only ?token=<shared MANAGEMENT_TOKEN>
|
||||
(or nothing, on an open server) -- those requests resolve to the
|
||||
unique legacy_token_enabled frame for as long as that migration
|
||||
window stays open. The first id-bearing request arriving with legacy
|
||||
credentials while the legacy frame has no device_id yet BINDS that id
|
||||
to it -- that's the moment the deployed frame comes back up on new
|
||||
firmware after its OTA, and it must not register as a second frame.
|
||||
"""
|
||||
device_id = request.query_params.get("id", "").strip().lower()
|
||||
token = request.query_params.get("token", "")
|
||||
legacy = management_token()
|
||||
legacy_ok = not legacy or token == legacy
|
||||
|
||||
if device_id:
|
||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||
if frame is None:
|
||||
legacy_frame = db.scalars(
|
||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
||||
).first()
|
||||
if legacy_frame is not None and legacy_frame.device_id is None and legacy_ok:
|
||||
legacy_frame.device_id = device_id
|
||||
frame = legacy_frame
|
||||
logger.info("Bound device id %s to legacy frame #%d", device_id, frame.id)
|
||||
else:
|
||||
frame = _register_frame(db, device_id)
|
||||
else:
|
||||
token_ok = bool(token) and token == frame.device_token
|
||||
if token_ok and not frame.device_token_ack:
|
||||
frame.device_token_ack = True
|
||||
logger.info("Frame #%d acknowledged its device token", frame.id)
|
||||
if not token_ok:
|
||||
if frame.legacy_token_enabled and legacy_ok:
|
||||
pass
|
||||
elif not frame.device_token_ack:
|
||||
# Handshake window: the device registered but hasn't
|
||||
# received its token yet (the wake cycle fetches the
|
||||
# image BEFORE polling /frame/config, where the token
|
||||
# is delivered) -- the id stays the credential, same
|
||||
# trust level as the open registration that created
|
||||
# the row. Closes permanently on the first
|
||||
# authenticated request.
|
||||
pass
|
||||
else:
|
||||
raise HTTPException(401, "Missing or invalid access token")
|
||||
else:
|
||||
if not legacy_ok:
|
||||
raise HTTPException(401, "Missing or invalid access token")
|
||||
frame = db.scalars(
|
||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
||||
).first()
|
||||
if frame is None:
|
||||
# Nothing to resolve a no-id request to. migration.py always
|
||||
# creates frame #1 at startup, so this only happens if it was
|
||||
# deleted -- treat like an unknown device.
|
||||
raise HTTPException(401, "No frame accepts legacy credentials")
|
||||
|
||||
frame.last_seen = time.time()
|
||||
db.commit()
|
||||
return frame
|
||||
+36
-107
@@ -1,137 +1,85 @@
|
||||
"""JSON-file-backed config: Immich connection, selected album, and cursor
|
||||
state (which photo /frame/image serves next)."""
|
||||
"""LEGACY config.json model -- kept only so migration.py can import an
|
||||
existing single-frame deployment's state into the database on first
|
||||
boot. Nothing else should import this module; runtime state lives in
|
||||
SQLite (see models.py/db.py).
|
||||
|
||||
The file at CONFIG_PATH is deliberately never modified or deleted by the
|
||||
migration: it's the rollback path (redeploying the pre-database server
|
||||
image picks it right back up).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Iterator
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json"))
|
||||
|
||||
# Reentrant so load()/save() can each take it internally for their own I/O
|
||||
# while a caller also holds it for a whole locked() span (see below).
|
||||
_lock = RLock()
|
||||
|
||||
|
||||
class FrameStats(BaseModel):
|
||||
"""Cumulative, lifetime counters -- purely informational, never read
|
||||
back to drive any behavior, so there's no harm in them being a little
|
||||
approximate at the edges. Shown in a collapsed "Stats" section in the
|
||||
web UI (GET /api/stats). Never reset except by deleting config.json."""
|
||||
first_seen: float = 0.0 # first time this frame ever checked in
|
||||
device_wakes: int = 0 # wake cycles, counted once each via GET /frame/config
|
||||
photos_displayed: int = 0 # times the current photo actually changed (any cause)
|
||||
photos_removed: int = 0 # times a photo was permanently excluded from rotation
|
||||
battery_reports: int = 0 # POST /frame/battery calls
|
||||
recharge_cycles: int = 0 # times a battery recharge was detected
|
||||
ota_updates_applied: int = 0 # times the device's reported firmware version changed
|
||||
config_saves: int = 0 # POST /api/config calls
|
||||
first_seen: float = 0.0
|
||||
device_wakes: int = 0
|
||||
photos_displayed: int = 0
|
||||
photos_removed: int = 0
|
||||
battery_reports: int = 0
|
||||
recharge_cycles: int = 0
|
||||
ota_updates_applied: int = 0
|
||||
config_saves: int = 0
|
||||
|
||||
|
||||
class FrameConfig(BaseModel):
|
||||
immich_url: str = ""
|
||||
immich_api_key: str = ""
|
||||
management_token: str = "" # gates the web UI (see main.py); empty = no gate, open on trusted LAN
|
||||
management_token: str = ""
|
||||
album_id: str = ""
|
||||
order: str = "sequential" # or "shuffle"
|
||||
order: str = "sequential"
|
||||
refresh_interval_s: int = 3600
|
||||
# Quiet hours: no point waking the device overnight just to swap a
|
||||
# photo nobody's looking at. Times are "HH:MM" interpreted in
|
||||
# `timezone` below and may wrap past midnight (e.g. start=22:00,
|
||||
# end=07:00). Purely a server-side decision -- the device is unaware,
|
||||
# it just gets told a longer refresh_interval_s by GET /frame/config
|
||||
# while quiet hours are in effect (see main.py's
|
||||
# _effective_refresh_interval_s).
|
||||
quiet_hours_enabled: bool = False
|
||||
quiet_hours_start: str = "22:00"
|
||||
quiet_hours_end: str = "07:00"
|
||||
# IANA zone name (e.g. "America/New_York") quiet_hours_start/end are
|
||||
# interpreted in. Set from the web UI rather than the container's TZ
|
||||
# environment variable, so it survives container recreation and
|
||||
# doesn't need a docker-compose.yml edit to change.
|
||||
timezone: str = "UTC"
|
||||
smart_crop_faces: bool = True
|
||||
# How the physical frame is hung: landscape (native), portrait,
|
||||
# landscape_flipped, portrait_flipped. Purely a server-side render
|
||||
# decision -- the device always receives native 800x480 bytes.
|
||||
orientation: str = "landscape"
|
||||
|
||||
# Current photo + upcoming queue (see app/photo_queue.py). current_asset_set_at
|
||||
# is what lets the server decide "has it been long enough to advance" on its
|
||||
# own clock, independent of how/why the device asked for a photo.
|
||||
current_asset_id: str = ""
|
||||
current_asset_set_at: float = 0.0
|
||||
queue: list[str] = []
|
||||
queue_cursor: int = 0 # internal bookkeeping for sequential queue top-up; not user-facing
|
||||
queue_target_len: int = 20 # how many upcoming photos to keep queued/shown in the web UI
|
||||
history: list[str] = [] # bounded stack of previously-current asset ids, most recent last
|
||||
excluded_asset_ids: list[str] = [] # permanently removed from this frame's rotation (not deleted from Immich)
|
||||
queue_cursor: int = 0
|
||||
queue_target_len: int = 20
|
||||
history: list[str] = []
|
||||
excluded_asset_ids: list[str] = []
|
||||
|
||||
# Last battery report from the device (POST /frame/battery); -1 = never
|
||||
# reported / not battery-powered. battery_as_of mirrors the
|
||||
# current_asset_set_at timestamp pattern.
|
||||
battery_percent: int = -1
|
||||
battery_as_of: float = 0.0
|
||||
# [timestamp, percent] pairs for the CURRENT discharge cycle only --
|
||||
# reset whenever a report jumps up enough to indicate a recharge (see
|
||||
# main.py). Feeds the "on battery for" and "estimated remaining"
|
||||
# numbers in the web UI's Device panel.
|
||||
battery_history: list = []
|
||||
# Every report ever received, never reset by a recharge -- the
|
||||
# permanent record behind the web UI's battery history graph. Capped
|
||||
# generously (not a real limit at realistic report rates, just a
|
||||
# safety bound), unlike battery_history above which is deliberately
|
||||
# scoped to one cycle.
|
||||
battery_log: list = []
|
||||
|
||||
# Device liveness/telemetry: last_seen is touched by every /frame/*
|
||||
# request; device_firmware_version/device_board_variant come from the
|
||||
# X-Frame-Version/X-Frame-Board headers the device sends with its
|
||||
# config poll (CONFIG_FRAME_BOARD_NAME on the firmware side).
|
||||
last_seen: float = 0.0
|
||||
device_firmware_version: str = ""
|
||||
device_board_variant: str = "" # "" until a device has ever checked in
|
||||
# Version parsed out of the most recently uploaded OTA image
|
||||
# (POST /api/firmware); "" = none uploaded yet.
|
||||
device_board_variant: str = ""
|
||||
firmware_available_version: str = ""
|
||||
|
||||
# Gitea-hosted firmware auto-update (see app/gitea_releases.py).
|
||||
# repo_url empty = feature off, no Gitea calls made at all. Which
|
||||
# release asset to pull is learned from the device itself
|
||||
# (device_board_variant below, from its X-Frame-Board header) rather
|
||||
# than picked by the user -- must match one of the names
|
||||
# .gitea/workflows/firmware-release-build.yml publishes
|
||||
# (firmware-<board_variant>.bin).
|
||||
firmware_update_repo_url: str = "" # e.g. "https://git.example.com/owner/repo"
|
||||
firmware_auto_update: bool = False # pull+stage a newer release with no button click
|
||||
# Optional Gitea PAT (read-only access is enough) for a private repo's
|
||||
# releases; blank is fine for a public repo. GITEA_FIRMWARE_TOKEN env
|
||||
# var overrides, mirroring MANAGEMENT_TOKEN below -- never exposed to
|
||||
# the web UI template or any JSON response.
|
||||
firmware_update_repo_url: str = ""
|
||||
firmware_auto_update: bool = False
|
||||
firmware_update_token: str = ""
|
||||
firmware_update_checked_at: float = 0.0 # throttle bookkeeping, see gitea_releases.UPDATE_CHECK_INTERVAL_S
|
||||
firmware_gitea_latest_version: str = "" # latest release's version, from its tag name
|
||||
firmware_update_checked_at: float = 0.0
|
||||
firmware_gitea_latest_version: str = ""
|
||||
|
||||
stats: FrameStats = FrameStats()
|
||||
|
||||
|
||||
def load() -> FrameConfig:
|
||||
with _lock:
|
||||
if not CONFIG_PATH.exists():
|
||||
cfg = FrameConfig()
|
||||
else:
|
||||
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
|
||||
"""Reads the legacy file with the same env-override behavior the old
|
||||
server applied on every load -- which is exactly how env-configured
|
||||
IMMICH_URL/IMMICH_API_KEY get baked into the database at migration
|
||||
time even though they were never written to the file itself."""
|
||||
if not CONFIG_PATH.exists():
|
||||
cfg = FrameConfig()
|
||||
else:
|
||||
cfg = FrameConfig(**json.loads(CONFIG_PATH.read_text()))
|
||||
|
||||
# IMMICH_URL/IMMICH_API_KEY/MANAGEMENT_TOKEN/GITEA_FIRMWARE_TOKEN set in
|
||||
# the environment (e.g. docker-compose.yml, see
|
||||
# docker-compose.yml.example) take precedence over whatever's saved in
|
||||
# CONFIG_PATH, so credentials never need to go through the web UI.
|
||||
env_url = os.environ.get("IMMICH_URL")
|
||||
env_key = os.environ.get("IMMICH_API_KEY")
|
||||
env_token = os.environ.get("MANAGEMENT_TOKEN")
|
||||
@@ -146,22 +94,3 @@ def load() -> FrameConfig:
|
||||
cfg.firmware_update_token = env_gitea_token
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
def save(cfg: FrameConfig) -> None:
|
||||
with _lock:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_PATH.write_text(cfg.model_dump_json(indent=2))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def locked() -> Iterator[None]:
|
||||
"""Serializes an entire load-mutate-save cycle. load()/save() each
|
||||
only lock their own I/O, which isn't enough by itself: uvicorn
|
||||
dispatches sync routes to a thread pool, so two concurrent requests
|
||||
(e.g. the device's own poll landing alongside a web UI edit) can each
|
||||
load() the same on-disk state and the second save() silently clobber
|
||||
the first's changes. Route handlers that mutate config should wrap
|
||||
their whole load/mutate/save span in this."""
|
||||
with _lock:
|
||||
yield
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Engine, sessions, and the per-frame lock that replaces the old
|
||||
whole-config.json RLock.
|
||||
|
||||
Single uvicorn worker (see Dockerfile) -- handlers are sync and run in
|
||||
the threadpool, so this is ordinary multi-threading in one process: the
|
||||
same regime the old config.locked() RLock handled, now scoped per frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from .models import Frame
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")
|
||||
|
||||
_is_sqlite = DATABASE_URL.startswith("sqlite")
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if _is_sqlite else {},
|
||||
)
|
||||
|
||||
if _is_sqlite:
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _sqlite_pragmas(dbapi_connection, _record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.close()
|
||||
|
||||
|
||||
# expire_on_commit=False so a Frame resolved by the require_device
|
||||
# dependency (which commits its last_seen touch) stays usable in the
|
||||
# route handler without a re-select per attribute. Freshness inside
|
||||
# mutation spans is handled explicitly by frame_locked()'s refresh.
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
"""FastAPI dependency: one session per request (FastAPI caches the
|
||||
dependency, so require_device and the route handler share it)."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# One lock per frame id, created on demand. Guarded by a module lock so
|
||||
# two threads can't race to create different Lock objects for the same
|
||||
# frame (which would defeat the whole point).
|
||||
_frame_locks: dict[int, threading.Lock] = {}
|
||||
_frame_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
def _get_lock(frame_id: int) -> threading.Lock:
|
||||
with _frame_locks_guard:
|
||||
lock = _frame_locks.get(frame_id)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
_frame_locks[frame_id] = lock
|
||||
return lock
|
||||
|
||||
|
||||
@contextmanager
|
||||
def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
|
||||
"""Serializes a whole read-modify-write span on one frame -- the
|
||||
direct successor of the old config.locked(). The refresh() inside the
|
||||
lock is what makes it correct: without it the session could hold
|
||||
attribute state read *before* another thread's committed write, and
|
||||
saving would silently clobber it (the same lost-update race the old
|
||||
pattern's 're-read inside the lock' comment guarded against)."""
|
||||
with _get_lock(frame_id):
|
||||
frame = db.get(Frame, frame_id)
|
||||
if frame is None:
|
||||
raise LookupError(f"Frame {frame_id} does not exist")
|
||||
db.refresh(frame)
|
||||
yield frame
|
||||
db.commit()
|
||||
+19
-25
@@ -15,12 +15,7 @@ import io
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from .image_pipeline import (
|
||||
_face_aware_crop_box,
|
||||
_plain_center_crop_box,
|
||||
logical_render_size,
|
||||
logical_to_native,
|
||||
)
|
||||
from .image_pipeline import _placement_transform, logical_render_size, logical_to_native
|
||||
|
||||
# Small caps, not arbitrary: each label is its own malloc'd overlay
|
||||
# buffer on the device (see firmware/main/manage_qr_overlay.c), and the
|
||||
@@ -31,20 +26,21 @@ MAX_LABELED_FACES = 4
|
||||
NAME_MAX_LEN = 10
|
||||
|
||||
|
||||
def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_faces: bool,
|
||||
def compute_face_labels(preview_bytes: bytes, faces: list[dict], display_mode: str,
|
||||
orientation: str = "landscape") -> list[dict]:
|
||||
"""Returns up to MAX_LABELED_FACES [{"name", "x", "y"}], x/y in native
|
||||
800x480 panel pixel space at each named face's bottom-center point.
|
||||
Faces without an Immich-identified person name are skipped entirely.
|
||||
preview_bytes must be the same preview image render_frame() used for
|
||||
the currently-displayed frame, and smart_crop_faces/orientation must
|
||||
match the settings that were active then -- otherwise the crop box and
|
||||
rotation computed here won't match what's actually on screen.
|
||||
the currently-displayed frame, and display_mode/orientation must
|
||||
match the settings that were active then -- otherwise the placement
|
||||
and rotation computed here won't match what's actually on screen.
|
||||
|
||||
The crop math runs in logical (pre-rotation) space, matching
|
||||
render_frame()'s composition step; each anchor is then rotated into
|
||||
native panel coordinates via logical_to_native(), since the firmware
|
||||
draws labels in native space.
|
||||
The placement math runs in logical (pre-rotation) space, matching
|
||||
render_frame()'s composition step (see image_pipeline._placement_transform,
|
||||
shared so the two can't drift apart); each anchor is then rotated
|
||||
into native panel coordinates via logical_to_native(), since the
|
||||
firmware draws labels in native space.
|
||||
"""
|
||||
named = [face for face in faces if (face.get("person") or {}).get("name")]
|
||||
if not named:
|
||||
@@ -53,24 +49,22 @@ def compute_face_labels(preview_bytes: bytes, faces: list[dict], smart_crop_face
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
fitted = ImageOps.exif_transpose(Image.open(io.BytesIO(preview_bytes)).convert("RGB"))
|
||||
|
||||
if smart_crop_faces and faces:
|
||||
left, top, right, bottom = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
|
||||
crop_w, crop_h = right - left, bottom - top
|
||||
else:
|
||||
left, top, crop_w, crop_h = _plain_center_crop_box(fitted.width, fitted.height, logical_w, logical_h)
|
||||
scale_x, scale_y, offset_x, offset_y = _placement_transform(
|
||||
fitted.width, fitted.height, logical_w, logical_h, display_mode, faces
|
||||
)
|
||||
|
||||
labels = []
|
||||
for face in named[:MAX_LABELED_FACES]:
|
||||
face_w = face.get("imageWidth") or fitted.width
|
||||
face_h = face.get("imageHeight") or fitted.height
|
||||
scale_x = fitted.width / face_w
|
||||
scale_y = fitted.height / face_h
|
||||
img_scale_x = fitted.width / face_w
|
||||
img_scale_y = fitted.height / face_h
|
||||
|
||||
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * scale_x
|
||||
bottom_y = face["boundingBoxY2"] * scale_y
|
||||
center_x = (face["boundingBoxX1"] + face["boundingBoxX2"]) / 2 * img_scale_x
|
||||
bottom_y = face["boundingBoxY2"] * img_scale_y
|
||||
|
||||
frame_x = (center_x - left) * (logical_w / crop_w)
|
||||
frame_y = (bottom_y - top) * (logical_h / crop_h)
|
||||
frame_x = center_x * scale_x + offset_x
|
||||
frame_y = bottom_y * scale_y + offset_y
|
||||
|
||||
if not (0 <= frame_x <= logical_w and 0 <= frame_y <= logical_h):
|
||||
continue # this face got cropped out of the final frame entirely
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Local firmware image storage + esp_app_desc_t parsing. Shared by the
|
||||
manual upload path (POST /api/firmware) and the Gitea auto-update path
|
||||
(see gitea_releases.py) -- both end up writing the same firmware.bin slot
|
||||
that GET /frame/firmware streams to the device."""
|
||||
"""Per-frame firmware image storage + esp_app_desc_t parsing. Shared by
|
||||
the manual upload path and the Gitea auto-update path -- both end up
|
||||
writing the same per-frame slot that GET /frame/firmware streams to the
|
||||
device. The migration moves the old single /data/firmware.bin into frame
|
||||
#1's slot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,8 +19,8 @@ APP_DESC_MAGIC = 0xABCD5432
|
||||
EXPECTED_PROJECT_NAME = "espresso_frame"
|
||||
|
||||
|
||||
def firmware_path():
|
||||
return config.CONFIG_PATH.parent / "firmware.bin"
|
||||
def firmware_path(frame_id: int):
|
||||
return config.CONFIG_PATH.parent / "firmware" / f"{frame_id}.bin"
|
||||
|
||||
|
||||
def parse_app_version(data: bytes) -> str:
|
||||
|
||||
+226
-40
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageOps
|
||||
|
||||
EPD_WIDTH = 800
|
||||
EPD_HEIGHT = 480
|
||||
@@ -45,39 +47,56 @@ def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
|
||||
return int(logical_h - 1 - y), int(x)
|
||||
return int(x), int(y)
|
||||
|
||||
# Approximate sRGB for each of the panel's 6 ink colors. These are
|
||||
# reasonable placeholders, not measured values -- Waveshare doesn't publish
|
||||
# exact color primaries for this panel. Tune them once you can compare a
|
||||
# rendered test image against the real panel.
|
||||
PALETTE_RGB = [
|
||||
# (0, 0, 0), # BLACK
|
||||
# (255, 255, 255), # WHITE
|
||||
# (255, 219, 0), # YELLOW
|
||||
# (207, 0, 15), # RED
|
||||
# (0, 39, 133), # BLUE
|
||||
# (0, 133, 55), # GREEN
|
||||
(0, 0, 0),
|
||||
(255, 255, 255),
|
||||
(255, 243, 56),
|
||||
(191, 0, 0),
|
||||
(100, 64, 255),
|
||||
(67, 138, 28)
|
||||
# Approximate sRGB for each of the panel's 6 ink colors -- reasonable
|
||||
# placeholders, not measured values (Waveshare doesn't publish exact
|
||||
# color primaries for this panel). This is the fallback for any frame
|
||||
# that hasn't tuned its own (Frame.palette_rgb, set from a frame's
|
||||
# Configuration tab -- "Advanced configuration" -- once you can compare
|
||||
# a rendered test image against the real panel; different panel units
|
||||
# can vary enough to be worth calibrating per frame).
|
||||
DEFAULT_PALETTE_RGB = [
|
||||
(0, 0, 0), # BLACK
|
||||
(255, 255, 255), # WHITE
|
||||
(255, 219, 0), # YELLOW
|
||||
(207, 0, 15), # RED
|
||||
(0, 39, 133), # BLUE
|
||||
(0, 133, 55), # GREEN
|
||||
]
|
||||
|
||||
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
||||
|
||||
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
||||
# in the same order as PALETTE_RGB. 0x4 is intentionally unused upstream.
|
||||
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
|
||||
# hardware protocol, never user-configurable. 0x4 is intentionally unused
|
||||
# upstream.
|
||||
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
||||
|
||||
|
||||
def _build_palette_image() -> Image.Image:
|
||||
def palette_to_hex(palette_rgb: list) -> list[str]:
|
||||
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
|
||||
configuration color pickers."""
|
||||
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
|
||||
|
||||
|
||||
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
|
||||
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
|
||||
a 6-hex-digit color (what <input type="color"> always sends, but a
|
||||
direct API call might not)."""
|
||||
hex_str = hex_str.strip().lstrip("#")
|
||||
if len(hex_str) != 6:
|
||||
return None
|
||||
try:
|
||||
return (int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _build_palette_image(palette_rgb: list) -> Image.Image:
|
||||
pal_img = Image.new("P", (1, 1))
|
||||
pal_img.putpalette([channel for rgb in PALETTE_RGB for channel in rgb])
|
||||
pal_img.putpalette([channel for rgb in palette_rgb for channel in rgb])
|
||||
return pal_img
|
||||
|
||||
|
||||
_PALETTE_IMAGE = _build_palette_image()
|
||||
|
||||
|
||||
def _plain_center_crop_box(
|
||||
img_width: int, img_height: int, target_width: int, target_height: int
|
||||
) -> tuple[float, float, int, int]:
|
||||
@@ -152,29 +171,98 @@ def _face_aware_crop_box(
|
||||
return (int(left), int(top), int(left) + crop_w, int(top) + crop_h)
|
||||
|
||||
|
||||
def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
orientation: str = "landscape") -> bytes:
|
||||
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
|
||||
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
|
||||
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
||||
# Display modes: how a photo's aspect ratio gets reconciled with the
|
||||
# panel's. "crop_faces" falls back to "crop_fill" behavior when no faces
|
||||
# were detected/passed. DEFAULT_DISPLAY_MODE matches this project's old
|
||||
# always-on smart_crop_faces=True default.
|
||||
DISPLAY_MODES = ["crop_fill", "crop_faces", "stretch_fill", "letterbox"]
|
||||
DISPLAY_MODE_LABELS = {
|
||||
"crop_fill": "Crop to fill",
|
||||
"crop_faces": "Crop to faces",
|
||||
"stretch_fill": "Stretch to fill",
|
||||
"letterbox": "Shrink to fit",
|
||||
}
|
||||
DEFAULT_DISPLAY_MODE = "crop_faces"
|
||||
LETTERBOX_BG = (255, 255, 255)
|
||||
|
||||
If `faces` (from ImmichClient.get_asset_faces) is non-empty, crops
|
||||
toward keeping them on screen instead of a plain center-crop.
|
||||
|
||||
`orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
|
||||
the frame physically hangs, then rotates into native panel space --
|
||||
the output byte layout is identical either way.
|
||||
"""
|
||||
def _placement_transform(
|
||||
img_width: int, img_height: int, target_w: int, target_h: int,
|
||||
display_mode: str, faces: list[dict] | None = None,
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""Returns (scale_x, scale_y, offset_x, offset_y) mapping a point in
|
||||
source-image pixel space to a point in target logical space, for the
|
||||
given display_mode. Shared by render_frame (which also does the
|
||||
actual pixel crop/resize/pad) and face_labels.py (label position
|
||||
math) -- they must stay in exact agreement or overlay labels drift
|
||||
off the people they're meant to point at."""
|
||||
if display_mode == "stretch_fill":
|
||||
return target_w / img_width, target_h / img_height, 0.0, 0.0
|
||||
if display_mode == "letterbox":
|
||||
scale = min(target_w / img_width, target_h / img_height)
|
||||
return scale, scale, (target_w - img_width * scale) / 2, (target_h - img_height * scale) / 2
|
||||
if display_mode == "crop_faces" and faces:
|
||||
left, top, right, bottom = _face_aware_crop_box(img_width, img_height, target_w, target_h, faces)
|
||||
crop_w, crop_h = right - left, bottom - top
|
||||
else:
|
||||
left, top, crop_w, crop_h = _plain_center_crop_box(img_width, img_height, target_w, target_h)
|
||||
scale_x, scale_y = target_w / crop_w, target_h / crop_h
|
||||
return scale_x, scale_y, -left * scale_x, -top * scale_y
|
||||
|
||||
|
||||
def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str) -> Image.Image:
|
||||
"""Crop/resize/letterbox `source` per display_mode -- returns an RGB
|
||||
image at logical_render_size(orientation), before enhancement or
|
||||
quantization. See render_frame for what each display_mode does."""
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
fitted = ImageOps.exif_transpose(source.convert("RGB"))
|
||||
|
||||
if faces:
|
||||
if display_mode == "stretch_fill":
|
||||
return fitted.resize((logical_w, logical_h), Image.LANCZOS)
|
||||
if display_mode == "letterbox":
|
||||
scale = min(logical_w / fitted.width, logical_h / fitted.height)
|
||||
new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale))
|
||||
resized = fitted.resize((new_w, new_h), Image.LANCZOS)
|
||||
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
|
||||
canvas.paste(resized, ((logical_w - new_w) // 2, (logical_h - new_h) // 2))
|
||||
return canvas
|
||||
if display_mode == "crop_faces" and faces:
|
||||
box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
|
||||
fitted = fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
|
||||
else:
|
||||
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
|
||||
return fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
|
||||
return ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
|
||||
|
||||
quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
|
||||
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
|
||||
if color_boost != 1.0:
|
||||
img = ImageEnhance.Color(img).enhance(color_boost)
|
||||
if contrast_boost != 1.0:
|
||||
img = ImageEnhance.Contrast(img).enhance(contrast_boost)
|
||||
return img
|
||||
|
||||
|
||||
def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image:
|
||||
"""RGB -> palette-quantized P-mode image, same size/orientation as
|
||||
`img` (no rotation here). dither_strength blends `img` toward its own
|
||||
flat (undithered) quantization before running Floyd-Steinberg on the
|
||||
blend: at 0 there's zero quantization error left to diffuse (so the
|
||||
result IS the flat quantization, no dithering texture at all); at 1
|
||||
it's `img` unchanged (full-strength dithering, this project's
|
||||
original always-on behavior); values between give a smooth continuum
|
||||
of dithering intensity rather than an on/off toggle."""
|
||||
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB)
|
||||
if dither_strength >= 1.0:
|
||||
return img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
if dither_strength <= 0.0:
|
||||
return img.quantize(palette=palette_image, dither=Image.Dither.NONE)
|
||||
flat = img.quantize(palette=palette_image, dither=Image.Dither.NONE).convert("RGB")
|
||||
blended = Image.blend(flat, img, dither_strength)
|
||||
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
||||
|
||||
|
||||
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
|
||||
"""Rotates a logical-space quantized image into native panel space
|
||||
and packs it 2 pixels/byte the way epd7in3e.c expects. Always
|
||||
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes."""
|
||||
transpose = ORIENTATION_TRANSPOSE.get(orientation)
|
||||
if transpose is not None:
|
||||
quantized = quantized.transpose(transpose)
|
||||
@@ -190,3 +278,101 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
i += 1
|
||||
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
||||
contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes:
|
||||
"""Fits `source` to the panel's resolution, applies color/contrast
|
||||
enhancement, quantizes it to the 6-color palette, and packs 2
|
||||
pixels/byte the way epd7in3e.c expects. Always returns exactly
|
||||
EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
||||
|
||||
`display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio
|
||||
is reconciled with the panel's: crop_fill (center-crop to fill,
|
||||
excess trimmed), crop_faces (as crop_fill, but shifts the crop to
|
||||
keep `faces` on screen -- falls back to crop_fill if none), stretch_fill
|
||||
(fills exactly, aspect ratio not preserved), letterbox (whole photo
|
||||
visible, letterboxed with LETTERBOX_BG where it doesn't fill).
|
||||
|
||||
`color_boost`/`contrast_boost` are PIL ImageEnhance factors (1.0 =
|
||||
unchanged, matching PIL's own convention); `dither_strength` is
|
||||
0.0-1.0 (see _quantize).
|
||||
|
||||
`orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
|
||||
the frame physically hangs, then rotates into native panel space --
|
||||
the output byte layout is identical either way.
|
||||
|
||||
`palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors,
|
||||
see Frame.palette_rgb) -- None uses the default.
|
||||
"""
|
||||
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
|
||||
|
||||
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None,
|
||||
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
||||
contrast_boost: float = 1.0, dither_strength: float = 1.0) -> bytes:
|
||||
"""Identical composition/enhancement/quantization pipeline as
|
||||
render_frame, but returned as a normal browser-viewable PNG in
|
||||
logical (upright, as-the-frame-actually-hangs) orientation rather
|
||||
than packed native-panel bytes and rotation -- what the web UI's
|
||||
"how it will look on the frame" preview shows."""
|
||||
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
||||
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
||||
buf = io.BytesIO()
|
||||
quantized.convert("RGB").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
||||
orientation: str = "landscape", palette_rgb: list | None = None) -> bytes:
|
||||
"""A readable full-panel message (plus an optional QR code) in the
|
||||
same packed format as render_frame -- what /frame/image serves for a
|
||||
frame that isn't claimed or configured yet, so a fresh device shows
|
||||
instructions instead of an error screen and never error-loops."""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
title_font = ImageFont.load_default(size=34)
|
||||
body_font = ImageFont.load_default(size=24)
|
||||
|
||||
qr_img = None
|
||||
if qr_url:
|
||||
import qrcode
|
||||
|
||||
qr = qrcode.QRCode(border=1, box_size=1)
|
||||
qr.add_data(qr_url)
|
||||
qr.make(fit=True)
|
||||
raw = qr.make_image().get_image().convert("RGB")
|
||||
# Integer upscale with NEAREST keeps modules crisp on the panel.
|
||||
target = 220
|
||||
scale = max(1, target // raw.width)
|
||||
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
||||
|
||||
# Vertical layout: text block, then QR under it, centered as a group.
|
||||
line_heights = []
|
||||
for i, line in enumerate(lines):
|
||||
font = title_font if i == 0 else body_font
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
|
||||
gap = 14
|
||||
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
|
||||
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
|
||||
y = max(20, (logical_h - total_h) // 2)
|
||||
|
||||
for line, font, w, h in line_heights:
|
||||
draw.text(((logical_w - w) // 2, y), line, fill=(0, 0, 0), font=font)
|
||||
y += h + gap
|
||||
|
||||
if qr_img:
|
||||
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
|
||||
|
||||
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
|
||||
+82
-887
@@ -1,199 +1,53 @@
|
||||
"""ESPresso Frame server: pulls photos from Immich, pre-processes them for
|
||||
the panel, and serves the ESP32 a ready-to-display frame."""
|
||||
"""ESPresso Frame server: pulls photos from Immich, pre-processes them
|
||||
for the panel, and serves ESP32 frames ready-to-display images.
|
||||
|
||||
This module is assembly only -- routes live in app/routers/:
|
||||
device.py the firmware-facing /frame/* protocol (paths frozen)
|
||||
api_frames.py the web UI's JSON API, /api/frames/{id}/...
|
||||
frame_pages.py the per-frame Photos/Configuration/Stats pages
|
||||
pages.py setup/login/claim/settings/admin
|
||||
manage.py the limited manage-QR surface (/m/, /api/m/)
|
||||
Storage is SQLite via models.py/db.py; migration.py imports a
|
||||
pre-database config.json deployment on first boot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, available_timezones
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, Form, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import config, gitea_releases, photo_queue
|
||||
from .face_labels import compute_face_labels
|
||||
from .firmware import firmware_path, parse_app_version
|
||||
from .image_pipeline import render_frame
|
||||
from .immich_client import ImmichClient
|
||||
from . import migration
|
||||
from .auth import (
|
||||
browser_token_valid,
|
||||
current_user,
|
||||
management_token,
|
||||
user_frames,
|
||||
users_exist,
|
||||
)
|
||||
from .db import SessionLocal
|
||||
from .models import Frame
|
||||
from .routers import api_frames, device, frame_pages, manage, pages
|
||||
from .routers.common import shell_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Schema + legacy-config import, before the first request is served.
|
||||
migration.run_migrations()
|
||||
|
||||
app = FastAPI(title="ESPresso Frame Server")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
MIN_REFRESH_INTERVAL_S = 60
|
||||
MAX_REFRESH_INTERVAL_S = 86400
|
||||
MIN_QUEUE_TARGET_LEN = 5
|
||||
MAX_QUEUE_TARGET_LEN = 5000
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||
|
||||
# Populated once from the OS's zoneinfo database (installed via the
|
||||
# `tzdata` apt package in the Dockerfile) and offered as a <select> in the
|
||||
# web UI's "Timezone" field -- see api_config_save/index below.
|
||||
ALL_TIMEZONES = sorted(available_timezones())
|
||||
|
||||
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
||||
|
||||
# Battery-history / estimate tuning (see /frame/battery and _battery_estimate).
|
||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
||||
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- see FrameConfig.battery_log
|
||||
RECHARGE_JUMP_PCT = 5 # a report this much above the previous one = battery was recharged
|
||||
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
|
||||
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
|
||||
|
||||
# "Overdue" threshold multiplier: the device should check in roughly every
|
||||
# refresh_interval_s; give it half again as long before flagging it.
|
||||
OVERDUE_FACTOR = 1.5
|
||||
|
||||
|
||||
def _valid_hhmm(s: str) -> bool:
|
||||
try:
|
||||
datetime.strptime(s, "%H:%M")
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _zoneinfo(name: str) -> ZoneInfo:
|
||||
"""Falls back to UTC for an unrecognized zone name -- defensive only;
|
||||
api_config_save already validates against ALL_TIMEZONES before saving,
|
||||
so this only matters for a config.json hand-edited or written by an
|
||||
older version of this file."""
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]:
|
||||
"""Whether `now` falls inside the quiet-hours window, and the next
|
||||
boundary: if inside, when it ends; if outside, when it next starts.
|
||||
Handles a window that wraps past midnight (e.g. 22:00-07:00). Returns
|
||||
(False, None) for a degenerate window (start == end)."""
|
||||
sh, sm = (int(x) for x in start_str.split(":"))
|
||||
eh, em = (int(x) for x in end_str.split(":"))
|
||||
start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
|
||||
end = now.replace(hour=eh, minute=em, second=0, microsecond=0)
|
||||
|
||||
if start == end:
|
||||
return False, None
|
||||
|
||||
if start < end:
|
||||
# Same-day window, e.g. 13:00-15:00. End is exclusive, so being
|
||||
# exactly at `end` counts as already outside the window.
|
||||
if start <= now < end:
|
||||
return True, end
|
||||
if now < start:
|
||||
return False, start
|
||||
return False, start + timedelta(days=1)
|
||||
|
||||
# Wraps midnight, e.g. 22:00-07:00.
|
||||
if now >= start:
|
||||
return True, end + timedelta(days=1)
|
||||
if now < end:
|
||||
return True, end
|
||||
return False, start
|
||||
|
||||
|
||||
def _quiet_hours_span_s(start_str: str, end_str: str) -> int:
|
||||
"""Duration of the quiet-hours window in seconds, wrap-aware."""
|
||||
sh, sm = (int(x) for x in start_str.split(":"))
|
||||
eh, em = (int(x) for x in end_str.split(":"))
|
||||
span_min = ((eh * 60 + em) - (sh * 60 + sm)) % (24 * 60)
|
||||
return span_min * 60
|
||||
|
||||
|
||||
def _effective_refresh_interval_s(cfg: config.FrameConfig) -> int:
|
||||
"""The refresh interval actually handed to the device: its configured
|
||||
value, unless quiet hours are enabled, in which case it's clamped so
|
||||
the device sleeps through the whole window instead of waking inside
|
||||
it. A device already mid-sleep when quiet hours begin can still land
|
||||
one wake inside the window (nothing server-side can prevent that
|
||||
without touching the firmware) -- but from that wake on, it's told to
|
||||
sleep exactly until the window ends."""
|
||||
if not cfg.quiet_hours_enabled:
|
||||
return cfg.refresh_interval_s
|
||||
now = datetime.now(_zoneinfo(cfg.timezone))
|
||||
in_quiet, boundary = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
|
||||
if boundary is None:
|
||||
return cfg.refresh_interval_s
|
||||
seconds_to_boundary = max(1, int((boundary - now).total_seconds()))
|
||||
if in_quiet:
|
||||
return seconds_to_boundary
|
||||
return min(cfg.refresh_interval_s, seconds_to_boundary)
|
||||
|
||||
|
||||
def _in_quiet_hours(cfg: config.FrameConfig) -> bool:
|
||||
"""Whether quiet hours are in effect right now -- separate from
|
||||
_effective_refresh_interval_s, which only shapes what the *device* is
|
||||
told to sleep for. This instead gates photo_queue.get_current()'s
|
||||
time-based advance, since that check runs independent of the device
|
||||
(also triggered by the web UI's /api/queue, e.g. an open browser tab
|
||||
polling overnight) and would otherwise happily advance the current
|
||||
photo mid-quiet-hours on raw elapsed time alone."""
|
||||
if not cfg.quiet_hours_enabled:
|
||||
return False
|
||||
now = datetime.now(_zoneinfo(cfg.timezone))
|
||||
in_quiet, _ = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
|
||||
return in_quiet
|
||||
|
||||
|
||||
def _max_expected_gap_s(cfg: config.FrameConfig) -> int:
|
||||
"""Longest gap between wakes the device might legitimately have --
|
||||
normally just refresh_interval_s, but quiet hours can make the real
|
||||
gap much longer, and the "overdue" check (see api_queue) shouldn't
|
||||
mistake a device quietly sleeping through the night for a dead one."""
|
||||
gap = cfg.refresh_interval_s
|
||||
if cfg.quiet_hours_enabled:
|
||||
gap = max(gap, _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end))
|
||||
return gap
|
||||
|
||||
|
||||
def _touch_last_seen() -> None:
|
||||
"""Records that the device just made contact. Called by every
|
||||
/frame/* route -- a handful of extra config writes per wake cycle,
|
||||
which is nothing at hourly wakes."""
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
cfg.last_seen = time.time()
|
||||
config.save(cfg)
|
||||
|
||||
|
||||
def _token_valid(request: Request, cfg: config.FrameConfig) -> bool:
|
||||
"""No management_token configured (MANAGEMENT_TOKEN env var, see
|
||||
docker-compose.yml.example) means the whole server stays open on a
|
||||
trusted LAN, matching this project's original default. Once one's
|
||||
set, a request is authorized by either a ?token= query param (what
|
||||
the ESP32 sends on every device request, and what the manage-menu/
|
||||
share QR codes embed for a human scanning them) or the cookie
|
||||
index() sets after a valid query-param hit (so the web UI's own
|
||||
fetch()/<img> calls, which carry no query string, stay authorized
|
||||
for the rest of that browsing visit)."""
|
||||
if not cfg.management_token:
|
||||
return True
|
||||
supplied = request.query_params.get("token") or request.cookies.get(MANAGEMENT_TOKEN_COOKIE)
|
||||
return supplied is not None and supplied == cfg.management_token
|
||||
|
||||
|
||||
def require_access_token(request: Request) -> None:
|
||||
"""Dependency for every route except / and /health: the web UI's
|
||||
/api/* and every device-facing /frame/*. index() handles the
|
||||
unauthorized case itself (a friendlier HTML prompt, not a bare 401)
|
||||
since that's the one route a human is actually meant to land on with
|
||||
no token yet; the ESP32 sends its token as ?token= on every request
|
||||
it makes (see frame_client.c's build_url()), so device endpoints
|
||||
just 401 outright on a missing/wrong one. /health stays open -- it
|
||||
reveals nothing but process liveness, and gating it would break
|
||||
plain infra/uptime monitoring for no real security benefit."""
|
||||
if not _token_valid(request, config.load()):
|
||||
raise HTTPException(401, "Missing or invalid access token")
|
||||
app.include_router(device.router)
|
||||
app.include_router(api_frames.router)
|
||||
app.include_router(frame_pages.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(manage.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -201,712 +55,53 @@ def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/frame/config", dependencies=[Depends(require_access_token)])
|
||||
def frame_config(request: Request):
|
||||
"""Device-facing settings, polled by the frame alongside its
|
||||
reachability check. Always returns 200 with current settings
|
||||
(defaults if nothing's been saved yet) -- no Immich-configured gate,
|
||||
since this doubles as the "is the server up" signal. Also captures
|
||||
the device's running firmware version and board variant (X-Frame-
|
||||
Version/X-Frame-Board headers -- the latter is how the Gitea
|
||||
auto-update feature learns which release asset to fetch, instead of
|
||||
a user picking it in the web UI) and advertises the uploaded OTA
|
||||
image's version, so the device's update check costs zero extra
|
||||
round trips."""
|
||||
reported_version = request.headers.get("X-Frame-Version", "")
|
||||
reported_board = request.headers.get("X-Frame-Board", "")
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
cfg.last_seen = time.time()
|
||||
if cfg.stats.first_seen == 0:
|
||||
cfg.stats.first_seen = cfg.last_seen
|
||||
cfg.stats.device_wakes += 1
|
||||
if reported_version:
|
||||
if cfg.device_firmware_version and reported_version != cfg.device_firmware_version:
|
||||
cfg.stats.ota_updates_applied += 1
|
||||
cfg.device_firmware_version = reported_version
|
||||
if reported_board:
|
||||
cfg.device_board_variant = reported_board
|
||||
config.save(cfg)
|
||||
return {
|
||||
"refresh_interval_s": _effective_refresh_interval_s(cfg),
|
||||
"firmware_version": cfg.firmware_available_version or None,
|
||||
}
|
||||
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
|
||||
"""The on-frame manage QR points at the server root with the device's
|
||||
own credentials (new firmware: ?id=&token=; deployed firmware:
|
||||
?token=<legacy shared token>). Those scans get the frame's limited
|
||||
manage page -- never the full UI, which requires a login.
|
||||
allow_legacy is False before /setup has run: at that point a bare
|
||||
?token= hit is the admin coming through the token prompt to do
|
||||
first-run setup, not a QR scan."""
|
||||
device_id = request.query_params.get("id", "").strip().lower()
|
||||
token = request.query_params.get("token", "")
|
||||
if device_id and token:
|
||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||
if frame is not None and token == frame.device_token:
|
||||
return f"/m/{frame.manage_token}"
|
||||
if allow_legacy and token and management_token() and token == management_token():
|
||||
frame = db.scalars(
|
||||
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
|
||||
).first()
|
||||
if frame is not None:
|
||||
return f"/m/{frame.manage_token}"
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
cfg = config.load()
|
||||
if not _token_valid(request, cfg):
|
||||
supplied = request.query_params.get("token")
|
||||
return templates.TemplateResponse(
|
||||
"token_prompt.html", {"request": request, "wrong": supplied is not None}
|
||||
)
|
||||
|
||||
response = templates.TemplateResponse(
|
||||
"index.html", {"request": request, "cfg": cfg, "timezones": ALL_TIMEZONES}
|
||||
)
|
||||
supplied = request.query_params.get("token")
|
||||
if cfg.management_token and supplied == cfg.management_token:
|
||||
# Query-param access (typically the manage-menu QR code) earns a
|
||||
# cookie so the rest of this visit's fetch()/<img> calls -- which
|
||||
# never carry the query string -- stay authorized too.
|
||||
response.set_cookie(
|
||||
MANAGEMENT_TOKEN_COOKIE, supplied, max_age=86400 * 365, httponly=True, samesite="lax"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/albums", dependencies=[Depends(require_access_token)])
|
||||
def api_albums():
|
||||
cfg = config.load()
|
||||
if not cfg.immich_url or not cfg.immich_api_key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
try:
|
||||
albums = ImmichClient(cfg.immich_url, cfg.immich_api_key).list_albums()
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
|
||||
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
|
||||
|
||||
|
||||
@app.post("/api/config", dependencies=[Depends(require_access_token)])
|
||||
def api_config_save(
|
||||
album_id: str = Form(""),
|
||||
order: str = Form("sequential"),
|
||||
refresh_interval_s: int = Form(3600),
|
||||
smart_crop_faces: bool = Form(True),
|
||||
queue_target_len: int = Form(20),
|
||||
orientation: str = Form("landscape"),
|
||||
quiet_hours_enabled: bool = Form(False),
|
||||
quiet_hours_start: str = Form("22:00"),
|
||||
quiet_hours_end: str = Form("07:00"),
|
||||
timezone: str = Form("UTC"),
|
||||
firmware_update_repo_url: str = Form(""),
|
||||
firmware_auto_update: bool = Form(False),
|
||||
):
|
||||
# Immich URL/API key/Gitea token are env-var only (IMMICH_URL/
|
||||
# IMMICH_API_KEY/GITEA_FIRMWARE_TOKEN, see docker-compose.yml.example)
|
||||
# -- config.load() already applies them, and this handler doesn't touch
|
||||
# cfg.immich_url/immich_api_key/firmware_update_token at all, so
|
||||
# there's nothing here that could overwrite or clear them.
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
if album_id != cfg.album_id:
|
||||
# A newly selected album starts clean -- the old current photo and
|
||||
# queue don't mean anything in the new album's context.
|
||||
cfg.current_asset_id = ""
|
||||
cfg.current_asset_set_at = 0.0
|
||||
cfg.queue = []
|
||||
cfg.queue_cursor = 0
|
||||
cfg.history = []
|
||||
cfg.excluded_asset_ids = []
|
||||
cfg.album_id = album_id
|
||||
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
||||
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
|
||||
cfg.smart_crop_faces = smart_crop_faces
|
||||
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
||||
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
|
||||
cfg.quiet_hours_enabled = quiet_hours_enabled
|
||||
if _valid_hhmm(quiet_hours_start):
|
||||
cfg.quiet_hours_start = quiet_hours_start
|
||||
if _valid_hhmm(quiet_hours_end):
|
||||
cfg.quiet_hours_end = quiet_hours_end
|
||||
if timezone in ALL_TIMEZONES:
|
||||
cfg.timezone = timezone
|
||||
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
||||
cfg.firmware_auto_update = firmware_auto_update
|
||||
cfg.stats.config_saves += 1
|
||||
config.save(cfg)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@app.get("/api/stats", dependencies=[Depends(require_access_token)])
|
||||
def api_stats():
|
||||
return config.load().stats.model_dump()
|
||||
|
||||
|
||||
def _require_configured(cfg: config.FrameConfig) -> None:
|
||||
if not cfg.immich_url or not cfg.immich_api_key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
if not cfg.album_id:
|
||||
raise HTTPException(400, "No album configured yet")
|
||||
|
||||
|
||||
def _list_assets(client: ImmichClient, cfg: config.FrameConfig) -> list[dict]:
|
||||
try:
|
||||
assets = client.list_album_assets(cfg.album_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
|
||||
if not assets:
|
||||
raise HTTPException(404, "Album has no photos")
|
||||
return assets
|
||||
|
||||
|
||||
def _render_asset(client: ImmichClient, cfg: config.FrameConfig, asset_id: str) -> bytes:
|
||||
try:
|
||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||
|
||||
faces = None
|
||||
if cfg.smart_crop_faces:
|
||||
try:
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
# A faces lookup hiccup shouldn't block showing a photo at
|
||||
# all -- just fall back to a plain center-crop this cycle.
|
||||
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
||||
|
||||
source = Image.open(io.BytesIO(jpeg_bytes))
|
||||
return render_frame(source, faces=faces, orientation=cfg.orientation)
|
||||
|
||||
|
||||
@app.get("/frame/image", dependencies=[Depends(require_access_token)])
|
||||
def frame_image():
|
||||
"""Returns the current photo. Idempotent: only actually advances to
|
||||
the next photo once refresh_interval_s has elapsed since the current
|
||||
one was set (see app/photo_queue.py) -- safe to call as often as the
|
||||
device wants, including after an unplanned reboot, without skipping
|
||||
ahead in the album."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)):
|
||||
config.save(cfg)
|
||||
|
||||
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
|
||||
|
||||
|
||||
@app.post("/frame/advance", dependencies=[Depends(require_access_token)])
|
||||
def frame_advance():
|
||||
"""Forces an immediate advance to the next photo, ignoring
|
||||
refresh_interval_s, and resets the interval clock from now. Used by
|
||||
the device's next-photo button."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
photo_queue.advance_forced(cfg, assets)
|
||||
config.save(cfg)
|
||||
|
||||
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
|
||||
|
||||
|
||||
@app.post("/frame/back", dependencies=[Depends(require_access_token)])
|
||||
def frame_back():
|
||||
"""Returns to the previously-current photo (the mirror image of
|
||||
/frame/advance -- see photo_queue.back_forced()), and resets the
|
||||
interval clock from now. A no-op (still 200, current photo
|
||||
unchanged) if there's no history to go back to -- same "always
|
||||
returns something displayable" contract as /frame/advance, rather
|
||||
than erroring. Used by the device's back-photo button."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
photo_queue.back_forced(cfg, assets)
|
||||
config.save(cfg)
|
||||
|
||||
return Response(content=_render_asset(client, cfg, cfg.current_asset_id), media_type="application/octet-stream")
|
||||
|
||||
|
||||
class BatteryReport(BaseModel):
|
||||
percent: int
|
||||
|
||||
|
||||
@app.post("/frame/battery", dependencies=[Depends(require_access_token)])
|
||||
def frame_battery(body: BatteryReport):
|
||||
"""Battery level reported by the device (only when running on battery
|
||||
-- it stays silent on mains, where the charging voltage would read
|
||||
misleadingly full). Stored with a timestamp plus a per-discharge-
|
||||
cycle history that feeds the Device panel's "on battery for" and
|
||||
"estimated remaining" numbers."""
|
||||
if not 0 <= body.percent <= 100:
|
||||
raise HTTPException(400, "percent must be 0-100")
|
||||
now = time.time()
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
cfg.stats.battery_reports += 1
|
||||
if cfg.battery_history and body.percent >= cfg.battery_history[-1][1] + RECHARGE_JUMP_PCT:
|
||||
# Percent jumped up meaningfully -- the battery was recharged
|
||||
# (or swapped). Start a fresh discharge cycle so runtime and
|
||||
# discharge-rate estimates never span a charge.
|
||||
cfg.battery_history = []
|
||||
cfg.stats.recharge_cycles += 1
|
||||
cfg.battery_history.append([now, body.percent])
|
||||
cfg.battery_history = cfg.battery_history[-BATTERY_HISTORY_MAX:]
|
||||
cfg.battery_log.append([now, body.percent])
|
||||
cfg.battery_log = cfg.battery_log[-BATTERY_LOG_MAX:]
|
||||
cfg.battery_percent = body.percent
|
||||
cfg.battery_as_of = now
|
||||
cfg.last_seen = now
|
||||
config.save(cfg)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@app.post("/api/firmware", dependencies=[Depends(require_access_token)])
|
||||
def api_firmware_upload(file: UploadFile = File(...)):
|
||||
"""Uploads a firmware image for OTA. The version is parsed out of the
|
||||
image itself (esp_app_desc_t) rather than trusted from a filename or
|
||||
form field, and the project name is checked so an unrelated .bin
|
||||
can't be pushed to the frame by mistake."""
|
||||
data = file.file.read()
|
||||
version = parse_app_version(data)
|
||||
firmware_path().write_bytes(data)
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
cfg.firmware_available_version = version
|
||||
config.save(cfg)
|
||||
return {"status": "saved", "version": version, "size": len(data)}
|
||||
|
||||
|
||||
@app.get("/frame/firmware", dependencies=[Depends(require_access_token)])
|
||||
def frame_firmware():
|
||||
"""The uploaded OTA image, streamed to the device (esp_https_ota).
|
||||
404 until something has been uploaded."""
|
||||
_touch_last_seen()
|
||||
path = firmware_path()
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "No firmware uploaded")
|
||||
return FileResponse(path, media_type="application/octet-stream")
|
||||
|
||||
|
||||
def _fetch_latest_release(cfg: config.FrameConfig) -> dict | None:
|
||||
try:
|
||||
return gitea_releases.fetch_latest_release(cfg.firmware_update_repo_url, cfg.firmware_update_token)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Gitea at {cfg.firmware_update_repo_url}: {e}") from e
|
||||
|
||||
|
||||
def _apply_gitea_update(cfg: config.FrameConfig) -> str:
|
||||
"""Downloads the configured Gitea repo's latest release asset for this
|
||||
frame's board variant and stages it exactly like a manual
|
||||
POST /api/firmware upload would. The board comes from the device
|
||||
itself (device_board_variant, learned from its X-Frame-Board header
|
||||
on GET /frame/config -- see frame_config()), not a user picker, so
|
||||
there's nothing to fetch until a device has checked in at least
|
||||
once. Network I/O happens before the lock is taken, matching the
|
||||
load/mutate/save concurrency pattern used elsewhere (see
|
||||
config.locked())."""
|
||||
if not cfg.device_board_variant:
|
||||
raise HTTPException(400, "No frame has checked in yet -- can't tell which board's build to fetch")
|
||||
release = _fetch_latest_release(cfg)
|
||||
if not release:
|
||||
raise HTTPException(404, "No releases found in the configured Gitea repo")
|
||||
asset_name = gitea_releases.asset_name_for_board(cfg.device_board_variant)
|
||||
asset_url = release["assets"].get(asset_name)
|
||||
if not asset_url:
|
||||
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
|
||||
try:
|
||||
data = gitea_releases.download_asset(asset_url, cfg.firmware_update_token)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
|
||||
version = parse_app_version(data) # same validation the manual upload path applies
|
||||
firmware_path().write_bytes(data)
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
cfg.firmware_available_version = version
|
||||
cfg.firmware_gitea_latest_version = version
|
||||
cfg.firmware_update_checked_at = time.time()
|
||||
config.save(cfg)
|
||||
return version
|
||||
|
||||
|
||||
@app.get("/api/firmware/check", dependencies=[Depends(require_access_token)])
|
||||
def api_firmware_check():
|
||||
"""Throttled check of the configured Gitea repo's latest release
|
||||
(gitea_releases.UPDATE_CHECK_INTERVAL_S) -- cheap, since it only reads
|
||||
the release's tag name, not its binaries. If firmware_auto_update is
|
||||
on and a newer version is found, applies it immediately; otherwise
|
||||
just reports it so the web UI can offer the "Update frame" button.
|
||||
Applying (auto or manual) needs to know the frame's board, which is
|
||||
learned from the device's own X-Frame-Board header rather than
|
||||
picked by the user -- update_available stays false until a device
|
||||
has checked in at least once, regardless of what Gitea has."""
|
||||
cfg = config.load()
|
||||
if not cfg.firmware_update_repo_url:
|
||||
return {"enabled": False}
|
||||
|
||||
now = time.time()
|
||||
if now - cfg.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
|
||||
# Deliberately not updated on failure (see below) -- checked_at only
|
||||
# advances on a successful reach, so a Gitea outage gets retried
|
||||
# every poll instead of waiting out the full throttle interval.
|
||||
release = _fetch_latest_release(cfg)
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
cfg.firmware_update_checked_at = now
|
||||
if release:
|
||||
cfg.firmware_gitea_latest_version = release["version"]
|
||||
config.save(cfg)
|
||||
cfg = config.load()
|
||||
|
||||
update_available = (
|
||||
bool(cfg.firmware_gitea_latest_version)
|
||||
and cfg.firmware_gitea_latest_version != cfg.firmware_available_version
|
||||
and bool(cfg.device_board_variant)
|
||||
)
|
||||
if update_available and cfg.firmware_auto_update:
|
||||
_apply_gitea_update(cfg)
|
||||
cfg = config.load()
|
||||
update_available = False
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"board": cfg.device_board_variant or None,
|
||||
"latest_version": cfg.firmware_gitea_latest_version or None,
|
||||
"staged_version": cfg.firmware_available_version or None,
|
||||
"update_available": update_available,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/firmware/apply-latest", dependencies=[Depends(require_access_token)])
|
||||
def api_firmware_apply_latest():
|
||||
"""The "Update frame" button: applies the latest Gitea release right
|
||||
now, bypassing the check throttle -- this is an explicit user action,
|
||||
not a background poll."""
|
||||
cfg = config.load()
|
||||
if not cfg.firmware_update_repo_url:
|
||||
raise HTTPException(400, "No Gitea firmware repo configured")
|
||||
version = _apply_gitea_update(cfg)
|
||||
return {"status": "saved", "version": version}
|
||||
|
||||
|
||||
def _battery_estimate_s(cfg: config.FrameConfig) -> int | None:
|
||||
"""Linear remaining-time estimate from the current discharge cycle's
|
||||
observed rate, or None when there's not enough signal to be honest
|
||||
about (too little time observed, or too little drop -- a flat line
|
||||
extrapolates to garbage)."""
|
||||
hist = cfg.battery_history
|
||||
if len(hist) < 2:
|
||||
return None
|
||||
first_ts, first_pct = hist[0]
|
||||
last_ts, last_pct = hist[-1]
|
||||
span = last_ts - first_ts
|
||||
drop = first_pct - last_pct
|
||||
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
|
||||
return None
|
||||
rate = drop / span # percent per second
|
||||
return int(last_pct / rate)
|
||||
|
||||
|
||||
LOCATION_LINE_MAX_LEN = 14
|
||||
|
||||
US_STATE_ABBR = {
|
||||
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
|
||||
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
|
||||
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
|
||||
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
|
||||
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
|
||||
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
|
||||
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
|
||||
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
|
||||
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
|
||||
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
|
||||
"district of columbia": "DC",
|
||||
}
|
||||
|
||||
CA_PROVINCE_ABBR = {
|
||||
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
|
||||
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
|
||||
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
|
||||
"saskatchewan": "SK", "yukon": "YT",
|
||||
}
|
||||
|
||||
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
|
||||
CA_COUNTRY_NAMES = {"canada"}
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int) -> str:
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def _format_location(exif: dict) -> tuple[str, str] | None:
|
||||
"""Returns (city_line, region_line), each independently truncated to
|
||||
fit its own corner-overlay line, or None if Immich hasn't geocoded
|
||||
this photo. region_line is the abbreviated state/province for US/CAN
|
||||
locations (e.g. "CA", "ON"), else the full country name."""
|
||||
city = exif.get("city")
|
||||
if not city:
|
||||
return None
|
||||
|
||||
state = exif.get("state")
|
||||
country = exif.get("country")
|
||||
country_key = (country or "").strip().lower()
|
||||
|
||||
if state and country_key in US_COUNTRY_NAMES:
|
||||
region = US_STATE_ABBR.get(state.strip().lower(), state)
|
||||
elif state and country_key in CA_COUNTRY_NAMES:
|
||||
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
|
||||
elif country:
|
||||
region = country
|
||||
elif state:
|
||||
region = state
|
||||
else:
|
||||
region = ""
|
||||
|
||||
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
|
||||
|
||||
|
||||
def _format_taken_at(exif: dict) -> str | None:
|
||||
raw = exif.get("dateTimeOriginal")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/frame/photo-info", dependencies=[Depends(require_access_token)])
|
||||
def frame_photo_info():
|
||||
"""Location/date-taken text for the manage-button overlay, plus the
|
||||
asset id used to build the share-QR's target URL. Read-only, same
|
||||
idempotent current-photo semantics as /frame/image -- doesn't advance
|
||||
anything."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)):
|
||||
config.save(cfg)
|
||||
|
||||
if not cfg.current_asset_id:
|
||||
raise HTTPException(404, "No current photo")
|
||||
|
||||
try:
|
||||
asset = client.get_asset(cfg.current_asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
||||
|
||||
exif = asset.get("exifInfo") or {}
|
||||
location = _format_location(exif)
|
||||
return {
|
||||
"asset_id": cfg.current_asset_id,
|
||||
"location_line1": location[0] if location else None,
|
||||
"location_line2": location[1] if location and location[1] else None,
|
||||
"taken_at": _format_taken_at(exif),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/frame/share/{asset_id}", dependencies=[Depends(require_access_token)])
|
||||
def frame_share(asset_id: str):
|
||||
"""Creates a 30-minute public Immich share link for asset_id and
|
||||
redirects to it -- what the manage overlay's bottom-left QR code
|
||||
points to (the firmware bakes ?token= into that QR the same way it
|
||||
does for the management QR, see frame_client.c's build_url()). The
|
||||
link is created lazily, when this actually gets hit (i.e. when
|
||||
someone scans it), not when the manage button was pressed, so the
|
||||
30-minute window starts when it's actually used. Also scoped to the
|
||||
photo currently showing or queued -- not any arbitrary Immich asset
|
||||
id -- as a second layer even a leaked token wouldn't bypass."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
if asset_id != cfg.current_asset_id and asset_id not in cfg.queue:
|
||||
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
|
||||
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
try:
|
||||
share_url = client.create_share_link(asset_id, expires_in_s=1800)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not create share link: {e}") from e
|
||||
|
||||
return RedirectResponse(share_url)
|
||||
|
||||
|
||||
@app.get("/frame/face-labels", dependencies=[Depends(require_access_token)])
|
||||
def frame_face_labels():
|
||||
"""Named-face positions for the manage button's escalated "level 2"
|
||||
menu -- who's in the current photo, per Immich's own face
|
||||
recognition (no detection/recognition happens here, see
|
||||
app/face_labels.py). Response is a flattened, fixed-slot shape
|
||||
(name_0/x_0/y_0, name_1/x_1/y_1, ...) rather than a JSON array, so
|
||||
the device's hand-rolled parser can read it with the same flat-
|
||||
scalar helpers it already has, instead of needing a real array
|
||||
parser. Empty (count: 0) if no faces are named, or if anything about
|
||||
fetching them fails -- this is a "nice to have" addition to the
|
||||
overlay, not worth failing the whole menu over."""
|
||||
_touch_last_seen()
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
if photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg)):
|
||||
config.save(cfg)
|
||||
|
||||
if not cfg.current_asset_id:
|
||||
return {"count": 0}
|
||||
|
||||
try:
|
||||
faces = client.get_asset_faces(cfg.current_asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch faces for asset %s: %s", cfg.current_asset_id, e)
|
||||
return {"count": 0}
|
||||
|
||||
if not any((face.get("person") or {}).get("name") for face in faces):
|
||||
return {"count": 0} # skip the extra preview download in the common no-named-faces case
|
||||
|
||||
try:
|
||||
preview_bytes = client.download_asset_preview(cfg.current_asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not download asset %s for face-label mapping: %s", cfg.current_asset_id, e)
|
||||
return {"count": 0}
|
||||
|
||||
labels = compute_face_labels(preview_bytes, faces, cfg.smart_crop_faces, cfg.orientation)
|
||||
|
||||
result: dict[str, object] = {"count": len(labels)}
|
||||
for i, label in enumerate(labels):
|
||||
result[f"name_{i}"] = label["name"]
|
||||
result[f"x_{i}"] = label["x"]
|
||||
result[f"y_{i}"] = label["y"]
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/queue", dependencies=[Depends(require_access_token)])
|
||||
def api_queue():
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
current_changed = photo_queue.get_current(cfg, assets, in_quiet_hours=_in_quiet_hours(cfg))
|
||||
queue_before = list(cfg.queue)
|
||||
photo_queue.sync_queue_length(cfg, assets)
|
||||
if current_changed or cfg.queue != queue_before:
|
||||
config.save(cfg)
|
||||
|
||||
def entry(asset_id: str) -> dict:
|
||||
return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"}
|
||||
|
||||
now = time.time()
|
||||
return {
|
||||
"current": entry(cfg.current_asset_id) if cfg.current_asset_id else None,
|
||||
"upcoming": [entry(asset_id) for asset_id in cfg.queue],
|
||||
"device": {
|
||||
"last_seen": cfg.last_seen or None,
|
||||
"overdue": bool(cfg.last_seen and now - cfg.last_seen > _max_expected_gap_s(cfg) * OVERDUE_FACTOR),
|
||||
"firmware_version": cfg.device_firmware_version or None,
|
||||
"firmware_available": cfg.firmware_available_version or None,
|
||||
"battery": (
|
||||
{"percent": cfg.battery_percent, "as_of": cfg.battery_as_of}
|
||||
if cfg.battery_percent >= 0
|
||||
else None
|
||||
),
|
||||
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
|
||||
"battery_estimate_s": _battery_estimate_s(cfg),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/battery-log", dependencies=[Depends(require_access_token)])
|
||||
def api_battery_log():
|
||||
cfg = config.load()
|
||||
return {"log": cfg.battery_log}
|
||||
|
||||
|
||||
class QueueReorderRequest(BaseModel):
|
||||
queue: list[str]
|
||||
|
||||
|
||||
@app.post("/api/queue/reorder", dependencies=[Depends(require_access_token)])
|
||||
def api_queue_reorder(body: QueueReorderRequest):
|
||||
"""Applies the client's requested order, tolerating drift between the
|
||||
browser's last-fetched snapshot and the server's current queue (e.g.
|
||||
a top-up/trim landed in between) instead of hard-rejecting: any ID
|
||||
the client sent that's no longer actually queued is dropped, and any
|
||||
ID the server has that the client didn't know about is appended
|
||||
rather than lost."""
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
current_set = set(cfg.queue)
|
||||
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
|
||||
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
|
||||
cfg.queue = reordered
|
||||
config.save(cfg)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
class QueuePromoteRequest(BaseModel):
|
||||
asset_id: str
|
||||
|
||||
|
||||
@app.post("/api/queue/promote", dependencies=[Depends(require_access_token)])
|
||||
def api_queue_promote(body: QueuePromoteRequest):
|
||||
"""Moves a single photo to the front of the queue -- "Show next" in
|
||||
the web UI. Unlike /api/queue/reorder, this doesn't depend on the
|
||||
client supplying a full, exactly-current snapshot of the queue at
|
||||
all, so it can't fail due to the queue having shifted server-side
|
||||
since the browser's last fetch."""
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
if body.asset_id not in cfg.queue:
|
||||
raise HTTPException(400, "That photo is no longer in the upcoming queue")
|
||||
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
|
||||
config.save(cfg)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
class QueueRemoveRequest(BaseModel):
|
||||
asset_id: str
|
||||
|
||||
|
||||
@app.post("/api/queue/remove", dependencies=[Depends(require_access_token)])
|
||||
def api_queue_remove(body: QueueRemoveRequest):
|
||||
"""Permanently removes a photo from this frame's rotation -- "Remove"
|
||||
in the web UI, on either an upcoming card or the current photo. Does
|
||||
NOT touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
assets = _list_assets(client, cfg)
|
||||
|
||||
with config.locked():
|
||||
cfg = config.load() # re-read: state may have changed since the unlocked read above
|
||||
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
|
||||
config.save(cfg)
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@app.get("/api/photo-thumbnail/{asset_id}", dependencies=[Depends(require_access_token)])
|
||||
def api_photo_thumbnail(asset_id: str):
|
||||
cfg = config.load()
|
||||
_require_configured(cfg)
|
||||
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||
try:
|
||||
content, content_type = client.download_asset_thumbnail(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
||||
return Response(content=content, media_type=content_type)
|
||||
"""Routing hub: manage-QR scans go to the limited manage page, users
|
||||
land on their first frame (or an empty-state page), and everyone
|
||||
else is walked through setup/login."""
|
||||
with SessionLocal() as db:
|
||||
have_users = users_exist(db)
|
||||
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users)
|
||||
if manage_redirect is not None:
|
||||
return RedirectResponse(manage_redirect, status_code=303)
|
||||
|
||||
user = current_user(request, db)
|
||||
if user is None:
|
||||
if not have_users:
|
||||
if management_token() and not browser_token_valid(request):
|
||||
supplied = request.query_params.get("token")
|
||||
return templates.TemplateResponse(
|
||||
"token_prompt.html", {"request": request, "wrong": supplied is not None}
|
||||
)
|
||||
# Pre-setup: reachable (optionally token-gated), nudge setup.
|
||||
return RedirectResponse("/setup", status_code=303)
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
frames = user_frames(db, user)
|
||||
if frames:
|
||||
return RedirectResponse(f"/frames/{frames[0].id}", status_code=303)
|
||||
return templates.TemplateResponse("frames_empty.html", shell_context(request, db, user))
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Schema versioning + one-time import of a legacy config.json deployment.
|
||||
|
||||
Hand-rolled on purpose (vs alembic): single worker, single SQLite file,
|
||||
~30 lines of runner. Each migration is (version, fn(connection)); v1 is
|
||||
just create_all. DDL stays dialect-neutral so a future move to Postgres
|
||||
is a DATABASE_URL change, not a rewrite.
|
||||
|
||||
Run at import time from main.py, before any request is served.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from . import config
|
||||
from .db import SessionLocal, engine
|
||||
from .models import Base, BatteryLog, Frame, ServerSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _migration_1(conn) -> None:
|
||||
Base.metadata.create_all(bind=conn)
|
||||
|
||||
|
||||
def _migration_2(conn) -> None:
|
||||
"""Adds email (users) and battery-alert threshold (frames) columns,
|
||||
plus the new server_settings/password_reset_tokens tables. ALTER
|
||||
TABLE ADD COLUMN with a default is safe on SQLite against a live,
|
||||
already-populated database -- existing rows just get the default."""
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_threshold_pct INTEGER NOT NULL DEFAULT -1"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN battery_alert_sent INTEGER NOT NULL DEFAULT 0"))
|
||||
Base.metadata.create_all(bind=conn) # creates the two new tables only; existing ones untouched
|
||||
|
||||
|
||||
def _migration_3(conn) -> None:
|
||||
"""Replaces the STARTTLS-or-nothing smtp_use_tls boolean with a
|
||||
three-way smtp_encryption ("none"/"starttls"/"ssl") -- implicit TLS
|
||||
(port 465 typically) is a different handshake entirely, not just a
|
||||
skipped starttls() call, so it needs its own connection path in
|
||||
app/mail.py."""
|
||||
conn.execute(text("ALTER TABLE server_settings ADD COLUMN smtp_encryption TEXT NOT NULL DEFAULT 'starttls'"))
|
||||
conn.execute(text(
|
||||
"UPDATE server_settings SET smtp_encryption = CASE WHEN smtp_use_tls THEN 'starttls' ELSE 'none' END"
|
||||
))
|
||||
conn.execute(text("ALTER TABLE server_settings DROP COLUMN smtp_use_tls"))
|
||||
|
||||
|
||||
def _migration_4(conn) -> None:
|
||||
"""Advanced configuration: a per-frame color palette override. NULL
|
||||
for every existing row -- exactly "use the default", no behavior
|
||||
change until a frame's Configuration tab sets one."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN palette_rgb TEXT"))
|
||||
|
||||
|
||||
def _migration_5(conn) -> None:
|
||||
"""Replaces the smart_crop_faces boolean with display_mode (see
|
||||
image_pipeline.DISPLAY_MODES) -- crop_faces/crop_fill are exactly
|
||||
the old True/False behavior, stretch_fill/letterbox are new."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN display_mode TEXT NOT NULL DEFAULT 'crop_faces'"))
|
||||
conn.execute(text(
|
||||
"UPDATE frames SET display_mode = CASE WHEN smart_crop_faces THEN 'crop_faces' ELSE 'crop_fill' END"
|
||||
))
|
||||
conn.execute(text("ALTER TABLE frames DROP COLUMN smart_crop_faces"))
|
||||
|
||||
|
||||
def _migration_6(conn) -> None:
|
||||
"""Advanced configuration: color/contrast enhancement + dithering
|
||||
strength (image_pipeline.render_frame). Defaults (1.0/1.0/1.0)
|
||||
reproduce the exact previous rendering -- no behavior change until a
|
||||
frame's Configuration tab adjusts one."""
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN color_boost REAL NOT NULL DEFAULT 1.0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN contrast_boost REAL NOT NULL DEFAULT 1.0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN dither_strength REAL NOT NULL DEFAULT 1.0"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
(3, _migration_3),
|
||||
(4, _migration_4),
|
||||
(5, _migration_5),
|
||||
(6, _migration_6),
|
||||
]
|
||||
|
||||
|
||||
def run_migrations() -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)"))
|
||||
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
|
||||
current = row[0] if row else 0
|
||||
for version, fn in MIGRATIONS:
|
||||
if version > current:
|
||||
logger.info("Applying schema migration %d", version)
|
||||
fn(conn)
|
||||
if row is None:
|
||||
conn.execute(
|
||||
text("INSERT INTO schema_version (version) VALUES (:v)"), {"v": version}
|
||||
)
|
||||
row = (version,)
|
||||
else:
|
||||
conn.execute(text("UPDATE schema_version SET version = :v"), {"v": version})
|
||||
_ensure_frame_one()
|
||||
_ensure_server_settings()
|
||||
|
||||
|
||||
def new_device_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def new_manage_token() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def _ensure_frame_one() -> None:
|
||||
"""First boot only (frames table empty): create frame #1 -- imported
|
||||
verbatim from a legacy config.json if one exists, otherwise fresh
|
||||
defaults. Either way it's the legacy-token frame: the deployed
|
||||
firmware sends no device id and (at most) the shared MANAGEMENT_TOKEN,
|
||||
and require_device resolves those requests here. The frames-nonempty
|
||||
guard makes this idempotent; config.json is left untouched as the
|
||||
rollback path."""
|
||||
with SessionLocal() as db:
|
||||
if db.scalars(select(Frame).limit(1)).first() is not None:
|
||||
return
|
||||
|
||||
cfg = config.load() # all defaults if the file doesn't exist
|
||||
had_file = config.CONFIG_PATH.exists()
|
||||
|
||||
frame = Frame(
|
||||
name="Frame 1",
|
||||
device_id=None,
|
||||
device_token=new_device_token(),
|
||||
manage_token=new_manage_token(),
|
||||
legacy_token_enabled=True,
|
||||
created_at=time.time(),
|
||||
immich_url=cfg.immich_url,
|
||||
immich_api_key=cfg.immich_api_key,
|
||||
album_id=cfg.album_id,
|
||||
order=cfg.order,
|
||||
refresh_interval_s=cfg.refresh_interval_s,
|
||||
quiet_hours_enabled=cfg.quiet_hours_enabled,
|
||||
quiet_hours_start=cfg.quiet_hours_start,
|
||||
quiet_hours_end=cfg.quiet_hours_end,
|
||||
timezone=cfg.timezone,
|
||||
display_mode="crop_faces" if cfg.smart_crop_faces else "crop_fill",
|
||||
orientation=cfg.orientation,
|
||||
queue_target_len=cfg.queue_target_len,
|
||||
current_asset_id=cfg.current_asset_id,
|
||||
current_asset_set_at=cfg.current_asset_set_at,
|
||||
queue=list(cfg.queue),
|
||||
queue_cursor=cfg.queue_cursor,
|
||||
history=list(cfg.history),
|
||||
excluded_asset_ids=list(cfg.excluded_asset_ids),
|
||||
battery_percent=cfg.battery_percent,
|
||||
battery_as_of=cfg.battery_as_of,
|
||||
battery_history=[list(pair) for pair in cfg.battery_history],
|
||||
last_seen=cfg.last_seen,
|
||||
device_firmware_version=cfg.device_firmware_version,
|
||||
device_board_variant=cfg.device_board_variant,
|
||||
firmware_available_version=cfg.firmware_available_version,
|
||||
firmware_update_repo_url=cfg.firmware_update_repo_url,
|
||||
firmware_auto_update=cfg.firmware_auto_update,
|
||||
firmware_update_token=cfg.firmware_update_token,
|
||||
firmware_update_checked_at=cfg.firmware_update_checked_at,
|
||||
firmware_gitea_latest_version=cfg.firmware_gitea_latest_version,
|
||||
stats_first_seen=cfg.stats.first_seen,
|
||||
stats_device_wakes=cfg.stats.device_wakes,
|
||||
stats_photos_displayed=cfg.stats.photos_displayed,
|
||||
stats_photos_removed=cfg.stats.photos_removed,
|
||||
stats_battery_reports=cfg.stats.battery_reports,
|
||||
stats_recharge_cycles=cfg.stats.recharge_cycles,
|
||||
stats_ota_updates_applied=cfg.stats.ota_updates_applied,
|
||||
stats_config_saves=cfg.stats.config_saves,
|
||||
)
|
||||
db.add(frame)
|
||||
db.flush() # assign frame.id for the battery log rows
|
||||
|
||||
for pair in cfg.battery_log:
|
||||
db.add(BatteryLog(frame_id=frame.id, ts=pair[0], percent=pair[1]))
|
||||
|
||||
db.commit()
|
||||
|
||||
# The single legacy firmware slot becomes frame #1's per-frame slot.
|
||||
legacy_bin = config.CONFIG_PATH.parent / "firmware.bin"
|
||||
if legacy_bin.exists():
|
||||
per_frame_dir = config.CONFIG_PATH.parent / "firmware"
|
||||
per_frame_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(legacy_bin, per_frame_dir / f"{frame.id}.bin")
|
||||
|
||||
if had_file:
|
||||
logger.info(
|
||||
"Imported legacy config.json as frame #%d (%d battery log entries)",
|
||||
frame.id,
|
||||
len(cfg.battery_log),
|
||||
)
|
||||
else:
|
||||
logger.info("Fresh install: created default frame #%d", frame.id)
|
||||
|
||||
|
||||
def _ensure_server_settings() -> None:
|
||||
"""The SMTP config singleton (id=1) -- created with everything blank
|
||||
(email sending disabled) the first time this runs; /admin edits it in
|
||||
place from then on."""
|
||||
with SessionLocal() as db:
|
||||
if db.get(ServerSettings, 1) is None:
|
||||
db.add(ServerSettings(id=1))
|
||||
db.commit()
|
||||
@@ -0,0 +1,267 @@
|
||||
"""SQLAlchemy models: users, sessions, frames, links, claims, battery log.
|
||||
|
||||
One deliberately WIDE `frames` row per frame (settings + state + telemetry
|
||||
+ stats together): every device request touches exactly one row, so the
|
||||
per-frame lock in db.frame_locked() keeps the old whole-config-lock
|
||||
semantics trivially correct, and SQLite doesn't care about row width.
|
||||
|
||||
The queue/history/excluded/battery_history columns are MutableList-mapped
|
||||
JSON: photo_queue.py mutates them in place (pop/append/insert), which a
|
||||
plain JSON column would silently not persist -- MutableList marks the row
|
||||
dirty on in-place changes.
|
||||
|
||||
The ORM attribute for the photo ordering setting is `order` (matching the
|
||||
old FrameConfig field name so photo_queue.py ports unchanged) but the
|
||||
column is named photo_order to stay clear of the SQL keyword.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.ext.mutable import MutableList
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# Normalized to lowercase in code before insert/lookup -- portable
|
||||
# case-insensitive uniqueness without SQLite-only COLLATE NOCASE.
|
||||
username: Mapped[str] = mapped_column(String, unique=True)
|
||||
display_name: Mapped[str] = mapped_column(String, default="")
|
||||
# Pluggable identity: "local" now; an OIDC provider later would set
|
||||
# provider_subject and leave password_hash NULL.
|
||||
identity_provider: Mapped[str] = mapped_column(String, default="local")
|
||||
provider_subject: Mapped[str] = mapped_column(String, default="")
|
||||
password_hash: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
immich_url: Mapped[str] = mapped_column(String, default="")
|
||||
immich_api_key: Mapped[str] = mapped_column(String, default="")
|
||||
# Password-reset emails and battery-threshold alerts (frames.owner's
|
||||
# email -- see routers/device.py's frame_battery) go here; blank = no
|
||||
# email configured, both features silently no-op for this user.
|
||||
email: Mapped[str] = mapped_column(String, default="")
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_users_provider_subject",
|
||||
"identity_provider",
|
||||
"provider_subject",
|
||||
unique=True,
|
||||
sqlite_where=provider_subject != "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
token_hash: Mapped[str] = mapped_column(String, unique=True) # sha256 hex of cookie value
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
csrf_token: Mapped[str] = mapped_column(String)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
expires_at: Mapped[float] = mapped_column(Float, index=True)
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
|
||||
|
||||
class Frame(Base):
|
||||
__tablename__ = "frames"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# 12 lowercase hex chars of the device's full STA MAC. NULL only for
|
||||
# the migrated legacy frame until its device first reports an id.
|
||||
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
||||
name: Mapped[str] = mapped_column(String, default="")
|
||||
# Renderer dispatch seam for future calendar/canva modes -- only
|
||||
# "photos" is registered today (see routers/device.py RENDERERS).
|
||||
mode: Mapped[str] = mapped_column(String, default="photos")
|
||||
# Whose Immich library this frame pulls from; NULL = unclaimed.
|
||||
owner_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# "Take control" soft lock -- only this user may mutate settings/queue.
|
||||
controlled_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
device_token: Mapped[str] = mapped_column(String)
|
||||
# Device has authenticated with device_token at least once -- stop
|
||||
# pushing it in /frame/config responses.
|
||||
device_token_ack: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
manage_token: Mapped[str] = mapped_column(String, unique=True)
|
||||
# Migration window: this frame also accepts the legacy shared
|
||||
# MANAGEMENT_TOKEN (and no-id requests resolve to it). Only ever the
|
||||
# migrated frame #1; cleared from /admin once the device is on
|
||||
# per-frame auth.
|
||||
legacy_token_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
claimed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
# Migration staging only: Immich creds imported from the legacy
|
||||
# config.json/env live here until /setup copies them to admin #1.
|
||||
# Runtime resolution prefers owner creds, then env, then these (see
|
||||
# routers/common.py immich_creds()).
|
||||
immich_url: Mapped[str] = mapped_column(String, default="")
|
||||
immich_api_key: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
# -- settings (attribute names match the old FrameConfig fields) --
|
||||
album_id: Mapped[str] = mapped_column(String, default="")
|
||||
order: Mapped[str] = mapped_column("photo_order", String, default="sequential")
|
||||
refresh_interval_s: Mapped[int] = mapped_column(Integer, default=3600)
|
||||
quiet_hours_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
quiet_hours_start: Mapped[str] = mapped_column(String, default="22:00")
|
||||
quiet_hours_end: Mapped[str] = mapped_column(String, default="07:00")
|
||||
timezone: Mapped[str] = mapped_column(String, default="UTC")
|
||||
# How a photo's aspect ratio is reconciled with the panel's -- see
|
||||
# image_pipeline.DISPLAY_MODES.
|
||||
display_mode: Mapped[str] = mapped_column(String, default="crop_faces")
|
||||
orientation: Mapped[str] = mapped_column(String, default="landscape")
|
||||
queue_target_len: Mapped[int] = mapped_column(Integer, default=20)
|
||||
# Advanced configuration: [[r,g,b], ...] x6 (black/white/yellow/red/
|
||||
# blue/green, matching image_pipeline.PANEL_CODES order) overriding
|
||||
# DEFAULT_PALETTE_RGB for this frame's actual panel. NULL = use the
|
||||
# default -- most frames never touch this.
|
||||
palette_rgb: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
# Advanced configuration: PIL ImageEnhance factors, 1.0 = unchanged
|
||||
# (see image_pipeline.render_frame).
|
||||
color_boost: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
contrast_boost: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
# 0.0-1.0, see image_pipeline._quantize -- 1.0 matches this project's
|
||||
# original always-on full-strength Floyd-Steinberg dithering.
|
||||
dither_strength: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
|
||||
# -- state --
|
||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
||||
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
queue: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
queue_cursor: Mapped[int] = mapped_column(Integer, default=0)
|
||||
history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
|
||||
# -- telemetry --
|
||||
battery_percent: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
battery_as_of: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# Current discharge cycle only (reset on recharge detection); the
|
||||
# permanent record is the battery_log table.
|
||||
battery_history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list)
|
||||
last_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
device_firmware_version: Mapped[str] = mapped_column(String, default="")
|
||||
device_board_variant: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
# Battery-low email alert (see routers/device.py's frame_battery).
|
||||
# -1 = disabled. Sent to the owner's email once per discharge cycle
|
||||
# (battery_alert_sent resets alongside battery_history whenever a
|
||||
# recharge is detected, same trigger as stats_recharge_cycles).
|
||||
battery_alert_threshold_pct: Mapped[int] = mapped_column(Integer, default=-1)
|
||||
battery_alert_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# -- firmware / OTA (per frame; image lives at /data/firmware/<id>.bin) --
|
||||
firmware_available_version: Mapped[str] = mapped_column(String, default="")
|
||||
firmware_update_repo_url: Mapped[str] = mapped_column(String, default="")
|
||||
firmware_auto_update: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
firmware_update_token: Mapped[str] = mapped_column(String, default="")
|
||||
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
# -- stats (flattened from the old nested FrameStats) --
|
||||
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
stats_photos_displayed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
stats_photos_removed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
stats_battery_reports: Mapped[int] = mapped_column(Integer, default=0)
|
||||
stats_recharge_cycles: Mapped[int] = mapped_column(Integer, default=0)
|
||||
stats_ota_updates_applied: Mapped[int] = mapped_column(Integer, default=0)
|
||||
stats_config_saves: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
owner: Mapped[User | None] = relationship(foreign_keys=[owner_user_id])
|
||||
controlled_by: Mapped[User | None] = relationship(foreign_keys=[controlled_by_user_id])
|
||||
|
||||
|
||||
class UserFrame(Base):
|
||||
"""A user linked to a frame: sees it in their sidebar, may view its
|
||||
pages, and may take control. Ownership (whose Immich creds the frame
|
||||
renders from) is frames.owner_user_id, separate from linking."""
|
||||
|
||||
__tablename__ = "user_frames"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
frame_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
|
||||
class PendingClaim(Base):
|
||||
"""A claim submitted before the frame's first check-in (the user beat
|
||||
the device to the server after provisioning). Attached automatically
|
||||
when a device with this id self-registers; expired rows are pruned
|
||||
opportunistically."""
|
||||
|
||||
__tablename__ = "pending_claims"
|
||||
|
||||
device_id: 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 ServerSettings(Base):
|
||||
"""Singleton row (id always 1) holding operator-level SMTP config, set
|
||||
from /admin -- not env vars, since this is infrastructure a household
|
||||
admin configures once through the UI rather than at container
|
||||
deploy time. Used for password-reset emails and battery-threshold
|
||||
alerts (see app/mail.py). smtp_host empty = email sending disabled;
|
||||
every send site checks that and no-ops rather than erroring."""
|
||||
|
||||
__tablename__ = "server_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
smtp_host: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
|
||||
smtp_username: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_password: Mapped[str] = mapped_column(String, default="")
|
||||
smtp_from_address: Mapped[str] = mapped_column(String, default="")
|
||||
# "none" (plaintext, port 25 typically), "starttls" (upgrades a
|
||||
# plaintext connection, port 587 typically), or "ssl" (TLS from the
|
||||
# first byte -- a different handshake entirely, not just starttls()
|
||||
# skipped; port 465 typically). See app/mail.py.
|
||||
smtp_encryption: Mapped[str] = mapped_column(String, default="starttls")
|
||||
|
||||
|
||||
class PasswordResetToken(Base):
|
||||
"""A single-use, time-limited "forgot password" link. token is the
|
||||
URL-safe secret itself (not hashed, like PendingClaim/manage_token --
|
||||
it's a short-lived bearer credential emailed once, not a long-lived
|
||||
session secret)."""
|
||||
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
token: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
expires_at: Mapped[float] = mapped_column(Float)
|
||||
|
||||
|
||||
class BatteryLog(Base):
|
||||
"""Every battery report ever, per frame -- the permanent record behind
|
||||
the battery history chart (was a 20k-entry JSON array in config.json)."""
|
||||
|
||||
__tablename__ = "battery_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||
ts: Mapped[float] = mapped_column(Float)
|
||||
percent: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
__table_args__ = (Index("ix_battery_log_frame_ts", "frame_id", "ts"),)
|
||||
+11
-11
@@ -34,12 +34,12 @@ from __future__ import annotations
|
||||
import random
|
||||
import time
|
||||
|
||||
from .config import FrameConfig
|
||||
from .models import Frame
|
||||
|
||||
HISTORY_MAX_LEN = 20
|
||||
|
||||
|
||||
def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
def _top_up(cfg: Frame, assets: list[dict]) -> None:
|
||||
valid_ids = {a["id"] for a in assets}
|
||||
excluded_ids = set(cfg.excluded_asset_ids)
|
||||
cfg.queue = [asset_id for asset_id in cfg.queue if asset_id in valid_ids and asset_id not in excluded_ids]
|
||||
@@ -85,7 +85,7 @@ def _top_up(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
cfg.queue_cursor = (cfg.queue_cursor + i + 1) % n
|
||||
|
||||
|
||||
def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
def advance_forced(cfg: Frame, assets: list[dict]) -> None:
|
||||
"""Unconditionally moves to the next photo, ignoring elapsed time, and
|
||||
resets the interval clock from now. Used by the explicit next-photo
|
||||
action (POST /frame/advance) and by get_current() once the refresh
|
||||
@@ -105,7 +105,7 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
# only asset is already current) -- keep showing what we have.
|
||||
cfg.current_asset_id = assets[0]["id"]
|
||||
cfg.current_asset_set_at = time.time()
|
||||
cfg.stats.photos_displayed += 1
|
||||
cfg.stats_photos_displayed += 1
|
||||
# Refill back up to queue_target_len now that current_asset_id has
|
||||
# changed -- otherwise the queue is left one short until the *next*
|
||||
# advance, since the pop above consumes one of the items _top_up just
|
||||
@@ -113,7 +113,7 @@ def advance_forced(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
_top_up(cfg, assets)
|
||||
|
||||
|
||||
def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
|
||||
def back_forced(cfg: Frame, assets: list[dict]) -> bool:
|
||||
"""Unconditionally moves to the previously-current photo, the mirror
|
||||
image of advance_forced() -- pops the most recent entry off history,
|
||||
pushes the photo it's replacing onto the front of queue (so pressing
|
||||
@@ -132,12 +132,12 @@ def back_forced(cfg: FrameConfig, assets: list[dict]) -> bool:
|
||||
cfg.queue.insert(0, cfg.current_asset_id)
|
||||
cfg.current_asset_id = previous_id
|
||||
cfg.current_asset_set_at = time.time()
|
||||
cfg.stats.photos_displayed += 1
|
||||
cfg.stats_photos_displayed += 1
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) -> bool:
|
||||
def remove_from_rotation(cfg: Frame, assets: list[dict], asset_id: str) -> bool:
|
||||
"""Permanently excludes asset_id from this frame's rotation (see the
|
||||
module docstring) -- doesn't touch Immich, just this frame's own
|
||||
selection. Scrubs it out of queue and history too, so it can't
|
||||
@@ -149,7 +149,7 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
|
||||
changed as a result."""
|
||||
if asset_id not in cfg.excluded_asset_ids:
|
||||
cfg.excluded_asset_ids.append(asset_id)
|
||||
cfg.stats.photos_removed += 1
|
||||
cfg.stats_photos_removed += 1
|
||||
cfg.queue = [a for a in cfg.queue if a != asset_id]
|
||||
cfg.history = [a for a in cfg.history if a != asset_id]
|
||||
|
||||
@@ -167,12 +167,12 @@ def remove_from_rotation(cfg: FrameConfig, assets: list[dict], asset_id: str) ->
|
||||
remaining = [a["id"] for a in assets if a["id"] not in excluded_ids]
|
||||
cfg.current_asset_id = remaining[0] if remaining else ""
|
||||
cfg.current_asset_set_at = time.time()
|
||||
cfg.stats.photos_displayed += 1
|
||||
cfg.stats_photos_displayed += 1
|
||||
_top_up(cfg, assets)
|
||||
return True
|
||||
|
||||
|
||||
def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
def sync_queue_length(cfg: Frame, assets: list[dict]) -> None:
|
||||
"""Tops up or trims cfg.queue to match cfg.queue_target_len without
|
||||
otherwise touching current_asset_id. Used by GET /api/queue so a
|
||||
change to the "upcoming photos to show" setting takes effect on page
|
||||
@@ -180,7 +180,7 @@ def sync_queue_length(cfg: FrameConfig, assets: list[dict]) -> None:
|
||||
_top_up(cfg, assets)
|
||||
|
||||
|
||||
def get_current(cfg: FrameConfig, assets: list[dict], in_quiet_hours: bool = False) -> bool:
|
||||
def get_current(cfg: Frame, assets: list[dict], in_quiet_hours: bool = False) -> bool:
|
||||
"""Time-based, idempotent path used by GET /frame/image. Advances only
|
||||
if the current photo is unset/invalid or refresh_interval_s has
|
||||
elapsed since it was set. Returns whether it changed anything, so the
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Quiet-hours math, extracted verbatim from the old main.py. Everything
|
||||
takes the frame-like object duck-typed on quiet_hours_enabled/start/end,
|
||||
timezone, and refresh_interval_s -- both the old FrameConfig and the
|
||||
Frame ORM model satisfy it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, available_timezones
|
||||
|
||||
# Populated once from the OS's zoneinfo database (installed via the
|
||||
# `tzdata` apt package in the Dockerfile) and offered as a <select> in the
|
||||
# web UI's "Timezone" field.
|
||||
ALL_TIMEZONES = sorted(available_timezones())
|
||||
|
||||
|
||||
def valid_hhmm(s: str) -> bool:
|
||||
try:
|
||||
datetime.strptime(s, "%H:%M")
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _zoneinfo(name: str) -> ZoneInfo:
|
||||
"""Falls back to UTC for an unrecognized zone name -- defensive only;
|
||||
the config-save route validates against ALL_TIMEZONES before saving,
|
||||
so this only matters for state hand-edited or written by an older
|
||||
version of this code."""
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]:
|
||||
"""Whether `now` falls inside the quiet-hours window, and the next
|
||||
boundary: if inside, when it ends; if outside, when it next starts.
|
||||
Handles a window that wraps past midnight (e.g. 22:00-07:00). Returns
|
||||
(False, None) for a degenerate window (start == end)."""
|
||||
sh, sm = (int(x) for x in start_str.split(":"))
|
||||
eh, em = (int(x) for x in end_str.split(":"))
|
||||
start = now.replace(hour=sh, minute=sm, second=0, microsecond=0)
|
||||
end = now.replace(hour=eh, minute=em, second=0, microsecond=0)
|
||||
|
||||
if start == end:
|
||||
return False, None
|
||||
|
||||
if start < end:
|
||||
# Same-day window, e.g. 13:00-15:00. End is exclusive, so being
|
||||
# exactly at `end` counts as already outside the window.
|
||||
if start <= now < end:
|
||||
return True, end
|
||||
if now < start:
|
||||
return False, start
|
||||
return False, start + timedelta(days=1)
|
||||
|
||||
# Wraps midnight, e.g. 22:00-07:00.
|
||||
if now >= start:
|
||||
return True, end + timedelta(days=1)
|
||||
if now < end:
|
||||
return True, end
|
||||
return False, start
|
||||
|
||||
|
||||
def _quiet_hours_span_s(start_str: str, end_str: str) -> int:
|
||||
"""Duration of the quiet-hours window in seconds, wrap-aware."""
|
||||
sh, sm = (int(x) for x in start_str.split(":"))
|
||||
eh, em = (int(x) for x in end_str.split(":"))
|
||||
span_min = ((eh * 60 + em) - (sh * 60 + sm)) % (24 * 60)
|
||||
return span_min * 60
|
||||
|
||||
|
||||
def effective_refresh_interval_s(cfg) -> int:
|
||||
"""The refresh interval actually handed to the device: its configured
|
||||
value, unless quiet hours are enabled, in which case it's clamped so
|
||||
the device sleeps through the whole window instead of waking inside
|
||||
it. A device already mid-sleep when quiet hours begin can still land
|
||||
one wake inside the window (nothing server-side can prevent that
|
||||
without touching the firmware) -- but from that wake on, it's told to
|
||||
sleep exactly until the window ends."""
|
||||
if not cfg.quiet_hours_enabled:
|
||||
return cfg.refresh_interval_s
|
||||
now = datetime.now(_zoneinfo(cfg.timezone))
|
||||
in_quiet, boundary = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
|
||||
if boundary is None:
|
||||
return cfg.refresh_interval_s
|
||||
seconds_to_boundary = max(1, int((boundary - now).total_seconds()))
|
||||
if in_quiet:
|
||||
return seconds_to_boundary
|
||||
return min(cfg.refresh_interval_s, seconds_to_boundary)
|
||||
|
||||
|
||||
def in_quiet_hours(cfg) -> bool:
|
||||
"""Whether quiet hours are in effect right now -- separate from
|
||||
effective_refresh_interval_s, which only shapes what the *device* is
|
||||
told to sleep for. This instead gates photo_queue.get_current()'s
|
||||
time-based advance, since that check runs independent of the device
|
||||
(also triggered by the web UI's queue endpoint, e.g. an open browser
|
||||
tab polling overnight) and would otherwise happily advance the
|
||||
current photo mid-quiet-hours on raw elapsed time alone."""
|
||||
if not cfg.quiet_hours_enabled:
|
||||
return False
|
||||
now = datetime.now(_zoneinfo(cfg.timezone))
|
||||
in_quiet, _ = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
|
||||
return in_quiet
|
||||
|
||||
|
||||
def max_expected_gap_s(cfg) -> int:
|
||||
"""Longest gap between wakes the device might legitimately have --
|
||||
normally just refresh_interval_s, but quiet hours can make the real
|
||||
gap much longer, and the "overdue" check shouldn't mistake a device
|
||||
quietly sleeping through the night for a dead one."""
|
||||
gap = cfg.refresh_interval_s
|
||||
if cfg.quiet_hours_enabled:
|
||||
gap = max(gap, _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end))
|
||||
return gap
|
||||
@@ -0,0 +1,491 @@
|
||||
"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/...
|
||||
|
||||
Auth: session-only (require_frame_view for reads, require_frame_control
|
||||
for mutations -- the "take control" soft lock). The limited manage-QR
|
||||
surface lives separately under /api/m/ (routers/manage.py), and device
|
||||
traffic under /frame/* (routers/device.py).
|
||||
|
||||
Config saves are PARTIAL updates: each page's form posts only its own
|
||||
fields (the old single Settings form split across the Photos and
|
||||
Configuration tabs), so every field is optional and only provided ones
|
||||
are touched. Checkboxes are sent explicitly as "true"/"false" strings by
|
||||
the page JS -- an absent field means "not this form's field", never
|
||||
"unchecked".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import gitea_releases, photo_queue, quiet_hours
|
||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db
|
||||
from ..image_pipeline import (
|
||||
DEFAULT_DISPLAY_MODE,
|
||||
DISPLAY_MODES,
|
||||
PALETTE_LABELS,
|
||||
hex_to_rgb,
|
||||
render_preview_png,
|
||||
)
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame
|
||||
from .common import (
|
||||
OVERDUE_FACTOR,
|
||||
battery_estimate_s,
|
||||
fetch_source_and_faces,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
require_configured,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MIN_REFRESH_INTERVAL_S = 60
|
||||
MAX_REFRESH_INTERVAL_S = 86400
|
||||
MIN_QUEUE_TARGET_LEN = 5
|
||||
MAX_QUEUE_TARGET_LEN = 5000
|
||||
|
||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/albums")
|
||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "The frame owner hasn't set up their Immich connection yet (Settings)")
|
||||
try:
|
||||
albums = immich_client_for(frame).list_albums()
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Immich at {url}: {e}") from e
|
||||
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/config")
|
||||
def api_config_save(
|
||||
name: str | None = Form(None),
|
||||
album_id: str | None = Form(None),
|
||||
order: str | None = Form(None),
|
||||
refresh_interval_s: int | None = Form(None),
|
||||
display_mode: str | None = Form(None),
|
||||
queue_target_len: int | None = Form(None),
|
||||
orientation: str | None = Form(None),
|
||||
quiet_hours_enabled: bool | None = Form(None),
|
||||
quiet_hours_start: str | None = Form(None),
|
||||
quiet_hours_end: str | None = Form(None),
|
||||
timezone: str | None = Form(None),
|
||||
firmware_update_repo_url: str | None = Form(None),
|
||||
firmware_auto_update: bool | None = Form(None),
|
||||
battery_alert_threshold_pct: int | None = Form(None),
|
||||
palette: list[str] | None = Form(None),
|
||||
palette_reset: bool | None = Form(None),
|
||||
color_boost: float | None = Form(None),
|
||||
contrast_boost: float | None = Form(None),
|
||||
dither_strength: float | None = Form(None),
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
if name is not None:
|
||||
cfg.name = name.strip()[:64] or cfg.name
|
||||
if album_id is not None and album_id != cfg.album_id:
|
||||
# A newly selected album starts clean -- the old current photo
|
||||
# and queue don't mean anything in the new album's context.
|
||||
cfg.current_asset_id = ""
|
||||
cfg.current_asset_set_at = 0.0
|
||||
cfg.queue = []
|
||||
cfg.queue_cursor = 0
|
||||
cfg.history = []
|
||||
cfg.excluded_asset_ids = []
|
||||
cfg.album_id = album_id
|
||||
if order is not None:
|
||||
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
||||
if refresh_interval_s is not None:
|
||||
cfg.refresh_interval_s = max(
|
||||
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
|
||||
)
|
||||
if display_mode is not None:
|
||||
cfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
|
||||
if queue_target_len is not None:
|
||||
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
||||
if orientation is not None:
|
||||
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
|
||||
if quiet_hours_enabled is not None:
|
||||
cfg.quiet_hours_enabled = quiet_hours_enabled
|
||||
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
|
||||
cfg.quiet_hours_start = quiet_hours_start
|
||||
if quiet_hours_end is not None and quiet_hours.valid_hhmm(quiet_hours_end):
|
||||
cfg.quiet_hours_end = quiet_hours_end
|
||||
if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
|
||||
cfg.timezone = timezone
|
||||
if firmware_update_repo_url is not None:
|
||||
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
||||
if firmware_auto_update is not None:
|
||||
cfg.firmware_auto_update = firmware_auto_update
|
||||
if battery_alert_threshold_pct is not None:
|
||||
cfg.battery_alert_threshold_pct = max(-1, min(100, battery_alert_threshold_pct))
|
||||
# A changed threshold should be able to fire again immediately,
|
||||
# not stay suppressed by a flag set under the old value.
|
||||
cfg.battery_alert_sent = False
|
||||
if palette_reset:
|
||||
cfg.palette_rgb = None
|
||||
elif palette is not None:
|
||||
if len(palette) != len(PALETTE_LABELS):
|
||||
raise HTTPException(400, f"Expected {len(PALETTE_LABELS)} palette colors, got {len(palette)}")
|
||||
parsed = [hex_to_rgb(h) for h in palette]
|
||||
if any(rgb is None for rgb in parsed):
|
||||
raise HTTPException(400, "Palette colors must be #rrggbb hex values")
|
||||
cfg.palette_rgb = [list(rgb) for rgb in parsed]
|
||||
if color_boost is not None:
|
||||
cfg.color_boost = max(0.0, min(2.0, color_boost))
|
||||
if contrast_boost is not None:
|
||||
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
||||
if dither_strength is not None:
|
||||
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/take-control")
|
||||
def api_take_control(
|
||||
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Always succeeds for any linked user -- the lock is deliberately
|
||||
soft. The previous holder just sees who has it now."""
|
||||
user = require_user_api(request, db)
|
||||
previous = frame.controlled_by
|
||||
frame.controlled_by_user_id = user.id
|
||||
db.commit()
|
||||
logger.info("User '%s' took control of frame #%d (from %s)", user.username, frame.id,
|
||||
previous.username if previous else "nobody")
|
||||
return {"status": "saved", "controller": user.display_name or user.username}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/stats")
|
||||
def api_stats(frame: Frame = Depends(require_frame_view)):
|
||||
return {
|
||||
"first_seen": frame.stats_first_seen,
|
||||
"device_wakes": frame.stats_device_wakes,
|
||||
"photos_displayed": frame.stats_photos_displayed,
|
||||
"photos_removed": frame.stats_photos_removed,
|
||||
"battery_reports": frame.stats_battery_reports,
|
||||
"recharge_cycles": frame.stats_recharge_cycles,
|
||||
"ota_updates_applied": frame.stats_ota_updates_applied,
|
||||
"config_saves": frame.stats_config_saves,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/queue")
|
||||
def api_queue(
|
||||
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
||||
):
|
||||
user = require_user_api(request, db)
|
||||
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))
|
||||
photo_queue.sync_queue_length(cfg, assets)
|
||||
snapshot = {
|
||||
"current_asset_id": cfg.current_asset_id,
|
||||
"queue": list(cfg.queue),
|
||||
"last_seen": cfg.last_seen,
|
||||
"overdue_gap": quiet_hours.max_expected_gap_s(cfg) * OVERDUE_FACTOR,
|
||||
"firmware_version": cfg.device_firmware_version,
|
||||
"firmware_available": cfg.firmware_available_version,
|
||||
"battery_percent": cfg.battery_percent,
|
||||
"battery_as_of": cfg.battery_as_of,
|
||||
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
|
||||
"battery_estimate_s": battery_estimate_s(cfg),
|
||||
"controller_id": cfg.controlled_by_user_id,
|
||||
"controller": (
|
||||
(cfg.controlled_by.display_name or cfg.controlled_by.username)
|
||||
if cfg.controlled_by
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def entry(asset_id: str) -> dict:
|
||||
return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"}
|
||||
|
||||
now = time.time()
|
||||
return {
|
||||
"current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None,
|
||||
"upcoming": [entry(asset_id) for asset_id in snapshot["queue"]],
|
||||
"control": {
|
||||
"controller": snapshot["controller"],
|
||||
"you": snapshot["controller_id"] == user.id,
|
||||
},
|
||||
"device": {
|
||||
"last_seen": snapshot["last_seen"] or None,
|
||||
"overdue": bool(
|
||||
snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
|
||||
),
|
||||
"firmware_version": snapshot["firmware_version"] or None,
|
||||
"firmware_available": snapshot["firmware_available"] or None,
|
||||
"battery": (
|
||||
{"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
|
||||
if snapshot["battery_percent"] >= 0
|
||||
else None
|
||||
),
|
||||
"on_battery_since": snapshot["on_battery_since"],
|
||||
"battery_estimate_s": snapshot["battery_estimate_s"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/battery-log")
|
||||
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
rows = db.execute(
|
||||
select(BatteryLog.ts, BatteryLog.percent)
|
||||
.where(BatteryLog.frame_id == frame.id)
|
||||
.order_by(BatteryLog.ts)
|
||||
).all()
|
||||
return {"log": [[ts, percent] for ts, percent in rows]}
|
||||
|
||||
|
||||
class QueueReorderRequest(BaseModel):
|
||||
queue: list[str]
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/queue/reorder")
|
||||
def api_queue_reorder(
|
||||
body: QueueReorderRequest,
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Applies the client's requested order, tolerating drift between the
|
||||
browser's last-fetched snapshot and the server's current queue (e.g.
|
||||
a top-up/trim landed in between) instead of hard-rejecting: any ID
|
||||
the client sent that's no longer actually queued is dropped, and any
|
||||
ID the server has that the client didn't know about is appended
|
||||
rather than lost."""
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
current_set = set(cfg.queue)
|
||||
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
|
||||
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
|
||||
cfg.queue = reordered
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
class QueuePromoteRequest(BaseModel):
|
||||
asset_id: str
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/queue/promote")
|
||||
def api_queue_promote(
|
||||
body: QueuePromoteRequest,
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Moves a single photo to the front of the queue -- "Show next".
|
||||
Unlike reorder, doesn't depend on the client knowing the queue's
|
||||
exact current order, so it can't fail from staleness."""
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
if body.asset_id not in cfg.queue:
|
||||
raise HTTPException(400, "That photo is no longer in the upcoming queue")
|
||||
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
class QueueRemoveRequest(BaseModel):
|
||||
asset_id: str
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/queue/remove")
|
||||
def api_queue_remove(
|
||||
body: QueueRemoveRequest,
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Permanently removes a photo from this frame's rotation. Does NOT
|
||||
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.remove_from_rotation(cfg, assets, body.asset_id)
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
|
||||
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
content, content_type = client.download_asset_thumbnail(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
|
||||
def _current_asset_id(frame: Frame, db: Session) -> str:
|
||||
"""Same idempotent get_current() dance /api/frames/{id}/queue uses --
|
||||
picks a current photo if none is set yet, otherwise just reads it,
|
||||
never advances early."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.get_current(cfg, assets, in_quiet_hours=quiet_hours.in_quiet_hours(cfg))
|
||||
asset_id = cfg.current_asset_id
|
||||
if not asset_id:
|
||||
raise HTTPException(404, "No current photo")
|
||||
return asset_id
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview/original")
|
||||
def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""The Immich preview image behind the currently-displayed photo,
|
||||
unprocessed -- the "now displaying" side of the Configuration tab's
|
||||
before/after comparison."""
|
||||
asset_id = _current_asset_id(frame, db)
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview/rendered")
|
||||
def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""The same photo run through this frame's actual saved rendering
|
||||
pipeline (display mode, palette, color/contrast/dithering) and
|
||||
exported as a PNG -- the "how it will look on the frame" side of the
|
||||
comparison. Not a live preview of unsaved slider values; reflects
|
||||
whatever's currently saved."""
|
||||
asset_id = _current_asset_id(frame, db)
|
||||
client = immich_client_for(frame)
|
||||
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
||||
png = render_preview_png(
|
||||
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
display_mode=frame.display_mode, color_boost=frame.color_boost,
|
||||
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/firmware")
|
||||
def api_firmware_upload(
|
||||
file: UploadFile = File(...),
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Uploads a firmware image for OTA. The version is parsed out of the
|
||||
image itself (esp_app_desc_t) rather than trusted from a filename or
|
||||
form field, and the project name is checked so an unrelated .bin
|
||||
can't be pushed to the frame by mistake."""
|
||||
data = file.file.read()
|
||||
version = parse_app_version(data)
|
||||
path = firmware_path(frame.id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
cfg.firmware_available_version = version
|
||||
return {"status": "saved", "version": version, "size": len(data)}
|
||||
|
||||
|
||||
def _fetch_latest_release(frame: Frame) -> dict | None:
|
||||
try:
|
||||
return gitea_releases.fetch_latest_release(
|
||||
frame.firmware_update_repo_url, frame.firmware_update_token
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Gitea at {frame.firmware_update_repo_url}: {e}") from e
|
||||
|
||||
|
||||
def _apply_gitea_update(db: Session, frame: Frame) -> str:
|
||||
"""Downloads the configured Gitea repo's latest release asset for this
|
||||
frame's board variant (learned from the device's X-Frame-Board
|
||||
header, never picked by hand) and stages it exactly like a manual
|
||||
upload. Network I/O happens before the lock is taken."""
|
||||
if not frame.device_board_variant:
|
||||
raise HTTPException(400, "The frame hasn't checked in yet -- can't tell which board's build to fetch")
|
||||
release = _fetch_latest_release(frame)
|
||||
if not release:
|
||||
raise HTTPException(404, "No releases found in the configured Gitea repo")
|
||||
asset_name = gitea_releases.asset_name_for_board(frame.device_board_variant)
|
||||
asset_url = release["assets"].get(asset_name)
|
||||
if not asset_url:
|
||||
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
|
||||
try:
|
||||
data = gitea_releases.download_asset(asset_url, frame.firmware_update_token)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download '{asset_name}' from Gitea: {e}") from e
|
||||
version = parse_app_version(data) # same validation the manual upload path applies
|
||||
path = firmware_path(frame.id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
cfg.firmware_available_version = version
|
||||
cfg.firmware_gitea_latest_version = version
|
||||
cfg.firmware_update_checked_at = time.time()
|
||||
return version
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/firmware/check")
|
||||
def api_firmware_check(
|
||||
force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Throttled check of the configured Gitea repo's latest release
|
||||
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
|
||||
on and a newer version is found, applies it immediately; otherwise
|
||||
just reports it so the UI can offer the "Update frame" button.
|
||||
force=true (the "Check now" button) bypasses the throttle."""
|
||||
if not frame.firmware_update_repo_url:
|
||||
return {"enabled": False}
|
||||
|
||||
now = time.time()
|
||||
if force or now - frame.firmware_update_checked_at >= gitea_releases.UPDATE_CHECK_INTERVAL_S:
|
||||
# checked_at only advances on a successful reach, so a Gitea
|
||||
# outage gets retried every poll instead of waiting out the full
|
||||
# throttle interval.
|
||||
release = _fetch_latest_release(frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
cfg.firmware_update_checked_at = now
|
||||
if release:
|
||||
cfg.firmware_gitea_latest_version = release["version"]
|
||||
|
||||
update_available = (
|
||||
bool(frame.firmware_gitea_latest_version)
|
||||
and frame.firmware_gitea_latest_version != frame.firmware_available_version
|
||||
and bool(frame.device_board_variant)
|
||||
)
|
||||
if update_available and frame.firmware_auto_update:
|
||||
_apply_gitea_update(db, frame)
|
||||
update_available = False
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"board": frame.device_board_variant or None,
|
||||
"latest_version": frame.firmware_gitea_latest_version or None,
|
||||
"staged_version": frame.firmware_available_version or None,
|
||||
"update_available": update_available,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/firmware/apply-latest")
|
||||
def api_firmware_apply_latest(
|
||||
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
||||
):
|
||||
"""The "Update frame" button: applies the latest Gitea release right
|
||||
now, bypassing the check throttle -- an explicit user action, not a
|
||||
background poll."""
|
||||
if not frame.firmware_update_repo_url:
|
||||
raise HTTPException(400, "No Gitea firmware repo configured")
|
||||
version = _apply_gitea_update(db, frame)
|
||||
return {"status": "saved", "version": version}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Helpers shared by the device and browser routers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..image_pipeline import render_frame
|
||||
from ..immich_client import ImmichClient
|
||||
from ..models import Frame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
|
||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
||||
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- cap on the battery_log table per frame
|
||||
RECHARGE_JUMP_PCT = 5 # a report this much above the previous one = battery was recharged
|
||||
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
|
||||
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
|
||||
|
||||
# "Overdue" threshold multiplier: the device should check in roughly every
|
||||
# refresh_interval_s; give it half again as long before flagging it.
|
||||
OVERDUE_FACTOR = 1.5
|
||||
|
||||
|
||||
def immich_creds(frame: Frame) -> tuple[str, str]:
|
||||
"""Which Immich this frame renders from. Owner's creds once the frame
|
||||
is claimed (Phase B+); env vars as the operator-level fallback (the
|
||||
pre-redesign source of truth); the frame's own staging columns last
|
||||
(populated by the config.json migration for exactly the case where
|
||||
the old file held creds but the env no longer does)."""
|
||||
owner = frame.owner
|
||||
if owner is not None and owner.immich_url and owner.immich_api_key:
|
||||
return owner.immich_url, owner.immich_api_key
|
||||
env_url = os.environ.get("IMMICH_URL", "")
|
||||
env_key = os.environ.get("IMMICH_API_KEY", "")
|
||||
if env_url and env_key:
|
||||
return env_url, env_key
|
||||
return frame.immich_url, frame.immich_api_key
|
||||
|
||||
|
||||
def immich_client_for(frame: Frame) -> ImmichClient:
|
||||
url, key = immich_creds(frame)
|
||||
return ImmichClient(url, key)
|
||||
|
||||
|
||||
def require_configured(frame: Frame) -> None:
|
||||
url, key = immich_creds(frame)
|
||||
if not url or not key:
|
||||
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||
if not frame.album_id:
|
||||
raise HTTPException(400, "No album configured yet")
|
||||
|
||||
|
||||
def list_assets(client: ImmichClient, frame: Frame) -> list[dict]:
|
||||
try:
|
||||
assets = client.list_album_assets(frame.album_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
||||
if not assets:
|
||||
raise HTTPException(404, "Album has no photos")
|
||||
return assets
|
||||
|
||||
|
||||
def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) -> tuple[Image.Image, list[dict] | None]:
|
||||
"""The shared first half of rendering: download the Immich preview
|
||||
and (only if display_mode needs it) its detected faces. Used by both
|
||||
render_asset (device-facing) and the web UI's rendered-preview
|
||||
endpoint (routers/api_frames.py) so they can't drift apart."""
|
||||
try:
|
||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||
|
||||
faces = None
|
||||
if frame.display_mode == "crop_faces":
|
||||
try:
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
# A faces lookup hiccup shouldn't block showing a photo at
|
||||
# all -- just fall back to a plain center-crop this cycle.
|
||||
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
||||
|
||||
return Image.open(io.BytesIO(jpeg_bytes)), faces
|
||||
|
||||
|
||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
|
||||
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
||||
return render_frame(source, faces=faces, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||
dither_strength=frame.dither_strength)
|
||||
|
||||
|
||||
def battery_estimate_s(frame: Frame) -> int | None:
|
||||
"""Linear remaining-time estimate from the current discharge cycle's
|
||||
observed rate, or None when there's not enough signal to be honest
|
||||
about (too little time observed, or too little drop -- a flat line
|
||||
extrapolates to garbage)."""
|
||||
hist = frame.battery_history
|
||||
if len(hist) < 2:
|
||||
return None
|
||||
first_ts, first_pct = hist[0]
|
||||
last_ts, last_pct = hist[-1]
|
||||
span = last_ts - first_ts
|
||||
drop = first_pct - last_pct
|
||||
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
|
||||
return None
|
||||
rate = drop / span # percent per second
|
||||
return int(last_pct / rate)
|
||||
|
||||
|
||||
def shell_context(request, db: Session, user, active_frame: Frame | None = None,
|
||||
active_nav: str | None = None) -> dict:
|
||||
"""Template context every app-shell (sidebar) page needs: the user's
|
||||
frame list with an online indicator, the active highlights, and the
|
||||
session's CSRF token. Import here (not auth) keeps the router
|
||||
modules' template plumbing in one place."""
|
||||
import time as _time
|
||||
|
||||
from .. import quiet_hours
|
||||
from ..auth import current_session, user_frames
|
||||
|
||||
session = current_session(request, db)
|
||||
frames = user_frames(db, user)
|
||||
now = _time.time()
|
||||
for f in frames:
|
||||
# Same "not overdue" definition the Device panel uses.
|
||||
gap = quiet_hours.max_expected_gap_s(f) * OVERDUE_FACTOR
|
||||
f.recently_seen = bool(f.last_seen and now - f.last_seen <= gap)
|
||||
return {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"csrf_token": session.csrf_token if session else None,
|
||||
"sidebar_frames": frames,
|
||||
"active_frame": active_frame,
|
||||
"active_nav": active_nav,
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Device-facing /frame/* routes. These paths are FROZEN -- they're baked
|
||||
into deployed firmware -- so multi-frame support changes only how the
|
||||
calling frame is resolved (see auth.require_device), never the paths or
|
||||
response key names the deployed flat parser depends on
|
||||
("refresh_interval_s", "firmware_version")."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import mail, photo_queue, quiet_hours
|
||||
from ..auth import get_server_settings, require_device
|
||||
from ..db import frame_locked, get_db
|
||||
from ..face_labels import compute_face_labels
|
||||
from ..firmware import firmware_path
|
||||
from ..image_pipeline import render_placeholder
|
||||
from ..models import BatteryLog, Frame
|
||||
from .common import (
|
||||
BATTERY_HISTORY_MAX,
|
||||
BATTERY_LOG_MAX,
|
||||
RECHARGE_JUMP_PCT,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
render_asset,
|
||||
require_configured,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _setup_placeholder(frame: Frame, request: Request) -> bytes:
|
||||
"""What an unclaimed or not-yet-configured frame displays instead of a
|
||||
photo -- instructions with a QR, rendered at 200 so the device treats
|
||||
it as a perfectly normal image and never error-loops. The URLs are
|
||||
built from the request's own base URL: whatever address the device
|
||||
reached us at is by definition an address that works on this
|
||||
network."""
|
||||
base = str(request.base_url).rstrip("/")
|
||||
if frame.owner_user_id is None and frame.device_id:
|
||||
claim_url = f"{base}/claim?device_id={frame.device_id}"
|
||||
return render_placeholder(
|
||||
["This frame isn't claimed yet", "Scan to link it to your account:"],
|
||||
qr_url=claim_url,
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
)
|
||||
if frame.owner_user_id is None:
|
||||
return render_placeholder(
|
||||
["Almost there!", f"Open {base} to finish setting up this frame."],
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
)
|
||||
return render_placeholder(
|
||||
["Almost there!", "Pick an album for this frame:", base],
|
||||
qr_url=base,
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
)
|
||||
|
||||
|
||||
def _frame_configured(frame: Frame) -> bool:
|
||||
url, key = immich_creds(frame)
|
||||
return bool(url and key and frame.album_id)
|
||||
|
||||
|
||||
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
|
||||
# /frame/image looks up the frame's mode here. Only photos exists today.
|
||||
def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
|
||||
if not _frame_configured(frame):
|
||||
return _setup_placeholder(frame, request)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return render_asset(client, frame, asset_id)
|
||||
|
||||
|
||||
RENDERERS = {
|
||||
"photos": _render_photos_mode,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/frame/config")
|
||||
def frame_config(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Device-facing settings, polled by the frame alongside its
|
||||
reachability check. Always returns 200 with current settings -- no
|
||||
Immich-configured gate, since this doubles as the "is the server up"
|
||||
signal. Also captures the device's running firmware version and board
|
||||
variant (X-Frame-Version/X-Frame-Board headers) and advertises the
|
||||
available OTA image's version, so the device's update check costs
|
||||
zero extra round trips."""
|
||||
reported_version = request.headers.get("X-Frame-Version", "")
|
||||
reported_board = request.headers.get("X-Frame-Board", "")
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
if locked.stats_first_seen == 0:
|
||||
locked.stats_first_seen = time.time()
|
||||
locked.stats_device_wakes += 1
|
||||
if reported_version:
|
||||
if locked.device_firmware_version and reported_version != locked.device_firmware_version:
|
||||
locked.stats_ota_updates_applied += 1
|
||||
locked.device_firmware_version = reported_version
|
||||
if reported_board:
|
||||
locked.device_board_variant = reported_board
|
||||
|
||||
response = {
|
||||
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
|
||||
"firmware_version": locked.firmware_available_version or None,
|
||||
}
|
||||
# Per-frame token push: only once the device has introduced itself
|
||||
# by id (so the response to pure-legacy firmware stays byte-
|
||||
# compatible with its 256-byte parse buffer), and only until the
|
||||
# device has authenticated with the token once (device_token_ack).
|
||||
if locked.device_id is not None and not locked.device_token_ack:
|
||||
response["device_token"] = locked.device_token
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/frame/image")
|
||||
def frame_image(
|
||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Returns the frame's current image. For photos mode: idempotent --
|
||||
only actually advances to the next photo once refresh_interval_s has
|
||||
elapsed since the current one was set (see app/photo_queue.py) --
|
||||
safe to call as often as the device wants, including after an
|
||||
unplanned reboot, without skipping ahead in the album. An unclaimed/
|
||||
unconfigured frame gets a rendered instruction placeholder (200, not
|
||||
an error) so a fresh device never error-loops."""
|
||||
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
|
||||
return Response(content=renderer(db, frame, request), media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/advance")
|
||||
def frame_advance(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Forces an immediate advance to the next photo, ignoring
|
||||
refresh_interval_s, and resets the interval clock from now. Used by
|
||||
the device's next-photo button."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.advance_forced(locked, assets)
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/back")
|
||||
def frame_back(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Returns to the previously-current photo (the mirror image of
|
||||
/frame/advance -- see photo_queue.back_forced()), and resets the
|
||||
interval clock from now. A no-op (still 200, current photo
|
||||
unchanged) if there's no history to go back to -- same "always
|
||||
returns something displayable" contract as /frame/advance, rather
|
||||
than erroring. Used by the device's back-photo button."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.back_forced(locked, assets)
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
|
||||
|
||||
|
||||
class BatteryReport(BaseModel):
|
||||
percent: int
|
||||
|
||||
|
||||
@router.post("/frame/battery")
|
||||
def frame_battery(
|
||||
body: BatteryReport, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Battery level reported by the device (only when running on battery
|
||||
-- it stays silent on mains, where the charging voltage would read
|
||||
misleadingly full). Stored with a timestamp plus a per-discharge-
|
||||
cycle history that feeds the Device panel's "on battery for" and
|
||||
"estimated remaining" numbers; every report also lands in the
|
||||
permanent battery_log table behind the history chart."""
|
||||
if not 0 <= body.percent <= 100:
|
||||
raise HTTPException(400, "percent must be 0-100")
|
||||
now = time.time()
|
||||
should_alert = False
|
||||
alert_email = ""
|
||||
alert_frame_name = ""
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.stats_battery_reports += 1
|
||||
if locked.battery_history and body.percent >= locked.battery_history[-1][1] + RECHARGE_JUMP_PCT:
|
||||
# Percent jumped up meaningfully -- the battery was recharged
|
||||
# (or swapped). Start a fresh discharge cycle so runtime and
|
||||
# discharge-rate estimates never span a charge -- and let a
|
||||
# low-battery alert fire again next time it actually gets low.
|
||||
locked.battery_history = []
|
||||
locked.stats_recharge_cycles += 1
|
||||
locked.battery_alert_sent = False
|
||||
locked.battery_history.append([now, body.percent])
|
||||
locked.battery_history = locked.battery_history[-BATTERY_HISTORY_MAX:]
|
||||
locked.battery_percent = body.percent
|
||||
locked.battery_as_of = now
|
||||
|
||||
db.add(BatteryLog(frame_id=locked.id, ts=now, percent=body.percent))
|
||||
# Safety bound, not a real limit at realistic report rates --
|
||||
# mirrors the old JSON list's cap.
|
||||
count = db.scalar(select(func.count()).select_from(BatteryLog).where(BatteryLog.frame_id == locked.id))
|
||||
if count is not None and count >= BATTERY_LOG_MAX:
|
||||
cutoff_ids = select(BatteryLog.id).where(BatteryLog.frame_id == locked.id).order_by(
|
||||
BatteryLog.ts
|
||||
).limit(count + 1 - BATTERY_LOG_MAX)
|
||||
db.execute(delete(BatteryLog).where(BatteryLog.id.in_(cutoff_ids)))
|
||||
|
||||
# Once per discharge cycle (see the recharge reset above), not
|
||||
# once per report -- a frame idling at 4% would otherwise get an
|
||||
# email every wake.
|
||||
if (
|
||||
locked.battery_alert_threshold_pct >= 0
|
||||
and body.percent <= locked.battery_alert_threshold_pct
|
||||
and not locked.battery_alert_sent
|
||||
and locked.owner is not None
|
||||
and locked.owner.email
|
||||
):
|
||||
should_alert = True
|
||||
alert_email = locked.owner.email
|
||||
alert_frame_name = locked.name or f"Frame {locked.id}"
|
||||
|
||||
if should_alert:
|
||||
# Network I/O outside the lock, same convention as everywhere
|
||||
# else in this file -- then a short re-lock to record that it
|
||||
# went out, only on actual success (an SMTP hiccup should let
|
||||
# the next report's still-below-threshold reading try again
|
||||
# rather than silently giving up for the rest of the cycle).
|
||||
settings = get_server_settings(db)
|
||||
sent = mail.send_email(
|
||||
settings, alert_email, f"{alert_frame_name}: battery low",
|
||||
f"{alert_frame_name}'s battery is at {body.percent}%.",
|
||||
)
|
||||
if sent:
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.battery_alert_sent = True
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/frame/firmware")
|
||||
def frame_firmware(frame: Frame = Depends(require_device)):
|
||||
"""The frame's staged OTA image, streamed to the device
|
||||
(esp_https_ota). 404 until something has been uploaded/fetched."""
|
||||
path = firmware_path(frame.id)
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "No firmware uploaded")
|
||||
return FileResponse(path, media_type="application/octet-stream")
|
||||
|
||||
|
||||
LOCATION_LINE_MAX_LEN = 14
|
||||
|
||||
US_STATE_ABBR = {
|
||||
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
|
||||
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
|
||||
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
|
||||
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
|
||||
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
|
||||
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
|
||||
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
|
||||
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
|
||||
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
|
||||
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
|
||||
"district of columbia": "DC",
|
||||
}
|
||||
|
||||
CA_PROVINCE_ABBR = {
|
||||
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
|
||||
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
|
||||
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
|
||||
"saskatchewan": "SK", "yukon": "YT",
|
||||
}
|
||||
|
||||
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
|
||||
CA_COUNTRY_NAMES = {"canada"}
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int) -> str:
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def _format_location(exif: dict) -> tuple[str, str] | None:
|
||||
"""Returns (city_line, region_line), each independently truncated to
|
||||
fit its own corner-overlay line, or None if Immich hasn't geocoded
|
||||
this photo. region_line is the abbreviated state/province for US/CAN
|
||||
locations (e.g. "CA", "ON"), else the full country name."""
|
||||
city = exif.get("city")
|
||||
if not city:
|
||||
return None
|
||||
|
||||
state = exif.get("state")
|
||||
country = exif.get("country")
|
||||
country_key = (country or "").strip().lower()
|
||||
|
||||
if state and country_key in US_COUNTRY_NAMES:
|
||||
region = US_STATE_ABBR.get(state.strip().lower(), state)
|
||||
elif state and country_key in CA_COUNTRY_NAMES:
|
||||
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
|
||||
elif country:
|
||||
region = country
|
||||
elif state:
|
||||
region = state
|
||||
else:
|
||||
region = ""
|
||||
|
||||
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
|
||||
|
||||
|
||||
def _format_taken_at(exif: dict) -> str | None:
|
||||
raw = exif.get("dateTimeOriginal")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/frame/photo-info")
|
||||
def frame_photo_info(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Location/date-taken text for the manage-button overlay, plus the
|
||||
asset id used to build the share-QR's target URL. Read-only, same
|
||||
idempotent current-photo semantics as /frame/image -- doesn't advance
|
||||
anything."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
if not asset_id:
|
||||
raise HTTPException(404, "No current photo")
|
||||
|
||||
try:
|
||||
asset = client.get_asset(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
||||
|
||||
exif = asset.get("exifInfo") or {}
|
||||
location = _format_location(exif)
|
||||
return {
|
||||
"asset_id": asset_id,
|
||||
"location_line1": location[0] if location else None,
|
||||
"location_line2": location[1] if location and location[1] else None,
|
||||
"taken_at": _format_taken_at(exif),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/frame/share/{asset_id}")
|
||||
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
||||
"""Creates a 30-minute public Immich share link for asset_id and
|
||||
redirects to it -- what the manage overlay's bottom-left QR code
|
||||
points to. The link is created lazily, when this actually gets hit
|
||||
(i.e. when someone scans it), not when the manage button was
|
||||
pressed, so the 30-minute window starts when it's actually used.
|
||||
Also scoped to the photo currently showing or queued on THIS frame --
|
||||
not any arbitrary Immich asset id -- as a second layer even a leaked
|
||||
token wouldn't bypass."""
|
||||
require_configured(frame)
|
||||
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||
raise HTTPException(404, "That photo isn't currently showing or queued on this frame")
|
||||
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
share_url = client.create_share_link(asset_id, expires_in_s=1800)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not create share link: {e}") from e
|
||||
|
||||
return RedirectResponse(share_url)
|
||||
|
||||
|
||||
@router.get("/frame/face-labels")
|
||||
def frame_face_labels(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Named-face positions for the manage button's escalated "level 2"
|
||||
menu -- who's in the current photo, per Immich's own face
|
||||
recognition (no detection/recognition happens here, see
|
||||
app/face_labels.py). Response is a flattened, fixed-slot shape
|
||||
(name_0/x_0/y_0, ...) rather than a JSON array, so the device's
|
||||
hand-rolled parser can read it with the same flat-scalar helpers it
|
||||
already has. Empty (count: 0) if no faces are named, or if anything
|
||||
about fetching them fails -- this is a "nice to have" addition to
|
||||
the overlay, not worth failing the whole menu over."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
display_mode = locked.display_mode
|
||||
orientation = locked.orientation
|
||||
|
||||
if not asset_id:
|
||||
return {"count": 0}
|
||||
|
||||
try:
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
||||
return {"count": 0}
|
||||
|
||||
if not any((face.get("person") or {}).get("name") for face in faces):
|
||||
return {"count": 0} # skip the extra preview download in the common no-named-faces case
|
||||
|
||||
try:
|
||||
preview_bytes = client.download_asset_preview(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
||||
return {"count": 0}
|
||||
|
||||
labels = compute_face_labels(preview_bytes, faces, display_mode, orientation)
|
||||
|
||||
result: dict[str, object] = {"count": len(labels)}
|
||||
for i, label in enumerate(labels):
|
||||
result[f"name_{i}"] = label["name"]
|
||||
result[f"x_{i}"] = label["x"]
|
||||
result[f"y_{i}"] = label["y"]
|
||||
return result
|
||||
@@ -0,0 +1,60 @@
|
||||
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, and
|
||||
Stats tabs, all inside the sidebar app shell. Data loading happens
|
||||
client-side against /api/frames/{id}/... (routers/api_frames.py); these
|
||||
routes just authorize and render the scaffold."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import can_view_frame, current_user
|
||||
from ..db import get_db
|
||||
from ..image_pipeline import (
|
||||
DEFAULT_PALETTE_RGB,
|
||||
DISPLAY_MODE_LABELS,
|
||||
PALETTE_LABELS,
|
||||
palette_to_hex,
|
||||
)
|
||||
from ..models import Frame
|
||||
from ..quiet_hours import ALL_TIMEZONES
|
||||
from .common import shell_context
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab: str, **extra):
|
||||
user = current_user(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse(f"/login?next=/frames/{frame_id}", status_code=303)
|
||||
frame = db.get(Frame, frame_id)
|
||||
if frame is None or not can_view_frame(db, user, frame):
|
||||
raise HTTPException(404, "No such frame")
|
||||
ctx = shell_context(request, db, user, active_frame=frame)
|
||||
ctx.update({"frame": frame, "active_tab": tab, **extra})
|
||||
return templates.TemplateResponse(template, ctx)
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
|
||||
def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
||||
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
return _frame_page(
|
||||
request, db, frame_id, "frame_config.html", "config",
|
||||
timezones=ALL_TIMEZONES,
|
||||
palette_labels=PALETTE_LABELS,
|
||||
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||
palette_to_hex=palette_to_hex,
|
||||
display_mode_labels=DISPLAY_MODE_LABELS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
|
||||
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
|
||||
@@ -0,0 +1,119 @@
|
||||
"""The limited no-login manage surface behind the on-frame "scan to
|
||||
manage" QR. The QR resolves to /m/<manage_token> (see main.index's
|
||||
device-credential redirect); the token grants exactly: view the current
|
||||
photo + upcoming queue, promote ("show next"), advance, back, and
|
||||
thumbnails. No settings, no removal, no other frames -- full control
|
||||
requires logging in."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import photo_queue, quiet_hours
|
||||
from ..db import frame_locked, get_db
|
||||
from ..models import Frame
|
||||
from .common import immich_client_for, list_assets, require_configured
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
def require_manage(manage_token: str, db: Session = Depends(get_db)) -> Frame:
|
||||
frame = db.scalars(select(Frame).where(Frame.manage_token == manage_token)).first()
|
||||
if frame is None:
|
||||
raise HTTPException(404, "Unknown manage link")
|
||||
return frame
|
||||
|
||||
|
||||
@router.get("/m/{manage_token}", response_class=HTMLResponse)
|
||||
def manage_page(manage_token: str, request: Request, db: Session = Depends(get_db)):
|
||||
frame = require_manage(manage_token, db)
|
||||
return templates.TemplateResponse(
|
||||
"manage.html",
|
||||
{"request": request, "frame": frame, "manage_token": manage_token},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/m/{manage_token}/queue")
|
||||
def manage_queue(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
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))
|
||||
photo_queue.sync_queue_length(cfg, assets)
|
||||
current = cfg.current_asset_id
|
||||
queue = list(cfg.queue)
|
||||
|
||||
def entry(asset_id: str) -> dict:
|
||||
return {"id": asset_id, "thumbnail_url": f"/api/m/{frame.manage_token}/thumbnail/{asset_id}"}
|
||||
|
||||
return {
|
||||
"frame_name": frame.name,
|
||||
"current": entry(current) if current else None,
|
||||
"upcoming": [entry(asset_id) for asset_id in queue],
|
||||
}
|
||||
|
||||
|
||||
class ManagePromoteRequest(BaseModel):
|
||||
asset_id: str
|
||||
|
||||
|
||||
@router.post("/api/m/{manage_token}/promote")
|
||||
def manage_promote(
|
||||
body: ManagePromoteRequest,
|
||||
frame: Frame = Depends(require_manage),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
if body.asset_id not in cfg.queue:
|
||||
raise HTTPException(400, "That photo is no longer in the upcoming queue")
|
||||
cfg.queue = [body.asset_id] + [a for a in cfg.queue if a != body.asset_id]
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.post("/api/m/{manage_token}/advance")
|
||||
def manage_advance(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
"""Advances the server-side current photo; the panel itself updates
|
||||
on the device's next wake (or its next-photo button)."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.advance_forced(cfg, assets)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.post("/api/m/{manage_token}/back")
|
||||
def manage_back(frame: Frame = Depends(require_manage), db: Session = Depends(get_db)):
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
photo_queue.back_forced(cfg, assets)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@router.get("/api/m/{manage_token}/thumbnail/{asset_id}")
|
||||
def manage_thumbnail(asset_id: str, frame: Frame = Depends(require_manage)):
|
||||
"""Thumbnails scoped to what this frame is actually showing/queuing --
|
||||
the manage token must not become a general Immich proxy."""
|
||||
if asset_id != frame.current_asset_id and asset_id not in frame.queue:
|
||||
raise HTTPException(404, "Not on this frame")
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
content, content_type = client.download_asset_thumbnail(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
||||
return Response(content=content, media_type=content_type)
|
||||
@@ -0,0 +1,670 @@
|
||||
"""HTML page routes: first-run setup, login/logout, user settings, and
|
||||
the admin panel. The frame pages themselves stay in main.py (Phase A's
|
||||
single-frame index) until the Phase D restructure.
|
||||
|
||||
All POSTs here are plain HTML forms, so CSRF rides a hidden form field
|
||||
(checked explicitly) rather than the X-CSRF-Token header the JSON API
|
||||
uses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import mail
|
||||
from ..auth import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_LIFETIME_S,
|
||||
consume_password_reset_token,
|
||||
create_password_reset_token,
|
||||
create_session,
|
||||
current_session,
|
||||
current_user,
|
||||
destroy_session,
|
||||
get_server_settings,
|
||||
hash_password,
|
||||
users_exist,
|
||||
verify_password,
|
||||
)
|
||||
from ..db import get_db
|
||||
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
USERNAME_MAX_LEN = 64
|
||||
PASSWORD_MIN_LEN = 8
|
||||
PENDING_CLAIM_TTL_S = 24 * 3600
|
||||
|
||||
|
||||
def _set_session_cookie(response, cookie_value: str) -> None:
|
||||
# No Secure flag: the server itself is plain HTTP by design (TLS is a
|
||||
# reverse proxy's job, see README) and a LAN deployment without HTTPS
|
||||
# must still be able to log in.
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
cookie_value,
|
||||
max_age=SESSION_LIFETIME_S,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def _check_form_csrf(request: Request, db: Session, csrf_token: str) -> None:
|
||||
session = current_session(request, db)
|
||||
if session is None or not hmac.compare_digest(csrf_token, session.csrf_token):
|
||||
raise HTTPException(403, "Missing or invalid CSRF token")
|
||||
|
||||
|
||||
def _normalize_username(username: str) -> str:
|
||||
return username.strip().lower()
|
||||
|
||||
|
||||
def _validate_credentials(username: str, password: str) -> str:
|
||||
username = _normalize_username(username)
|
||||
if not username or len(username) > USERNAME_MAX_LEN:
|
||||
raise HTTPException(400, "Invalid username")
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LEN} characters")
|
||||
return username
|
||||
|
||||
|
||||
@router.get("/setup", response_class=HTMLResponse)
|
||||
def setup_page(request: Request, db: Session = Depends(get_db)):
|
||||
if users_exist(db):
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
return templates.TemplateResponse("setup.html", {"request": request, "error": None})
|
||||
|
||||
|
||||
@router.post("/setup")
|
||||
def setup_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
display_name: str = Form(""),
|
||||
password: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Creates admin #1 -- only ever available while no users exist, so it
|
||||
needs no CSRF/session (there is nothing to ride). Links every
|
||||
existing frame (i.e. the migrated frame #1) to the new admin, makes
|
||||
them its owner + controller, and inherits the migrated Immich creds
|
||||
onto their account (that's how env/config.json creds become per-user
|
||||
state)."""
|
||||
if users_exist(db):
|
||||
raise HTTPException(403, "Setup has already been completed")
|
||||
username = _validate_credentials(username, password)
|
||||
|
||||
admin = User(
|
||||
username=username,
|
||||
display_name=display_name.strip() or username,
|
||||
password_hash=hash_password(password),
|
||||
is_admin=True,
|
||||
created_at=time.time(),
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
|
||||
for frame in db.scalars(select(Frame)):
|
||||
db.add(UserFrame(user_id=admin.id, frame_id=frame.id))
|
||||
if frame.owner_user_id is None:
|
||||
frame.owner_user_id = admin.id
|
||||
frame.claimed_at = time.time()
|
||||
if frame.controlled_by_user_id is None:
|
||||
frame.controlled_by_user_id = admin.id
|
||||
if not admin.immich_url and frame.immich_url and frame.immich_api_key:
|
||||
admin.immich_url = frame.immich_url
|
||||
admin.immich_api_key = frame.immich_api_key
|
||||
|
||||
db.commit()
|
||||
logger.info("First-run setup: created admin '%s' and linked %s", username,
|
||||
", ".join(f"frame #{f.id}" for f in db.scalars(select(Frame))) or "no frames")
|
||||
|
||||
cookie_value, _ = create_session(db, admin)
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
_set_session_cookie(response, cookie_value)
|
||||
return response
|
||||
|
||||
|
||||
def _safe_next(next_url: str) -> str:
|
||||
"""Same-site relative paths only -- a login redirect target from a
|
||||
query param must never become an open redirect."""
|
||||
if next_url.startswith("/") and not next_url.startswith("//"):
|
||||
return next_url
|
||||
return "/"
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request, next: str = "", db: Session = Depends(get_db)):
|
||||
if not users_exist(db):
|
||||
return RedirectResponse("/setup", status_code=303)
|
||||
if current_user(request, db) is not None:
|
||||
return RedirectResponse(_safe_next(next), status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"login.html", {"request": request, "error": None, "next": next}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
next: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.scalars(
|
||||
select(User).where(User.username == _normalize_username(username))
|
||||
).first()
|
||||
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "error": "Wrong username or password.", "next": next},
|
||||
status_code=401,
|
||||
)
|
||||
cookie_value, _ = create_session(db, user)
|
||||
response = RedirectResponse(_safe_next(next), status_code=303)
|
||||
_set_session_cookie(response, cookie_value)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, csrf_token: str = Form(""), db: Session = Depends(get_db)):
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
destroy_session(db, request)
|
||||
response = RedirectResponse("/login", status_code=303)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/forgot-password", response_class=HTMLResponse)
|
||||
def forgot_password_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"forgot_password.html", {"request": request, "sent": False, "error": None}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_class=HTMLResponse)
|
||||
def forgot_password_submit(
|
||||
request: Request, email: str = Form(...), db: Session = Depends(get_db)
|
||||
):
|
||||
"""Always shows the same "check your email" result regardless of
|
||||
whether the address matches an account -- otherwise this endpoint
|
||||
would let anyone enumerate registered emails. Silently no-ops (same
|
||||
response) if SMTP isn't configured or the user has no email set."""
|
||||
email = email.strip().lower()
|
||||
user = db.scalars(select(User).where(User.email != "").where(User.email == email)).first()
|
||||
if user is not None:
|
||||
token = create_password_reset_token(db, user)
|
||||
reset_url = str(request.base_url).rstrip("/") + f"/reset-password/{token}"
|
||||
settings = get_server_settings(db)
|
||||
mail.send_email(
|
||||
settings, user.email, "Reset your ESPresso Frame password",
|
||||
f"Someone (hopefully you) asked to reset the password for '{user.username}'.\n\n"
|
||||
f"Reset it here (valid for 1 hour): {reset_url}\n\n"
|
||||
"If you didn't request this, ignore this email.",
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"forgot_password.html", {"request": request, "sent": True, "error": None}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/reset-password/{token}", response_class=HTMLResponse)
|
||||
def reset_password_page(token: str, request: Request, db: Session = Depends(get_db)):
|
||||
row = db.get(PasswordResetToken, token)
|
||||
valid = row is not None and row.expires_at > time.time()
|
||||
return templates.TemplateResponse(
|
||||
"reset_password.html", {"request": request, "token": token, "valid": valid, "error": None}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reset-password/{token}", response_class=HTMLResponse)
|
||||
def reset_password_submit(
|
||||
token: str, request: Request, password: str = Form(...), db: Session = Depends(get_db)
|
||||
):
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
return templates.TemplateResponse(
|
||||
"reset_password.html",
|
||||
{"request": request, "token": token, "valid": True,
|
||||
"error": f"Password must be at least {PASSWORD_MIN_LEN} characters."},
|
||||
)
|
||||
user = consume_password_reset_token(db, token)
|
||||
if user is None:
|
||||
return templates.TemplateResponse(
|
||||
"reset_password.html",
|
||||
{"request": request, "token": token, "valid": False, "error": None},
|
||||
)
|
||||
user.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
logger.info("Password reset via email link for user '%s'", user.username)
|
||||
cookie_value, _ = create_session(db, user)
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
_set_session_cookie(response, cookie_value)
|
||||
return response
|
||||
|
||||
|
||||
def _normalize_device_id(device_id: str) -> str:
|
||||
device_id = device_id.strip().lower()
|
||||
if len(device_id) != 12 or any(c not in "0123456789abcdef" for c in device_id):
|
||||
raise HTTPException(400, "Invalid device id")
|
||||
return device_id
|
||||
|
||||
|
||||
def _attempt_claim(db: Session, user: User, device_id: str) -> str:
|
||||
"""Claims the frame for `user` if it has registered, else records a
|
||||
pending claim the frame's first check-in will attach (see
|
||||
auth._register_frame). Returns "claimed" or "pending"."""
|
||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||
now = time.time()
|
||||
if frame is not None:
|
||||
if frame.owner_user_id is not None:
|
||||
raise HTTPException(409, "That frame is already claimed")
|
||||
frame.owner_user_id = user.id
|
||||
frame.claimed_at = now
|
||||
if frame.controlled_by_user_id is None:
|
||||
frame.controlled_by_user_id = user.id
|
||||
if db.get(UserFrame, (user.id, frame.id)) is None:
|
||||
db.add(UserFrame(user_id=user.id, frame_id=frame.id))
|
||||
pending = db.get(PendingClaim, device_id)
|
||||
if pending is not None:
|
||||
db.delete(pending)
|
||||
db.commit()
|
||||
logger.info("User '%s' claimed frame #%d (%s)", user.username, frame.id, device_id)
|
||||
return "claimed"
|
||||
|
||||
pending = db.get(PendingClaim, device_id)
|
||||
if pending is None:
|
||||
pending = PendingClaim(device_id=device_id, user_id=user.id, created_at=now,
|
||||
expires_at=now + PENDING_CLAIM_TTL_S)
|
||||
db.add(pending)
|
||||
else:
|
||||
pending.user_id = user.id
|
||||
pending.expires_at = now + PENDING_CLAIM_TTL_S
|
||||
db.commit()
|
||||
logger.info("User '%s' filed a pending claim for device %s", user.username, device_id)
|
||||
return "pending"
|
||||
|
||||
|
||||
def _render_claim(request: Request, db: Session, device_id: str, error: str | None = None):
|
||||
user = current_user(request, db)
|
||||
session = current_session(request, db) if user else None
|
||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||
if frame is None:
|
||||
status = "unregistered"
|
||||
pending = db.get(PendingClaim, device_id)
|
||||
pending_yours = bool(pending and user and pending.user_id == user.id
|
||||
and pending.expires_at > time.time())
|
||||
elif frame.owner_user_id is None:
|
||||
status, pending_yours = "unclaimed", False
|
||||
elif user is not None and (
|
||||
frame.owner_user_id == user.id or db.get(UserFrame, (user.id, frame.id)) is not None
|
||||
):
|
||||
status, pending_yours = "claimed_yours", False
|
||||
else:
|
||||
status, pending_yours = "claimed", False
|
||||
return templates.TemplateResponse(
|
||||
"claim.html",
|
||||
{
|
||||
"request": request,
|
||||
"device_id": device_id,
|
||||
"status": status,
|
||||
"pending_yours": pending_yours,
|
||||
"user": user,
|
||||
"csrf_token": session.csrf_token if session else None,
|
||||
"error": error,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/claim", response_class=HTMLResponse)
|
||||
def claim_page(request: Request, device_id: str = "", db: Session = Depends(get_db)):
|
||||
"""Where the captive portal's post-provisioning redirect lands. Also
|
||||
the enrollment gate: a valid device id is what entitles a stranger to
|
||||
create an account (signup form on this page); everyone else gets
|
||||
enrolled by the admin."""
|
||||
device_id = _normalize_device_id(device_id)
|
||||
return _render_claim(request, db, device_id)
|
||||
|
||||
|
||||
@router.post("/claim")
|
||||
def claim_submit(
|
||||
request: Request,
|
||||
device_id: str = Form(...),
|
||||
csrf_token: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
device_id = _normalize_device_id(device_id)
|
||||
user = current_user(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse(f"/login?next=/claim%3Fdevice_id%3D{device_id}", status_code=303)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
try:
|
||||
_attempt_claim(db, user, device_id)
|
||||
except HTTPException as e:
|
||||
if e.status_code == 409:
|
||||
return _render_claim(request, db, device_id, error=e.detail)
|
||||
raise
|
||||
return RedirectResponse(f"/claim?device_id={device_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/claim/signup")
|
||||
def claim_signup(
|
||||
request: Request,
|
||||
device_id: str = Form(...),
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Account creation, gated on a plausible frame claim: the device id
|
||||
must belong to a frame that is unclaimed (or not yet registered --
|
||||
the user beat the device here after provisioning). A fabricated id
|
||||
can create an orphan account whose pending claim expires in 24h --
|
||||
accepted at household scale, and visible in /admin."""
|
||||
device_id = _normalize_device_id(device_id)
|
||||
frame = db.scalars(select(Frame).where(Frame.device_id == device_id)).first()
|
||||
if frame is not None and frame.owner_user_id is not None:
|
||||
return _render_claim(request, db, device_id,
|
||||
error="That frame is already claimed -- log in instead.")
|
||||
username = _validate_credentials(username, password)
|
||||
if db.scalars(select(User).where(User.username == username)).first() is not None:
|
||||
return _render_claim(request, db, device_id,
|
||||
error=f"Username '{username}' is taken -- log in instead?")
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
display_name=username,
|
||||
password_hash=hash_password(password),
|
||||
is_admin=False,
|
||||
created_at=time.time(),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
logger.info("User '%s' signed up via claim gate for device %s", username, device_id)
|
||||
|
||||
_attempt_claim(db, user, device_id)
|
||||
cookie_value, _ = create_session(db, user)
|
||||
response = RedirectResponse(f"/claim?device_id={device_id}", status_code=303)
|
||||
_set_session_cookie(response, cookie_value)
|
||||
return response
|
||||
|
||||
|
||||
def _settings_context(request: Request, db: Session, user, saved: bool, error: str | None) -> dict:
|
||||
from .common import shell_context
|
||||
|
||||
ctx = shell_context(request, db, user, active_nav="settings")
|
||||
ctx.update({"saved": saved, "error": error})
|
||||
return ctx
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
def settings_page(request: Request, db: Session = Depends(get_db)):
|
||||
user = current_user(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"settings.html", _settings_context(request, db, user, saved=False, error=None)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/settings", response_class=HTMLResponse)
|
||||
def settings_submit(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
display_name: str = Form(""),
|
||||
email: str = Form(""),
|
||||
immich_url: str = Form(""),
|
||||
immich_api_key: str = Form(""),
|
||||
current_password: str = Form(""),
|
||||
new_password: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = current_user(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
|
||||
error = None
|
||||
user.display_name = display_name.strip() or user.username
|
||||
user.email = email.strip().lower()
|
||||
user.immich_url = immich_url.strip()
|
||||
# Blank API key field = keep the existing one (it's never echoed back
|
||||
# into the form -- a secret that round-trips through HTML is a secret
|
||||
# in every browser's autofill store).
|
||||
if immich_api_key.strip():
|
||||
user.immich_api_key = immich_api_key.strip()
|
||||
|
||||
if new_password:
|
||||
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
||||
error = "Current password is wrong -- password not changed."
|
||||
elif len(new_password) < PASSWORD_MIN_LEN:
|
||||
error = f"New password must be at least {PASSWORD_MIN_LEN} characters."
|
||||
else:
|
||||
user.password_hash = hash_password(new_password)
|
||||
|
||||
db.commit()
|
||||
return templates.TemplateResponse(
|
||||
"settings.html", _settings_context(request, db, user, saved=error is None, error=error)
|
||||
)
|
||||
|
||||
|
||||
def _require_admin_page(request: Request, db: Session) -> User:
|
||||
user = current_user(request, db)
|
||||
if user is None or not user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
return user
|
||||
|
||||
|
||||
def _render_admin(request: Request, db: Session, admin: User, notice: str | None = None,
|
||||
error: str | None = None) -> HTMLResponse:
|
||||
from .common import shell_context
|
||||
|
||||
users = list(db.scalars(select(User).order_by(User.id)))
|
||||
frames = list(db.scalars(select(Frame).order_by(Frame.id)))
|
||||
links = list(db.scalars(select(UserFrame)))
|
||||
links_by_frame: dict[int, list[User]] = {}
|
||||
users_by_id = {u.id: u for u in users}
|
||||
for link in links:
|
||||
links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id])
|
||||
ctx = shell_context(request, db, admin, active_nav="admin")
|
||||
ctx.update({
|
||||
"users": users,
|
||||
"frames": frames,
|
||||
"links_by_frame": links_by_frame,
|
||||
"smtp": get_server_settings(db),
|
||||
"notice": notice,
|
||||
"error": error,
|
||||
})
|
||||
return templates.TemplateResponse("admin.html", ctx)
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
def admin_page(request: Request, db: Session = Depends(get_db)):
|
||||
user = current_user(request, db)
|
||||
if user is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Admin only")
|
||||
return _render_admin(request, db, user)
|
||||
|
||||
|
||||
@router.post("/admin/users", response_class=HTMLResponse)
|
||||
def admin_create_user(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
is_admin: bool = Form(False),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
username = _validate_credentials(username, password)
|
||||
if db.scalars(select(User).where(User.username == username)).first() is not None:
|
||||
return _render_admin(request, db, admin, error=f"Username '{username}' already exists.")
|
||||
db.add(User(
|
||||
username=username,
|
||||
display_name=username,
|
||||
password_hash=hash_password(password),
|
||||
is_admin=is_admin,
|
||||
created_at=time.time(),
|
||||
))
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice=f"User '{username}' created.")
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/reset-password", response_class=HTMLResponse)
|
||||
def admin_reset_password(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
password: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
target = db.get(User, user_id)
|
||||
if target is None:
|
||||
return _render_admin(request, db, admin, error="No such user.")
|
||||
if len(password) < PASSWORD_MIN_LEN:
|
||||
return _render_admin(request, db, admin,
|
||||
error=f"Password must be at least {PASSWORD_MIN_LEN} characters.")
|
||||
target.password_hash = hash_password(password)
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice=f"Password reset for '{target.username}'.")
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/delete", response_class=HTMLResponse)
|
||||
def admin_delete_user(
|
||||
user_id: int,
|
||||
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 user_id == admin.id:
|
||||
return _render_admin(request, db, admin, error="You can't delete your own account.")
|
||||
target = db.get(User, user_id)
|
||||
if target is None:
|
||||
return _render_admin(request, db, admin, error="No such user.")
|
||||
name = target.username
|
||||
db.delete(target) # sessions/links cascade; frames.owner goes NULL
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice=f"User '{name}' deleted.")
|
||||
|
||||
|
||||
@router.post("/admin/frames/{frame_id}/link-user", response_class=HTMLResponse)
|
||||
def admin_link_user(
|
||||
frame_id: int,
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
username: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
frame = db.get(Frame, frame_id)
|
||||
target = db.scalars(select(User).where(User.username == _normalize_username(username))).first()
|
||||
if frame is None or target is None:
|
||||
return _render_admin(request, db, admin, error="No such frame or user.")
|
||||
if db.get(UserFrame, (target.id, frame_id)) is not None:
|
||||
return _render_admin(request, db, admin, error=f"'{target.username}' is already linked.")
|
||||
db.add(UserFrame(user_id=target.id, frame_id=frame_id))
|
||||
if frame.owner_user_id is None:
|
||||
# Linking to an unclaimed frame claims it -- the admin flow for
|
||||
# adopting a frame that self-registered without a pending claim.
|
||||
frame.owner_user_id = target.id
|
||||
frame.claimed_at = time.time()
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin,
|
||||
notice=f"Linked '{target.username}' to frame #{frame_id}.")
|
||||
|
||||
|
||||
@router.post("/admin/frames/{frame_id}/end-legacy", response_class=HTMLResponse)
|
||||
def admin_end_legacy(
|
||||
frame_id: int,
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Closes the legacy-token migration window once the device is
|
||||
confirmed on per-frame auth (device_token_ack + recent last_seen in
|
||||
the frames table below)."""
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
frame = db.get(Frame, frame_id)
|
||||
if frame is None:
|
||||
return _render_admin(request, db, admin, error="No such frame.")
|
||||
frame.legacy_token_enabled = False
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice=f"Legacy token disabled for frame #{frame_id}.")
|
||||
|
||||
|
||||
@router.post("/admin/smtp", response_class=HTMLResponse)
|
||||
def admin_smtp_save(
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
smtp_host: str = Form(""),
|
||||
smtp_port: int = Form(587),
|
||||
smtp_username: str = Form(""),
|
||||
smtp_password: str = Form(""),
|
||||
smtp_from_address: str = Form(""),
|
||||
smtp_encryption: str = Form("starttls"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Saves the SMTP config used for password-reset emails and battery-
|
||||
threshold alerts. Blank password = keep the existing one, same
|
||||
round-trip-avoidance as the Immich API key field in /settings."""
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
settings = get_server_settings(db)
|
||||
settings.smtp_host = smtp_host.strip()
|
||||
settings.smtp_port = max(1, min(65535, smtp_port))
|
||||
settings.smtp_username = smtp_username.strip()
|
||||
if smtp_password.strip():
|
||||
settings.smtp_password = smtp_password.strip()
|
||||
settings.smtp_from_address = smtp_from_address.strip()
|
||||
settings.smtp_encryption = smtp_encryption if smtp_encryption in ("none", "starttls", "ssl") else "starttls"
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice="SMTP settings saved.")
|
||||
|
||||
|
||||
@router.post("/admin/smtp/test", response_class=HTMLResponse)
|
||||
def admin_smtp_test(request: Request, csrf_token: str = Form(""), db: Session = Depends(get_db)):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
if not admin.email:
|
||||
return _render_admin(request, db, admin, error="Set an email on your own account (Settings) to test SMTP.")
|
||||
settings = get_server_settings(db)
|
||||
ok = mail.send_email(
|
||||
settings, admin.email, "ESPresso Frame test email",
|
||||
"If you're reading this, SMTP is configured correctly.",
|
||||
)
|
||||
if ok:
|
||||
return _render_admin(request, db, admin, notice=f"Test email sent to {admin.email}.")
|
||||
return _render_admin(request, db, admin, error="Failed to send -- check the SMTP settings and server logs.")
|
||||
|
||||
|
||||
@router.post("/admin/frames/{frame_id}/delete", response_class=HTMLResponse)
|
||||
def admin_delete_frame(
|
||||
frame_id: int,
|
||||
request: Request,
|
||||
csrf_token: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
admin = _require_admin_page(request, db)
|
||||
_check_form_csrf(request, db, csrf_token)
|
||||
frame = db.get(Frame, frame_id)
|
||||
if frame is None:
|
||||
return _render_admin(request, db, admin, error="No such frame.")
|
||||
db.delete(frame) # links/battery log cascade
|
||||
db.commit()
|
||||
return _render_admin(request, db, admin, notice=f"Frame #{frame_id} deleted.")
|
||||
@@ -0,0 +1,89 @@
|
||||
// Hand-drawn canvas battery-history chart. Ported intact from the
|
||||
// original single-page UI. Reads theme colors live so it redraws
|
||||
// correctly on theme changes (see the themechange listener in
|
||||
// frame_stats.js).
|
||||
|
||||
let lastBatteryLog = null;
|
||||
|
||||
function drawBatteryChart(log) {
|
||||
lastBatteryLog = log;
|
||||
const wrap = document.getElementById('battery-chart-wrap');
|
||||
if (!log || log.length < 2) {
|
||||
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = '';
|
||||
const width = wrap.clientWidth || 440;
|
||||
const height = 180;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.border = `1px solid ${themeColor('--border')}`;
|
||||
canvas.style.borderRadius = '8px';
|
||||
wrap.appendChild(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const gridColor = themeColor('--border');
|
||||
const mutedColor = themeColor('--text-muted');
|
||||
const accentColor = themeColor('--accent');
|
||||
|
||||
const pad = { left: 28, right: 8, top: 10, bottom: 20 };
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
|
||||
const times = log.map((p) => p[0]);
|
||||
const minT = Math.min(...times);
|
||||
const maxT = Math.max(...times);
|
||||
const spanT = Math.max(1, maxT - minT);
|
||||
|
||||
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
|
||||
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
|
||||
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.fillStyle = mutedColor;
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.textAlign = 'left';
|
||||
[0, 25, 50, 75, 100].forEach((pct) => {
|
||||
const yy = y(pct);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, yy);
|
||||
ctx.lineTo(width - pad.right, yy);
|
||||
ctx.stroke();
|
||||
ctx.fillText(String(pct), 2, yy + 3);
|
||||
});
|
||||
|
||||
ctx.strokeStyle = accentColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
log.forEach((p, i) => {
|
||||
const px = x(p[0]);
|
||||
const py = y(p[1]);
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
ctx.fillStyle = mutedColor;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(fmt(minT), pad.left, height - 4);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
|
||||
}
|
||||
|
||||
async function loadBatteryLog() {
|
||||
const wrap = document.getElementById('battery-chart-wrap');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/battery-log`);
|
||||
if (!resp.ok) {
|
||||
wrap.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
drawBatteryChart(data.log);
|
||||
} catch (e) {
|
||||
wrap.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Shared plumbing for every page: CSRF-injecting fetch, theme toggle,
|
||||
// sidebar toggle (mobile), and small formatting helpers. No framework,
|
||||
// no build step -- plain scripts, load order handled by <script> tags.
|
||||
|
||||
// Session-cookie auth needs CSRF proof on mutating requests. Wrapping
|
||||
// fetch once means no call site has to remember the header. The token
|
||||
// rides a <meta> tag emitted only for session-authed pages.
|
||||
(function () {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!meta || !meta.content) return;
|
||||
var CSRF = meta.content;
|
||||
var origFetch = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
init = init || {};
|
||||
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
|
||||
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
|
||||
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
|
||||
init.headers = new Headers(init.headers || (input && input.headers) || {});
|
||||
init.headers.set('X-CSRF-Token', CSRF);
|
||||
}
|
||||
return origFetch.call(this, input, init);
|
||||
};
|
||||
})();
|
||||
|
||||
// Theme toggle: explicit choice wins over the OS preference and is
|
||||
// remembered; with no explicit choice, CSS falls back to
|
||||
// prefers-color-scheme on its own. (The pre-paint snippet in the page
|
||||
// <head> applies the stored theme before first render.)
|
||||
(function () {
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (!btn) return;
|
||||
|
||||
function currentTheme() {
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem('theme'); } catch (e) { /* ignore */ }
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
var theme = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
try { localStorage.setItem('theme', theme); } catch (e) { /* ignore */ }
|
||||
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: theme } }));
|
||||
});
|
||||
})();
|
||||
|
||||
// Mobile sidebar: hamburger opens, backdrop or navigation closes.
|
||||
(function () {
|
||||
var shell = document.querySelector('.shell');
|
||||
var toggle = document.getElementById('sidebar-toggle');
|
||||
var backdrop = document.querySelector('.sidebar-backdrop');
|
||||
if (!shell || !toggle) return;
|
||||
toggle.addEventListener('click', function () { shell.classList.toggle('sidebar-open'); });
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', function () { shell.classList.remove('sidebar-open'); });
|
||||
}
|
||||
})();
|
||||
|
||||
function showStatus(ok, message) {
|
||||
var el = document.getElementById('result');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="status ' + (ok ? 'ok' : 'err') + '"></div>';
|
||||
el.firstChild.textContent = message;
|
||||
}
|
||||
|
||||
// A 409 from a control-gated endpoint means someone else holds the
|
||||
// frame's control lock -- surface who, plus how to take over.
|
||||
async function apiError(resp) {
|
||||
var text = await resp.text();
|
||||
try {
|
||||
var body = JSON.parse(text);
|
||||
var detail = body.detail !== undefined ? body.detail : body;
|
||||
if (detail && detail.error === 'not_controller') {
|
||||
var holder = detail.holder || 'Someone else';
|
||||
return holder + ' has control of this frame — use "Take control" to make changes.';
|
||||
}
|
||||
if (typeof detail === 'string') return detail;
|
||||
} catch (e) { /* not JSON */ }
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
var d = Math.floor(seconds / 86400);
|
||||
var h = Math.floor((seconds % 86400) / 3600);
|
||||
var m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return d + 'd ' + h + 'h';
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm';
|
||||
}
|
||||
|
||||
// Reads resolved colors from CSS custom properties rather than
|
||||
// hardcoding hex values, so canvas drawing matches the current theme.
|
||||
function themeColor(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// Configuration tab: frame settings + firmware card + take control.
|
||||
// window.FRAME_API is set by the template. Checkboxes are always sent
|
||||
// explicitly as "true"/"false" -- the server treats absent fields as
|
||||
// "leave unchanged", so a checkbox must never be simply omitted.
|
||||
|
||||
async function saveConfig() {
|
||||
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
||||
const body = new URLSearchParams({
|
||||
name: document.getElementById('frame_name').value || '',
|
||||
order: document.getElementById('order').value,
|
||||
orientation: document.getElementById('orientation').value,
|
||||
refresh_interval_s: String(minutes * 60),
|
||||
display_mode: document.getElementById('display_mode').value,
|
||||
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
||||
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
||||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
||||
timezone: document.getElementById('timezone').value || 'UTC',
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await saveConfig();
|
||||
showStatus(true, 'Saved.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function takeControl() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'You have control now.');
|
||||
loadControl();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadControl() {
|
||||
const banner = document.getElementById('control-banner');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
||||
if (!resp.ok) return; // unconfigured frame: control still works via 409s
|
||||
const data = await resp.json();
|
||||
if (data.control && !data.control.you) {
|
||||
banner.style.display = 'flex';
|
||||
document.getElementById('control-holder').textContent = data.control.controller
|
||||
? `${data.control.controller} currently has control of this frame.`
|
||||
: 'Nobody has control of this frame yet.';
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
} catch (e) { /* banner is best-effort */ }
|
||||
}
|
||||
|
||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||
|
||||
// ---- Advanced configuration: color palette ----
|
||||
//
|
||||
// Hex field and R/G/B number fields are kept in sync live, both
|
||||
// directions -- editing either updates the other plus the preview
|
||||
// swatch. Hex stays the field actually read at save time (it's what
|
||||
// the server already validates as #rrggbb); the R/G/B fields are purely
|
||||
// an alternate, more precise way to arrive at the same value than
|
||||
// eyeballing a color-picker swatch.
|
||||
|
||||
function paletteHexInputs() {
|
||||
return Array.from(document.querySelectorAll('.palette-hex'))
|
||||
.sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
|
||||
}
|
||||
|
||||
function hexFromRgb(r, g, b) {
|
||||
const clamp = (v) => Math.max(0, Math.min(255, Math.round(Number(v) || 0)));
|
||||
return '#' + [r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function rgbFromHex(hex) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec((hex || '').trim());
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function paletteFieldsFor(index) {
|
||||
const at = (cls) => document.querySelector(`.${cls}[data-index="${index}"]`);
|
||||
return { hex: at('palette-hex'), r: at('palette-r'), g: at('palette-g'), b: at('palette-b'), swatch: at('palette-swatch-preview') };
|
||||
}
|
||||
|
||||
function syncPaletteFromHex(index) {
|
||||
const f = paletteFieldsFor(index);
|
||||
const rgb = rgbFromHex(f.hex.value);
|
||||
if (!rgb) return;
|
||||
[f.r.value, f.g.value, f.b.value] = rgb;
|
||||
f.swatch.style.background = f.hex.value;
|
||||
}
|
||||
|
||||
function syncPaletteFromRgb(index) {
|
||||
const f = paletteFieldsFor(index);
|
||||
const hex = hexFromRgb(f.r.value, f.g.value, f.b.value);
|
||||
f.hex.value = hex;
|
||||
f.swatch.style.background = hex;
|
||||
}
|
||||
|
||||
const palettePickerCount = paletteHexInputs().length;
|
||||
for (let i = 0; i < palettePickerCount; i++) {
|
||||
const f = paletteFieldsFor(i);
|
||||
f.hex.addEventListener('input', () => syncPaletteFromHex(i));
|
||||
[f.r, f.g, f.b].forEach((el) => el.addEventListener('input', () => syncPaletteFromRgb(i)));
|
||||
}
|
||||
|
||||
// Sliders: live numeric readout next to each, no save until the button
|
||||
// below is clicked.
|
||||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
||||
const input = document.getElementById(id);
|
||||
const readout = document.getElementById(`${id}_value`);
|
||||
input.addEventListener('input', () => { readout.textContent = Number(input.value).toFixed(2); });
|
||||
});
|
||||
|
||||
async function savePalette(extra) {
|
||||
const body = new URLSearchParams(extra || {});
|
||||
for (const input of paletteHexInputs()) {
|
||||
body.append('palette', input.value);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
loadPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('palette-save').addEventListener('click', () => {
|
||||
savePalette({
|
||||
color_boost: document.getElementById('color_boost').value,
|
||||
contrast_boost: document.getElementById('contrast_boost').value,
|
||||
dither_strength: document.getElementById('dither_strength').value,
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('palette-reset').addEventListener('click', () => {
|
||||
const inputs = paletteHexInputs();
|
||||
window.DEFAULT_PALETTE_HEX.forEach((hex, i) => {
|
||||
inputs[i].value = hex;
|
||||
syncPaletteFromHex(i);
|
||||
});
|
||||
['color_boost', 'contrast_boost', 'dither_strength'].forEach((id) => {
|
||||
document.getElementById(id).value = '1';
|
||||
document.getElementById(`${id}_value`).textContent = '1.00';
|
||||
});
|
||||
savePalette({ palette_reset: 'true', color_boost: '1', contrast_boost: '1', dither_strength: '1' });
|
||||
});
|
||||
|
||||
// ---- Preview: current photo vs. how it renders with saved settings ----
|
||||
|
||||
function loadPreview() {
|
||||
const bust = Date.now(); // avoid a stale cached image after settings change
|
||||
document.getElementById('preview-original').src = `${window.FRAME_API}/preview/original?_=${bust}`;
|
||||
document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${bust}`;
|
||||
}
|
||||
|
||||
document.getElementById('preview-refresh').addEventListener('click', loadPreview);
|
||||
loadPreview();
|
||||
|
||||
// ---- Battery alerts card ----
|
||||
|
||||
document.getElementById('battery-alert-save').addEventListener('click', async () => {
|
||||
const raw = document.getElementById('battery_alert_threshold_pct').value.trim();
|
||||
const body = new URLSearchParams({
|
||||
battery_alert_threshold_pct: raw === '' ? '-1' : raw,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Firmware card ----
|
||||
|
||||
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
||||
const input = document.getElementById('firmware-file');
|
||||
if (!input.files.length) {
|
||||
showStatus(false, 'Pick a firmware .bin first.');
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('file', input.files[0]);
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/firmware`, { method: 'POST', body: form });
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
const result = await resp.json();
|
||||
document.getElementById('firmware-available').textContent =
|
||||
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
|
||||
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
function showRepoDisplayMode(url) {
|
||||
document.getElementById('firmware-repo-text').textContent = url;
|
||||
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
|
||||
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
|
||||
}
|
||||
|
||||
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
|
||||
document.getElementById('firmware-repo-display').style.display = 'none';
|
||||
document.getElementById('firmware-repo-edit').style.display = 'block';
|
||||
document.getElementById('firmware_update_repo_url').focus();
|
||||
});
|
||||
|
||||
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
|
||||
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
|
||||
});
|
||||
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.');
|
||||
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
|
||||
loadFirmwareCheck();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFirmwareCheck(force) {
|
||||
const statusEl = document.getElementById('firmware-gitea-status');
|
||||
const btn = document.getElementById('firmware-update-btn');
|
||||
const boardEl = document.getElementById('firmware-board');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''));
|
||||
if (!resp.ok) {
|
||||
if (force) {
|
||||
showStatus(false, await apiError(resp));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.board) {
|
||||
boardEl.textContent = `Detected board: ${data.board}`;
|
||||
}
|
||||
if (!data.enabled) {
|
||||
statusEl.style.display = 'none';
|
||||
btn.style.display = 'none';
|
||||
if (force) {
|
||||
showStatus(false, 'No Gitea repo URL configured.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'block';
|
||||
if (!data.board) {
|
||||
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
|
||||
btn.style.display = 'none';
|
||||
} else if (data.update_available) {
|
||||
statusEl.textContent = `Update available: v${data.latest_version}.`;
|
||||
btn.style.display = 'inline-block';
|
||||
} else if (data.latest_version) {
|
||||
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
||||
btn.style.display = 'none';
|
||||
} else {
|
||||
statusEl.textContent = 'No releases found yet.';
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
if (force) {
|
||||
showStatus(true, 'Checked.');
|
||||
}
|
||||
} catch (e) {
|
||||
// A failed passive poll is silent; an explicit "Check now" click
|
||||
// still surfaces the error.
|
||||
if (force) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true));
|
||||
|
||||
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('firmware-update-btn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/firmware/apply-latest`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
const result = await resp.json();
|
||||
document.getElementById('firmware-available').textContent =
|
||||
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
|
||||
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
|
||||
loadFirmwareCheck();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
loadControl();
|
||||
loadFirmwareCheck();
|
||||
// The server throttles actual Gitea API calls itself, so this poll is
|
||||
// cheap either way.
|
||||
setInterval(loadFirmwareCheck, 60000);
|
||||
@@ -0,0 +1,126 @@
|
||||
// Photos tab: now-displaying, album picker, and the upcoming grid
|
||||
// (rendering/drag logic in queue.js). window.FRAME_API is set by the
|
||||
// template.
|
||||
|
||||
function renderControlBanner(control) {
|
||||
const banner = document.getElementById('control-banner');
|
||||
if (!banner) return;
|
||||
if (!control || control.you) {
|
||||
banner.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
banner.style.display = 'flex';
|
||||
document.getElementById('control-holder').textContent = control.controller
|
||||
? `${control.controller} currently has control of this frame.`
|
||||
: 'Nobody has control of this frame yet.';
|
||||
}
|
||||
|
||||
async function takeControl() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'You have control now.');
|
||||
loadQueue();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
if (dragState) {
|
||||
return; // don't yank the grid out from under an in-progress drag
|
||||
}
|
||||
const currentEl = document.getElementById('current-thumb');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
||||
if (!resp.ok) {
|
||||
currentEl.innerHTML =
|
||||
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
|
||||
renderUpcoming([]);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
currentEl.innerHTML = '';
|
||||
if (data.current) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'thumb-wrap';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'thumb';
|
||||
img.src = data.current.thumbnail_url;
|
||||
img.alt = '';
|
||||
wrap.appendChild(img);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'remove-btn';
|
||||
removeBtn.title = 'Remove from rotation';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
|
||||
wrap.appendChild(removeBtn);
|
||||
|
||||
currentEl.appendChild(wrap);
|
||||
} else {
|
||||
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
|
||||
}
|
||||
renderControlBanner(data.control);
|
||||
renderUpcoming(data.upcoming);
|
||||
} catch (e) {
|
||||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function savePhotoSettings() {
|
||||
const body = new URLSearchParams({
|
||||
album_id: document.getElementById('album_id').value || '',
|
||||
queue_target_len: document.getElementById('queue_target_len').value,
|
||||
});
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('load-albums').addEventListener('click', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/albums`);
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
const albums = await resp.json();
|
||||
|
||||
const select = document.getElementById('album_id');
|
||||
select.innerHTML = '';
|
||||
for (const a of albums) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${a.name} (${a.count})`;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('photos-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await savePhotoSettings();
|
||||
showStatus(true, 'Saved.');
|
||||
loadQueue();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||
|
||||
loadQueue();
|
||||
// Slow poll: picks up real changes (new photo displayed, queue edited
|
||||
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
||||
setInterval(loadQueue, 10000);
|
||||
@@ -0,0 +1,118 @@
|
||||
// Stats tab: device status, lifetime counters, battery history chart
|
||||
// (chart logic in battery_chart.js). window.FRAME_API set by template.
|
||||
|
||||
let lastDevice = null;
|
||||
|
||||
function renderDeviceStatus(device) {
|
||||
const el = document.getElementById('device-status');
|
||||
el.innerHTML = '';
|
||||
if (!device || !device.last_seen) {
|
||||
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
|
||||
return;
|
||||
}
|
||||
const now = Date.now() / 1000;
|
||||
const rows = [];
|
||||
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
||||
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
||||
if (device.firmware_version) {
|
||||
let fw = `v${device.firmware_version}`;
|
||||
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
|
||||
fw += ` (v${device.firmware_available} waiting)`;
|
||||
}
|
||||
rows.push(['Firmware', fw, false]);
|
||||
}
|
||||
if (device.battery) {
|
||||
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
||||
}
|
||||
if (device.on_battery_since) {
|
||||
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
|
||||
}
|
||||
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
|
||||
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
|
||||
}
|
||||
for (const [label, value, alert] of rows) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'sub';
|
||||
if (alert) {
|
||||
p.style.color = 'var(--danger-text)';
|
||||
p.style.fontWeight = '600';
|
||||
}
|
||||
p.textContent = `${label}: ${value}`;
|
||||
el.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevice() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
lastDevice = data.device;
|
||||
renderDeviceStatus(data.device);
|
||||
} catch (e) { /* retried on the next poll */ }
|
||||
}
|
||||
|
||||
function renderStats(stats) {
|
||||
const el = document.getElementById('stats-box');
|
||||
el.innerHTML = '';
|
||||
const now = Date.now() / 1000;
|
||||
const rows = [
|
||||
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
|
||||
['Wake cycles', stats.device_wakes],
|
||||
['Photos displayed', stats.photos_displayed],
|
||||
['Photos removed from rotation', stats.photos_removed],
|
||||
['Battery reports received', stats.battery_reports],
|
||||
['Battery recharge cycles', stats.recharge_cycles],
|
||||
['OTA updates applied', stats.ota_updates_applied],
|
||||
['Settings saved', stats.config_saves],
|
||||
];
|
||||
for (const [label, value] of rows) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'sub';
|
||||
p.textContent = `${label}: ${value}`;
|
||||
el.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const el = document.getElementById('stats-box');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/stats`);
|
||||
if (!resp.ok) {
|
||||
el.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
return;
|
||||
}
|
||||
renderStats(await resp.json());
|
||||
} catch (e) {
|
||||
el.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
loadDevice();
|
||||
loadStats();
|
||||
loadBatteryLog();
|
||||
|
||||
// Redraw the canvas chart (and the "Last seen" alert color) with the
|
||||
// new theme's colors as soon as the toggle is used -- canvas pixels
|
||||
// don't repaint themselves the way CSS does.
|
||||
document.addEventListener('themechange', () => {
|
||||
if (lastBatteryLog) {
|
||||
drawBatteryChart(lastBatteryLog);
|
||||
}
|
||||
if (lastDevice) {
|
||||
renderDeviceStatus(lastDevice);
|
||||
}
|
||||
});
|
||||
|
||||
// Fast tick: re-renders "Last seen"/"On battery for" from already-
|
||||
// fetched data every second so they count up smoothly without hitting
|
||||
// the server that often.
|
||||
setInterval(() => {
|
||||
if (lastDevice) {
|
||||
renderDeviceStatus(lastDevice);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setInterval(loadDevice, 10000);
|
||||
@@ -0,0 +1,240 @@
|
||||
// The upcoming-photos grid: rendering plus drag-to-reorder. Ported
|
||||
// intact from the original single-page UI -- the Pointer Events state
|
||||
// machine below (hold-to-arm on touch so page scrolling still works) is
|
||||
// battle-tested; treat changes with suspicion.
|
||||
//
|
||||
// Expects window.FRAME_API = '/api/frames/<id>' set by the page, and a
|
||||
// loadQueue() global (frame_photos.js) to refetch authoritative state.
|
||||
|
||||
let upcomingItems = [];
|
||||
|
||||
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
|
||||
// code drives mouse, touch, and pen -- native drag-and-drop is
|
||||
// mouse-only by spec and never fires at all on phones/tablets.
|
||||
//
|
||||
// On touch specifically, a card only "arms" for dragging after a
|
||||
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
|
||||
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
|
||||
// the whole time up to that point, so a normal touch-and-swipe to
|
||||
// scroll the page still works even though it starts on a card. Once
|
||||
// armed, touch-action switches to "none" for the rest of that touch
|
||||
// so drag tracking gets every pointermove reliably. Mouse skips the
|
||||
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
|
||||
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
|
||||
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
|
||||
const DRAG_HOLD_MS = 250;
|
||||
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
|
||||
|
||||
function vibrate(ms) {
|
||||
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
|
||||
// it's a harmless no-op everywhere else rather than needing a
|
||||
// feature check at every call site.
|
||||
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function clearDragOverStyling() {
|
||||
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
|
||||
}
|
||||
|
||||
function endDrag(card) {
|
||||
if (dragState && dragState.holdTimer) {
|
||||
clearTimeout(dragState.holdTimer);
|
||||
}
|
||||
card.style.touchAction = '';
|
||||
card.style.transform = '';
|
||||
card.classList.remove('dragging', 'drag-armed');
|
||||
clearDragOverStyling();
|
||||
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
|
||||
vibrate(15);
|
||||
moveItem(dragState.fromIndex, dragState.toIndex);
|
||||
}
|
||||
dragState = null;
|
||||
}
|
||||
|
||||
function renderUpcoming(items) {
|
||||
upcomingItems = items;
|
||||
const grid = document.getElementById('upcoming-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
items.forEach((item, i) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'photo-card';
|
||||
card.dataset.index = String(i);
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = item.thumbnail_url;
|
||||
img.alt = '';
|
||||
card.appendChild(img);
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge';
|
||||
badge.textContent = String(i + 1);
|
||||
card.appendChild(badge);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'remove-btn';
|
||||
removeBtn.title = 'Remove from rotation';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeAsset(item.id);
|
||||
});
|
||||
card.appendChild(removeBtn);
|
||||
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.type = 'button';
|
||||
nextBtn.className = 'show-next';
|
||||
nextBtn.textContent = 'Show next';
|
||||
nextBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
showNext(i);
|
||||
});
|
||||
card.appendChild(nextBtn);
|
||||
|
||||
card.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
|
||||
return; // let the button's own click handler run, don't start a drag
|
||||
}
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) {
|
||||
return; // left button only
|
||||
}
|
||||
|
||||
dragState = {
|
||||
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
|
||||
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
|
||||
};
|
||||
|
||||
if (e.pointerType === 'touch') {
|
||||
dragState.holdTimer = setTimeout(() => {
|
||||
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
|
||||
dragState.armed = true;
|
||||
card.classList.add('drag-armed');
|
||||
card.style.touchAction = 'none';
|
||||
vibrate(10);
|
||||
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
|
||||
}
|
||||
}, DRAG_HOLD_MS);
|
||||
} else {
|
||||
card.setPointerCapture(e.pointerId);
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('pointermove', (e) => {
|
||||
if (!dragState || dragState.pointerId !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragState.armed) {
|
||||
// Still deciding whether this is a hold-to-drag or a scroll --
|
||||
// moving this much before the hold timer fires means scroll;
|
||||
// bail out and let the browser's native pan-y handle it.
|
||||
const dx0 = e.clientX - dragState.startX;
|
||||
const dy0 = e.clientY - dragState.startY;
|
||||
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
|
||||
clearTimeout(dragState.holdTimer);
|
||||
dragState = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = e.clientX - dragState.startX;
|
||||
const dy = e.clientY - dragState.startY;
|
||||
|
||||
if (!dragState.moved) {
|
||||
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
dragState.moved = true;
|
||||
card.classList.remove('drag-armed');
|
||||
card.classList.add('dragging');
|
||||
}
|
||||
|
||||
// Follows the finger 1:1 -- the actual "pick it up and carry it"
|
||||
// feedback.
|
||||
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
|
||||
|
||||
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
|
||||
clearDragOverStyling();
|
||||
if (overCard && overCard !== card) {
|
||||
overCard.classList.add('drag-over');
|
||||
dragState.toIndex = Number(overCard.dataset.index);
|
||||
} else {
|
||||
dragState.toIndex = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('pointerup', (e) => {
|
||||
if (dragState && dragState.pointerId === e.pointerId) {
|
||||
endDrag(card);
|
||||
}
|
||||
});
|
||||
card.addEventListener('pointercancel', (e) => {
|
||||
if (dragState && dragState.pointerId === e.pointerId) {
|
||||
endDrag(card);
|
||||
}
|
||||
});
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function moveItem(fromIndex, toIndex) {
|
||||
const items = upcomingItems.slice();
|
||||
const [moved] = items.splice(fromIndex, 1);
|
||||
items.splice(toIndex, 0, moved);
|
||||
renderUpcoming(items);
|
||||
persistOrder(items);
|
||||
}
|
||||
|
||||
async function showNext(index) {
|
||||
const assetId = upcomingItems[index].id;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue/promote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asset_id: assetId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
loadQueue(); // always refetch the authoritative order rather than guessing locally
|
||||
}
|
||||
|
||||
async function removeAsset(assetId) {
|
||||
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asset_id: assetId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
async function persistOrder(items) {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue/reorder`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ queue: items.map((item) => item.id) }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
loadQueue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
:root {
|
||||
--bg: #f5f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0f1f4;
|
||||
--border: #e3e5e9;
|
||||
--text: #16181d;
|
||||
--text-muted: #666d7a;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--focus-ring: rgba(37, 99, 235, 0.35);
|
||||
--success-bg: #dcfce7;
|
||||
--success-text: #166534;
|
||||
--danger-bg: #fee2e2;
|
||||
--danger-text: #991b1b;
|
||||
--warn-bg: #fef9c3;
|
||||
--warn-text: #854d0e;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
|
||||
--shadow-hover: 0 8px 20px rgba(16, 24, 40, 0.10);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(153, 27, 27, 0.85);
|
||||
--color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0d1016;
|
||||
--surface: #161a22;
|
||||
--surface-alt: #1d222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8eaed;
|
||||
--text-muted: #9aa2b1;
|
||||
--accent: #4c8dff;
|
||||
--accent-hover: #6ea1ff;
|
||||
--focus-ring: rgba(76, 141, 255, 0.4);
|
||||
--success-bg: rgba(34, 197, 94, 0.16);
|
||||
--success-text: #4ade80;
|
||||
--danger-bg: rgba(239, 68, 68, 0.16);
|
||||
--danger-text: #f87171;
|
||||
--warn-bg: rgba(234, 179, 8, 0.16);
|
||||
--warn-text: #fbbf24;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(248, 113, 113, 0.35);
|
||||
--color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d1016;
|
||||
--surface: #161a22;
|
||||
--surface-alt: #1d222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8eaed;
|
||||
--text-muted: #9aa2b1;
|
||||
--accent: #4c8dff;
|
||||
--accent-hover: #6ea1ff;
|
||||
--focus-ring: rgba(76, 141, 255, 0.4);
|
||||
--success-bg: rgba(34, 197, 94, 0.16);
|
||||
--success-text: #4ade80;
|
||||
--danger-bg: rgba(239, 68, 68, 0.16);
|
||||
--danger-text: #f87171;
|
||||
--warn-bg: rgba(234, 179, 8, 0.16);
|
||||
--warn-text: #fbbf24;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(248, 113, 113, 0.35);
|
||||
--color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
color-scheme: var(--color-scheme);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background-color .15s ease, color .15s ease;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 72px;
|
||||
}
|
||||
.page.page-narrow {
|
||||
max-width: 420px;
|
||||
padding-top: 88px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.brand { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.brand-mark {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
|
||||
p.sub { color: var(--text-muted); font-size: 13.5px; margin: 5px 0 0; line-height: 1.5; }
|
||||
|
||||
.icon-btn {
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: background-color .15s ease, border-color .15s ease, transform .1s ease;
|
||||
}
|
||||
.icon-btn:hover { background: var(--surface-alt); }
|
||||
.icon-btn:active { transform: scale(0.94); }
|
||||
|
||||
.topbar-actions { display: flex; align-items: center; gap: 14px; }
|
||||
.topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
|
||||
.topnav a { color: var(--text-muted); text-decoration: none; }
|
||||
.topnav a:hover { color: var(--text); }
|
||||
.inline-form { display: inline; margin: 0; }
|
||||
button.linklike {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.linklike:hover { color: var(--text); background: none; }
|
||||
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
.admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
|
||||
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.admin-actions form { margin: 4px 0 0; }
|
||||
.admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
|
||||
.admin-actions input[type="password"] { margin-top: 6px; }
|
||||
.admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
|
||||
.admin-frame:last-child { border-bottom: none; }
|
||||
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
|
||||
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
|
||||
|
||||
h2.card-title, summary.card-title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 650;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
summary.card-title { cursor: pointer; margin-bottom: 0; }
|
||||
details.card[open] summary.card-title { margin-bottom: 14px; }
|
||||
details.card .sub { margin-top: 8px; }
|
||||
|
||||
.palette-table-wrap { overflow-x: auto; margin-top: 14px; }
|
||||
.palette-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.palette-table th {
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0 8px 8px 0;
|
||||
}
|
||||
.palette-table td { padding: 4px 8px 4px 0; vertical-align: middle; }
|
||||
.palette-table input { margin-top: 0; }
|
||||
.palette-swatch-preview {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.palette-table input.palette-hex {
|
||||
width: 92px;
|
||||
font-family: ui-monospace, "SF Mono", Consolas, monospace;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.palette-table input.palette-rgb {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
float: right;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
accent-color: var(--accent);
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.preview-compare {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-alt);
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px 22px 22px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card + .card { margin-top: 20px; }
|
||||
|
||||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
label:first-child { margin-top: 0; }
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
transition: background-color .15s ease, transform .1s ease;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button.btn-inline {
|
||||
margin-top: 0;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
margin-right: 8px;
|
||||
}
|
||||
button.secondary:hover { background: var(--surface-alt); }
|
||||
|
||||
.status { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 14px; }
|
||||
.status.ok { background: var(--success-bg); color: var(--success-text); }
|
||||
.status.err { background: var(--danger-bg); color: var(--danger-text); }
|
||||
.info-box {
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
|
||||
|
||||
code {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.info-box code, .info-box.warn code { background: rgba(127, 127, 127, 0.18); color: inherit; }
|
||||
|
||||
.thumb { width: 160px; max-width: 100%; border-radius: 8px; display: block; }
|
||||
|
||||
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
|
||||
.photo-card {
|
||||
position: relative; cursor: grab; border-radius: 8px; overflow: hidden; aspect-ratio: 1;
|
||||
background: var(--surface-alt); border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
transition: box-shadow .15s ease, transform .15s ease;
|
||||
/* pan-y (not none): lets a normal touch-scroll of the page work
|
||||
when you touch a card without meaning to drag it. Dragging on
|
||||
touch instead requires a brief hold first (see the JS below),
|
||||
which switches this to "none" for the rest of that touch --
|
||||
only once we're sure it's a deliberate drag, not a scroll. */
|
||||
touch-action: pan-y;
|
||||
/* Without this, a press-and-drag gesture also triggers the
|
||||
browser's native text/content selection (the blue highlight) --
|
||||
distracting, and on some browsers it fights the pointer-based
|
||||
drag tracking below closely enough to break it outright. */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
|
||||
.photo-card:active { cursor: grabbing; }
|
||||
.photo-card.drag-armed {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring) inset;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
/* No transform transition here -- the JS drives transform on every
|
||||
pointermove to track the finger 1:1, and the .15s base transition
|
||||
would otherwise make it visibly lag behind a fast swipe. */
|
||||
.photo-card.dragging {
|
||||
z-index: 20;
|
||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
|
||||
transition: box-shadow .15s ease;
|
||||
pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
|
||||
cursor: grabbing;
|
||||
}
|
||||
.photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; }
|
||||
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
|
||||
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
|
||||
.photo-card .remove-btn, .thumb-wrap .remove-btn {
|
||||
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
|
||||
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
|
||||
background: var(--overlay); color: white;
|
||||
}
|
||||
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: var(--overlay-hover); }
|
||||
.photo-card .show-next {
|
||||
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
|
||||
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
|
||||
}
|
||||
.thumb-wrap { position: relative; display: inline-block; }
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.main-col, .side-col { display: flex; flex-direction: column; }
|
||||
@media (max-width: 860px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* App shell: left sidebar (frame list + account nav) + main content. */
|
||||
/* Used by app_base.html for all logged-in pages; the narrow auth/ */
|
||||
/* manage pages keep the simple centered .page layout above. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.shell { display: flex; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
width: 248px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 20px 14px 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar .brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 18px; }
|
||||
.sidebar .brand h1 { font-size: 17px; }
|
||||
.sidebar-section {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
padding: 14px 8px 6px;
|
||||
}
|
||||
.sidebar a.nav-item, .sidebar .nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: background-color .12s ease;
|
||||
}
|
||||
.sidebar a.nav-item:hover { background: var(--surface-alt); }
|
||||
.sidebar a.nav-item.active { background: var(--surface-alt); font-weight: 600; }
|
||||
.nav-item .frame-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex: none;
|
||||
background: var(--text-muted); opacity: 0.5;
|
||||
}
|
||||
.nav-item.online .frame-dot { background: #22c55e; opacity: 1; }
|
||||
.nav-item .nav-sub { margin-left: auto; font-size: 11.5px; color: var(--text-muted); }
|
||||
.sidebar-footer { margin-top: auto; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.sidebar-footer .nav-item { color: var(--text-muted); font-size: 13.5px; }
|
||||
.sidebar-footer form { margin: 0; }
|
||||
.sidebar-footer button.linklike {
|
||||
display: block; width: 100%; text-align: left; padding: 9px 10px; border-radius: 8px;
|
||||
}
|
||||
.sidebar-footer button.linklike:hover { background: var(--surface-alt); }
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px 28px 72px;
|
||||
max-width: 1160px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-head h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
|
||||
.page-head .head-actions { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tabs a {
|
||||
padding: 9px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color .12s ease;
|
||||
}
|
||||
.tabs a:hover { color: var(--text); }
|
||||
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
|
||||
.control-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13.5px;
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.control-banner button { margin: 0; }
|
||||
|
||||
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
|
||||
.mobile-bar { display: none; }
|
||||
.sidebar-backdrop { display: none; }
|
||||
@media (max-width: 860px) {
|
||||
.mobile-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.mobile-bar .brand { display: flex; align-items: center; gap: 8px; }
|
||||
.mobile-bar h1 { font-size: 16px; margin: 0; }
|
||||
.mobile-bar .icon-btn { width: 34px; height: 34px; box-shadow: none; }
|
||||
.mobile-bar .spacer { flex: 1; }
|
||||
|
||||
.shell { display: block; }
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
z-index: 50;
|
||||
height: 100vh;
|
||||
transform: translateX(-105%);
|
||||
transition: transform .2s ease;
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
.shell.sidebar-open .sidebar { transform: translateX(0); }
|
||||
.sidebar-backdrop {
|
||||
position: fixed; inset: 0; z-index: 40;
|
||||
background: var(--overlay);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
.shell.sidebar-open .sidebar-backdrop { display: block; opacity: 1; pointer-events: auto; }
|
||||
.sidebar-backdrop { display: block; }
|
||||
|
||||
.main { padding: 18px 14px 64px; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<nav class="tabs">
|
||||
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
|
||||
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
|
||||
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
|
||||
</nav>
|
||||
@@ -0,0 +1,135 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}Admin{% endblock %}
|
||||
{% block page_title %}Administration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Users</h2>
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Username</th><th>Display name</th><th>Role</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.display_name }}</td>
|
||||
<td>{{ "admin" if u.is_admin else "user" }}</td>
|
||||
<td class="admin-actions">
|
||||
<details>
|
||||
<summary>Reset password</summary>
|
||||
<form method="post" action="/admin/users/{{ u.id }}/reset-password">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="password" name="password" minlength="8" placeholder="New password" required>
|
||||
<button type="submit" class="secondary btn-inline">Reset</button>
|
||||
</form>
|
||||
</details>
|
||||
{% if u.id != user.id %}
|
||||
<form method="post" action="/admin/users/{{ u.id }}/delete"
|
||||
onsubmit="return confirm('Delete user {{ u.username }}?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary btn-inline">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Email (SMTP)</h2>
|
||||
<p class="sub">Used for "forgot password" links and battery-low
|
||||
alerts (set per frame in its Configuration tab). Each user needs
|
||||
an email set in their own Settings for either to reach them.</p>
|
||||
<form method="post" action="/admin/smtp">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>SMTP server
|
||||
<input type="text" name="smtp_host" placeholder="smtp.example.com" value="{{ smtp.smtp_host }}">
|
||||
</label>
|
||||
<label>Port
|
||||
<input type="number" name="smtp_port" min="1" max="65535" value="{{ smtp.smtp_port }}">
|
||||
</label>
|
||||
<label>Encryption
|
||||
<select name="smtp_encryption">
|
||||
<option value="starttls" {% if smtp.smtp_encryption == "starttls" %}selected{% endif %}>STARTTLS (usually port 587)</option>
|
||||
<option value="ssl" {% if smtp.smtp_encryption == "ssl" %}selected{% endif %}>SSL/TLS (usually port 465)</option>
|
||||
<option value="none" {% if smtp.smtp_encryption == "none" %}selected{% endif %}>None (usually port 25)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Username
|
||||
<input type="text" name="smtp_username" autocomplete="off" value="{{ smtp.smtp_username }}">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="smtp_password" autocomplete="off"
|
||||
placeholder="{% if smtp.smtp_password %}(unchanged -- enter a new one to replace){% else %}smtp password{% endif %}">
|
||||
</label>
|
||||
<label>From address
|
||||
<input type="text" name="smtp_from_address" placeholder="[email protected]" value="{{ smtp.smtp_from_address }}">
|
||||
</label>
|
||||
<button type="submit">Save SMTP settings</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/smtp/test" style="margin-top: 8px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Send test email to myself</button>
|
||||
</form>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
|
||||
<form method="post" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required>
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" minlength="8" required autocomplete="new-password">
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="is_admin" name="is_admin" value="true">
|
||||
<label for="is_admin">Administrator</label>
|
||||
</div>
|
||||
<button type="submit">Create user</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Frames</h2>
|
||||
{% for f in frames %}
|
||||
<div class="admin-frame">
|
||||
<p class="sub">
|
||||
<strong>#{{ f.id }} {{ f.name }}</strong><br>
|
||||
device: <code>{{ f.device_id or "not yet reported" }}</code><br>
|
||||
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
|
||||
· linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
|
||||
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
|
||||
· token ack: {{ "yes" if f.device_token_ack else "no" }}
|
||||
{% if f.legacy_token_enabled %}· <strong>legacy token window OPEN</strong>{% endif %}
|
||||
</p>
|
||||
<form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="text" name="username" placeholder="Link user by name" required>
|
||||
<button type="submit" class="secondary btn-inline">Link</button>
|
||||
</form>
|
||||
{% if f.legacy_token_enabled %}
|
||||
<form method="post" action="/admin/frames/{{ f.id }}/end-legacy" class="admin-inline-form"
|
||||
onsubmit="return confirm('Close the legacy-token window for frame #{{ f.id }}? Only do this once the device has acknowledged its own token.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary btn-inline">Close legacy window</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form"
|
||||
onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary btn-inline">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not frames %}<p class="sub">No frames yet.</p>{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}ESPresso Frame{% endblock %}</title>
|
||||
{% if csrf_token %}<meta name="csrf-token" content="{{ csrf_token }}">{% endif %}
|
||||
<script>
|
||||
// Applied before first paint so there's no flash of the wrong theme.
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', stored);
|
||||
}
|
||||
} catch (e) { /* localStorage unavailable (private mode, etc.) */ }
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="sidebar-backdrop"></div>
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">☕</span>
|
||||
<h1>ESPresso Frame</h1>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">Frames</div>
|
||||
{% for f in sidebar_frames %}
|
||||
<a class="nav-item {% if active_frame and active_frame.id == f.id %}active{% endif %} {% if f.recently_seen %}online{% endif %}"
|
||||
href="/frames/{{ f.id }}">
|
||||
<span class="frame-dot" aria-hidden="true"></span>
|
||||
{{ f.name or ("Frame " ~ f.id) }}
|
||||
{% if f.owner_user_id is none %}
|
||||
<span class="nav-sub">unclaimed</span>
|
||||
{% elif f.battery_percent >= 0 %}
|
||||
<span class="nav-sub battery-badge" title="Battery">🔋{{ f.battery_percent }}%</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if not sidebar_frames %}
|
||||
<p class="sub" style="padding: 0 10px;">No frames yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<a class="nav-item {% if active_nav == 'settings' %}active{% endif %}" href="/settings">Settings</a>
|
||||
{% if user.is_admin %}
|
||||
<a class="nav-item {% if active_nav == 'admin' %}active{% endif %}" href="/admin">Admin</a>
|
||||
{% endif %}
|
||||
<form method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="linklike">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main-wrap" style="flex: 1; min-width: 0;">
|
||||
<div class="mobile-bar">
|
||||
<button type="button" id="sidebar-toggle" class="icon-btn" title="Menu" aria-label="Open menu">☰</button>
|
||||
<div class="brand"><span aria-hidden="true">☕</span><h1>ESPresso Frame</h1></div>
|
||||
<div class="spacer"></div>
|
||||
</div>
|
||||
|
||||
<main class="main">
|
||||
<div class="page-head">
|
||||
<h1>{% block page_title %}{% endblock %}</h1>
|
||||
<div class="head-actions">
|
||||
{% block head_actions %}{% endblock %}
|
||||
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
|
||||
</div>
|
||||
</div>
|
||||
{% block tabs %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/common.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}ESPresso Frame{% endblock %}</title>
|
||||
{% if csrf_token %}<meta name="csrf-token" content="{{ csrf_token }}">{% endif %}
|
||||
<script>
|
||||
// Applied before first paint so there's no flash of the wrong theme.
|
||||
(function () {
|
||||
@@ -15,298 +16,7 @@
|
||||
} catch (e) { /* localStorage unavailable (private mode, etc.) */ }
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0f1f4;
|
||||
--border: #e3e5e9;
|
||||
--text: #16181d;
|
||||
--text-muted: #666d7a;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--focus-ring: rgba(37, 99, 235, 0.35);
|
||||
--success-bg: #dcfce7;
|
||||
--success-text: #166534;
|
||||
--danger-bg: #fee2e2;
|
||||
--danger-text: #991b1b;
|
||||
--warn-bg: #fef9c3;
|
||||
--warn-text: #854d0e;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
|
||||
--shadow-hover: 0 8px 20px rgba(16, 24, 40, 0.10);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(153, 27, 27, 0.85);
|
||||
--color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0d1016;
|
||||
--surface: #161a22;
|
||||
--surface-alt: #1d222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8eaed;
|
||||
--text-muted: #9aa2b1;
|
||||
--accent: #4c8dff;
|
||||
--accent-hover: #6ea1ff;
|
||||
--focus-ring: rgba(76, 141, 255, 0.4);
|
||||
--success-bg: rgba(34, 197, 94, 0.16);
|
||||
--success-text: #4ade80;
|
||||
--danger-bg: rgba(239, 68, 68, 0.16);
|
||||
--danger-text: #f87171;
|
||||
--warn-bg: rgba(234, 179, 8, 0.16);
|
||||
--warn-text: #fbbf24;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(248, 113, 113, 0.35);
|
||||
--color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d1016;
|
||||
--surface: #161a22;
|
||||
--surface-alt: #1d222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8eaed;
|
||||
--text-muted: #9aa2b1;
|
||||
--accent: #4c8dff;
|
||||
--accent-hover: #6ea1ff;
|
||||
--focus-ring: rgba(76, 141, 255, 0.4);
|
||||
--success-bg: rgba(34, 197, 94, 0.16);
|
||||
--success-text: #4ade80;
|
||||
--danger-bg: rgba(239, 68, 68, 0.16);
|
||||
--danger-text: #f87171;
|
||||
--warn-bg: rgba(234, 179, 8, 0.16);
|
||||
--warn-text: #fbbf24;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(248, 113, 113, 0.35);
|
||||
--color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
color-scheme: var(--color-scheme);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background-color .15s ease, color .15s ease;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 72px;
|
||||
}
|
||||
.page.page-narrow {
|
||||
max-width: 420px;
|
||||
padding-top: 88px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.brand { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.brand-mark {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
|
||||
p.sub { color: var(--text-muted); font-size: 13.5px; margin: 5px 0 0; line-height: 1.5; }
|
||||
|
||||
.icon-btn {
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: background-color .15s ease, border-color .15s ease, transform .1s ease;
|
||||
}
|
||||
.icon-btn:hover { background: var(--surface-alt); }
|
||||
.icon-btn:active { transform: scale(0.94); }
|
||||
|
||||
h2.card-title, summary.card-title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 650;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
summary.card-title { cursor: pointer; margin-bottom: 0; }
|
||||
details.card[open] summary.card-title { margin-bottom: 14px; }
|
||||
details.card .sub { margin-top: 8px; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px 22px 22px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card + .card { margin-top: 20px; }
|
||||
|
||||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
label:first-child { margin-top: 0; }
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
transition: background-color .15s ease, transform .1s ease;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button.btn-inline {
|
||||
margin-top: 0;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
margin-right: 8px;
|
||||
}
|
||||
button.secondary:hover { background: var(--surface-alt); }
|
||||
|
||||
.status { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 14px; }
|
||||
.status.ok { background: var(--success-bg); color: var(--success-text); }
|
||||
.status.err { background: var(--danger-bg); color: var(--danger-text); }
|
||||
.info-box {
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
|
||||
|
||||
code {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.info-box code, .info-box.warn code { background: rgba(127, 127, 127, 0.18); color: inherit; }
|
||||
|
||||
.thumb { width: 160px; max-width: 100%; border-radius: 8px; display: block; }
|
||||
|
||||
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
|
||||
.photo-card {
|
||||
position: relative; cursor: grab; border-radius: 8px; overflow: hidden; aspect-ratio: 1;
|
||||
background: var(--surface-alt); border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
transition: box-shadow .15s ease, transform .15s ease;
|
||||
/* pan-y (not none): lets a normal touch-scroll of the page work
|
||||
when you touch a card without meaning to drag it. Dragging on
|
||||
touch instead requires a brief hold first (see the JS below),
|
||||
which switches this to "none" for the rest of that touch --
|
||||
only once we're sure it's a deliberate drag, not a scroll. */
|
||||
touch-action: pan-y;
|
||||
/* Without this, a press-and-drag gesture also triggers the
|
||||
browser's native text/content selection (the blue highlight) --
|
||||
distracting, and on some browsers it fights the pointer-based
|
||||
drag tracking below closely enough to break it outright. */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
|
||||
.photo-card:active { cursor: grabbing; }
|
||||
.photo-card.drag-armed {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring) inset;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
/* No transform transition here -- the JS drives transform on every
|
||||
pointermove to track the finger 1:1, and the .15s base transition
|
||||
would otherwise make it visibly lag behind a fast swipe. */
|
||||
.photo-card.dragging {
|
||||
z-index: 20;
|
||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
|
||||
transition: box-shadow .15s ease;
|
||||
pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
|
||||
cursor: grabbing;
|
||||
}
|
||||
.photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; }
|
||||
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
|
||||
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
|
||||
.photo-card .remove-btn, .thumb-wrap .remove-btn {
|
||||
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
|
||||
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
|
||||
background: var(--overlay); color: white;
|
||||
}
|
||||
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: var(--overlay-hover); }
|
||||
.photo-card .show-next {
|
||||
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
|
||||
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
|
||||
}
|
||||
.thumb-wrap { position: relative; display: inline-block; }
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.main-col, .side-col { display: flex; flex-direction: column; }
|
||||
@media (max-width: 860px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
@@ -325,32 +35,7 @@
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Shared theme toggle: explicit choice wins over the OS preference and
|
||||
// is remembered; with no explicit choice, the CSS above falls back to
|
||||
// prefers-color-scheme on its own.
|
||||
(function () {
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (!btn) return;
|
||||
|
||||
function currentTheme() {
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem('theme'); } catch (e) { /* ignore */ }
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
try { localStorage.setItem('theme', theme); } catch (e) { /* ignore */ }
|
||||
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: theme } }));
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
apply(currentTheme() === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/common.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Claim your frame</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if status == "unregistered" %}<meta http-equiv="refresh" content="6">{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Frame <code>{{ device_id }}</code></h2>
|
||||
|
||||
{% if status == "claimed_yours" %}
|
||||
<div class="status ok">This frame is linked to your account.</div>
|
||||
<p class="sub">It will show up in your frame list. If it was just
|
||||
provisioned, give it a minute to connect and fetch its first image.</p>
|
||||
<p><a href="/">Go to your frames</a></p>
|
||||
|
||||
{% elif status == "claimed" %}
|
||||
<p class="sub">This frame already belongs to someone. If it's yours,
|
||||
ask them (or an admin) to link your account to it.</p>
|
||||
|
||||
{% elif status == "unregistered" %}
|
||||
{% if pending_yours %}
|
||||
<div class="status ok">Claim recorded.</div>
|
||||
<p class="sub">Waiting for the frame to connect for the first time --
|
||||
it links to your account automatically the moment it checks in.
|
||||
This page refreshes itself; it's safe to close, too.</p>
|
||||
{% else %}
|
||||
<p class="sub">The frame hasn't checked in yet -- it's probably still
|
||||
restarting and joining your WiFi. This page refreshes itself.
|
||||
{% if user %}You can claim it now anyway; it'll attach when it
|
||||
arrives.{% endif %}</p>
|
||||
{% endif %}
|
||||
{% elif status == "unclaimed" %}
|
||||
<p class="sub">This frame is connected and ready to be claimed.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if user and status in ("unclaimed", "unregistered") and not pending_yours %}
|
||||
<form method="post" action="/claim">
|
||||
<input type="hidden" name="device_id" value="{{ device_id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Claim this frame</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if not user and status in ("unclaimed", "unregistered") %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Create your account</h2>
|
||||
<p class="sub">A valid frame is your invitation -- set up an account to
|
||||
claim it. Already have one?
|
||||
<a href="/login?next=/claim%3Fdevice_id%3D{{ device_id }}">Log in instead</a>.</p>
|
||||
<form method="post" action="/claim/signup">
|
||||
<input type="hidden" name="device_id" value="{{ device_id }}">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required autocomplete="username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" minlength="8" required autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Create account & claim frame</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,207 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
||||
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="control-banner" class="control-banner" style="display: none;">
|
||||
<span id="control-holder"></span>
|
||||
<button type="button" id="take-control" class="btn-inline">Take control</button>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Display settings</h2>
|
||||
<form id="config-form">
|
||||
<label>Frame name
|
||||
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
|
||||
</label>
|
||||
<label>Order
|
||||
<select id="order">
|
||||
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option>
|
||||
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Orientation
|
||||
<select id="orientation">
|
||||
<option value="landscape" {% if frame.orientation == "landscape" %}selected{% endif %}>Landscape</option>
|
||||
<option value="portrait" {% if frame.orientation == "portrait" %}selected{% endif %}>Portrait</option>
|
||||
<option value="landscape_flipped" {% if frame.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
|
||||
<option value="portrait_flipped" {% if frame.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Refresh interval (minutes)
|
||||
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
||||
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
|
||||
</label>
|
||||
<label>Display mode
|
||||
<select id="display_mode">
|
||||
{% for mode, label in display_mode_labels.items() %}
|
||||
<option value="{{ mode }}" {% if frame.display_mode == mode %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">How a photo's aspect ratio
|
||||
is reconciled with the panel's: <strong>Crop to fill</strong>
|
||||
trims the excess; <strong>Crop to faces</strong> does the same
|
||||
but shifts the crop to keep people on screen; <strong>Stretch to
|
||||
fill</strong> fills the panel exactly without cropping (photos
|
||||
not matching the panel's aspect ratio look stretched);
|
||||
<strong>Shrink to fit</strong> shows the whole photo, letterboxed
|
||||
if needed.</p>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
|
||||
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
|
||||
</div>
|
||||
<label>Quiet hours start
|
||||
<input type="time" id="quiet_hours_start" value="{{ frame.quiet_hours_start }}">
|
||||
</label>
|
||||
<label>Quiet hours end
|
||||
<input type="time" id="quiet_hours_end" value="{{ frame.quiet_hours_end }}">
|
||||
</label>
|
||||
<label>Timezone
|
||||
<select id="timezone">
|
||||
{% for tz in timezones %}
|
||||
<option value="{{ tz }}" {% if tz == frame.timezone %}selected{% endif %}>{{ tz }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Quiet hours times are
|
||||
interpreted in this timezone. The device may still wake once right
|
||||
at the start of quiet hours -- it can't know ahead of time -- but
|
||||
goes right back to sleep until they end.</p>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Firmware update</h2>
|
||||
<p class="sub" id="firmware-board">
|
||||
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
|
||||
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
|
||||
</p>
|
||||
<p class="sub" id="firmware-available">
|
||||
{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame
|
||||
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
|
||||
</p>
|
||||
<input type="file" id="firmware-file" accept=".bin">
|
||||
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
||||
|
||||
<div id="firmware-repo-display" style="margin-top: 16px; {% if not frame.firmware_update_repo_url %}display: none;{% endif %}">
|
||||
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ frame.firmware_update_repo_url }}</code>
|
||||
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
|
||||
</p>
|
||||
</div>
|
||||
<label id="firmware-repo-edit" style="{% if frame.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
|
||||
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
|
||||
value="{{ frame.firmware_update_repo_url }}">
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
|
||||
<label for="firmware_auto_update">Automatically apply updates</label>
|
||||
</div>
|
||||
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
||||
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
||||
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
||||
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery alerts</h2>
|
||||
<label>Email me when battery drops below (%)
|
||||
<input type="number" id="battery_alert_threshold_pct" min="0" max="100"
|
||||
value="{% if frame.battery_alert_threshold_pct >= 0 %}{{ frame.battery_alert_threshold_pct }}{% endif %}"
|
||||
placeholder="disabled">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Sent once per discharge cycle
|
||||
to the frame owner's email (set in Settings) -- clear the field to
|
||||
disable. Needs SMTP configured by an admin.</p>
|
||||
<button type="button" class="secondary" id="battery-alert-save">Save</button>
|
||||
</section>
|
||||
|
||||
<details class="card">
|
||||
<summary class="card-title">Advanced configuration</summary>
|
||||
<p class="sub">Color-quantization values used when dithering photos
|
||||
for this panel -- approximations by default, since exact primaries
|
||||
aren't published. Tune them by comparing a rendered photo against
|
||||
the physical panel; different panel units can vary enough to be
|
||||
worth calibrating per frame.</p>
|
||||
<div class="palette-table-wrap">
|
||||
<table class="palette-table">
|
||||
<thead><tr><th></th><th>Color</th><th>Hex</th><th>R</th><th>G</th><th>B</th></tr></thead>
|
||||
<tbody>
|
||||
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
|
||||
{% set current_hex = palette_to_hex(current_palette) %}
|
||||
{% for label in palette_labels %}
|
||||
<tr>
|
||||
<td><span class="palette-swatch-preview" data-index="{{ loop.index0 }}"
|
||||
style="background: {{ current_hex[loop.index0] }};"></span></td>
|
||||
<td>{{ label }}</td>
|
||||
<td><input type="text" class="palette-hex" id="palette_{{ loop.index0 }}" data-index="{{ loop.index0 }}"
|
||||
value="{{ current_hex[loop.index0] }}" maxlength="7" pattern="#[0-9a-fA-F]{6}"
|
||||
spellcheck="false" autocomplete="off"></td>
|
||||
<td><input type="number" class="palette-rgb palette-r" data-index="{{ loop.index0 }}"
|
||||
min="0" max="255" value="{{ current_palette[loop.index0][0] }}"></td>
|
||||
<td><input type="number" class="palette-rgb palette-g" data-index="{{ loop.index0 }}"
|
||||
min="0" max="255" value="{{ current_palette[loop.index0][1] }}"></td>
|
||||
<td><input type="number" class="palette-rgb palette-b" data-index="{{ loop.index0 }}"
|
||||
min="0" max="255" value="{{ current_palette[loop.index0][2] }}"></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 20px;">Image adjustments</h2>
|
||||
<label>Color enhancement <span class="slider-value" id="color_boost_value">{{ "%.2f" | format(frame.color_boost) }}</span>
|
||||
<input type="range" id="color_boost" min="0" max="2" step="0.05" value="{{ frame.color_boost }}">
|
||||
</label>
|
||||
<label>Contrast <span class="slider-value" id="contrast_boost_value">{{ "%.2f" | format(frame.contrast_boost) }}</span>
|
||||
<input type="range" id="contrast_boost" min="0" max="2" step="0.05" value="{{ frame.contrast_boost }}">
|
||||
</label>
|
||||
<label>Dithering strength <span class="slider-value" id="dither_strength_value">{{ "%.2f" | format(frame.dither_strength) }}</span>
|
||||
<input type="range" id="dither_strength" min="0" max="1" step="0.05" value="{{ frame.dither_strength }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">1.00 is unchanged for color/
|
||||
contrast. Dithering strength trades noise texture for smoother
|
||||
gradients as it goes down; 0 is a flat, un-dithered quantization.
|
||||
Use the preview below to compare.</p>
|
||||
|
||||
<button type="button" class="secondary" id="palette-save" style="margin-top: 16px;">Save</button>
|
||||
<button type="button" class="secondary" id="palette-reset">Reset to defaults</button>
|
||||
</details>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">The current photo, and exactly how it renders on the
|
||||
panel with this frame's saved settings above.</p>
|
||||
<div class="preview-compare">
|
||||
<div class="preview-pane">
|
||||
<p class="sub">Now displaying</p>
|
||||
<img class="preview-img" id="preview-original" alt="Original photo">
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<p class="sub">How it will look on the frame</p>
|
||||
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
|
||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
||||
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="control-banner" class="control-banner" style="display: none;">
|
||||
<span id="control-holder"></span>
|
||||
<button type="button" id="take-control" class="btn-inline">Take control</button>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Album</h2>
|
||||
<form id="photos-form">
|
||||
<label>Album
|
||||
<select id="album_id">
|
||||
{% if frame.album_id %}<option value="{{ frame.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Upcoming photos to show
|
||||
<select id="queue_target_len">
|
||||
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
|
||||
<option value="{{ n }}" {% if frame.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="secondary" id="load-albums">Load Albums</button>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Now displaying</h2>
|
||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Upcoming</h2>
|
||||
<p class="sub">Drag a photo to reorder (on touch, hold briefly first so a
|
||||
normal scroll still works), "Show next" to jump it to the front, or
|
||||
the × to remove it from rotation entirely.</p>
|
||||
<div id="upcoming-grid" class="photo-grid"></div>
|
||||
</section>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script src="/static/queue.js"></script>
|
||||
<script src="/static/frame_photos.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
|
||||
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
||||
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery history</h2>
|
||||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Lifetime stats</h2>
|
||||
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Device</h2>
|
||||
<div id="device-status"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script src="/static/battery_chart.js"></script>
|
||||
<script src="/static/frame_stats.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}ESPresso Frame{% endblock %}
|
||||
{% block page_title %}Welcome{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">No frames yet</h2>
|
||||
<p class="sub">Set up a frame and it'll appear in the sidebar:</p>
|
||||
<p class="sub">1. Power the frame on -- it opens a WiFi network named
|
||||
<code>ESPRESSO_XXXXXX</code> and shows join instructions on its panel.</p>
|
||||
<p class="sub">2. Join that network and fill in your WiFi details plus this
|
||||
server's address.</p>
|
||||
<p class="sub">3. Your browser lands on this server's claim page and links
|
||||
the frame to your account automatically.</p>
|
||||
<p class="sub" style="margin-top: 12px;">Already provisioned? Ask whoever
|
||||
claimed it (or an admin) to link your account, or scan the frame's
|
||||
on-panel manage QR.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,812 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if cfg.immich_url %}
|
||||
<div class="info-box">Immich: <code>{{ cfg.immich_url }}</code> (API key configured). Set via
|
||||
<code>IMMICH_URL</code>/<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> -- see
|
||||
<code>docker-compose.yml.example</code>.</div>
|
||||
{% else %}
|
||||
<div class="info-box warn">Immich isn't configured yet. Set <code>IMMICH_URL</code> and
|
||||
<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> (copy
|
||||
<code>docker-compose.yml.example</code>) and restart the server.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card config-panel">
|
||||
<h2 class="card-title">Settings</h2>
|
||||
<form id="config-form">
|
||||
<label>Album
|
||||
<select id="album_id">
|
||||
{% if cfg.album_id %}<option value="{{ cfg.album_id }}" selected>(current selection -- reload to rename)</option>{% endif %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Order
|
||||
<select id="order">
|
||||
<option value="sequential" {% if cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
|
||||
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Orientation
|
||||
<select id="orientation">
|
||||
<option value="landscape" {% if cfg.orientation == "landscape" %}selected{% endif %}>Landscape</option>
|
||||
<option value="portrait" {% if cfg.orientation == "portrait" %}selected{% endif %}>Portrait</option>
|
||||
<option value="landscape_flipped" {% if cfg.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
|
||||
<option value="portrait_flipped" {% if cfg.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Refresh interval (minutes)
|
||||
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
||||
value="{{ (cfg.refresh_interval_s // 60) or 60 }}" required>
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="smart_crop_faces" {% if cfg.smart_crop_faces %}checked{% endif %}>
|
||||
<label for="smart_crop_faces">Center faces in crop</label>
|
||||
</div>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="quiet_hours_enabled" {% if cfg.quiet_hours_enabled %}checked{% endif %}>
|
||||
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
|
||||
</div>
|
||||
<label>Quiet hours start
|
||||
<input type="time" id="quiet_hours_start" value="{{ cfg.quiet_hours_start }}">
|
||||
</label>
|
||||
<label>Quiet hours end
|
||||
<input type="time" id="quiet_hours_end" value="{{ cfg.quiet_hours_end }}">
|
||||
</label>
|
||||
<label>Timezone
|
||||
<select id="timezone">
|
||||
{% for tz in timezones %}
|
||||
<option value="{{ tz }}" {% if tz == cfg.timezone %}selected{% endif %}>{{ tz }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Quiet hours times above are
|
||||
interpreted in this timezone. The device may still wake once right
|
||||
at the start of quiet hours -- it can't know ahead of time -- but
|
||||
goes right back to sleep until they end.</p>
|
||||
<label>Upcoming photos to show
|
||||
<select id="queue_target_len">
|
||||
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
|
||||
<option value="{{ n }}" {% if cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="secondary" id="load-albums">Load Albums</button>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<div id="result"></div>
|
||||
</section>
|
||||
|
||||
<details class="card">
|
||||
<summary class="card-title">Stats</summary>
|
||||
<div id="stats-box"><p class="sub">Loading...</p></div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Now displaying</h2>
|
||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Device</h2>
|
||||
<div id="device-status"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery history</h2>
|
||||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Firmware update</h2>
|
||||
<p class="sub" id="firmware-board">
|
||||
{% if cfg.device_board_variant %}Detected board: {{ cfg.device_board_variant }}
|
||||
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
|
||||
</p>
|
||||
<p class="sub" id="firmware-available">
|
||||
{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame
|
||||
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
|
||||
</p>
|
||||
<input type="file" id="firmware-file" accept=".bin">
|
||||
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
||||
|
||||
<div id="firmware-repo-display" style="margin-top: 16px; {% if not cfg.firmware_update_repo_url %}display: none;{% endif %}">
|
||||
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ cfg.firmware_update_repo_url }}</code>
|
||||
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
|
||||
</p>
|
||||
</div>
|
||||
<label id="firmware-repo-edit" style="{% if cfg.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
|
||||
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
|
||||
value="{{ cfg.firmware_update_repo_url }}">
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="firmware_auto_update" {% if cfg.firmware_auto_update %}checked{% endif %}>
|
||||
<label for="firmware_auto_update">Automatically apply updates</label>
|
||||
</div>
|
||||
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
||||
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
||||
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Upcoming</h2>
|
||||
<p class="sub">Drag a photo to reorder (on touch, hold briefly first so a
|
||||
normal scroll still works), "Show next" to jump it to the front, or
|
||||
the × to remove it from rotation entirely.</p>
|
||||
<div id="upcoming-grid" class="photo-grid"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const resultEl = document.getElementById('result');
|
||||
|
||||
function showStatus(ok, message) {
|
||||
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
||||
const body = new URLSearchParams({
|
||||
album_id: document.getElementById('album_id').value || '',
|
||||
order: document.getElementById('order').value,
|
||||
orientation: document.getElementById('orientation').value,
|
||||
refresh_interval_s: String(minutes * 60),
|
||||
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
|
||||
queue_target_len: document.getElementById('queue_target_len').value,
|
||||
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
||||
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
||||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
||||
timezone: document.getElementById('timezone').value || 'UTC',
|
||||
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
|
||||
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
|
||||
});
|
||||
const resp = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('load-albums').addEventListener('click', async () => {
|
||||
try {
|
||||
await saveConfig();
|
||||
|
||||
const resp = await fetch('/api/albums');
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
const albums = await resp.json();
|
||||
|
||||
const select = document.getElementById('album_id');
|
||||
select.innerHTML = '';
|
||||
for (const a of albums) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${a.name} (${a.count})`;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await saveConfig();
|
||||
showStatus(true, 'Saved.');
|
||||
loadQueue();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
function showRepoDisplayMode(url) {
|
||||
document.getElementById('firmware-repo-text').textContent = url;
|
||||
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
|
||||
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
|
||||
}
|
||||
|
||||
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
|
||||
document.getElementById('firmware-repo-display').style.display = 'none';
|
||||
document.getElementById('firmware-repo-edit').style.display = 'block';
|
||||
document.getElementById('firmware_update_repo_url').focus();
|
||||
});
|
||||
|
||||
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
|
||||
try {
|
||||
await saveConfig();
|
||||
showStatus(true, 'Saved.');
|
||||
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
|
||||
loadFirmwareCheck();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
let upcomingItems = [];
|
||||
|
||||
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
|
||||
// code drives mouse, touch, and pen -- native drag-and-drop is
|
||||
// mouse-only by spec and never fires at all on phones/tablets.
|
||||
//
|
||||
// On touch specifically, a card only "arms" for dragging after a
|
||||
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
|
||||
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
|
||||
// the whole time up to that point, so a normal touch-and-swipe to
|
||||
// scroll the page still works even though it starts on a card. Once
|
||||
// armed, touch-action switches to "none" for the rest of that touch
|
||||
// so drag tracking gets every pointermove reliably. Mouse skips the
|
||||
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
|
||||
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
|
||||
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
|
||||
const DRAG_HOLD_MS = 250;
|
||||
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
|
||||
|
||||
function vibrate(ms) {
|
||||
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
|
||||
// it's a harmless no-op everywhere else rather than needing a
|
||||
// feature check at every call site.
|
||||
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function clearDragOverStyling() {
|
||||
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
|
||||
}
|
||||
|
||||
function endDrag(card) {
|
||||
if (dragState && dragState.holdTimer) {
|
||||
clearTimeout(dragState.holdTimer);
|
||||
}
|
||||
card.style.touchAction = '';
|
||||
card.style.transform = '';
|
||||
card.classList.remove('dragging', 'drag-armed');
|
||||
clearDragOverStyling();
|
||||
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
|
||||
vibrate(15);
|
||||
moveItem(dragState.fromIndex, dragState.toIndex);
|
||||
}
|
||||
dragState = null;
|
||||
}
|
||||
|
||||
function renderUpcoming(items) {
|
||||
upcomingItems = items;
|
||||
const grid = document.getElementById('upcoming-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
items.forEach((item, i) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'photo-card';
|
||||
card.dataset.index = String(i);
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = item.thumbnail_url;
|
||||
img.alt = '';
|
||||
card.appendChild(img);
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge';
|
||||
badge.textContent = String(i + 1);
|
||||
card.appendChild(badge);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'remove-btn';
|
||||
removeBtn.title = 'Remove from rotation';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeAsset(item.id);
|
||||
});
|
||||
card.appendChild(removeBtn);
|
||||
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.type = 'button';
|
||||
nextBtn.className = 'show-next';
|
||||
nextBtn.textContent = 'Show next';
|
||||
nextBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
showNext(i);
|
||||
});
|
||||
card.appendChild(nextBtn);
|
||||
|
||||
card.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
|
||||
return; // let the button's own click handler run, don't start a drag
|
||||
}
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) {
|
||||
return; // left button only
|
||||
}
|
||||
|
||||
dragState = {
|
||||
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
|
||||
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
|
||||
};
|
||||
|
||||
if (e.pointerType === 'touch') {
|
||||
dragState.holdTimer = setTimeout(() => {
|
||||
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
|
||||
dragState.armed = true;
|
||||
card.classList.add('drag-armed');
|
||||
card.style.touchAction = 'none';
|
||||
vibrate(10);
|
||||
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
|
||||
}
|
||||
}, DRAG_HOLD_MS);
|
||||
} else {
|
||||
card.setPointerCapture(e.pointerId);
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('pointermove', (e) => {
|
||||
if (!dragState || dragState.pointerId !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragState.armed) {
|
||||
// Still deciding whether this is a hold-to-drag or a scroll --
|
||||
// moving this much before the hold timer fires means scroll;
|
||||
// bail out and let the browser's native pan-y handle it.
|
||||
const dx0 = e.clientX - dragState.startX;
|
||||
const dy0 = e.clientY - dragState.startY;
|
||||
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
|
||||
clearTimeout(dragState.holdTimer);
|
||||
dragState = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = e.clientX - dragState.startX;
|
||||
const dy = e.clientY - dragState.startY;
|
||||
|
||||
if (!dragState.moved) {
|
||||
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
dragState.moved = true;
|
||||
card.classList.remove('drag-armed');
|
||||
card.classList.add('dragging');
|
||||
}
|
||||
|
||||
// Follows the finger 1:1 -- the actual "pick it up and carry it"
|
||||
// feedback that was missing before (the card used to just fade
|
||||
// in place while a static outline highlighted the drop target).
|
||||
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
|
||||
|
||||
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
|
||||
clearDragOverStyling();
|
||||
if (overCard && overCard !== card) {
|
||||
overCard.classList.add('drag-over');
|
||||
dragState.toIndex = Number(overCard.dataset.index);
|
||||
} else {
|
||||
dragState.toIndex = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('pointerup', (e) => {
|
||||
if (dragState && dragState.pointerId === e.pointerId) {
|
||||
endDrag(card);
|
||||
}
|
||||
});
|
||||
card.addEventListener('pointercancel', (e) => {
|
||||
if (dragState && dragState.pointerId === e.pointerId) {
|
||||
endDrag(card);
|
||||
}
|
||||
});
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function moveItem(fromIndex, toIndex) {
|
||||
const items = upcomingItems.slice();
|
||||
const [moved] = items.splice(fromIndex, 1);
|
||||
items.splice(toIndex, 0, moved);
|
||||
renderUpcoming(items);
|
||||
persistOrder(items);
|
||||
}
|
||||
|
||||
async function showNext(index) {
|
||||
const assetId = upcomingItems[index].id;
|
||||
try {
|
||||
const resp = await fetch('/api/queue/promote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asset_id: assetId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
loadQueue(); // always refetch the authoritative order rather than guessing locally
|
||||
}
|
||||
|
||||
async function removeAsset(assetId) {
|
||||
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/queue/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asset_id: assetId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
async function persistOrder(items) {
|
||||
try {
|
||||
const resp = await fetch('/api/queue/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ queue: items.map((item) => item.id) }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
loadQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function renderDeviceStatus(device) {
|
||||
const el = document.getElementById('device-status');
|
||||
el.innerHTML = '';
|
||||
if (!device || !device.last_seen) {
|
||||
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
|
||||
return;
|
||||
}
|
||||
const now = Date.now() / 1000;
|
||||
const rows = [];
|
||||
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
||||
rows.push([`Last seen`, `${ago} ago`, device.overdue]);
|
||||
if (device.firmware_version) {
|
||||
let fw = `v${device.firmware_version}`;
|
||||
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
|
||||
fw += ` (v${device.firmware_available} waiting)`;
|
||||
}
|
||||
rows.push(['Firmware', fw, false]);
|
||||
}
|
||||
if (device.battery) {
|
||||
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
||||
}
|
||||
if (device.on_battery_since) {
|
||||
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
|
||||
}
|
||||
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
|
||||
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
|
||||
}
|
||||
for (const [label, value, alert] of rows) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'sub';
|
||||
if (alert) {
|
||||
p.style.color = 'var(--danger-text)';
|
||||
p.style.fontWeight = '600';
|
||||
}
|
||||
p.textContent = `${label}: ${value}`;
|
||||
el.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
||||
const input = document.getElementById('firmware-file');
|
||||
if (!input.files.length) {
|
||||
showStatus(false, 'Pick a firmware .bin first.');
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('file', input.files[0]);
|
||||
try {
|
||||
const resp = await fetch('/api/firmware', { method: 'POST', body: form });
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
const result = await resp.json();
|
||||
document.getElementById('firmware-available').textContent =
|
||||
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
|
||||
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFirmwareCheck() {
|
||||
const statusEl = document.getElementById('firmware-gitea-status');
|
||||
const btn = document.getElementById('firmware-update-btn');
|
||||
const boardEl = document.getElementById('firmware-board');
|
||||
try {
|
||||
const resp = await fetch('/api/firmware/check');
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.board) {
|
||||
boardEl.textContent = `Detected board: ${data.board}`;
|
||||
}
|
||||
if (!data.enabled) {
|
||||
statusEl.style.display = 'none';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'block';
|
||||
if (!data.board) {
|
||||
statusEl.textContent = "Waiting for the frame to check in before it can look up the right build.";
|
||||
btn.style.display = 'none';
|
||||
} else if (data.update_available) {
|
||||
statusEl.textContent = `Update available: v${data.latest_version}.`;
|
||||
btn.style.display = 'inline-block';
|
||||
} else if (data.latest_version) {
|
||||
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
||||
btn.style.display = 'none';
|
||||
} else {
|
||||
statusEl.textContent = 'No releases found yet.';
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
// A failed check is silent -- the manual upload path still works
|
||||
// regardless, and this just retries on the next poll.
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('firmware-update-btn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/firmware/apply-latest', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
const result = await resp.json();
|
||||
document.getElementById('firmware-available').textContent =
|
||||
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
|
||||
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
|
||||
loadFirmwareCheck();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
let lastDevice = null;
|
||||
|
||||
async function loadQueue() {
|
||||
if (dragState) {
|
||||
return; // don't yank the grid out from under an in-progress drag
|
||||
}
|
||||
const currentEl = document.getElementById('current-thumb');
|
||||
try {
|
||||
const resp = await fetch('/api/queue');
|
||||
if (!resp.ok) {
|
||||
currentEl.innerHTML = '<p class="sub">Not available yet -- configure Immich and an album first.</p>';
|
||||
renderUpcoming([]);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
currentEl.innerHTML = '';
|
||||
if (data.current) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'thumb-wrap';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'thumb';
|
||||
img.src = data.current.thumbnail_url;
|
||||
img.alt = '';
|
||||
wrap.appendChild(img);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'remove-btn';
|
||||
removeBtn.title = 'Remove from rotation';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
|
||||
wrap.appendChild(removeBtn);
|
||||
|
||||
currentEl.appendChild(wrap);
|
||||
} else {
|
||||
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
|
||||
}
|
||||
lastDevice = data.device;
|
||||
renderDeviceStatus(data.device);
|
||||
renderUpcoming(data.upcoming);
|
||||
} catch (e) {
|
||||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Reads resolved colors from CSS custom properties rather than
|
||||
// hardcoding hex values, so the chart matches the current theme
|
||||
// (light/dark) without needing its own separate palette.
|
||||
function themeColor(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
let lastBatteryLog = null;
|
||||
|
||||
function drawBatteryChart(log) {
|
||||
lastBatteryLog = log;
|
||||
const wrap = document.getElementById('battery-chart-wrap');
|
||||
if (!log || log.length < 2) {
|
||||
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = '';
|
||||
const width = wrap.clientWidth || 440;
|
||||
const height = 180;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.border = `1px solid ${themeColor('--border')}`;
|
||||
canvas.style.borderRadius = '8px';
|
||||
wrap.appendChild(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const gridColor = themeColor('--border');
|
||||
const mutedColor = themeColor('--text-muted');
|
||||
const accentColor = themeColor('--accent');
|
||||
|
||||
const pad = { left: 28, right: 8, top: 10, bottom: 20 };
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
|
||||
const times = log.map((p) => p[0]);
|
||||
const minT = Math.min(...times);
|
||||
const maxT = Math.max(...times);
|
||||
const spanT = Math.max(1, maxT - minT);
|
||||
|
||||
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
|
||||
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
|
||||
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.fillStyle = mutedColor;
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.textAlign = 'left';
|
||||
[0, 25, 50, 75, 100].forEach((pct) => {
|
||||
const yy = y(pct);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, yy);
|
||||
ctx.lineTo(width - pad.right, yy);
|
||||
ctx.stroke();
|
||||
ctx.fillText(String(pct), 2, yy + 3);
|
||||
});
|
||||
|
||||
ctx.strokeStyle = accentColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
log.forEach((p, i) => {
|
||||
const px = x(p[0]);
|
||||
const py = y(p[1]);
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
ctx.fillStyle = mutedColor;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(fmt(minT), pad.left, height - 4);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
|
||||
}
|
||||
|
||||
async function loadBatteryLog() {
|
||||
const wrap = document.getElementById('battery-chart-wrap');
|
||||
try {
|
||||
const resp = await fetch('/api/battery-log');
|
||||
if (!resp.ok) {
|
||||
wrap.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
drawBatteryChart(data.log);
|
||||
} catch (e) {
|
||||
wrap.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(stats) {
|
||||
const el = document.getElementById('stats-box');
|
||||
el.innerHTML = '';
|
||||
const now = Date.now() / 1000;
|
||||
const rows = [
|
||||
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
|
||||
['Wake cycles', stats.device_wakes],
|
||||
['Photos displayed', stats.photos_displayed],
|
||||
['Photos removed from rotation', stats.photos_removed],
|
||||
['Battery reports received', stats.battery_reports],
|
||||
['Battery recharge cycles', stats.recharge_cycles],
|
||||
['OTA updates applied', stats.ota_updates_applied],
|
||||
['Settings saved', stats.config_saves],
|
||||
];
|
||||
for (const [label, value] of rows) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'sub';
|
||||
p.textContent = `${label}: ${value}`;
|
||||
el.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const el = document.getElementById('stats-box');
|
||||
try {
|
||||
const resp = await fetch('/api/stats');
|
||||
if (!resp.ok) {
|
||||
el.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
return;
|
||||
}
|
||||
renderStats(await resp.json());
|
||||
} catch (e) {
|
||||
el.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
loadQueue();
|
||||
loadBatteryLog();
|
||||
loadStats();
|
||||
loadFirmwareCheck();
|
||||
|
||||
// Redraw the canvas chart (and the "Last seen" alert color) with the
|
||||
// new theme's colors as soon as the toggle in the header is used --
|
||||
// canvas pixels don't repaint themselves the way CSS does.
|
||||
document.addEventListener('themechange', () => {
|
||||
if (lastBatteryLog) {
|
||||
drawBatteryChart(lastBatteryLog);
|
||||
}
|
||||
if (lastDevice) {
|
||||
renderDeviceStatus(lastDevice);
|
||||
}
|
||||
});
|
||||
|
||||
// Fast tick: re-renders "Last seen"/"On battery for" etc. from the
|
||||
// already-fetched device data every second, so they count up smoothly
|
||||
// (1s ago, 5s ago, 1m ago...) without hitting the server that often.
|
||||
setInterval(() => {
|
||||
if (lastDevice) {
|
||||
renderDeviceStatus(lastDevice);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Slow poll: picks up real changes (new photo displayed, queue edited
|
||||
// from elsewhere, battery report, firmware version) without a manual
|
||||
// refresh. Skipped mid-drag (see loadQueue above).
|
||||
setInterval(loadQueue, 10000);
|
||||
|
||||
// Separate, slower poll for the Gitea release check -- cheap either
|
||||
// way since the server itself throttles actual Gitea API calls to
|
||||
// once per gitea_releases.UPDATE_CHECK_INTERVAL_S.
|
||||
setInterval(loadFirmwareCheck, 60000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Sign in</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Log in</h2>
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
<p class="sub" style="margin-top: 14px;"><a href="/forgot-password">Forgot your password?</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">{{ frame.name or "Frame" }} — quick controls</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Up next</h2>
|
||||
<p class="sub">Tap "Show next" to move a photo to the front. The frame
|
||||
picks it up on its next refresh. <a href="/login">Log in</a> for full
|
||||
settings.</p>
|
||||
<div id="upcoming-grid" class="photo-grid"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Now displaying</h2>
|
||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button type="button" class="secondary" id="btn-back">← Previous</button>
|
||||
<button type="button" class="secondary" id="btn-advance">Next →</button>
|
||||
</div>
|
||||
<p class="sub" style="margin-top: 8px;">Changes what the frame shows on
|
||||
its next wake.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const TOKEN = {{ manage_token | tojson }};
|
||||
const resultEl = document.getElementById('result');
|
||||
|
||||
function showStatus(ok, message) {
|
||||
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
|
||||
}
|
||||
|
||||
async function post(path, body) {
|
||||
const resp = await fetch(`/api/m/${TOKEN}/${path}`, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
const currentEl = document.getElementById('current-thumb');
|
||||
const grid = document.getElementById('upcoming-grid');
|
||||
try {
|
||||
const resp = await fetch(`/api/m/${TOKEN}/queue`);
|
||||
if (!resp.ok) {
|
||||
currentEl.innerHTML = '<p class="sub">This frame isn\'t set up yet.</p>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
currentEl.innerHTML = '';
|
||||
if (data.current) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'thumb';
|
||||
img.src = data.current.thumbnail_url;
|
||||
img.alt = '';
|
||||
currentEl.appendChild(img);
|
||||
} else {
|
||||
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
|
||||
}
|
||||
grid.innerHTML = '';
|
||||
for (const item of data.upcoming) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'photo-card';
|
||||
const img = document.createElement('img');
|
||||
img.src = item.thumbnail_url;
|
||||
img.alt = '';
|
||||
img.draggable = false;
|
||||
card.appendChild(img);
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'show-next';
|
||||
btn.textContent = 'Show next';
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await post('promote', { asset_id: item.id });
|
||||
showStatus(true, 'Moved to the front.');
|
||||
loadQueue();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
card.appendChild(btn);
|
||||
grid.appendChild(card);
|
||||
}
|
||||
} catch (e) {
|
||||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-advance').addEventListener('click', async () => {
|
||||
try { await post('advance'); showStatus(true, 'Advanced.'); loadQueue(); }
|
||||
catch (e) { showStatus(false, e.message); }
|
||||
});
|
||||
document.getElementById('btn-back').addEventListener('click', async () => {
|
||||
try { await post('back'); showStatus(true, 'Went back.'); loadQueue(); }
|
||||
catch (e) { showStatus(false, e.message); }
|
||||
});
|
||||
|
||||
loadQueue();
|
||||
setInterval(loadQueue, 15000);
|
||||
</script>
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}Settings{% endblock %}
|
||||
{% block page_title %}Your account{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if saved %}<div class="status ok">Saved.</div>{% endif %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Profile & photo library</h2>
|
||||
<form method="post" action="/settings">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Display name
|
||||
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
|
||||
</label>
|
||||
<label>Email
|
||||
<input type="email" name="email" value="{{ user.email }}" placeholder="[email protected]">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Used for password-reset links
|
||||
and, for frames you own, battery-low alerts (set a threshold in a
|
||||
frame's Configuration tab).</p>
|
||||
<label>Immich URL
|
||||
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
|
||||
value="{{ user.immich_url }}">
|
||||
</label>
|
||||
<label>Immich API key
|
||||
<input type="password" name="immich_api_key" autocomplete="off"
|
||||
placeholder="{% if user.immich_api_key %}(unchanged -- enter a new key to replace){% else %}your-immich-api-key{% endif %}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Frames you own pull photos from
|
||||
this Immich library. The key needs read access to albums/assets/faces
|
||||
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
||||
<label>Current password
|
||||
<input type="password" name="current_password" autocomplete="current-password">
|
||||
</label>
|
||||
<label>New password
|
||||
<input type="password" name="new_password" minlength="8" autocomplete="new-password">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Leave both blank to keep your
|
||||
current password.</p>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">First-run setup</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Create the admin account</h2>
|
||||
<p class="sub">This server has no users yet. The account you create here
|
||||
is the administrator: it can enroll other users and manage every
|
||||
frame. Any frame this server already knows about is linked to it
|
||||
automatically.</p>
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/setup">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>Display name (optional)
|
||||
<input type="text" name="display_name" maxlength="64" autocomplete="name">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" minlength="8" required autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Create admin account</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -5,3 +5,5 @@ httpx==0.28.1
|
||||
pillow==12.3.0
|
||||
python-multipart==0.0.20
|
||||
jinja2==3.1.5
|
||||
sqlalchemy==2.0.51
|
||||
qrcode==8.2
|
||||
|
||||
Reference in New Issue
Block a user