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.
This commit is contained in:
2026-07-20 23:46:57 -04:00
parent fa47b88473
commit 4f4b2844e6
6 changed files with 263 additions and 8 deletions
+6 -2
View File
@@ -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
+20
View File
@@ -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
+131 -5
View File
@@ -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);
+69
View File
@@ -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);
+36
View File
@@ -2,6 +2,7 @@
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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);
+1 -1
View File
@@ -1 +1 @@
1.0.1
1.1.0