Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ jobs:
python3 -m unittest discover -s ../../../../scripts/tests -p test_package_firmware.py -k lock_reader -v
fi &&
if [ "${{ matrix.name }}" = "waveshare-amoled-room-node" ]; then
python3 ../../components/esp-openclaw-room-node/tests/run_lifecycle_host_tests.py --managed-components managed_components
python3 ../../components/esp-openclaw-room-node/tests/run_lifecycle_host_tests.py --managed-components managed_components &&
python3 ../../components/esp-openclaw-room-node/tests/run_ui_host_tests.py --lvgl-dir managed_components/lvgl__lvgl
fi &&
if [ "${{ matrix.name }}" != "component-test-app-build" ]; then
python3 ../../scripts/package_firmware.py \
Expand Down
1 change: 1 addition & 0 deletions components/esp-openclaw-room-node/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ idf_component_register(
"room_ui_controller.c"
INCLUDE_DIRS "include"
PRIV_INCLUDE_DIRS "."
EMBED_FILES "assets/openclaw_lobster.argb8888"
REQUIRES
av_render
console
Expand Down
18 changes: 14 additions & 4 deletions components/esp-openclaw-room-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ storage port exposes an approved file root. Codec models, pins, panel
controllers, remote-Wi-Fi transport, and scheduler profiles remain outside
this component.

Non-animated displays use a static OpenClaw home with board identity, separate
Wi-Fi/Gateway/Talk facts, and bounded setup guidance or error details. Canvas and Diagnostics
cover the home without replacing those facts. The optional trailing
`display.idle_brightness` field accepts 0-100; omitted or zero preserves idle
display sleep, and explicit off requests remain unchanged. A nonzero value
keeps the backlight dimly lit and consumes more idle power. The home is a
status surface, not evidence that touch, camera, networking or Talk has been
qualified on a particular board.

The audio port's input gain override is optional. Boards that set
`configure_input_gain` also provide `input_gain_db`; otherwise shared media
initialization preserves the codec or board default.
Expand All @@ -31,12 +40,13 @@ by the current media stack: WakeNet only advances while its AFE fetch path is
drained. It lives here once, alongside the shared capture orchestration, until
the upstream capture component exposes the equivalent wake callback contract.
The closure retains Espressif's modified-MIT notice in
`LICENSE.ESPRESSIF-MODIFIED-MIT`; the rest of this component is Apache-2.0.
`LICENSE.ESPRESSIF-MODIFIED-MIT`. The OpenClaw home image retains its MIT notice
and source provenance in [assets/NOTICE.md](assets/NOTICE.md); the remaining
component source is Apache-2.0.

Long-press the status screen or an empty Canvas background to open the shared
Diagnostics overlay. Non-animated status screens show `Hold for diagnostics`
near the bottom; the hint is hidden on Canvas and while the modal is open. The
overlay keeps the current screen loaded beneath a blocking, scrollable modal;
Diagnostics overlay. The overlay keeps the current screen loaded beneath a
blocking, scrollable modal;
tap the large Close button (or long-press the modal) to return. Audio is shown
first: live MIC, post-AFE, and RX/SPK PCM meters include freshness, counters,
capture ownership, AFE/WakeNet mode, and renderer results.
Expand Down
40 changes: 40 additions & 0 deletions components/esp-openclaw-room-node/assets/NOTICE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# OpenClaw Home Image

`openclaw_lobster.argb8888` is the existing transparent 180 x 180 OpenClaw
`ui/public/apple-touch-icon.png`, converted without resizing to straight-alpha
BGRA bytes for LVGL's little-endian ARGB8888 format. It contains exactly 129600
bytes and is embedded as read-only firmware data. No PNG decoder, image
animation, or per-frame image allocation is used.

Source: https://github.com/openclaw/openclaw/blob/56cd7472a3ce190e866fbe67838fc45705e6b2cd/ui/public/apple-touch-icon.png

License: https://github.com/openclaw/openclaw/blob/56cd7472a3ce190e866fbe67838fc45705e6b2cd/LICENSE

Conversion uses Pillow 11.3.0: `Image.open(source).convert("RGBA").tobytes("raw", "BGRA")`.

SHA-256:

- Source PNG: `e8c7ce0a3a6c52bd904cc55e31a5b3a8b6392dcd70ba9e220ecf0ef1d6bd8c16`
- Embedded bitmap: `984065fcee3b54174a3849c56ba5c31c890ee2bca35124e88ce287255aa58044`

## MIT License

Copyright (c) 2026 OpenClaw Foundation

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
110 changes: 99 additions & 11 deletions components/esp-openclaw-room-node/esp_openclaw_room_node.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include "esp_peer.h"
#include "esp_peer_default.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "esp_webrtc.h"
#include "esp_capture.h"
#include "freertos/idf_additions.h"
Expand Down Expand Up @@ -50,6 +51,11 @@ static TimerHandle_t talk_timeout_timer;
static bool operator_ready;
static bool node_ready;
static bool media_ready;
static bool media_initialized;
static bool node_session_missing;
static bool operator_session_missing;
static bool node_connection_pending;
static room_ui_wifi_state_t home_wifi_state;
static bool talk_start_in_flight;
static bool talk_dialing;
static bool talk_active;
Expand All @@ -75,6 +81,56 @@ static char *gateway_http_base;
* generation-checked facts under state_lock are the only work authority. */
typedef uint8_t talk_teardown_request_t;

static void refresh_home_facts(void)
{
xSemaphoreTake(state_lock, portMAX_DELAY);
const room_ui_facts_t facts = {
.wifi = home_wifi_state,
.gateway = node_client == NULL ? ROOM_UI_GATEWAY_STARTING
: node_ready ? ROOM_UI_GATEWAY_CONNECTED
: node_session_missing ? ROOM_UI_GATEWAY_NO_SESSION
: node_connection_pending ? ROOM_UI_GATEWAY_CONNECTING : ROOM_UI_GATEWAY_OFFLINE,
.talk = !media_initialized ? ROOM_UI_TALK_STARTING
: !media_ready ? ROOM_UI_TALK_UNAVAILABLE
: talk_call != NULL && (talk_cancel_requested || talk_closing) ? ROOM_UI_TALK_STOPPING
: talk_active ? ROOM_UI_TALK_ACTIVE
: talk_call != NULL ? ROOM_UI_TALK_CONNECTING
: operator_ready ? ROOM_UI_TALK_READY
: operator_session_missing ? ROOM_UI_TALK_NO_SESSION : ROOM_UI_TALK_WAITING,
};
/* Store while the owner is locked, but never wait for LVGL under that lock. */
room_ui_store_facts(&facts);
xSemaphoreGive(state_lock);
room_ui_refresh();
}

static void refresh_wifi_facts(esp_event_base_t base, int32_t event_id)
{
esp_openclaw_node_wifi_status_t wifi = {0};
esp_openclaw_node_wifi_get_status(&wifi);
bool offline = (base == WIFI_EVENT &&
(event_id == WIFI_EVENT_STA_DISCONNECTED || event_id == WIFI_EVENT_STA_STOP)) ||
(base == IP_EVENT && event_id == IP_EVENT_STA_LOST_IP);
xSemaphoreTake(state_lock, portMAX_DELAY);
home_wifi_state = wifi.connected && !offline ? ROOM_UI_WIFI_CONNECTED
: !wifi.has_saved_network ? ROOM_UI_WIFI_UNCONFIGURED
: offline ? ROOM_UI_WIFI_OFFLINE : ROOM_UI_WIFI_CONNECTING;
xSemaphoreGive(state_lock);
refresh_home_facts();
}

static void home_network_event(void *arg, esp_event_base_t base, int32_t event_id, void *data)
{
(void)arg;
(void)data;
if ((base == WIFI_EVENT && (event_id == WIFI_EVENT_STA_START ||
event_id == WIFI_EVENT_STA_CONNECTED || event_id == WIFI_EVENT_STA_DISCONNECTED ||
event_id == WIFI_EVENT_STA_STOP)) ||
(base == IP_EVENT && (event_id == IP_EVENT_STA_GOT_IP || event_id == IP_EVENT_STA_LOST_IP))) {
refresh_wifi_facts(base, event_id);
}
}

static void media_scheduler(const char *name, media_lib_thread_cfg_t *cfg)
{
if (strcmp(name, "aenc_0") == 0 || strcmp(name, "AUD_SRC") == 0) {
Expand Down Expand Up @@ -280,6 +336,7 @@ static void talk_teardown_task(void *arg)
esp_openclaw_talk_call_handle_t call = talk_call;
const char *message = talk_cancel_message;
xSemaphoreGive(state_lock);
refresh_home_facts();
/* One worker serializes call/operator UI. A delayed callback never
* paints over a replacement call's newer connecting/speaking state. */
if (speaking) room_ui_set(ROOM_UI_SPEAKING, NULL);
Expand Down Expand Up @@ -318,6 +375,7 @@ static void talk_teardown_task(void *arg)
talk_closing = false;
xSemaphoreGive(state_lock);
esp_openclaw_talk_call_release(call);
refresh_home_facts();
}
}

Expand Down Expand Up @@ -513,6 +571,7 @@ static void operator_event(
if (current) {
operator_ready = connected;
if (connected) {
operator_session_missing = false;
++operator_incarnation;
if (client == talk_operator && talk_operator_incarnation != operator_incarnation) {
request_talk_stop_locked(talk_generation, NULL);
Expand All @@ -535,6 +594,14 @@ static void operator_event(
}
}

static void record_operator_session_result(esp_err_t err)
{
if (err != ESP_OK && err != ESP_ERR_NOT_FOUND) return;
xSemaphoreTake(state_lock, portMAX_DELAY);
operator_session_missing = err == ESP_ERR_NOT_FOUND;
xSemaphoreGive(state_lock);
}

static esp_err_t start_operator_client(void)
{
xSemaphoreTake(state_lock, portMAX_DELAY);
Expand All @@ -549,6 +616,7 @@ static esp_err_t start_operator_client(void)
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION,
};
esp_err_t err = esp_openclaw_node_request_connect(existing, &reconnect);
record_operator_session_result(err);
return err == ESP_ERR_INVALID_STATE ? ESP_OK : err;
}

Expand Down Expand Up @@ -576,7 +644,9 @@ static esp_err_t start_operator_client(void)
xSemaphoreGive(state_lock);
/* The canvas keep-warm refresh needs operator scope. */
room_canvas_set_refresh_client(created);
return esp_openclaw_node_request_connect(created, &request);
err = esp_openclaw_node_request_connect(created, &request);
record_operator_session_result(err);
return err;
}

static void operator_start_task(void *arg)
Expand All @@ -588,6 +658,7 @@ static void operator_start_task(void *arg)
operator_start_scheduled = false;
xSemaphoreGive(state_lock);
esp_err_t err = start_operator_client();
refresh_home_facts();
if (err != ESP_OK) {
// Every retry logs: the display alone cannot say WHY the operator
// session is down, and a silent loop here cost a debugging session.
Expand All @@ -609,15 +680,17 @@ static esp_err_t request_node_connection(void)
.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SAVED_SESSION,
};
esp_err_t err = esp_openclaw_node_request_connect(node_client, &request);
if (err != ESP_ERR_NOT_FOUND) {
return err;
if (err == ESP_ERR_NOT_FOUND && CONFIG_OPENCLAW_ROOM_SETUP_CODE[0] != '\0') {
request.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE;
request.value = CONFIG_OPENCLAW_ROOM_SETUP_CODE;
err = esp_openclaw_node_request_connect(node_client, &request);
}
if (CONFIG_OPENCLAW_ROOM_SETUP_CODE[0] == '\0') {
return ESP_ERR_NOT_FOUND;
}
request.source = ESP_OPENCLAW_NODE_CONNECT_SOURCE_SETUP_CODE;
request.value = CONFIG_OPENCLAW_ROOM_SETUP_CODE;
return esp_openclaw_node_request_connect(node_client, &request);
xSemaphoreTake(state_lock, portMAX_DELAY);
if (err == ESP_OK || err == ESP_ERR_NOT_FOUND) node_session_missing = err == ESP_ERR_NOT_FOUND;
node_connection_pending = err == ESP_OK;
xSemaphoreGive(state_lock);
refresh_home_facts();
return err;
}

static void schedule_node_reconnect(uint32_t delay_ms);
Expand Down Expand Up @@ -676,7 +749,10 @@ static void node_event(
(void)ctx;
xSemaphoreTake(state_lock, portMAX_DELAY);
node_ready = event == ESP_OPENCLAW_NODE_EVENT_CONNECTED;
node_connection_pending = false;
if (node_ready) node_session_missing = false;
xSemaphoreGive(state_lock);
refresh_home_facts();
if (event == ESP_OPENCLAW_NODE_EVENT_CONNECTED) {
/*
* The session URI lands in NVS only after the first hello-ok, so the
Expand Down Expand Up @@ -733,6 +809,7 @@ static void node_event(
host += 6;
}
room_ui_set_gateway(host);
refresh_home_facts();
free(gateway_uri);
/* The timer path retries task creation itself, so scheduling only
* fails on timer-create OOM at boot; retry through the same path. */
Expand Down Expand Up @@ -1237,11 +1314,18 @@ esp_err_t esp_openclaw_room_node_start(const esp_openclaw_room_node_config_t *co
if (config->services.prepare_network != NULL) {
esp_err_t network_err = config->services.prepare_network(config->services.ctx);
if (network_err != ESP_OK) {
xSemaphoreTake(state_lock, portMAX_DELAY);
home_wifi_state = ROOM_UI_WIFI_UNAVAILABLE;
xSemaphoreGive(state_lock);
refresh_home_facts();
room_ui_set(ROOM_UI_ERROR, "Wi-Fi coprocessor unavailable");
return network_err;
}
}
ESP_ERROR_CHECK(esp_openclaw_node_wifi_start());
/* Register after the station helper so its status reflects each event first. */
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, home_network_event, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, home_network_event, NULL));
ESP_LOGI(TAG, "Wi-Fi runtime started");
seed_wifi_credentials_from_kconfig();
esp_openclaw_node_wifi_status_t wifi_status = {0};
Expand All @@ -1255,6 +1339,7 @@ esp_err_t esp_openclaw_room_node_start(const esp_openclaw_room_node_config_t *co
!esp_openclaw_node_wifi_wait_for_connection(pdMS_TO_TICKS(30000))) {
ESP_LOGW(TAG, "Wi-Fi did not connect within 30 s; fix credentials over the USB console");
}
refresh_wifi_facts(NULL, 0);

ESP_LOGI(TAG, "initializing room media");
esp_err_t media_err = room_media_init(on_wake, NULL);
Expand All @@ -1265,9 +1350,12 @@ esp_err_t esp_openclaw_room_node_start(const esp_openclaw_room_node_config_t *co
TAG,
"room media init failed: %s; Talk wake is disabled",
esp_err_to_name(media_err));
} else {
media_ready = true;
}
xSemaphoreTake(state_lock, portMAX_DELAY);
media_ready = media_err == ESP_OK;
media_initialized = true;
xSemaphoreGive(state_lock);
refresh_home_facts();

ESP_ERROR_CHECK(start_node_client());
ESP_ERROR_CHECK(esp_openclaw_node_example_repl_start(node_client));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ typedef struct {
bool animated_face; /**< Enable the procedural face when the display pipeline can sustain it. */
uint16_t animation_frame_ms; /**< Sustainable face cadence for this display pipeline; 0 uses 16 ms. */
void *ctx;
uint8_t idle_brightness; /**< Idle backlight percentage, 0..100; omitted/0 preserves display sleep. */
} esp_openclaw_room_display_port_t;

typedef struct {
Expand Down
3 changes: 2 additions & 1 deletion components/esp-openclaw-room-node/room_board.c
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ esp_err_t room_board_bind(const esp_openclaw_room_node_config_t *config)
if (config == NULL || config->display.start == NULL ||
config->display.lock == NULL || config->display.unlock == NULL ||
config->display.set_brightness == NULL || config->audio.open == NULL ||
config->audio.afe_layout == NULL || config->audio.record_channels == 0) {
config->audio.afe_layout == NULL || config->audio.record_channels == 0 ||
config->display.idle_brightness > 100) {
return ESP_ERR_INVALID_ARG;
}
board = *config;
Expand Down
Loading