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 <noreply@anthropic.com>
366 lines
12 KiB
C
366 lines
12 KiB
C
#include "wifi_onboard.h"
|
|
#include "onboard_html.h"
|
|
#include "mimi_config.h"
|
|
#include "wifi/wifi_manager.h"
|
|
|
|
#include <string.h>
|
|
#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 */
|
|
}
|