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/cli/serial_cli.c b/main/cli/serial_cli.c
index 4968ff7..a77fe28 100644
--- a/main/cli/serial_cli.c
+++ b/main/cli/serial_cli.c
@@ -528,6 +528,32 @@ static void print_config(const char *label, const char *ns, const char *key,
}
}
+static void print_config_u16(const char *label, const char *ns, const char *key,
+ const char *build_val)
+{
+ char nvs_val[16] = {0};
+ const char *source = "not set";
+ const char *display = "(empty)";
+
+ nvs_handle_t nvs;
+ if (nvs_open(ns, NVS_READONLY, &nvs) == ESP_OK) {
+ uint16_t value = 0;
+ if (nvs_get_u16(nvs, key, &value) == ESP_OK && value > 0) {
+ snprintf(nvs_val, sizeof(nvs_val), "%u", (unsigned)value);
+ source = "NVS";
+ display = nvs_val;
+ }
+ nvs_close(nvs);
+ }
+
+ if (strcmp(source, "not set") == 0 && build_val[0] != '\0') {
+ source = "build";
+ display = build_val;
+ }
+
+ printf(" %-14s: %s [%s]\n", label, display, source);
+}
+
static int cmd_config_show(int argc, char **argv)
{
printf("=== Current Configuration ===\n");
@@ -538,7 +564,7 @@ static int cmd_config_show(int argc, char **argv)
print_config("Model", MIMI_NVS_LLM, MIMI_NVS_KEY_MODEL, MIMI_SECRET_MODEL, false);
print_config("Provider", MIMI_NVS_LLM, MIMI_NVS_KEY_PROVIDER, MIMI_SECRET_MODEL_PROVIDER, false);
print_config("Proxy Host", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_HOST, MIMI_SECRET_PROXY_HOST, false);
- print_config("Proxy Port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT, MIMI_SECRET_PROXY_PORT, false);
+ print_config_u16("Proxy Port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT, MIMI_SECRET_PROXY_PORT);
print_config("Search Key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_SEARCH_KEY, true);
print_config("Tavily Key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_TAVILY_KEY, MIMI_SECRET_TAVILY_KEY, true);
printf("=============================\n");
diff --git a/main/mimi.c b/main/mimi.c
index 0e8e8fa..b949970 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,48 @@ 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(WIFI_ONBOARD_MODE_CAPTIVE); /* blocks, restarts on success */
+ return; /* unreachable */
+ }
+
+ if (wifi_onboard_start(WIFI_ONBOARD_MODE_ADMIN) != ESP_OK) {
+ ESP_LOGW(TAG, "Local admin portal unavailable; continuing without config hotspot");
+ }
+
+ {
+ /* 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..fbdbb03 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 "proxy_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..ac4fb02
--- /dev/null
+++ b/main/onboard/onboard_html.h
@@ -0,0 +1,147 @@
+#pragma once
+
+static const char ONBOARD_HTML[] =
+"
"
+""
+""
+"MimiClaw Setup"
+""
+"MimiClaw Setup
"
+""
+"This local portal remains available at 192.168.4.1 for later updates."
+"
"
+
+/* WiFi section (expanded by default) */
+""
+"
WiFi Configuration
"
+"
"
+"
"
+"
"
+"
"
+"
"
+"
"
+"
"
+"
"
+
+/* LLM section */
+""
+
+/* Telegram section */
+""
+"
Telegram Bot
"
+"
"
+""
+""
+"
"
+
+/* 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..6b00429
--- /dev/null
+++ b/main/onboard/wifi_onboard.c
@@ -0,0 +1,525 @@
+#include "wifi_onboard.h"
+#include "onboard_html.h"
+#include "mimi_config.h"
+#include "wifi/wifi_manager.h"
+
+#include
+#include
+#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";
+static httpd_handle_t s_server = NULL;
+static bool s_captive_mode = false;
+
+static void json_add_effective_config(cJSON *root, const char *json_key,
+ const char *ns, const char *nvs_key,
+ const char *build_val)
+{
+ char value[256] = {0};
+ bool found = false;
+
+ nvs_handle_t nvs;
+ if (nvs_open(ns, NVS_READONLY, &nvs) == ESP_OK) {
+ size_t len = sizeof(value);
+ if (nvs_get_str(nvs, nvs_key, value, &len) == ESP_OK) {
+ found = true;
+ }
+ nvs_close(nvs);
+ }
+
+ if (!found && build_val) {
+ strlcpy(value, build_val, sizeof(value));
+ }
+
+ cJSON_AddStringToObject(root, json_key, value);
+}
+
+static void json_add_effective_config_u16(cJSON *root, const char *json_key,
+ const char *ns, const char *nvs_key,
+ const char *build_val)
+{
+ char value[16] = {0};
+ bool found = false;
+
+ nvs_handle_t nvs;
+ if (nvs_open(ns, NVS_READONLY, &nvs) == ESP_OK) {
+ uint16_t port = 0;
+ if (nvs_get_u16(nvs, nvs_key, &port) == ESP_OK && port > 0) {
+ snprintf(value, sizeof(value), "%u", (unsigned)port);
+ found = true;
+ }
+ nvs_close(nvs);
+ }
+
+ if (!found && build_val) {
+ strlcpy(value, build_val, sizeof(value));
+ }
+
+ cJSON_AddStringToObject(root, json_key, value);
+}
+
+/* ── 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;
+}
+
+static esp_err_t http_get_config(httpd_req_t *req)
+{
+ cJSON *root = cJSON_CreateObject();
+ if (!root) {
+ httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM");
+ return ESP_FAIL;
+ }
+
+ json_add_effective_config(root, "ssid", MIMI_NVS_WIFI, MIMI_NVS_KEY_SSID, MIMI_SECRET_WIFI_SSID);
+ json_add_effective_config(root, "password", MIMI_NVS_WIFI, MIMI_NVS_KEY_PASS, MIMI_SECRET_WIFI_PASS);
+ json_add_effective_config(root, "api_key", MIMI_NVS_LLM, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_API_KEY);
+ json_add_effective_config(root, "model", MIMI_NVS_LLM, MIMI_NVS_KEY_MODEL, MIMI_SECRET_MODEL);
+ json_add_effective_config(root, "provider", MIMI_NVS_LLM, MIMI_NVS_KEY_PROVIDER, MIMI_SECRET_MODEL_PROVIDER);
+ json_add_effective_config(root, "tg_token", MIMI_NVS_TG, MIMI_NVS_KEY_TG_TOKEN, MIMI_SECRET_TG_TOKEN);
+ json_add_effective_config(root, "feishu_app_id", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_ID, MIMI_SECRET_FEISHU_APP_ID);
+ json_add_effective_config(root, "feishu_app_secret", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_SECRET, MIMI_SECRET_FEISHU_APP_SECRET);
+ json_add_effective_config(root, "proxy_host", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_HOST, MIMI_SECRET_PROXY_HOST);
+ json_add_effective_config_u16(root, "proxy_port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT, MIMI_SECRET_PROXY_PORT);
+ json_add_effective_config(root, "proxy_type", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_TYPE, MIMI_SECRET_PROXY_TYPE);
+ json_add_effective_config(root, "search_key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_SEARCH_KEY);
+ json_add_effective_config(root, "tavily_key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_TAVILY_KEY, MIMI_SECRET_TAVILY_KEY);
+
+ char *json = cJSON_PrintUnformatted(root);
+ cJSON_Delete(root);
+ if (!json) {
+ httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OOM");
+ return ESP_FAIL;
+ }
+
+ httpd_resp_set_type(req, "application/json");
+ httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
+ esp_err_t ret = httpd_resp_send(req, json, strlen(json));
+ free(json);
+ return ret;
+}
+
+/*
+ * Sync one JSON string field into NVS.
+ * - missing field: leave current NVS value unchanged
+ * - empty string: erase current NVS value
+ * - non-empty string: save/update current NVS value
+ */
+static void nvs_sync_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)) return;
+
+ nvs_handle_t nvs;
+ if (nvs_open(ns, NVS_READWRITE, &nvs) == ESP_OK) {
+ if (item->valuestring[0] == '\0') {
+ esp_err_t err = nvs_erase_key(nvs, nvs_key);
+ if (err == ESP_OK) {
+ ESP_LOGI(TAG, "Cleared %s/%s", ns, nvs_key);
+ } else if (err != ESP_ERR_NVS_NOT_FOUND) {
+ ESP_LOGW(TAG, "Failed clearing %s/%s: %s", ns, nvs_key, esp_err_to_name(err));
+ }
+ } else {
+ nvs_set_str(nvs, nvs_key, item->valuestring);
+ ESP_LOGI(TAG, "Saved %s/%s", ns, nvs_key);
+ }
+ nvs_commit(nvs);
+ nvs_close(nvs);
+ }
+}
+
+static void nvs_sync_u16_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)) return;
+
+ nvs_handle_t nvs;
+ if (nvs_open(ns, NVS_READWRITE, &nvs) == ESP_OK) {
+ if (item->valuestring[0] == '\0') {
+ esp_err_t err = nvs_erase_key(nvs, nvs_key);
+ if (err == ESP_OK) {
+ ESP_LOGI(TAG, "Cleared %s/%s", ns, nvs_key);
+ } else if (err != ESP_ERR_NVS_NOT_FOUND) {
+ ESP_LOGW(TAG, "Failed clearing %s/%s: %s", ns, nvs_key, esp_err_to_name(err));
+ }
+ } else {
+ char *end = NULL;
+ unsigned long value = strtoul(item->valuestring, &end, 10);
+ if (end == item->valuestring || *end != '\0' || value > UINT16_MAX) {
+ ESP_LOGW(TAG, "Ignoring invalid %s value: %s", json_key, item->valuestring);
+ } else {
+ ESP_ERROR_CHECK(nvs_set_u16(nvs, nvs_key, (uint16_t)value));
+ ESP_LOGI(TAG, "Saved %s/%s", ns, nvs_key);
+ }
+ }
+ nvs_commit(nvs);
+ nvs_close(nvs);
+ }
+}
+
+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_sync_field(root, "ssid", MIMI_NVS_WIFI, MIMI_NVS_KEY_SSID);
+ nvs_sync_field(root, "password", MIMI_NVS_WIFI, MIMI_NVS_KEY_PASS);
+
+ /* LLM */
+ nvs_sync_field(root, "api_key", MIMI_NVS_LLM, MIMI_NVS_KEY_API_KEY);
+ nvs_sync_field(root, "model", MIMI_NVS_LLM, MIMI_NVS_KEY_MODEL);
+ nvs_sync_field(root, "provider", MIMI_NVS_LLM, MIMI_NVS_KEY_PROVIDER);
+
+ /* Telegram */
+ nvs_sync_field(root, "tg_token", MIMI_NVS_TG, MIMI_NVS_KEY_TG_TOKEN);
+
+ /* Feishu */
+ nvs_sync_field(root, "feishu_app_id", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_ID);
+ nvs_sync_field(root, "feishu_app_secret", MIMI_NVS_FEISHU, MIMI_NVS_KEY_FEISHU_APP_SECRET);
+
+ /* Proxy */
+ nvs_sync_field(root, "proxy_host", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_HOST);
+ nvs_sync_u16_field(root, "proxy_port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT);
+ nvs_sync_field(root, "proxy_type", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_TYPE);
+
+ /* Search */
+ nvs_sync_field(root, "search_key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_API_KEY);
+ nvs_sync_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(bool keep_sta)
+{
+ /* 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();
+ }
+
+ /* APSTA lets the local config AP coexist with WiFi scanning/STA usage. */
+ (void)keep_sta;
+ 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_err_t err = esp_wifi_start();
+ if (err != ESP_OK && !(keep_sta && err == ESP_ERR_WIFI_CONN)) {
+ return err;
+ }
+
+ ESP_LOGI(TAG, "Soft AP started: %s (open)", ssid);
+ return ESP_OK;
+}
+
+static httpd_handle_t start_http_server(bool captive)
+{
+ if (s_server) {
+ if (captive && !s_captive_mode) {
+ ESP_LOGW(TAG, "HTTP server already running without captive redirects");
+ }
+ return s_server;
+ }
+
+ httpd_config_t config = HTTPD_DEFAULT_CONFIG();
+ config.server_port = MIMI_ONBOARD_HTTP_PORT;
+ config.max_uri_handlers = captive ? 16 : 8;
+ config.stack_size = 8192;
+ config.lru_purge_enable = true;
+
+ if (httpd_start(&s_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(s_server, &uri_root);
+
+ httpd_uri_t uri_config = {
+ .uri = "/config", .method = HTTP_GET, .handler = http_get_config,
+ };
+ httpd_register_uri_handler(s_server, &uri_config);
+
+ /* WiFi scan */
+ httpd_uri_t uri_scan = {
+ .uri = "/scan", .method = HTTP_GET, .handler = http_get_scan,
+ };
+ httpd_register_uri_handler(s_server, &uri_scan);
+
+ /* Save config */
+ httpd_uri_t uri_save = {
+ .uri = "/save", .method = HTTP_POST, .handler = http_post_save,
+ };
+ httpd_register_uri_handler(s_server, &uri_save);
+
+ if (captive) {
+ /* 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(s_server, &uri_captive);
+ }
+ }
+
+ s_captive_mode = captive;
+ ESP_LOGI(TAG, "HTTP server started on port %d", MIMI_ONBOARD_HTTP_PORT);
+ return s_server;
+}
+
+/* ── Public API ─────────────────────────────────────────────────── */
+
+esp_err_t wifi_onboard_start(wifi_onboard_mode_t mode)
+{
+ ESP_LOGI(TAG, "========================================");
+ ESP_LOGI(TAG, " Starting WiFi Configuration Portal");
+ ESP_LOGI(TAG, "========================================");
+
+ bool captive = (mode == WIFI_ONBOARD_MODE_CAPTIVE);
+ if (captive) {
+ /* Stop STA retries before starting captive portal. */
+ wifi_manager_set_reconnect_enabled(false);
+ wifi_manager_stop();
+ }
+
+ /* Start soft AP */
+ esp_err_t err = start_softap(!captive);
+ if (err != ESP_OK) return err;
+
+ if (captive) {
+ /* Start DNS hijack only for true captive portal mode. */
+ xTaskCreate(dns_hijack_task, "dns_hijack",
+ MIMI_ONBOARD_DNS_STACK, NULL, 5, NULL);
+ }
+
+ /* Start HTTP server */
+ httpd_handle_t server = start_http_server(captive);
+ if (!server) return ESP_FAIL;
+
+ ESP_LOGI(TAG, "Connect to MimiClaw-XXXX WiFi, then open http://192.168.4.1");
+
+ if (!captive) {
+ ESP_LOGI(TAG, "Local admin portal stays available while STA is connected");
+ return ESP_OK;
+ }
+
+ /* 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..a6e0d66
--- /dev/null
+++ b/main/onboard/wifi_onboard.h
@@ -0,0 +1,15 @@
+#pragma once
+
+#include "esp_err.h"
+
+typedef enum {
+ WIFI_ONBOARD_MODE_CAPTIVE = 0,
+ WIFI_ONBOARD_MODE_ADMIN,
+} wifi_onboard_mode_t;
+
+/**
+ * Start WiFi onboarding/configuration portal.
+ * CAPTIVE mode opens DNS hijack + config page and blocks forever.
+ * ADMIN mode keeps a local config hotspot alive without captive redirects.
+ */
+esp_err_t wifi_onboard_start(wifi_onboard_mode_t mode);
diff --git a/main/wifi/wifi_manager.c b/main/wifi/wifi_manager.c
index 1ccbab2..9ca967b 100644
--- a/main/wifi/wifi_manager.c
+++ b/main/wifi/wifi_manager.c
@@ -15,6 +15,7 @@ static EventGroupHandle_t s_wifi_event_group;
static int s_retry_count = 0;
static char s_ip_str[16] = "0.0.0.0";
static bool s_connected = false;
+static bool s_reconnect_enabled = true;
static const char *wifi_reason_to_str(wifi_err_reason_t reason)
{
@@ -44,7 +45,7 @@ static void event_handler(void *arg, esp_event_base_t event_base,
if (disc) {
ESP_LOGW(TAG, "Disconnected (reason=%d:%s)", disc->reason, wifi_reason_to_str(disc->reason));
}
- if (s_retry_count < MIMI_WIFI_MAX_RETRY) {
+ if (s_reconnect_enabled && s_retry_count < MIMI_WIFI_MAX_RETRY) {
/* Exponential backoff: 1s, 2s, 4s, 8s, ... capped at 30s */
uint32_t delay_ms = MIMI_WIFI_RETRY_BASE_MS << s_retry_count;
if (delay_ms > MIMI_WIFI_RETRY_MAX_MS) {
@@ -85,8 +86,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;
}
@@ -122,8 +121,10 @@ esp_err_t wifi_manager_start(void)
return ESP_ERR_NOT_FOUND;
}
+ s_reconnect_enabled = true;
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 +233,41 @@ 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)
+{
+ s_reconnect_enabled = false;
+ 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;
+}
+
+void wifi_manager_set_reconnect_enabled(bool enabled)
+{
+ s_reconnect_enabled = enabled;
+ if (!enabled) {
+ s_retry_count = 0;
+ }
+}
diff --git a/main/wifi/wifi_manager.h b/main/wifi/wifi_manager.h
index 5f2b746..e6f6661 100644
--- a/main/wifi/wifi_manager.h
+++ b/main/wifi/wifi_manager.h
@@ -49,3 +49,18 @@ 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);
+
+/**
+ * Enable or disable STA auto-reconnect on disconnect events.
+ */
+void wifi_manager_set_reconnect_enabled(bool enabled);