Merge pull request #131 from memovai/feat/wifi-onboarding-captive-portal
This commit is contained in:
@@ -21,6 +21,7 @@ idf_component_register(
|
|||||||
"tools/tool_get_time.c"
|
"tools/tool_get_time.c"
|
||||||
"tools/tool_files.c"
|
"tools/tool_files.c"
|
||||||
"skills/skill_loader.c"
|
"skills/skill_loader.c"
|
||||||
|
"onboard/wifi_onboard.c"
|
||||||
INCLUDE_DIRS
|
INCLUDE_DIRS
|
||||||
"."
|
"."
|
||||||
REQUIRES
|
REQUIRES
|
||||||
|
|||||||
@@ -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)
|
static int cmd_config_show(int argc, char **argv)
|
||||||
{
|
{
|
||||||
printf("=== Current Configuration ===\n");
|
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("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("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 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("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);
|
print_config("Tavily Key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_TAVILY_KEY, MIMI_SECRET_TAVILY_KEY, true);
|
||||||
printf("=============================\n");
|
printf("=============================\n");
|
||||||
|
|||||||
53
main/mimi.c
53
main/mimi.c
@@ -25,6 +25,7 @@
|
|||||||
#include "cron/cron_service.h"
|
#include "cron/cron_service.h"
|
||||||
#include "heartbeat/heartbeat.h"
|
#include "heartbeat/heartbeat.h"
|
||||||
#include "skills/skill_loader.h"
|
#include "skills/skill_loader.h"
|
||||||
|
#include "onboard/wifi_onboard.h"
|
||||||
|
|
||||||
static const char *TAG = "mimi";
|
static const char *TAG = "mimi";
|
||||||
|
|
||||||
@@ -141,34 +142,48 @@ void app_main(void)
|
|||||||
|
|
||||||
/* Start WiFi */
|
/* Start WiFi */
|
||||||
esp_err_t wifi_err = wifi_manager_start();
|
esp_err_t wifi_err = wifi_manager_start();
|
||||||
|
bool wifi_ok = false;
|
||||||
if (wifi_err == ESP_OK) {
|
if (wifi_err == ESP_OK) {
|
||||||
ESP_LOGI(TAG, "Scanning nearby APs on boot...");
|
ESP_LOGI(TAG, "Scanning nearby APs on boot...");
|
||||||
wifi_manager_scan_and_print();
|
wifi_manager_scan_and_print();
|
||||||
ESP_LOGI(TAG, "Waiting for WiFi connection...");
|
ESP_LOGI(TAG, "Waiting for WiFi connection...");
|
||||||
if (wifi_manager_wait_connected(30000) == ESP_OK) {
|
if (wifi_manager_wait_connected(30000) == ESP_OK) {
|
||||||
|
wifi_ok = true;
|
||||||
ESP_LOGI(TAG, "WiFi connected: %s", wifi_manager_get_ip());
|
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 {
|
} else {
|
||||||
ESP_LOGW(TAG, "WiFi connection timeout. Check MIMI_SECRET_WIFI_SSID in mimi_secrets.h");
|
ESP_LOGW(TAG, "WiFi connection timeout");
|
||||||
}
|
}
|
||||||
} else {
|
} 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.");
|
ESP_LOGI(TAG, "MimiClaw ready. Type 'help' for CLI commands.");
|
||||||
|
|||||||
@@ -149,3 +149,11 @@
|
|||||||
#define MIMI_NVS_KEY_PROVIDER "provider"
|
#define MIMI_NVS_KEY_PROVIDER "provider"
|
||||||
#define MIMI_NVS_KEY_PROXY_HOST "host"
|
#define MIMI_NVS_KEY_PROXY_HOST "host"
|
||||||
#define MIMI_NVS_KEY_PROXY_PORT "port"
|
#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
|
||||||
|
|||||||
147
main/onboard/onboard_html.h
Normal file
147
main/onboard/onboard_html.h
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
static const char ONBOARD_HTML[] =
|
||||||
|
"<!DOCTYPE html><html><head>"
|
||||||
|
"<meta charset='utf-8'>"
|
||||||
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||||
|
"<title>MimiClaw Setup</title>"
|
||||||
|
"<style>"
|
||||||
|
"*{box-sizing:border-box;margin:0;padding:0}"
|
||||||
|
"body{font-family:-apple-system,sans-serif;background:#f5f5f5;color:#333;padding:16px;max-width:480px;margin:0 auto}"
|
||||||
|
"h1{text-align:center;margin:16px 0;font-size:1.4em;color:#1a73e8}"
|
||||||
|
".card{background:#fff;border-radius:12px;margin:12px 0;box-shadow:0 1px 3px rgba(0,0,0,.12);overflow:hidden}"
|
||||||
|
".card-hdr{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;cursor:pointer;user-select:none;font-weight:600;font-size:.95em}"
|
||||||
|
".card-hdr::after{content:'\\25BC';font-size:.7em;transition:transform .2s}"
|
||||||
|
".card.collapsed .card-hdr::after{transform:rotate(-90deg)}"
|
||||||
|
".card.collapsed .card-body{display:none}"
|
||||||
|
".card-body{padding:0 16px 16px}"
|
||||||
|
"label{display:block;margin:10px 0 4px;font-size:.85em;color:#555}"
|
||||||
|
"input,select{width:100%;padding:10px 12px;border:1px solid #ddd;border-radius:8px;font-size:.95em;outline:none}"
|
||||||
|
"input:focus,select:focus{border-color:#1a73e8}"
|
||||||
|
".btn{display:block;width:100%;padding:12px;border:none;border-radius:8px;font-size:1em;font-weight:600;cursor:pointer;margin:8px 0}"
|
||||||
|
".btn-scan{background:#e8f0fe;color:#1a73e8}"
|
||||||
|
".btn-save{background:#1a73e8;color:#fff;margin-top:20px;font-size:1.1em}"
|
||||||
|
".btn:active{opacity:.8}"
|
||||||
|
".ap-list{max-height:200px;overflow-y:auto;border:1px solid #ddd;border-radius:8px;margin:8px 0}"
|
||||||
|
".ap-item{padding:10px 12px;border-bottom:1px solid #eee;cursor:pointer;display:flex;justify-content:space-between}"
|
||||||
|
".ap-item:last-child{border-bottom:none}"
|
||||||
|
".ap-item:active{background:#e8f0fe}"
|
||||||
|
".ap-rssi{color:#888;font-size:.85em}"
|
||||||
|
".ap-lock::before{content:'\\1F512';font-size:.75em;margin-right:4px}"
|
||||||
|
".status{text-align:center;padding:20px;color:#1a73e8;font-size:1.1em;display:none}"
|
||||||
|
"</style></head><body>"
|
||||||
|
"<h1>MimiClaw Setup</h1>"
|
||||||
|
"<p style='text-align:center;color:#666;font-size:.9em;margin-bottom:12px'>"
|
||||||
|
"This local portal remains available at 192.168.4.1 for later updates."
|
||||||
|
"</p>"
|
||||||
|
|
||||||
|
/* WiFi section (expanded by default) */
|
||||||
|
"<div class='card' id='sec-wifi'>"
|
||||||
|
"<div class='card-hdr' onclick='toggle(this)'>WiFi Configuration</div>"
|
||||||
|
"<div class='card-body'>"
|
||||||
|
"<button class='btn btn-scan' onclick='scan()'>Scan WiFi Networks</button>"
|
||||||
|
"<div class='ap-list' id='ap-list' style='display:none'></div>"
|
||||||
|
"<label>SSID</label>"
|
||||||
|
"<input id='ssid' placeholder='WiFi network name'>"
|
||||||
|
"<label>Password</label>"
|
||||||
|
"<input id='password' type='password' placeholder='WiFi password'>"
|
||||||
|
"</div></div>"
|
||||||
|
|
||||||
|
/* LLM section */
|
||||||
|
"<div class='card collapsed' id='sec-llm'>"
|
||||||
|
"<div class='card-hdr' onclick='toggle(this)'>LLM Configuration</div>"
|
||||||
|
"<div class='card-body'>"
|
||||||
|
"<label>API Key</label>"
|
||||||
|
"<input id='api_key' type='password' placeholder='sk-...'>"
|
||||||
|
"<label>Model</label>"
|
||||||
|
"<input id='model' placeholder='claude-opus-4-5' value='claude-opus-4-5'>"
|
||||||
|
"<label>Provider</label>"
|
||||||
|
"<select id='provider'>"
|
||||||
|
"<option value='anthropic'>Anthropic</option>"
|
||||||
|
"<option value='openai'>OpenAI</option>"
|
||||||
|
"</select>"
|
||||||
|
"</div></div>"
|
||||||
|
|
||||||
|
/* Telegram section */
|
||||||
|
"<div class='card collapsed' id='sec-tg'>"
|
||||||
|
"<div class='card-hdr' onclick='toggle(this)'>Telegram Bot</div>"
|
||||||
|
"<div class='card-body'>"
|
||||||
|
"<label>Bot Token</label>"
|
||||||
|
"<input id='tg_token' placeholder='123456:ABC-DEF...'>"
|
||||||
|
"</div></div>"
|
||||||
|
|
||||||
|
/* Feishu section */
|
||||||
|
"<div class='card collapsed' id='sec-feishu'>"
|
||||||
|
"<div class='card-hdr' onclick='toggle(this)'>Feishu</div>"
|
||||||
|
"<div class='card-body'>"
|
||||||
|
"<label>App ID</label>"
|
||||||
|
"<input id='feishu_app_id' placeholder='cli_xxxx'>"
|
||||||
|
"<label>App Secret</label>"
|
||||||
|
"<input id='feishu_app_secret' type='password' placeholder='App Secret'>"
|
||||||
|
"</div></div>"
|
||||||
|
|
||||||
|
/* Proxy section */
|
||||||
|
"<div class='card collapsed' id='sec-proxy'>"
|
||||||
|
"<div class='card-hdr' onclick='toggle(this)'>Proxy</div>"
|
||||||
|
"<div class='card-body'>"
|
||||||
|
"<label>Host</label>"
|
||||||
|
"<input id='proxy_host' placeholder='192.168.1.100'>"
|
||||||
|
"<label>Port</label>"
|
||||||
|
"<input id='proxy_port' type='number' placeholder='7890'>"
|
||||||
|
"<label>Type</label>"
|
||||||
|
"<select id='proxy_type'>"
|
||||||
|
"<option value=''>None</option>"
|
||||||
|
"<option value='http'>HTTP</option>"
|
||||||
|
"<option value='socks5'>SOCKS5</option>"
|
||||||
|
"</select>"
|
||||||
|
"</div></div>"
|
||||||
|
|
||||||
|
/* Search section */
|
||||||
|
"<div class='card collapsed' id='sec-search'>"
|
||||||
|
"<div class='card-hdr' onclick='toggle(this)'>Search</div>"
|
||||||
|
"<div class='card-body'>"
|
||||||
|
"<label>Brave Search API Key</label>"
|
||||||
|
"<input id='search_key' type='password' placeholder='BSA...'>"
|
||||||
|
"<label>Tavily API Key</label>"
|
||||||
|
"<input id='tavily_key' type='password' placeholder='tvly-...'>"
|
||||||
|
"</div></div>"
|
||||||
|
|
||||||
|
"<button class='btn btn-save' onclick='save()'>Save & Restart</button>"
|
||||||
|
"<div class='status' id='status'>Saving... Device will restart.</div>"
|
||||||
|
|
||||||
|
"<script>"
|
||||||
|
"function toggle(el){"
|
||||||
|
"el.parentElement.classList.toggle('collapsed')}"
|
||||||
|
|
||||||
|
"function loadConfig(){"
|
||||||
|
"fetch('/config').then(r=>r.json()).then(cfg=>{"
|
||||||
|
"Object.keys(cfg).forEach(k=>{"
|
||||||
|
"var el=document.getElementById(k);"
|
||||||
|
"if(el && cfg[k] !== undefined && cfg[k] !== null){el.value=cfg[k]}"
|
||||||
|
"})"
|
||||||
|
"}).catch(()=>{})}"
|
||||||
|
|
||||||
|
"function scan(){"
|
||||||
|
"var btn=event.target;btn.textContent='Scanning...';btn.disabled=true;"
|
||||||
|
"fetch('/scan').then(r=>r.json()).then(list=>{"
|
||||||
|
"var el=document.getElementById('ap-list');el.style.display='block';el.innerHTML='';"
|
||||||
|
"list.forEach(ap=>{"
|
||||||
|
"var d=document.createElement('div');d.className='ap-item';"
|
||||||
|
"d.innerHTML='<span>'+(ap.auth?'<span class=ap-lock></span>':'')+ap.ssid+'</span><span class=ap-rssi>'+ap.rssi+' dBm</span>';"
|
||||||
|
"d.onclick=function(){document.getElementById('ssid').value=ap.ssid};"
|
||||||
|
"el.appendChild(d)});"
|
||||||
|
"btn.textContent='Scan WiFi Networks';btn.disabled=false;"
|
||||||
|
"}).catch(()=>{btn.textContent='Scan WiFi Networks';btn.disabled=false})}"
|
||||||
|
|
||||||
|
"function save(){"
|
||||||
|
"var fields=['ssid','password','api_key','model','provider','tg_token',"
|
||||||
|
"'feishu_app_id','feishu_app_secret','proxy_host','proxy_port','proxy_type','search_key','tavily_key'];"
|
||||||
|
"var data={};"
|
||||||
|
"fields.forEach(f=>{data[f]=document.getElementById(f).value.trim()});"
|
||||||
|
"document.getElementById('status').style.display='block';"
|
||||||
|
"fetch('/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})"
|
||||||
|
".then(()=>{document.getElementById('status').textContent='Saved! Restarting...';})"
|
||||||
|
".catch(()=>{document.getElementById('status').textContent='Error. Please try again.';})}"
|
||||||
|
"loadConfig();"
|
||||||
|
"</script>"
|
||||||
|
"</body></html>";
|
||||||
525
main/onboard/wifi_onboard.c
Normal file
525
main/onboard/wifi_onboard.c
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
#include "wifi_onboard.h"
|
||||||
|
#include "onboard_html.h"
|
||||||
|
#include "mimi_config.h"
|
||||||
|
#include "wifi/wifi_manager.h"
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.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";
|
||||||
|
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 */
|
||||||
|
}
|
||||||
15
main/onboard/wifi_onboard.h
Normal file
15
main/onboard/wifi_onboard.h
Normal file
@@ -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);
|
||||||
@@ -15,6 +15,7 @@ static EventGroupHandle_t s_wifi_event_group;
|
|||||||
static int s_retry_count = 0;
|
static int s_retry_count = 0;
|
||||||
static char s_ip_str[16] = "0.0.0.0";
|
static char s_ip_str[16] = "0.0.0.0";
|
||||||
static bool s_connected = false;
|
static bool s_connected = false;
|
||||||
|
static bool s_reconnect_enabled = true;
|
||||||
|
|
||||||
static const char *wifi_reason_to_str(wifi_err_reason_t reason)
|
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) {
|
if (disc) {
|
||||||
ESP_LOGW(TAG, "Disconnected (reason=%d:%s)", disc->reason, wifi_reason_to_str(disc->reason));
|
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 */
|
/* Exponential backoff: 1s, 2s, 4s, 8s, ... capped at 30s */
|
||||||
uint32_t delay_ms = MIMI_WIFI_RETRY_BASE_MS << s_retry_count;
|
uint32_t delay_ms = MIMI_WIFI_RETRY_BASE_MS << s_retry_count;
|
||||||
if (delay_ms > MIMI_WIFI_RETRY_MAX_MS) {
|
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(
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(
|
||||||
IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, NULL));
|
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");
|
ESP_LOGI(TAG, "WiFi manager initialized");
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
@@ -122,8 +121,10 @@ esp_err_t wifi_manager_start(void)
|
|||||||
return ESP_ERR_NOT_FOUND;
|
return ESP_ERR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s_reconnect_enabled = true;
|
||||||
ESP_LOGI(TAG, "Connecting to SSID: %s", wifi_cfg.sta.ssid);
|
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_set_config(WIFI_IF_STA, &wifi_cfg));
|
||||||
ESP_ERROR_CHECK(esp_wifi_start());
|
ESP_ERROR_CHECK(esp_wifi_start());
|
||||||
|
|
||||||
@@ -232,3 +233,41 @@ void wifi_manager_scan_and_print(void)
|
|||||||
free(ap_list);
|
free(ap_list);
|
||||||
esp_wifi_connect();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,3 +49,18 @@ EventGroupHandle_t wifi_manager_get_event_group(void);
|
|||||||
* Scan and print nearby APs.
|
* Scan and print nearby APs.
|
||||||
*/
|
*/
|
||||||
void wifi_manager_scan_and_print(void);
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user