feat(wifi): retry association with backoff in shared connect helper

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.
This commit is contained in:
Julien Calixte
2026-07-10 23:44:02 +02:00
parent 4460475fb0
commit 875658a661
6 changed files with 104 additions and 75 deletions

View File

@@ -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<EspWifi<'static>>) -> 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<()> {

View File

@@ -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<EspWifi<'static>>) -> 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<()> {

View File

@@ -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<EspWifi<'static>>) -> 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<()> {

10
firmware/src/lib.rs Normal file
View File

@@ -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;

78
firmware/src/net.rs Normal file
View File

@@ -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 23 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<EspWifi<'static>>,
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<EspWifi<'static>>) -> Result<()> {
wifi.connect().context("Wi-Fi association failed")?;
wifi.wait_netif_up().context("DHCP / netif never came up")?;
Ok(())
}