Move button actions to per-widget config, add hold-for-global-action
Build and push server image / test (push) Successful in 36s
Firmware build check / build-check (push) Successful in 2m4s
Build and push server image / build-and-push (push) Successful in 3m12s
Build and push server image / deploy (push) Successful in 58s

Next/back button assignment moves from a frame-level "Button
assignments" card into each widget's own gear-icon dialog, prefilled
with a sane default at creation (photos/calendar -> advance/back,
whiteboard/weather -> check_now, others -> none). At most one binding
per (widget, button) now -- cross-widget execution order never
mattered since each widget's action only touches its own state.

New firmware capability: holding NEXT or BACK past a configurable
duration (min 3s, server-side default) triggers a frame-wide action
instead of the per-widget short-press one -- cycling saved layouts,
refreshing all widgets, or freezing/unfreezing every photo widget (see
app/global_actions.py). Firmware next/back checks gain the same
hold-duration polling the combo button already had; the threshold
comes from the previous wake's /frame/config fetch (persisted in NVS),
since this wake's button decision happens before that request.

Not done here: firmware/version.txt is intentionally left unbumped --
this hasn't been built or hardware-tested (no ESP-IDF toolchain in this
environment), so no firmware release build should be triggered yet.
This commit is contained in:
2026-07-27 22:09:33 +00:00
parent 9911151d8d
commit fcf3aec4c0
47 changed files with 1351 additions and 602 deletions
+41 -20
View File
@@ -150,28 +150,49 @@ for month view to stay legible.
## Button actions
Each physical button (NEXT/BACK) maps to an **ordered list** of
`(widget, action)` bindings, not a fixed meaning -- e.g. NEXT can be
"photo widget A: advance" *and* "calendar widget B: advance" together, or
even a mismatched combination on purpose. On a press,
`routers/device.py`'s `_run_button_actions` runs every assigned action for
that button in order (each in its own `widget_locked` span -- never nested,
since the underlying per-frame lock isn't reentrant), catching and
logging any single action's failure without blocking the rest, then
re-renders and returns the whole composed panel once at the end regardless
of which actions succeeded.
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
every widget on the frame that has one -- **at most one binding per
widget per button** (a widget can't be bound to two different actions on
the same button). On a press, `routers/device.py`'s `_run_button_actions`
runs every widget's assigned action for that button (each in its own
`widget_locked` span -- never nested, since the underlying per-frame lock
isn't reentrant), catching and logging any single action's failure
without blocking the rest, then re-renders and returns the whole composed
panel once at the end regardless of which actions succeeded. Which
widget's action runs first never matters -- each only touches its own
state, and the shared re-render happens once, after all of them finish.
The web UI for this is the "Button assignments" card on a frame's
Configuration tab (`static/frame_config.js`, `GET`/`PUT
/api/frames/{id}/buttons`) -- add/remove/reorder, autosaved. Two widgets of
the same type would otherwise both just say "Photos" in the assignment
dropdowns; the UI disambiguates using each widget's grid position (e.g.
"Photos 1 (left)" / "Photos 2 (right)"), the same way you'd tell them
apart by eye on the Layout canvas.
The UI for this lives in each widget's own gear-icon config dialog (the
"Button actions" card, `templates/_widget_button_fields.html` +
`static/widget_dialog_button_actions.js`, `POST
/api/frames/{id}/widgets/{widget_id}/button-actions`) -- not a frame-level
tab, since assigning a widget's next/back behavior is naturally part of
configuring that widget. The card only renders for widget types with a
non-empty `ACTIONS` (photos, calendar, whiteboard, weather); tasks/
static/text/battery have nothing to bind so the card is omitted for
them. An empty selection ("(none)") clears that button's binding for the
widget.
A newly-created widget (including the one auto-migrated from a frame's old
`mode` on upgrade) gets a sensible default binding reproducing its old
button behavior -- see `migration.py`'s `_default_button_actions`.
A newly-created widget (including the one auto-migrated from a frame's
old `mode` on upgrade) gets a sensible default binding reproducing its
old button behavior -- see `widgets.default_button_actions` (called from
both `migration.py`'s backfill and `api_widgets.py`'s
`api_widget_create`), so a widget is never left with nothing bound until
someone deliberately reassigns it.
### Hold-for-global-action
Holding NEXT or BACK past a configurable duration (`Frame.hold_duration_ms`,
minimum 3000ms, set on the Configuration tab) triggers a **global**
action instead of the per-widget one -- not scoped to any widget, e.g.
cycling through the user's saved layouts. See `app/global_actions.py`'s
`GLOBAL_ACTIONS`/`GLOBAL_ACTION_LABELS` registry and
`routers/device.py`'s `/frame/global-next`/`/frame/global-back` (the
device calls these instead of `/frame/advance`/`/frame/back` once it
detects a long press -- see `firmware/main/next_button.c`/`back_button.c`).
`Frame.next_hold_action`/`back_hold_action` pick which registry entry (if
any) each button's hold triggers; unset is a silent no-op, same
convention as an unbound short-press button.
## Per-widget config UI
+33 -1
View File
@@ -68,6 +68,7 @@ Under **ESPresso Frame Configuration**:
| `FRAME_COMBO_BUTTON_GPIO` | 1 | Menu/reset button GPIO (-1 to disable). Must be 0-7 |
| `FRAME_COMBO_SOFT_RESET_HOLD_MS` | 3000 | How long the combo button must be held (then released) to soft-reset |
| `FRAME_COMBO_FACTORY_RESET_HOLD_MS` | 15000 | How long the combo button must be held to factory-reset |
| `FRAME_HOLD_ACTION_MS` | 3000 | **Fallback only** -- how long NEXT/BACK must be held to trigger a global action instead of a short press; see below |
| `FRAME_BATTERY_ADC_GPIO` | -1 (disabled) | Battery voltage-divider ADC GPIO; see the Battery section below |
| `FRAME_VBUS_SENSE_GPIO` | -1 (disabled) | USB-power sense GPIO for hiding the battery indicator on mains |
@@ -84,6 +85,34 @@ reflashing. The Kconfig value only applies before the device has ever
successfully reached a configured server, or if the response doesn't
include a valid interval.
### Holding NEXT/BACK for a global action
Past `FRAME_HOLD_ACTION_MS`, holding NEXT or BACK stops meaning "advance/
back this widget" and instead triggers whatever frame-wide action (if
any) is configured for that button's hold on the server's Configuration
tab -- e.g. cycling through saved layouts (see
`server/app/global_actions.py`). Fires immediately at the threshold,
without waiting for release -- same convention as the combo button's
factory-reset tier below.
Same "fallback only" caveat as `FRAME_SLEEP_INTERVAL_S` above, but with
one more wrinkle: the server's actual `hold_duration_ms` (set on the
Configuration tab, `GET /frame/config`'s response) can't be used for
*this* wake's button decision -- that decision happens in `main.c`
before WiFi even connects, but `/frame/config` isn't fetched until near
the end of the wake cycle (after the image fetch, deliberately -- see
`frame_client_run`'s own comment on why). So the device always acts on
whatever value the *previous* wake fetched (persisted in NVS via
`frame_config_set_hold_duration_ms`), falling back to
`FRAME_HOLD_ACTION_MS` only before it's ever successfully fetched one.
In practice this means changing the duration on the Configuration tab
takes effect starting with the wake *after* the next one, not
immediately.
Holding a button through the poll loop keeps the device awake and
connected longer than a normal short-press wake -- the same tradeoff
already accepted for the combo button's menu/reset holds below.
### WiFi fast-connect
After a successful home-WiFi connection, the device caches the AP's
@@ -204,6 +233,8 @@ device (if asleep) and tells the server to advance to the next photo
right away, regardless of the configured refresh interval -- no long hold
needed, since advancing is easily reversible by pressing again. See
`FRAME_NEXT_BUTTON_GPIO` above to change the pin or disable the feature.
Holding it past `FRAME_HOLD_ACTION_MS` instead means something else
entirely -- see "Holding NEXT/BACK for a global action" above.
Normal wakes and reboots never advance the photo on their own -- the
server decides when to advance based on its own clock (see
@@ -223,7 +254,8 @@ disable the feature.
If there's nothing to go back to yet (freshly provisioned, or you've
already gone back as far as there is history), it's a no-op -- the
current photo stays exactly as it was, no flash on the panel.
current photo stays exactly as it was, no flash on the panel. Same
long-hold caveat as the next-photo button above.
## Battery (XIAO ESP32-C6)
+17
View File
@@ -188,6 +188,23 @@ menu "ESPresso Frame Configuration"
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
confused for each other.
config FRAME_HOLD_ACTION_MS
int "Next/back hold-for-global-action duration (ms)"
default 3000
range 3000 10000
help
How long the NEXT or BACK button must be held before it
triggers a frame-wide action (see server/app/global_actions.py
-- e.g. cycling saved layouts) instead of that button's normal
short-press behavior. Only a first-boot/never-connected
fallback: once the device has fetched GET /frame/config at
least once, the server's own Frame.hold_duration_ms (set on
the Configuration tab) overrides this on every later boot --
see wifi_provisioning.h's frame_config_get_hold_duration_ms.
Floor matches the server's own minimum, so a long-held button
never means something different depending on which value
happened to apply.
config FRAME_BATTERY_ADC_GPIO
int "Battery voltage-divider ADC GPIO (-1 to disable)"
default -1
+36 -14
View File
@@ -1,3 +1,5 @@
#include <stdint.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
@@ -5,6 +7,8 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "wifi_provisioning.h"
#include "back_button.h"
static const char *TAG = "back_button";
@@ -14,6 +18,7 @@ static const char *TAG = "back_button";
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
#define BACK_BUTTON_DEBOUNCE_MS 20
#define BACK_BUTTON_DEBOUNCE_CHECKS 3
#define BACK_BUTTON_POLL_MS 100
void back_button_init(void)
{
@@ -36,7 +41,7 @@ void back_button_init(void)
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
bool back_button_check(void)
back_button_result_t back_button_check(void)
{
/* A quick tap can easily release before this runs (~0.4-0.5s into
* boot, confirmed on hardware -- a live gpio_get_level() check here
@@ -44,32 +49,49 @@ bool back_button_check(void)
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO)) {
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
return true;
}
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
* power-on/reflash) -- fall back to a live, debounced level check so
* holding the button down while powering on also works. */
if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* fresh power-on/reflash) -- fall back to a live, debounced level
* check so holding the button down while powering on also
* works. */
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return false;
return BACK_BUTTON_NOT_PRESSED;
}
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */
return BACK_BUTTON_NOT_PRESSED; /* noise, not a real press */
}
}
}
ESP_LOGI(TAG, "Back-photo button held during power-on, going back");
return true;
/* Confirmed pressed -- measure how long, same reasoning/pattern as
* next_button_check(). */
uint32_t hold_threshold_ms;
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
}
uint32_t elapsed_ms = 0;
while (gpio_get_level(BACK_BUTTON_GPIO) == 0) {
if (elapsed_ms >= hold_threshold_ms) {
ESP_LOGI(TAG, "Back button held past %ums, triggering global hold action",
(unsigned)hold_threshold_ms);
return BACK_BUTTON_HOLD;
}
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_POLL_MS));
elapsed_ms += BACK_BUTTON_POLL_MS;
}
ESP_LOGI(TAG, "Back-photo button short press (%ums), going back", (unsigned)elapsed_ms);
return BACK_BUTTON_SHORT_PRESS;
}
#else
void back_button_init(void) {}
bool back_button_check(void) { return false; }
back_button_result_t back_button_check(void) { return BACK_BUTTON_NOT_PRESSED; }
#endif
+18 -4
View File
@@ -12,9 +12,23 @@
*/
void back_button_init(void);
typedef enum {
BACK_BUTTON_NOT_PRESSED,
/** A short press -- same immediate-response reasoning as the
* next-photo button. */
BACK_BUTTON_SHORT_PRESS,
/** Held past the configured hold duration (see
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
* triggers a frame-wide action instead (see
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_BACK).
* Fires immediately at the threshold, without waiting for release. */
BACK_BUTTON_HOLD,
} back_button_result_t;
/**
* Returns whether the back-photo button is currently held, debounced with
* a couple of short re-checks to reject noise. Same immediate-response
* reasoning as the next-photo button -- no long hold-to-confirm gate.
* Checks the back-photo button and, if it's pressed at all, blocks
* polling its level until either it's released (BACK_BUTTON_SHORT_PRESS)
* or the hold duration elapses (BACK_BUTTON_HOLD) -- same pattern as
* next_button_check(). Evaluated once per wake.
*/
bool back_button_check(void);
back_button_result_t back_button_check(void);
+29 -6
View File
@@ -276,6 +276,14 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
typedef struct {
bool reachable;
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
/* How long NEXT/BACK must be held to trigger a global action instead
* of a short press (see next_button.h/back_button.h) --
* CONFIG_FRAME_HOLD_ACTION_MS if absent/unparseable (older server) or
* unreachable. Persisted via frame_config_set_hold_duration_ms() for
* the *next* boot's button-hold decision -- this fetch happens too
* late in the cycle for its own boot's decision, see that function's
* own doc comment. */
uint32_t hold_duration_ms;
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
@@ -370,6 +378,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
frame_server_config_t result = {
.reachable = false,
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
.hold_duration_ms = CONFIG_FRAME_HOLD_ACTION_MS,
};
result.firmware_version[0] = '\0';
result.device_token[0] = '\0';
@@ -419,6 +428,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
(int)result.refresh_interval_s);
}
uint32_t hold_ms;
if (json_extract_uint(body, "hold_duration_ms", &hold_ms)) {
result.hold_duration_ms = hold_ms;
}
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));
@@ -443,12 +456,13 @@ static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
return n > 0 ? (size_t)n : 0;
}
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
* /frame/back to force a move in either direction (FETCH_ADVANCE /
* FETCH_BACK -- the next-photo / back-photo buttons). manage=true (the
* manage button) appends &manage=1, telling the server to bake its
* overlay into this same response instead of returning the bare
* content -- see server/app/routers/device.py. Returning non-ESP_OK
/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance, /frame/back,
* /frame/global-next, or /frame/global-back to force a move/action
* (FETCH_ADVANCE / FETCH_BACK -- a short press; FETCH_GLOBAL_NEXT /
* FETCH_GLOBAL_BACK -- a held press, see next_button.h/back_button.h).
* manage=true (the manage button) appends &manage=1, telling the server
* to bake its overlay into this same response instead of returning the
* bare content -- see server/app/routers/device.py. Returning non-ESP_OK
* means the panel was never actually refreshed -- epd_display_stream()
* (see epd7in3e.c) refuses to trigger a physical refresh on a short/
* wrong-size stream, so a failure here always leaves the visible screen
@@ -460,6 +474,10 @@ static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t act
path = "frame/advance";
} else if (action == FETCH_BACK) {
path = "frame/back";
} else if (action == FETCH_GLOBAL_NEXT) {
path = "frame/global-next";
} else if (action == FETCH_GLOBAL_BACK) {
path = "frame/global-back";
}
char url[256];
@@ -720,6 +738,11 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
report_battery(cfg, battery_percent);
frame_server_config_t server_cfg = fetch_frame_config(cfg);
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
if (server_cfg.reachable) {
/* For next boot's button-hold decision, not this one -- see
* frame_config_get_hold_duration_ms()'s own doc comment. */
frame_config_set_hold_duration_ms(server_cfg.hold_duration_ms);
}
/* One-time identity handshake: the server pushes this frame's
* own token until we've authenticated with it once. Persist it
+8 -3
View File
@@ -9,14 +9,19 @@
* Which photo-fetch behavior this wake cycle should use -- normally the
* idempotent GET /frame/image (the server decides on its own whether to
* advance, based on its configured refresh interval, so a plain
* wake/reboot never skips a photo just by asking), or POST
* /frame/advance / POST /frame/back to force a move in either direction
* (the next-photo / back-photo buttons).
* wake/reboot never skips a photo just by asking), POST /frame/advance /
* POST /frame/back to force a move in either direction (a short press of
* the next-photo / back-photo buttons), or POST /frame/global-next /
* POST /frame/global-back to run whatever frame-wide action (if any) is
* configured for a held press (see next_button.h/back_button.h's
* *_HOLD result and app/global_actions.py server-side).
*/
typedef enum {
FETCH_NORMAL,
FETCH_ADVANCE,
FETCH_BACK,
FETCH_GLOBAL_NEXT,
FETCH_GLOBAL_BACK,
} fetch_action_t;
/**
+13 -4
View File
@@ -40,12 +40,21 @@ void app_main(void)
back_button_init();
combo_button_init();
bool next_pressed = next_button_check();
bool back_pressed = back_button_check();
next_button_result_t next_result = next_button_check();
back_button_result_t back_result = back_button_check();
/* Next takes priority over back if somehow both read pressed at once
* (e.g. both held through a power-on) -- an arbitrary but
* deterministic tie-break, not expected to matter in practice. */
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL;
* deterministic tie-break, not expected to matter in practice. Same
* priority applies whether the winning button resolved to a short
* press or a hold. */
fetch_action_t action;
if (next_result != NEXT_BUTTON_NOT_PRESSED) {
action = (next_result == NEXT_BUTTON_HOLD) ? FETCH_GLOBAL_NEXT : FETCH_ADVANCE;
} else if (back_result != BACK_BUTTON_NOT_PRESSED) {
action = (back_result == BACK_BUTTON_HOLD) ? FETCH_GLOBAL_BACK : FETCH_BACK;
} else {
action = FETCH_NORMAL;
}
/* Soft-resets or clears config + restarts internally for a medium/
* long hold and never returns in those cases -- only returns here
* for "not pressed" (false) or "quick press" (true, show the menu). */
+41 -14
View File
@@ -1,3 +1,5 @@
#include <stdint.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_sleep.h"
@@ -5,6 +7,8 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "wifi_provisioning.h"
#include "next_button.h"
static const char *TAG = "next_button";
@@ -14,6 +18,7 @@ static const char *TAG = "next_button";
#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
#define NEXT_BUTTON_DEBOUNCE_MS 20
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3
#define NEXT_BUTTON_POLL_MS 100
void next_button_init(void)
{
@@ -40,7 +45,7 @@ void next_button_init(void)
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
}
bool next_button_check(void)
next_button_result_t next_button_check(void)
{
/* A quick tap can easily release before this runs (~0.4-0.5s into
* boot, confirmed on hardware -- a live gpio_get_level() check here
@@ -48,32 +53,54 @@ bool next_button_check(void)
* status register is latched at the moment of waking and isn't
* cleared until the next sleep entry, so it reliably reflects a tap
* regardless of how quickly it was released. */
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) {
ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance");
return true;
}
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
* power-on/reflash) -- fall back to a live, debounced level check so
* holding the button down while powering on also works. */
if (!caused_wake) {
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
* fresh power-on/reflash) -- fall back to a live, debounced level
* check so holding the button down while powering on also
* works. */
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return false;
return NEXT_BUTTON_NOT_PRESSED;
}
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
return false; /* noise, not a real press */
return NEXT_BUTTON_NOT_PRESSED; /* noise, not a real press */
}
}
}
ESP_LOGI(TAG, "Next-photo button held during power-on, forcing advance");
return true;
/* Confirmed pressed (either the wake cause, or debounced during
* power-on) -- measure how long, same polling pattern as
* combo_button.c's own hold-tier detection. Reads the last hold
* duration the server reported (persisted from a previous cycle,
* see frame_config_get_hold_duration_ms's own doc comment), falling
* back to the Kconfig default before the device has ever fetched
* one. */
uint32_t hold_threshold_ms;
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
}
uint32_t elapsed_ms = 0;
while (gpio_get_level(NEXT_BUTTON_GPIO) == 0) {
if (elapsed_ms >= hold_threshold_ms) {
ESP_LOGI(TAG, "Next button held past %ums, triggering global hold action",
(unsigned)hold_threshold_ms);
return NEXT_BUTTON_HOLD;
}
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_POLL_MS));
elapsed_ms += NEXT_BUTTON_POLL_MS;
}
ESP_LOGI(TAG, "Next-photo button short press (%ums), forcing advance", (unsigned)elapsed_ms);
return NEXT_BUTTON_SHORT_PRESS;
}
#else
void next_button_init(void) {}
bool next_button_check(void) { return false; }
next_button_result_t next_button_check(void) { return NEXT_BUTTON_NOT_PRESSED; }
#endif
+22 -5
View File
@@ -12,10 +12,27 @@
*/
void next_button_init(void);
typedef enum {
NEXT_BUTTON_NOT_PRESSED,
/** A short press -- advancing a photo is low-stakes and should feel
* immediate, so this fires the moment the button releases (or right
* away for a wake-triggered press, once it's confirmed not a hold). */
NEXT_BUTTON_SHORT_PRESS,
/** Held past the configured hold duration (see
* wifi_provisioning.h's frame_config_get_hold_duration_ms) --
* triggers a frame-wide action instead (see
* server/app/global_actions.py, frame_client.h's FETCH_GLOBAL_NEXT).
* Fires immediately at the threshold, without waiting for release --
* same convention as combo_button.c's factory-reset tier. */
NEXT_BUTTON_HOLD,
} next_button_result_t;
/**
* Returns whether the next-photo button is currently held, debounced with
* a couple of short re-checks to reject noise. No long hold-to-confirm
* gate -- advancing a photo is low-stakes and should feel immediate, so
* this returns right away either way.
* Checks the next-photo button and, if it's pressed at all (either what
* caused this wake, per the latched wakeup-status register, or held
* through a debounced power-on check), blocks polling its level until
* either it's released (NEXT_BUTTON_SHORT_PRESS) or the hold duration
* elapses (NEXT_BUTTON_HOLD, returned immediately, not waiting for
* release). Evaluated once per wake.
*/
bool next_button_check(void);
next_button_result_t next_button_check(void);
+23
View File
@@ -234,6 +234,29 @@ void frame_config_invalidate_last_display_crc32(void)
nvs_close(handle);
}
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out)
{
nvs_handle_t handle;
esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle);
if (err != ESP_OK) {
return err;
}
err = nvs_get_u32(handle, "hold_ms", out);
nvs_close(handle);
return err;
}
void frame_config_set_hold_duration_ms(uint32_t ms)
{
nvs_handle_t handle;
if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
return;
}
nvs_set_u32(handle, "hold_ms", ms);
nvs_commit(handle);
nvs_close(handle);
}
/* ------------------------------------------------------------------------
* WiFi fast-connect cache
* ---------------------------------------------------------------------- */
+21
View File
@@ -94,6 +94,27 @@ void frame_config_set_last_display_crc32(uint32_t crc32);
*/
void frame_config_invalidate_last_display_crc32(void);
/**
* Returns the hold_duration_ms the server most recently reported via GET
* /frame/config (see frame_client.c's fetch_frame_config/frame_client_run)
* -- how long NEXT/BACK must be held before next_button_check()/
* back_button_check() treat it as a hold-for-global-action instead of a
* short press. Returns ESP_ERR_NVS_NOT_FOUND if the device has never
* fetched one yet (fresh install/factory reset); caller should fall back
* to CONFIG_FRAME_HOLD_ACTION_MS in that case.
*
* Deliberately a *previous* cycle's value: this cycle's own button
* decision happens in main.c before WiFi even connects, but
* /frame/config isn't fetched until near the end of frame_client_run
* (after the image fetch, for connection-warmth/timeout reasons -- see
* its own comment) -- so there's no same-cycle fresh value to use yet.
*/
esp_err_t frame_config_get_hold_duration_ms(uint32_t *out);
/** Persists the hold duration reported by the server, for the *next*
* boot's button-hold decision to use. */
void frame_config_set_hold_duration_ms(uint32_t ms);
/**
* Returns this device's provisioning AP identity: a fixed SSID (from
* Kconfig) and a password that's generated once on first use and persisted
+115
View File
@@ -0,0 +1,115 @@
"""Frame-wide actions triggered by holding NEXT/BACK past
Frame.hold_duration_ms, instead of the per-widget action a short press
runs (see models.FrameButtonAction, app/widgets/*.py's ACTIONS). Not
scoped to any one widget -- e.g. cycling through the owner's saved
layouts -- so this is its own registry rather than living in a widget
module.
Each function's signature is (db, frame) -> None, the frame-level
analogue of a widget ACTIONS entry's (db, frame, widget) -> None, and
each is responsible for its own locking/commit internally (frame_locked/
widget_locked), same convention as app/widgets/*.py. routers/device.py's
/frame/global-next and /frame/global-back look up which (if any) of
these Frame.next_hold_action/back_hold_action points to and call it,
same "unset/unknown -> silent no-op" posture as an unbound short-press
button."""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session
from . import grid
from .db import frame_locked
from .models import Frame, PhotoWidgetConfig, SavedLayout, Widget
from .routers.api_layouts import apply_layout_to_frame
from .widgets import WIDGET_TYPES
logger = logging.getLogger(__name__)
def cycle_layout(db: Session, frame: Frame) -> None:
"""Applies the owner's next saved layout compatible with this
frame's current grid size, in a stable order (by id), wrapping back
to the first past the last one. A silent no-op if the frame is
unclaimed or its owner has no compatible saved layouts -- same
posture as every other action here when there's nothing to do."""
if frame.owner_user_id is None:
return
cols, rows = grid.grid_dims(frame.orientation)
candidates = db.scalars(
select(SavedLayout)
.where(SavedLayout.user_id == frame.owner_user_id, SavedLayout.cols == cols, SavedLayout.rows == rows)
.order_by(SavedLayout.id)
).all()
if not candidates:
return
next_layout = candidates[0]
if frame.last_cycled_layout_id is not None:
for i, layout in enumerate(candidates):
if layout.id == frame.last_cycled_layout_id:
next_layout = candidates[(i + 1) % len(candidates)]
break
apply_layout_to_frame(db, frame, next_layout)
with frame_locked(db, frame.id) as locked:
locked.last_cycled_layout_id = next_layout.id
def refresh_all_widgets(db: Session, frame: Frame) -> None:
"""Runs every widget's own check_now (calendar/weather/whiteboard),
regardless of which button it's normally bound to -- a manual "sync
everything now" global action. One widget's failure doesn't block
the rest, same posture as routers/device.py's _run_button_actions."""
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
for widget in widgets:
module = WIDGET_TYPES.get(widget.widget_type)
check_now = module.ACTIONS.get("check_now") if module else None
if check_now is None:
continue
try:
check_now(db, frame, widget)
except Exception:
logger.exception(
"refresh_all_widgets failed for widget %d (frame %d)", widget.id, frame.id
)
def toggle_all_photo_locks(db: Session, frame: Frame) -> None:
"""Flips PhotoWidgetConfig.locked for every photo widget on the frame
at once. Target state is the opposite of "everything's already
locked" -- one hold freezes every photo widget unless they're all
already frozen, in which case it unfreezes all of them. A no-op if
the frame has no photo widgets."""
widget_ids = [
w.id for w in db.scalars(
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
)
]
if not widget_ids:
return
configs = db.scalars(
select(PhotoWidgetConfig).where(PhotoWidgetConfig.widget_id.in_(widget_ids))
).all()
if not configs:
return
target = not all(c.locked for c in configs)
with frame_locked(db, frame.id):
for config in configs:
config.locked = target
GLOBAL_ACTIONS = {
"cycle_layout": cycle_layout,
"refresh_all_widgets": refresh_all_widgets,
"toggle_all_photo_locks": toggle_all_photo_locks,
}
GLOBAL_ACTION_LABELS = {
"cycle_layout": "Cycle saved layouts",
"refresh_all_widgets": "Refresh all widgets now",
"toggle_all_photo_locks": "Freeze/unfreeze all photo widgets",
}
+54 -23
View File
@@ -24,7 +24,6 @@ from .models import (
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameTaskList,
PhotoWidgetConfig,
ServerSettings,
@@ -32,6 +31,7 @@ from .models import (
WhiteboardWidgetConfig,
Widget,
)
from .widgets import default_button_actions
logger = logging.getLogger(__name__)
@@ -725,6 +725,55 @@ def _migration_27(conn) -> None:
conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0"))
def _migration_28(conn) -> None:
"""One action per (widget, button) instead of an ordered per-button
list -- button-action editing moved from the frame-level "Button
assignments" card into each widget's own config dialog (see
models.FrameButtonAction's updated docstring, routers/api_widgets.py's
api_widget_config_save). Cross-widget execution order never actually
mattered (each widget's action only touches its own state), so this
only needs to de-dupe down to one row before the new unique index can
be created -- MIN(id) per (widget_id, button) survives, arbitrarily
but deterministically, since which specific extra binding a user's
old list happened to have doesn't matter anymore."""
conn.execute(text(
"DELETE FROM frame_button_actions WHERE id NOT IN "
"(SELECT MIN(id) FROM frame_button_actions GROUP BY widget_id, button)"
))
conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_frame_button_actions_widget_button "
"ON frame_button_actions (widget_id, button)"
))
def _migration_29(conn) -> None:
"""Hold-for-global-action (see app/global_actions.py): holding NEXT/
BACK past hold_duration_ms triggers a frame-wide action instead of
the per-widget one a short press runs. next_hold_action/
back_hold_action are NULL (disabled) by default -- existing frames
get no new button behavior until someone opts in on the
Configuration tab. last_cycled_layout_id tracks where a repeated
"cycle saved layouts" hold should resume from.
Guarded per-column, same reasoning as migration 26/27's own
comments: frames is a table test_migrations.py's pre-widget-system
replay tests leave un-dropped (unlike calendar/task/widget tables
those tests DROP and recreate in an old shape), so it keeps the
fresh-install create_all() copy -- which already has these columns
-- when those tests replay migrations 17+ from schema_version 16.
Without the guard, replaying this migration there re-adds a column
that's already there and SQLite raises "duplicate column name"."""
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
if "hold_duration_ms" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN hold_duration_ms INTEGER NOT NULL DEFAULT 3000"))
if "next_hold_action" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN next_hold_action TEXT"))
if "back_hold_action" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN back_hold_action TEXT"))
if "last_cycled_layout_id" not in existing:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -753,6 +802,8 @@ MIGRATIONS = [
(25, _migration_25),
(26, _migration_26),
(27, _migration_27),
(28, _migration_28),
(29, _migration_29),
]
@@ -963,26 +1014,6 @@ def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWid
)
def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
"""NEXT/BACK -> whatever this widget's own advance/back concept is
(see app/widgets/ for the actual action registry, built in a later
phase) -- reproduces each mode's exact old button behavior for the
one auto-migrated widget, so upgrading changes nothing about what the
physical buttons do until someone deliberately reassigns them."""
if widget_type == "whiteboard":
# No real "next"/"back" concept for a static board -- both
# buttons already meant "check now" before this migration (see
# the old _advance_whiteboard_mode/_back_whiteboard_mode).
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
]
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
]
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
"""Only relevant for a database jumping straight from before the
widget system existed to after tasks became their own widget type
@@ -1032,7 +1063,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.flush() # assign ids before the FK'd config rows reference them
db.add(_calendar_config_from_frame(frame, cal_widget.id))
db.add(_photo_config_from_frame(frame, photo_widget.id))
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar"))
_maybe_add_legacy_tasks_widget(
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
)
@@ -1048,7 +1079,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
db.add(_calendar_config_from_frame(frame, widget.id))
elif mode == "whiteboard":
db.add(_whiteboard_config_from_frame(frame, widget.id))
db.add_all(_default_button_actions(frame.id, widget.id, mode))
db.add_all(default_button_actions(frame.id, widget.id, mode))
if mode == "calendar":
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
+27 -8
View File
@@ -311,6 +311,23 @@ class Frame(Base):
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
# -- hold-for-global-action (see app/global_actions.py) -- holding
# NEXT/BACK past hold_duration_ms triggers a global action instead of
# the per-widget one that a short press runs (models.FrameButtonAction).
# Not scoped to any widget, e.g. cycling saved layouts -- hence its
# own pair of frame-level columns rather than living in that table.
hold_duration_ms: Mapped[int] = mapped_column(Integer, default=3000)
next_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
back_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
# Where "cycle saved layouts" resumes from -- the last SavedLayout id
# it applied, so repeated holds advance through the list instead of
# re-applying the same one every time. Deliberately not a real FK:
# this is just a resume cursor, not a relationship needing cascade/
# referential integrity -- if that layout's since been deleted or
# renamed away, global_actions.cycle_layout just doesn't find it and
# starts over from the first one, same as an unset value.
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- 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)
@@ -692,14 +709,15 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
class FrameButtonAction(Base):
"""One (widget, action) binding for one of a frame's two physical
buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
"advance"}. A button can have several of these (sort_order gives
execution order); on a press, every row for that (frame, button) runs
-- see routers/device.py's frame_advance/frame_back. Deliberately
unconstrained about which widget/action pairs with which button (the
user's own idea for resolving "what does NEXT even mean with several
widgets on screen": let them assign literally anything to either
button, including mismatched combinations, rather than the server
guessing a sensible default)."""
"advance"}. At most one binding per (widget, button) -- edited from
that widget's own config dialog (routers/api_widgets.py's
api_widget_config_save), prefilled with a sane default at widget
creation (app/widgets/default_button_actions). On a press, every
widget's row for that (frame, button) runs -- see routers/device.py's
frame_advance/frame_back. sort_order is unused (which widget's action
runs first never matters: each only touches its own state, and one
shared re-render happens after all of them finish) but kept around so
dispatch has a stable, deterministic query order."""
__tablename__ = "frame_button_actions"
@@ -713,6 +731,7 @@ class FrameButtonAction(Base):
__table_args__ = (
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
Index("ix_frame_button_actions_widget_button", "widget_id", "button", unique=True),
)
+25 -94
View File
@@ -22,17 +22,16 @@ 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 delete, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, grid, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES
from ..models import BatteryLog, Frame, Widget
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
from .device import render_frame_preview_png
@@ -45,6 +44,11 @@ MAX_REFRESH_INTERVAL_S = 86400
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
# See app/global_actions.py -- how long NEXT/BACK must be held before the
# device treats it as a hold instead of a short press.
MIN_HOLD_DURATION_MS = 3000
MAX_HOLD_DURATION_MS = 10000
def _reset_widget_layout_for_new_orientation(db: Session, frame_id: int, new_orientation: str) -> None:
"""A widget's x/y/w/h are grid cells relative to the OLD orientation's
@@ -101,6 +105,9 @@ def api_config_save(
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
hold_duration_ms: int | None = Form(None),
next_hold_action: str | None = Form(None),
back_hold_action: str | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
@@ -120,7 +127,14 @@ def api_config_save(
_reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a
landscape<->portrait change, so an old placement is usually not just
visually wrong but literally out of bounds on the new grid."""
visually wrong but literally out of bounds on the new grid.
hold_duration_ms/next_hold_action/back_hold_action configure hold-
for-global-action (see app/global_actions.py) -- a frame-wide
setting, not per-widget, hence living here rather than on
api_widgets.py's per-widget button-actions endpoint. An unrecognized
action value clears the binding rather than erroring, same posture
as this endpoint's other enum-ish fields (orientation, timezone)."""
with frame_locked(db, frame.id) as cfg:
if name is not None:
cfg.name = name.strip()[:64] or cfg.name
@@ -168,6 +182,12 @@ def api_config_save(
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
if dither_strength is not None:
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
if hold_duration_ms is not None:
cfg.hold_duration_ms = max(MIN_HOLD_DURATION_MS, min(MAX_HOLD_DURATION_MS, hold_duration_ms))
if next_hold_action is not None:
cfg.next_hold_action = next_hold_action if next_hold_action in GLOBAL_ACTIONS else None
if back_hold_action is not None:
cfg.back_hold_action = back_hold_action if back_hold_action in GLOBAL_ACTIONS else None
cfg.stats_config_saves += 1
return {"status": "saved"}
@@ -251,95 +271,6 @@ def api_frame_preview(
return Response(content=png, media_type="image/png")
BUTTONS = ("next", "back")
@router.get("/api/frames/{frame_id}/buttons")
def api_buttons_get(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""Everything the button-assignment UI needs in one call: every
widget on the frame with the actions its type supports (see
app/widgets/*.py's ACTIONS/ACTION_LABELS), plus each button's current
ordered list of (widget, action) bindings.
Includes each widget's placement (x/y/w/h) and the frame's grid
dimensions -- two widgets of the same type otherwise look identical
in the assignment UI's dropdowns (both just say "Photos"); the
client derives a position label ("top-left" etc.) from this to tell
them apart, the same way you'd tell them apart by eye on the Layout
canvas."""
cols, rows = grid.grid_dims(frame.orientation)
widgets = db.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
widget_options = [
{
"id": w.id,
"widget_type": w.widget_type,
"x": w.x, "y": w.y, "w": w.w, "h": w.h,
"actions": [
{"action": action, "label": label}
for action, label in getattr(WIDGET_TYPES.get(w.widget_type), "ACTION_LABELS", {}).items()
],
}
for w in widgets
]
result = {"widgets": widget_options, "grid": {"cols": cols, "rows": rows}}
for button in BUTTONS:
rows = db.scalars(
select(FrameButtonAction)
.where(FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button)
.order_by(FrameButtonAction.sort_order)
).all()
result[button] = [{"id": r.id, "widget_id": r.widget_id, "action": r.action} for r in rows]
return result
class ButtonActionItem(BaseModel):
widget_id: int
action: str
class ButtonActionsRequest(BaseModel):
actions: list[ButtonActionItem]
@router.put("/api/frames/{frame_id}/buttons/{button}")
def api_buttons_save(
button: str, body: ButtonActionsRequest,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Replaces the whole ordered action list for one button in a single
call -- simpler and more atomic than separate add/remove/reorder
endpoints for what's normally a list of one to a handful of entries,
and the UI always has the full list in hand anyway (see
static/frame_config.js)."""
if button not in BUTTONS:
raise HTTPException(404, "No such button")
widgets_by_id = {w.id: w for w in db.scalars(select(Widget).where(Widget.frame_id == frame.id))}
for item in body.actions:
widget = widgets_by_id.get(item.widget_id)
if widget is None:
raise HTTPException(400, f"No such widget: {item.widget_id}")
module = WIDGET_TYPES.get(widget.widget_type)
if module is None or item.action not in module.ACTIONS:
raise HTTPException(
400, f"{widget.widget_type} widgets don't support the {item.action!r} action"
)
with frame_locked(db, frame.id):
db.execute(
delete(FrameButtonAction).where(
FrameButtonAction.frame_id == frame.id, FrameButtonAction.button == button
)
)
for i, item in enumerate(body.actions):
db.add(FrameButtonAction(
frame_id=frame.id, button=button, widget_id=item.widget_id, action=item.action,
sort_order=i, created_at=time.time(),
))
db.commit()
return {"status": "saved"}
@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(
+29 -17
View File
@@ -237,25 +237,19 @@ def api_layout_delete(layout_id: int, request: Request, db: Session = Depends(ge
return {"status": "deleted"}
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply")
def api_layout_apply(
layout_id: int, request: Request,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Replaces this frame's entire widget arrangement with a saved
layout's -- every current widget (and its own config/sources/button
actions, all ondelete="CASCADE") is deleted first, same "act
unconditionally on the server, confirm on the client" posture as
def apply_layout_to_frame(db: Session, frame: Frame, layout: SavedLayout) -> int:
"""Replaces frame's entire widget arrangement with layout's snapshot
-- every current widget (and its own config/sources/button actions,
all ondelete="CASCADE") is deleted first, same "act unconditionally
on the server, confirm on the client" posture as
api_widgets.api_widgets_clear. A source whose owning user account
(or a whiteboard's user_id) no longer exists is silently dropped
rather than left dangling -- config is JSON, not FK-checked, so
nothing enforces that at the storage layer."""
user = require_user_api(request, db)
layout = _user_owned_layout(db, layout_id, user)
cols, rows = grid.grid_dims(frame.orientation)
if (layout.cols, layout.rows) != (cols, rows):
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
nothing enforces that at the storage layer. Shared by api_layout_apply
(explicit user action) and global_actions.cycle_layout (a hold-
triggered global action, see app/global_actions.py) -- caller is
responsible for checking the grid-size match first. Returns the
number of widgets applied."""
snapshots = db.scalars(
select(SavedLayoutWidget)
.where(SavedLayoutWidget.saved_layout_id == layout.id)
@@ -318,4 +312,22 @@ def api_layout_apply(
sort_order=action.sort_order, created_at=time.time(),
))
db.commit()
return {"status": "applied", "widget_count": len(snapshots)}
return len(snapshots)
@router.post("/api/frames/{frame_id}/layouts/{layout_id}/apply")
def api_layout_apply(
layout_id: int, request: Request,
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
):
"""Replaces this frame's entire widget arrangement with a saved
layout's -- see apply_layout_to_frame above for what that actually
does."""
user = require_user_api(request, db)
layout = _user_owned_layout(db, layout_id, user)
cols, rows = grid.grid_dims(frame.orientation)
if (layout.cols, layout.rows) != (cols, rows):
raise HTTPException(400, "This layout was saved for a different frame size/orientation")
widget_count = apply_layout_to_frame(db, frame, layout)
return {"status": "applied", "widget_count": widget_count}
+51 -1
View File
@@ -45,6 +45,7 @@ from ..image_upload import decode_upload
from ..models import (
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
@@ -57,7 +58,7 @@ from ..models import (
Widget,
)
from ..text_content import has_text, parse_rich_text
from ..widgets import WIDGET_TYPES
from ..widgets import WIDGET_TYPES, default_button_actions
from ..widgets import battery as battery_widget
from ..widgets import text as text_widget
from .common import (
@@ -214,6 +215,7 @@ def api_widget_create(
db.add(widget)
db.flush()
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
db.add_all(default_button_actions(frame.id, widget.id, body.widget_type))
db.commit()
return _widget_dict(widget)
@@ -476,6 +478,54 @@ def api_widget_config_save(
return {"status": "saved"}
class WidgetButtonActionsRequest(BaseModel):
next_button_action: str
back_button_action: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/button-actions")
def api_widget_button_actions(
body: WidgetButtonActionsRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Sets this widget's NEXT/BACK button bindings (models.FrameButtonAction)
-- its own endpoint, not folded into api_widget_config_save, same
reasoning as api_widget_border above: these rows live in their own
table, not this widget's per-type config table. Replaces the old
frame-level "Button assignments" card (routers/api_frames.py's
api_buttons_get/api_buttons_save, now removed) -- each widget's own
dialog edits its own binding directly, prefilled at widget-creation
time with a sane default (see widgets.default_button_actions).
An empty string clears the binding for that button. Unlike
api_widget_config_save's silent-ignore-unrecognized-value posture,
a value outside this widget type's own ACTIONS is a 400 -- this
request body is specifically about button actions, so a bad value
here is a real client bug worth surfacing, not a stray field to
shrug off."""
frame, widget = frame_widget
valid_actions = set(WIDGET_TYPES[widget.widget_type].ACTIONS)
for value in (body.next_button_action, body.back_button_action):
if value != "" and value not in valid_actions:
raise HTTPException(400, f"{widget.widget_type} widgets don't support the {value!r} action")
with frame_locked(db, frame.id):
for button, value in (("next", body.next_button_action), ("back", body.back_button_action)):
existing = db.scalars(
select(FrameButtonAction).where(
FrameButtonAction.widget_id == widget.id, FrameButtonAction.button == button
)
).first()
if value == "":
if existing is not None:
db.delete(existing)
elif existing is not None:
existing.action = value
else:
db.add(FrameButtonAction(frame_id=frame.id, button=button, widget_id=widget.id, action=value))
db.commit()
return {"status": "saved"}
# --- Photos: queue/thumbnail/preview ------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
+46
View File
@@ -25,6 +25,7 @@ from .. import grid, mail, quiet_hours
from ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db
from ..firmware import firmware_path
from ..global_actions import GLOBAL_ACTIONS
from ..image_pipeline import draw_widget_border, logical_render_size, render_panel, render_placeholder, resolve_border_color
from ..models import BatteryLog, Frame, FrameButtonAction, Widget
from ..widgets import WIDGET_TYPES
@@ -184,6 +185,22 @@ def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
)
def _run_global_action(db: Session, frame: Frame, button: str) -> None:
"""The hold-triggered counterpart to _run_button_actions -- runs
whichever entry in app/global_actions.GLOBAL_ACTIONS this button's
Frame.next_hold_action/back_hold_action points to, if any (unset or
unrecognized is a silent no-op, same posture as an unbound short-
press button). See routers/device.py's frame_global_next/back."""
action = frame.next_hold_action if button == "next" else frame.back_hold_action
action_fn = GLOBAL_ACTIONS.get(action) if action else None
if action_fn is None:
return
try:
action_fn(db, frame)
except Exception:
logger.exception("Global hold action %r failed for frame %d", action, frame.id)
@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
@@ -209,6 +226,11 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
response = {
"refresh_interval_s": quiet_hours.effective_refresh_interval_s(locked),
"firmware_version": locked.firmware_available_version or None,
# Additive key -- old firmware's hand-rolled parser only ever
# extracts the keys it knows about, so this is safe for
# firmware that predates hold-for-global-action (see
# firmware/main/next_button.c, app/global_actions.py).
"hold_duration_ms": locked.hold_duration_ms,
}
# Per-frame token push: only once the device has introduced itself
# by id (so the response to pure-legacy firmware stays byte-
@@ -270,6 +292,30 @@ def frame_back(request: Request, frame: Frame = Depends(require_device), db: Ses
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/global-next")
def frame_global_next(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""Fires when the device detects NEXT held past Frame.hold_duration_ms
instead of a short press -- runs Frame.next_hold_action (see
app/global_actions.GLOBAL_ACTIONS) if one is set, then re-renders and
returns the whole panel same as /frame/advance. A separate endpoint
from /frame/advance (not a query flag on it) so the frozen short-press
path's behavior never has to account for the long-press case -- see
firmware/main/next_button.c for the short/long split."""
_run_global_action(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
return Response(content=content, media_type="application/octet-stream")
@router.post("/frame/global-back")
def frame_global_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
"""The mirror of /frame/global-next, for a held BACK button."""
_run_global_action(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
return Response(content=content, media_type="application/octet-stream")
class BatteryReport(BaseModel):
percent: int
+31 -8
View File
@@ -21,6 +21,7 @@ from .. import weather
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
from ..global_actions import GLOBAL_ACTION_LABELS
from ..image_pipeline import (
BORDER_STYLES,
BORDER_STYLE_LABELS,
@@ -36,6 +37,7 @@ from ..models import (
BatteryWidgetConfig,
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
@@ -49,6 +51,7 @@ from ..models import (
Widget,
)
from ..quiet_hours import ALL_TIMEZONES
from ..widgets import WIDGET_TYPES
from ..widgets import text as text_widget
from .common import shell_context, widget_of_type
@@ -88,6 +91,7 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
photo_widget_id=photo_widget_id,
global_action_labels=GLOBAL_ACTION_LABELS,
)
@@ -232,11 +236,30 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"max_border_thickness": MAX_BORDER_THICKNESS,
}
# Every dialog also includes the shared "Button actions" card
# (_widget_button_fields.html) if this widget type supports any --
# empty for tasks/static/text/battery, so the card renders nothing
# for those. Bindings, not the type's own config, so this lives in
# FrameButtonAction (see models.py), same reasoning as border_ctx
# above for why it's a separate card/endpoint from the type-specific
# form.
bindings = {
row.button: row.action
for row in db.scalars(
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id)
)
}
button_ctx = {
"button_action_labels": WIDGET_TYPES[widget.widget_type].ACTION_LABELS,
"next_button_action": bindings.get("next", ""),
"back_button_action": bindings.get("back", ""),
}
if widget.widget_type == "photos":
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_photos.html", {
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
"display_mode_labels": DISPLAY_MODE_LABELS, **border_ctx,
"display_mode_labels": DISPLAY_MODE_LABELS, **border_ctx, **button_ctx,
})
if widget.widget_type == "calendar":
@@ -247,7 +270,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
"week_start_labels": WEEK_START_LABELS,
"calendar_color_labels": PALETTE_LABELS,
**border_ctx,
**border_ctx, **button_ctx,
})
if widget.widget_type == "tasks":
@@ -256,7 +279,7 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
"task_color_labels": PALETTE_LABELS,
**border_ctx,
**border_ctx, **button_ctx,
})
if widget.widget_type == "static":
@@ -264,14 +287,14 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
return templates.TemplateResponse("_widget_dialog_static.html", {
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
**border_ctx,
**border_ctx, **button_ctx,
})
if widget.widget_type == "text":
text_cfg = db.get(TextWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_text.html", {
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
"text_font_families": text_widget.FONT_FAMILIES, **border_ctx,
"text_font_families": text_widget.FONT_FAMILIES, **border_ctx, **button_ctx,
})
if widget.widget_type == "whiteboard":
@@ -282,20 +305,20 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session =
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
"request": request, "frame": frame, "widget": widget, "user": user,
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx,
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
})
if widget.widget_type == "weather":
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_weather.html", {
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
"weather_provider_labels": weather.PROVIDER_LABELS, **border_ctx,
"weather_provider_labels": weather.PROVIDER_LABELS, **border_ctx, **button_ctx,
})
if widget.widget_type == "battery":
battery_cfg = db.get(BatteryWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_battery.html", {
"request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, **border_ctx,
"request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, **border_ctx, **button_ctx,
})
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
+25 -200
View File
@@ -59,6 +59,31 @@ document.getElementById('config-form').addEventListener('submit', async (e) => {
}
});
// Hold-for-global-action (see app/global_actions.py) -- a frame-wide
// setting, not per-widget, so it shares api_config_save/the /config
// endpoint rather than getting its own -- just a separate card/form on
// this page for a distinct-enough concern.
document.getElementById('hold-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const seconds = parseInt(document.getElementById('hold_duration_s').value, 10) || 3;
const body = new URLSearchParams({
hold_duration_ms: String(seconds * 1000),
next_hold_action: document.getElementById('next_hold_action').value,
back_hold_action: document.getElementById('back_hold_action').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.');
} catch (err) {
showStatus(false, err.message);
}
});
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
@@ -363,203 +388,3 @@ loadFirmwareCheck();
// cheap either way.
setInterval(loadFirmwareCheck, 60000);
// --- Button assignments -----------------------------------------------
// {widgets: [{id, widget_type, x, y, w, h, actions: [{action, label}]}],
// grid: {cols, rows}, next: [...], back: [...]} -- see api_frames.py's
// api_buttons_get. Each button's list is edited client-side (add/
// remove/reorder) then PUT as a whole -- simpler than separate reorder/
// add/remove endpoints for what's normally a handful of entries, and
// this file already has the full list in hand after any edit.
let buttonsData = null;
let widgetNames = {}; // widget id -> disambiguated display name, see buildWidgetNames
const BUTTONS = ['next', 'back'];
// "top-left"/"bottom"/"center" etc. from a widget's grid rect vs the
// frame's grid dims -- the same rough position you'd read off the
// Layout canvas by eye, used to tell apart two widgets of the same type
// that would otherwise both just say "Photos".
function widgetPositionLabel(w, grid) {
const cx = w.x + w.w / 2;
const cy = w.y + w.h / 2;
const horiz = cx < grid.cols / 2 ? 'left' : (cx > grid.cols / 2 ? 'right' : '');
const vert = cy < grid.rows / 2 ? 'top' : (cy > grid.rows / 2 ? 'bottom' : '');
if (!horiz && !vert) return 'center';
if (!vert) return horiz;
if (!horiz) return vert;
return `${vert}-${horiz}`;
}
// A single widget of a given type keeps the plain type name ("Photos")
// -- the common case, no need to clutter it. Only widgets sharing a
// type with another widget on the same frame get a number + position
// suffix, numbered in reading order (top-to-bottom, left-to-right).
function buildWidgetNames(widgets, grid) {
const byType = {};
widgets.forEach((w) => { (byType[w.widget_type] = byType[w.widget_type] || []).push(w); });
const names = {};
Object.values(byType).forEach((group) => {
if (group.length === 1) {
names[group[0].id] = WIDGET_LABELS[group[0].widget_type] || group[0].widget_type;
return;
}
const ordered = [...group].sort((a, b) => (a.y - b.y) || (a.x - b.x));
ordered.forEach((w, i) => {
const base = WIDGET_LABELS[w.widget_type] || w.widget_type;
names[w.id] = `${base} ${i + 1} (${widgetPositionLabel(w, grid)})`;
});
});
return names;
}
function widgetActionLabel(widgetId, action) {
const w = buttonsData.widgets.find((w) => w.id === widgetId);
if (!w) return `(deleted widget): ${action}`;
const found = w.actions.find((a) => a.action === action);
const actionLabel = found ? found.label : action;
return `${widgetNames[widgetId] || WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`;
}
function renderButtonList(button) {
const list = document.getElementById(`button-actions-${button}`);
const rows = buttonsData[button];
list.innerHTML = '';
if (!rows.length) {
list.innerHTML = '<li class="sub">Nothing assigned -- this button wont do anything.</li>';
return;
}
rows.forEach((row, i) => {
const li = document.createElement('li');
li.className = 'button-action-row';
const span = document.createElement('span');
span.textContent = widgetActionLabel(row.widget_id, row.action);
const controls = document.createElement('span');
controls.className = 'button-action-controls';
const up = document.createElement('button');
up.type = 'button';
up.className = 'icon-btn';
up.textContent = '↑';
up.title = 'Move up';
up.disabled = i === 0;
up.addEventListener('click', () => moveButtonAction(button, i, -1));
const down = document.createElement('button');
down.type = 'button';
down.className = 'icon-btn';
down.textContent = '↓';
down.title = 'Move down';
down.disabled = i === rows.length - 1;
down.addEventListener('click', () => moveButtonAction(button, i, 1));
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'icon-btn';
remove.textContent = '×';
remove.title = 'Remove';
remove.addEventListener('click', () => removeButtonAction(button, i));
controls.appendChild(up);
controls.appendChild(down);
controls.appendChild(remove);
li.appendChild(span);
li.appendChild(controls);
list.appendChild(li);
});
}
function populateActionSelect(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
const actionSel = document.getElementById(`button-add-action-${button}`);
actionSel.innerHTML = '';
const w = buttonsData.widgets.find((w) => String(w.id) === widgetSel.value);
if (!w) return;
w.actions.forEach((a) => {
const opt = document.createElement('option');
opt.value = a.action;
opt.textContent = a.label;
actionSel.appendChild(opt);
});
}
function populateWidgetSelect(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
widgetSel.innerHTML = '';
buttonsData.widgets.forEach((w) => {
const opt = document.createElement('option');
opt.value = w.id;
opt.textContent = widgetNames[w.id] || WIDGET_LABELS[w.widget_type] || w.widget_type;
widgetSel.appendChild(opt);
});
populateActionSelect(button);
}
async function saveButtonActions(button) {
try {
const resp = await fetch(`${window.FRAME_BASE_API}/buttons/${button}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actions: buttonsData[button].map((r) => ({ widget_id: r.widget_id, action: r.action })),
}),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Button assignments saved.');
} catch (e) {
showStatus(false, e.message);
await loadButtons(); // resync with server truth rather than leave a stale edit on screen
}
}
function moveButtonAction(button, index, delta) {
const rows = buttonsData[button];
const target = index + delta;
if (target < 0 || target >= rows.length) return;
[rows[index], rows[target]] = [rows[target], rows[index]];
renderButtonList(button);
saveButtonActions(button);
}
function removeButtonAction(button, index) {
buttonsData[button].splice(index, 1);
renderButtonList(button);
saveButtonActions(button);
}
function addButtonAction(button) {
const widgetSel = document.getElementById(`button-add-widget-${button}`);
const actionSel = document.getElementById(`button-add-action-${button}`);
if (!widgetSel.value || !actionSel.value) return;
buttonsData[button].push({ widget_id: Number(widgetSel.value), action: actionSel.value });
renderButtonList(button);
saveButtonActions(button);
}
async function loadButtons() {
try {
const resp = await fetch(`${window.FRAME_BASE_API}/buttons`);
if (!resp.ok) throw new Error(await apiError(resp));
buttonsData = await resp.json();
widgetNames = buildWidgetNames(buttonsData.widgets, buttonsData.grid);
document.getElementById('button-assign-groups').style.display =
buttonsData.widgets.length ? '' : 'none';
document.getElementById('button-assign-empty-hint').style.display =
buttonsData.widgets.length ? 'none' : '';
BUTTONS.forEach((button) => {
renderButtonList(button);
populateWidgetSelect(button);
});
} catch (e) {
showStatus(false, e.message);
}
}
BUTTONS.forEach((button) => {
document.getElementById(`button-add-widget-${button}`)
.addEventListener('change', () => populateActionSelect(button));
document.getElementById(`button-add-${button}`)
.addEventListener('click', () => addButtonAction(button));
});
loadButtons();
-18
View File
@@ -265,22 +265,6 @@ input:focus, select:focus {
box-shadow: 0 0 0 3px var(--focus-ring);
}
.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; }
.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.button-action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
}
.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; }
.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; }
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
.saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.saved-layout-add input { width: auto; flex: 1 1 200px; margin-top: 0; }
.saved-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
@@ -308,8 +292,6 @@ input:focus, select:focus {
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
.saved-layout-controls .btn-inline { margin: 0; }
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.button-action-add select { width: auto; margin-top: 0; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
.checkbox-row input { width: auto; margin-top: 0; }
@@ -31,6 +31,7 @@ function initBatteryDialog() {
document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview);
loadBatteryPreview();
initBorderFields();
initButtonActionFields();
}
function closeBatteryDialog() {
@@ -0,0 +1,33 @@
// Shared "Button actions" card (models.FrameButtonAction,
// _widget_button_fields.html) -- present on every widget type's dialog
// that supports any actions at all (the card renders nothing for
// tasks/static/text/battery, whose ACTIONS is empty), so this is one
// shared init function each widget_dialog_<type>.js's init<Type>Dialog()
// calls, rather than N copies of the same save wiring -- same pattern as
// widget_dialog_border.js. Not a page-load script by itself --
// frame_layout.js loads it unconditionally (like every other
// widget_dialog_*.js) since which dialog is open varies.
function initButtonActionFields() {
const form = document.getElementById('button-actions-form');
if (!form) return; // this widget type has no actions -- card didn't render
form.addEventListener('submit', async (e) => {
e.preventDefault();
const body = JSON.stringify({
next_button_action: document.getElementById('next_button_action').value,
back_button_action: document.getElementById('back_button_action').value,
});
try {
const resp = await fetch(`${window.FRAME_API}/button-actions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Button actions saved.');
} catch (err) {
showStatus(false, err.message);
}
});
}
@@ -198,6 +198,7 @@ function initCalendarDialog() {
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
initBorderFields();
initButtonActionFields();
}
function closeCalendarDialog() {
@@ -148,6 +148,7 @@ function initPhotosDialog() {
// from elsewhere) without a manual refresh. Skipped mid-drag.
photosPollTimer = setInterval(loadQueue, 10000);
initBorderFields();
initButtonActionFields();
}
function closePhotosDialog() {
@@ -56,6 +56,7 @@ function initStaticDialog() {
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
loadStaticPreview();
initBorderFields();
initButtonActionFields();
}
function closeStaticDialog() {
+1
View File
@@ -89,6 +89,7 @@ function initTasksDialog() {
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
loadTasksPreview();
initBorderFields();
initButtonActionFields();
}
function closeTasksDialog() {
+1
View File
@@ -131,6 +131,7 @@ function initTextDialog() {
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
loadTextPreview();
initBorderFields();
initButtonActionFields();
}
function closeTextDialog() {
@@ -152,6 +152,7 @@ function initWeatherDialog() {
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
loadWeatherPreview(false);
initBorderFields();
initButtonActionFields();
}
function closeWeatherDialog() {
@@ -97,6 +97,7 @@ function initWhiteboardDialog() {
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
loadWhiteboardPreview(false);
initBorderFields();
initButtonActionFields();
// --- file picker (Browse...) ---
const browseToggle = document.getElementById('whiteboard-browse-toggle');
@@ -0,0 +1,27 @@
{% if button_action_labels %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Button actions</h2>
<p class="sub">What the frame's physical NEXT/BACK buttons do to this
widget. Every widget on the frame runs its own binding when a
button is pressed -- this only affects this one.</p>
<form id="button-actions-form">
<label>Next button
<select id="next_button_action">
<option value="" {% if not next_button_action %}selected{% endif %}>(none)</option>
{% for action, label in button_action_labels.items() %}
<option value="{{ action }}" {% if next_button_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Back button
<select id="back_button_action">
<option value="" {% if not back_button_action %}selected{% endif %}>(none)</option>
{% for action, label in button_action_labels.items() %}
<option value="{{ action }}" {% if back_button_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
{% endif %}
@@ -17,6 +17,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -129,6 +129,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -43,6 +43,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
@@ -38,6 +38,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -61,6 +61,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -49,6 +49,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -75,6 +75,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
@@ -52,6 +52,8 @@
{% include "_widget_border_fields.html" %}
{% include "_widget_button_fields.html" %}
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
+30 -30
View File
@@ -55,37 +55,37 @@
</section>
<section class="card">
<h2 class="card-title">Button assignments</h2>
<p class="sub">What the frame's physical NEXT and BACK buttons do --
assign one or more widget actions to each, in the order they
should run. A button with several actions runs all of them, in
order, then the panel redraws once.</p>
<p class="sub" id="button-assign-empty-hint" style="display: none;">
Add a widget on the Layout tab first -- there's nothing to assign
a button to yet.</p>
<div id="button-assign-groups">
<div class="button-assign-group">
<h3 class="button-assign-label">NEXT button</h3>
<ul class="button-action-list" id="button-actions-next"></ul>
<div class="button-action-add">
<select id="button-add-widget-next"></select>
<select id="button-add-action-next"></select>
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button>
</div>
</div>
<div class="button-assign-group" style="margin-top: 20px;">
<h3 class="button-assign-label">BACK button</h3>
<ul class="button-action-list" id="button-actions-back"></ul>
<div class="button-action-add">
<select id="button-add-widget-back"></select>
<select id="button-add-action-back"></select>
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button>
</div>
</div>
</div>
<h2 class="card-title">Hold actions</h2>
<p class="sub">Holding NEXT or BACK past this duration runs a
frame-wide action instead of each widget's normal short-press
binding (set per-widget in that widget's own config dialog).
Not scoped to any widget -- e.g. cycling through your saved
layouts.</p>
<form id="hold-config-form">
<label>Hold duration (seconds)
<input type="number" id="hold_duration_s" min="3" max="10"
value="{{ (frame.hold_duration_ms // 1000) or 3 }}" required>
</label>
<label>NEXT held action
<select id="next_hold_action">
<option value="" {% if not frame.next_hold_action %}selected{% endif %}>(none)</option>
{% for action, label in global_action_labels.items() %}
<option value="{{ action }}" {% if frame.next_hold_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>BACK held action
<select id="back_hold_action">
<option value="" {% if not frame.back_hold_action %}selected{% endif %}>(none)</option>
{% for action, label in global_action_labels.items() %}
<option value="{{ action }}" {% if frame.back_hold_action == action %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Save</button>
</form>
</section>
</div>
<div class="side-col">
+1
View File
@@ -77,6 +77,7 @@
<script src="/static/widget_dialog_weather.js"></script>
<script src="/static/widget_dialog_battery.js"></script>
<script src="/static/widget_dialog_border.js"></script>
<script src="/static/widget_dialog_button_actions.js"></script>
<script src="/static/frame_layout.js"></script>
<script src="/static/saved_layouts.js"></script>
{% endblock %}
+29
View File
@@ -36,6 +36,7 @@ Each module in this package exposes:
from __future__ import annotations
from ..models import FrameButtonAction
from . import battery, calendar, photos, static_image, tasks, text, weather, whiteboard
WIDGET_TYPES = {
@@ -48,3 +49,31 @@ WIDGET_TYPES = {
"weather": weather,
"battery": battery,
}
def default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
"""NEXT/BACK -> whatever this widget's own advance/back concept is.
Called both when backfilling pre-widget-system frames (see
app/migration.py) and when a widget is newly created (see
routers/api_widgets.py's api_widget_create) -- a widget is never left
without a sane starting binding, so the physical buttons always do
something reasonable for it until someone deliberately reassigns
them in that widget's own config dialog."""
if widget_type == "whiteboard":
# No real "next"/"back" concept for a static board -- both
# buttons mean "check now".
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
]
if widget_type == "weather":
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
]
if widget_type in ("photos", "calendar"):
return [
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
]
return []
+131 -119
View File
@@ -1,11 +1,13 @@
"""GET/PUT /api/frames/{id}/buttons -- the button-assignment UI's API
(see routers/api_frames.py's api_buttons_get/api_buttons_save and
static/frame_config.js). Covers the CRUD/validation layer; multi-action
execution order and partial-failure-continues on an actual button press
are already exercised end-to-end in test_device_widget_dispatch.py and
routers/device.py's _run_button_actions -- this file doesn't re-test
device.py's dispatch, just that the assignment API stores/serves/
validates what the UI edits."""
"""POST /api/frames/{id}/widgets/{widget_id}/button-actions -- each
widget's own NEXT/BACK binding editor (see routers/api_widgets.py's
api_widget_button_actions and models.FrameButtonAction), replacing the
old frame-level "Button assignments" card. Covers the CRUD/validation
layer; multi-widget dispatch and partial-failure-continues on an actual
button press are already exercised end-to-end in
test_device_widget_dispatch.py and routers/device.py's
_run_button_actions -- this file doesn't re-test device.py's dispatch,
just that each widget's own binding endpoint stores/validates what its
dialog edits."""
from __future__ import annotations
@@ -16,6 +18,19 @@ from app.models import Frame, FrameButtonAction, Widget, WhiteboardWidgetConfig
from .conftest import csrf_headers, link_user, login, make_user
def _photo_widget_id(db_session) -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one().id
def _shrink_default_widget(client, db_session, w=4, h=5) -> int:
"""Frees up the right-hand side of the grid for a second widget."""
widget_id = _photo_widget_id(db_session)
resp = client.patch(f"/api/frames/1/widgets/{widget_id}",
json={"x": 0, "y": 0, "w": w, "h": h}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
return widget_id
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
@@ -26,153 +41,150 @@ def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
return widget
def test_get_buttons_reflects_the_default_migration_mapping(client, db_session):
"""Frame #1's auto-migrated photos widget should already have NEXT ->
advance, BACK -> back from _default_button_actions (see
migration.py) -- the UI just needs to be able to see that default."""
def test_new_widget_gets_the_sane_default_binding(client, db_session):
"""A brand new photos widget (via the API, not the migration
backfill) should already have NEXT -> advance, BACK -> back from
widgets.default_button_actions -- no separate save required."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
_shrink_default_widget(client, db_session)
resp = client.post("/api/frames/1/widgets", json={"widget_type": "calendar"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget_id = resp.json()["id"]
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 200
data = resp.json()
assert {w["id"]: w["widget_type"] for w in data["widgets"]} == {photo_widget.id: "photos"}
photo_actions = {a["action"] for w in data["widgets"] for a in w["actions"]}
assert photo_actions == {"advance", "back"}
assert data["next"] == [{"id": data["next"][0]["id"], "widget_id": photo_widget.id, "action": "advance"}]
assert data["back"] == [{"id": data["back"][0]["id"], "widget_id": photo_widget.id, "action": "back"}]
rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
assert {r.button: r.action for r in rows} == {"next": "advance", "back": "back"}
def test_get_buttons_includes_placement_and_grid_dims(client, db_session):
"""Two widgets of the same type otherwise look identical in the
assignment UI ("Photos" / "Photos") -- the client tells them apart
using x/y/w/h against the frame's grid dims (see
static/frame_config.js's buildWidgetNames), so the API needs to
actually hand those over."""
def test_new_whiteboard_widget_gets_check_now_on_both_buttons(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
photo_widget.x, photo_widget.y, photo_widget.w, photo_widget.h = 0, 0, 4, 5
db_session.commit()
second = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
sort_order=1, created_at=time.time())
db_session.add(second)
db_session.flush()
from app.models import PhotoWidgetConfig
db_session.add(PhotoWidgetConfig(widget_id=second.id))
db_session.commit()
_shrink_default_widget(client, db_session)
resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget_id = resp.json()["id"]
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 200
data = resp.json()
assert data["grid"] == {"cols": 8, "rows": 5}
by_id = {w["id"]: w for w in data["widgets"]}
assert by_id[photo_widget.id]["x"] == 0 and by_id[photo_widget.id]["w"] == 4
assert by_id[second.id]["x"] == 4 and by_id[second.id]["w"] == 4
rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
assert {r.button: r.action for r in rows} == {"next": "check_now", "back": "check_now"}
def test_put_replaces_the_whole_list_in_order(client, db_session):
def test_new_tasks_widget_gets_no_default_bindings(client, db_session):
"""tasks has an empty ACTIONS -- nothing sane to default to."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
board_widget = _add_whiteboard_widget(db_session, frame)
_shrink_default_widget(client, db_session)
resp = client.post("/api/frames/1/widgets", json={"widget_type": "tasks"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget_id = resp.json()["id"]
resp = client.put("/api/frames/1/buttons/next", json={
"actions": [
{"widget_id": board_widget.id, "action": "check_now"},
{"widget_id": photo_widget.id, "action": "advance"},
],
}, headers=csrf_headers(client))
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).count() == 0
def test_save_updates_an_existing_binding(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _photo_widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "back", "back_button_action": "advance"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
rows = db_session.query(FrameButtonAction).filter_by(
frame_id=frame.id, button="next"
).order_by(FrameButtonAction.sort_order).all()
assert [(r.widget_id, r.action) for r in rows] == [
(board_widget.id, "check_now"), (photo_widget.id, "advance"),
]
# BACK's own default mapping (photos -> back) is untouched by a PUT to next.
back_rows = db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="back").all()
assert len(back_rows) == 1
assert back_rows[0].action == "back"
rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)}
assert rows == {"next": "back", "back": "advance"}
def test_put_empty_list_clears_the_button(client, db_session):
def test_save_empty_string_clears_the_binding(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _photo_widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "", "back_button_action": "back"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)}
assert rows == {"back": "back"}
def test_save_rejects_action_the_widget_type_does_not_support(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _photo_widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "check_now", "back_button_action": ""},
headers=csrf_headers(client))
assert resp.status_code == 400
# Nothing partially applied -- the old default binding is untouched.
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").one().action == "advance"
def test_save_is_one_binding_per_widget_per_button(client, db_session):
"""Saving twice for the same button updates the one row in place,
it never accumulates a second row (matches the new unique index on
(widget_id, button))."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
widget_id = _photo_widget_id(db_session)
resp = client.put("/api/frames/1/buttons/next", json={"actions": []}, headers=csrf_headers(client))
for action in ("advance", "back", "advance"):
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": action, "back_button_action": "back"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="next").count() == 0
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").count() == 1
def test_put_rejects_unknown_button_name(client, db_session):
def test_save_404s_for_unknown_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.put("/api/frames/1/buttons/sideways", json={"actions": []}, headers=csrf_headers(client))
resp = client.post("/api/frames/1/widgets/999999/button-actions",
json={"next_button_action": "advance", "back_button_action": "back"},
headers=csrf_headers(client))
assert resp.status_code == 404
def test_put_rejects_widget_from_another_frame(client, db_session):
def test_save_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
other = Frame(name="Other", device_token="tok-other", manage_token="mtok-other", created_at=time.time())
db_session.add(other)
db_session.flush()
other_widget = Widget(frame_id=other.id, widget_type="photos", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time())
db_session.add(other_widget)
db_session.commit()
make_user(db_session, "mallory")
widget_id = _photo_widget_id(db_session)
resp = client.put("/api/frames/1/buttons/next", json={
"actions": [{"widget_id": other_widget.id, "action": "advance"}],
}, headers=csrf_headers(client))
assert resp.status_code == 400
# Nothing partially applied -- the whole request is validated before any write.
assert db_session.query(FrameButtonAction).filter_by(frame_id=1, button="next").count() == 1
client.cookies.clear()
login(client, "mallory")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "back", "back_button_action": "advance"},
headers=csrf_headers(client))
assert resp.status_code == 404
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").one().action == "advance"
def test_put_rejects_action_the_widget_type_does_not_support(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
resp = client.put("/api/frames/1/buttons/next", json={
"actions": [{"widget_id": photo_widget.id, "action": "check_now"}],
}, headers=csrf_headers(client))
assert resp.status_code == 400
def test_deleting_a_widget_cascades_its_button_bindings(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
resp = client.delete(f"/api/frames/1/widgets/{photo_widget.id}", headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id).count() == 0
def test_linked_user_can_view_and_control_can_save(client, db_session):
def test_save_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
widget_id = _photo_widget_id(db_session)
client.cookies.clear()
login(client, "bob")
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 200
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "back", "back_button_action": "advance"},
headers=csrf_headers(client))
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
def test_unrelated_user_cannot_view(client, db_session):
def test_two_widgets_of_the_same_type_have_independent_bindings(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
photo_widget_id = _photo_widget_id(db_session)
frame = db_session.get(Frame, 1)
board = _add_whiteboard_widget(db_session, frame)
client.cookies.clear()
login(client, "mallory")
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 404
resp = client.post(f"/api/frames/1/widgets/{board.id}/button-actions",
json={"next_button_action": "check_now", "back_button_action": ""},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
photo_rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=photo_widget_id)}
board_rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=board.id)}
assert photo_rows == {"next": "advance", "back": "back"}
assert board_rows == {"next": "check_now"}
+294
View File
@@ -0,0 +1,294 @@
"""Hold-for-global-action (see app/global_actions.py): the
Configuration-tab save path (hold_duration_ms/next_hold_action/
back_hold_action, folded into api_frames.py's api_config_save), the
device-facing dispatch (/frame/global-next, /frame/global-back in
routers/device.py), and the three registry actions themselves
(cycle_layout, refresh_all_widgets, toggle_all_photo_locks)."""
from __future__ import annotations
import time
from app import global_actions, widgets
from app.models import (
CalendarWidgetConfig,
Frame,
PhotoWidgetConfig,
SavedLayout,
Widget,
WhiteboardWidgetConfig,
)
from .conftest import csrf_headers, link_user, login, make_user
EXPECTED_BYTES = 800 * 480 // 2
# --- Configuration-tab save (api_frames.py's api_config_save) --------------
def test_save_hold_duration_clamps_to_range(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/config", data={"hold_duration_ms": "999"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.get(Frame, 1).hold_duration_ms == 3000 # MIN_HOLD_DURATION_MS
resp = client.post("/api/frames/1/config", data={"hold_duration_ms": "999999"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.get(Frame, 1).hold_duration_ms == 10000 # MAX_HOLD_DURATION_MS
def test_save_sets_and_clears_hold_actions(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post(
"/api/frames/1/config",
data={"next_hold_action": "cycle_layout", "back_hold_action": "refresh_all_widgets"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
assert frame.next_hold_action == "cycle_layout"
assert frame.back_hold_action == "refresh_all_widgets"
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "", "back_hold_action": ""},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
db_session.refresh(frame)
assert frame.next_hold_action is None
assert frame.back_hold_action is None
def test_save_unknown_action_clears_to_none(client, db_session):
"""Not a 400 -- same silent-normalize posture as this endpoint's
other enum-ish fields (orientation, timezone)."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "not_a_real_action"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
assert db_session.get(Frame, 1).next_hold_action is None
def test_save_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
client.cookies.clear()
login(client, "bob")
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "cycle_layout"}, headers=csrf_headers(client)
)
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
def test_save_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
client.cookies.clear()
login(client, "mallory")
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "cycle_layout"}, headers=csrf_headers(client)
)
assert resp.status_code == 404
assert db_session.get(Frame, 1).next_hold_action is None
def test_save_logged_out_401s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
client.cookies.clear()
resp = client.post("/api/frames/1/config", data={"next_hold_action": "cycle_layout"})
assert resp.status_code == 401
# --- Device dispatch (/frame/global-next, /frame/global-back) --------------
def test_global_next_is_a_noop_when_unset(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/frame/global-next")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
def test_global_next_runs_the_configured_action(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.next_hold_action = "toggle_all_photo_locks"
db_session.commit()
resp = client.post("/frame/global-next")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
def test_global_back_runs_the_configured_action(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.back_hold_action = "toggle_all_photo_locks"
db_session.commit()
resp = client.post("/frame/global-back")
assert resp.status_code == 200
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
def test_global_next_with_an_unrecognized_stored_action_is_a_noop(client, db_session):
"""Defensive -- a value that stopped being a valid registry key
(e.g. after a downgrade) shouldn't 500 the whole request."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.next_hold_action = "no_longer_exists"
db_session.commit()
resp = client.post("/frame/global-next")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
# --- Registry: toggle_all_photo_locks ---------------------------------------
def test_toggle_all_photo_locks_locks_then_unlocks(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
global_actions.toggle_all_photo_locks(db_session, frame)
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
global_actions.toggle_all_photo_locks(db_session, frame)
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is False
def test_toggle_all_photo_locks_noop_with_no_photo_widgets(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
db_session.delete(widget)
db_session.commit()
global_actions.toggle_all_photo_locks(db_session, frame) # should not raise
# --- Registry: refresh_all_widgets ------------------------------------------
def test_refresh_all_widgets_only_calls_check_now_capable_types(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
board_widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=1, h=1,
sort_order=1, created_at=time.time())
db_session.add(board_widget)
db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=board_widget.id))
db_session.commit()
calls = []
monkeypatch.setattr(
widgets.whiteboard, "get_or_refresh_whiteboard_for_widget",
lambda db, frame, widget, force=False: calls.append((widget.id, force)),
)
global_actions.refresh_all_widgets(db_session, frame)
# Only the whiteboard widget has a check_now action -- the photos
# widget (no check_now in its ACTIONS) is left untouched.
assert calls == [(board_widget.id, True)]
assert photo_widget.id not in [c[0] for c in calls]
def test_refresh_all_widgets_one_failure_does_not_block_others(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
board_a = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=1, h=1,
sort_order=1, created_at=time.time())
board_b = Widget(frame_id=frame.id, widget_type="whiteboard", x=1, y=0, w=1, h=1,
sort_order=2, created_at=time.time())
db_session.add_all([board_a, board_b])
db_session.flush()
db_session.add_all([WhiteboardWidgetConfig(widget_id=board_a.id), WhiteboardWidgetConfig(widget_id=board_b.id)])
db_session.commit()
calls = []
def _flaky(db, frame, widget, force=False):
if widget.id == board_a.id:
raise RuntimeError("simulated fetch failure")
calls.append(widget.id)
monkeypatch.setattr(widgets.whiteboard, "get_or_refresh_whiteboard_for_widget", _flaky)
global_actions.refresh_all_widgets(db_session, frame) # should not raise
assert calls == [board_b.id]
# --- Registry: cycle_layout --------------------------------------------------
def test_cycle_layout_noop_when_frame_unclaimed(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.owner_user_id = None
db_session.commit()
global_actions.cycle_layout(db_session, frame) # should not raise
assert db_session.query(Widget).filter_by(frame_id=frame.id).count() == 1 # untouched
def test_cycle_layout_noop_when_no_compatible_layouts(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
global_actions.cycle_layout(db_session, frame) # no saved layouts at all yet
assert db_session.query(Widget).filter_by(frame_id=frame.id).count() == 1
def test_cycle_layout_applies_and_wraps_around(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
resp_a = client.post("/api/frames/1/layouts", json={"name": "Layout A"}, headers=csrf_headers(client))
assert resp_a.status_code == 200, resp_a.text
layout_a_id = resp_a.json()["id"]
# A second, differently-shaped widget arrangement to save as "Layout B".
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
photo_widget.w = 4
cal_widget = Widget(frame_id=frame.id, widget_type="calendar", x=4, y=0, w=4, h=5,
sort_order=1, created_at=time.time())
db_session.add(cal_widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=cal_widget.id))
db_session.commit()
resp_b = client.post("/api/frames/1/layouts", json={"name": "Layout B"}, headers=csrf_headers(client))
assert resp_b.status_code == 200, resp_b.text
layout_b_id = resp_b.json()["id"]
# A layout with the wrong grid dims must never be selected.
incompatible = SavedLayout(user_id=frame.owner_user_id, name="Wrong size", cols=1, rows=1,
created_at=time.time(), updated_at=time.time())
db_session.add(incompatible)
db_session.commit()
global_actions.cycle_layout(db_session, frame)
db_session.refresh(frame)
assert frame.last_cycled_layout_id == layout_a_id
global_actions.cycle_layout(db_session, frame)
db_session.refresh(frame)
assert frame.last_cycled_layout_id == layout_b_id
global_actions.cycle_layout(db_session, frame) # wraps back to the first
db_session.refresh(frame)
assert frame.last_cycled_layout_id == layout_a_id
+62
View File
@@ -89,6 +89,9 @@ def test_expected_columns_exist_on_current_schema():
assert {"border_style", "border_thickness", "border_color_index"} <= widget_columns # migration 26
photo_widget_columns = {c["name"] for c in inspector.get_columns("photo_widget_configs")}
assert "locked" in photo_widget_columns # migration 27
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
@@ -269,6 +272,65 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
assert config.queue == ["legacy-asset", "next-asset"]
def test_migration_28_dedupes_and_enforces_one_action_per_widget_per_button(db_session):
"""Exercises _migration_28's real ALTER path: a pre-existing database
with more than one FrameButtonAction row bound to the same (widget,
button) -- the old frame-level "Button assignments" card allowed
this -- gets de-duped down to one row (MIN(id) survives) before the
new unique index is created, rather than the migration failing
outright on "UNIQUE constraint failed"."""
frame = db_session.get(Frame, 1)
widget = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).first()
with db_module.engine.begin() as conn:
# The fresh-install create_all() copy already has the unique
# index (it reflects models.py's current shape) -- drop it first
# so the duplicate insert below doesn't just fail immediately,
# simulating a real pre-migration-28 database.
conn.execute(text("DROP INDEX IF EXISTS ix_frame_button_actions_widget_button"))
conn.execute(text(
"INSERT INTO frame_button_actions (frame_id, button, widget_id, action, sort_order, created_at) "
"VALUES (:frame_id, 'next', :widget_id, 'back', 1, 0)"
), {"frame_id": frame.id, "widget_id": widget.id})
conn.execute(text("UPDATE schema_version SET version = 27"))
run_migrations()
with db_module.engine.connect() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
actions = db_session.scalars(
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id, FrameButtonAction.button == "next")
).all()
assert len(actions) == 1
inspector = inspect(db_module.engine)
indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
assert "ix_frame_button_actions_widget_button" in indexes
def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_session):
"""Exercises _migration_29's real guarded ALTER path (frames isn't
dropped/recreated by the pre-widget-system replay tests, so its
columns must be added defensively, same reasoning as migration
26/27's own comments)."""
with db_module.engine.begin() as conn:
conn.execute(text("UPDATE schema_version SET version = 28"))
run_migrations()
with db_module.engine.connect() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
frame = db_session.get(Frame, 1)
assert frame.hold_duration_ms == 3000
assert frame.next_hold_action is None
assert frame.back_hold_action is None
assert frame.last_cycled_layout_id is None
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
"""Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database
+6 -4
View File
@@ -145,8 +145,9 @@ def test_move_unknown_widget_404s(client, db_session):
def test_delete_widget_and_cascades_button_actions(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=widget_id, action="advance", sort_order=0))
db_session.commit()
# Already has a default next->advance binding from creation (see
# widgets.default_button_actions) -- nothing to add manually.
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).count() > 0
resp = client.delete(f"/api/frames/1/widgets/{widget_id}", headers=csrf_headers(client))
assert resp.status_code == 200
@@ -168,8 +169,9 @@ def test_clear_all_removes_every_widget_and_cascades_button_actions(client, db_s
create_resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
headers=csrf_headers(client))
assert create_resp.status_code == 200, create_resp.text
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=photos_id, action="advance", sort_order=0))
db_session.commit()
# photos_id already has a default next->advance binding from creation
# (see widgets.default_button_actions) -- nothing to add manually.
assert db_session.query(FrameButtonAction).filter_by(widget_id=photos_id).count() > 0
resp = client.delete("/api/frames/1/widgets", headers=csrf_headers(client))
assert resp.status_code == 200, resp.text