From ed7daed115de73af22286fecdbb78ede94b5ce28 Mon Sep 17 00:00:00 2001 From: tnm Date: Fri, 20 Feb 2026 17:25:14 -0800 Subject: [PATCH 1/2] Add opt-in BLE WiFi provisioning path --- main/CMakeLists.txt | 1 + main/Kconfig.projbuild | 20 +++ main/main.c | 300 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 289 insertions(+), 32 deletions(-) diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index c39c6c4..c30b7c9 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -36,4 +36,5 @@ idf_component_register( lwip esp_driver_usb_serial_jtag app_update + wifi_provisioning ) diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 4775c70..8e22134 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -13,6 +13,26 @@ menu "zclaw Configuration" help WiFi network password fallback. Leave empty to use NVS provisioning. + menu "BLE Provisioning" + depends on BT_ENABLED + + config ZCLAW_BLE_PROVISIONING + bool "Enable BLE WiFi provisioning when device is unprovisioned" + default n + help + When enabled and no WiFi credentials exist in NVS, the device + starts BLE provisioning service so a phone app can submit WiFi + credentials without serial provisioning. + + config ZCLAW_BLE_PROV_POP + string "BLE provisioning proof-of-possession (optional)" + default "" + depends on ZCLAW_BLE_PROVISIONING + help + Optional PoP for security-1 provisioning. Leave empty to use + security-0 (no PoP) for faster local setup. + endmenu + config ZCLAW_CLAUDE_API_KEY string "LLM API Key (for testing only)" default "" diff --git a/main/main.c b/main/main.c index 31421fc..bff1bb5 100644 --- a/main/main.c +++ b/main/main.c @@ -17,11 +17,16 @@ #include "freertos/event_groups.h" #include "esp_wifi.h" #include "esp_event.h" +#include "esp_netif.h" #include "esp_log.h" #include "esp_err.h" #include "esp_system.h" #include "nvs_flash.h" #include "driver/gpio.h" +#if CONFIG_ZCLAW_BLE_PROVISIONING +#include "wifi_provisioning/manager.h" +#include "wifi_provisioning/scheme_ble.h" +#endif #include static const char *TAG = "main"; @@ -34,6 +39,12 @@ static EventGroupHandle_t s_wifi_event_group; static int s_retry_num = 0; static bool s_safe_mode = false; static uint8_t s_last_disconnect_reason = 0; +static bool s_in_ble_provisioning = false; +static bool s_wifi_handlers_registered = false; +static bool s_wifi_inited = false; +static bool s_sta_netif_created = false; +static esp_event_handler_instance_t s_instance_any_id; +static esp_event_handler_instance_t s_instance_got_ip; #ifndef WIFI_REASON_BEACON_TIMEOUT #define WIFI_REASON_BEACON_TIMEOUT 200 @@ -231,6 +242,11 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base, ESP_LOGW(TAG, "WiFi hint: %s", hint); } + if (s_in_ble_provisioning) { + ESP_LOGW(TAG, "Waiting for new credentials via BLE provisioning"); + return; + } + if (s_retry_num < WIFI_MAX_RETRY) { esp_wifi_connect(); s_retry_num++; @@ -249,6 +265,64 @@ static void wifi_event_handler(void *arg, esp_event_base_t event_base, } } +static esp_err_t wifi_stack_init(void) +{ + esp_err_t err; + + if (!s_wifi_event_group) { + s_wifi_event_group = xEventGroupCreate(); + if (!s_wifi_event_group) { + return ESP_ERR_NO_MEM; + } + } + + err = esp_netif_init(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + return err; + } + + err = esp_event_loop_create_default(); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + return err; + } + + if (!s_sta_netif_created) { + if (esp_netif_get_handle_from_ifkey("WIFI_STA_DEF") == NULL) { + if (esp_netif_create_default_wifi_sta() == NULL) { + return ESP_FAIL; + } + } + s_sta_netif_created = true; + } + + if (!s_wifi_inited) { + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + err = esp_wifi_init(&cfg); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + return err; + } + s_wifi_inited = true; + } + + if (!s_wifi_handlers_registered) { + err = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &wifi_event_handler, NULL, &s_instance_any_id); + if (err != ESP_OK) { + return err; + } + + err = esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &wifi_event_handler, NULL, &s_instance_got_ip); + if (err != ESP_OK) { + esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, s_instance_any_id); + return err; + } + s_wifi_handlers_registered = true; + } + + return ESP_OK; +} + // Check factory reset button static bool check_factory_reset(void) { @@ -292,6 +366,7 @@ static bool device_is_configured(void) #endif } +#if !CONFIG_ZCLAW_BLE_PROVISIONING static void print_provisioning_help(void) { ESP_LOGE(TAG, ""); @@ -303,6 +378,7 @@ static void print_provisioning_help(void) ESP_LOGE(TAG, "Then restart the board."); ESP_LOGE(TAG, ""); } +#endif // Connect to WiFi using stored credentials static bool wifi_connect_sta(void) @@ -334,21 +410,13 @@ static bool wifi_connect_sta(void) ESP_LOGI(TAG, "Loaded WiFi credentials: ssid='%s', password_len=%u", ssid, (unsigned)strlen(pass)); - s_wifi_event_group = xEventGroupCreate(); - - ESP_ERROR_CHECK(esp_netif_init()); - ESP_ERROR_CHECK(esp_event_loop_create_default()); - esp_netif_create_default_wifi_sta(); - - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - ESP_ERROR_CHECK(esp_wifi_init(&cfg)); - - esp_event_handler_instance_t instance_any_id; - esp_event_handler_instance_t instance_got_ip; - ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, - &wifi_event_handler, NULL, &instance_any_id)); - ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, - &wifi_event_handler, NULL, &instance_got_ip)); + esp_err_t init_err = wifi_stack_init(); + if (init_err != ESP_OK) { + ESP_LOGE(TAG, "WiFi stack init failed: %s", esp_err_to_name(init_err)); + return false; + } + xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT); + s_retry_num = 0; wifi_config_t wifi_config = {0}; strncpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid) - 1); @@ -371,6 +439,164 @@ static bool wifi_connect_sta(void) return (bits & WIFI_CONNECTED_BIT) != 0; } +#if CONFIG_ZCLAW_BLE_PROVISIONING +static const char *ble_prov_fail_reason_name(wifi_prov_sta_fail_reason_t reason) +{ + switch (reason) { + case WIFI_PROV_STA_AUTH_ERROR: return "AUTH_ERROR"; + case WIFI_PROV_STA_AP_NOT_FOUND: return "AP_NOT_FOUND"; + default: return "UNKNOWN"; + } +} + +static void ble_prov_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data) +{ + (void)arg; + if (event_base != WIFI_PROV_EVENT) { + return; + } + + switch (event_id) { + case WIFI_PROV_START: + ESP_LOGI(TAG, "BLE provisioning started"); + break; + case WIFI_PROV_CRED_RECV: { + const wifi_sta_config_t *sta_cfg = (const wifi_sta_config_t *)event_data; + char ssid[33] = {0}; + if (sta_cfg) { + memcpy(ssid, sta_cfg->ssid, sizeof(sta_cfg->ssid)); + } + ESP_LOGI(TAG, "BLE provisioning received credentials for ssid='%s'", ssid); + break; + } + case WIFI_PROV_CRED_FAIL: { + const wifi_prov_sta_fail_reason_t *reason = (const wifi_prov_sta_fail_reason_t *)event_data; + wifi_prov_sta_fail_reason_t fail = reason ? *reason : WIFI_PROV_STA_AP_NOT_FOUND; + ESP_LOGW(TAG, "BLE provisioning connect failed: %s", ble_prov_fail_reason_name(fail)); + break; + } + case WIFI_PROV_CRED_SUCCESS: + ESP_LOGI(TAG, "BLE provisioning WiFi credentials accepted"); + break; + case WIFI_PROV_END: + ESP_LOGI(TAG, "BLE provisioning service stopped"); + break; + default: + break; + } +} + +static void build_ble_service_name(char *out, size_t out_len) +{ + uint8_t mac[6] = {0}; + if (esp_wifi_get_mac(WIFI_IF_STA, mac) == ESP_OK) { + snprintf(out, out_len, "zclaw-%02X%02X%02X", mac[3], mac[4], mac[5]); + return; + } + snprintf(out, out_len, "zclaw"); +} + +static bool wifi_provision_over_ble(void) +{ + esp_err_t err = wifi_stack_init(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "WiFi stack init failed for BLE provisioning: %s", esp_err_to_name(err)); + return false; + } + + xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT); + s_retry_num = 0; + + wifi_prov_mgr_config_t prov_cfg = { + .scheme = wifi_prov_scheme_ble, + .scheme_event_handler = WIFI_PROV_SCHEME_BLE_EVENT_HANDLER_FREE_BLE, + .app_event_handler = WIFI_PROV_EVENT_HANDLER_NONE, + }; + + err = wifi_prov_mgr_init(prov_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "wifi_prov_mgr_init failed: %s", esp_err_to_name(err)); + return false; + } + + esp_event_handler_instance_t prov_event_instance; + err = esp_event_handler_instance_register(WIFI_PROV_EVENT, ESP_EVENT_ANY_ID, + &ble_prov_event_handler, NULL, &prov_event_instance); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to register BLE provisioning event handler: %s", esp_err_to_name(err)); + wifi_prov_mgr_deinit(); + return false; + } + + bool provisioned = false; + err = wifi_prov_mgr_is_provisioned(&provisioned); + if (err != ESP_OK) { + ESP_LOGE(TAG, "wifi_prov_mgr_is_provisioned failed: %s", esp_err_to_name(err)); + esp_event_handler_instance_unregister(WIFI_PROV_EVENT, ESP_EVENT_ANY_ID, prov_event_instance); + wifi_prov_mgr_deinit(); + return false; + } + + if (provisioned) { + ESP_LOGI(TAG, "NVS already has WiFi credentials; skipping BLE provisioning"); + esp_event_handler_instance_unregister(WIFI_PROV_EVENT, ESP_EVENT_ANY_ID, prov_event_instance); + wifi_prov_mgr_deinit(); + return wifi_connect_sta(); + } + + char service_name[20] = {0}; + build_ble_service_name(service_name, sizeof(service_name)); + + wifi_prov_security_t security = WIFI_PROV_SECURITY_0; + const void *security_params = NULL; + const char *pop_value = ""; + +#if defined(CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_1) && defined(CONFIG_ZCLAW_BLE_PROV_POP) + if (CONFIG_ZCLAW_BLE_PROV_POP[0] != '\0') { + security = WIFI_PROV_SECURITY_1; + security_params = CONFIG_ZCLAW_BLE_PROV_POP; + pop_value = CONFIG_ZCLAW_BLE_PROV_POP; + } +#endif + + ESP_LOGW(TAG, "No WiFi credentials found in NVS. Starting BLE provisioning..."); + ESP_LOGI(TAG, "BLE service name: %s", service_name); + ESP_LOGI(TAG, "Use the Espressif 'ESP BLE Provisioning' app"); + ESP_LOGI(TAG, "QR payload: {\"ver\":\"v1\",\"name\":\"%s\",\"transport\":\"ble\",\"pop\":\"%s\"}", + service_name, pop_value); + + s_in_ble_provisioning = true; + err = wifi_prov_mgr_start_provisioning(security, security_params, service_name, NULL); + if (err != ESP_OK) { + ESP_LOGE(TAG, "wifi_prov_mgr_start_provisioning failed: %s", esp_err_to_name(err)); + s_in_ble_provisioning = false; + esp_event_handler_instance_unregister(WIFI_PROV_EVENT, ESP_EVENT_ANY_ID, prov_event_instance); + wifi_prov_mgr_deinit(); + return false; + } + + EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group, + WIFI_CONNECTED_BIT, + pdTRUE, pdFALSE, portMAX_DELAY); + bool connected = (bits & WIFI_CONNECTED_BIT) != 0; + s_in_ble_provisioning = false; + + wifi_prov_mgr_stop_provisioning(); + wifi_prov_mgr_wait(); + esp_event_handler_instance_unregister(WIFI_PROV_EVENT, ESP_EVENT_ANY_ID, prov_event_instance); + wifi_prov_mgr_deinit(); + + if (!connected) { + ESP_LOGE(TAG, "BLE provisioning ended without WiFi connection"); + return false; + } + + ESP_LOGI(TAG, "BLE provisioning complete, WiFi connected"); + return true; +} +#endif + void app_main(void) { ESP_LOGI(TAG, ""); @@ -443,29 +669,39 @@ void app_main(void) } #else - // 4. Check if configured or in safe mode - if (!device_is_configured() || s_safe_mode) { - if (s_safe_mode) { - ESP_LOGE(TAG, ""); - ESP_LOGE(TAG, "========================================"); - ESP_LOGE(TAG, " SAFE MODE - Too many boot failures"); - ESP_LOGE(TAG, " Hold BOOT button for factory reset"); - ESP_LOGE(TAG, "========================================"); - ESP_LOGE(TAG, ""); - ESP_LOGE(TAG, "Recovery options:"); - ESP_LOGE(TAG, " 1) Hold BOOT for factory reset"); - ESP_LOGE(TAG, " 2) Reflash firmware and reprovision"); - ESP_LOGE(TAG, ""); - } else { - print_provisioning_help(); + // 4. Safe mode blocks normal startup + if (s_safe_mode) { + ESP_LOGE(TAG, ""); + ESP_LOGE(TAG, "========================================"); + ESP_LOGE(TAG, " SAFE MODE - Too many boot failures"); + ESP_LOGE(TAG, " Hold BOOT button for factory reset"); + ESP_LOGE(TAG, "========================================"); + ESP_LOGE(TAG, ""); + ESP_LOGE(TAG, "Recovery options:"); + ESP_LOGE(TAG, " 1) Hold BOOT for factory reset"); + ESP_LOGE(TAG, " 2) Reflash firmware and reprovision"); + ESP_LOGE(TAG, ""); + while (1) { + vTaskDelay(pdMS_TO_TICKS(5000)); } + } + + // 5. Connect to WiFi (or provision first if needed) + bool wifi_ready = false; + if (device_is_configured()) { + wifi_ready = wifi_connect_sta(); + } else { +#if CONFIG_ZCLAW_BLE_PROVISIONING + wifi_ready = wifi_provision_over_ble(); +#else + print_provisioning_help(); while (1) { vTaskDelay(pdMS_TO_TICKS(5000)); } +#endif } - // 5. Connect to WiFi - if (!wifi_connect_sta()) { + if (!wifi_ready) { ESP_LOGE(TAG, "WiFi failed, restarting..."); vTaskDelay(pdMS_TO_TICKS(3000)); esp_restart(); From 2cbec6e0d375cf891868bb5262bc15a353428a0f Mon Sep 17 00:00:00 2001 From: tnm Date: Fri, 20 Feb 2026 17:33:46 -0800 Subject: [PATCH 2/2] Fix cron stack overflow and add stack-usage guard --- .github/workflows/firmware-stack-guard.yml | 27 ++++++ main/cron.c | 23 ++--- scripts/check-stack-usage.sh | 101 +++++++++++++++++++++ 3 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/firmware-stack-guard.yml create mode 100755 scripts/check-stack-usage.sh diff --git a/.github/workflows/firmware-stack-guard.yml b/.github/workflows/firmware-stack-guard.yml new file mode 100644 index 0000000..7c8d927 --- /dev/null +++ b/.github/workflows/firmware-stack-guard.yml @@ -0,0 +1,27 @@ +name: Firmware Stack Guard + +on: + push: + pull_request: + +jobs: + stack-guard: + runs-on: ubuntu-latest + container: + image: espressif/idf:v5.4 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install jq + run: | + apt-get update + apt-get install -y jq + + - name: Build firmware + run: | + . "$IDF_PATH/export.sh" + idf.py build + + - name: Guard cron stack usage + run: ./scripts/check-stack-usage.sh --build-dir build --max-bytes 1024 diff --git a/main/cron.c b/main/cron.c index eead2e9..3edc1b9 100644 --- a/main/cron.c +++ b/main/cron.c @@ -26,6 +26,13 @@ static cron_entry_t s_entries[CRON_MAX_ENTRIES]; static bool s_time_synced = false; static SemaphoreHandle_t s_entries_mutex = NULL; static char s_timezone[TIMEZONE_MAX_LEN] = DEFAULT_TIMEZONE_POSIX; +typedef struct { + uint8_t id; + char action[CRON_MAX_ACTION_LEN]; +} pending_cron_fire_t; +// Keep pending actions in static storage; allocating this on cron task stack +// can exceed CRON_TASK_STACK_SIZE on smaller targets (e.g. ESP32-C6). +static pending_cron_fire_t s_pending_fires[CRON_MAX_ENTRIES]; static bool entries_lock(TickType_t timeout_ticks) { @@ -454,17 +461,11 @@ esp_err_t cron_delete(uint8_t id) // Check and fire due entries static void check_entries(void) { - typedef struct { - uint8_t id; - char action[CRON_MAX_ACTION_LEN]; - } pending_cron_fire_t; - time_t now; struct tm timeinfo; time(&now); localtime_r(&now, &timeinfo); - pending_cron_fire_t pending[CRON_MAX_ENTRIES]; int pending_count = 0; if (!entries_lock(pdMS_TO_TICKS(1000))) { @@ -501,9 +502,9 @@ static void check_entries(void) } if (pending_count < CRON_MAX_ENTRIES) { - pending[pending_count].id = entry->id; - strncpy(pending[pending_count].action, entry->action, sizeof(pending[pending_count].action) - 1); - pending[pending_count].action[sizeof(pending[pending_count].action) - 1] = '\0'; + s_pending_fires[pending_count].id = entry->id; + strncpy(s_pending_fires[pending_count].action, entry->action, sizeof(s_pending_fires[pending_count].action) - 1); + s_pending_fires[pending_count].action[sizeof(s_pending_fires[pending_count].action) - 1] = '\0'; pending_count++; } } @@ -512,11 +513,11 @@ static void check_entries(void) entries_unlock(); for (int i = 0; i < pending_count; i++) { - ESP_LOGI(TAG, "Firing cron %d: %s", pending[i].id, pending[i].action); + ESP_LOGI(TAG, "Firing cron %d: %s", s_pending_fires[i].id, s_pending_fires[i].action); // Push action to agent queue channel_msg_t msg; - snprintf(msg.text, sizeof(msg.text), "[CRON %d] %s", pending[i].id, pending[i].action); + snprintf(msg.text, sizeof(msg.text), "[CRON %d] %s", s_pending_fires[i].id, s_pending_fires[i].action); if (xQueueSend(s_agent_queue, &msg, pdMS_TO_TICKS(100)) != pdTRUE) { ESP_LOGW(TAG, "Agent queue full, cron action dropped"); diff --git a/scripts/check-stack-usage.sh b/scripts/check-stack-usage.sh new file mode 100755 index 0000000..06da386 --- /dev/null +++ b/scripts/check-stack-usage.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Guard critical stack usage for selected functions from compile_commands.json. + +set -euo pipefail + +BUILD_DIR="build" +SOURCE_SUFFIX="/main/cron.c" +FUNCTION_NAME="check_entries" +MAX_BYTES=1024 + +usage() { + cat <&2 + usage + exit 1 + ;; + esac +done + +if ! command -v jq >/dev/null 2>&1; then + echo "Error: jq is required for stack usage check" >&2 + exit 1 +fi + +COMPILE_DB="$BUILD_DIR/compile_commands.json" +if [ ! -f "$COMPILE_DB" ]; then + echo "Error: compile commands not found: $COMPILE_DB" >&2 + echo "Run a firmware build first (e.g. idf.py build)." >&2 + exit 1 +fi + +CMD="$(jq -r --arg sfx "$SOURCE_SUFFIX" '.[] | select(.file | endswith($sfx)) | .command' "$COMPILE_DB" | head -n 1)" +if [ -z "$CMD" ]; then + echo "Error: no compile command found for source suffix '$SOURCE_SUFFIX' in $COMPILE_DB" >&2 + exit 1 +fi + +TMP_BASE="${TMPDIR:-/tmp}/zclaw-stack-${FUNCTION_NAME}-$$" +TMP_OBJ="${TMP_BASE}.o" +TMP_SU="${TMP_BASE}.su" + +cleanup() { + rm -f "$TMP_OBJ" "$TMP_SU" +} +trap cleanup EXIT + +# Reuse the exact project compile flags but redirect output to a temp object. +CMD="$(printf '%s' "$CMD" | sed -E "s# -o [^ ]+# -o ${TMP_OBJ}#")" +eval "$CMD -fstack-usage" + +if [ ! -f "$TMP_SU" ]; then + echo "Error: stack usage report not generated: $TMP_SU" >&2 + exit 1 +fi + +USAGE_BYTES="$(awk -F'\t' -v fn="$FUNCTION_NAME" '$1 ~ (":" fn "$") {print $2; exit}' "$TMP_SU")" +if [ -z "$USAGE_BYTES" ]; then + echo "Error: function '$FUNCTION_NAME' not found in stack report" >&2 + cat "$TMP_SU" >&2 + exit 1 +fi + +if [ "$USAGE_BYTES" -gt "$MAX_BYTES" ]; then + echo "FAIL: ${FUNCTION_NAME} stack usage ${USAGE_BYTES} bytes exceeds limit ${MAX_BYTES} bytes" >&2 + exit 1 +fi + +echo "PASS: ${FUNCTION_NAME} stack usage ${USAGE_BYTES} bytes (limit ${MAX_BYTES})"