From b18a57f88e78a89d5fd332b09792aa71254804ca Mon Sep 17 00:00:00 2001 From: lvhaiyu Date: Sun, 19 Apr 2026 16:33:11 +0800 Subject: [PATCH] feat(core): Added AP configuration and a guard mechanism --- application/basic_demo/CMakeLists.txt | 2 +- .../assets_local/284_240/layout.json | 2 +- application/basic_demo/main/Kconfig.projbuild | 30 ++ .../basic_demo/main/app_expression_emote.c | 27 +- .../basic_demo/main/app_expression_emote.h | 2 + application/basic_demo/main/basic_demo_wifi.c | 298 +++++++++++++++--- application/basic_demo/main/basic_demo_wifi.h | 5 + application/basic_demo/main/captive_dns.c | 196 ++++++++++++ application/basic_demo/main/captive_dns.h | 19 ++ .../basic_demo/main/config_http_server.c | 29 ++ application/basic_demo/main/main.c | 81 ++++- .../cap_lua/include/cap_lua.h | 21 +- .../cap_lua/skills/cap_lua_run.md | 14 +- .../claw_capabilities/cap_lua/src/cap_lua.c | 24 +- .../cap_lua/src/cap_lua_async.c | 99 +++--- .../cap_lua/src/cap_lua_internal.h | 29 +- .../cap_skill_mgr/src/cap_skill_mgr.c | 84 +++++ .../claw_modules/claw_cap/src/claw_cap.c | 4 - .../claw_core/include/claw_core.h | 21 -- .../claw_modules/claw_core/src/claw_core.c | 13 - .../src/llm/claw_llm_http_transport.c | 8 - .../src/llm/claw_llm_http_transport.h | 14 - .../include/claw_event_router.h | 15 - .../claw_event_router/src/claw_event_router.c | 28 +- .../claw_skill/include/claw_skill.h | 13 + .../claw_modules/claw_skill/src/claw_skill.c | 65 ++++ .../lua_module_display/src/display_hal.c | 15 - 27 files changed, 881 insertions(+), 277 deletions(-) create mode 100644 application/basic_demo/main/captive_dns.c create mode 100644 application/basic_demo/main/captive_dns.h diff --git a/application/basic_demo/CMakeLists.txt b/application/basic_demo/CMakeLists.txt index b70379d..cb88c04 100644 --- a/application/basic_demo/CMakeLists.txt +++ b/application/basic_demo/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -# include("${CMAKE_SOURCE_DIR}/tools/cmake/esp_idf_patch.cmake") +include("${CMAKE_SOURCE_DIR}/tools/cmake/esp_idf_patch.cmake") include("${CMAKE_SOURCE_DIR}/tools/cmake/flash_partition_defaults.cmake") include($ENV{IDF_PATH}/tools/cmake/project.cmake) diff --git a/application/basic_demo/assets_local/284_240/layout.json b/application/basic_demo/assets_local/284_240/layout.json index b77d15c..8811d4b 100644 --- a/application/basic_demo/assets_local/284_240/layout.json +++ b/application/basic_demo/assets_local/284_240/layout.json @@ -40,7 +40,7 @@ "long_mode": { "type": "GFX_LABEL_LONG_SCROLL", "loop": true, - "speed": 10 + "speed": 30 } } } diff --git a/application/basic_demo/main/Kconfig.projbuild b/application/basic_demo/main/Kconfig.projbuild index 02b6487..93ec964 100644 --- a/application/basic_demo/main/Kconfig.projbuild +++ b/application/basic_demo/main/Kconfig.projbuild @@ -45,6 +45,36 @@ menu "Default Wi-Fi Settings" help Default Wi-Fi password used to populate basic demo settings. + config BASIC_DEMO_WIFI_AP_SSID_PREFIX + string "Provisioning AP SSID prefix" + default "esp-claw" + help + Prefix used to build the unique provisioning AP SSID. The full + SSID is "-" (e.g. esp-claw-84AEE5). + + config BASIC_DEMO_WIFI_AP_CHANNEL + int "Provisioning AP channel" + range 1 13 + default 1 + help + Channel used by the provisioning SoftAP. + + config BASIC_DEMO_WIFI_AP_MAX_CONN + int "Provisioning AP max connections" + range 1 10 + default 4 + help + Maximum number of concurrent stations on the provisioning AP. + + config BASIC_DEMO_WIFI_MAX_RETRY + int "STA max retries before AP fallback" + range 1 20 + default 5 + help + After this many consecutive STA connection failures the device + falls back to pure AP mode and waits for the user to provision + new credentials. + endmenu menu "Default LLM Settings" diff --git a/application/basic_demo/main/app_expression_emote.c b/application/basic_demo/main/app_expression_emote.c index 1f58811..d75beb3 100644 --- a/application/basic_demo/main/app_expression_emote.c +++ b/application/basic_demo/main/app_expression_emote.c @@ -184,7 +184,7 @@ static emote_config_t app_emote_get_default_config(void) return config; } -static esp_err_t app_emote_show_idle_msg(const char *idle, const char *msg) +static esp_err_t app_emote_apply(const char *idle, const char *msg) { ESP_RETURN_ON_FALSE(s_emote_handle != NULL, ESP_ERR_INVALID_STATE, TAG, "emote handle is NULL"); @@ -193,6 +193,7 @@ static esp_err_t app_emote_show_idle_msg(const char *idle, const char *msg) TAG, "set emote message failed"); ESP_RETURN_ON_ERROR(emote_set_anim_emoji(s_emote_handle, idle), TAG, "set emote idle animation failed"); + if (display_arbiter_is_owner(DISPLAY_ARBITER_OWNER_EMOTE)) { ESP_RETURN_ON_ERROR(emote_notify_all_refresh(s_emote_handle), TAG, "refresh emote display failed"); @@ -203,14 +204,30 @@ static esp_err_t app_emote_show_idle_msg(const char *idle, const char *msg) esp_err_t app_expression_emote_set_network_state(bool connected) { - const char *idle = connected ? "swim" : "offline"; - const char *msg = connected ? "Wi-Fi connected" : "Wi-Fi offline"; + return app_expression_emote_set_status(connected, NULL); +} +esp_err_t app_expression_emote_set_status(bool sta_connected, const char *ap_ssid) +{ ESP_RETURN_ON_FALSE(s_emote_handle != NULL, ESP_ERR_INVALID_STATE, TAG, "emote handle is NULL"); - ESP_LOGI(TAG, "Update network emote: %s", idle); - return app_emote_show_idle_msg(idle, msg); + const bool ap_present = (ap_ssid != NULL && ap_ssid[0] != '\0'); + const char *idle = sta_connected ? "swim" : "offline"; + + char msg[96]; + if (sta_connected && ap_present) { + snprintf(msg, sizeof(msg), "Online * AP: %s", ap_ssid); + } else if (sta_connected) { + snprintf(msg, sizeof(msg), "Wi-Fi connected"); + } else if (ap_present) { + snprintf(msg, sizeof(msg), "Setup WiFi: %s", ap_ssid); + } else { + snprintf(msg, sizeof(msg), "Wi-Fi offline"); + } + + ESP_LOGI(TAG, "Update network emote: idle=%s msg=\"%s\"", idle, msg); + return app_emote_apply(idle, msg); } static void app_emote_cleanup(void) diff --git a/application/basic_demo/main/app_expression_emote.h b/application/basic_demo/main/app_expression_emote.h index d60e527..65babf3 100644 --- a/application/basic_demo/main/app_expression_emote.h +++ b/application/basic_demo/main/app_expression_emote.h @@ -16,6 +16,8 @@ extern "C" { esp_err_t app_expression_emote_start(void); esp_err_t app_expression_emote_set_network_state(bool connected); +esp_err_t app_expression_emote_set_status(bool sta_connected, const char *ap_ssid); + #ifdef __cplusplus } #endif diff --git a/application/basic_demo/main/basic_demo_wifi.c b/application/basic_demo/main/basic_demo_wifi.c index cad6cc4..311964a 100644 --- a/application/basic_demo/main/basic_demo_wifi.c +++ b/application/basic_demo/main/basic_demo_wifi.c @@ -5,38 +5,96 @@ */ #include "basic_demo_wifi.h" +#include #include #include "esp_event.h" #include "esp_log.h" +#include "esp_mac.h" #include "esp_netif.h" +#include "esp_timer.h" #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" +#include "sdkconfig.h" static const char *TAG = "basic_demo_wifi"; -#define WIFI_CONNECTED_BIT BIT0 -#define WIFI_FAIL_BIT BIT1 -#define WIFI_MAX_RETRY 10 -#define WIFI_RETRY_DELAY_MS 1000 +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +#define WIFI_RETRY_BASE_MS 1000 +#define WIFI_RETRY_MAX_MS 30000 + +#ifndef CONFIG_BASIC_DEMO_WIFI_AP_SSID_PREFIX +#define CONFIG_BASIC_DEMO_WIFI_AP_SSID_PREFIX "esp-claw" +#endif +#ifndef CONFIG_BASIC_DEMO_WIFI_AP_CHANNEL +#define CONFIG_BASIC_DEMO_WIFI_AP_CHANNEL 1 +#endif +#ifndef CONFIG_BASIC_DEMO_WIFI_AP_MAX_CONN +#define CONFIG_BASIC_DEMO_WIFI_AP_MAX_CONN 4 +#endif +#ifndef CONFIG_BASIC_DEMO_WIFI_MAX_RETRY +#define CONFIG_BASIC_DEMO_WIFI_MAX_RETRY 5 +#endif + +typedef enum { + WIFI_MODE_OFF = 0, + WIFI_MODE_PROVISION_AP, /* AP only, no STA credentials */ + WIFI_MODE_APSTA_TRYING, /* APSTA, STA still trying */ + WIFI_MODE_APSTA_OK, /* APSTA, STA connected */ + WIFI_MODE_AP_FALLBACK, /* STA failed N times, dropped back to pure AP */ +} wifi_mode_state_t; static EventGroupHandle_t s_wifi_event_group; static int s_retry_count; static bool s_connected; +static bool s_ap_active; +static bool s_sta_configured; static char s_ip_addr[16] = "0.0.0.0"; +static char s_ap_ip[16] = "192.168.4.1"; +static char s_ap_ssid[33]; +static wifi_mode_state_t s_mode = WIFI_MODE_OFF; +static esp_netif_t *s_sta_netif; +static esp_netif_t *s_ap_netif; static basic_demo_wifi_state_cb_t s_state_cb; static void *s_state_cb_user_ctx; +static esp_timer_handle_t s_reconnect_timer; -static void notify_wifi_state(bool connected) +static void notify_state_changed(bool force); +static esp_err_t fallback_to_ap(void); +static void reconnect_timer_cb(void *arg); + +static void compose_ap_ssid(void) { - if (s_connected == connected) { + uint8_t mac[6] = {0}; + if (esp_read_mac(mac, ESP_MAC_WIFI_SOFTAP) != ESP_OK) { + esp_read_mac(mac, ESP_MAC_WIFI_STA); + } + snprintf(s_ap_ssid, sizeof(s_ap_ssid), "%s-%02X%02X%02X", + CONFIG_BASIC_DEMO_WIFI_AP_SSID_PREFIX, mac[3], mac[4], mac[5]); + ESP_LOGI(TAG, "Provisioning AP SSID: %s", s_ap_ssid); +} + +static void apply_ap_config(void) +{ + wifi_config_t ap_cfg = {0}; + strlcpy((char *)ap_cfg.ap.ssid, s_ap_ssid, sizeof(ap_cfg.ap.ssid)); + ap_cfg.ap.ssid_len = strlen(s_ap_ssid); + ap_cfg.ap.channel = CONFIG_BASIC_DEMO_WIFI_AP_CHANNEL; + ap_cfg.ap.max_connection = CONFIG_BASIC_DEMO_WIFI_AP_MAX_CONN; + ap_cfg.ap.authmode = WIFI_AUTH_OPEN; + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_cfg)); +} + +static void refresh_ap_ip_str(void) +{ + if (!s_ap_netif) { return; } - - s_connected = connected; - if (s_state_cb) { - s_state_cb(connected, s_state_cb_user_ctx); + esp_netif_ip_info_t ip_info = {0}; + if (esp_netif_get_ip_info(s_ap_netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) { + snprintf(s_ap_ip, sizeof(s_ap_ip), IPSTR, IP2STR(&ip_info.ip)); } } @@ -47,35 +105,141 @@ static void wifi_event_handler(void *arg, { (void)arg; - if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { - esp_wifi_connect(); - return; - } - - if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { - notify_wifi_state(false); - strlcpy(s_ip_addr, "0.0.0.0", sizeof(s_ip_addr)); - if (s_retry_count < WIFI_MAX_RETRY) { - s_retry_count++; - ESP_LOGW(TAG, "Disconnected, retry %d/%d", s_retry_count, WIFI_MAX_RETRY); - vTaskDelay(pdMS_TO_TICKS(WIFI_RETRY_DELAY_MS)); + if (event_base == WIFI_EVENT) { + switch (event_id) { + case WIFI_EVENT_STA_START: esp_wifi_connect(); - } else { - xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + return; + + case WIFI_EVENT_STA_DISCONNECTED: + strlcpy(s_ip_addr, "0.0.0.0", sizeof(s_ip_addr)); + if (s_connected) { + s_connected = false; + notify_state_changed(false); + } + if (!s_sta_configured) { + return; + } + if (s_retry_count < CONFIG_BASIC_DEMO_WIFI_MAX_RETRY) { + uint32_t delay_ms = WIFI_RETRY_BASE_MS << s_retry_count; + if (delay_ms > WIFI_RETRY_MAX_MS) { + delay_ms = WIFI_RETRY_MAX_MS; + } + s_retry_count++; + s_mode = s_ap_active ? WIFI_MODE_APSTA_TRYING : s_mode; + ESP_LOGI(TAG, "STA disconnected, retry %d/%d in %" PRIu32 "ms", + s_retry_count, CONFIG_BASIC_DEMO_WIFI_MAX_RETRY, delay_ms); + if (s_reconnect_timer) { + esp_timer_stop(s_reconnect_timer); + esp_timer_start_once(s_reconnect_timer, + (uint64_t)delay_ms * 1000ULL); + } else { + esp_wifi_connect(); + } + } else { + ESP_LOGE(TAG, "STA failed after %d retries, falling back to AP", + CONFIG_BASIC_DEMO_WIFI_MAX_RETRY); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + fallback_to_ap(); + } + return; + + case WIFI_EVENT_AP_START: + s_ap_active = true; + refresh_ap_ip_str(); + ESP_LOGW(TAG, "*** Provisioning AP active: %s @ %s ***", + s_ap_ssid, s_ap_ip); + notify_state_changed(true); + return; + + case WIFI_EVENT_AP_STOP: + s_ap_active = false; + ESP_LOGW(TAG, "Provisioning AP stopped"); + notify_state_changed(true); + return; + + case WIFI_EVENT_AP_STACONNECTED: { + wifi_event_ap_staconnected_t *ev = (wifi_event_ap_staconnected_t *)event_data; + ESP_LOGI(TAG, "AP client connected: " MACSTR " aid=%d", + MAC2STR(ev->mac), ev->aid); + return; + } + + case WIFI_EVENT_AP_STADISCONNECTED: { + wifi_event_ap_stadisconnected_t *ev = (wifi_event_ap_stadisconnected_t *)event_data; + ESP_LOGI(TAG, "AP client disconnected: " MACSTR " aid=%d", + MAC2STR(ev->mac), ev->aid); + return; + } + + default: + return; } - return; } if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; snprintf(s_ip_addr, sizeof(s_ip_addr), IPSTR, IP2STR(&event->ip_info.ip)); - notify_wifi_state(true); s_retry_count = 0; + s_connected = true; + s_mode = s_ap_active ? WIFI_MODE_APSTA_OK : s_mode; + ESP_LOGI(TAG, "STA connected, IP=%s", s_ip_addr); xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); - ESP_LOGI(TAG, "Connected, IP=%s", s_ip_addr); + notify_state_changed(true); } } +static void notify_state_changed(bool force) +{ + static bool s_last_connected; + static bool s_last_ap_active; + static bool s_initialized; + + if (!force && s_initialized && s_last_connected == s_connected && + s_last_ap_active == s_ap_active) { + return; + } + + s_last_connected = s_connected; + s_last_ap_active = s_ap_active; + s_initialized = true; + + if (s_state_cb) { + s_state_cb(s_connected, s_state_cb_user_ctx); + } +} + +static void reconnect_timer_cb(void *arg) +{ + (void)arg; + if (!s_sta_configured) { + return; + } + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK && err != ESP_ERR_WIFI_CONN) { + ESP_LOGW(TAG, "esp_wifi_connect failed: %s", esp_err_to_name(err)); + } +} + +static esp_err_t fallback_to_ap(void) +{ + s_mode = WIFI_MODE_AP_FALLBACK; + s_sta_configured = false; + s_retry_count = 0; + + esp_err_t err = esp_wifi_set_mode(WIFI_MODE_AP); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Fallback set_mode failed: %s", esp_err_to_name(err)); + return err; + } + apply_ap_config(); + refresh_ap_ip_str(); + ESP_LOGW(TAG, "*** Reprovision required, AP active: %s @ %s ***", + s_ap_ssid, s_ap_ip); + notify_state_changed(true); + return ESP_OK; +} + esp_err_t basic_demo_wifi_init(void) { wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); @@ -87,41 +251,65 @@ esp_err_t basic_demo_wifi_init(void) ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); - esp_netif_create_default_wifi_sta(); + s_sta_netif = esp_netif_create_default_wifi_sta(); + s_ap_netif = esp_netif_create_default_wifi_ap(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); ESP_ERROR_CHECK(esp_event_handler_instance_register( WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL, NULL)); ESP_ERROR_CHECK(esp_event_handler_instance_register( IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL)); + const esp_timer_create_args_t timer_args = { + .callback = reconnect_timer_cb, + .name = "wifi_reconnect", + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &s_reconnect_timer)); + + compose_ap_ssid(); return ESP_OK; } esp_err_t basic_demo_wifi_start(const char *ssid, const char *password) { - wifi_config_t wifi_config = {0}; + s_sta_configured = (ssid && ssid[0] != '\0'); + s_retry_count = 0; + if (s_reconnect_timer) { + esp_timer_stop(s_reconnect_timer); + } + xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT); - if (!ssid || ssid[0] == '\0') { - ESP_LOGW(TAG, "Wi-Fi SSID is empty, skipping Wi-Fi start"); - return ESP_ERR_INVALID_STATE; + if (s_sta_configured) { + wifi_config_t sta_cfg = {0}; + strlcpy((char *)sta_cfg.sta.ssid, ssid, sizeof(sta_cfg.sta.ssid)); + strlcpy((char *)sta_cfg.sta.password, + password ? password : "", + sizeof(sta_cfg.sta.password)); + sta_cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + sta_cfg.sta.pmf_cfg.capable = true; + sta_cfg.sta.pmf_cfg.required = false; + + s_mode = WIFI_MODE_APSTA_TRYING; + ESP_LOGI(TAG, "Starting Wi-Fi APSTA, STA target: %s", ssid); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA)); + apply_ap_config(); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &sta_cfg)); + } else { + s_mode = WIFI_MODE_PROVISION_AP; + ESP_LOGW(TAG, "No STA credentials, starting Wi-Fi in pure AP mode"); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); + apply_ap_config(); } - strlcpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid)); - strlcpy((char *)wifi_config.sta.password, password ? password : "", sizeof(wifi_config.sta.password)); - wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; - wifi_config.sta.pmf_cfg.capable = true; - wifi_config.sta.pmf_cfg.required = false; - - s_retry_count = 0; - xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT); - ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); - ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_start()); return ESP_OK; } esp_err_t basic_demo_wifi_wait_connected(uint32_t timeout_ms) { + if (!s_sta_configured) { + return ESP_ERR_INVALID_STATE; + } + EventBits_t bits; TickType_t ticks = (timeout_ms == UINT32_MAX) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms); @@ -157,3 +345,29 @@ const char *basic_demo_wifi_get_ip(void) { return s_ip_addr; } + +const char *basic_demo_wifi_get_ap_ssid(void) +{ + return s_ap_ssid; +} + +const char *basic_demo_wifi_get_ap_ip(void) +{ + return s_ap_ip; +} + +bool basic_demo_wifi_is_ap_active(void) +{ + return s_ap_active; +} + +const char *basic_demo_wifi_get_mode_string(void) +{ + switch (s_mode) { + case WIFI_MODE_PROVISION_AP: return "provision"; + case WIFI_MODE_APSTA_TRYING: return "apsta"; + case WIFI_MODE_APSTA_OK: return "sta_ok"; + case WIFI_MODE_AP_FALLBACK: return "fallback"; + default: return "off"; + } +} diff --git a/application/basic_demo/main/basic_demo_wifi.h b/application/basic_demo/main/basic_demo_wifi.h index d102aaf..3b20c16 100644 --- a/application/basic_demo/main/basic_demo_wifi.h +++ b/application/basic_demo/main/basic_demo_wifi.h @@ -18,3 +18,8 @@ esp_err_t basic_demo_wifi_wait_connected(uint32_t timeout_ms); esp_err_t basic_demo_wifi_register_state_callback(basic_demo_wifi_state_cb_t cb, void *user_ctx); bool basic_demo_wifi_is_connected(void); const char *basic_demo_wifi_get_ip(void); + +const char *basic_demo_wifi_get_ap_ssid(void); +const char *basic_demo_wifi_get_ap_ip(void); +bool basic_demo_wifi_is_ap_active(void); +const char *basic_demo_wifi_get_mode_string(void); diff --git a/application/basic_demo/main/captive_dns.c b/application/basic_demo/main/captive_dns.c new file mode 100644 index 0000000..51ce9b0 --- /dev/null +++ b/application/basic_demo/main/captive_dns.c @@ -0,0 +1,196 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#include "captive_dns.h" + +#include +#include + +#include "esp_log.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lwip/ip4_addr.h" +#include "lwip/netdb.h" +#include "lwip/sockets.h" + +static const char *TAG = "captive_dns"; + +#define DNS_PORT 53 +#define DNS_BUF_SIZE 512 +#define DHCPS_OFFER_DNS 0x02 + +static TaskHandle_t s_dns_task; +static volatile bool s_running; + +static esp_err_t captive_dns_configure_dhcp_dns(void) +{ + esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); + if (!ap) { + ESP_LOGW(TAG, "AP netif not found, skipping DHCP DNS option"); + return ESP_ERR_INVALID_STATE; + } + + esp_netif_ip_info_t ip_info = {0}; + esp_netif_get_ip_info(ap, &ip_info); + if (ip_info.ip.addr == 0) { + IP4_ADDR(&ip_info.ip, 192, 168, 4, 1); + } + + esp_netif_dns_info_t dns = {0}; + dns.ip.type = ESP_IPADDR_TYPE_V4; + dns.ip.u_addr.ip4.addr = ip_info.ip.addr; + + esp_netif_dhcp_status_t status = ESP_NETIF_DHCP_INIT; + esp_netif_dhcps_get_status(ap, &status); + const bool was_started = (status == ESP_NETIF_DHCP_STARTED); + if (was_started) { + esp_netif_dhcps_stop(ap); + } + + uint8_t offer_dns = DHCPS_OFFER_DNS; + esp_err_t err = esp_netif_dhcps_option(ap, ESP_NETIF_OP_SET, + ESP_NETIF_DOMAIN_NAME_SERVER, + &offer_dns, sizeof(offer_dns)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "set DHCP DOMAIN_NAME_SERVER option failed: %s", + esp_err_to_name(err)); + } else { + err = esp_netif_set_dns_info(ap, ESP_NETIF_DNS_MAIN, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "set AP DNS info failed: %s", esp_err_to_name(err)); + } + } + + if (was_started) { + esp_err_t start_err = esp_netif_dhcps_start(ap); + if (start_err != ESP_OK) { + ESP_LOGE(TAG, "restart DHCP server failed: %s", + esp_err_to_name(start_err)); + } + } + + if (err == ESP_OK) { + ESP_LOGI(TAG, "DHCP server will advertise DNS=" IPSTR " to clients", + IP2STR(&ip_info.ip)); + } + return err; +} + +static int dns_build_response(const uint8_t *req, int req_len, + uint8_t *resp, int resp_max, + uint32_t redirect_ip) +{ + if (req_len < 12 || resp_max < req_len + 16) { + return -1; + } + + memcpy(resp, req, req_len); + resp[2] = 0x81; + resp[3] = 0x80; + resp[6] = 0x00; resp[7] = 0x01; + + int pos = req_len; + resp[pos++] = 0xC0; resp[pos++] = 0x0C; + resp[pos++] = 0x00; resp[pos++] = 0x01; + resp[pos++] = 0x00; resp[pos++] = 0x01; + resp[pos++] = 0x00; resp[pos++] = 0x00; + resp[pos++] = 0x00; resp[pos++] = 0x3C; + resp[pos++] = 0x00; resp[pos++] = 0x04; + resp[pos++] = (redirect_ip >> 24) & 0xFF; + resp[pos++] = (redirect_ip >> 16) & 0xFF; + resp[pos++] = (redirect_ip >> 8) & 0xFF; + resp[pos++] = (redirect_ip ) & 0xFF; + return pos; +} + +static void dns_task(void *arg) +{ + (void)arg; + + int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { + ESP_LOGE(TAG, "Failed to create DNS socket: errno=%d", errno); + s_running = false; + s_dns_task = NULL; + vTaskDelete(NULL); + return; + } + + struct sockaddr_in addr = { + .sin_family = AF_INET, + .sin_port = htons(DNS_PORT), + .sin_addr.s_addr = htonl(INADDR_ANY), + }; + if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + ESP_LOGE(TAG, "DNS bind failed: errno=%d", errno); + close(sock); + s_running = false; + s_dns_task = NULL; + vTaskDelete(NULL); + return; + } + + struct timeval tv = { .tv_sec = 1, .tv_usec = 0 }; + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + + uint32_t redirect_ip_host = 0xC0A80401; + esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); + esp_netif_ip_info_t ip_info = {0}; + if (ap && esp_netif_get_ip_info(ap, &ip_info) == ESP_OK && ip_info.ip.addr != 0) { + redirect_ip_host = ntohl(ip_info.ip.addr); + } + ESP_LOGI(TAG, "Captive DNS up on :%d -> %lu.%lu.%lu.%lu", DNS_PORT, + (redirect_ip_host >> 24) & 0xFFu, + (redirect_ip_host >> 16) & 0xFFu, + (redirect_ip_host >> 8) & 0xFFu, + redirect_ip_host & 0xFFu); + + static uint8_t buf[DNS_BUF_SIZE]; + static uint8_t resp[DNS_BUF_SIZE + 16]; + + while (s_running) { + struct sockaddr_in src; + socklen_t src_len = sizeof(src); + int len = recvfrom(sock, buf, sizeof(buf), 0, + (struct sockaddr *)&src, &src_len); + if (len < 0) { + continue; + } + int resp_len = dns_build_response(buf, len, resp, sizeof(resp), redirect_ip_host); + if (resp_len > 0) { + sendto(sock, resp, resp_len, 0, (struct sockaddr *)&src, src_len); + } + } + + close(sock); + ESP_LOGI(TAG, "Captive DNS stopped"); + s_dns_task = NULL; + vTaskDelete(NULL); +} + +esp_err_t captive_dns_start(void) +{ + if (s_dns_task != NULL) { + return ESP_OK; + } + + (void)captive_dns_configure_dhcp_dns(); + + s_running = true; + BaseType_t ret = xTaskCreate(dns_task, "captive_dns", 3072, NULL, 5, &s_dns_task); + if (ret != pdPASS) { + s_running = false; + s_dns_task = NULL; + ESP_LOGE(TAG, "Failed to create captive DNS task"); + return ESP_FAIL; + } + return ESP_OK; +} + +void captive_dns_stop(void) +{ + s_running = false; +} diff --git a/application/basic_demo/main/captive_dns.h b/application/basic_demo/main/captive_dns.h new file mode 100644 index 0000000..4f39c5f --- /dev/null +++ b/application/basic_demo/main/captive_dns.h @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t captive_dns_start(void); +void captive_dns_stop(void); + +#ifdef __cplusplus +} +#endif diff --git a/application/basic_demo/main/config_http_server.c b/application/basic_demo/main/config_http_server.c index f717ec8..9fd11bf 100644 --- a/application/basic_demo/main/config_http_server.c +++ b/application/basic_demo/main/config_http_server.c @@ -290,6 +290,10 @@ static esp_err_t status_handler(httpd_req_t *req) cJSON_AddBoolToObject(root, "wifi_connected", basic_demo_wifi_is_connected()); json_add_string(root, "ip", basic_demo_wifi_get_ip()); json_add_string(root, "storage_base_path", s_ctx.storage_base_path); + cJSON_AddBoolToObject(root, "ap_active", basic_demo_wifi_is_ap_active()); + json_add_string(root, "ap_ssid", basic_demo_wifi_get_ap_ssid()); + json_add_string(root, "ap_ip", basic_demo_wifi_get_ap_ip()); + json_add_string(root, "wifi_mode", basic_demo_wifi_get_mode_string()); char *payload = cJSON_PrintUnformatted(root); cJSON_Delete(root); @@ -784,6 +788,26 @@ static esp_err_t files_mkdir_handler(httpd_req_t *req) return httpd_resp_sendstr(req, "{\"ok\":true}"); } +static esp_err_t captive_404_handler(httpd_req_t *req, httpd_err_code_t error) +{ + if (!basic_demo_wifi_is_ap_active()) { + return httpd_resp_send_err(req, error, NULL); + } + + const char *ap_ip = basic_demo_wifi_get_ap_ip(); + if (!ap_ip || !ap_ip[0]) { + ap_ip = "192.168.4.1"; + } + + char location[40]; + snprintf(location, sizeof(location), "http://%s/", ap_ip); + + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", location); + httpd_resp_set_hdr(req, "Connection", "close"); + return httpd_resp_send(req, NULL, 0); +} + esp_err_t config_http_server_init(const char *storage_base_path) { if (!storage_base_path || storage_base_path[0] != '/') { @@ -834,6 +858,11 @@ esp_err_t config_http_server_start(void) "Failed to register URI handler"); } + ESP_RETURN_ON_ERROR(httpd_register_err_handler(s_ctx.server, + HTTPD_404_NOT_FOUND, + captive_404_handler), + TAG, "Failed to register captive 404 handler"); + ESP_LOGI(TAG, "HTTP server started on port %d", BASIC_DEMO_HTTP_SERVER_PORT); return ESP_OK; } diff --git a/application/basic_demo/main/main.c b/application/basic_demo/main/main.c index 5d7ce27..0f2b8a8 100644 --- a/application/basic_demo/main/main.c +++ b/application/basic_demo/main/main.c @@ -9,9 +9,13 @@ #endif #include "basic_demo_settings.h" #include "basic_demo_wifi.h" +#include "cap_lua.h" +#include "captive_dns.h" +#include "claw_skill.h" #include "config_http_server.h" #include "esp_err.h" #include "esp_log.h" +#include #include "time.h" #include "esp_vfs_fat.h" #include "freertos/task.h" @@ -28,12 +32,51 @@ const char *basic_demo_fatfs_base_path = "/fatfs"; static wl_handle_t s_wl_handle = WL_INVALID_HANDLE; +static esp_err_t cap_lua_run_deactivate_guard(const char *session_id, + const char *skill_id, + char *reason_out, + size_t reason_size) +{ + (void)session_id; + (void)skill_id; + + size_t active = cap_lua_get_active_async_job_count(); + if (active == 0) { + return ESP_OK; + } + if (reason_out && reason_size > 0) { + if (active == SIZE_MAX) { + snprintf(reason_out, reason_size, + "Lua async runner is busy (lock contended). " + "Call lua_list_async_jobs to confirm, then " + "lua_stop_all_async_jobs before retrying deactivate_skill."); + } else { + snprintf(reason_out, reason_size, + "%u Lua async job(s) still running. " + "Call lua_stop_all_async_jobs (or lua_stop_async_job per id/name) " + "first, then retry deactivate_skill.", + (unsigned)active); + } + } + return ESP_ERR_INVALID_STATE; +} + static void on_wifi_state_changed(bool connected, void *user_ctx) { (void)user_ctx; + const char *ap_ssid = basic_demo_wifi_is_ap_active() + ? basic_demo_wifi_get_ap_ssid() + : NULL; + + ESP_LOGI(TAG, "Wi-Fi state: sta_connected=%d ap_active=%d mode=%s ap_ssid=%s", + connected, + basic_demo_wifi_is_ap_active(), + basic_demo_wifi_get_mode_string(), + ap_ssid ? ap_ssid : "(none)"); + #if defined(CONFIG_BASIC_DEMO_ENABLE_EMOTE) - esp_err_t err = app_expression_emote_set_network_state(connected); + esp_err_t err = app_expression_emote_set_status(connected, ap_ssid); if (err != ESP_OK) { ESP_LOGW(TAG, "Failed to update network emote: %s", esp_err_to_name(err)); } @@ -166,19 +209,39 @@ void app_main(void) ESP_ERROR_CHECK(config_http_server_init(basic_demo_fatfs_base_path)); ESP_ERROR_CHECK(basic_demo_wifi_register_state_callback(on_wifi_state_changed, NULL)); - if (basic_demo_wifi_start(s_settings.wifi_ssid, s_settings.wifi_password) == ESP_OK) { - ESP_ERROR_CHECK(config_http_server_start()); - if (basic_demo_wifi_wait_connected(30000) == ESP_OK) { - ESP_LOGI(TAG, "Wi-Fi ready: %s", basic_demo_wifi_get_ip()); - } else { - ESP_LOGW(TAG, "Wi-Fi connection timed out"); - } + esp_err_t wifi_err = basic_demo_wifi_start(s_settings.wifi_ssid, s_settings.wifi_password); + if (wifi_err != ESP_OK) { + ESP_LOGE(TAG, "Wi-Fi start failed: %s", esp_err_to_name(wifi_err)); } else { - ESP_LOGW(TAG, "Continuing without Wi-Fi"); + ESP_ERROR_CHECK(config_http_server_start()); + if (captive_dns_start() != ESP_OK) { + ESP_LOGW(TAG, "Captive DNS could not start, portal pop-up disabled"); + } + + if (s_settings.wifi_ssid[0] != '\0') { + if (basic_demo_wifi_wait_connected(30000) == ESP_OK) { + ESP_LOGI(TAG, "Wi-Fi STA ready: %s", basic_demo_wifi_get_ip()); + } else { + ESP_LOGW(TAG, "STA could not connect, dropped to AP fallback"); + } + } + + ESP_LOGW(TAG, + "*** Provisioning portal: SSID=\"%s\" (open) IP=%s URL=http://%s/ ***", + basic_demo_wifi_get_ap_ssid(), + basic_demo_wifi_get_ap_ip(), + basic_demo_wifi_get_ap_ip()); } ESP_ERROR_CHECK(app_claw_start(&s_settings)); + esp_err_t guard_err = claw_skill_register_deactivate_guard("cap_lua_run", + cap_lua_run_deactivate_guard); + if (guard_err != ESP_OK) { + ESP_LOGW(TAG, "Failed to register cap_lua_run deactivate guard: %s", + esp_err_to_name(guard_err)); + } + #if BASIC_DEMO_ENABLE_MEM_LOG /* Start memory monitor: print internal free, min free, PSRAM free every 20s */ xTaskCreate(memory_monitor_task, "mem_mon", 4096, NULL, 1, NULL); diff --git a/components/claw_capabilities/cap_lua/include/cap_lua.h b/components/claw_capabilities/cap_lua/include/cap_lua.h index 67934e7..0ec5061 100644 --- a/components/claw_capabilities/cap_lua/include/cap_lua.h +++ b/components/claw_capabilities/cap_lua/include/cap_lua.h @@ -42,16 +42,6 @@ esp_err_t cap_lua_run_script(const char *path, char *output, size_t output_size); -/* - * Submit a managed Lua script to run asynchronously. - * - * - timeout_ms == 0 : no wall-clock deadline (cancel-only). - * - name : optional handle (defaults to script basename). - * - exclusive : optional mutex group (e.g. "display"); same group can - * have at most one running job. - * - replace : if true, conflicting jobs (same name OR exclusive) are - * stopped before this one starts. - */ esp_err_t cap_lua_run_script_async(const char *path, const char *args_json, uint32_t timeout_ms, @@ -71,17 +61,10 @@ esp_err_t cap_lua_stop_all_jobs(const char *exclusive_filter, char *output, size_t output_size); -/* Context provider that injects a snapshot of active async jobs into LLM context. */ extern const claw_core_context_provider_t cap_lua_async_jobs_provider; -/* - * Diagnostic completion observer (matches claw_core_completion_observer_fn): - * detects assistant replies that claim to have stopped / cancelled / cleared - * an async script while the active-jobs context was injected and no matching - * stop tool was invoked this turn. Emits ESP_LOGW only — no behaviour change. - * - * Wire this once during boot via claw_core_add_completion_observer(). - */ +size_t cap_lua_get_active_async_job_count(void); + void cap_lua_honesty_observe_completion(const claw_core_completion_summary_t *summary, void *user_ctx); diff --git a/components/claw_capabilities/cap_lua/skills/cap_lua_run.md b/components/claw_capabilities/cap_lua/skills/cap_lua_run.md index ac1b8a5..e13eebd 100644 --- a/components/claw_capabilities/cap_lua/skills/cap_lua_run.md +++ b/components/claw_capabilities/cap_lua/skills/cap_lua_run.md @@ -1,6 +1,10 @@ # Lua Script Execution -Use this skill when the user wants to see existing Lua scripts, run one, or inspect async execution jobs. +Use this skill when the user wants to see existing Lua scripts, run one, inspect async execution jobs, or stop them. + +> **Tool-call contract**: stopping, switching, or clearing an async job only takes effect when you actually invoke `lua_stop_async_job`, `lua_stop_all_async_jobs`, or `lua_run_script_async` (with `replace:true`). The active-jobs context block is informational — it does not stop anything. Never reply "stopped / cancelled / cleared / switched" without calling the matching tool in the same turn. + +> **Do NOT deactivate this skill while any async job is active.** The stop/list tools live inside this skill — dropping it leaves the running job orphaned with no way to cancel it. The platform refuses such `deactivate_skill` calls and returns a `reason` field telling you to stop the jobs first. If you see that error, call `lua_stop_all_async_jobs` (or per-id `lua_stop_async_job`), wait for the result, and only then retry `deactivate_skill` if you still need to. ## Speed @@ -15,11 +19,13 @@ Use this skill when the user wants to see existing Lua scripts, run one, or insp - Use `lua_run_script` for short tasks that should return output immediately. - Use `lua_run_script_async` for loops, animations, watchers, or other long-running behavior. - Use `lua_list_async_jobs` and `lua_get_async_job` to inspect async execution state. +- Use `lua_stop_async_job` (by `job_id` or `name`) or `lua_stop_all_async_jobs` (optionally filtered by `exclusive`) to cancel running jobs. - `path` must be a relative `.lua` path under the configured Lua base directory. - Newly authored scripts that are still being validated must run from `temp/*.lua`. - `args` may be an object or array. The runtime exposes it to Lua as the global `args`. - When the tool is invoked from an IM chat session, the firmware may merge `channel`, `chat_id`, and `session_id` into `args` if those keys are absent (so Lua can send messages back to the same chat). -- `timeout_ms` is optional, but when present it must be a positive integer. +- `timeout_ms` is optional, but when present it must be a non-negative integer (0 = run until cancelled, only valid for async jobs). +- For async jobs, prefer setting `name` (logical handle) and `exclusive` (resource group, e.g. `"display"`) so subsequent `replace:true` calls can deterministically take over the slot. ## Minimal Examples ```json @@ -30,6 +36,10 @@ Use this skill when the user wants to see existing Lua scripts, run one, or insp {"path":"blink.lua","args":{"pin":2},"timeout_ms":3000} ``` +```json +{"path":"digital_clock.lua","name":"clock","exclusive":"display","replace":true} +``` + ## Guidance - If the target script does not exist yet, switch to the Lua authoring flow first. - Prefer re-running the same relative path while iterating on a script instead of creating many near-duplicate files. diff --git a/components/claw_capabilities/cap_lua/src/cap_lua.c b/components/claw_capabilities/cap_lua/src/cap_lua.c index 03e1d4e..8fbf2a6 100644 --- a/components/claw_capabilities/cap_lua/src/cap_lua.c +++ b/components/claw_capabilities/cap_lua/src/cap_lua.c @@ -230,6 +230,11 @@ static esp_err_t cap_lua_group_init(void) return ESP_OK; } +size_t cap_lua_get_active_async_job_count(void) +{ + return cap_lua_async_active_count(); +} + static esp_err_t cap_lua_group_start(void) { return cap_lua_async_start(); @@ -483,9 +488,6 @@ static esp_err_t cap_lua_run_script_async_execute(const char *input_json, } strlcpy(request_path, path ? path : resolved_path, sizeof(request_path)); - /* Pre-check that the script actually exists before queuing. Without this - * the job is happily accepted, the async task starts, immediately fails - * to load, and the LLM has already reported success to the user. */ { struct stat script_stat; if (stat(resolved_path, &script_stat) != 0) { @@ -515,9 +517,6 @@ static esp_err_t cap_lua_run_script_async_execute(const char *input_json, replace = cJSON_IsTrue(replace_item); } - /* Copy strings owned by `root` into the job struct BEFORE cJSON_Delete / - * cap_lua_build_args_json — both can recycle the freed string buffers and - * leave us holding garbage. */ strlcpy(job.path, resolved_path, sizeof(job.path)); if (name && name[0]) { strlcpy(job.name, name, sizeof(job.name)); @@ -557,10 +556,6 @@ static esp_err_t cap_lua_run_script_async_execute(const char *input_json, return err; } - /* Block briefly so the tool result reflects whether the job actually - * launched or already failed during script load. Wakes immediately on - * terminal transition; otherwise reports the live status (typically - * RUNNING) once the budget elapses. */ cap_lua_job_status_t settle_status = CAP_LUA_JOB_RUNNING; char settle_summary[128] = {0}; cap_lua_async_wait_settle(job_id, 150, &settle_status, settle_summary, sizeof(settle_summary)); @@ -640,10 +635,6 @@ static esp_err_t cap_lua_stop_all_async_jobs_execute(const char *input_json, (void)ctx; - /* Distinguish "no args -> stop everything" from "garbage JSON -> reject". - * The latter must be a hard error because this API has destructive side - * effects on every running async job, and silently ignoring a malformed - * payload would mass-stop jobs that the caller never intended to touch. */ if (input_json && input_json[0]) { root = cJSON_Parse(input_json); if (!root) { @@ -1148,17 +1139,12 @@ void cap_lua_honesty_observe_completion(const claw_core_completion_summary_t *su if (!strstr(providers, "Lua Async Jobs")) { return; } - /* If a stop / replace tool was actually invoked this turn, the reply is - * truthful regardless of phrasing. */ const char *tools = summary->tool_calls_csv ? summary->tool_calls_csv : ""; if (strstr(tools, "lua_stop_async_job") || strstr(tools, "lua_stop_all_async_jobs") || strstr(tools, "lua_run_script_async")) { return; } - /* Pattern-match the assistant's reply against typical "I stopped it" - * phrases in both English and Chinese. Keep this list small and explicit - * to avoid false positives like "if you want to cancel, tell me". */ static const char *const claim_keywords[] = { "已取消", "已停止", "已关闭", "已清除", "取消了", "停止了", "关掉了", "关闭了", "stopped", "cancelled", "canceled", "cleared", diff --git a/components/claw_capabilities/cap_lua/src/cap_lua_async.c b/components/claw_capabilities/cap_lua/src/cap_lua_async.c index 3a12064..5f71a26 100644 --- a/components/claw_capabilities/cap_lua/src/cap_lua_async.c +++ b/components/claw_capabilities/cap_lua/src/cap_lua_async.c @@ -6,6 +6,7 @@ #include "cap_lua_internal.h" #include +#include #include #include #include @@ -49,10 +50,6 @@ typedef struct { static SemaphoreHandle_t s_job_lock; static cap_lua_job_record_t s_jobs[CAP_LUA_ASYNC_MAX_JOBS]; -/* Per-slot binary semaphore signalled when the slot's job reaches a terminal - * state. Lives outside the record struct because the struct is memset on slot - * recycle; the sem itself is created once at init and reused for every job - * that lands in this slot, drained before each new submit. */ static SemaphoreHandle_t s_slot_terminal_sem[CAP_LUA_ASYNC_MAX_JOBS]; static size_t s_running_jobs; static bool s_runner_started; @@ -198,8 +195,6 @@ static void cap_lua_finish_job(cap_lua_job_ctx_t *ctx, ctx->slot < CAP_LUA_ASYNC_MAX_JOBS && s_jobs[ctx->slot].used && strcmp(s_jobs[ctx->slot].job_id, ctx->job_id) == 0) { - /* Order matters: stop request takes precedence over timeout, so a - * race between the two is reported as STOPPED. */ stopped = s_jobs[ctx->slot].stop_requested; if (!stopped && error_msg && strstr(error_msg, "execution timed out") != NULL) { timed_out = true; @@ -355,16 +350,14 @@ static bool cap_lua_wait_for_terminal(int slot, uint32_t wait_ms) if (!sem) { return false; } - /* The sem give happens after finish_job releases s_job_lock, so a fast - * concurrent submit can drain the slot's status back to QUEUED before - * the give lands. Always verify against the live status; the sem only - * shortens the wait. */ xSemaphoreTake(sem, pdMS_TO_TICKS(wait_ms)); return cap_lua_slot_is_terminal(slot); } -/* Mark slot stopped under lock, then wait outside lock for terminal status. */ -static esp_err_t cap_lua_stop_slot_and_wait(int slot, uint32_t wait_ms, bool *out_was_running) +static esp_err_t cap_lua_stop_slot_and_wait(int slot, + const char *expected_job_id, + uint32_t wait_ms, + bool *out_was_running) { bool was_running = false; @@ -375,6 +368,12 @@ static esp_err_t cap_lua_stop_slot_and_wait(int slot, uint32_t wait_ms, bool *ou xSemaphoreGive(s_job_lock); return ESP_ERR_NOT_FOUND; } + if (expected_job_id && expected_job_id[0] && + strncmp(s_jobs[slot].job_id, expected_job_id, + sizeof(s_jobs[slot].job_id)) != 0) { + xSemaphoreGive(s_job_lock); + return ESP_ERR_NOT_FOUND; + } if (!cap_lua_status_is_terminal(s_jobs[slot].status)) { s_jobs[slot].stop_requested = true; was_running = true; @@ -400,9 +399,6 @@ static esp_err_t cap_lua_stop_slot_and_wait(int slot, uint32_t wait_ms, bool *ou return ESP_OK; } -/* Internal worker holding the actual submit logic. cap_lua_async_submit is a - * thin wrapper that calls this twice when the post-lock recheck loses a race - * against another submitter, so the caller never sees a transient EBUSY. */ static esp_err_t cap_lua_async_submit_once(const cap_lua_async_job_t *job, char *job_id_out, size_t job_id_out_size, @@ -445,7 +441,6 @@ static esp_err_t cap_lua_async_submit_once(const cap_lua_async_job_t *job, return ESP_ERR_INVALID_STATE; } - /* --- Pre-flight: detect conflicts; honor replace=true by stopping them --- */ while (true) { int conflict_slot = -1; char conflict_reason[64] = {0}; @@ -511,12 +506,12 @@ static esp_err_t cap_lua_async_submit_once(const cap_lua_async_job_t *job, return ESP_ERR_INVALID_STATE; } - /* replace=true: stop conflicting slot and wait, then re-check loop */ ESP_LOGI(TAG, "Replacing conflicting job '%s' (id=%s) due to %s", conflict_name[0] ? conflict_name : "(unnamed)", conflict_id, conflict_reason); bool was_running = false; esp_err_t stop_err = cap_lua_stop_slot_and_wait(conflict_slot, + conflict_id, CAP_LUA_STOP_WAIT_DEFAULT_MS, &was_running); if (stop_err == ESP_ERR_TIMEOUT) { @@ -539,10 +534,8 @@ static esp_err_t cap_lua_async_submit_once(const cap_lua_async_job_t *job, } return stop_err; } - /* loop again to re-detect; another conflict might exist (e.g. name AND exclusive) */ } - /* --- Allocate ctx and slot --- */ cap_lua_job_ctx_t *ctx = calloc(1, sizeof(*ctx)); if (!ctx) { return ESP_ERR_NO_MEM; @@ -569,11 +562,6 @@ static esp_err_t cap_lua_async_submit_once(const cap_lua_async_job_t *job, return ESP_ERR_TIMEOUT; } - /* Race window: between releasing the pre-flight lock and re-acquiring it - * here, another submitter may have filled a slot under the same name or - * exclusive group. Re-check ALL invariants under lock so the - * single-instance / exclusive-group guarantee can never be broken by - * concurrent submits from different tasks (LLM, CLI, event router). */ if (s_running_jobs >= CAP_LUA_ASYNC_MAX_CONCURRENT) { xSemaphoreGive(s_job_lock); free(ctx->args_json); @@ -612,11 +600,6 @@ static esp_err_t cap_lua_async_submit_once(const cap_lua_async_job_t *job, xSemaphoreGive(s_job_lock); free(ctx->args_json); free(ctx); - /* Signal the wrapper to retry once: the inner pre-flight already - * did the stop-and-wait, but a different submitter slipped in. A - * single bounded retry absorbs the race without ever asking the - * LLM to redo it. The wrapper drops this flag on the second pass - * so we cannot oscillate indefinitely under sustained contention. */ if (out_recheck_lost_race) { *out_recheck_lost_race = true; } @@ -639,8 +622,6 @@ static esp_err_t cap_lua_async_submit_once(const cap_lua_async_job_t *job, } cap_lua_clear_slot(&s_jobs[slot]); - /* Drain any leftover terminal signal from the previous occupant so that - * the next waiter only sees this job's transition. */ if (s_slot_terminal_sem[slot]) { xSemaphoreTake(s_slot_terminal_sem[slot], 0); } @@ -852,10 +833,14 @@ esp_err_t cap_lua_async_stop_job(const char *id_or_name, wait_ms = CAP_LUA_STOP_WAIT_DEFAULT_MS; } bool was_running = false; - esp_err_t err = cap_lua_stop_slot_and_wait(slot, wait_ms, &was_running); + esp_err_t err = cap_lua_stop_slot_and_wait(slot, job_id, wait_ms, &was_running); if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) == pdTRUE) { - const char *status_name = cap_lua_job_status_name(s_jobs[slot].status); + const char *status_name = "gone"; + if (s_jobs[slot].used && + strncmp(s_jobs[slot].job_id, job_id, sizeof(job_id)) == 0) { + status_name = cap_lua_job_status_name(s_jobs[slot].status); + } if (err == ESP_OK && was_running) { snprintf(output, output_size, "OK: stopped job %s (status=%s)", job_id, status_name); @@ -880,7 +865,10 @@ esp_err_t cap_lua_async_stop_all_jobs(const char *exclusive_filter, char *output, size_t output_size) { - int targets[CAP_LUA_ASYNC_MAX_JOBS]; + struct { + int slot; + char job_id[CAP_LUA_JOB_ID_LEN]; + } targets[CAP_LUA_ASYNC_MAX_JOBS]; int target_count = 0; if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { @@ -895,7 +883,10 @@ esp_err_t cap_lua_async_stop_all_jobs(const char *exclusive_filter, continue; } } - targets[target_count++] = i; + targets[target_count].slot = i; + strlcpy(targets[target_count].job_id, s_jobs[i].job_id, + sizeof(targets[target_count].job_id)); + target_count++; } xSemaphoreGive(s_job_lock); @@ -906,8 +897,22 @@ esp_err_t cap_lua_async_stop_all_jobs(const char *exclusive_filter, int stopped = 0; int timed_out = 0; for (int t = 0; t < target_count; t++) { + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(1000)) != pdTRUE) { + continue; + } + bool same_job = (s_jobs[targets[t].slot].used && + strncmp(s_jobs[targets[t].slot].job_id, + targets[t].job_id, + sizeof(targets[t].job_id)) == 0); + xSemaphoreGive(s_job_lock); + if (!same_job) { + continue; + } bool was_running = false; - esp_err_t err = cap_lua_stop_slot_and_wait(targets[t], wait_ms, &was_running); + esp_err_t err = cap_lua_stop_slot_and_wait(targets[t].slot, + targets[t].job_id, + wait_ms, + &was_running); if (err == ESP_OK) { stopped++; } else if (err == ESP_ERR_TIMEOUT) { @@ -1024,3 +1029,25 @@ size_t cap_lua_async_collect_active_snapshots(cap_lua_async_job_snapshot_t *out, xSemaphoreGive(s_job_lock); return count; } + +size_t cap_lua_async_active_count(void) +{ + size_t count = 0; + + if (!s_job_lock) { + return 0; + } + if (xSemaphoreTake(s_job_lock, pdMS_TO_TICKS(200)) != pdTRUE) { + /* Fail closed: callers (e.g. deactivate guard) MUST treat unknown as + * non-zero, otherwise contention on the job lock would silently allow + * the dangerous operation through. */ + return SIZE_MAX; + } + for (int i = 0; i < CAP_LUA_ASYNC_MAX_JOBS; i++) { + if (s_jobs[i].used && !cap_lua_status_is_terminal(s_jobs[i].status)) { + count++; + } + } + xSemaphoreGive(s_job_lock); + return count; +} diff --git a/components/claw_capabilities/cap_lua/src/cap_lua_internal.h b/components/claw_capabilities/cap_lua/src/cap_lua_internal.h index a878ebe..7a46c70 100644 --- a/components/claw_capabilities/cap_lua/src/cap_lua_internal.h +++ b/components/claw_capabilities/cap_lua/src/cap_lua_internal.h @@ -17,20 +17,9 @@ #define CAP_LUA_MAX_SCRIPT_SIZE (16 * 1024) #define CAP_LUA_OUTPUT_SIZE (4 * 1024) #define CAP_LUA_SYNC_DEFAULT_TIMEOUT_MS 60000 -/* - * Async jobs default to "until cancelled" so long-running clocks, watchers, - * and games can run indefinitely. Use lua_stop_async_job to terminate them. - */ #define CAP_LUA_ASYNC_DEFAULT_TIMEOUT_MS 0 #define CAP_LUA_ASYNC_MAX_JOBS 16 #define CAP_LUA_ASYNC_MAX_CONCURRENT 4 -/* - * Stack lives in internal DRAM (FATFS / display drivers expect it). 12 KB is - * the empirically-tuned minimum that still fits typical Lua scripts with a - * comfortable margin; reducing further increases the risk of stack overflow - * in deeper Lua call chains, while raising it makes allocation more likely to - * fail once the heap is fragmented after boot. - */ #define CAP_LUA_ASYNC_STACK (12 * 1024) #define CAP_LUA_ASYNC_PRIO 4 #define CAP_LUA_MAX_MODULES 16 @@ -38,8 +27,8 @@ #define CAP_LUA_JOB_NAME_MAX 32 #define CAP_LUA_JOB_EXCLUSIVE_MAX 16 #define CAP_LUA_JOB_PATH_MAX 192 -#define CAP_LUA_JOB_ID_LEN 9 /* 8 hex + NUL */ -#define CAP_LUA_STOP_WAIT_DEFAULT_MS 2000 /* sync wait window for stop */ +#define CAP_LUA_JOB_ID_LEN 9 +#define CAP_LUA_STOP_WAIT_DEFAULT_MS 2000 typedef struct { char path[CAP_LUA_JOB_PATH_MAX]; @@ -78,14 +67,6 @@ esp_err_t cap_lua_ensure_base_dir(void); esp_err_t cap_lua_runtime_init(void); -/* - * Execute a Lua script synchronously. - * - * stop_requested may be NULL; when non-NULL the runtime hook will raise - * a Lua error tagged "stopped by user" once the flag becomes true. - * - * timeout_ms == 0 means "no wall-clock deadline". - */ esp_err_t cap_lua_runtime_execute_file(const char *path, const char *args_json, uint32_t timeout_ms, @@ -121,16 +102,12 @@ esp_err_t cap_lua_async_stop_all_jobs(const char *exclusive_filter, size_t output_size); size_t cap_lua_async_collect_active_snapshots(cap_lua_async_job_snapshot_t *out, size_t max); +size_t cap_lua_async_active_count(void); const char *cap_lua_job_status_name(cap_lua_job_status_t status); -/* Lightweight single-job status probe. Returns ESP_ERR_NOT_FOUND if the slot - * has already been recycled. summary_out may be NULL. */ esp_err_t cap_lua_async_get_status(const char *job_id, cap_lua_job_status_t *out_status, char *summary_out, size_t summary_out_size); -/* Block up to timeout_ms waiting for the job to reach a terminal state. If - * the job is already terminal, returns immediately. Returns the latest status - * snapshot regardless of whether the wait timed out. */ esp_err_t cap_lua_async_wait_settle(const char *job_id, uint32_t timeout_ms, cap_lua_job_status_t *out_status, diff --git a/components/claw_capabilities/cap_skill_mgr/src/cap_skill_mgr.c b/components/claw_capabilities/cap_skill_mgr/src/cap_skill_mgr.c index f0e0646..5df42b5 100644 --- a/components/claw_capabilities/cap_skill_mgr/src/cap_skill_mgr.c +++ b/components/claw_capabilities/cap_skill_mgr/src/cap_skill_mgr.c @@ -506,8 +506,92 @@ static esp_err_t cap_skill_deactivate_execute(const char *input_json, snprintf(skill_id_buf, sizeof(skill_id_buf), "%s", skill_id_item->valuestring); if (strcmp(skill_id_buf, "all") == 0) { + char **active_ids = NULL; + size_t active_count = 0; + esp_err_t list_err = claw_skill_load_active_skill_ids(ctx->session_id, + &active_ids, + &active_count); + if (list_err != ESP_OK && list_err != ESP_ERR_NOT_FOUND) { + cJSON_Delete(root); + snprintf(output, output_size, + "{\"ok\":false,\"error\":\"failed to enumerate active skills\"," + "\"skill_id\":\"all\",\"detail\":\"%s\"}", + esp_err_to_name(list_err)); + return list_err; + } + + char first_block_id[64] = {0}; + char first_block_reason[256] = {0}; + for (size_t i = 0; i < active_count; i++) { + if (!active_ids[i] || !active_ids[i][0]) { + continue; + } + char r[256] = {0}; + esp_err_t g = claw_skill_check_deactivate_allowed(ctx->session_id, + active_ids[i], + r, sizeof(r)); + if (g != ESP_OK) { + snprintf(first_block_id, sizeof(first_block_id), "%s", active_ids[i]); + snprintf(first_block_reason, sizeof(first_block_reason), "%s", r); + break; + } + } + for (size_t i = 0; i < active_count; i++) { + free(active_ids[i]); + } + free(active_ids); + + if (first_block_id[0]) { + cJSON_Delete(root); + cJSON *resp = cJSON_CreateObject(); + char *rendered = NULL; + if (resp) { + cJSON_AddBoolToObject(resp, "ok", false); + cJSON_AddStringToObject(resp, "error", "deactivate blocked by skill guard"); + cJSON_AddStringToObject(resp, "skill_id", "all"); + cJSON_AddStringToObject(resp, "blocked_by", first_block_id); + cJSON_AddStringToObject(resp, "reason", first_block_reason); + rendered = cJSON_PrintUnformatted(resp); + cJSON_Delete(resp); + } + if (rendered) { + snprintf(output, output_size, "%s", rendered); + free(rendered); + } else { + snprintf(output, output_size, + "{\"ok\":false,\"error\":\"deactivate blocked\"," + "\"skill_id\":\"all\",\"blocked_by\":\"%s\"}", + first_block_id); + } + return ESP_OK; + } err = claw_skill_clear_active_for_session(ctx->session_id); } else { + char guard_reason[256] = {0}; + err = claw_skill_check_deactivate_allowed(ctx->session_id, skill_id_buf, + guard_reason, sizeof(guard_reason)); + if (err != ESP_OK) { + cJSON_Delete(root); + cJSON *resp = cJSON_CreateObject(); + char *rendered = NULL; + if (resp) { + cJSON_AddBoolToObject(resp, "ok", false); + cJSON_AddStringToObject(resp, "error", "deactivate blocked by skill guard"); + cJSON_AddStringToObject(resp, "skill_id", skill_id_buf); + cJSON_AddStringToObject(resp, "reason", guard_reason); + rendered = cJSON_PrintUnformatted(resp); + cJSON_Delete(resp); + } + if (rendered) { + snprintf(output, output_size, "%s", rendered); + free(rendered); + } else { + snprintf(output, output_size, + "{\"ok\":false,\"error\":\"deactivate blocked\"," + "\"skill_id\":\"%s\"}", skill_id_buf); + } + return ESP_OK; + } err = claw_skill_deactivate_for_session(ctx->session_id, skill_id_buf); } cJSON_Delete(root); diff --git a/components/claw_modules/claw_cap/src/claw_cap.c b/components/claw_modules/claw_cap/src/claw_cap.c index ecef728..8c71ae3 100644 --- a/components/claw_modules/claw_cap/src/claw_cap.c +++ b/components/claw_modules/claw_cap/src/claw_cap.c @@ -22,10 +22,6 @@ static const char *TAG = "claw_cap"; #define CLAW_CAP_DEFAULT_MAX_GROUPS 4 #define CLAW_CAP_UNLOAD_POLL_MS 20 -/* Soft cap on the description string published to the LLM tools array. Keeps - * the request body bounded and protects against accidental verbose copy that - * could push the body past upstream parser limits (observed as HTTP 400 on - * some gateways). Tune cautiously: shrinking too much hurts tool selection. */ #define CLAW_CAP_TOOL_DESCRIPTION_MAX 256 typedef struct { diff --git a/components/claw_modules/claw_core/include/claw_core.h b/components/claw_modules/claw_core/include/claw_core.h index 4d40c59..48b6c4c 100644 --- a/components/claw_modules/claw_core/include/claw_core.h +++ b/components/claw_modules/claw_core/include/claw_core.h @@ -122,16 +122,6 @@ struct claw_core_response { char *error_message; }; -/* - * Diagnostic snapshot of one fully completed claw_core request, fired once - * per request after the LLM produces a final assistant message (success path - * only). Observers are intended for telemetry / honesty checks; they MUST NOT - * mutate state or block the worker beyond a few milliseconds. - * - * Both CSV strings are comma-separated, never NULL, always NUL-terminated - * (may be empty). Names in tool_calls_csv preserve invocation order and are - * NOT deduped. Names in context_providers_csv are deduped. - */ typedef struct { uint32_t request_id; const char *session_id; /* may be NULL */ @@ -153,17 +143,6 @@ esp_err_t claw_core_call_cap(const char *cap_name, const claw_core_request_t *request, char **out_output); esp_err_t claw_core_submit(const claw_core_request_t *request, uint32_t timeout_ms); -/* - * Cooperatively abort the in-flight LLM request held by the worker task. - * - * `request_id == 0` cancels whatever is currently in flight (panic-stop). - * Otherwise the cancel only fires when the in-flight id matches, which lets - * callers safely retry from a stale id without disturbing newer rounds. - * - * Returns ESP_OK if a cancel was armed, ESP_ERR_NOT_FOUND if no matching - * request is in flight. The actual unwind is asynchronous; the worker - * publishes a normal response with error_message="request cancelled". - */ esp_err_t claw_core_cancel_request(uint32_t request_id); esp_err_t claw_core_receive(claw_core_response_t *response, uint32_t timeout_ms); esp_err_t claw_core_receive_for(uint32_t request_id, diff --git a/components/claw_modules/claw_core/src/claw_core.c b/components/claw_modules/claw_core/src/claw_core.c index d819742..c2bb42f 100644 --- a/components/claw_modules/claw_core/src/claw_core.c +++ b/components/claw_modules/claw_core/src/claw_core.c @@ -82,9 +82,6 @@ typedef struct { SemaphoreHandle_t response_lock; claw_core_pending_response_t *pending_head; claw_core_pending_response_t *pending_tail; - /* Cooperative-cancel slot for the request currently held by the worker. - * `inflight_request_id` is non-zero only between request dequeue and - * worker completion; protected by `inflight_lock`. */ SemaphoreHandle_t inflight_lock; uint32_t inflight_request_id; volatile bool inflight_abort; @@ -183,9 +180,6 @@ static char *build_session_assistant_text(const char *tool_summary, const char * return combined; } -/* CSV utilities for the per-request observer summary. Both buffers are - * stack-allocated in claw_core_task; the helpers below append a token and - * silently drop on overflow (diagnostic data is best-effort). */ #define CLAW_CORE_OBS_CSV_MAX 384 static bool obs_csv_contains(const char *csv, const char *name) @@ -1141,8 +1135,6 @@ static void claw_core_task(void *arg) continue; } - /* Publish the in-flight request id so claw_core_cancel_request can - * target it. inflight_abort starts cleared each round. */ if (xSemaphoreTake(s_core.inflight_lock, portMAX_DELAY) == pdTRUE) { s_core.inflight_request_id = request.view.request_id; s_core.inflight_abort = false; @@ -1288,8 +1280,6 @@ static void claw_core_task(void *arg) } finish_request: - /* Disarm BEFORE pushing the response so a subsequent cancel for an - * unrelated request never trips a stale flag here. */ claw_llm_http_disarm_abort(); if (xSemaphoreTake(s_core.inflight_lock, portMAX_DELAY) == pdTRUE) { bool was_cancelled = s_core.inflight_abort; @@ -1531,9 +1521,6 @@ esp_err_t claw_core_cancel_request(uint32_t request_id) if (!s_core.initialized) { return ESP_ERR_INVALID_STATE; } - /* request_id == 0 means "cancel whatever is in flight"; useful for a - * panic-stop hot key. Otherwise we only flip the flag when the in-flight - * id matches, so stale cancels are no-ops. */ if (xSemaphoreTake(s_core.inflight_lock, pdMS_TO_TICKS(200)) != pdTRUE) { return ESP_ERR_TIMEOUT; } diff --git a/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c b/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c index eb2fe9b..2394611 100644 --- a/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c +++ b/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.c @@ -21,16 +21,11 @@ static const char *TAG = "llm_http"; #define CLAW_LLM_HTTP_RB_INITIAL_CAP 4096 -/* Cooperative-abort slot. Owner is set so that a stale flag from a different - * task is ignored (defensive — claw_core serializes LLM calls already). */ static volatile bool *s_abort_flag = NULL; static TaskHandle_t s_abort_owner = NULL; void claw_llm_http_arm_abort(volatile bool *flag) { - /* Single-owner invariant: claw_core serializes LLM calls. Catching a - * concurrent arm here turns a silent abort-slot overwrite into a fatal - * panic so the assumption can't quietly rot. */ TaskHandle_t self = xTaskGetCurrentTaskHandle(); assert(s_abort_flag == NULL || s_abort_owner == self); s_abort_flag = flag; @@ -144,9 +139,6 @@ static esp_err_t http_event_handler(esp_http_client_event_t *evt) { response_buffer_t *buffer = (response_buffer_t *)evt->user_data; - /* Poll on every callback so abort latency is bounded by the next event - * (header parsed, chunk arrived, finish, etc.). Returning a non-OK status - * forces esp_http_client_perform to bail out. */ if (abort_requested()) { return ESP_FAIL; } diff --git a/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.h b/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.h index 93a257a..8b5c271 100644 --- a/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.h +++ b/components/claw_modules/claw_core/src/llm/claw_llm_http_transport.h @@ -14,19 +14,5 @@ esp_err_t claw_llm_http_post_json(const claw_llm_http_json_request_t *request, char **out_error_message); void claw_llm_http_response_free(claw_llm_http_response_t *response); -/* - * Cooperative abort for the calling task's in-flight HTTP request. - * - * `arm` records (flag, current task handle) in a file-static slot. While armed, - * the HTTP event handler polls *flag on every callback (HEADER/DATA/...) and - * returns ESP_FAIL when the flag is true, causing esp_http_client_perform to - * exit with an error and claw_llm_http_post_json to return ESP_FAIL. - * - * The arm/disarm pair MUST be called from the same task that will run - * claw_llm_http_post_json. The flag must outlive the HTTP call. Only one - * task may have an armed flag at a time; arming again from a different task - * silently overwrites the previous slot, which is fine because the LLM - * runtime serializes requests in claw_core_task. - */ void claw_llm_http_arm_abort(volatile bool *flag); void claw_llm_http_disarm_abort(void); diff --git a/components/claw_modules/claw_event_router/include/claw_event_router.h b/components/claw_modules/claw_event_router/include/claw_event_router.h index b4ba2aa..1f1893a 100644 --- a/components/claw_modules/claw_event_router/include/claw_event_router.h +++ b/components/claw_modules/claw_event_router/include/claw_event_router.h @@ -104,22 +104,7 @@ esp_err_t claw_event_router_init(const claw_event_router_config_t *config); esp_err_t claw_event_router_start(void); esp_err_t claw_event_router_stop(void); esp_err_t claw_event_router_reload(void); -/* - * Mark a queued event as cancelled. The router task will discard it on - * dequeue without invoking any actions. Returns ESP_OK if the event was - * found and tagged, ESP_ERR_NOT_FOUND if no such pending event exists - * (already dispatched, never queued, or evicted from the tracking table). - * - * Cancellation is queue-only: events that have already entered process_event - * cannot be cancelled here — use claw_core_cancel_request for the LLM round - * or lua_stop_async_job for an async script that the router spawned. - */ esp_err_t claw_event_router_cancel_event(const char *event_id); -/* - * Bulk-cancel all queued events that match the optional filters. Pass NULL - * (or empty string) for either filter to skip that constraint. Returns the - * number of pending entries marked, written to *out_cancelled when non-NULL. - */ esp_err_t claw_event_router_purge_queue(const char *event_type_filter, const char *source_cap_filter, size_t *out_cancelled); diff --git a/components/claw_modules/claw_event_router/src/claw_event_router.c b/components/claw_modules/claw_event_router/src/claw_event_router.c index 5924591..dc09d0b 100644 --- a/components/claw_modules/claw_event_router/src/claw_event_router.c +++ b/components/claw_modules/claw_event_router/src/claw_event_router.c @@ -75,10 +75,8 @@ typedef struct { size_t rule_count; claw_event_router_result_t last_result; claw_event_router_config_t config; - /* Tracks events that have been queued but not yet picked up by the - * router task. Protected by the existing recursive `mutex`. */ claw_event_router_pending_t pending[CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE]; - size_t pending_dropped; /* monotonic counter for diagnostics */ + size_t pending_dropped; } claw_event_router_runtime_t; static claw_event_router_runtime_t s_runtime = { @@ -230,18 +228,6 @@ static void claw_event_router_unlock(void) xSemaphoreGiveRecursive(s_runtime.mutex); } -/* --- Pending-event tracking --------------------------------------------- - * The router queue holds claw_event_t copies but does not allow indexed - * access, so a parallel slot table records every event that has been - * enqueued but not yet picked up by the worker. Slots are reclaimed in - * FIFO order on dequeue or when the table is full and we have to make - * room for a fresh event (the oldest non-cancelled entry is evicted). - * - * All helpers here assume the caller already holds the runtime mutex, - * EXCEPT pending_track / pending_take_for_event_id / pending_filter, - * which acquire it themselves. - */ - static int pending_find_slot_locked(const char *event_id) { if (!event_id || !event_id[0]) { @@ -265,10 +251,6 @@ static int pending_alloc_slot_locked(void) return i; } } - /* Table full: evict the first non-cancelled entry. The router task - * will then process its event without a tracking record (still safe, - * just not cancellable). Cancelled entries are preserved so that the - * pending cancel still triggers when their event reaches the head. */ for (int i = 0; i < (int)CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE; i++) { if (!s_runtime.pending[i].cancelled) { oldest = i; @@ -311,8 +293,6 @@ static void pending_track(const claw_event_t *event) claw_event_router_unlock(); } -/* Called by the router task right after dequeue. Returns true when the - * event was tagged cancelled and should be dropped. */ static bool pending_take_for_event_id(const char *event_id) { bool cancelled = false; @@ -2085,9 +2065,6 @@ esp_err_t claw_event_router_init(const claw_event_router_config_t *config) uint32_t queue_len = config && config->event_queue_len ? config->event_queue_len : CLAW_EVENT_ROUTER_DEFAULT_QUEUE_LEN; - /* Pending tracking is fixed-size; if the queue can hold more in-flight - * events than the table can track, cancel requests start getting silently - * evicted. Fail fast at config time instead of at runtime. */ if (queue_len > CLAW_EVENT_ROUTER_PENDING_TABLE_SIZE) { ESP_LOGE(TAG, "event_queue_len=%u exceeds pending table size %u", (unsigned)queue_len, @@ -2274,9 +2251,6 @@ esp_err_t claw_event_router_publish(const claw_event_t *event) if (err != ESP_OK) { return err; } - /* Track BEFORE enqueue so the router task on the other core can never - * dequeue and process the event before we have a record of it. If - * xQueueSend fails we roll the slot back. */ pending_track(&cloned); if (xQueueSend(s_runtime.event_queue, &cloned, pdMS_TO_TICKS(1000)) != pdTRUE) { (void)pending_take_for_event_id(cloned.event_id); diff --git a/components/claw_modules/claw_skill/include/claw_skill.h b/components/claw_modules/claw_skill/include/claw_skill.h index c645741..3ed94f5 100644 --- a/components/claw_modules/claw_skill/include/claw_skill.h +++ b/components/claw_modules/claw_skill/include/claw_skill.h @@ -44,6 +44,19 @@ esp_err_t claw_skill_activate_for_session(const char *session_id, const char *sk esp_err_t claw_skill_deactivate_for_session(const char *session_id, const char *skill_id); esp_err_t claw_skill_clear_active_for_session(const char *session_id); +typedef esp_err_t (*claw_skill_deactivate_guard_t)(const char *session_id, + const char *skill_id, + char *reason_out, + size_t reason_size); + +esp_err_t claw_skill_register_deactivate_guard(const char *skill_id, + claw_skill_deactivate_guard_t guard); + +esp_err_t claw_skill_check_deactivate_allowed(const char *session_id, + const char *skill_id, + char *reason_out, + size_t reason_size); + /* Prompt providers for the catalog and active skill documents. */ extern const claw_core_context_provider_t claw_skill_skills_list_provider; extern const claw_core_context_provider_t claw_skill_active_skill_docs_provider; diff --git a/components/claw_modules/claw_skill/src/claw_skill.c b/components/claw_modules/claw_skill/src/claw_skill.c index 063da1a..ac32532 100644 --- a/components/claw_modules/claw_skill/src/claw_skill.c +++ b/components/claw_modules/claw_skill/src/claw_skill.c @@ -22,6 +22,15 @@ static const char *SKILLS_LIST_FILE = "skills_list.json"; #define CLAW_SKILL_DEFAULT_MAX_FILES 64 #define CLAW_SKILL_DEFAULT_MAX_BYTES 2048 #define CLAW_SKILL_MAX_PATH 192 +#define CLAW_SKILL_MAX_GUARDS 8 + +typedef struct { + char skill_id[64]; + claw_skill_deactivate_guard_t guard; +} claw_skill_guard_entry_t; + +static claw_skill_guard_entry_t s_guards[CLAW_SKILL_MAX_GUARDS]; +static size_t s_guard_count; #ifdef CONFIG_CLAW_SKILL_DEBUG_LOG #define CLAW_SKILL_DIAGE(...) ESP_LOGE(TAG, __VA_ARGS__) @@ -1084,6 +1093,62 @@ esp_err_t claw_skill_clear_active_for_session(const char *session_id) return save_active_skill_ids_to_disk(session_id, NULL, 0); } +esp_err_t claw_skill_register_deactivate_guard(const char *skill_id, + claw_skill_deactivate_guard_t guard) +{ + if (!skill_id || !skill_id[0] || !guard) { + return ESP_ERR_INVALID_ARG; + } + + for (size_t i = 0; i < s_guard_count; i++) { + if (strcmp(s_guards[i].skill_id, skill_id) == 0) { + s_guards[i].guard = guard; + ESP_LOGI(TAG, "Replaced deactivate guard for skill '%s'", skill_id); + return ESP_OK; + } + } + + if (s_guard_count >= CLAW_SKILL_MAX_GUARDS) { + ESP_LOGE(TAG, "Cannot register guard for '%s': table full (max %d)", + skill_id, CLAW_SKILL_MAX_GUARDS); + return ESP_ERR_NO_MEM; + } + + safe_copy(s_guards[s_guard_count].skill_id, + sizeof(s_guards[s_guard_count].skill_id), + skill_id); + s_guards[s_guard_count].guard = guard; + s_guard_count++; + ESP_LOGI(TAG, "Registered deactivate guard for skill '%s'", skill_id); + return ESP_OK; +} + +esp_err_t claw_skill_check_deactivate_allowed(const char *session_id, + const char *skill_id, + char *reason_out, + size_t reason_size) +{ + if (reason_out && reason_size > 0) { + reason_out[0] = '\0'; + } + if (!skill_id || !skill_id[0]) { + return ESP_OK; + } + + for (size_t i = 0; i < s_guard_count; i++) { + if (strcmp(s_guards[i].skill_id, skill_id) != 0) { + continue; + } + esp_err_t err = s_guards[i].guard(session_id, skill_id, reason_out, reason_size); + if (err != ESP_OK && reason_out && reason_size > 0 && reason_out[0] == '\0') { + snprintf(reason_out, reason_size, + "deactivation refused by skill '%s' guard", skill_id); + } + return err; + } + return ESP_OK; +} + static esp_err_t claw_skill_skills_list_collect(const claw_core_request_t *request, claw_core_context_t *out_context, void *user_ctx) diff --git a/components/lua_modules/lua_module_display/src/display_hal.c b/components/lua_modules/lua_module_display/src/display_hal.c index d91ab00..1b8f10e 100644 --- a/components/lua_modules/lua_module_display/src/display_hal.c +++ b/components/lua_modules/lua_module_display/src/display_hal.c @@ -156,18 +156,6 @@ esp_err_t display_hal_create(esp_lcd_panel_handle_t panel_handle, ESP_GOTO_ON_FALSE(lcd_width > 0 && lcd_height > 0, ESP_ERR_INVALID_ARG, fail, TAG, "invalid lcd size"); - /* Idempotent re-create: if a previous Lua script left the HAL fully - * initialized with identical handles and geometry, return success without - * churning resources. This is the happy path when the cap_lua - * exclusive("display") arbiter swaps scripts of the same UI stack. - * - * The "fully initialized" guard matters because the fail: epilogue below - * only rolls back the swap buffer, not the basic fields / semaphore / - * callbacks. Without this guard a partial-init failure (e.g. semaphore - * alloc failed in a previous call) would leave the basic fields matching - * and trick the next call into a no-op success while the underlying sync - * objects are missing. We therefore require every resource the HAL hands - * out to be present before declaring a no-op. */ if (s_state.panel == panel_handle && s_state.io == io_handle && s_state.panel_if == panel_if && @@ -181,9 +169,6 @@ esp_err_t display_hal_create(esp_lcd_panel_handle_t panel_handle, goto fail; } - /* Defensive cleanup: if a prior session left a swap buffer behind (e.g. a - * Lua script crashed before display.deinit), free it before reallocating - * to avoid leaks that would accumulate across runs. */ if (s_state.submit_swap_buffer) { ESP_LOGW(TAG, "display_hal_create: freeing leftover swap buffer (%u px)", (unsigned)s_state.submit_swap_buffer_pixels);