From 875658a661718089db353afcae26954a09378987 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Fri, 10 Jul 2026 23:44:02 +0200 Subject: [PATCH] feat(wifi): retry association with backoff in shared connect helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-shot connect_wifi aborted boot on any transient association or DHCP miss — common right after a reset, when the AP still holds the stale pre-reset association. Add a bounded retry (5 attempts, 500ms→4s backoff, disconnect between tries) so those transient failures recover instead of failing the boot. Extract the logic into firmware::net (new lib target) and rewire the wifi_tls / git_push / git_sync spikes to it, removing three duplicated copies. main.rs reuses this when Wi-Fi lands there instead of adding a fourth copy. --- firmware/Cargo.toml | 7 ++++ firmware/src/bin/git_push.rs | 25 ++---------- firmware/src/bin/git_sync.rs | 25 ++---------- firmware/src/bin/wifi_tls.rs | 34 ++-------------- firmware/src/lib.rs | 10 +++++ firmware/src/net.rs | 78 ++++++++++++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 75 deletions(-) create mode 100644 firmware/src/lib.rs create mode 100644 firmware/src/net.rs diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 1759eff..24cce99 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -10,6 +10,13 @@ rust-version = "1.85" # rust-analyzer — see README "harness = false" note). autobins = false +# Shared library surface (currently just resilient Wi-Fi bring-up in +# `firmware::net`), reused by the editor bin and the spike bins so the connect +# retry logic lives in exactly one place rather than being copied per binary. +[lib] +name = "firmware" +path = "src/lib.rs" + [[bin]] name = "firmware" path = "src/main.rs" diff --git a/firmware/src/bin/git_push.rs b/firmware/src/bin/git_push.rs index 10b301e..ebec07a 100644 --- a/firmware/src/bin/git_push.rs +++ b/firmware/src/bin/git_push.rs @@ -58,7 +58,8 @@ use esp_idf_svc::hal::peripherals::Peripherals; use esp_idf_svc::nvs::EspDefaultNvsPartition; use esp_idf_svc::sntp::{EspSntp, SyncStatus}; use esp_idf_svc::sys::{self, esp}; -use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi}; +use esp_idf_svc::wifi::{BlockingWifi, EspWifi}; +use firmware::net::connect_wifi; use git2::{ CertificateCheckStatus, Cred, CredentialType, IndexAddOption, PushOptions, RemoteCallbacks, Repository, Signature, @@ -140,7 +141,7 @@ fn run() -> Result<()> { EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?, sys_loop, )?; - connect_wifi(&mut wifi)?; + connect_wifi(&mut wifi, WIFI_SSID, WIFI_PASS)?; let ip = wifi.wifi().sta_netif().get_ip_info()?; log::info!("Wi-Fi up — IP {}", ip.ip); wifi @@ -182,26 +183,6 @@ fn run() -> Result<()> { } } -/// Associate with the configured AP and wait for DHCP. Mirrors Spike 6. -fn connect_wifi(wifi: &mut BlockingWifi>) -> Result<()> { - let auth_method = if WIFI_PASS.is_empty() { - AuthMethod::None - } else { - AuthMethod::WPA2Personal - }; - wifi.set_configuration(&Configuration::Client(ClientConfiguration { - ssid: WIFI_SSID.try_into().ok().context("SSID > 32 bytes")?, - password: WIFI_PASS.try_into().ok().context("password > 64 bytes")?, - auth_method, - ..Default::default() - }))?; - wifi.start()?; - log::info!("associating with \"{WIFI_SSID}\"…"); - wifi.connect().context("Wi-Fi association failed")?; - wifi.wait_netif_up().context("DHCP / netif never came up")?; - Ok(()) -} - /// Kick off SNTP and block until first sync. Required before TLS (cert validity) /// and before committing (signature timestamp). Mirrors Spike 6. fn sync_clock() -> Result<()> { diff --git a/firmware/src/bin/git_sync.rs b/firmware/src/bin/git_sync.rs index 9abd850..61a3378 100644 --- a/firmware/src/bin/git_sync.rs +++ b/firmware/src/bin/git_sync.rs @@ -44,7 +44,8 @@ use esp_idf_svc::hal::peripherals::Peripherals; use esp_idf_svc::nvs::EspDefaultNvsPartition; use esp_idf_svc::sntp::{EspSntp, SyncStatus}; use esp_idf_svc::sys::{self, esp}; -use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi}; +use esp_idf_svc::wifi::{BlockingWifi, EspWifi}; +use firmware::net::connect_wifi; use git2::build::{CheckoutBuilder, RepoBuilder}; use git2::{ CertificateCheckStatus, Commit, Cred, CredentialType, FetchOptions, IndexAddOption, @@ -135,7 +136,7 @@ fn run() -> Result<()> { EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?, sys_loop, )?; - connect_wifi(&mut wifi)?; + connect_wifi(&mut wifi, WIFI_SSID, WIFI_PASS)?; let ip = wifi.wifi().sta_netif().get_ip_info()?; log::info!("Wi-Fi up — IP {}", ip.ip); wifi @@ -405,26 +406,6 @@ fn short(oid: git2::Oid) -> String { oid.to_string()[..8].to_string() } -/// Associate with the configured AP and wait for DHCP. Mirrors Spike 6 / git_push. -fn connect_wifi(wifi: &mut BlockingWifi>) -> Result<()> { - let auth_method = if WIFI_PASS.is_empty() { - AuthMethod::None - } else { - AuthMethod::WPA2Personal - }; - wifi.set_configuration(&Configuration::Client(ClientConfiguration { - ssid: WIFI_SSID.try_into().ok().context("SSID > 32 bytes")?, - password: WIFI_PASS.try_into().ok().context("password > 64 bytes")?, - auth_method, - ..Default::default() - }))?; - wifi.start()?; - log::info!("associating with \"{WIFI_SSID}\"…"); - wifi.connect().context("Wi-Fi association failed")?; - wifi.wait_netif_up().context("DHCP / netif never came up")?; - Ok(()) -} - /// Kick off SNTP and block until first sync. Required before TLS (cert validity) /// and before committing (signature timestamp). Mirrors Spike 6 / git_push. fn sync_clock() -> Result<()> { diff --git a/firmware/src/bin/wifi_tls.rs b/firmware/src/bin/wifi_tls.rs index 595426c..b057efb 100644 --- a/firmware/src/bin/wifi_tls.rs +++ b/firmware/src/bin/wifi_tls.rs @@ -28,7 +28,8 @@ use esp_idf_svc::hal::peripherals::Peripherals; use esp_idf_svc::http::client::{Configuration as HttpConfig, EspHttpConnection}; use esp_idf_svc::nvs::EspDefaultNvsPartition; use esp_idf_svc::sntp::{EspSntp, SyncStatus}; -use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi}; +use esp_idf_svc::wifi::{BlockingWifi, EspWifi}; +use firmware::net::connect_wifi; /// Injected by build.rs so serial output identifies the exact build. const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT")); @@ -81,7 +82,7 @@ fn run() -> Result<()> { sys_loop, )?; - connect_wifi(&mut wifi)?; + connect_wifi(&mut wifi, WIFI_SSID, WIFI_PASS)?; let ip = wifi.wifi().sta_netif().get_ip_info()?; log::info!("Wi-Fi up — IP {}, GW {}", ip.ip, ip.subnet.gateway); @@ -91,35 +92,6 @@ fn run() -> Result<()> { Ok(()) } -/// Associate with the configured AP and wait for the netif (DHCP) to come up. -fn connect_wifi(wifi: &mut BlockingWifi>) -> Result<()> { - // Open network → no auth; otherwise WPA2-Personal (see .env.example for WPA3). - let auth_method = if WIFI_PASS.is_empty() { - AuthMethod::None - } else { - AuthMethod::WPA2Personal - }; - - wifi.set_configuration(&Configuration::Client(ClientConfiguration { - ssid: WIFI_SSID - .try_into() - .ok() - .context("SSID longer than 32 bytes")?, - password: WIFI_PASS - .try_into() - .ok() - .context("password longer than 64 bytes")?, - auth_method, - ..Default::default() - }))?; - - wifi.start()?; - log::info!("associating with \"{WIFI_SSID}\"…"); - wifi.connect().context("Wi-Fi association failed")?; - wifi.wait_netif_up().context("DHCP / netif never came up")?; - Ok(()) -} - /// Kick off SNTP and block until the first sync (or time out). Required before /// TLS: cert validity is checked against wall time. fn sync_clock() -> Result<()> { diff --git a/firmware/src/lib.rs b/firmware/src/lib.rs new file mode 100644 index 0000000..c3932d9 --- /dev/null +++ b/firmware/src/lib.rs @@ -0,0 +1,10 @@ +//! Shared library surface for the Typoena firmware crate. +//! +//! The editor binary (`src/main.rs`) and the spike binaries under `src/bin/` +//! are each separate crate roots; anything they need to share lives here and is +//! reached as `firmware::…`. Currently that is just the resilient Wi-Fi +//! bring-up, extracted from three duplicated `connect_wifi` copies so the retry +//! logic lives in exactly one place and the eventual editor integration reuses +//! it instead of growing a fourth copy. + +pub mod net; diff --git a/firmware/src/net.rs b/firmware/src/net.rs new file mode 100644 index 0000000..8a65252 --- /dev/null +++ b/firmware/src/net.rs @@ -0,0 +1,78 @@ +//! Shared networking helpers. +//! +//! Extracted from the near-identical `connect_wifi` copies that lived in the +//! wifi_tls / git_push / git_sync spikes. The single copy adds the resilience +//! the spikes lacked: a bounded retry with exponential backoff around +//! association + DHCP. + +use anyhow::{Context, Result}; +use esp_idf_svc::hal::delay::FreeRtos; +use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi}; + +/// Association + DHCP attempts before giving up. The first attempt after a +/// reset frequently fails: the AP may still hold the pre-reset association and +/// reject the re-join until it ages out, the radio may not be settled, and DHCP +/// can drop the first DISCOVER on a cold interface. Retrying a handful of times +/// turns those transient misses into a clean connect on attempt 2–3 instead of +/// a failed boot. +const MAX_ATTEMPTS: u32 = 5; + +/// Backoff before the first retry; doubles each attempt up to [`MAX_BACKOFF_MS`]. +const INITIAL_BACKOFF_MS: u32 = 500; +const MAX_BACKOFF_MS: u32 = 4000; + +/// Configure the station, start the radio, and associate with `ssid`, retrying +/// association + DHCP with exponential backoff. +/// +/// `set_configuration` and `start` run once; only the association + netif-up +/// wait is retried — restarting the radio each attempt is wasteful and can wedge +/// the driver. Between attempts the station is disconnected to clear any +/// half-open state before the next `connect`. +/// +/// An empty `pass` selects an open network; otherwise WPA2-Personal. +pub fn connect_wifi( + wifi: &mut BlockingWifi>, + ssid: &str, + pass: &str, +) -> Result<()> { + let auth_method = if pass.is_empty() { + AuthMethod::None + } else { + AuthMethod::WPA2Personal + }; + wifi.set_configuration(&Configuration::Client(ClientConfiguration { + ssid: ssid.try_into().ok().context("SSID > 32 bytes")?, + password: pass.try_into().ok().context("password > 64 bytes")?, + auth_method, + ..Default::default() + }))?; + wifi.start()?; + + let mut backoff_ms = INITIAL_BACKOFF_MS; + for attempt in 1..=MAX_ATTEMPTS { + log::info!("associating with \"{ssid}\" (attempt {attempt}/{MAX_ATTEMPTS})…"); + match associate_once(wifi) { + Ok(()) => return Ok(()), + Err(e) if attempt < MAX_ATTEMPTS => { + log::warn!("Wi-Fi attempt {attempt} failed: {e:#}; retrying in {backoff_ms} ms"); + // Clear any half-open association before the next connect. Ignore + // the result — it errors harmlessly when we never associated. + let _ = wifi.disconnect(); + FreeRtos::delay_ms(backoff_ms); + backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS); + } + Err(e) => { + return Err(e).with_context(|| format!("Wi-Fi failed after {MAX_ATTEMPTS} attempts")); + } + } + } + unreachable!("loop returns on success or on the final-attempt error") +} + +/// One association + DHCP wait. Split out so the retry loop reads cleanly and to +/// keep the two `wifi` borrows in separate statements. +fn associate_once(wifi: &mut BlockingWifi>) -> Result<()> { + wifi.connect().context("Wi-Fi association failed")?; + wifi.wait_netif_up().context("DHCP / netif never came up")?; + Ok(()) +}