diff --git a/docs/widgets.md b/docs/widgets.md index ba36973..17c8b28 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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 diff --git a/firmware/README.md b/firmware/README.md index 329dd51..60b1c31 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -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) diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild index 69dfbef..d0a020b 100644 --- a/firmware/main/Kconfig.projbuild +++ b/firmware/main/Kconfig.projbuild @@ -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 diff --git a/firmware/main/back_button.c b/firmware/main/back_button.c index 6a30f44..b72326d 100644 --- a/firmware/main/back_button.c +++ b/firmware/main/back_button.c @@ -1,3 +1,5 @@ +#include + #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 (gpio_get_level(BACK_BUTTON_GPIO) != 0) { - return false; - } - - for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) { - vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS)); + 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; /* noise, not a real press */ + 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 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 diff --git a/firmware/main/back_button.h b/firmware/main/back_button.h index e927bcd..92ec363 100644 --- a/firmware/main/back_button.h +++ b/firmware/main/back_button.h @@ -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); diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index ea1cfb8..712ff14 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -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 diff --git a/firmware/main/frame_client.h b/firmware/main/frame_client.h index f1bc442..560260c 100644 --- a/firmware/main/frame_client.h +++ b/firmware/main/frame_client.h @@ -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; /** diff --git a/firmware/main/main.c b/firmware/main/main.c index faf44e2..9e14fbd 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -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). */ diff --git a/firmware/main/next_button.c b/firmware/main/next_button.c index 997e747..dcbb485 100644 --- a/firmware/main/next_button.c +++ b/firmware/main/next_button.c @@ -1,3 +1,5 @@ +#include + #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 (gpio_get_level(NEXT_BUTTON_GPIO) != 0) { - return false; - } - - for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) { - vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS)); + 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; /* noise, not a real press */ + 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 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 diff --git a/firmware/main/next_button.h b/firmware/main/next_button.h index 6a50123..652ad3c 100644 --- a/firmware/main/next_button.h +++ b/firmware/main/next_button.h @@ -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); diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c index 2c38589..7ace5c7 100644 --- a/firmware/main/wifi_provisioning.c +++ b/firmware/main/wifi_provisioning.c @@ -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 * ---------------------------------------------------------------------- */ diff --git a/firmware/main/wifi_provisioning.h b/firmware/main/wifi_provisioning.h index b24a7b5..a9869b6 100644 --- a/firmware/main/wifi_provisioning.h +++ b/firmware/main/wifi_provisioning.h @@ -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 diff --git a/server/app/global_actions.py b/server/app/global_actions.py new file mode 100644 index 0000000..a1fc682 --- /dev/null +++ b/server/app/global_actions.py @@ -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", +} diff --git a/server/app/migration.py b/server/app/migration.py index 2eebf17..f0f6a74 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -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) diff --git a/server/app/models.py b/server/app/models.py index 439a4d6..e0e57f0 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -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: , 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), ) diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index 6800e1d..afe8455 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -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( diff --git a/server/app/routers/api_layouts.py b/server/app/routers/api_layouts.py index 8f2d80b..048b53b 100644 --- a/server/app/routers/api_layouts.py +++ b/server/app/routers/api_layouts.py @@ -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} diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index e8c1648..b194ac7 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -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") diff --git a/server/app/routers/device.py b/server/app/routers/device.py index aecc917..4df1389 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -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 diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index 5b4f265..c3b343a 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -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}") diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js index 27f6c18..c60e052 100644 --- a/server/app/static/frame_config.js +++ b/server/app/static/frame_config.js @@ -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 = '
  • Nothing assigned -- this button won’t do anything.
  • '; - 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(); diff --git a/server/app/static/theme.css b/server/app/static/theme.css index e1dbff6..b37af6b 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -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; } diff --git a/server/app/static/widget_dialog_battery.js b/server/app/static/widget_dialog_battery.js index d371c76..42ee8cd 100644 --- a/server/app/static/widget_dialog_battery.js +++ b/server/app/static/widget_dialog_battery.js @@ -31,6 +31,7 @@ function initBatteryDialog() { document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview); loadBatteryPreview(); initBorderFields(); + initButtonActionFields(); } function closeBatteryDialog() { diff --git a/server/app/static/widget_dialog_button_actions.js b/server/app/static/widget_dialog_button_actions.js new file mode 100644 index 0000000..20bcbe8 --- /dev/null +++ b/server/app/static/widget_dialog_button_actions.js @@ -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_.js's initDialog() +// 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); + } + }); +} diff --git a/server/app/static/widget_dialog_calendar.js b/server/app/static/widget_dialog_calendar.js index c1edcc8..581c3a9 100644 --- a/server/app/static/widget_dialog_calendar.js +++ b/server/app/static/widget_dialog_calendar.js @@ -198,6 +198,7 @@ function initCalendarDialog() { document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview); loadCalendarPreview(); initBorderFields(); + initButtonActionFields(); } function closeCalendarDialog() { diff --git a/server/app/static/widget_dialog_photos.js b/server/app/static/widget_dialog_photos.js index 72af251..7c22123 100644 --- a/server/app/static/widget_dialog_photos.js +++ b/server/app/static/widget_dialog_photos.js @@ -148,6 +148,7 @@ function initPhotosDialog() { // from elsewhere) without a manual refresh. Skipped mid-drag. photosPollTimer = setInterval(loadQueue, 10000); initBorderFields(); + initButtonActionFields(); } function closePhotosDialog() { diff --git a/server/app/static/widget_dialog_static.js b/server/app/static/widget_dialog_static.js index 5181f4a..623f09c 100644 --- a/server/app/static/widget_dialog_static.js +++ b/server/app/static/widget_dialog_static.js @@ -56,6 +56,7 @@ function initStaticDialog() { document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview); loadStaticPreview(); initBorderFields(); + initButtonActionFields(); } function closeStaticDialog() { diff --git a/server/app/static/widget_dialog_tasks.js b/server/app/static/widget_dialog_tasks.js index fd2ca41..672bac0 100644 --- a/server/app/static/widget_dialog_tasks.js +++ b/server/app/static/widget_dialog_tasks.js @@ -89,6 +89,7 @@ function initTasksDialog() { document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview); loadTasksPreview(); initBorderFields(); + initButtonActionFields(); } function closeTasksDialog() { diff --git a/server/app/static/widget_dialog_text.js b/server/app/static/widget_dialog_text.js index 4a12a55..d31722c 100644 --- a/server/app/static/widget_dialog_text.js +++ b/server/app/static/widget_dialog_text.js @@ -131,6 +131,7 @@ function initTextDialog() { document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview); loadTextPreview(); initBorderFields(); + initButtonActionFields(); } function closeTextDialog() { diff --git a/server/app/static/widget_dialog_weather.js b/server/app/static/widget_dialog_weather.js index 3d23821..535504f 100644 --- a/server/app/static/widget_dialog_weather.js +++ b/server/app/static/widget_dialog_weather.js @@ -152,6 +152,7 @@ function initWeatherDialog() { document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true)); loadWeatherPreview(false); initBorderFields(); + initButtonActionFields(); } function closeWeatherDialog() { diff --git a/server/app/static/widget_dialog_whiteboard.js b/server/app/static/widget_dialog_whiteboard.js index 870bd62..4ee5946 100644 --- a/server/app/static/widget_dialog_whiteboard.js +++ b/server/app/static/widget_dialog_whiteboard.js @@ -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'); diff --git a/server/app/templates/_widget_button_fields.html b/server/app/templates/_widget_button_fields.html new file mode 100644 index 0000000..9cbbb8e --- /dev/null +++ b/server/app/templates/_widget_button_fields.html @@ -0,0 +1,27 @@ +{% if button_action_labels %} +
    +

    Button actions

    +

    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.

    +
    + + + +
    +
    +{% endif %} diff --git a/server/app/templates/_widget_dialog_battery.html b/server/app/templates/_widget_dialog_battery.html index e633ec3..7a7f8f3 100644 --- a/server/app/templates/_widget_dialog_battery.html +++ b/server/app/templates/_widget_dialog_battery.html @@ -17,6 +17,8 @@ {% include "_widget_border_fields.html" %} +{% include "_widget_button_fields.html" %} +

    Preview

    How this widget currently renders.

    diff --git a/server/app/templates/_widget_dialog_calendar.html b/server/app/templates/_widget_dialog_calendar.html index f42eb56..bcf063b 100644 --- a/server/app/templates/_widget_dialog_calendar.html +++ b/server/app/templates/_widget_dialog_calendar.html @@ -129,6 +129,8 @@ {% include "_widget_border_fields.html" %} +{% include "_widget_button_fields.html" %} +

    Preview

    How this widget currently renders.

    diff --git a/server/app/templates/_widget_dialog_photos.html b/server/app/templates/_widget_dialog_photos.html index 5728eba..d8eed8b 100644 --- a/server/app/templates/_widget_dialog_photos.html +++ b/server/app/templates/_widget_dialog_photos.html @@ -43,6 +43,8 @@ {% include "_widget_border_fields.html" %} +{% include "_widget_button_fields.html" %} +

    Now displaying

    Loading...

    diff --git a/server/app/templates/_widget_dialog_static.html b/server/app/templates/_widget_dialog_static.html index 37bbb1d..e58787a 100644 --- a/server/app/templates/_widget_dialog_static.html +++ b/server/app/templates/_widget_dialog_static.html @@ -38,6 +38,8 @@ {% include "_widget_border_fields.html" %} +{% include "_widget_button_fields.html" %} +

    Preview

    How this widget currently renders.

    diff --git a/server/app/templates/_widget_dialog_tasks.html b/server/app/templates/_widget_dialog_tasks.html index f6ba409..db2447f 100644 --- a/server/app/templates/_widget_dialog_tasks.html +++ b/server/app/templates/_widget_dialog_tasks.html @@ -61,6 +61,8 @@ {% include "_widget_border_fields.html" %} +{% include "_widget_button_fields.html" %} +

    Preview

    How this widget currently renders.

    diff --git a/server/app/templates/_widget_dialog_text.html b/server/app/templates/_widget_dialog_text.html index 6fd1f97..2f62706 100644 --- a/server/app/templates/_widget_dialog_text.html +++ b/server/app/templates/_widget_dialog_text.html @@ -49,6 +49,8 @@ {% include "_widget_border_fields.html" %} +{% include "_widget_button_fields.html" %} +

    Preview

    How this widget currently renders.

    diff --git a/server/app/templates/_widget_dialog_weather.html b/server/app/templates/_widget_dialog_weather.html index e30edcd..6a597c4 100644 --- a/server/app/templates/_widget_dialog_weather.html +++ b/server/app/templates/_widget_dialog_weather.html @@ -75,6 +75,8 @@ {% include "_widget_border_fields.html" %} +{% include "_widget_button_fields.html" %} +

    Preview

    How this widget currently renders.

    diff --git a/server/app/templates/_widget_dialog_whiteboard.html b/server/app/templates/_widget_dialog_whiteboard.html index 1336a0c..4a27878 100644 --- a/server/app/templates/_widget_dialog_whiteboard.html +++ b/server/app/templates/_widget_dialog_whiteboard.html @@ -52,6 +52,8 @@ {% include "_widget_border_fields.html" %} +{% include "_widget_button_fields.html" %} +

    Preview

    How this widget currently renders.

    diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index b2d2a4a..3c5a432 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -55,37 +55,37 @@
    -

    Button assignments

    -

    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.

    - - -
    -
    -

    NEXT button

    -
      -
      - - - -
      -
      - -
      -

      BACK button

      -
        -
        - - - -
        -
        -
        +

        Hold actions

        +

        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.

        +
        + + + + +
        +
        diff --git a/server/app/templates/frame_layout.html b/server/app/templates/frame_layout.html index f9878c7..ef7ce4c 100644 --- a/server/app/templates/frame_layout.html +++ b/server/app/templates/frame_layout.html @@ -77,6 +77,7 @@ + {% endblock %} diff --git a/server/app/widgets/__init__.py b/server/app/widgets/__init__.py index 4974ae2..6f8116f 100644 --- a/server/app/widgets/__init__.py +++ b/server/app/widgets/__init__.py @@ -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 [] diff --git a/server/tests/test_button_actions.py b/server/tests/test_button_actions.py index 77a1795..31d6121 100644 --- a/server/tests/test_button_actions.py +++ b/server/tests/test_button_actions.py @@ -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)) - assert resp.status_code == 200, resp.text - assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="next").count() == 0 + 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(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"} diff --git a/server/tests/test_global_actions.py b/server/tests/test_global_actions.py new file mode 100644 index 0000000..576e6e4 --- /dev/null +++ b/server/tests/test_global_actions.py @@ -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 diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index bf03639..fe4ed8a 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -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 diff --git a/server/tests/test_widget_placement.py b/server/tests/test_widget_placement.py index 2f1cde0..80f783a 100644 --- a/server/tests/test_widget_placement.py +++ b/server/tests/test_widget_placement.py @@ -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