Multi-SSID Wi-Fi with a saved-network list; fix could-not-save
Wi-Fi credentials now live in the widget's own NVS (up to 8 networks) instead of the driver's single slot. The save handler just appends to the list and reboots, so it no longer calls esp_wifi_set_config in APSTA mode -- the source of the intermittent could-not-save. A connect task cycles the saved networks newest-first and holds the first that connects, re-scanning on drop, so the widget follows you between locations. The portal lists saved networks with a Forget button and an add form. An existing driver-stored credential is migrated on first boot. Verified end to end over the LAN: save persists across reboot, forget removes.
This commit is contained in:
1 parent
78df0e4163
commit
222888bf1f
2 files changed
+197
-56
No files matched your search
@@ -43,7 +43,11 @@ and stores its choices in NVS.
|
||||
|
||||
With no saved network the widget opens the open hotspot **insecam-setup**;
|
||||
join it with a phone and the captive portal (or `http://192.168.4.1`) asks for
|
||||
your Wi-Fi. Holding the knob down while the widget starts forces the portal.
|
||||
your Wi-Fi. It remembers up to 8 networks and tries them newest-first at boot,
|
||||
so the widget follows you between locations; the portal lists the saved ones
|
||||
with a **Forget** button and a form to add another. Holding the knob down while
|
||||
the widget starts, or choosing **Wi-Fi setup** in the menu, reopens the portal
|
||||
even when already connected.
|
||||
|
||||
## Controls
|
||||
|
||||
|
||||
+192
-55
@@ -4,35 +4,93 @@
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "lwip/sockets.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/task.h"
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "net";
|
||||
#define BIT_CONNECTED 1
|
||||
#define MAX_NETS 8
|
||||
#define ATTEMPT_MS 9000
|
||||
|
||||
typedef struct { char ssid[33]; char pass[65]; } cred_t;
|
||||
|
||||
static EventGroupHandle_t events;
|
||||
static esp_timer_handle_t reconnect_timer;
|
||||
static char ip_str[16] = "0.0.0.0";
|
||||
static bool portal_started;
|
||||
|
||||
static void reconnect_cb(void *arg) { esp_wifi_connect(); }
|
||||
static cred_t nets[MAX_NETS];
|
||||
static int nets_count;
|
||||
static nvs_handle_t nvs;
|
||||
|
||||
// ---------------------------------------------------------------- saved networks
|
||||
|
||||
static void nets_load(void)
|
||||
{
|
||||
uint8_t n = 0;
|
||||
nvs_get_u8(nvs, "cnt", &n);
|
||||
if (n > MAX_NETS) n = MAX_NETS;
|
||||
nets_count = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
char key[8];
|
||||
snprintf(key, sizeof key, "n%d", i);
|
||||
size_t len = sizeof(cred_t);
|
||||
if (nvs_get_blob(nvs, key, &nets[nets_count], &len) == ESP_OK && len == sizeof(cred_t) && nets[nets_count].ssid[0])
|
||||
nets_count++;
|
||||
}
|
||||
}
|
||||
|
||||
static void nets_save(void)
|
||||
{
|
||||
for (int i = 0; i < MAX_NETS; i++) {
|
||||
char key[8];
|
||||
snprintf(key, sizeof key, "n%d", i);
|
||||
if (i < nets_count) nvs_set_blob(nvs, key, &nets[i], sizeof(cred_t));
|
||||
else nvs_erase_key(nvs, key);
|
||||
}
|
||||
nvs_set_u8(nvs, "cnt", nets_count);
|
||||
nvs_commit(nvs);
|
||||
}
|
||||
|
||||
// Newest network goes to the front (tried first); a repeat SSID just updates its password.
|
||||
static void nets_add(const char *ssid, const char *pass)
|
||||
{
|
||||
int existing = -1;
|
||||
for (int i = 0; i < nets_count; i++) if (!strcmp(nets[i].ssid, ssid)) existing = i;
|
||||
cred_t c = {0};
|
||||
strlcpy(c.ssid, ssid, sizeof c.ssid);
|
||||
strlcpy(c.pass, pass, sizeof c.pass);
|
||||
if (existing >= 0) {
|
||||
memmove(&nets[1], &nets[0], existing * sizeof(cred_t));
|
||||
} else {
|
||||
if (nets_count < MAX_NETS) nets_count++;
|
||||
memmove(&nets[1], &nets[0], (nets_count - 1) * sizeof(cred_t));
|
||||
}
|
||||
nets[0] = c;
|
||||
nets_save();
|
||||
}
|
||||
|
||||
static void nets_forget(int idx)
|
||||
{
|
||||
if (idx < 0 || idx >= nets_count) return;
|
||||
memmove(&nets[idx], &nets[idx + 1], (nets_count - idx - 1) * sizeof(cred_t));
|
||||
nets_count--;
|
||||
nets_save();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- station
|
||||
|
||||
static void wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||
{
|
||||
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
|
||||
esp_wifi_connect();
|
||||
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
wifi_event_sta_disconnected_t *d = data;
|
||||
xEventGroupClearBits(events, BIT_CONNECTED);
|
||||
ESP_LOGW(TAG, "disconnected (reason %d), retrying", d->reason);
|
||||
esp_timer_start_once(reconnect_timer, 2000 * 1000);
|
||||
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
xEventGroupClearBits(events, BIT_CONNECTED); // the connect task drives retries
|
||||
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
|
||||
ip_event_got_ip_t *e = data;
|
||||
snprintf(ip_str, sizeof ip_str, IPSTR, IP2STR(&e->ip_info.ip));
|
||||
@@ -41,6 +99,33 @@ static void wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle the saved networks, newest first, until one connects; hold it until it drops.
|
||||
static void wifi_task(void *arg)
|
||||
{
|
||||
while (1) {
|
||||
if (nets_count == 0) { vTaskDelay(pdMS_TO_TICKS(1000)); continue; }
|
||||
bool connected = false;
|
||||
for (int i = 0; i < nets_count && !connected; i++) {
|
||||
wifi_config_t cfg = {0};
|
||||
strlcpy((char *)cfg.sta.ssid, nets[i].ssid, sizeof cfg.sta.ssid);
|
||||
strlcpy((char *)cfg.sta.password, nets[i].pass, sizeof cfg.sta.password);
|
||||
cfg.sta.threshold.authmode = nets[i].pass[0] ? WIFI_AUTH_WPA_PSK : WIFI_AUTH_OPEN;
|
||||
esp_wifi_disconnect();
|
||||
xEventGroupClearBits(events, BIT_CONNECTED);
|
||||
if (esp_wifi_set_config(WIFI_IF_STA, &cfg) != ESP_OK) continue;
|
||||
ESP_LOGI(TAG, "trying \"%s\"", nets[i].ssid);
|
||||
if (esp_wifi_connect() != ESP_OK) { vTaskDelay(pdMS_TO_TICKS(300)); continue; }
|
||||
if (xEventGroupWaitBits(events, BIT_CONNECTED, pdFALSE, pdFALSE, pdMS_TO_TICKS(ATTEMPT_MS)) & BIT_CONNECTED) {
|
||||
ESP_LOGI(TAG, "connected to \"%s\"", nets[i].ssid);
|
||||
connected = true;
|
||||
while (xEventGroupGetBits(events) & BIT_CONNECTED) vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
ESP_LOGW(TAG, "lost \"%s\", rescanning", nets[i].ssid);
|
||||
}
|
||||
}
|
||||
if (!connected) vTaskDelay(pdMS_TO_TICKS(3000)); // all failed; pause before another sweep
|
||||
}
|
||||
}
|
||||
|
||||
void net_init(void)
|
||||
{
|
||||
esp_err_t err = nvs_flash_init();
|
||||
@@ -49,6 +134,8 @@ void net_init(void)
|
||||
err = nvs_flash_init();
|
||||
}
|
||||
ESP_ERROR_CHECK(err);
|
||||
ESP_ERROR_CHECK(nvs_open("wifinets", NVS_READWRITE, &nvs));
|
||||
nets_load();
|
||||
|
||||
events = xEventGroupCreate();
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
@@ -58,27 +145,28 @@ void net_init(void)
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_FLASH));
|
||||
esp_timer_create_args_t targs = {.callback = reconnect_cb, .name = "wifi_retry"};
|
||||
ESP_ERROR_CHECK(esp_timer_create(&targs, &reconnect_timer));
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event, NULL));
|
||||
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event, NULL));
|
||||
|
||||
// One-time migration: pull a credential saved by an older firmware (driver NVS) into the list.
|
||||
if (nets_count == 0) {
|
||||
wifi_config_t old = {0};
|
||||
if (esp_wifi_get_config(WIFI_IF_STA, &old) == ESP_OK && old.sta.ssid[0]) {
|
||||
nets_add((char *)old.sta.ssid, (char *)old.sta.password);
|
||||
ESP_LOGI(TAG, "migrated stored network \"%s\"", (char *)old.sta.ssid);
|
||||
}
|
||||
}
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); // the list above is the source of truth
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
|
||||
xTaskCreate(wifi_task, "wifi", 4096, NULL, 6, NULL);
|
||||
|
||||
wifi_config_t stored = {0};
|
||||
if (esp_wifi_get_config(WIFI_IF_STA, &stored) == ESP_OK && stored.sta.ssid[0])
|
||||
ESP_LOGI(TAG, "stored credentials for \"%s\"", (char *)stored.sta.ssid);
|
||||
else
|
||||
ESP_LOGW(TAG, "no stored Wi-Fi credentials");
|
||||
ESP_LOGI(TAG, "%d saved network%s", nets_count, nets_count == 1 ? "" : "s");
|
||||
}
|
||||
|
||||
bool net_has_credentials(void)
|
||||
{
|
||||
wifi_config_t stored = {0};
|
||||
return esp_wifi_get_config(WIFI_IF_STA, &stored) == ESP_OK && stored.sta.ssid[0];
|
||||
}
|
||||
bool net_has_credentials(void) { return nets_count > 0; }
|
||||
|
||||
bool net_wait_connected(int timeout_ms)
|
||||
{
|
||||
@@ -91,15 +179,6 @@ const char *net_ip_str(void) { return ip_str; }
|
||||
|
||||
// ---------------------------------------------------------------- captive portal
|
||||
|
||||
static const char *PAGE =
|
||||
"<!doctype html><meta name=viewport content='width=device-width'><title>insecam widget setup</title>"
|
||||
"<style>body{font:17px system-ui,sans-serif;background:#0e1116;color:#e6edf3;max-width:420px;margin:40px auto;padding:20px}"
|
||||
"input,button{box-sizing:border-box;width:100%;padding:12px;margin:8px 0;font-size:17px;border-radius:6px;border:1px solid #444}"
|
||||
"button{background:#2f81f7;color:#fff;border:0}label{display:block;margin-top:12px}</style>"
|
||||
"<h2>insecam desk widget</h2><p>Give the widget your Wi-Fi so it can reach the cameras.</p>"
|
||||
"<form method=post action=/save><label>Wi-Fi name<input name=ssid maxlength=32 required autocapitalize=off></label>"
|
||||
"<label>Password<input name=password type=password maxlength=63></label><button>Save and restart</button></form>";
|
||||
|
||||
static bool form_value(const char *body, const char *key, char *out, size_t cap)
|
||||
{
|
||||
size_t klen = strlen(key);
|
||||
@@ -129,14 +208,60 @@ static bool form_value(const char *body, const char *key, char *out, size_t cap)
|
||||
return false;
|
||||
}
|
||||
|
||||
static void send_escaped(httpd_req_t *req, const char *s)
|
||||
{
|
||||
char buf[128];
|
||||
size_t n = 0;
|
||||
for (; *s; s++) {
|
||||
const char *rep = *s == '&' ? "&" : *s == '<' ? "<" : *s == '>' ? ">" :
|
||||
*s == '"' ? """ : NULL;
|
||||
const char one[2] = {*s, 0};
|
||||
const char *piece = rep ? rep : one;
|
||||
size_t pl = strlen(piece);
|
||||
if (n + pl >= sizeof buf) { httpd_resp_send_chunk(req, buf, n); n = 0; }
|
||||
memcpy(buf + n, piece, pl);
|
||||
n += pl;
|
||||
}
|
||||
if (n) httpd_resp_send_chunk(req, buf, n);
|
||||
}
|
||||
|
||||
static esp_err_t page_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_type(req, "text/html");
|
||||
return httpd_resp_sendstr(req, PAGE);
|
||||
httpd_resp_sendstr_chunk(req,
|
||||
"<!doctype html><meta name=viewport content='width=device-width'><title>insecam widget setup</title>"
|
||||
"<style>body{font:17px system-ui,sans-serif;background:#0e1116;color:#e6edf3;max-width:440px;margin:32px auto;padding:20px}"
|
||||
"input,button{box-sizing:border-box;width:100%;padding:12px;margin:6px 0;font-size:17px;border-radius:6px;border:1px solid #444}"
|
||||
"button{background:#2f81f7;color:#fff;border:0}label{display:block;margin-top:12px}"
|
||||
"ul{list-style:none;padding:0}li{display:flex;align-items:center;justify-content:space-between;"
|
||||
"background:#161b22;border:1px solid #30363d;border-radius:6px;padding:8px 12px;margin:6px 0}"
|
||||
".f{width:auto;margin:0;padding:8px 12px;background:#8b2e2e}</style>"
|
||||
"<h2>insecam desk widget</h2>");
|
||||
|
||||
if (nets_count) {
|
||||
httpd_resp_sendstr_chunk(req, "<p>Saved networks (tried newest first):</p><ul>");
|
||||
for (int i = 0; i < nets_count; i++) {
|
||||
httpd_resp_sendstr_chunk(req, "<li><span>");
|
||||
send_escaped(req, nets[i].ssid);
|
||||
char btn[128];
|
||||
snprintf(btn, sizeof btn, "</span><form method=post action=/forget><input type=hidden name=i value=%d>"
|
||||
"<button class=f>Forget</button></form></li>", i);
|
||||
httpd_resp_sendstr_chunk(req, btn);
|
||||
}
|
||||
httpd_resp_sendstr_chunk(req, "</ul>");
|
||||
} else {
|
||||
httpd_resp_sendstr_chunk(req, "<p>Give the widget your Wi-Fi so it can reach the cameras.</p>");
|
||||
}
|
||||
|
||||
httpd_resp_sendstr_chunk(req,
|
||||
"<form method=post action=/save><label>Add a network<input name=ssid maxlength=32 required "
|
||||
"autocapitalize=off placeholder='Wi-Fi name'></label>"
|
||||
"<label><input name=password type=password maxlength=63 placeholder='Password (blank if open)'></label>"
|
||||
"<button>Save and restart</button></form>");
|
||||
httpd_resp_sendstr_chunk(req, NULL);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// Anything else (Android's /generate_204, Apple's /hotspot-detect.html, ...) bounces to the form,
|
||||
// which is what makes phones pop the "sign in to network" sheet.
|
||||
static esp_err_t redirect_handler(httpd_req_t *req)
|
||||
{
|
||||
httpd_resp_set_status(req, "302 Found");
|
||||
@@ -150,38 +275,47 @@ static void restart_task(void *arg)
|
||||
esp_restart();
|
||||
}
|
||||
|
||||
static esp_err_t save_handler(httpd_req_t *req)
|
||||
static bool read_body(httpd_req_t *req, char *body, size_t cap)
|
||||
{
|
||||
char body[512], ssid[33], password[65];
|
||||
if (req->content_len <= 0 || req->content_len >= sizeof body)
|
||||
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "bad form");
|
||||
if (req->content_len <= 0 || req->content_len >= cap) return false;
|
||||
size_t used = 0;
|
||||
while (used < req->content_len) {
|
||||
int n = httpd_req_recv(req, body + used, req->content_len - used);
|
||||
if (n <= 0) return ESP_FAIL;
|
||||
if (n <= 0) return false;
|
||||
used += n;
|
||||
}
|
||||
body[used] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
static esp_err_t save_handler(httpd_req_t *req)
|
||||
{
|
||||
char body[512], ssid[33], password[65];
|
||||
if (!read_body(req, body, sizeof body))
|
||||
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "bad form");
|
||||
if (!form_value(body, "ssid", ssid, sizeof ssid) || !ssid[0])
|
||||
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Wi-Fi name missing");
|
||||
form_value(body, "password", password, sizeof password);
|
||||
|
||||
wifi_config_t cfg = {0};
|
||||
memcpy(cfg.sta.ssid, ssid, strlen(ssid));
|
||||
memcpy(cfg.sta.password, password, strlen(password));
|
||||
cfg.sta.threshold.authmode = password[0] ? WIFI_AUTH_WPA_PSK : WIFI_AUTH_OPEN;
|
||||
nets_add(ssid, password);
|
||||
memset(body, 0, sizeof body);
|
||||
memset(password, 0, sizeof password);
|
||||
if (esp_wifi_set_config(WIFI_IF_STA, &cfg) != ESP_OK)
|
||||
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "could not save");
|
||||
memset(&cfg, 0, sizeof cfg);
|
||||
httpd_resp_set_type(req, "text/html");
|
||||
httpd_resp_sendstr(req, "<meta name=viewport content='width=device-width'><p style='font:20px sans-serif'>Saved. The widget is restarting.</p>");
|
||||
httpd_resp_sendstr(req, "<meta name=viewport content='width=device-width'>"
|
||||
"<p style='font:20px sans-serif'>Saved. The widget is restarting.</p>");
|
||||
xTaskCreate(restart_task, "restart", 2048, NULL, 2, NULL);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// Minimal DNS: answer every A query with 192.168.4.1 so captive-portal probes land on us.
|
||||
static esp_err_t forget_handler(httpd_req_t *req)
|
||||
{
|
||||
char body[64], idx[8];
|
||||
if (read_body(req, body, sizeof body) && form_value(body, "i", idx, sizeof idx))
|
||||
nets_forget(atoi(idx));
|
||||
httpd_resp_set_status(req, "303 See Other");
|
||||
httpd_resp_set_hdr(req, "Location", "/");
|
||||
return httpd_resp_sendstr(req, "");
|
||||
}
|
||||
|
||||
static void dns_task(void *arg)
|
||||
{
|
||||
int sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
|
||||
@@ -196,17 +330,17 @@ static void dns_task(void *arg)
|
||||
struct sockaddr_in from;
|
||||
socklen_t flen = sizeof from;
|
||||
int n = recvfrom(sock, buf, sizeof buf - 16, 0, (struct sockaddr *)&from, &flen);
|
||||
if (n < 12 || (buf[2] & 0x80)) continue; // not a query
|
||||
if (n < 12 || (buf[2] & 0x80)) continue;
|
||||
int qd = (buf[4] << 8) | buf[5];
|
||||
if (qd < 1) continue;
|
||||
int p = 12; // walk the first QNAME
|
||||
int p = 12;
|
||||
while (p < n && buf[p]) p += buf[p] + 1;
|
||||
if (p + 5 > n) continue;
|
||||
int qtype = (buf[p + 1] << 8) | buf[p + 2];
|
||||
int qend = p + 5;
|
||||
buf[2] = 0x84; buf[3] = 0x00; // response, authoritative, no error
|
||||
buf[4] = 0; buf[5] = 1; // one question
|
||||
buf[6] = 0; buf[7] = qtype == 1 ? 1 : 0; // one answer for A queries
|
||||
buf[2] = 0x84; buf[3] = 0x00;
|
||||
buf[4] = 0; buf[5] = 1;
|
||||
buf[6] = 0; buf[7] = qtype == 1 ? 1 : 0;
|
||||
buf[8] = buf[9] = buf[10] = buf[11] = 0;
|
||||
int len = qend;
|
||||
if (qtype == 1) {
|
||||
@@ -233,12 +367,15 @@ void net_start_portal(void)
|
||||
httpd_handle_t server;
|
||||
httpd_config_t hc = HTTPD_DEFAULT_CONFIG();
|
||||
hc.uri_match_fn = httpd_uri_match_wildcard;
|
||||
hc.max_uri_handlers = 12;
|
||||
ESP_ERROR_CHECK(httpd_start(&server, &hc));
|
||||
httpd_uri_t root = {.uri = "/", .method = HTTP_GET, .handler = page_handler};
|
||||
httpd_uri_t save = {.uri = "/save", .method = HTTP_POST, .handler = save_handler};
|
||||
httpd_uri_t forget = {.uri = "/forget", .method = HTTP_POST, .handler = forget_handler};
|
||||
httpd_uri_t any = {.uri = "/*", .method = HTTP_GET, .handler = redirect_handler};
|
||||
httpd_register_uri_handler(server, &root);
|
||||
httpd_register_uri_handler(server, &save);
|
||||
httpd_register_uri_handler(server, &forget);
|
||||
httpd_register_uri_handler(server, &any);
|
||||
xTaskCreate(dns_task, "dns", 3072, NULL, 3, NULL);
|
||||
ESP_LOGI(TAG, "setup hotspot \"%s\" at http://192.168.4.1", CONFIG_WIDGET_AP_SSID);
|
||||
|
||||
Reference in new issue
Block a user