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()),