From 6c283553f943feb3b1990cfe89fc0ec03394aca5 Mon Sep 17 00:00:00 2001 From: crispyberry Date: Sun, 8 Mar 2026 12:15:26 +0800 Subject: [PATCH] feat: add WiFi onboarding captive portal When no WiFi credentials are configured or connection fails, the device automatically starts a soft AP (MimiClaw-XXXX) with a captive portal. Users can configure WiFi, LLM, Telegram, Feishu, Proxy and Search settings via a mobile-friendly web page, then the device restarts and connects with the new credentials. Co-Authored-By: Claude Opus 4.6 --- main/CMakeLists.txt | 1 + main/mimi.c | 49 +++-- main/mimi_config.h | 8 + main/onboard/onboard_html.h | 136 ++++++++++++++ main/onboard/wifi_onboard.c | 365 ++++++++++++++++++++++++++++++++++++ main/onboard/wifi_onboard.h | 10 + main/wifi/wifi_manager.c | 32 +++- main/wifi/wifi_manager.h | 10 + 8 files changed, 590 insertions(+), 21 deletions(-) create mode 100644 main/onboard/onboard_html.h create mode 100644 main/onboard/wifi_onboard.c create mode 100644 main/onboard/wifi_onboard.h diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 5f3fe1e..484c8bd 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -21,6 +21,7 @@ idf_component_register( "tools/tool_get_time.c" "tools/tool_files.c" "skills/skill_loader.c" + "onboard/wifi_onboard.c" INCLUDE_DIRS "." REQUIRES diff --git a/main/mimi.c b/main/mimi.c index 0e8e8fa..3712b72 100644 --- a/main/mimi.c +++ b/main/mimi.c @@ -25,6 +25,7 @@ #include "cron/cron_service.h" #include "heartbeat/heartbeat.h" #include "skills/skill_loader.h" +#include "onboard/wifi_onboard.h" static const char *TAG = "mimi"; @@ -141,34 +142,44 @@ void app_main(void) /* Start WiFi */ esp_err_t wifi_err = wifi_manager_start(); + bool wifi_ok = false; if (wifi_err == ESP_OK) { ESP_LOGI(TAG, "Scanning nearby APs on boot..."); wifi_manager_scan_and_print(); ESP_LOGI(TAG, "Waiting for WiFi connection..."); if (wifi_manager_wait_connected(30000) == ESP_OK) { + wifi_ok = true; ESP_LOGI(TAG, "WiFi connected: %s", wifi_manager_get_ip()); - - /* Outbound dispatch task should start first to avoid dropping early replies. */ - ESP_ERROR_CHECK((xTaskCreatePinnedToCore( - outbound_dispatch_task, "outbound", - MIMI_OUTBOUND_STACK, NULL, - MIMI_OUTBOUND_PRIO, NULL, MIMI_OUTBOUND_CORE) == pdPASS) - ? ESP_OK : ESP_FAIL); - - /* Start network-dependent services */ - ESP_ERROR_CHECK(agent_loop_start()); - ESP_ERROR_CHECK(telegram_bot_start()); - ESP_ERROR_CHECK(feishu_bot_start()); - cron_service_start(); - heartbeat_start(); - ESP_ERROR_CHECK(ws_server_start()); - - ESP_LOGI(TAG, "All services started!"); } else { - ESP_LOGW(TAG, "WiFi connection timeout. Check MIMI_SECRET_WIFI_SSID in mimi_secrets.h"); + ESP_LOGW(TAG, "WiFi connection timeout"); } } else { - ESP_LOGW(TAG, "No WiFi credentials. Set MIMI_SECRET_WIFI_SSID in mimi_secrets.h"); + ESP_LOGW(TAG, "No WiFi credentials configured"); + } + + if (!wifi_ok) { + ESP_LOGW(TAG, "Entering WiFi onboarding mode..."); + wifi_onboard_start(); /* blocks, restarts on success */ + return; /* unreachable */ + } + + { + /* Outbound dispatch task should start first to avoid dropping early replies. */ + ESP_ERROR_CHECK((xTaskCreatePinnedToCore( + outbound_dispatch_task, "outbound", + MIMI_OUTBOUND_STACK, NULL, + MIMI_OUTBOUND_PRIO, NULL, MIMI_OUTBOUND_CORE) == pdPASS) + ? ESP_OK : ESP_FAIL); + + /* Start network-dependent services */ + ESP_ERROR_CHECK(agent_loop_start()); + ESP_ERROR_CHECK(telegram_bot_start()); + ESP_ERROR_CHECK(feishu_bot_start()); + cron_service_start(); + heartbeat_start(); + ESP_ERROR_CHECK(ws_server_start()); + + ESP_LOGI(TAG, "All services started!"); } ESP_LOGI(TAG, "MimiClaw ready. Type 'help' for CLI commands."); diff --git a/main/mimi_config.h b/main/mimi_config.h index 9be7c08..99522b6 100644 --- a/main/mimi_config.h +++ b/main/mimi_config.h @@ -149,3 +149,11 @@ #define MIMI_NVS_KEY_PROVIDER "provider" #define MIMI_NVS_KEY_PROXY_HOST "host" #define MIMI_NVS_KEY_PROXY_PORT "port" +#define MIMI_NVS_KEY_PROXY_TYPE "type" + +/* WiFi Onboarding (Captive Portal) */ +#define MIMI_ONBOARD_AP_PREFIX "MimiClaw-" +#define MIMI_ONBOARD_AP_PASS "" /* open network */ +#define MIMI_ONBOARD_HTTP_PORT 80 +#define MIMI_ONBOARD_DNS_STACK (4 * 1024) +#define MIMI_ONBOARD_MAX_SCAN 20 diff --git a/main/onboard/onboard_html.h b/main/onboard/onboard_html.h new file mode 100644 index 0000000..7131b64 --- /dev/null +++ b/main/onboard/onboard_html.h @@ -0,0 +1,136 @@ +#pragma once + +static const char ONBOARD_HTML[] = +"" +"" +"" +"MimiClaw Setup" +"" +"

MimiClaw Setup

" + +/* WiFi section (expanded by default) */ +"
" +"
WiFi Configuration
" +"
" +"" +"" +"" +"" +"" +"" +"
" + +/* LLM section */ +"" + +/* Telegram section */ +"" + +/* Feishu section */ +"" + +/* Proxy section */ +"" + +/* Search section */ +"" + +"" +"
Saving... Device will restart.
" + +"" +""; diff --git a/main/onboard/wifi_onboard.c b/main/onboard/wifi_onboard.c new file mode 100644 index 0000000..2268539 --- /dev/null +++ b/main/onboard/wifi_onboard.c @@ -0,0 +1,365 @@ +#include "wifi_onboard.h" +#include "onboard_html.h" +#include "mimi_config.h" +#include "wifi/wifi_manager.h" + +#include +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_netif.h" +#include "esp_mac.h" +#include "esp_http_server.h" +#include "esp_system.h" +#include "nvs.h" +#include "cJSON.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lwip/sockets.h" +#include "lwip/netdb.h" + +static const char *TAG = "onboard"; + +/* ── DNS hijack ─────────────────────────────────────────────────── */ + +/* Minimal DNS response: always answer 192.168.4.1 */ +static void dns_hijack_task(void *arg) +{ + int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { + ESP_LOGE(TAG, "DNS socket error"); + vTaskDelete(NULL); + return; + } + + struct sockaddr_in addr = { + .sin_family = AF_INET, + .sin_port = htons(53), + .sin_addr.s_addr = htonl(INADDR_ANY), + }; + + int opt = 1; + setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + ESP_LOGE(TAG, "DNS bind failed"); + close(sock); + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "DNS hijack listening on :53"); + + uint8_t buf[512]; + struct sockaddr_in client; + socklen_t client_len; + + while (1) { + client_len = sizeof(client); + int len = recvfrom(sock, buf, sizeof(buf), 0, + (struct sockaddr *)&client, &client_len); + if (len < 12) continue; /* too short for DNS header */ + + /* Build response: copy query, set response flags, append answer */ + uint8_t resp[512]; + if (len + 16 > (int)sizeof(resp)) continue; + + memcpy(resp, buf, len); + + /* Set QR=1 (response), AA=1 (authoritative), RA=1 */ + resp[2] = 0x85; /* QR=1, Opcode=0, AA=1, TC=0, RD=1 */ + resp[3] = 0x80; /* RA=1, Z=0, RCODE=0 (no error) */ + + /* Answer count = 1 */ + resp[6] = 0x00; + resp[7] = 0x01; + + /* Append answer: pointer to name + A record with 192.168.4.1 */ + int off = len; + resp[off++] = 0xC0; /* pointer */ + resp[off++] = 0x0C; /* offset to question name */ + resp[off++] = 0x00; resp[off++] = 0x01; /* type A */ + resp[off++] = 0x00; resp[off++] = 0x01; /* class IN */ + resp[off++] = 0x00; resp[off++] = 0x00; + resp[off++] = 0x00; resp[off++] = 0x3C; /* TTL = 60 */ + resp[off++] = 0x00; resp[off++] = 0x04; /* data length = 4 */ + resp[off++] = 192; resp[off++] = 168; + resp[off++] = 4; resp[off++] = 1; + + sendto(sock, resp, off, 0, + (struct sockaddr *)&client, client_len); + } +} + +/* ── HTTP handlers ──────────────────────────────────────────────── */ + +static esp_err_t http_get_root(httpd_req_t *req) +{ + httpd_resp_set_type(req, "text/html"); + httpd_resp_set_hdr(req, "Cache-Control", "no-cache"); + return httpd_resp_send(req, ONBOARD_HTML, sizeof(ONBOARD_HTML) - 1); +} + +/* Captive portal detection endpoints → redirect to root */ +static esp_err_t http_captive_redirect(httpd_req_t *req) +{ + httpd_resp_set_status(req, "302 Found"); + httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); + return httpd_resp_send(req, NULL, 0); +} + +static esp_err_t http_get_scan(httpd_req_t *req) +{ + wifi_scan_config_t scan_cfg = { + .ssid = NULL, + .bssid = NULL, + .channel = 0, + .show_hidden = true, + }; + + esp_err_t err = esp_wifi_scan_start(&scan_cfg, true); + if (err != ESP_OK) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Scan failed"); + return ESP_FAIL; + } + + uint16_t ap_count = 0; + esp_wifi_scan_get_ap_num(&ap_count); + if (ap_count > MIMI_ONBOARD_MAX_SCAN) ap_count = MIMI_ONBOARD_MAX_SCAN; + + wifi_ap_record_t *ap_list = calloc(ap_count, sizeof(wifi_ap_record_t)); + if (!ap_list) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM"); + return ESP_FAIL; + } + + uint16_t ap_max = ap_count; + esp_wifi_scan_get_ap_records(&ap_max, ap_list); + + cJSON *arr = cJSON_CreateArray(); + for (uint16_t i = 0; i < ap_max; i++) { + if (ap_list[i].ssid[0] == '\0') continue; /* skip hidden */ + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "ssid", (const char *)ap_list[i].ssid); + cJSON_AddNumberToObject(obj, "rssi", ap_list[i].rssi); + cJSON_AddNumberToObject(obj, "ch", ap_list[i].primary); + cJSON_AddBoolToObject(obj, "auth", ap_list[i].authmode != WIFI_AUTH_OPEN); + cJSON_AddItemToArray(arr, obj); + } + free(ap_list); + + char *json = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + + httpd_resp_set_type(req, "application/json"); + esp_err_t ret = httpd_resp_send(req, json, strlen(json)); + free(json); + return ret; +} + +/* Helper: save a single NVS string if json field is present and non-empty */ +static void nvs_save_field(cJSON *root, const char *json_key, + const char *ns, const char *nvs_key) +{ + cJSON *item = cJSON_GetObjectItem(root, json_key); + if (!item || !cJSON_IsString(item) || item->valuestring[0] == '\0') return; + + nvs_handle_t nvs; + if (nvs_open(ns, NVS_READWRITE, &nvs) == ESP_OK) { + nvs_set_str(nvs, nvs_key, item->valuestring); + nvs_commit(nvs); + nvs_close(nvs); + ESP_LOGI(TAG, "Saved %s/%s", ns, nvs_key); + } +} + +static esp_err_t http_post_save(httpd_req_t *req) +{ + int total_len = req->content_len; + if (total_len <= 0 || total_len > 2048) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad length"); + return ESP_FAIL; + } + + char *buf = calloc(1, total_len + 1); + if (!buf) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM"); + return ESP_FAIL; + } + + int received = 0; + while (received < total_len) { + int ret = httpd_req_recv(req, buf + received, total_len - received); + if (ret <= 0) { + free(buf); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Recv error"); + return ESP_FAIL; + } + received += ret; + } + + cJSON *root = cJSON_Parse(buf); + free(buf); + if (!root) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON"); + return ESP_FAIL; + } + + /* WiFi (required) */ + nvs_save_field(root, "ssid", MIMI_NVS_WIFI, MIMI_NVS_KEY_SSID); + nvs_save_field(root, "password", MIMI_NVS_WIFI, MIMI_NVS_KEY_PASS); + + /* LLM */ + nvs_save_field(root, "api_key", MIMI_NVS_LLM, MIMI_NVS_KEY_API_KEY); + nvs_save_field(root, "model", MIMI_NVS_LLM, MIMI_NVS_KEY_MODEL); + nvs_save_field(root, "provider", MIMI_NVS_LLM, MIMI_NVS_KEY_PROVIDER); + + /* Telegram */ + nvs_save_field(root, "tg_token", MIMI_NVS_TG, MIMI_NVS_KEY_TG_TOKEN); + + /* Feishu */ + nvs_save_field(root, "feishu_app_id", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_ID); + nvs_save_field(root, "feishu_app_secret", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_SECRET); + + /* Proxy */ + nvs_save_field(root, "proxy_host", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_HOST); + nvs_save_field(root, "proxy_port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT); + nvs_save_field(root, "proxy_type", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_TYPE); + + /* Search */ + nvs_save_field(root, "search_key", MIMI_NVS_SEARCH, "brave_key"); + nvs_save_field(root, "tavily_key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_TAVILY_KEY); + + cJSON_Delete(root); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"ok\":true}", 11); + + ESP_LOGI(TAG, "Configuration saved, restarting in 2s..."); + vTaskDelay(pdMS_TO_TICKS(2000)); + esp_restart(); + + return ESP_OK; /* unreachable */ +} + +/* ── Soft AP + HTTP server startup ──────────────────────────────── */ + +static esp_err_t start_softap(void) +{ + /* Get last 2 bytes of MAC for unique SSID suffix */ + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_SOFTAP); + char ssid[32]; + snprintf(ssid, sizeof(ssid), "%s%02X%02X", MIMI_ONBOARD_AP_PREFIX, mac[4], mac[5]); + + /* Create AP netif if not already present */ + static esp_netif_t *ap_netif = NULL; + if (!ap_netif) { + ap_netif = esp_netif_create_default_wifi_ap(); + } + + /* Switch to APSTA so we can scan while serving */ + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA)); + + wifi_config_t ap_cfg = { + .ap = { + .max_connection = 4, + .authmode = WIFI_AUTH_OPEN, + .channel = 1, + }, + }; + strncpy((char *)ap_cfg.ap.ssid, ssid, sizeof(ap_cfg.ap.ssid) - 1); + ap_cfg.ap.ssid_len = strlen(ssid); + + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_cfg)); + ESP_ERROR_CHECK(esp_wifi_start()); + + ESP_LOGI(TAG, "Soft AP started: %s (open)", ssid); + return ESP_OK; +} + +static httpd_handle_t start_http_server(void) +{ + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = MIMI_ONBOARD_HTTP_PORT; + config.max_uri_handlers = 10; + config.stack_size = 8192; + config.lru_purge_enable = true; + + httpd_handle_t server = NULL; + if (httpd_start(&server, &config) != ESP_OK) { + ESP_LOGE(TAG, "Failed to start HTTP server"); + return NULL; + } + + /* Main page */ + httpd_uri_t uri_root = { + .uri = "/", .method = HTTP_GET, .handler = http_get_root, + }; + httpd_register_uri_handler(server, &uri_root); + + /* WiFi scan */ + httpd_uri_t uri_scan = { + .uri = "/scan", .method = HTTP_GET, .handler = http_get_scan, + }; + httpd_register_uri_handler(server, &uri_scan); + + /* Save config */ + httpd_uri_t uri_save = { + .uri = "/save", .method = HTTP_POST, .handler = http_post_save, + }; + httpd_register_uri_handler(server, &uri_save); + + /* Captive portal detection endpoints */ + const char *captive_uris[] = { + "/generate_204", /* Android */ + "/gen_204", /* Android alt */ + "/hotspot-detect.html", /* iOS/macOS */ + "/library/test/success.html", /* iOS alt */ + "/connecttest.txt", /* Windows */ + "/redirect", /* Windows alt */ + }; + for (int i = 0; i < sizeof(captive_uris) / sizeof(captive_uris[0]); i++) { + httpd_uri_t uri_captive = { + .uri = captive_uris[i], + .method = HTTP_GET, + .handler = http_captive_redirect, + }; + httpd_register_uri_handler(server, &uri_captive); + } + + ESP_LOGI(TAG, "HTTP server started on port %d", MIMI_ONBOARD_HTTP_PORT); + return server; +} + +/* ── Public API ─────────────────────────────────────────────────── */ + +esp_err_t wifi_onboard_start(void) +{ + ESP_LOGI(TAG, "========================================"); + ESP_LOGI(TAG, " Starting WiFi Onboarding Portal"); + ESP_LOGI(TAG, "========================================"); + + /* Stop STA if it was running */ + wifi_manager_stop(); + + /* Start soft AP */ + esp_err_t err = start_softap(); + if (err != ESP_OK) return err; + + /* Start DNS hijack task */ + xTaskCreate(dns_hijack_task, "dns_hijack", + MIMI_ONBOARD_DNS_STACK, NULL, 5, NULL); + + /* Start HTTP server */ + httpd_handle_t server = start_http_server(); + if (!server) return ESP_FAIL; + + ESP_LOGI(TAG, "Connect to MimiClaw-XXXX WiFi, then open http://192.168.4.1"); + + /* Block forever — onboarding ends with esp_restart() in /save handler */ + while (1) { + vTaskDelay(pdMS_TO_TICKS(1000)); + } + + return ESP_OK; /* unreachable */ +} diff --git a/main/onboard/wifi_onboard.h b/main/onboard/wifi_onboard.h new file mode 100644 index 0000000..79b53b3 --- /dev/null +++ b/main/onboard/wifi_onboard.h @@ -0,0 +1,10 @@ +#pragma once + +#include "esp_err.h" + +/** + * Start WiFi onboarding captive portal. + * Opens a soft AP, DNS hijacker, and HTTP configuration server. + * Blocks until the user submits credentials, then saves to NVS and restarts. + */ +esp_err_t wifi_onboard_start(void); diff --git a/main/wifi/wifi_manager.c b/main/wifi/wifi_manager.c index 1ccbab2..0bb34b6 100644 --- a/main/wifi/wifi_manager.c +++ b/main/wifi/wifi_manager.c @@ -85,8 +85,6 @@ esp_err_t wifi_manager_init(void) ESP_ERROR_CHECK(esp_event_handler_instance_register( IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, NULL)); - ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); - ESP_LOGI(TAG, "WiFi manager initialized"); return ESP_OK; } @@ -124,6 +122,7 @@ esp_err_t wifi_manager_start(void) ESP_LOGI(TAG, "Connecting to SSID: %s", wifi_cfg.sta.ssid); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg)); ESP_ERROR_CHECK(esp_wifi_start()); @@ -232,3 +231,32 @@ void wifi_manager_scan_and_print(void) free(ap_list); esp_wifi_connect(); } + +bool wifi_manager_has_credentials(void) +{ + /* Check NVS first */ + nvs_handle_t nvs; + if (nvs_open(MIMI_NVS_WIFI, NVS_READONLY, &nvs) == ESP_OK) { + char ssid[33] = {0}; + size_t len = sizeof(ssid); + esp_err_t err = nvs_get_str(nvs, MIMI_NVS_KEY_SSID, ssid, &len); + nvs_close(nvs); + if (err == ESP_OK && ssid[0] != '\0') return true; + } + + /* Fall back to build-time secrets */ + if (MIMI_SECRET_WIFI_SSID[0] != '\0') return true; + + return false; +} + +esp_err_t wifi_manager_stop(void) +{ + esp_wifi_disconnect(); + esp_wifi_stop(); + s_connected = false; + s_retry_count = 0; + xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT); + ESP_LOGI(TAG, "WiFi stopped"); + return ESP_OK; +} diff --git a/main/wifi/wifi_manager.h b/main/wifi/wifi_manager.h index 5f2b746..d25e316 100644 --- a/main/wifi/wifi_manager.h +++ b/main/wifi/wifi_manager.h @@ -49,3 +49,13 @@ EventGroupHandle_t wifi_manager_get_event_group(void); * Scan and print nearby APs. */ void wifi_manager_scan_and_print(void); + +/** + * Check if WiFi credentials exist (NVS or build-time secrets). + */ +bool wifi_manager_has_credentials(void); + +/** + * Stop WiFi (for mode switching during onboarding). + */ +esp_err_t wifi_manager_stop(void);