Move button actions to per-widget config, add hold-for-global-action
Next/back button assignment moves from a frame-level "Button assignments" card into each widget's own gear-icon dialog, prefilled with a sane default at creation (photos/calendar -> advance/back, whiteboard/weather -> check_now, others -> none). At most one binding per (widget, button) now -- cross-widget execution order never mattered since each widget's action only touches its own state. New firmware capability: holding NEXT or BACK past a configurable duration (min 3s, server-side default) triggers a frame-wide action instead of the per-widget short-press one -- cycling saved layouts, refreshing all widgets, or freezing/unfreezing every photo widget (see app/global_actions.py). Firmware next/back checks gain the same hold-duration polling the combo button already had; the threshold comes from the previous wake's /frame/config fetch (persisted in NVS), since this wake's button decision happens before that request. Not done here: firmware/version.txt is intentionally left unbumped -- this hasn't been built or hardware-tested (no ESP-IDF toolchain in this environment), so no firmware release build should be triggered yet.
This commit is contained in:
+41
-20
@@ -150,28 +150,49 @@ for month view to stay legible.
|
||||
|
||||
## Button actions
|
||||
|
||||
Each physical button (NEXT/BACK) maps to an **ordered list** of
|
||||
`(widget, action)` bindings, not a fixed meaning -- e.g. NEXT can be
|
||||
"photo widget A: advance" *and* "calendar widget B: advance" together, or
|
||||
even a mismatched combination on purpose. On a press,
|
||||
`routers/device.py`'s `_run_button_actions` runs every assigned action for
|
||||
that button in order (each in its own `widget_locked` span -- never nested,
|
||||
since the underlying per-frame lock isn't reentrant), catching and
|
||||
logging any single action's failure without blocking the rest, then
|
||||
re-renders and returns the whole composed panel once at the end regardless
|
||||
of which actions succeeded.
|
||||
Each physical button (NEXT/BACK) runs the `(widget, action)` binding of
|
||||
every widget on the frame that has one -- **at most one binding per
|
||||
widget per button** (a widget can't be bound to two different actions on
|
||||
the same button). On a press, `routers/device.py`'s `_run_button_actions`
|
||||
runs every widget's assigned action for that button (each in its own
|
||||
`widget_locked` span -- never nested, since the underlying per-frame lock
|
||||
isn't reentrant), catching and logging any single action's failure
|
||||
without blocking the rest, then re-renders and returns the whole composed
|
||||
panel once at the end regardless of which actions succeeded. Which
|
||||
widget's action runs first never matters -- each only touches its own
|
||||
state, and the shared re-render happens once, after all of them finish.
|
||||
|
||||
The web UI for this is the "Button assignments" card on a frame's
|
||||
Configuration tab (`static/frame_config.js`, `GET`/`PUT
|
||||
/api/frames/{id}/buttons`) -- add/remove/reorder, autosaved. Two widgets of
|
||||
the same type would otherwise both just say "Photos" in the assignment
|
||||
dropdowns; the UI disambiguates using each widget's grid position (e.g.
|
||||
"Photos 1 (left)" / "Photos 2 (right)"), the same way you'd tell them
|
||||
apart by eye on the Layout canvas.
|
||||
The UI for this lives in each widget's own gear-icon config dialog (the
|
||||
"Button actions" card, `templates/_widget_button_fields.html` +
|
||||
`static/widget_dialog_button_actions.js`, `POST
|
||||
/api/frames/{id}/widgets/{widget_id}/button-actions`) -- not a frame-level
|
||||
tab, since assigning a widget's next/back behavior is naturally part of
|
||||
configuring that widget. The card only renders for widget types with a
|
||||
non-empty `ACTIONS` (photos, calendar, whiteboard, weather); tasks/
|
||||
static/text/battery have nothing to bind so the card is omitted for
|
||||
them. An empty selection ("(none)") clears that button's binding for the
|
||||
widget.
|
||||
|
||||
A newly-created widget (including the one auto-migrated from a frame's old
|
||||
`mode` on upgrade) gets a sensible default binding reproducing its old
|
||||
button behavior -- see `migration.py`'s `_default_button_actions`.
|
||||
A newly-created widget (including the one auto-migrated from a frame's
|
||||
old `mode` on upgrade) gets a sensible default binding reproducing its
|
||||
old button behavior -- see `widgets.default_button_actions` (called from
|
||||
both `migration.py`'s backfill and `api_widgets.py`'s
|
||||
`api_widget_create`), so a widget is never left with nothing bound until
|
||||
someone deliberately reassigns it.
|
||||
|
||||
### Hold-for-global-action
|
||||
|
||||
Holding NEXT or BACK past a configurable duration (`Frame.hold_duration_ms`,
|
||||
minimum 3000ms, set on the Configuration tab) triggers a **global**
|
||||
action instead of the per-widget one -- not scoped to any widget, e.g.
|
||||
cycling through the user's saved layouts. See `app/global_actions.py`'s
|
||||
`GLOBAL_ACTIONS`/`GLOBAL_ACTION_LABELS` registry and
|
||||
`routers/device.py`'s `/frame/global-next`/`/frame/global-back` (the
|
||||
device calls these instead of `/frame/advance`/`/frame/back` once it
|
||||
detects a long press -- see `firmware/main/next_button.c`/`back_button.c`).
|
||||
`Frame.next_hold_action`/`back_hold_action` pick which registry entry (if
|
||||
any) each button's hold triggers; unset is a silent no-op, same
|
||||
convention as an unbound short-press button.
|
||||
|
||||
## Per-widget config UI
|
||||
|
||||
|
||||
+33
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -188,6 +188,23 @@ menu "ESPresso Frame Configuration"
|
||||
FRAME_COMBO_SOFT_RESET_HOLD_MS so the two tiers can't be
|
||||
confused for each other.
|
||||
|
||||
config FRAME_HOLD_ACTION_MS
|
||||
int "Next/back hold-for-global-action duration (ms)"
|
||||
default 3000
|
||||
range 3000 10000
|
||||
help
|
||||
How long the NEXT or BACK button must be held before it
|
||||
triggers a frame-wide action (see server/app/global_actions.py
|
||||
-- e.g. cycling saved layouts) instead of that button's normal
|
||||
short-press behavior. Only a first-boot/never-connected
|
||||
fallback: once the device has fetched GET /frame/config at
|
||||
least once, the server's own Frame.hold_duration_ms (set on
|
||||
the Configuration tab) overrides this on every later boot --
|
||||
see wifi_provisioning.h's frame_config_get_hold_duration_ms.
|
||||
Floor matches the server's own minimum, so a long-held button
|
||||
never means something different depending on which value
|
||||
happened to apply.
|
||||
|
||||
config FRAME_BATTERY_ADC_GPIO
|
||||
int "Battery voltage-divider ADC GPIO (-1 to disable)"
|
||||
default -1
|
||||
|
||||
+36
-14
@@ -1,3 +1,5 @@
|
||||
#include <stdint.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
@@ -5,6 +7,8 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "wifi_provisioning.h"
|
||||
|
||||
#include "back_button.h"
|
||||
|
||||
static const char *TAG = "back_button";
|
||||
@@ -14,6 +18,7 @@ static const char *TAG = "back_button";
|
||||
#define BACK_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_BACK_BUTTON_GPIO)
|
||||
#define BACK_BUTTON_DEBOUNCE_MS 20
|
||||
#define BACK_BUTTON_DEBOUNCE_CHECKS 3
|
||||
#define BACK_BUTTON_POLL_MS 100
|
||||
|
||||
void back_button_init(void)
|
||||
{
|
||||
@@ -36,7 +41,7 @@ void back_button_init(void)
|
||||
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << BACK_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
}
|
||||
|
||||
bool back_button_check(void)
|
||||
back_button_result_t back_button_check(void)
|
||||
{
|
||||
/* A quick tap can easily release before this runs (~0.4-0.5s into
|
||||
* boot, confirmed on hardware -- a live gpio_get_level() check here
|
||||
@@ -44,32 +49,49 @@ bool back_button_check(void)
|
||||
* status register is latched at the moment of waking and isn't
|
||||
* cleared until the next sleep entry, so it reliably reflects a tap
|
||||
* regardless of how quickly it was released. */
|
||||
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO)) {
|
||||
ESP_LOGI(TAG, "Back-photo button caused this wake, going back");
|
||||
return true;
|
||||
}
|
||||
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << BACK_BUTTON_GPIO);
|
||||
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
|
||||
* power-on/reflash) -- fall back to a live, debounced level check so
|
||||
* holding the button down while powering on also works. */
|
||||
if (!caused_wake) {
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
|
||||
* fresh power-on/reflash) -- fall back to a live, debounced level
|
||||
* check so holding the button down while powering on also
|
||||
* works. */
|
||||
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
|
||||
return false;
|
||||
return BACK_BUTTON_NOT_PRESSED;
|
||||
}
|
||||
|
||||
for (int i = 0; i < BACK_BUTTON_DEBOUNCE_CHECKS; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_DEBOUNCE_MS));
|
||||
if (gpio_get_level(BACK_BUTTON_GPIO) != 0) {
|
||||
return false; /* noise, not a real press */
|
||||
return BACK_BUTTON_NOT_PRESSED; /* noise, not a real press */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Back-photo button held during power-on, going back");
|
||||
return true;
|
||||
/* Confirmed pressed -- measure how long, same reasoning/pattern as
|
||||
* next_button_check(). */
|
||||
uint32_t hold_threshold_ms;
|
||||
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
|
||||
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
|
||||
}
|
||||
|
||||
uint32_t elapsed_ms = 0;
|
||||
while (gpio_get_level(BACK_BUTTON_GPIO) == 0) {
|
||||
if (elapsed_ms >= hold_threshold_ms) {
|
||||
ESP_LOGI(TAG, "Back button held past %ums, triggering global hold action",
|
||||
(unsigned)hold_threshold_ms);
|
||||
return BACK_BUTTON_HOLD;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(BACK_BUTTON_POLL_MS));
|
||||
elapsed_ms += BACK_BUTTON_POLL_MS;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Back-photo button short press (%ums), going back", (unsigned)elapsed_ms);
|
||||
return BACK_BUTTON_SHORT_PRESS;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
void back_button_init(void) {}
|
||||
bool back_button_check(void) { return false; }
|
||||
back_button_result_t back_button_check(void) { return BACK_BUTTON_NOT_PRESSED; }
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,14 +9,19 @@
|
||||
* Which photo-fetch behavior this wake cycle should use -- normally the
|
||||
* idempotent GET /frame/image (the server decides on its own whether to
|
||||
* advance, based on its configured refresh interval, so a plain
|
||||
* wake/reboot never skips a photo just by asking), or POST
|
||||
* /frame/advance / POST /frame/back to force a move in either direction
|
||||
* (the next-photo / back-photo buttons).
|
||||
* wake/reboot never skips a photo just by asking), POST /frame/advance /
|
||||
* POST /frame/back to force a move in either direction (a short press of
|
||||
* the next-photo / back-photo buttons), or POST /frame/global-next /
|
||||
* POST /frame/global-back to run whatever frame-wide action (if any) is
|
||||
* configured for a held press (see next_button.h/back_button.h's
|
||||
* *_HOLD result and app/global_actions.py server-side).
|
||||
*/
|
||||
typedef enum {
|
||||
FETCH_NORMAL,
|
||||
FETCH_ADVANCE,
|
||||
FETCH_BACK,
|
||||
FETCH_GLOBAL_NEXT,
|
||||
FETCH_GLOBAL_BACK,
|
||||
} fetch_action_t;
|
||||
|
||||
/**
|
||||
|
||||
+13
-4
@@ -40,12 +40,21 @@ void app_main(void)
|
||||
back_button_init();
|
||||
combo_button_init();
|
||||
|
||||
bool next_pressed = next_button_check();
|
||||
bool back_pressed = back_button_check();
|
||||
next_button_result_t next_result = next_button_check();
|
||||
back_button_result_t back_result = back_button_check();
|
||||
/* Next takes priority over back if somehow both read pressed at once
|
||||
* (e.g. both held through a power-on) -- an arbitrary but
|
||||
* deterministic tie-break, not expected to matter in practice. */
|
||||
fetch_action_t action = next_pressed ? FETCH_ADVANCE : back_pressed ? FETCH_BACK : FETCH_NORMAL;
|
||||
* deterministic tie-break, not expected to matter in practice. Same
|
||||
* priority applies whether the winning button resolved to a short
|
||||
* press or a hold. */
|
||||
fetch_action_t action;
|
||||
if (next_result != NEXT_BUTTON_NOT_PRESSED) {
|
||||
action = (next_result == NEXT_BUTTON_HOLD) ? FETCH_GLOBAL_NEXT : FETCH_ADVANCE;
|
||||
} else if (back_result != BACK_BUTTON_NOT_PRESSED) {
|
||||
action = (back_result == BACK_BUTTON_HOLD) ? FETCH_GLOBAL_BACK : FETCH_BACK;
|
||||
} else {
|
||||
action = FETCH_NORMAL;
|
||||
}
|
||||
/* Soft-resets or clears config + restarts internally for a medium/
|
||||
* long hold and never returns in those cases -- only returns here
|
||||
* for "not pressed" (false) or "quick press" (true, show the menu). */
|
||||
|
||||
+41
-14
@@ -1,3 +1,5 @@
|
||||
#include <stdint.h>
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
@@ -5,6 +7,8 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "wifi_provisioning.h"
|
||||
|
||||
#include "next_button.h"
|
||||
|
||||
static const char *TAG = "next_button";
|
||||
@@ -14,6 +18,7 @@ static const char *TAG = "next_button";
|
||||
#define NEXT_BUTTON_GPIO ((gpio_num_t)CONFIG_FRAME_NEXT_BUTTON_GPIO)
|
||||
#define NEXT_BUTTON_DEBOUNCE_MS 20
|
||||
#define NEXT_BUTTON_DEBOUNCE_CHECKS 3
|
||||
#define NEXT_BUTTON_POLL_MS 100
|
||||
|
||||
void next_button_init(void)
|
||||
{
|
||||
@@ -40,7 +45,7 @@ void next_button_init(void)
|
||||
esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(1ULL << NEXT_BUTTON_GPIO, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
}
|
||||
|
||||
bool next_button_check(void)
|
||||
next_button_result_t next_button_check(void)
|
||||
{
|
||||
/* A quick tap can easily release before this runs (~0.4-0.5s into
|
||||
* boot, confirmed on hardware -- a live gpio_get_level() check here
|
||||
@@ -48,32 +53,54 @@ bool next_button_check(void)
|
||||
* status register is latched at the moment of waking and isn't
|
||||
* cleared until the next sleep entry, so it reliably reflects a tap
|
||||
* regardless of how quickly it was released. */
|
||||
if (esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO)) {
|
||||
ESP_LOGI(TAG, "Next-photo button caused this wake, forcing advance");
|
||||
return true;
|
||||
}
|
||||
bool caused_wake = esp_sleep_get_gpio_wakeup_status() & (1ULL << NEXT_BUTTON_GPIO);
|
||||
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a fresh
|
||||
* power-on/reflash) -- fall back to a live, debounced level check so
|
||||
* holding the button down while powering on also works. */
|
||||
if (!caused_wake) {
|
||||
/* Not a GPIO-wakeup-from-this-pin boot (normal timer wake, or a
|
||||
* fresh power-on/reflash) -- fall back to a live, debounced level
|
||||
* check so holding the button down while powering on also
|
||||
* works. */
|
||||
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
|
||||
return false;
|
||||
return NEXT_BUTTON_NOT_PRESSED;
|
||||
}
|
||||
|
||||
for (int i = 0; i < NEXT_BUTTON_DEBOUNCE_CHECKS; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_DEBOUNCE_MS));
|
||||
if (gpio_get_level(NEXT_BUTTON_GPIO) != 0) {
|
||||
return false; /* noise, not a real press */
|
||||
return NEXT_BUTTON_NOT_PRESSED; /* noise, not a real press */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Next-photo button held during power-on, forcing advance");
|
||||
return true;
|
||||
/* Confirmed pressed (either the wake cause, or debounced during
|
||||
* power-on) -- measure how long, same polling pattern as
|
||||
* combo_button.c's own hold-tier detection. Reads the last hold
|
||||
* duration the server reported (persisted from a previous cycle,
|
||||
* see frame_config_get_hold_duration_ms's own doc comment), falling
|
||||
* back to the Kconfig default before the device has ever fetched
|
||||
* one. */
|
||||
uint32_t hold_threshold_ms;
|
||||
if (frame_config_get_hold_duration_ms(&hold_threshold_ms) != ESP_OK) {
|
||||
hold_threshold_ms = CONFIG_FRAME_HOLD_ACTION_MS;
|
||||
}
|
||||
|
||||
uint32_t elapsed_ms = 0;
|
||||
while (gpio_get_level(NEXT_BUTTON_GPIO) == 0) {
|
||||
if (elapsed_ms >= hold_threshold_ms) {
|
||||
ESP_LOGI(TAG, "Next button held past %ums, triggering global hold action",
|
||||
(unsigned)hold_threshold_ms);
|
||||
return NEXT_BUTTON_HOLD;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(NEXT_BUTTON_POLL_MS));
|
||||
elapsed_ms += NEXT_BUTTON_POLL_MS;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Next-photo button short press (%ums), forcing advance", (unsigned)elapsed_ms);
|
||||
return NEXT_BUTTON_SHORT_PRESS;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
void next_button_init(void) {}
|
||||
bool next_button_check(void) { return false; }
|
||||
next_button_result_t next_button_check(void) { return NEXT_BUTTON_NOT_PRESSED; }
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Frame-wide actions triggered by holding NEXT/BACK past
|
||||
Frame.hold_duration_ms, instead of the per-widget action a short press
|
||||
runs (see models.FrameButtonAction, app/widgets/*.py's ACTIONS). Not
|
||||
scoped to any one widget -- e.g. cycling through the owner's saved
|
||||
layouts -- so this is its own registry rather than living in a widget
|
||||
module.
|
||||
|
||||
Each function's signature is (db, frame) -> None, the frame-level
|
||||
analogue of a widget ACTIONS entry's (db, frame, widget) -> None, and
|
||||
each is responsible for its own locking/commit internally (frame_locked/
|
||||
widget_locked), same convention as app/widgets/*.py. routers/device.py's
|
||||
/frame/global-next and /frame/global-back look up which (if any) of
|
||||
these Frame.next_hold_action/back_hold_action points to and call it,
|
||||
same "unset/unknown -> silent no-op" posture as an unbound short-press
|
||||
button."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import grid
|
||||
from .db import frame_locked
|
||||
from .models import Frame, PhotoWidgetConfig, SavedLayout, Widget
|
||||
from .routers.api_layouts import apply_layout_to_frame
|
||||
from .widgets import WIDGET_TYPES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cycle_layout(db: Session, frame: Frame) -> None:
|
||||
"""Applies the owner's next saved layout compatible with this
|
||||
frame's current grid size, in a stable order (by id), wrapping back
|
||||
to the first past the last one. A silent no-op if the frame is
|
||||
unclaimed or its owner has no compatible saved layouts -- same
|
||||
posture as every other action here when there's nothing to do."""
|
||||
if frame.owner_user_id is None:
|
||||
return
|
||||
cols, rows = grid.grid_dims(frame.orientation)
|
||||
candidates = db.scalars(
|
||||
select(SavedLayout)
|
||||
.where(SavedLayout.user_id == frame.owner_user_id, SavedLayout.cols == cols, SavedLayout.rows == rows)
|
||||
.order_by(SavedLayout.id)
|
||||
).all()
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
next_layout = candidates[0]
|
||||
if frame.last_cycled_layout_id is not None:
|
||||
for i, layout in enumerate(candidates):
|
||||
if layout.id == frame.last_cycled_layout_id:
|
||||
next_layout = candidates[(i + 1) % len(candidates)]
|
||||
break
|
||||
|
||||
apply_layout_to_frame(db, frame, next_layout)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.last_cycled_layout_id = next_layout.id
|
||||
|
||||
|
||||
def refresh_all_widgets(db: Session, frame: Frame) -> None:
|
||||
"""Runs every widget's own check_now (calendar/weather/whiteboard),
|
||||
regardless of which button it's normally bound to -- a manual "sync
|
||||
everything now" global action. One widget's failure doesn't block
|
||||
the rest, same posture as routers/device.py's _run_button_actions."""
|
||||
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
|
||||
for widget in widgets:
|
||||
module = WIDGET_TYPES.get(widget.widget_type)
|
||||
check_now = module.ACTIONS.get("check_now") if module else None
|
||||
if check_now is None:
|
||||
continue
|
||||
try:
|
||||
check_now(db, frame, widget)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"refresh_all_widgets failed for widget %d (frame %d)", widget.id, frame.id
|
||||
)
|
||||
|
||||
|
||||
def toggle_all_photo_locks(db: Session, frame: Frame) -> None:
|
||||
"""Flips PhotoWidgetConfig.locked for every photo widget on the frame
|
||||
at once. Target state is the opposite of "everything's already
|
||||
locked" -- one hold freezes every photo widget unless they're all
|
||||
already frozen, in which case it unfreezes all of them. A no-op if
|
||||
the frame has no photo widgets."""
|
||||
widget_ids = [
|
||||
w.id for w in db.scalars(
|
||||
select(Widget).where(Widget.frame_id == frame.id, Widget.widget_type == "photos")
|
||||
)
|
||||
]
|
||||
if not widget_ids:
|
||||
return
|
||||
configs = db.scalars(
|
||||
select(PhotoWidgetConfig).where(PhotoWidgetConfig.widget_id.in_(widget_ids))
|
||||
).all()
|
||||
if not configs:
|
||||
return
|
||||
target = not all(c.locked for c in configs)
|
||||
with frame_locked(db, frame.id):
|
||||
for config in configs:
|
||||
config.locked = target
|
||||
|
||||
|
||||
GLOBAL_ACTIONS = {
|
||||
"cycle_layout": cycle_layout,
|
||||
"refresh_all_widgets": refresh_all_widgets,
|
||||
"toggle_all_photo_locks": toggle_all_photo_locks,
|
||||
}
|
||||
|
||||
GLOBAL_ACTION_LABELS = {
|
||||
"cycle_layout": "Cycle saved layouts",
|
||||
"refresh_all_widgets": "Refresh all widgets now",
|
||||
"toggle_all_photo_locks": "Freeze/unfreeze all photo widgets",
|
||||
}
|
||||
+54
-23
@@ -24,7 +24,6 @@ from .models import (
|
||||
BatteryLog,
|
||||
CalendarWidgetConfig,
|
||||
Frame,
|
||||
FrameButtonAction,
|
||||
FrameTaskList,
|
||||
PhotoWidgetConfig,
|
||||
ServerSettings,
|
||||
@@ -32,6 +31,7 @@ from .models import (
|
||||
WhiteboardWidgetConfig,
|
||||
Widget,
|
||||
)
|
||||
from .widgets import default_button_actions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -725,6 +725,55 @@ def _migration_27(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0"))
|
||||
|
||||
|
||||
def _migration_28(conn) -> None:
|
||||
"""One action per (widget, button) instead of an ordered per-button
|
||||
list -- button-action editing moved from the frame-level "Button
|
||||
assignments" card into each widget's own config dialog (see
|
||||
models.FrameButtonAction's updated docstring, routers/api_widgets.py's
|
||||
api_widget_config_save). Cross-widget execution order never actually
|
||||
mattered (each widget's action only touches its own state), so this
|
||||
only needs to de-dupe down to one row before the new unique index can
|
||||
be created -- MIN(id) per (widget_id, button) survives, arbitrarily
|
||||
but deterministically, since which specific extra binding a user's
|
||||
old list happened to have doesn't matter anymore."""
|
||||
conn.execute(text(
|
||||
"DELETE FROM frame_button_actions WHERE id NOT IN "
|
||||
"(SELECT MIN(id) FROM frame_button_actions GROUP BY widget_id, button)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_frame_button_actions_widget_button "
|
||||
"ON frame_button_actions (widget_id, button)"
|
||||
))
|
||||
|
||||
|
||||
def _migration_29(conn) -> None:
|
||||
"""Hold-for-global-action (see app/global_actions.py): holding NEXT/
|
||||
BACK past hold_duration_ms triggers a frame-wide action instead of
|
||||
the per-widget one a short press runs. next_hold_action/
|
||||
back_hold_action are NULL (disabled) by default -- existing frames
|
||||
get no new button behavior until someone opts in on the
|
||||
Configuration tab. last_cycled_layout_id tracks where a repeated
|
||||
"cycle saved layouts" hold should resume from.
|
||||
|
||||
Guarded per-column, same reasoning as migration 26/27's own
|
||||
comments: frames is a table test_migrations.py's pre-widget-system
|
||||
replay tests leave un-dropped (unlike calendar/task/widget tables
|
||||
those tests DROP and recreate in an old shape), so it keeps the
|
||||
fresh-install create_all() copy -- which already has these columns
|
||||
-- when those tests replay migrations 17+ from schema_version 16.
|
||||
Without the guard, replaying this migration there re-adds a column
|
||||
that's already there and SQLite raises "duplicate column name"."""
|
||||
existing = {c["name"] for c in inspect(conn).get_columns("frames")}
|
||||
if "hold_duration_ms" not in existing:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN hold_duration_ms INTEGER NOT NULL DEFAULT 3000"))
|
||||
if "next_hold_action" not in existing:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN next_hold_action TEXT"))
|
||||
if "back_hold_action" not in existing:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN back_hold_action TEXT"))
|
||||
if "last_cycled_layout_id" not in existing:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -753,6 +802,8 @@ MIGRATIONS = [
|
||||
(25, _migration_25),
|
||||
(26, _migration_26),
|
||||
(27, _migration_27),
|
||||
(28, _migration_28),
|
||||
(29, _migration_29),
|
||||
]
|
||||
|
||||
|
||||
@@ -963,26 +1014,6 @@ def _whiteboard_config_from_frame(frame: Frame, widget_id: int) -> WhiteboardWid
|
||||
)
|
||||
|
||||
|
||||
def _default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
|
||||
"""NEXT/BACK -> whatever this widget's own advance/back concept is
|
||||
(see app/widgets/ for the actual action registry, built in a later
|
||||
phase) -- reproduces each mode's exact old button behavior for the
|
||||
one auto-migrated widget, so upgrading changes nothing about what the
|
||||
physical buttons do until someone deliberately reassigns them."""
|
||||
if widget_type == "whiteboard":
|
||||
# No real "next"/"back" concept for a static board -- both
|
||||
# buttons already meant "check now" before this migration (see
|
||||
# the old _advance_whiteboard_mode/_back_whiteboard_mode).
|
||||
return [
|
||||
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
|
||||
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
|
||||
]
|
||||
return [
|
||||
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
|
||||
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
|
||||
]
|
||||
|
||||
|
||||
def _maybe_add_legacy_tasks_widget(db, frame: Frame, existing: list[grid.Rect], next_sort_order: int) -> None:
|
||||
"""Only relevant for a database jumping straight from before the
|
||||
widget system existed to after tasks became their own widget type
|
||||
@@ -1032,7 +1063,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
|
||||
db.flush() # assign ids before the FK'd config rows reference them
|
||||
db.add(_calendar_config_from_frame(frame, cal_widget.id))
|
||||
db.add(_photo_config_from_frame(frame, photo_widget.id))
|
||||
db.add_all(_default_button_actions(frame.id, cal_widget.id, "calendar"))
|
||||
db.add_all(default_button_actions(frame.id, cal_widget.id, "calendar"))
|
||||
_maybe_add_legacy_tasks_widget(
|
||||
db, frame, [(0, 0, cols - half, rows), (cols - half, 0, half, rows)], next_sort_order=2
|
||||
)
|
||||
@@ -1048,7 +1079,7 @@ def _backfill_frame_widgets(db, frame: Frame) -> None:
|
||||
db.add(_calendar_config_from_frame(frame, widget.id))
|
||||
elif mode == "whiteboard":
|
||||
db.add(_whiteboard_config_from_frame(frame, widget.id))
|
||||
db.add_all(_default_button_actions(frame.id, widget.id, mode))
|
||||
db.add_all(default_button_actions(frame.id, widget.id, mode))
|
||||
if mode == "calendar":
|
||||
_maybe_add_legacy_tasks_widget(db, frame, [(0, 0, cols, rows)], next_sort_order=1)
|
||||
|
||||
|
||||
+27
-8
@@ -311,6 +311,23 @@ class Frame(Base):
|
||||
firmware_update_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
firmware_gitea_latest_version: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
# -- hold-for-global-action (see app/global_actions.py) -- holding
|
||||
# NEXT/BACK past hold_duration_ms triggers a global action instead of
|
||||
# the per-widget one that a short press runs (models.FrameButtonAction).
|
||||
# Not scoped to any widget, e.g. cycling saved layouts -- hence its
|
||||
# own pair of frame-level columns rather than living in that table.
|
||||
hold_duration_ms: Mapped[int] = mapped_column(Integer, default=3000)
|
||||
next_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
back_hold_action: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
# Where "cycle saved layouts" resumes from -- the last SavedLayout id
|
||||
# it applied, so repeated holds advance through the list instead of
|
||||
# re-applying the same one every time. Deliberately not a real FK:
|
||||
# this is just a resume cursor, not a relationship needing cascade/
|
||||
# referential integrity -- if that layout's since been deleted or
|
||||
# renamed away, global_actions.cycle_layout just doesn't find it and
|
||||
# starts over from the first one, same as an unset value.
|
||||
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# -- stats (flattened from the old nested FrameStats) --
|
||||
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
@@ -692,14 +709,15 @@ WIDGET_CONFIG_MODELS: dict[str, type] = {
|
||||
class FrameButtonAction(Base):
|
||||
"""One (widget, action) binding for one of a frame's two physical
|
||||
buttons -- e.g. {button: "next", widget_id: <photo widget>, action:
|
||||
"advance"}. A button can have several of these (sort_order gives
|
||||
execution order); on a press, every row for that (frame, button) runs
|
||||
-- see routers/device.py's frame_advance/frame_back. Deliberately
|
||||
unconstrained about which widget/action pairs with which button (the
|
||||
user's own idea for resolving "what does NEXT even mean with several
|
||||
widgets on screen": let them assign literally anything to either
|
||||
button, including mismatched combinations, rather than the server
|
||||
guessing a sensible default)."""
|
||||
"advance"}. At most one binding per (widget, button) -- edited from
|
||||
that widget's own config dialog (routers/api_widgets.py's
|
||||
api_widget_config_save), prefilled with a sane default at widget
|
||||
creation (app/widgets/default_button_actions). On a press, every
|
||||
widget's row for that (frame, button) runs -- see routers/device.py's
|
||||
frame_advance/frame_back. sort_order is unused (which widget's action
|
||||
runs first never matters: each only touches its own state, and one
|
||||
shared re-render happens after all of them finish) but kept around so
|
||||
dispatch has a stable, deterministic query order."""
|
||||
|
||||
__tablename__ = "frame_button_actions"
|
||||
|
||||
@@ -713,6 +731,7 @@ class FrameButtonAction(Base):
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_frame_button_actions_frame_button", "frame_id", "button", "sort_order"),
|
||||
Index("ix_frame_button_actions_widget_button", "widget_id", "button", unique=True),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -59,6 +59,31 @@ document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Hold-for-global-action (see app/global_actions.py) -- a frame-wide
|
||||
// setting, not per-widget, so it shares api_config_save/the /config
|
||||
// endpoint rather than getting its own -- just a separate card/form on
|
||||
// this page for a distinct-enough concern.
|
||||
document.getElementById('hold-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const seconds = parseInt(document.getElementById('hold_duration_s').value, 10) || 3;
|
||||
const body = new URLSearchParams({
|
||||
hold_duration_ms: String(seconds * 1000),
|
||||
next_hold_action: document.getElementById('next_hold_action').value,
|
||||
back_hold_action: document.getElementById('back_hold_action').value,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
} catch (err) {
|
||||
showStatus(false, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function takeControl() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||||
@@ -363,203 +388,3 @@ loadFirmwareCheck();
|
||||
// cheap either way.
|
||||
setInterval(loadFirmwareCheck, 60000);
|
||||
|
||||
// --- Button assignments -----------------------------------------------
|
||||
// {widgets: [{id, widget_type, x, y, w, h, actions: [{action, label}]}],
|
||||
// grid: {cols, rows}, next: [...], back: [...]} -- see api_frames.py's
|
||||
// api_buttons_get. Each button's list is edited client-side (add/
|
||||
// remove/reorder) then PUT as a whole -- simpler than separate reorder/
|
||||
// add/remove endpoints for what's normally a handful of entries, and
|
||||
// this file already has the full list in hand after any edit.
|
||||
let buttonsData = null;
|
||||
let widgetNames = {}; // widget id -> disambiguated display name, see buildWidgetNames
|
||||
const BUTTONS = ['next', 'back'];
|
||||
|
||||
// "top-left"/"bottom"/"center" etc. from a widget's grid rect vs the
|
||||
// frame's grid dims -- the same rough position you'd read off the
|
||||
// Layout canvas by eye, used to tell apart two widgets of the same type
|
||||
// that would otherwise both just say "Photos".
|
||||
function widgetPositionLabel(w, grid) {
|
||||
const cx = w.x + w.w / 2;
|
||||
const cy = w.y + w.h / 2;
|
||||
const horiz = cx < grid.cols / 2 ? 'left' : (cx > grid.cols / 2 ? 'right' : '');
|
||||
const vert = cy < grid.rows / 2 ? 'top' : (cy > grid.rows / 2 ? 'bottom' : '');
|
||||
if (!horiz && !vert) return 'center';
|
||||
if (!vert) return horiz;
|
||||
if (!horiz) return vert;
|
||||
return `${vert}-${horiz}`;
|
||||
}
|
||||
|
||||
// A single widget of a given type keeps the plain type name ("Photos")
|
||||
// -- the common case, no need to clutter it. Only widgets sharing a
|
||||
// type with another widget on the same frame get a number + position
|
||||
// suffix, numbered in reading order (top-to-bottom, left-to-right).
|
||||
function buildWidgetNames(widgets, grid) {
|
||||
const byType = {};
|
||||
widgets.forEach((w) => { (byType[w.widget_type] = byType[w.widget_type] || []).push(w); });
|
||||
const names = {};
|
||||
Object.values(byType).forEach((group) => {
|
||||
if (group.length === 1) {
|
||||
names[group[0].id] = WIDGET_LABELS[group[0].widget_type] || group[0].widget_type;
|
||||
return;
|
||||
}
|
||||
const ordered = [...group].sort((a, b) => (a.y - b.y) || (a.x - b.x));
|
||||
ordered.forEach((w, i) => {
|
||||
const base = WIDGET_LABELS[w.widget_type] || w.widget_type;
|
||||
names[w.id] = `${base} ${i + 1} (${widgetPositionLabel(w, grid)})`;
|
||||
});
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
function widgetActionLabel(widgetId, action) {
|
||||
const w = buttonsData.widgets.find((w) => w.id === widgetId);
|
||||
if (!w) return `(deleted widget): ${action}`;
|
||||
const found = w.actions.find((a) => a.action === action);
|
||||
const actionLabel = found ? found.label : action;
|
||||
return `${widgetNames[widgetId] || WIDGET_LABELS[w.widget_type] || w.widget_type}: ${actionLabel}`;
|
||||
}
|
||||
|
||||
function renderButtonList(button) {
|
||||
const list = document.getElementById(`button-actions-${button}`);
|
||||
const rows = buttonsData[button];
|
||||
list.innerHTML = '';
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<li class="sub">Nothing assigned -- this button won’t do anything.</li>';
|
||||
return;
|
||||
}
|
||||
rows.forEach((row, i) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'button-action-row';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = widgetActionLabel(row.widget_id, row.action);
|
||||
|
||||
const controls = document.createElement('span');
|
||||
controls.className = 'button-action-controls';
|
||||
|
||||
const up = document.createElement('button');
|
||||
up.type = 'button';
|
||||
up.className = 'icon-btn';
|
||||
up.textContent = '↑';
|
||||
up.title = 'Move up';
|
||||
up.disabled = i === 0;
|
||||
up.addEventListener('click', () => moveButtonAction(button, i, -1));
|
||||
|
||||
const down = document.createElement('button');
|
||||
down.type = 'button';
|
||||
down.className = 'icon-btn';
|
||||
down.textContent = '↓';
|
||||
down.title = 'Move down';
|
||||
down.disabled = i === rows.length - 1;
|
||||
down.addEventListener('click', () => moveButtonAction(button, i, 1));
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'icon-btn';
|
||||
remove.textContent = '×';
|
||||
remove.title = 'Remove';
|
||||
remove.addEventListener('click', () => removeButtonAction(button, i));
|
||||
|
||||
controls.appendChild(up);
|
||||
controls.appendChild(down);
|
||||
controls.appendChild(remove);
|
||||
li.appendChild(span);
|
||||
li.appendChild(controls);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function populateActionSelect(button) {
|
||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||||
const actionSel = document.getElementById(`button-add-action-${button}`);
|
||||
actionSel.innerHTML = '';
|
||||
const w = buttonsData.widgets.find((w) => String(w.id) === widgetSel.value);
|
||||
if (!w) return;
|
||||
w.actions.forEach((a) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.action;
|
||||
opt.textContent = a.label;
|
||||
actionSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function populateWidgetSelect(button) {
|
||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||||
widgetSel.innerHTML = '';
|
||||
buttonsData.widgets.forEach((w) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = w.id;
|
||||
opt.textContent = widgetNames[w.id] || WIDGET_LABELS[w.widget_type] || w.widget_type;
|
||||
widgetSel.appendChild(opt);
|
||||
});
|
||||
populateActionSelect(button);
|
||||
}
|
||||
|
||||
async function saveButtonActions(button) {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_BASE_API}/buttons/${button}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
actions: buttonsData[button].map((r) => ({ widget_id: r.widget_id, action: r.action })),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Button assignments saved.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
await loadButtons(); // resync with server truth rather than leave a stale edit on screen
|
||||
}
|
||||
}
|
||||
|
||||
function moveButtonAction(button, index, delta) {
|
||||
const rows = buttonsData[button];
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= rows.length) return;
|
||||
[rows[index], rows[target]] = [rows[target], rows[index]];
|
||||
renderButtonList(button);
|
||||
saveButtonActions(button);
|
||||
}
|
||||
|
||||
function removeButtonAction(button, index) {
|
||||
buttonsData[button].splice(index, 1);
|
||||
renderButtonList(button);
|
||||
saveButtonActions(button);
|
||||
}
|
||||
|
||||
function addButtonAction(button) {
|
||||
const widgetSel = document.getElementById(`button-add-widget-${button}`);
|
||||
const actionSel = document.getElementById(`button-add-action-${button}`);
|
||||
if (!widgetSel.value || !actionSel.value) return;
|
||||
buttonsData[button].push({ widget_id: Number(widgetSel.value), action: actionSel.value });
|
||||
renderButtonList(button);
|
||||
saveButtonActions(button);
|
||||
}
|
||||
|
||||
async function loadButtons() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_BASE_API}/buttons`);
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
buttonsData = await resp.json();
|
||||
widgetNames = buildWidgetNames(buttonsData.widgets, buttonsData.grid);
|
||||
document.getElementById('button-assign-groups').style.display =
|
||||
buttonsData.widgets.length ? '' : 'none';
|
||||
document.getElementById('button-assign-empty-hint').style.display =
|
||||
buttonsData.widgets.length ? 'none' : '';
|
||||
BUTTONS.forEach((button) => {
|
||||
renderButtonList(button);
|
||||
populateWidgetSelect(button);
|
||||
});
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
BUTTONS.forEach((button) => {
|
||||
document.getElementById(`button-add-widget-${button}`)
|
||||
.addEventListener('change', () => populateActionSelect(button));
|
||||
document.getElementById(`button-add-${button}`)
|
||||
.addEventListener('click', () => addButtonAction(button));
|
||||
});
|
||||
|
||||
loadButtons();
|
||||
|
||||
@@ -265,22 +265,6 @@ input:focus, select:focus {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.button-assign-label { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 8px; }
|
||||
.button-action-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.button-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.button-action-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||
.button-action-controls .icon-btn { padding: 3px 6px; font-size: 13px; }
|
||||
.button-action-controls .icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.saved-layout-add { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
|
||||
.saved-layout-add input { width: auto; flex: 1 1 200px; margin-top: 0; }
|
||||
.saved-layout-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
@@ -308,8 +292,6 @@ input:focus, select:focus {
|
||||
.saved-layout-controls { display: flex; align-items: center; gap: 2px; flex: none; }
|
||||
.saved-layout-controls .btn-inline { margin: 0; }
|
||||
.saved-layout-rename-input { width: auto; flex: 1 1 160px; margin-top: 0; }
|
||||
.button-action-add { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.button-action-add select { width: auto; margin-top: 0; }
|
||||
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
|
||||
@@ -31,6 +31,7 @@ function initBatteryDialog() {
|
||||
document.getElementById('battery-preview-refresh').addEventListener('click', loadBatteryPreview);
|
||||
loadBatteryPreview();
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeBatteryDialog() {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Shared "Button actions" card (models.FrameButtonAction,
|
||||
// _widget_button_fields.html) -- present on every widget type's dialog
|
||||
// that supports any actions at all (the card renders nothing for
|
||||
// tasks/static/text/battery, whose ACTIONS is empty), so this is one
|
||||
// shared init function each widget_dialog_<type>.js's init<Type>Dialog()
|
||||
// calls, rather than N copies of the same save wiring -- same pattern as
|
||||
// widget_dialog_border.js. Not a page-load script by itself --
|
||||
// frame_layout.js loads it unconditionally (like every other
|
||||
// widget_dialog_*.js) since which dialog is open varies.
|
||||
|
||||
function initButtonActionFields() {
|
||||
const form = document.getElementById('button-actions-form');
|
||||
if (!form) return; // this widget type has no actions -- card didn't render
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = JSON.stringify({
|
||||
next_button_action: document.getElementById('next_button_action').value,
|
||||
back_button_action: document.getElementById('back_button_action').value,
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/button-actions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Button actions saved.');
|
||||
} catch (err) {
|
||||
showStatus(false, err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -198,6 +198,7 @@ function initCalendarDialog() {
|
||||
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
|
||||
loadCalendarPreview();
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeCalendarDialog() {
|
||||
|
||||
@@ -148,6 +148,7 @@ function initPhotosDialog() {
|
||||
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
||||
photosPollTimer = setInterval(loadQueue, 10000);
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closePhotosDialog() {
|
||||
|
||||
@@ -56,6 +56,7 @@ function initStaticDialog() {
|
||||
document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview);
|
||||
loadStaticPreview();
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeStaticDialog() {
|
||||
|
||||
@@ -89,6 +89,7 @@ function initTasksDialog() {
|
||||
document.getElementById('tasks-preview-refresh').addEventListener('click', loadTasksPreview);
|
||||
loadTasksPreview();
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeTasksDialog() {
|
||||
|
||||
@@ -131,6 +131,7 @@ function initTextDialog() {
|
||||
document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview);
|
||||
loadTextPreview();
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeTextDialog() {
|
||||
|
||||
@@ -152,6 +152,7 @@ function initWeatherDialog() {
|
||||
document.getElementById('weather-preview-refresh').addEventListener('click', () => loadWeatherPreview(true));
|
||||
loadWeatherPreview(false);
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
}
|
||||
|
||||
function closeWeatherDialog() {
|
||||
|
||||
@@ -97,6 +97,7 @@ function initWhiteboardDialog() {
|
||||
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
|
||||
loadWhiteboardPreview(false);
|
||||
initBorderFields();
|
||||
initButtonActionFields();
|
||||
|
||||
// --- file picker (Browse...) ---
|
||||
const browseToggle = document.getElementById('whiteboard-browse-toggle');
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% if button_action_labels %}
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Button actions</h2>
|
||||
<p class="sub">What the frame's physical NEXT/BACK buttons do to this
|
||||
widget. Every widget on the frame runs its own binding when a
|
||||
button is pressed -- this only affects this one.</p>
|
||||
<form id="button-actions-form">
|
||||
<label>Next button
|
||||
<select id="next_button_action">
|
||||
<option value="" {% if not next_button_action %}selected{% endif %}>(none)</option>
|
||||
{% for action, label in button_action_labels.items() %}
|
||||
<option value="{{ action }}" {% if next_button_action == action %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Back button
|
||||
<select id="back_button_action">
|
||||
<option value="" {% if not back_button_action %}selected{% endif %}>(none)</option>
|
||||
{% for action, label in button_action_labels.items() %}
|
||||
<option value="{{ action }}" {% if back_button_action == action %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -129,6 +129,8 @@
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Now displaying</h2>
|
||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
|
||||
{% include "_widget_border_fields.html" %}
|
||||
|
||||
{% include "_widget_button_fields.html" %}
|
||||
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this widget currently renders.</p>
|
||||
|
||||
@@ -55,37 +55,37 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Button assignments</h2>
|
||||
<p class="sub">What the frame's physical NEXT and BACK buttons do --
|
||||
assign one or more widget actions to each, in the order they
|
||||
should run. A button with several actions runs all of them, in
|
||||
order, then the panel redraws once.</p>
|
||||
<p class="sub" id="button-assign-empty-hint" style="display: none;">
|
||||
Add a widget on the Layout tab first -- there's nothing to assign
|
||||
a button to yet.</p>
|
||||
|
||||
<div id="button-assign-groups">
|
||||
<div class="button-assign-group">
|
||||
<h3 class="button-assign-label">NEXT button</h3>
|
||||
<ul class="button-action-list" id="button-actions-next"></ul>
|
||||
<div class="button-action-add">
|
||||
<select id="button-add-widget-next"></select>
|
||||
<select id="button-add-action-next"></select>
|
||||
<button type="button" class="secondary btn-inline" id="button-add-next">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-assign-group" style="margin-top: 20px;">
|
||||
<h3 class="button-assign-label">BACK button</h3>
|
||||
<ul class="button-action-list" id="button-actions-back"></ul>
|
||||
<div class="button-action-add">
|
||||
<select id="button-add-widget-back"></select>
|
||||
<select id="button-add-action-back"></select>
|
||||
<button type="button" class="secondary btn-inline" id="button-add-back">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="card-title">Hold actions</h2>
|
||||
<p class="sub">Holding NEXT or BACK past this duration runs a
|
||||
frame-wide action instead of each widget's normal short-press
|
||||
binding (set per-widget in that widget's own config dialog).
|
||||
Not scoped to any widget -- e.g. cycling through your saved
|
||||
layouts.</p>
|
||||
<form id="hold-config-form">
|
||||
<label>Hold duration (seconds)
|
||||
<input type="number" id="hold_duration_s" min="3" max="10"
|
||||
value="{{ (frame.hold_duration_ms // 1000) or 3 }}" required>
|
||||
</label>
|
||||
<label>NEXT held action
|
||||
<select id="next_hold_action">
|
||||
<option value="" {% if not frame.next_hold_action %}selected{% endif %}>(none)</option>
|
||||
{% for action, label in global_action_labels.items() %}
|
||||
<option value="{{ action }}" {% if frame.next_hold_action == action %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>BACK held action
|
||||
<select id="back_hold_action">
|
||||
<option value="" {% if not frame.back_hold_action %}selected{% endif %}>(none)</option>
|
||||
{% for action, label in global_action_labels.items() %}
|
||||
<option value="{{ action }}" {% if frame.back_hold_action == action %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
<script src="/static/widget_dialog_weather.js"></script>
|
||||
<script src="/static/widget_dialog_battery.js"></script>
|
||||
<script src="/static/widget_dialog_border.js"></script>
|
||||
<script src="/static/widget_dialog_button_actions.js"></script>
|
||||
<script src="/static/frame_layout.js"></script>
|
||||
<script src="/static/saved_layouts.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -36,6 +36,7 @@ Each module in this package exposes:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import FrameButtonAction
|
||||
from . import battery, calendar, photos, static_image, tasks, text, weather, whiteboard
|
||||
|
||||
WIDGET_TYPES = {
|
||||
@@ -48,3 +49,31 @@ WIDGET_TYPES = {
|
||||
"weather": weather,
|
||||
"battery": battery,
|
||||
}
|
||||
|
||||
|
||||
def default_button_actions(frame_id: int, widget_id: int, widget_type: str) -> list[FrameButtonAction]:
|
||||
"""NEXT/BACK -> whatever this widget's own advance/back concept is.
|
||||
Called both when backfilling pre-widget-system frames (see
|
||||
app/migration.py) and when a widget is newly created (see
|
||||
routers/api_widgets.py's api_widget_create) -- a widget is never left
|
||||
without a sane starting binding, so the physical buttons always do
|
||||
something reasonable for it until someone deliberately reassigns
|
||||
them in that widget's own config dialog."""
|
||||
if widget_type == "whiteboard":
|
||||
# No real "next"/"back" concept for a static board -- both
|
||||
# buttons mean "check now".
|
||||
return [
|
||||
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
|
||||
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
|
||||
]
|
||||
if widget_type == "weather":
|
||||
return [
|
||||
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="check_now"),
|
||||
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="check_now"),
|
||||
]
|
||||
if widget_type in ("photos", "calendar"):
|
||||
return [
|
||||
FrameButtonAction(frame_id=frame_id, button="next", widget_id=widget_id, action="advance"),
|
||||
FrameButtonAction(frame_id=frame_id, button="back", widget_id=widget_id, action="back"),
|
||||
]
|
||||
return []
|
||||
|
||||
+131
-119
@@ -1,11 +1,13 @@
|
||||
"""GET/PUT /api/frames/{id}/buttons -- the button-assignment UI's API
|
||||
(see routers/api_frames.py's api_buttons_get/api_buttons_save and
|
||||
static/frame_config.js). Covers the CRUD/validation layer; multi-action
|
||||
execution order and partial-failure-continues on an actual button press
|
||||
are already exercised end-to-end in test_device_widget_dispatch.py and
|
||||
routers/device.py's _run_button_actions -- this file doesn't re-test
|
||||
device.py's dispatch, just that the assignment API stores/serves/
|
||||
validates what the UI edits."""
|
||||
"""POST /api/frames/{id}/widgets/{widget_id}/button-actions -- each
|
||||
widget's own NEXT/BACK binding editor (see routers/api_widgets.py's
|
||||
api_widget_button_actions and models.FrameButtonAction), replacing the
|
||||
old frame-level "Button assignments" card. Covers the CRUD/validation
|
||||
layer; multi-widget dispatch and partial-failure-continues on an actual
|
||||
button press are already exercised end-to-end in
|
||||
test_device_widget_dispatch.py and routers/device.py's
|
||||
_run_button_actions -- this file doesn't re-test device.py's dispatch,
|
||||
just that each widget's own binding endpoint stores/validates what its
|
||||
dialog edits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,6 +18,19 @@ from app.models import Frame, FrameButtonAction, Widget, WhiteboardWidgetConfig
|
||||
from .conftest import csrf_headers, link_user, login, make_user
|
||||
|
||||
|
||||
def _photo_widget_id(db_session) -> int:
|
||||
return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one().id
|
||||
|
||||
|
||||
def _shrink_default_widget(client, db_session, w=4, h=5) -> int:
|
||||
"""Frees up the right-hand side of the grid for a second widget."""
|
||||
widget_id = _photo_widget_id(db_session)
|
||||
resp = client.patch(f"/api/frames/1/widgets/{widget_id}",
|
||||
json={"x": 0, "y": 0, "w": w, "h": h}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
return widget_id
|
||||
|
||||
|
||||
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
|
||||
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
@@ -26,153 +41,150 @@ def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
|
||||
return widget
|
||||
|
||||
|
||||
def test_get_buttons_reflects_the_default_migration_mapping(client, db_session):
|
||||
"""Frame #1's auto-migrated photos widget should already have NEXT ->
|
||||
advance, BACK -> back from _default_button_actions (see
|
||||
migration.py) -- the UI just needs to be able to see that default."""
|
||||
def test_new_widget_gets_the_sane_default_binding(client, db_session):
|
||||
"""A brand new photos widget (via the API, not the migration
|
||||
backfill) should already have NEXT -> advance, BACK -> back from
|
||||
widgets.default_button_actions -- no separate save required."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
_shrink_default_widget(client, db_session)
|
||||
resp = client.post("/api/frames/1/widgets", json={"widget_type": "calendar"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
widget_id = resp.json()["id"]
|
||||
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert {w["id"]: w["widget_type"] for w in data["widgets"]} == {photo_widget.id: "photos"}
|
||||
photo_actions = {a["action"] for w in data["widgets"] for a in w["actions"]}
|
||||
assert photo_actions == {"advance", "back"}
|
||||
|
||||
assert data["next"] == [{"id": data["next"][0]["id"], "widget_id": photo_widget.id, "action": "advance"}]
|
||||
assert data["back"] == [{"id": data["back"][0]["id"], "widget_id": photo_widget.id, "action": "back"}]
|
||||
rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
|
||||
assert {r.button: r.action for r in rows} == {"next": "advance", "back": "back"}
|
||||
|
||||
|
||||
def test_get_buttons_includes_placement_and_grid_dims(client, db_session):
|
||||
"""Two widgets of the same type otherwise look identical in the
|
||||
assignment UI ("Photos" / "Photos") -- the client tells them apart
|
||||
using x/y/w/h against the frame's grid dims (see
|
||||
static/frame_config.js's buildWidgetNames), so the API needs to
|
||||
actually hand those over."""
|
||||
def test_new_whiteboard_widget_gets_check_now_on_both_buttons(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
photo_widget.x, photo_widget.y, photo_widget.w, photo_widget.h = 0, 0, 4, 5
|
||||
db_session.commit()
|
||||
second = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
|
||||
sort_order=1, created_at=time.time())
|
||||
db_session.add(second)
|
||||
db_session.flush()
|
||||
from app.models import PhotoWidgetConfig
|
||||
db_session.add(PhotoWidgetConfig(widget_id=second.id))
|
||||
db_session.commit()
|
||||
_shrink_default_widget(client, db_session)
|
||||
resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
widget_id = resp.json()["id"]
|
||||
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["grid"] == {"cols": 8, "rows": 5}
|
||||
by_id = {w["id"]: w for w in data["widgets"]}
|
||||
assert by_id[photo_widget.id]["x"] == 0 and by_id[photo_widget.id]["w"] == 4
|
||||
assert by_id[second.id]["x"] == 4 and by_id[second.id]["w"] == 4
|
||||
rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
|
||||
assert {r.button: r.action for r in rows} == {"next": "check_now", "back": "check_now"}
|
||||
|
||||
|
||||
def test_put_replaces_the_whole_list_in_order(client, db_session):
|
||||
def test_new_tasks_widget_gets_no_default_bindings(client, db_session):
|
||||
"""tasks has an empty ACTIONS -- nothing sane to default to."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
board_widget = _add_whiteboard_widget(db_session, frame)
|
||||
_shrink_default_widget(client, db_session)
|
||||
resp = client.post("/api/frames/1/widgets", json={"widget_type": "tasks"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
widget_id = resp.json()["id"]
|
||||
|
||||
resp = client.put("/api/frames/1/buttons/next", json={
|
||||
"actions": [
|
||||
{"widget_id": board_widget.id, "action": "check_now"},
|
||||
{"widget_id": photo_widget.id, "action": "advance"},
|
||||
],
|
||||
}, headers=csrf_headers(client))
|
||||
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).count() == 0
|
||||
|
||||
|
||||
def test_save_updates_an_existing_binding(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _photo_widget_id(db_session)
|
||||
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
|
||||
json={"next_button_action": "back", "back_button_action": "advance"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
rows = db_session.query(FrameButtonAction).filter_by(
|
||||
frame_id=frame.id, button="next"
|
||||
).order_by(FrameButtonAction.sort_order).all()
|
||||
assert [(r.widget_id, r.action) for r in rows] == [
|
||||
(board_widget.id, "check_now"), (photo_widget.id, "advance"),
|
||||
]
|
||||
|
||||
# BACK's own default mapping (photos -> back) is untouched by a PUT to next.
|
||||
back_rows = db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="back").all()
|
||||
assert len(back_rows) == 1
|
||||
assert back_rows[0].action == "back"
|
||||
rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)}
|
||||
assert rows == {"next": "back", "back": "advance"}
|
||||
|
||||
|
||||
def test_put_empty_list_clears_the_button(client, db_session):
|
||||
def test_save_empty_string_clears_the_binding(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _photo_widget_id(db_session)
|
||||
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
|
||||
json={"next_button_action": "", "back_button_action": "back"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)}
|
||||
assert rows == {"back": "back"}
|
||||
|
||||
|
||||
def test_save_rejects_action_the_widget_type_does_not_support(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _photo_widget_id(db_session)
|
||||
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
|
||||
json={"next_button_action": "check_now", "back_button_action": ""},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
# Nothing partially applied -- the old default binding is untouched.
|
||||
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").one().action == "advance"
|
||||
|
||||
|
||||
def test_save_is_one_binding_per_widget_per_button(client, db_session):
|
||||
"""Saving twice for the same button updates the one row in place,
|
||||
it never accumulates a second row (matches the new unique index on
|
||||
(widget_id, button))."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
widget_id = _photo_widget_id(db_session)
|
||||
|
||||
resp = client.put("/api/frames/1/buttons/next", json={"actions": []}, headers=csrf_headers(client))
|
||||
for action in ("advance", "back", "advance"):
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
|
||||
json={"next_button_action": action, "back_button_action": "back"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="next").count() == 0
|
||||
|
||||
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").count() == 1
|
||||
|
||||
|
||||
def test_put_rejects_unknown_button_name(client, db_session):
|
||||
def test_save_404s_for_unknown_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.put("/api/frames/1/buttons/sideways", json={"actions": []}, headers=csrf_headers(client))
|
||||
resp = client.post("/api/frames/1/widgets/999999/button-actions",
|
||||
json={"next_button_action": "advance", "back_button_action": "back"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_put_rejects_widget_from_another_frame(client, db_session):
|
||||
def test_save_unrelated_user_404s(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
other = Frame(name="Other", device_token="tok-other", manage_token="mtok-other", created_at=time.time())
|
||||
db_session.add(other)
|
||||
db_session.flush()
|
||||
other_widget = Widget(frame_id=other.id, widget_type="photos", x=0, y=0, w=8, h=5,
|
||||
sort_order=0, created_at=time.time())
|
||||
db_session.add(other_widget)
|
||||
db_session.commit()
|
||||
make_user(db_session, "mallory")
|
||||
widget_id = _photo_widget_id(db_session)
|
||||
|
||||
resp = client.put("/api/frames/1/buttons/next", json={
|
||||
"actions": [{"widget_id": other_widget.id, "action": "advance"}],
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
# Nothing partially applied -- the whole request is validated before any write.
|
||||
assert db_session.query(FrameButtonAction).filter_by(frame_id=1, button="next").count() == 1
|
||||
client.cookies.clear()
|
||||
login(client, "mallory")
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
|
||||
json={"next_button_action": "back", "back_button_action": "advance"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").one().action == "advance"
|
||||
|
||||
|
||||
def test_put_rejects_action_the_widget_type_does_not_support(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
|
||||
resp = client.put("/api/frames/1/buttons/next", json={
|
||||
"actions": [{"widget_id": photo_widget.id, "action": "check_now"}],
|
||||
}, headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_deleting_a_widget_cascades_its_button_bindings(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
frame = db_session.get(Frame, 1)
|
||||
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
||||
|
||||
resp = client.delete(f"/api/frames/1/widgets/{photo_widget.id}", headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id).count() == 0
|
||||
|
||||
|
||||
def test_linked_user_can_view_and_control_can_save(client, db_session):
|
||||
def test_save_linked_but_not_controlling_user_409s(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
bob = make_user(db_session, "bob")
|
||||
frame = db_session.get(Frame, 1)
|
||||
link_user(db_session, bob, frame)
|
||||
widget_id = _photo_widget_id(db_session)
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 200
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
|
||||
json={"next_button_action": "back", "back_button_action": "advance"},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["error"] == "not_controller"
|
||||
|
||||
|
||||
def test_unrelated_user_cannot_view(client, db_session):
|
||||
def test_two_widgets_of_the_same_type_have_independent_bindings(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
make_user(db_session, "mallory")
|
||||
photo_widget_id = _photo_widget_id(db_session)
|
||||
frame = db_session.get(Frame, 1)
|
||||
board = _add_whiteboard_widget(db_session, frame)
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "mallory")
|
||||
resp = client.get("/api/frames/1/buttons")
|
||||
assert resp.status_code == 404
|
||||
resp = client.post(f"/api/frames/1/widgets/{board.id}/button-actions",
|
||||
json={"next_button_action": "check_now", "back_button_action": ""},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
photo_rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=photo_widget_id)}
|
||||
board_rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=board.id)}
|
||||
assert photo_rows == {"next": "advance", "back": "back"}
|
||||
assert board_rows == {"next": "check_now"}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user