From 4f4b2844e64aa92143846780bd245df28ed5c973 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Mon, 20 Jul 2026 23:46:57 -0400 Subject: [PATCH] Firmware: WiFi fast-connect cache (skip scan + DHCP on the next wake) After a successful home-WiFi connection, caches BSSID/channel and IP/netmask/gateway/DNS in NVS. The next wake's first connect attempt uses the cached BSSID/channel (skips the all-channel scan) and applies the cached IP directly once the link comes up (skips DHCP) -- a couple fewer seconds of radio-on time per wake, free every wake since nothing about the network actually needs renegotiating most of the time. Falls back to a normal scan+DHCP attempt, and clears the cache, if: the fast attempt itself fails, or it "succeeds" at the WiFi layer but the full fetch cycle then fails anyway (a stale cached IP/DNS/gateway that associates but can't actually reach the server). Also cleared on (re)provisioning and factory reset, since a new network shouldn't try to reuse the old one's cache. The static-IP path needed care to get right without touching untested territory: esp_netif_set_ip_info() only posts IP_EVENT_STA_GOT_IP (what the existing connect-wait logic blocks on) once the netif is already up, which the internal netif-glue's own WIFI_EVENT_STA_CONNECTED handler guarantees by running first (registered earlier, in esp_netif_create_default_wifi_sta()) -- confirmed against ESP-IDF's own static_ip example and esp_netif_handlers.c source rather than assumed. Falling back after a failed fast attempt also needed an explicit esp_netif_dhcpc_start() first: esp_netif_dhcpc_stop() leaves the netif's DHCP status STOPPED rather than resetting to INIT, and left alone the glue would silently re-post the stale cached IP on the next connect instead of actually running DHCP (esp_netif_action_connected). Version bumped to 1.1.0 (real feature, not just a fix); build-verified clean on both board configs (devkit 8MB, XIAO 4MB), no new warnings. --- docs/architecture.md | 8 +- firmware/README.md | 20 +++++ firmware/main/frame_client.c | 136 ++++++++++++++++++++++++++++-- firmware/main/wifi_provisioning.c | 69 +++++++++++++++ firmware/main/wifi_provisioning.h | 36 ++++++++ firmware/version.txt | 2 +- 6 files changed, 263 insertions(+), 8 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 67452c4..e661365 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,8 +55,12 @@ sequenceDiagram The captive portal form saves SSID/password/toolsserver to NVS and reboots. 2. **Stored config exists**: connect to the saved WiFi network (a few - retries before falling back to provisioning if it fails), then run the - fetch cycle in `frame_client.c`: + retries before falling back to provisioning if it fails). The first + attempt tries a cached BSSID/channel + static IP from the last + successful connection, skipping the scan and DHCP; a bad cache falls + back to a normal attempt and gets cleared (see `firmware/README.md`'s + "WiFi fast-connect" section). Then run the fetch cycle in + `frame_client.c`: - Check the next-photo and back-photo buttons (`next_button_check()`, `back_button_check()`) -- if either was what woke the device (checked via the latched `esp_sleep_get_gpio_wakeup_status()`, not diff --git a/firmware/README.md b/firmware/README.md index aa525c3..829e447 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -84,6 +84,26 @@ reflashing. The Kconfig value only applies before the device has ever successfully reached a configured server, or if the response doesn't include a valid interval. +### WiFi fast-connect + +After a successful home-WiFi connection, the device caches the AP's +BSSID/channel and its own IP/netmask/gateway/DNS in NVS. The *next* +wake's first connect attempt uses that cache to skip the all-channel +scan (`wifi_config.sta.bssid_set` + a channel hint) and DHCP (a static +IP set directly) -- typically a couple of seconds less radio-on time +per wake, free every wake since it's already-known information, not a +fresh negotiation. + +If that cached attempt fails outright, or it "succeeds" at the WiFi +layer but the server turns out to be unreachable (a stale cached IP, +DNS entry, or gateway), the cache is cleared and that wake falls back to +a normal scan + DHCP -- and the *next* wake tries the fast path again +from a fresh cache. A (re)provisioning event or factory reset also +clears it, since a new network shouldn't try to reuse the old one's +cached BSSID. No user-facing config for this -- it's purely an +internal optimization, invisible unless you're watching the serial log +(`"fast path: cached BSSID/channel + static IP"` vs `"attempt N/M"`). + ## First boot With no stored WiFi config (a fresh device, or after erasing NVS -- see diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c index 90cc08f..6dc5e6b 100644 --- a/firmware/main/frame_client.c +++ b/firmware/main/frame_client.c @@ -30,6 +30,70 @@ static const char *TAG = "frame_client"; static EventGroupHandle_t s_sta_event_group; +/* Passed as the event-handler arg during frame_wifi_connect_sta so it can + * apply the cached static IP (fast_cache != NULL) exactly once, on the + * fast-connect attempt's own WIFI_EVENT_STA_CONNECTED -- fallback attempts + * leave fast_cache NULL and get normal DHCP. */ +typedef struct { + esp_netif_t *netif; + const frame_wifi_cache_t *fast_cache; +} sta_connect_ctx_t; + +/* Applies a cached static IP right after L2 link-up, skipping DHCP. + * esp_netif_set_ip_info() only posts IP_EVENT_STA_GOT_IP (which is what + * unblocks the connect-attempt wait below) once the netif is already + * "up" -- true by this point, since the internal netif-glue's own + * WIFI_EVENT_STA_CONNECTED subscriber (registered earlier, in + * esp_netif_create_default_wifi_sta()) runs before this one and brings + * the netif up first. Confirmed against ESP-IDF's own + * examples/protocols/static_ip. */ +static void apply_fast_ip(esp_netif_t *netif, const frame_wifi_cache_t *cache) +{ + if (esp_netif_dhcpc_stop(netif) != ESP_OK) { + return; + } + esp_netif_ip_info_t ip_info = { + .ip.addr = cache->ip, + .netmask.addr = cache->netmask, + .gw.addr = cache->gateway, + }; + if (esp_netif_set_ip_info(netif, &ip_info) != ESP_OK) { + return; + } + if (cache->dns != 0) { + esp_netif_dns_info_t dns_info = { .ip.type = ESP_IPADDR_TYPE_V4 }; + dns_info.ip.u_addr.ip4.addr = cache->dns; + esp_netif_set_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns_info); + } +} + +/* Records BSSID/channel/IP/netmask/gateway/DNS from a connection that just + * succeeded (fast path or normal), for the next wake's fast-connect + * attempt. Best-effort: any lookup failing here just means next wake + * falls back to a normal scan+DHCP, not a hard error. */ +static void save_wifi_cache(esp_netif_t *netif) +{ + wifi_ap_record_t ap_info; + if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK) { + return; + } + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK) { + return; + } + esp_netif_dns_info_t dns_info = {0}; + esp_netif_get_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns_info); + + frame_wifi_cache_t cache = {0}; + memcpy(cache.bssid, ap_info.bssid, sizeof(cache.bssid)); + cache.channel = ap_info.primary; + cache.ip = ip_info.ip.addr; + cache.netmask = ip_info.netmask.addr; + cache.gateway = ip_info.gw.addr; + cache.dns = dns_info.ip.u_addr.ip4.addr; + frame_wifi_cache_save(&cache); +} + /* Builds a full URL from cfg->toolsserver + a path (no leading slash), * appending cfg->access_token as ?token= if one's set. toolsserver is * normally a bare "host:port", defaulting to plain http; it may instead @@ -70,8 +134,13 @@ static void copy_wifi_field(uint8_t *dst, size_t dst_size, const char *src) static void sta_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { + sta_connect_ctx_t *ctx = (sta_connect_ctx_t *)arg; if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { esp_wifi_connect(); + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_CONNECTED) { + if (ctx->fast_cache != NULL) { + apply_fast_ip(ctx->netif, ctx->fast_cache); + } } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { ESP_LOGW(TAG, "Disconnected from home WiFi"); xEventGroupSetBits(s_sta_event_group, STA_FAILED_BIT); @@ -93,21 +162,68 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg) wifi_init_config_t init_cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&init_cfg)); + sta_connect_ctx_t ctx = { .netif = sta_netif, .fast_cache = NULL }; esp_event_handler_instance_t wifi_handler; esp_event_handler_instance_t ip_handler; - ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &sta_event_handler, NULL, &wifi_handler)); - ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &sta_event_handler, NULL, &ip_handler)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &sta_event_handler, &ctx, &wifi_handler)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &sta_event_handler, &ctx, &ip_handler)); wifi_config_t wifi_config = {0}; copy_wifi_field(wifi_config.sta.ssid, sizeof(wifi_config.sta.ssid), cfg->sta_ssid); copy_wifi_field(wifi_config.sta.password, sizeof(wifi_config.sta.password), cfg->sta_password); + frame_wifi_cache_t cache; + bool have_cache = frame_wifi_cache_load(&cache); + + wifi_config_t start_config = wifi_config; + if (have_cache) { + /* Known BSSID/channel -- skips the all-channel scan. The IP side + * of the fast path (skipping DHCP) happens in apply_fast_ip once + * WIFI_EVENT_STA_CONNECTED confirms this specific AP answered. */ + start_config.sta.bssid_set = true; + memcpy(start_config.sta.bssid, cache.bssid, sizeof(cache.bssid)); + start_config.sta.channel = cache.channel; + start_config.sta.scan_method = WIFI_FAST_SCAN; + } + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); - ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &start_config)); ESP_ERROR_CHECK(esp_wifi_start()); esp_err_t result = ESP_FAIL; - for (int attempt = 1; attempt <= CONFIG_FRAME_STA_CONNECT_MAX_RETRIES; attempt++) { + + if (have_cache) { + ESP_LOGI(TAG, "Connecting to '%s' (fast path: cached BSSID/channel + static IP)", cfg->sta_ssid); + ctx.fast_cache = &cache; + + xEventGroupClearBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT); + esp_wifi_connect(); + + EventBits_t bits = xEventGroupWaitBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT, + pdTRUE, pdFALSE, + pdMS_TO_TICKS(CONFIG_FRAME_STA_CONNECT_TIMEOUT_MS)); + ctx.fast_cache = NULL; + + if (bits & STA_CONNECTED_BIT) { + result = ESP_OK; + } else { + ESP_LOGW(TAG, "Fast-connect attempt failed, falling back to a full scan"); + frame_wifi_cache_clear(); + /* apply_fast_ip's esp_netif_dhcpc_stop() leaves the netif's + * internal DHCP state STOPPED rather than the default INIT -- + * left alone, the next WIFI_EVENT_STA_CONNECTED would make + * esp-netif's own glue silently re-post the stale cached IP + * instead of actually running DHCP (see + * esp_netif_action_connected in esp_netif_handlers.c). Calling + * this now (netif is down, mid-retry) just resets that state + * back to INIT -- confirmed against esp_netif_dhcpc_start's + * source, doesn't yet touch the network. */ + esp_netif_dhcpc_start(sta_netif); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + } + } + + for (int attempt = 1; result != ESP_OK && attempt <= CONFIG_FRAME_STA_CONNECT_MAX_RETRIES; attempt++) { ESP_LOGI(TAG, "Connecting to '%s' (attempt %d/%d)", cfg->sta_ssid, attempt, CONFIG_FRAME_STA_CONNECT_MAX_RETRIES); @@ -130,7 +246,9 @@ esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg) vEventGroupDelete(s_sta_event_group); s_sta_event_group = NULL; - if (result != ESP_OK) { + if (result == ESP_OK) { + save_wifi_cache(sta_netif); + } else { /* Fully tear the WiFi driver back down on failure -- the caller * falls back to provisioning, which calls esp_wifi_init() again * for AP mode. Leaving the driver merely stopped (rather than @@ -799,6 +917,14 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho * to show what went wrong instead of leaving stale content * with no indication anything failed. */ ESP_LOGW(TAG, "Fetch/display failed (%s), retrying sooner", esp_err_to_name(fetch_err)); + /* Covers the fast-connect cache's blind spot: WiFi can report + * a successful connection (cached static IP "worked" at the + * link layer) while the cached IP is actually stale/dead at + * the network layer -- this is the first real evidence of + * that, since it's the first thing that actually talks to the + * server. Clearing here means next wake gets a clean scan + + * DHCP instead of repeating the same silent failure. */ + frame_wifi_cache_clear(); status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_FAILED); } else if (first_connection) { status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_OK); diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c index 3e2486c..634c2b8 100644 --- a/firmware/main/wifi_provisioning.c +++ b/firmware/main/wifi_provisioning.c @@ -115,6 +115,11 @@ esp_err_t frame_config_save(const frame_config_t *cfg) } nvs_close(handle); + + /* A (re)provisioning event -- the fast-connect cache (if any) may + * belong to a different network than whatever was just saved. */ + frame_wifi_cache_clear(); + return err; } @@ -157,6 +162,8 @@ void frame_config_clear(void) nvs_erase_key(handle, "connected_once"); nvs_commit(handle); nvs_close(handle); + + frame_wifi_cache_clear(); } esp_err_t frame_config_get_last_display_crc32(uint32_t *out) @@ -193,6 +200,68 @@ void frame_config_invalidate_last_display_crc32(void) nvs_close(handle); } +/* ------------------------------------------------------------------------ + * WiFi fast-connect cache + * ---------------------------------------------------------------------- */ + +bool frame_wifi_cache_load(frame_wifi_cache_t *out) +{ + memset(out, 0, sizeof(*out)); + + nvs_handle_t handle; + if (nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle) != ESP_OK) { + return false; + } + + size_t bssid_len = sizeof(out->bssid); + bool ok = nvs_get_blob(handle, "wc_bssid", out->bssid, &bssid_len) == ESP_OK + && bssid_len == sizeof(out->bssid); + ok = ok && nvs_get_u8(handle, "wc_channel", &out->channel) == ESP_OK; + ok = ok && nvs_get_u32(handle, "wc_ip", &out->ip) == ESP_OK; + ok = ok && nvs_get_u32(handle, "wc_netmask", &out->netmask) == ESP_OK; + ok = ok && nvs_get_u32(handle, "wc_gateway", &out->gateway) == ESP_OK; + /* DNS is best-effort -- missing/zero just means the fast path's static + * IP won't have a DNS server set, which only matters if toolsserver is + * a hostname rather than a bare IP; a failed cycle clears the whole + * cache regardless (see frame_client_run), so this can't wedge. */ + nvs_get_u32(handle, "wc_dns", &out->dns); + + nvs_close(handle); + return ok; +} + +void frame_wifi_cache_save(const frame_wifi_cache_t *cache) +{ + nvs_handle_t handle; + if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) { + return; + } + nvs_set_blob(handle, "wc_bssid", cache->bssid, sizeof(cache->bssid)); + nvs_set_u8(handle, "wc_channel", cache->channel); + nvs_set_u32(handle, "wc_ip", cache->ip); + nvs_set_u32(handle, "wc_netmask", cache->netmask); + nvs_set_u32(handle, "wc_gateway", cache->gateway); + nvs_set_u32(handle, "wc_dns", cache->dns); + nvs_commit(handle); + nvs_close(handle); +} + +void frame_wifi_cache_clear(void) +{ + nvs_handle_t handle; + if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) { + return; + } + nvs_erase_key(handle, "wc_bssid"); + nvs_erase_key(handle, "wc_channel"); + nvs_erase_key(handle, "wc_ip"); + nvs_erase_key(handle, "wc_netmask"); + nvs_erase_key(handle, "wc_gateway"); + nvs_erase_key(handle, "wc_dns"); + nvs_commit(handle); + nvs_close(handle); +} + static void generate_ap_password(char *out, size_t out_size) { size_t len = MIN(FRAME_AP_PASSWORD_LEN, out_size - 1); diff --git a/firmware/main/wifi_provisioning.h b/firmware/main/wifi_provisioning.h index c66290c..f1a6338 100644 --- a/firmware/main/wifi_provisioning.h +++ b/firmware/main/wifi_provisioning.h @@ -2,6 +2,7 @@ #include #include +#include #include "esp_err.h" #define FRAME_CFG_SSID_MAX_LEN 32 @@ -83,3 +84,38 @@ void ap_identity_get(char *ssid_out, size_t ssid_len, char *pass_out, size_t pas * can provision the device. Does not return. */ void wifi_provisioning_start(void); + +/** + * Cached parameters from the most recent successful home-WiFi connection, + * letting the next wake's first connect attempt skip the all-channel scan + * (known BSSID/channel) and DHCP (known static IP/netmask/gateway/DNS). + * Fields are stored exactly as esp-wifi/esp-netif already use them + * internally, so they can be fed straight back in with no conversion. + */ +typedef struct { + uint8_t bssid[6]; + uint8_t channel; + uint32_t ip; + uint32_t netmask; + uint32_t gateway; + uint32_t dns; /* 0 = none cached (best-effort; a real DHCP fallback still repopulates this) */ +} frame_wifi_cache_t; + +/** + * Loads the cached fast-connect parameters. Returns false if there's + * nothing cached yet, or it was invalidated (see frame_wifi_cache_clear). + */ +bool frame_wifi_cache_load(frame_wifi_cache_t *out); + +/** Saves fast-connect parameters after a successful home-WiFi connection. */ +void frame_wifi_cache_save(const frame_wifi_cache_t *cache); + +/** + * Clears the fast-connect cache. Called after a cached fast-connect + * attempt itself fails (BSSID/channel went stale), after a full fetch + * cycle fails despite a successful connection (the cached static IP may + * be unreachable even though the link came up), and by + * frame_config_save()/frame_config_clear() -- a (re)provisioning event + * means whatever was cached may belong to a different network entirely. + */ +void frame_wifi_cache_clear(void); diff --git a/firmware/version.txt b/firmware/version.txt index 7dea76e..9084fa2 100644 --- a/firmware/version.txt +++ b/firmware/version.txt @@ -1 +1 @@ -1.0.1 +1.1.0