diff --git a/boards/common/components/stream32_deck/CMakeLists.txt b/boards/common/components/stream32_deck/CMakeLists.txt index 8b427a9..60dbf86 100644 --- a/boards/common/components/stream32_deck/CMakeLists.txt +++ b/boards/common/components/stream32_deck/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "deck_protocol.c" "deck_storage.c" "deck_ui.c" + SRCS "deck_clean.c" "deck_protocol.c" "deck_storage.c" "deck_ui.c" INCLUDE_DIRS "." REQUIRES esp_partition esp_timer lvgl PRIV_REQUIRES json mbedtls diff --git a/boards/common/components/stream32_deck/deck_clean.c b/boards/common/components/stream32_deck/deck_clean.c new file mode 100644 index 0000000..4ca9d14 --- /dev/null +++ b/boards/common/components/stream32_deck/deck_clean.c @@ -0,0 +1,138 @@ +#include "deck_clean.h" + +#include "esp_timer.h" +#include "sdkconfig.h" + +#define CLEAN_SCREEN_W CONFIG_STREAM32_DECK_SCREEN_WIDTH +#define CLEAN_SCREEN_H CONFIG_STREAM32_DECK_SCREEN_HEIGHT +/* Long enough that a wiping cloth cannot dwell its way out of the lock. */ +#define DECK_CLEAN_HOLD_MS 5000 + +static lv_obj_t *s_screen; +static lv_obj_t *s_fill; +/* Written from the LVGL task, read by the protocol task's poll. */ +static volatile int64_t s_press_ms; + +static int64_t now_ms(void) +{ + return esp_timer_get_time() / 1000; +} + +static void set_progress(int32_t percent) +{ + if (s_fill != NULL) { + lv_obj_set_width(s_fill, lv_pct(percent > 100 ? 100 : percent)); + } +} + +/* Timed here rather than through LVGL's long-press event because that + threshold is shared with every other consumer of the same input device. */ +static void hold_handler(lv_event_t *event) +{ + const lv_event_code_t code = lv_event_get_code(event); + + if (code == LV_EVENT_PRESSED) { + s_press_ms = now_ms(); + set_progress(0); + } else if (code == LV_EVENT_PRESSING && s_press_ms != 0) { + set_progress( + (int32_t)(((now_ms() - s_press_ms) * 100) / DECK_CLEAN_HOLD_MS) + ); + } else if (code == LV_EVENT_RELEASED || code == LV_EVENT_PRESS_LOST) { + deck_clean_reset(); + } +} + +/* A rounded card in the deck's colours, without lv_obj's default chrome. */ +static lv_obj_t *panel(lv_obj_t *parent, int32_t width, int32_t height) +{ + lv_obj_t *object = lv_obj_create(parent); + + lv_obj_set_size(object, width, height); + lv_obj_set_style_radius(object, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_set_style_bg_color(object, lv_color_hex(0x172630), LV_PART_MAIN); + lv_obj_set_style_border_width(object, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(object, 0, LV_PART_MAIN); + lv_obj_remove_flag( + object, + LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE + ); + return object; +} + +static lv_obj_t *caption(lv_obj_t *parent, const char *text, uint32_t color) +{ + lv_obj_t *label = lv_label_create(parent); + + lv_label_set_text(label, text); + lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); + return label; +} + +lv_obj_t *deck_clean_screen(void) +{ + if (s_screen != NULL) { + return s_screen; + } + + const int short_edge = CLEAN_SCREEN_W < CLEAN_SCREEN_H + ? CLEAN_SCREEN_W + : CLEAN_SCREEN_H; + const int target_px = short_edge / 3; + + s_screen = lv_obj_create(NULL); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(s_screen, lv_color_hex(0x0b1116), LV_PART_MAIN); + + lv_obj_t *title = caption(s_screen, "Screen cleaning", 0xffad22); + + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, short_edge / 8); + lv_obj_align_to( + caption(s_screen, "Wipe away. Hold the circle to exit.", 0x91a6b5), + title, + LV_ALIGN_OUT_BOTTOM_MID, + 0, + 12 + ); + + lv_obj_t *target = panel(s_screen, target_px, target_px); + + lv_obj_center(target); + lv_obj_set_style_border_width(target, 2, LV_PART_MAIN); + lv_obj_set_style_border_color( + target, + lv_color_hex(0x29404d), + LV_PART_MAIN + ); + lv_obj_set_style_border_color( + target, + lv_color_hex(0xffad22), + LV_PART_MAIN | LV_STATE_PRESSED + ); + lv_obj_add_flag(target, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(target, hold_handler, LV_EVENT_ALL, NULL); + lv_obj_center(caption(target, "Hold 5s", 0xf3f7f9)); + + /* Two plain objects instead of a bar widget, so no board has to enable an + extra LVGL widget for the hold to show progress. */ + lv_obj_t *track = panel(s_screen, target_px, 8); + + lv_obj_align_to(track, target, LV_ALIGN_OUT_BOTTOM_MID, 0, 24); + s_fill = panel(track, 0, lv_pct(100)); + lv_obj_align(s_fill, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_bg_color(s_fill, lv_color_hex(0xffad22), LV_PART_MAIN); + return s_screen; +} + +void deck_clean_reset(void) +{ + s_press_ms = 0; + set_progress(0); +} + +bool deck_clean_held(int64_t now) +{ + const int64_t started = s_press_ms; + + return started != 0 && now - started >= DECK_CLEAN_HOLD_MS; +} diff --git a/boards/common/components/stream32_deck/deck_clean.h b/boards/common/components/stream32_deck/deck_clean.h new file mode 100644 index 0000000..bbaec6f --- /dev/null +++ b/boards/common/components/stream32_deck/deck_clean.h @@ -0,0 +1,28 @@ +// The screen-cleaning overlay: a dark screen whose only live target is one +// centred hold. deck_ui decides when the lock is on; this owns what it looks +// like and how long the current hold has lasted. +#pragma once + +#include +#include + +#include "lvgl.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Builds the overlay on first use and returns it. Call with the display lock +// held; the caller loads the returned screen. +lv_obj_t *deck_clean_screen(void); + +// Drops any hold in progress and empties the progress bar. Display lock held. +void deck_clean_reset(void); + +// True once the target has been held long enough to release the lock. Safe to +// poll from the protocol task: it reads only the press timestamp. +bool deck_clean_held(int64_t now_ms); + +#ifdef __cplusplus +} +#endif diff --git a/boards/common/components/stream32_deck/deck_protocol.c b/boards/common/components/stream32_deck/deck_protocol.c index 495d2cd..bf9f74c 100644 --- a/boards/common/components/stream32_deck/deck_protocol.c +++ b/boards/common/components/stream32_deck/deck_protocol.c @@ -778,6 +778,37 @@ static const char *handle_page(const cJSON *message) return deck_ui_select_page((uint8_t)page); } +static const char *handle_clean( + const cJSON *message, + char *reply, + size_t reply_capacity +) +{ + const cJSON *active = cJSON_GetObjectItemCaseSensitive(message, "active"); + + if (!cJSON_IsBool(active)) { + return "clean-invalid"; + } + + const bool wanted = cJSON_IsTrue(active); + const char *error = deck_ui_set_clean(wanted); + + if (error != NULL) { + return error; + } + + const int written = snprintf( + reply, + reply_capacity, + "{\"type\":\"clean-ack\",\"active\":%s}", + wanted ? "true" : "false" + ); + + return written >= 0 && (size_t)written < reply_capacity + ? NULL + : "reply-too-small"; +} + static const char *handle_display(const cJSON *message) { const cJSON *blank_now = @@ -859,6 +890,9 @@ bool deck_protocol_dispatch( } else if (strcmp(type->valuestring, "key-update") == 0) { *error_out = handle_key_update(message, reply, reply_capacity); has_reply = true; + } else if (strcmp(type->valuestring, "clean") == 0) { + *error_out = handle_clean(message, reply, reply_capacity); + has_reply = true; } else if (strcmp(type->valuestring, "page") == 0) { *error_out = handle_page(message); } else if (strcmp(type->valuestring, "display") == 0) { diff --git a/boards/common/components/stream32_deck/deck_ui.c b/boards/common/components/stream32_deck/deck_ui.c index 380298e..5da15a0 100644 --- a/boards/common/components/stream32_deck/deck_ui.c +++ b/boards/common/components/stream32_deck/deck_ui.c @@ -4,6 +4,7 @@ #include #include +#include "deck_clean.h" #include "deck_storage.h" #include "esp_heap_caps.h" #include "esp_log.h" @@ -77,8 +78,10 @@ static bool s_forced_asleep; static bool s_consume_touch; static bool s_overlay_active; static int64_t s_overlay_activity_ms; +static bool s_clean_active; static lv_obj_t *s_deck_screen; +static lv_obj_t *s_clean_return_screen; static uint8_t *s_key_buffers[DECK_MAX_KEYS]; static lv_image_dsc_t s_key_dsc[DECK_MAX_KEYS]; @@ -438,7 +441,11 @@ static void build_page_locked(uint8_t page_index) } } - lv_screen_load(s_deck_screen); + /* A sync that lands mid-wipe stages the grid without lifting the lock. */ + if (!s_clean_active) { + lv_screen_load(s_deck_screen); + } + s_active = true; } @@ -510,11 +517,22 @@ bool deck_ui_active(void) void deck_ui_poll(void) { - if (s_panel_awake && !s_forced_asleep && s_idle_timeout_ms > 0 && + /* Wiping never touches the idle timer, so the exit target would otherwise + blank out from under the person holding it. */ + if (s_panel_awake && !s_clean_active && !s_forced_asleep && + s_idle_timeout_ms > 0 && now_ms() - s_last_activity_ms >= s_idle_timeout_ms) { set_panel_awake(false); } + /* Released here rather than in the press handler, which already holds the + display lock that deck_ui_set_clean needs. A busy lock leaves the hold + standing, so the next poll retries. */ + if (s_clean_active && deck_clean_held(now_ms()) && + deck_ui_set_clean(false) == NULL) { + notify_line("{\"type\":\"clean\",\"active\":false}"); + } + if (s_overlay_active && now_ms() - s_overlay_activity_ms >= DECK_OVERLAY_LEASE_MS) { deck_ui_clear_overlays(); @@ -542,7 +560,9 @@ void deck_ui_poll(void) bool deck_ui_handle_touch(bool pressed) { - if (s_forced_asleep) { + /* A wipe reaches neither the host nor a local goPage switch. The hold + target lives on its own screen and never routes through here. */ + if (s_clean_active || s_forced_asleep) { return true; } @@ -814,6 +834,47 @@ const char *deck_ui_select_page(uint8_t page) return NULL; } +bool deck_ui_clean_active(void) +{ + return s_clean_active; +} + +const char *deck_ui_set_clean(bool active) +{ + if (active == s_clean_active) { + return NULL; + } + + if (!bsp_display_lock(1000)) { + return "display-busy"; + } + + deck_clean_reset(); + + if (active) { + s_clean_return_screen = lv_screen_active(); + s_clean_active = true; + lv_screen_load(deck_clean_screen()); + } else { + s_clean_active = false; + /* A sync during the wipe may have built the deck behind the lock. */ + lv_screen_load( + s_active && s_deck_screen != NULL + ? s_deck_screen + : s_clean_return_screen + ); + } + + bsp_display_unlock(); + s_last_activity_ms = now_ms(); + + if (active && !s_forced_asleep) { + set_panel_awake(true); + } + + return NULL; +} + const char *deck_ui_blank_display(void) { s_consume_touch = false; diff --git a/boards/common/components/stream32_deck/deck_ui.h b/boards/common/components/stream32_deck/deck_ui.h index 68753d2..78e8f51 100644 --- a/boards/common/components/stream32_deck/deck_ui.h +++ b/boards/common/components/stream32_deck/deck_ui.h @@ -33,6 +33,13 @@ bool deck_ui_clear_overlays(void); // first touch wakes an idle display; host-forced sleep consumes every touch. bool deck_ui_handle_touch(bool pressed); +// Screen-cleaning lock. While it is on, a wipe cannot reach the deck: every +// touch is swallowed, including the local goPage switches. Only a five second +// hold on the centred target, or the host, releases it. The hold is reported +// through deck_ui_poll as a clean notification. +const char *deck_ui_set_clean(bool active); +bool deck_ui_clean_active(void); + // Narrow typed operations used by deck_protocol after JSON validation. const char *deck_ui_apply_layout( const deck_protocol_layout_t *layout, diff --git a/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/board.json b/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/board.json index c39825a..a553b68 100644 --- a/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/board.json +++ b/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/board.json @@ -26,9 +26,9 @@ "maxPages": 8 }, "firmware": { - "version": "0.2.0", + "version": "0.3.0", "projectPath": "firmware", - "imageName": "elecrow-crowpanel-advanced-10-1-esp32-p4-0.2.0.bin", + "imageName": "elecrow-crowpanel-advanced-10-1-esp32-p4-0.3.0.bin", "offset": 0 }, "capabilities": [ diff --git a/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/firmware/CMakeLists.txt b/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/firmware/CMakeLists.txt index 7b23b42..f2b5858 100644 --- a/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/firmware/CMakeLists.txt +++ b/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/firmware/CMakeLists.txt @@ -5,5 +5,5 @@ set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../common/components") include($ENV{IDF_PATH}/tools/cmake/project.cmake) -set(PROJECT_VER "0.2.0") +set(PROJECT_VER "0.3.0") project(stream32_elecrow_crowpanel_advanced_10_1_esp32_p4) diff --git a/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/firmware/main/main.c b/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/firmware/main/main.c index b807ad2..f999d9a 100644 --- a/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/firmware/main/main.c +++ b/boards/elecrow-crowpanel-advanced-10-1-esp32-p4/firmware/main/main.c @@ -171,7 +171,7 @@ static void send_hello(void) "{\"type\":\"hello\",\"protocol\":%d,\"boardId\":\"%s\"," "\"firmwareVersion\":\"%s\",\"deviceId\":\"%02x%02x%02x%02x%02x%02x\"," "\"features\":[\"display-control\",\"display-brightness\",\"display-blank\"," - "\"key-update\",\"image-rle\",\"%s\"]}", + "\"key-update\",\"image-rle\",\"clean-mode\",\"%s\"]}", STREAM32_PROTOCOL_VERSION, STREAM32_BOARD_ID, app->version, @@ -234,6 +234,12 @@ static void handle_host_message(const char *line, size_t length) : "UART0 connected to Stream32" ); send_hello(); + + /* A cleaning lock outlives the link, so a desktop that reconnects + mid-wipe learns the panel is still locked. */ + if (deck_ui_clean_active()) { + serial_write_line("{\"type\":\"clean\",\"active\":true}"); + } } } else if (strcmp(type->valuestring, "ping") == 0) { const cJSON *id = cJSON_GetObjectItemCaseSensitive(message, "id"); diff --git a/boards/tools/deck-protocol-source.test.js b/boards/tools/deck-protocol-source.test.js index 5b4f2c6..b84075e 100644 --- a/boards/tools/deck-protocol-source.test.js +++ b/boards/tools/deck-protocol-source.test.js @@ -16,6 +16,7 @@ const componentPath = (...parts) => const read = (file) => readFileSync(file, 'utf8'); const protocol = read(componentPath('deck_protocol.c')); const ui = read(componentPath('deck_ui.c')); +const clean = read(componentPath('deck_clean.c')); test('protocol decoding and image sequencing stay outside the LVGL UI', () => { assert.match(protocol, /static bool valid_utf8\(/); @@ -101,7 +102,7 @@ test('both board transports dispatch through the shared protocol module', () => assert.match( read(componentPath('CMakeLists.txt')), - /SRCS "deck_protocol\.c" "deck_storage\.c" "deck_ui\.c"/, + /SRCS "deck_clean\.c" "deck_protocol\.c" "deck_storage\.c" "deck_ui\.c"/, ); }); @@ -158,6 +159,58 @@ test('Sleep blanks through the idle wake path without changing lock state', () = assert.doesNotMatch(blankDisplay, /s_forced_asleep\s*=/); }); +test('the cleaning lock swallows the wipe and only a held exit lifts it', () => { + const handleTouch = ui.slice( + ui.indexOf('bool deck_ui_handle_touch('), + ui.indexOf('static void copy_layout('), + ); + const poll = ui.slice( + ui.indexOf('void deck_ui_poll('), + ui.indexOf('bool deck_ui_handle_touch('), + ); + const buildPage = ui.slice( + ui.lastIndexOf('static void build_page_locked('), + ui.indexOf('static void build_page(', ui.lastIndexOf('static void build_page_locked(')), + ); + + // Consumed before anything routes a press to the host or a local goPage. + assert.match(handleTouch, /^\s*if \(s_clean_active \|\| s_forced_asleep\) \{\s*return true;/m); + + // A sync arriving mid-wipe stages the grid behind the lock, never over it. + assert.match(buildPage, /if \(!s_clean_active\) \{\s*lv_screen_load\(s_deck_screen\);/); + + // Releasing needs the display lock, which the LVGL press callback holds, so + // the hold is settled from the protocol task and retried while it is busy. + assert.match( + poll, + /s_clean_active && deck_clean_held\(now_ms\(\)\) &&\s*deck_ui_set_clean\(false\) == NULL[\s\S]*notify_line\([^)]*clean[^)]*active[^)]*false/, + ); + assert.match(poll, /!s_clean_active && !s_forced_asleep[\s\S]*set_panel_awake\(false\)/); + + // The overlay owns only its own screen: no deck grid or storage reaches it. + assert.match(clean, /#define DECK_CLEAN_HOLD_MS 5000/); + assert.doesNotMatch(clean, /deck_storage_|s_pages|deck_protocol_/); + assert.ok(clean.split(/\r?\n/).length < 1000); + assert.match(protocol, /"clean-invalid"[\s\S]*deck_ui_set_clean\(wanted\)/); + assert.match(protocol, /clean-ack/); + + for (const board of [ + 'waveshare-esp32-s3-touch-lcd-4-v3', + 'elecrow-crowpanel-advanced-10-1-esp32-p4', + ]) { + const main = read( + path.join(ROOT, 'boards', board, 'firmware', 'main', 'main.c'), + ); + + assert.match(main, /clean-mode/); + // A lock outlives the link, so a reconnecting desktop is told about it. + assert.match( + main, + /send_hello\(\);[\s\S]{0,400}deck_ui_clean_active\(\)[\s\S]{0,200}write_line\([^)]*clean[^)]*active[^)]*true/, + ); + } +}); + test('key labels stay on one ellipsized line above artwork', () => { const start = ui.lastIndexOf('static void build_page_locked('); const buildPage = ui.slice(start, ui.indexOf('static void build_page(', start)); diff --git a/boards/waveshare-esp32-s3-touch-lcd-4-v3/board.json b/boards/waveshare-esp32-s3-touch-lcd-4-v3/board.json index c6f55d5..e225001 100644 --- a/boards/waveshare-esp32-s3-touch-lcd-4-v3/board.json +++ b/boards/waveshare-esp32-s3-touch-lcd-4-v3/board.json @@ -20,9 +20,9 @@ "maxPages": 8 }, "firmware": { - "version": "0.2.9", + "version": "0.3.0", "projectPath": "firmware", - "imageName": "waveshare-esp32-s3-touch-lcd-4-v3-0.2.9.bin", + "imageName": "waveshare-esp32-s3-touch-lcd-4-v3-0.3.0.bin", "offset": 0 }, "capabilities": [ diff --git a/boards/waveshare-esp32-s3-touch-lcd-4-v3/firmware/CMakeLists.txt b/boards/waveshare-esp32-s3-touch-lcd-4-v3/firmware/CMakeLists.txt index 5be3da4..573aab8 100644 --- a/boards/waveshare-esp32-s3-touch-lcd-4-v3/firmware/CMakeLists.txt +++ b/boards/waveshare-esp32-s3-touch-lcd-4-v3/firmware/CMakeLists.txt @@ -5,5 +5,5 @@ set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../common/components") include($ENV{IDF_PATH}/tools/cmake/project.cmake) -set(PROJECT_VER "0.2.9") +set(PROJECT_VER "0.3.0") project(stream32_waveshare_esp32_s3_touch_lcd_4_v3) diff --git a/boards/waveshare-esp32-s3-touch-lcd-4-v3/firmware/main/main.c b/boards/waveshare-esp32-s3-touch-lcd-4-v3/firmware/main/main.c index c5d8ff7..717b550 100644 --- a/boards/waveshare-esp32-s3-touch-lcd-4-v3/firmware/main/main.c +++ b/boards/waveshare-esp32-s3-touch-lcd-4-v3/firmware/main/main.c @@ -89,7 +89,7 @@ static void send_hello(void) "{\"type\":\"hello\",\"protocol\":%d,\"boardId\":\"%s\"," "\"firmwareVersion\":\"%s\",\"deviceId\":\"%02x%02x%02x%02x%02x%02x\"," "\"features\":[\"display-control\",\"display-blank\",\"key-update\"," - "\"image-rle\"]}", + "\"image-rle\",\"clean-mode\"]}", STREAM32_PROTOCOL_VERSION, STREAM32_BOARD_ID, app->version, @@ -143,6 +143,12 @@ static void handle_host_message(const char *line, size_t length) deck_protocol_clear_overlays(); update_connection_label("USB connected to Stream32"); send_hello(); + + /* A cleaning lock outlives the USB link, so a desktop that + reconnects mid-wipe learns the panel is still locked. */ + if (deck_ui_clean_active()) { + usb_write_line("{\"type\":\"clean\",\"active\":true}"); + } } } else if (strcmp(type->valuestring, "ping") == 0) { const cJSON *id = cJSON_GetObjectItemCaseSensitive(message, "id"); diff --git a/desktop/src/actions.js b/desktop/src/actions.js index 1104bfb..f7e7483 100644 --- a/desktop/src/actions.js +++ b/desktop/src/actions.js @@ -823,6 +823,9 @@ function createActionRunner({ case 'sleep': // Display blanking is resolved renderer-side against the device session. throw new TypeError('Sleep actions never reach the main process.'); + case 'clean': + // The cleaning lock is resolved renderer-side against the device session. + throw new TypeError('Clean actions never reach the main process.'); default: throw new TypeError(`Unknown action type: ${action?.type}`); } diff --git a/desktop/src/deck-model.js b/desktop/src/deck-model.js index ced3a75..990ecc4 100644 --- a/desktop/src/deck-model.js +++ b/desktop/src/deck-model.js @@ -135,6 +135,8 @@ function validateLeafAction(action, pageCount) { } case 'sleep': return { type: 'sleep' }; + case 'clean': + return { type: 'clean' }; case 'plugin': return validatePluginReference(action); default: @@ -207,17 +209,23 @@ function validateAction(action, pageCount) { return { type: 'multi', steps }; } +// Deck-scoped actions resolve renderer-side against the live device session, +// so reaching privileged execution means the renderer expanded nothing. +const RENDERER_ONLY_ACTIONS = new Map([ + ['page', 'Page'], + ['profile', 'Profile'], + ['sleep', 'Sleep'], + ['clean', 'Screen cleaning'], + ['multi', 'Multi and delay'], + ['delay', 'Multi and delay'], +]); + function validateHostAction(action) { - if (['page', 'profile', 'sleep', 'multi', 'delay'].includes(action?.type)) { + const name = RENDERER_ONLY_ACTIONS.get(action?.type); + + if (name) { throw new TypeError( - `${action?.type === 'page' - ? 'Page' - : action?.type === 'profile' - ? 'Profile' - : action?.type === 'sleep' - ? 'Sleep' - : 'Multi and delay'} actions ` + - 'never reach the main process unexpanded.', + `${name} actions never reach the main process unexpanded.`, ); } diff --git a/desktop/src/renderer/action-editor.js b/desktop/src/renderer/action-editor.js index 4898fa3..c05c7cb 100644 --- a/desktop/src/renderer/action-editor.js +++ b/desktop/src/renderer/action-editor.js @@ -125,6 +125,24 @@ const CORE_ACTIONS = [ color: '#263746', }, }, + { + key: 'core:clean', + source: 'Stream32', + category: 'Deck', + name: 'Clean screen', + description: + 'Lock this deck for wiping. Hold the circle on the deck for five ' + + 'seconds, or switch it off in Devices, to unlock it.', + icon: 'cleaning_services', + keywords: ['clean', 'wipe', 'lock', 'screen', 'smudge'], + available: true, + coreType: 'clean', + appearance: { + label: 'Clean', + icon: 'cleaning_services', + color: '#1f3a3a', + }, + }, { key: 'core:multi', source: 'Stream32', diff --git a/desktop/src/renderer/action-fields.js b/desktop/src/renderer/action-fields.js index 5cca0be..ed691f2 100644 --- a/desktop/src/renderer/action-fields.js +++ b/desktop/src/renderer/action-fields.js @@ -77,6 +77,8 @@ function newActionForDefinition(definition, profiles = []) { return { type: 'profile', profileId: profiles[0]?.id || '' }; case 'sleep': return { type: 'sleep' }; + case 'clean': + return { type: 'clean' }; case 'multi': return { type: 'multi', steps: [] }; default: @@ -160,6 +162,9 @@ function buildLeafAction(draft, definition, pageCount) { case 'sleep': candidate = { type: 'sleep' }; break; + case 'clean': + candidate = { type: 'clean' }; + break; default: throw new TypeError(`Expected a leaf action, received ${definition.coreType}.`); } @@ -499,6 +504,7 @@ function renderReady({ container, document }) { } const FIELD_BUILDERS = Object.freeze({ + clean: renderReady, hotkey: renderHotkey, launch: renderString, media: renderMedia, diff --git a/desktop/src/renderer/action-sequence.js b/desktop/src/renderer/action-sequence.js index 31dc352..f7bfbb6 100644 --- a/desktop/src/renderer/action-sequence.js +++ b/desktop/src/renderer/action-sequence.js @@ -16,6 +16,7 @@ async function runActionSequence( switchPage, switchProfile, blankDisplay, + cleanDisplay, sleep = defaultSleep, isCancelled = () => false, }, @@ -36,6 +37,8 @@ async function runActionSequence( await switchProfile(step.profileId); } else if (step.type === 'sleep') { await blankDisplay(); + } else if (step.type === 'clean') { + await cleanDisplay(); } else { await runLeaf(step); } diff --git a/desktop/src/renderer/deck-runtime.js b/desktop/src/renderer/deck-runtime.js index 7e4858c..f8c6199 100644 --- a/desktop/src/renderer/deck-runtime.js +++ b/desktop/src/renderer/deck-runtime.js @@ -10,12 +10,14 @@ const { } = require('../dynamic-state'); const { ProfileSwitcher } = require('./profile-switcher'); const { + encodeCleanMessage, encodeDisplayBlankMessage, encodeImageChunks, encodeKeyUpdateMessage, encodeLayoutMessage, encodePageMessage, layoutLineLimitFor, + validateCleanMessage, validateImageAck, validateKeyUpdateAck, validateLayoutAck, @@ -91,6 +93,7 @@ class DeckRuntime { this.syncTimers = new Map(); this.syncRunning = new Map(); this.multiRuns = new Set(); + this.cleaning = new Set(); this.liveValues = new Map(); this.liveQueues = new Map(); this.liveTimers = new Map(); @@ -488,6 +491,7 @@ class DeckRuntime { this.rejectPending(deviceId, new Error('The device disconnected.')); clearTimeout(this.syncTimers.get(deviceId)); this.syncTimers.delete(deviceId); + this.cleaning.delete(deviceId); this.clearLiveRuntime(deviceId); this.onRenderAll(); } @@ -525,6 +529,8 @@ class DeckRuntime { this.handlePress(deviceId, session, message); } else if (message.type === 'page') { this.handleDevicePage(deviceId, session, message); + } else if (message.type === 'clean') { + this.applyCleanState(deviceId, validateCleanMessage(message).active); } } @@ -742,6 +748,42 @@ class DeckRuntime { await session.send(encodeDisplayBlankMessage()); } + // The board is the source of truth: it also reports the five second hold + // that releases the lock without the desktop. + applyCleanState(deviceId, active) { + if (active) { + this.cleaning.add(deviceId); + } else { + this.cleaning.delete(deviceId); + } + + this.onRenderAll(); + } + + async setCleanMode(deviceId, active) { + const session = this.sessions.get(deviceId); + + if (!session) { + throw new Error('The deck is not connected.'); + } + + if (!session.hello?.features?.includes('clean-mode')) { + throw new Error('Screen cleaning requires updated board firmware.'); + } + + const ack = validateCleanMessage(await this.sendWithReply( + deviceId, + session, + encodeCleanMessage(active), + { + type: 'clean-ack', + identity: { active }, + errorCodes: ['clean-invalid', 'display-busy', 'unknown-type'], + }, + )); + this.applyCleanState(deviceId, ack.active); + } + async runKeyAction(deviceId, action, origin = {}) { try { if (action.type === 'page') { @@ -765,6 +807,11 @@ class DeckRuntime { return true; } + if (action.type === 'clean') { + await this.setCleanMode(deviceId, true); + return true; + } + if (action.type !== 'multi') { await this.api.runAction(action); this.flipToggleAfterSuccess(deviceId, origin); @@ -791,6 +838,7 @@ class DeckRuntime { switchProfile: (targetProfileId) => this.switchDeviceProfile(deviceId, targetProfileId), blankDisplay: () => this.blankDeviceDisplay(deviceId), + cleanDisplay: () => this.setCleanMode(deviceId, true), isCancelled: () => this.getSelectedProfileId(deviceId) !== profileId || Boolean( diff --git a/desktop/src/renderer/device-manager.js b/desktop/src/renderer/device-manager.js index 9ae0001..6da6074 100644 --- a/desktop/src/renderer/device-manager.js +++ b/desktop/src/renderer/device-manager.js @@ -28,7 +28,7 @@ function companionStatusText(row, link) { // Joins the persisted device registry, the live runtime sessions, and the // board catalog into one inventory. Pure so the view model is unit-testable // without a DOM. -function deviceInventory({ devices = {}, sessions, boards } = {}) { +function deviceInventory({ devices = {}, sessions, boards, cleaning } = {}) { const sessionFor = (id) => (sessions && sessions.get ? sessions.get(id) : undefined); const boardFor = (id) => (boards && boards.get ? boards.get(id) : undefined); @@ -63,6 +63,10 @@ function deviceInventory({ devices = {}, sessions, boards } = {}) { supportsBrightness: connected ? (session.hello?.features ?? []).includes('display-brightness') : false, + supportsClean: connected + ? (session.hello?.features ?? []).includes('clean-mode') + : false, + cleaning: connected && cleaning ? cleaning.has(deviceId) : false, deckLimits: board?.deck ?? { maxRows: MAX_ROWS, maxCols: MAX_COLS, @@ -154,6 +158,7 @@ class DeviceManager { this.empty = document.querySelector('#device-manager-empty'); this.status = document.querySelector('#device-manager-status'); this.scanButton = document.querySelector('#device-manager-scan'); + this.cleanAllButton = document.querySelector('#device-manager-clean-all'); this.companionHost = document.querySelector('#companion-host'); this.companionPort = document.querySelector('#companion-port'); this.companionLinkStatus = document.querySelector('#companion-link-status'); @@ -164,6 +169,7 @@ class DeviceManager { async initialize() { this.scanButton?.addEventListener('click', () => this.scan()); + this.cleanAllButton?.addEventListener('click', () => this.cleanAll()); for (const control of [this.companionHost, this.companionPort]) { control.addEventListener('change', () => this.saveCompanionAddress()); @@ -248,12 +254,17 @@ class DeviceManager { this.status.dataset.state = state; } - render() { - const rows = deviceInventory({ + inventory() { + return deviceInventory({ devices: this.deck.devices, sessions: this.deck.runtime.sessions, boards: this.deviceController.boards, + cleaning: this.deck.runtime.cleaning, }); + } + + render() { + const rows = this.inventory(); this.list.replaceChildren(); @@ -265,6 +276,14 @@ class DeviceManager { this.empty.hidden = rows.length > 0; } + if (this.cleanAllButton) { + const capable = rows.filter((row) => row.supportsClean); + this.cleanAllButton.hidden = capable.length < 2; + this.cleanAllButton.textContent = capable.every((row) => row.cleaning) + ? 'Unlock all screens' + : 'Clean all screens'; + } + const summary = summarizeInventory(rows); this.setStatus(summary.label, summary.state); } @@ -332,6 +351,15 @@ class DeviceManager { actions.append(update); } + if (row.supportsClean) { + const clean = this.createButton( + row.cleaning ? 'Stop cleaning' : 'Clean screen', + row.cleaning ? 'button button-primary' : 'button button-quiet', + ); + clean.addEventListener('click', () => this.toggleCleaning(row, clean)); + actions.append(clean); + } + const toggle = this.createButton( row.connected ? 'Disconnect' : 'Reconnect', 'button button-quiet', @@ -510,6 +538,57 @@ class DeviceManager { } } + async toggleCleaning(row, control) { + control.disabled = true; + let status; + + try { + await this.deck.runtime.setCleanMode(row.deviceId, !row.cleaning); + status = row.cleaning + ? [`${row.name} is unlocked.`, 'idle'] + : [ + `${row.name} is locked for cleaning. Hold the circle on the deck ` + + 'for five seconds, or switch it off here.', + 'ready', + ]; + } catch (error) { + status = [`Could not change cleaning mode: ${error.message}`, 'error']; + } + + // render() rewrites the status line, so the outcome has to follow it. + this.render(); + this.setStatus(...status); + } + + // Each board acknowledges on its own link, so a board that refuses must not + // leave the rest of them unlocked. + async cleanAll() { + const rows = this.inventory().filter((row) => row.supportsClean); + const active = !rows.every((row) => row.cleaning); + + this.cleanAllButton.disabled = true; + + const results = await Promise.allSettled( + rows.map((row) => this.deck.runtime.setCleanMode(row.deviceId, active)), + ); + const failures = results.flatMap((result, index) => + result.status === 'rejected' + ? [`${rows[index].name}: ${result.reason.message}`] + : []); + + this.cleanAllButton.disabled = false; + this.render(); + this.setStatus( + failures.length > 0 + ? `Could not change cleaning mode — ${failures.join('; ')}` + : active + ? `${rows.length} screens locked for cleaning. Hold the circle on ` + + 'a deck for five seconds to unlock it.' + : `${rows.length} screens unlocked.`, + failures.length > 0 ? 'error' : active ? 'ready' : 'idle', + ); + } + async toggleConnection(row, control) { control.disabled = true; diff --git a/desktop/src/renderer/index.html b/desktop/src/renderer/index.html index afe841e..15f264d 100644 --- a/desktop/src/renderer/index.html +++ b/desktop/src/renderer/index.html @@ -916,13 +916,23 @@

Device manager

and disconnect from one place.

- +
+ + +

{ assert.match(statuses.at(-1)[0], /updated board firmware/); }); +test('Screen cleaning locks on acknowledgement and unlocks on the deck hold', async () => { + const controller = createRuntime(); + const messages = []; + const statuses = []; + const session = { + hello: { deviceId: 'aaaa11112222', features: ['clean-mode'] }, + send: async (bytes) => { + messages.push(JSON.parse(new TextDecoder().decode(bytes))); + controller.handleDeviceMessage(session, { + type: 'clean-ack', + active: messages.at(-1).active, + }); + }, + }; + controller.sessions = new Map([['aaaa11112222', session]]); + controller.api = { + runAction: async () => { + throw new Error('Screen cleaning reached privileged execution.'); + }, + }; + controller.setSyncStatus = (...status) => statuses.push(status); + + assert.equal( + await controller.runKeyAction('aaaa11112222', { type: 'clean' }), + true, + ); + assert.deepEqual(messages, [{ type: 'clean', active: true }]); + assert.equal(controller.cleaning.has('aaaa11112222'), true); + + // The five second hold happens on the board, which reports it unprompted. + controller.handleDeviceMessage(session, { type: 'clean', active: false }); + assert.equal(controller.cleaning.has('aaaa11112222'), false); + + // A disconnect cannot leave the desktop showing a lock that is gone. + await controller.setCleanMode('aaaa11112222', true); + assert.equal(controller.cleaning.has('aaaa11112222'), true); + controller.detachSession(session); + assert.equal(controller.cleaning.has('aaaa11112222'), false); + + controller.sessions = new Map([['aaaa11112222', session]]); + session.hello.features = ['display-blank']; + assert.equal( + await controller.runKeyAction('aaaa11112222', { type: 'clean' }), + false, + ); + assert.match(statuses.at(-1)[0], /updated board firmware/); +}); + test('Multi profile switch cancels every later step', async () => { const controller = createRuntime(); const hostActions = []; diff --git a/desktop/test/device-manager.test.js b/desktop/test/device-manager.test.js index a43c0dc..8acefe9 100644 --- a/desktop/test/device-manager.test.js +++ b/desktop/test/device-manager.test.js @@ -159,18 +159,33 @@ function managerFixture() { shown: [], reconnects: 0, renamed: [], + cleaned: [], }; const manager = Object.create(DeviceManager.prototype); const document = { createElement: (tag) => makeElement(tag) }; + const cleaning = new Set(); manager.document = document; manager.list = makeElement('div'); manager.empty = makeElement('p'); manager.status = makeElement('p'); + manager.cleanAllButton = makeElement('button'); manager.showView = (name) => calls.shown.push(name); manager.deck = { devices, - runtime: { sessions }, + runtime: { + sessions, + cleaning, + setCleanMode: async (deviceId, active) => { + calls.cleaned.push([deviceId, active]); + + if (devices[deviceId].refusesCleaning) { + throw new Error('The device did not acknowledge in time.'); + } + + cleaning[active ? 'add' : 'delete'](deviceId); + }, + }, api: { renameDeck: async (deviceId, name) => { calls.renamed.push([deviceId, name]); @@ -265,6 +280,46 @@ test('offline boards reconnect and renaming persists the new name', async () => assert.equal(manager.deck.devices[calls.renamed[0][0]].name, 'Renamed booth'); }); +test('cleaning locks capable boards and keeps going when one refuses', async () => { + const { manager, calls } = managerFixture(); + const sessions = manager.deck.runtime.sessions; + + manager.render(); + assert.equal(findByText(manager.list, 'Clean screen'), null); + assert.equal(manager.cleanAllButton.hidden, true); + + sessions.get('aaaaaaaaaaaa').hello.features = ['clean-mode']; + sessions.get('bbbbbbbbbbbb').hello.features = ['clean-mode']; + manager.render(); + assert.equal(manager.cleanAllButton.hidden, false); + assert.equal(manager.cleanAllButton.textContent, 'Clean all screens'); + + // Connected boards sort by name, so the first card is Booth. + await fire(findByText(manager.list, 'Clean screen'), 'click'); + assert.deepEqual(calls.cleaned, [['bbbbbbbbbbbb', true]]); + assert.ok(findByText(manager.list, 'Stop cleaning')); + assert.match(manager.status.textContent, /Booth is locked for cleaning/); + + await manager.cleanAll(); + assert.deepEqual(calls.cleaned.slice(1), [ + ['bbbbbbbbbbbb', true], + ['aaaaaaaaaaaa', true], + ]); + assert.equal(manager.cleanAllButton.textContent, 'Unlock all screens'); + assert.equal(manager.cleanAllButton.disabled, false); + + // A board that will not answer must not strand the others still locked. + manager.deck.devices.aaaaaaaaaaaa.refusesCleaning = true; + await manager.cleanAll(); + assert.deepEqual(calls.cleaned.slice(3), [ + ['bbbbbbbbbbbb', false], + ['aaaaaaaaaaaa', false], + ]); + assert.equal(manager.deck.runtime.cleaning.has('bbbbbbbbbbbb'), false); + assert.equal(manager.status.dataset.state, 'error'); + assert.match(manager.status.textContent, /Studio: The device did not/); +}); + test('device manager view and nav are wired accessibly', () => { const html = readFileSync( path.join(__dirname, '..', 'src', 'renderer', 'index.html'), @@ -285,6 +340,7 @@ test('device manager view and nav are wired accessibly', () => { /id="device-manager-status"[\s\S]*aria-live="polite"/, ); assert.match(html, /id="device-manager-list"/); + assert.match(html, /id="device-manager-clean-all"/); assert.match(renderer, /\['deck', 'devices', 'flash', 'settings'\]/); assert.match(renderer, /new DeviceManager\(/); }); diff --git a/desktop/test/protocol.test.js b/desktop/test/protocol.test.js index daca542..ab92694 100644 --- a/desktop/test/protocol.test.js +++ b/desktop/test/protocol.test.js @@ -5,6 +5,7 @@ const { MAX_PROTOCOL_LINE_LENGTH, crc32, createLineDecoder, + encodeCleanMessage, encodeDisplayBlankMessage, encodeDisplayMessage, encodeHostHello, @@ -16,6 +17,7 @@ const { isExpectedChip, layoutLineLimitFor, transportRank, + validateCleanMessage, validateDeviceHello, validateImageAck, validateKeyUpdateAck, @@ -579,6 +581,34 @@ test('validates deck acknowledgements and events', () => { ); }); +test('encodes the cleaning lock and reads the board state back', () => { + assert.equal( + new TextDecoder().decode(encodeCleanMessage(true)), + '{"type":"clean","active":true}\n', + ); + assert.equal( + new TextDecoder().decode(encodeCleanMessage(false)), + '{"type":"clean","active":false}\n', + ); + assert.throws(() => encodeCleanMessage('yes'), /boolean/); + assert.throws(() => encodeCleanMessage(1), /boolean/); + + // Same shape for the ack and for the hold the board reports on its own. + assert.deepEqual( + validateCleanMessage({ type: 'clean-ack', active: true }), + { active: true }, + ); + assert.deepEqual( + validateCleanMessage({ type: 'clean', active: false }), + { active: false }, + ); + assert.throws(() => validateCleanMessage({ type: 'clean' }), /boolean/); + assert.throws( + () => validateCleanMessage({ type: 'clean', active: 'true' }), + /boolean/, + ); +}); + test('encodes validated display policy messages', () => { assert.equal( new TextDecoder().decode(encodeDisplayBlankMessage()),