Skip to content

Commit 3c4b336

Browse files
author
Ravi Singh
committed
feat(netmgr+ui): Wi-Fi onboarding UX overhaul — modal flow, AP grace, all-channel scan
Bench-tested end-to-end on a fresh-NVS ESP32-C3: connect to AP, save home Wi-Fi creds, success modal shows IP + mDNS, AP drops within 8 s, device reachable on home network. All three reported issues fixed. Firmware (netmgr.c): - Replace vTaskDelay() inside Wi-Fi event handler with esp_timer-based STA retry. Calling vTaskDelay() inside the IDF event loop blocks every subsequent Wi-Fi event for the duration; under reconnect storms the stack falls behind and you see ghost states. Retry now runs on the esp_timer task. - Switch STA scan_method from WIFI_FAST_SCAN to WIFI_ALL_CHANNEL_SCAN with WIFI_CONNECT_AP_BY_SIGNAL. FAST_SCAN relies on a cached BSSID/channel that doesn't exist on first connect, so first-attempt joins were silently failing and only succeeding on the second try. ALL_CHANNEL takes ~3 s extra but works reliably first time. - Add 8 s AP teardown grace timer. After STA gets IP, AP stays up long enough for a captive-portal browser to poll /api/wifi and read the new STA IP / hostname before the AP disappears underneath it. - Drop AP max_connection from 6 to 4 (single-device — 6 was sized for the v6.0 mesh that's now gone). - Clean stale "peer-mesh devices need a stable channel" comments. Web UI (screens.tsx + styles.css): - New JoinModal component with three phases: connecting (spinner), success (both URLs + Copy buttons + auto-redirect instructions), failed (error + try-again). - Frontend polls /api/wifi every 1.5 s for up to 25 s after save; flips to success state as soon as sta_connected + IP are present; stops polling at success so AP teardown doesn't surface as errors. - Auto-scroll join card into view + auto-focus password field on tap. Enter key submits. - copyToClipboard helper with Clipboard API + execCommand fallback — modern browsers refuse navigator.clipboard on non-secure origins (we serve HTTP from a LAN IP), so the legacy textarea-execCommand path is required. - Reset Wi-Fi shows toast optimistically before firing the request, since the request always fails from the browser's side as STA drops. - Mobile layout: drop the 2-column hw-grid that overlapped on phones, full-width Hostname card. - Remove user-facing AP-mode picker. Behaviour is automatic (AP up while STA offline, off while STA connected). Keeps the firmware netmgr_ap_mode_t enum for future use without exposing it. - @Keyframes spinner added to styles.css. Build: - ambisense.bin 0x11c6a0 (~1.13 MB), 19% partition free - UI bundle 90.3 KB raw, 26.8 KB gzipped
1 parent b8ace4f commit 3c4b336

5 files changed

Lines changed: 333 additions & 54 deletions

File tree

firmware/components/netmgr/netmgr.c

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include "esp_netif.h"
1414
#include "esp_mac.h"
1515
#include "esp_random.h"
16+
#include "esp_timer.h"
1617
#include "freertos/FreeRTOS.h"
1718
#include "freertos/task.h"
1819
#include "freertos/event_groups.h"
@@ -26,6 +27,15 @@ static const char *TAG = "netmgr";
2627

2728
#define STA_RETRY_MAX 3
2829
#define STA_RETRY_BACKOFF_MS 3000
30+
/* Grace period after STA gets an IP before AUTO policy tears the AP
31+
* interface down. Lets a phone that's still on the captive portal
32+
* finish polling /api/wifi and read the device's new STA IP / hostname
33+
* before the AP disappears underneath it. The frontend's polling
34+
* cadence is 1.5 s so the success modal flips within ~3 s of GOT_IP;
35+
* 8 s grace gives the user 5+ s to read the URL — and the modal
36+
* persists in the browser even after the AP drops, so they can
37+
* continue to copy the URL without a live network. */
38+
#define AP_TEARDOWN_GRACE_US (8ULL * 1000ULL * 1000ULL)
2939

3040
static struct {
3141
netmgr_state_t state;
@@ -42,6 +52,12 @@ static struct {
4252
bool sta_configured; /* true if NVS has stored creds */
4353
netmgr_ap_mode_t ap_mode;
4454
TaskHandle_t dns_task;
55+
/* Deferred work — both timers run on the esp_timer task, NOT the
56+
* Wi-Fi event loop, so they can safely call esp_wifi_connect() and
57+
* esp_wifi_set_mode() which would deadlock if called from inside an
58+
* event handler. */
59+
esp_timer_handle_t sta_retry_timer; /* fires STA_RETRY_BACKOFF_MS after disconnect */
60+
esp_timer_handle_t ap_teardown_timer; /* fires AP_TEARDOWN_GRACE_US after STA gets IP */
4561
} s_net;
4662

4763
/* Decide whether the AP interface should be on right now.
@@ -201,21 +217,51 @@ static void stop_captive_dns_now(void) {
201217
s_net.dns_running = false;
202218
}
203219

220+
/* esp_timer callbacks — these run on the esp_timer task, OUTSIDE the
221+
* Wi-Fi event loop, so they may safely call esp_wifi_* functions. */
222+
static void sta_retry_timer_cb(void *arg) {
223+
(void)arg;
224+
if (s_net.state == NETMGR_STATE_STA_CONNECTING) {
225+
ESP_LOGI(TAG, "STA retry timer firing");
226+
esp_wifi_connect();
227+
}
228+
}
229+
230+
static void ap_teardown_timer_cb(void *arg) {
231+
(void)arg;
232+
/* Re-check policy at fire time — STA might have dropped during the
233+
* grace period, in which case we leave the AP up. */
234+
if (s_net.state == NETMGR_STATE_STA_CONNECTED && !ap_should_be_on()) {
235+
ESP_LOGI(TAG, "AP teardown grace period elapsed; powering AP down per policy");
236+
apply_ap_state(false);
237+
}
238+
}
239+
204240
static void on_wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data) {
205241
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
206242
esp_wifi_connect();
207243
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
208244
bool was_connected = (s_net.state == NETMGR_STATE_STA_CONNECTED);
209245
s_net.state = NETMGR_STATE_STA_CONNECTING;
246+
/* Cancel any pending AP teardown — STA isn't connected right now. */
247+
if (s_net.ap_teardown_timer) esp_timer_stop(s_net.ap_teardown_timer);
210248
if (was_connected) {
211249
ESP_LOGW(TAG, "STA dropped after being connected — bringing AP back up while we retry");
212250
apply_ap_state(ap_should_be_on());
213251
}
214252
if (s_net.sta_retry < STA_RETRY_MAX) {
215253
s_net.sta_retry++;
216-
ESP_LOGW(TAG, "STA disconnected; retry %d/%d", s_net.sta_retry, STA_RETRY_MAX);
217-
vTaskDelay(pdMS_TO_TICKS(STA_RETRY_BACKOFF_MS));
218-
esp_wifi_connect();
254+
ESP_LOGW(TAG, "STA disconnected; retry %d/%d in %d ms",
255+
s_net.sta_retry, STA_RETRY_MAX, STA_RETRY_BACKOFF_MS);
256+
/* Schedule the retry on the esp_timer task — calling
257+
* vTaskDelay() inside the Wi-Fi event loop blocks every
258+
* subsequent Wi-Fi event for the duration, which under
259+
* reconnect storms causes the stack to fall behind. */
260+
if (s_net.sta_retry_timer) {
261+
esp_timer_stop(s_net.sta_retry_timer);
262+
esp_timer_start_once(s_net.sta_retry_timer,
263+
(uint64_t)STA_RETRY_BACKOFF_MS * 1000ULL);
264+
}
219265
} else {
220266
ESP_LOGW(TAG, "STA failed after %d retries; AP fallback active", STA_RETRY_MAX);
221267
xEventGroupSetBits(s_net.evt, EVT_FAIL);
@@ -227,18 +273,27 @@ static void on_wifi_event(void *arg, esp_event_base_t base, int32_t id, void *da
227273
ESP_LOGI(TAG, "STA got IP: " IPSTR, IP2STR(&e->ip_info.ip));
228274
s_net.sta_retry = 0;
229275
s_net.state = NETMGR_STATE_STA_CONNECTED;
276+
if (s_net.sta_retry_timer) esp_timer_stop(s_net.sta_retry_timer);
230277
xEventGroupSetBits(s_net.evt, EVT_GOT_IP);
231-
/* AUTO/STA_ONLY: power down the AP now that STA is up. ALWAYS: keep it. */
232-
apply_ap_state(ap_should_be_on());
278+
/* AUTO/STA_ONLY: AP should come down. But don't tear it down
279+
* immediately — a phone that's still on the captive portal
280+
* needs a polling window to read /api/wifi and learn the new
281+
* STA IP / hostname. Schedule the teardown 30 s out. */
282+
if (s_net.ap_active && !ap_should_be_on() && s_net.ap_teardown_timer) {
283+
esp_timer_stop(s_net.ap_teardown_timer);
284+
esp_timer_start_once(s_net.ap_teardown_timer, AP_TEARDOWN_GRACE_US);
285+
ESP_LOGI(TAG, "AP teardown scheduled in 8 s (captive-portal grace window)");
286+
}
233287
} else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) {
234288
wifi_event_ap_staconnected_t *e = (wifi_event_ap_staconnected_t *)data;
235289
ESP_LOGI(TAG, "AP client joined: " MACSTR, MAC2STR(e->mac));
236290
}
237291
}
238292

239-
/* Configure both AP and STA interfaces. The AP stays up for the
240-
* entire device lifetime — many installs have no router at all, and
241-
* peer-mesh devices need a stable channel to find each other. */
293+
/* Configure the AP interface. AP visibility is governed by the
294+
* netmgr_ap_mode_t policy (AUTO / ALWAYS / STA_ONLY). For first-setup
295+
* the AP starts open so the captive portal pops the setup page; the
296+
* user can lock it down via /api/wifi { ap_password: "..." }. */
242297
static esp_err_t configure_ap(void) {
243298
char ap_ssid[32];
244299
uint8_t mac[6];
@@ -249,8 +304,7 @@ static esp_err_t configure_ap(void) {
249304
char ap_pass[64] = {0};
250305
settings_get_str("wifi", "ap_pass", ap_pass, sizeof(ap_pass));
251306

252-
/* Channel: prefer NVS pin (so peers can be co-channeled even off-router);
253-
* default 6. PR #4's mesh uses this same channel. */
307+
/* AP channel: NVS override, otherwise default 6. */
254308
uint8_t channel = 6;
255309
uint8_t saved_ch = 0;
256310
if (settings_get_u8("wifi", "ap_ch", &saved_ch) == ESP_OK && saved_ch >= 1 && saved_ch <= 13) {
@@ -261,7 +315,7 @@ static esp_err_t configure_ap(void) {
261315
snprintf((char *)cfg.ap.ssid, sizeof(cfg.ap.ssid), "%s", ap_ssid);
262316
cfg.ap.ssid_len = strlen(ap_ssid);
263317
cfg.ap.channel = channel;
264-
cfg.ap.max_connection = 6; /* up to 5 mesh peers + 1 phone */
318+
cfg.ap.max_connection = 4; /* a phone or two; we don't need more */
265319
if (ap_pass[0] && strlen(ap_pass) >= 8) {
266320
snprintf((char *)cfg.ap.password, sizeof(cfg.ap.password), "%s", ap_pass);
267321
cfg.ap.authmode = WIFI_AUTH_WPA2_PSK;
@@ -281,7 +335,15 @@ static esp_err_t configure_sta(const char *ssid, const char *pass) {
281335
snprintf((char *)cfg.sta.ssid, sizeof(cfg.sta.ssid), "%s", ssid);
282336
if (pass && pass[0]) snprintf((char *)cfg.sta.password, sizeof(cfg.sta.password), "%s", pass);
283337
cfg.sta.threshold.authmode = WIFI_AUTH_OPEN;
284-
cfg.sta.scan_method = WIFI_FAST_SCAN;
338+
/* WIFI_FAST_SCAN relies on a cached BSSID/channel from a previous
339+
* successful connect. On a fresh device or after credentials change
340+
* the cache is empty and FAST_SCAN can give up before finding the
341+
* SSID — manifests to users as "first attempt fails, second
342+
* succeeds". ALL_CHANNEL_SCAN takes ~3 s extra but is reliable on
343+
* the very first try, which is the only attempt that matters during
344+
* onboarding. */
345+
cfg.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
346+
cfg.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
285347
cfg.sta.pmf_cfg.capable = true;
286348
return esp_wifi_set_config(WIFI_IF_STA, &cfg);
287349
}
@@ -308,6 +370,20 @@ esp_err_t netmgr_init(void) {
308370
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi_event, NULL));
309371
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &on_wifi_event, NULL));
310372

373+
/* Create the deferred-work timers. They get armed/disarmed from the
374+
* Wi-Fi event handler; their callbacks run on the esp_timer task,
375+
* keeping the event loop unblocked. */
376+
const esp_timer_create_args_t retry_args = {
377+
.callback = sta_retry_timer_cb, .arg = NULL,
378+
.dispatch_method = ESP_TIMER_TASK, .name = "sta_retry",
379+
};
380+
ESP_ERROR_CHECK(esp_timer_create(&retry_args, &s_net.sta_retry_timer));
381+
const esp_timer_create_args_t teardown_args = {
382+
.callback = ap_teardown_timer_cb, .arg = NULL,
383+
.dispatch_method = ESP_TIMER_TASK, .name = "ap_teardown",
384+
};
385+
ESP_ERROR_CHECK(esp_timer_create(&teardown_args, &s_net.ap_teardown_timer));
386+
311387
/* Read AP-mode policy from NVS; default AUTO (AP only when STA is
312388
* down, or always when no STA configured). */
313389
uint8_t apmode = NETMGR_AP_AUTO;

firmware/components/webui/ui.html

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.
1.44 KB
Binary file not shown.

0 commit comments

Comments
 (0)