18.3 KiB · text History b85db9e
//! WiFi bring-up: async STA connect for normal operation, blocking SoftAP for
//! provisioning.
//!
//! A config carries up to [`MAX_NETWORKS`](crate::config::MAX_NETWORKS) sets
//! of credentials, and the station scans before it joins so a device moved
//! between its own networks lands on whichever is actually in range -- see
//! [`connect_sta_blocking`].
//!
//! Each helper consumes the modem peripheral (available once) and returns the
//! live handle, which the caller **must keep alive** — dropping it tears the
//! interface down.

use anyhow::Result;
use esp_idf_svc::eventloop::{EspSubscription, EspSystemEventLoop, System};
use esp_idf_svc::hal::modem::WifiModemPeripheral;
use esp_idf_svc::handle::RawHandle;
use esp_idf_svc::netif::EspNetif;
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::timer::EspTaskTimerService;
use esp_idf_svc::wifi::{
    AccessPointConfiguration, AccessPointInfo, AsyncWifi, AuthMethod, BlockingWifi,
    ClientConfiguration, Configuration, EspWifi, WifiEvent,
};

use crate::config::{WifiConfig, WifiNetwork};

/// The SoftAP gateway. The captive DNS advertises this and the portal is
/// reachable on it.
///
/// It comes from `RouterConfiguration::default()` in **embedded-svc**
/// (`src/ipv4.rs`), which is what `EspNetif::new(NetifStack::Ap)` builds the AP
/// netif from -- *not* from ESP-IDF, whose own
/// `ESP_NETIF_INHERENT_DEFAULT_WIFI_AP` is `192.168.4.1` and is overridden
/// here. Reading the C default and concluding this constant was wrong is an
/// easy mistake to make; [`start_ap`] logs the address the netif actually took
/// so it does not have to be inferred from either source again.
pub const AP_IP: [u8; 4] = [192, 168, 71, 1];

/// The SoftAP gateway as a URL, for captive-portal redirects.
pub const AP_URL: &str = "http://192.168.71.1/";

/// Connect to one of the configured access points and wait for an IP address.
///
/// See [`connect_sta_blocking`] for how a network is chosen when the config
/// carries more than one; the two paths share that logic.
///
/// Generic over the modem peripheral rather than naming `Modem<'static>`, so
/// this works with whatever peripheral type the consumer's esp-idf-hal exposes.
pub async fn connect_sta<M: WifiModemPeripheral + 'static>(
    modem: M,
    sysloop: EspSystemEventLoop,
    timer: EspTaskTimerService,
    nvs: EspDefaultNvsPartition,
    wifi_cfg: &WifiConfig,
) -> Result<AsyncWifi<EspWifi<'static>>> {
    connect_sta_named(modem, sysloop, timer, nvs, wifi_cfg, None).await
}

/// [`connect_sta`], with a DHCP hostname.
///
/// See [`set_hostname`] for why the name has to be set here rather than by the
/// caller afterwards.
pub async fn connect_sta_named<M: WifiModemPeripheral + 'static>(
    modem: M,
    sysloop: EspSystemEventLoop,
    timer: EspTaskTimerService,
    nvs: EspDefaultNvsPartition,
    wifi_cfg: &WifiConfig,
    hostname: Option<&str>,
) -> Result<AsyncWifi<EspWifi<'static>>> {
    let networks = candidates(wifi_cfg)?;

    let mut wifi = AsyncWifi::wrap(
        EspWifi::new(modem, sysloop.clone(), Some(nvs))?,
        sysloop,
        timer,
    )?;

    if let Some(hostname) = hostname {
        set_hostname(wifi.wifi().sta_netif(), hostname)?;
    }

    // The driver wants a configuration before it will start, and the scan that
    // decides the real order cannot run until it has. The first candidate is
    // as good a placeholder as any -- it is overwritten below.
    wifi.set_configuration(&client_config(&networks[0])?)?;
    wifi.start().await?;

    let order = match wifi.scan().await {
        Ok(seen) => by_signal(networks, &seen),
        Err(e) => {
            log::warn!("scan failed ({e}); trying the configured networks in order");
            networks
        }
    };

    let mut last: Option<anyhow::Error> = None;
    for net in &order {
        log::info!("joining '{}'", net.ssid);
        // Any half-finished association from the previous candidate has to go
        // before the next configuration is set, or `connect` races it.
        let _ = wifi.disconnect().await;
        wifi.set_configuration(&client_config(net)?)?;

        match async {
            wifi.connect().await?;
            wifi.wait_netif_up().await
        }
        .await
        {
            Ok(()) => {
                let ip = wifi.wifi().sta_netif().get_ip_info()?;
                log::info!("WiFi connected to '{}', ip = {}", net.ssid, ip.ip);
                return Ok(wifi);
            }
            Err(e) => {
                log::warn!("could not join '{}': {e}", net.ssid);
                last = Some(e.into());
            }
        }
    }

    Err(join_failure(&order, last))
}

/// Connect to one of the configured access points, blocking until an IP
/// arrives.
///
/// The synchronous counterpart to [`connect_sta`], for firmware with no async
/// executor. Needs no [`EspTaskTimerService`].
///
/// # Choosing between networks
///
/// A config may carry several networks (see
/// [`WifiConfig::networks`](crate::config::WifiConfig::networks)) so a device
/// can be moved between them without being re-provisioned. The radio is
/// started, a scan is run, and the known SSIDs are tried loudest first —
/// which is what makes a device carried between two of its own networks land
/// on the nearer one rather than on whichever was typed in first. Networks the
/// scan did not see are still tried, after the ones it did, in config order:
/// a hidden SSID does not appear in a scan and is not thereby unreachable.
///
/// If the scan itself fails, the whole list is tried in config order. Only
/// when every candidate has been tried and failed does this return an error.
///
/// # Afterwards
///
/// This chooses a network **once**, at bring-up.
/// [`keep_connected`] re-associates to whatever was chosen here; it does not
/// re-scan and will not move the device to a different one of its networks.
/// Reaching another network means a reboot, which consumers already do when
/// the link stays down.
pub fn connect_sta_blocking<M: WifiModemPeripheral + 'static>(
    modem: M,
    sysloop: EspSystemEventLoop,
    nvs: EspDefaultNvsPartition,
    wifi_cfg: &WifiConfig,
) -> Result<BlockingWifi<EspWifi<'static>>> {
    connect_sta_blocking_named(modem, sysloop, nvs, wifi_cfg, None)
}

/// [`connect_sta_blocking`], with a DHCP hostname.
///
/// See [`set_hostname`] for why the name has to be set here rather than by the
/// caller afterwards.
pub fn connect_sta_blocking_named<M: WifiModemPeripheral + 'static>(
    modem: M,
    sysloop: EspSystemEventLoop,
    nvs: EspDefaultNvsPartition,
    wifi_cfg: &WifiConfig,
    hostname: Option<&str>,
) -> Result<BlockingWifi<EspWifi<'static>>> {
    let networks = candidates(wifi_cfg)?;

    let mut wifi = BlockingWifi::wrap(EspWifi::new(modem, sysloop.clone(), Some(nvs))?, sysloop)?;

    if let Some(hostname) = hostname {
        set_hostname(wifi.wifi().sta_netif(), hostname)?;
    }

    // The driver wants a configuration before it will start, and the scan that
    // decides the real order cannot run until it has. The first candidate is
    // as good a placeholder as any -- it is overwritten below.
    wifi.set_configuration(&client_config(&networks[0])?)?;
    wifi.start()?;

    let order = match wifi.scan() {
        Ok(seen) => by_signal(networks, &seen),
        Err(e) => {
            log::warn!("scan failed ({e}); trying the configured networks in order");
            networks
        }
    };

    let mut last: Option<anyhow::Error> = None;
    for net in &order {
        log::info!("joining '{}'", net.ssid);
        // Any half-finished association from the previous candidate has to go
        // before the next configuration is set, or `connect` races it.
        let _ = wifi.disconnect();
        wifi.set_configuration(&client_config(net)?)?;

        let joined = match wifi.connect() {
            Ok(()) => wifi.wait_netif_up(),
            Err(e) => Err(e),
        };
        match joined {
            Ok(()) => {
                let ip = wifi.wifi().sta_netif().get_ip_info()?;
                log::info!("WiFi connected to '{}', ip = {}", net.ssid, ip.ip);
                return Ok(wifi);
            }
            Err(e) => {
                log::warn!("could not join '{}': {e}", net.ssid);
                last = Some(e.into());
            }
        }
    }

    Err(join_failure(&order, last))
}

/// Longest hostname ESP-IDF accepts (`ESP_NETIF_HOSTNAME_MAX_SIZE`, 32).
///
/// Over-long names are rejected by `esp_netif_set_hostname` with
/// `ESP_ERR_ESP_NETIF_INVALID_PARAMS` rather than truncated.
pub const HOSTNAME_MAX_LEN: usize = 32;

/// Name the interface, so the device appears on the network as something other
/// than `espressif`.
///
/// This is the DHCP client identity (option 12), which is what a router's
/// client list and its DNS shows. Without it every ESP32 on the LAN reports
/// `CONFIG_LWIP_LOCAL_HOSTNAME`, whose default is `espressif`, and a fleet of
/// them is indistinguishable.
///
/// # Ordering
///
/// Call this **before the netif starts DHCP** -- i.e. before `wifi.start()`,
/// which is why the connect helpers take the name rather than leaving it to
/// the caller. lwip copies the pointer into the DHCP DISCOVER it sends the
/// moment the link comes up; setting the name after association leaves the
/// lease, and so the router's idea of the device, on the old name until the
/// lease is renewed. The underlying `struct netif` is allocated by
/// `esp_netif_new`, so it does exist this early (`esp_netif_lwip.c:772`) --
/// earlier still, before `EspWifi::new`, there is no netif and the call fails
/// with `ESP_ERR_ESP_NETIF_IF_NOT_READY`.
pub fn set_hostname(netif: &EspNetif, hostname: &str) -> Result<()> {
    anyhow::ensure!(
        !hostname.is_empty() && hostname.len() <= HOSTNAME_MAX_LEN,
        "hostname '{hostname}' must be 1..={HOSTNAME_MAX_LEN} bytes"
    );
    let name = std::ffi::CString::new(hostname)?;
    // SAFETY: the handle is live for as long as `netif` is, and ESP-IDF copies
    // the string (`strdup`) rather than retaining our pointer.
    esp_idf_svc::sys::esp!(unsafe {
        esp_idf_svc::sys::esp_netif_set_hostname(netif.handle(), name.as_ptr())
    })?;
    log::info!("hostname set to '{hostname}'");
    Ok(())
}

/// The networks to try, in config order, or an error if there are none.
///
/// Both connect paths index `[0]` for the pre-start placeholder config, so the
/// emptiness check has to happen here rather than being left to the loop.
fn candidates(wifi_cfg: &WifiConfig) -> Result<Vec<WifiNetwork>> {
    let networks = wifi_cfg.networks();
    anyhow::ensure!(
        !networks.is_empty(),
        "no wifi network configured (empty ssid)"
    );
    Ok(networks)
}

/// Order the configured networks by how loudly they are actually on the air.
///
/// Ones the scan saw come first, strongest RSSI first; ones it did not keep
/// their config order behind them, since a hidden SSID never appears in a scan
/// and dropping it here would make it unjoinable.
fn by_signal(networks: Vec<WifiNetwork>, seen: &[AccessPointInfo]) -> Vec<WifiNetwork> {
    // An SSID can appear once per AP; a mesh or an extender means several. The
    // strongest is the one the driver would pick anyway.
    let rssi = |ssid: &str| {
        seen.iter()
            .filter(|ap| ap.ssid.as_str() == ssid)
            .map(|ap| ap.signal_strength)
            .max()
    };

    let mut ordered = networks;
    ordered.sort_by_key(|net| match rssi(&net.ssid) {
        // Negated: `sort_by_key` is ascending, and a stronger signal is a
        // larger (less negative) dBm. `sort_by_key` is stable, so unseen
        // networks keep their config order among themselves.
        Some(dbm) => (0, -(dbm as i32)),
        None => (1, 0),
    });

    for net in &ordered {
        match rssi(&net.ssid) {
            Some(dbm) => log::info!("known network '{}' in range at {dbm} dBm", net.ssid),
            None => log::info!("known network '{}' not seen in the scan", net.ssid),
        }
    }

    ordered
}

/// The error for "every configured network was tried and none of them worked".
///
/// Names them, because the first question about a device that will not come up
/// is which networks it was even looking for.
fn join_failure(tried: &[WifiNetwork], last: Option<anyhow::Error>) -> anyhow::Error {
    let names = tried
        .iter()
        .map(|net| format!("'{}'", net.ssid))
        .collect::<Vec<_>>()
        .join(", ");
    match last {
        Some(e) => anyhow::anyhow!("could not join any of {names}: last error: {e}"),
        None => anyhow::anyhow!("could not join any of {names}"),
    }
}

/// Build the STA configuration for one network, shared by the async and
/// blocking paths.
fn client_config(net: &WifiNetwork) -> Result<Configuration> {
    let auth_method = if net.is_protected() {
        AuthMethod::WPA2Personal
    } else {
        AuthMethod::None
    };

    Ok(Configuration::Client(ClientConfiguration {
        // heapless::String<32> / <64> since esp-idf-svc 0.52: over-long
        // credentials are a real error rather than a silent truncation.
        ssid: net
            .ssid
            .as_str()
            .try_into()
            .map_err(|_| anyhow::anyhow!("ssid '{}' too long (max 32 bytes)", net.ssid))?,
        password: net
            .pass
            .as_str()
            .try_into()
            .map_err(|_| anyhow::anyhow!("password for '{}' too long (max 64 bytes)", net.ssid))?,
        auth_method,
        ..Default::default()
    }))
}

/// Keep the station associated, and say why whenever it is not.
///
/// **This is not automatic and every long-running consumer needs it.**
/// `esp-idf-svc`'s station associates once and then stays down if the AP drops
/// it: nothing in the crate, and nothing in ESP-IDF's station config,
/// reconnects. What that looks like depends on what the device was doing with
/// the network -- a client that dials out logs
///
/// ```text
/// esp-tls: connect() error: Host is unreachable
/// mqtt_client: Error transport connect
/// ```
///
/// forever, and one that only listens goes quiet with nothing in the log at
/// all, which is worse. Re-associating from the disconnect handler is the
/// pattern ESP-IDF's own examples use.
///
/// # Ordering
///
/// Call this **after** [`connect_sta_blocking`] (or [`connect_sta`]) has
/// returned. Those wait on the same events, and a handler calling
/// `esp_wifi_connect` underneath them during the initial association races
/// both.
///
/// # Keeping it alive
///
/// The returned subscription must be held for as long as the device wants to
/// stay on the network -- dropping it unregisters the handler and the station
/// silently stops reconnecting again.
///
/// ```ignore
/// let wifi = wifi::connect_sta_blocking(modem, sysloop.clone(), nvs, cfg.wifi())?;
/// wifi::disable_power_save()?;
/// let _reconnect = wifi::keep_connected(&sysloop)?;
/// ```
///
/// It logs the disconnect reason, which is carried nowhere else: 201 is
/// `NO_AP_FOUND`, 15 a handshake timeout (very nearly always the passphrase),
/// 205 a connection that was made and lost.
///
/// # Networks
///
/// This re-associates to the network the bring-up chose; it does not re-scan
/// and cannot move the device to another of its configured networks. That is
/// deliberate -- an AP that drops a client for ten seconds should not cost a
/// re-scan and a hop to a weaker network. Moving between networks happens at
/// boot, so a device carried somewhere else reaches its other network by the
/// reboot its consumer already performs when the link stays down.
pub fn keep_connected(sysloop: &EspSystemEventLoop) -> Result<EspSubscription<'static, System>> {
    let subscription = sysloop.subscribe::<WifiEvent, _>(|event| match event {
        WifiEvent::StaDisconnected(disconnected) => {
            log::warn!("wifi disconnected, re-associating: {disconnected:?}");
            // Called from the event handler on purpose; it takes no handle, so
            // it needs nothing this closure cannot have.
            if let Err(e) = esp_idf_svc::sys::esp!(unsafe { esp_idf_svc::sys::esp_wifi_connect() })
            {
                log::warn!("re-associate failed: {e}");
            }
        }
        WifiEvent::StaConnected(_) => log::info!("wifi associated"),
        _ => {}
    })?;
    Ok(subscription)
}

/// Disable WiFi power save.
///
/// The ESP-IDF default (`WIFI_PS_MIN_MODEM`) parks the radio roughly half the
/// time, which produced `wifi:bcn_timeout` -> disassociation against an 802.11ax
/// AP even at RSSI -40 on `lambent-flex-c6`. esp-idf-svc 0.52 exposes no safe
/// wrapper, hence the raw call. **Must be called after `start()`.**
///
/// Costs idle current; skip it on battery-powered devices.
pub fn disable_power_save() -> Result<()> {
    use esp_idf_svc::sys::{esp, esp_wifi_set_ps, wifi_ps_type_t_WIFI_PS_NONE};
    esp!(unsafe { esp_wifi_set_ps(wifi_ps_type_t_WIFI_PS_NONE) })?;
    Ok(())
}

/// Start an open SoftAP for provisioning.
pub fn start_ap<M: WifiModemPeripheral + 'static>(
    modem: M,
    sysloop: EspSystemEventLoop,
    nvs: EspDefaultNvsPartition,
    ssid: &str,
) -> Result<BlockingWifi<EspWifi<'static>>> {
    let mut wifi = BlockingWifi::wrap(EspWifi::new(modem, sysloop.clone(), Some(nvs))?, sysloop)?;

    wifi.set_configuration(&Configuration::AccessPoint(AccessPointConfiguration {
        ssid: ssid
            .try_into()
            .map_err(|_| anyhow::anyhow!("ssid too long (max 32 bytes)"))?,
        auth_method: AuthMethod::None,
        channel: 1,
        max_connections: 4,
        ..Default::default()
    }))?;

    wifi.start()?;

    // Read the address back rather than trusting the constant. The captive DNS
    // and every portal redirect are built from AP_IP, so if ESP-IDF ever hands
    // out a different gateway than the one compiled in here, the whole
    // provisioning flow fails in a way that looks like broken WiFi -- the
    // client associates, gets a lease, and nothing answers.
    match wifi.wifi().ap_netif().get_ip_info() {
        Ok(info) => {
            let expected = std::net::Ipv4Addr::from(AP_IP);
            if info.ip == expected {
                log::info!("SoftAP '{ssid}' started at {AP_URL}");
            } else {
                log::warn!(
                    "SoftAP '{ssid}' came up at {} but AP_IP says {expected}; the captive \
                     DNS and portal redirects will point at the wrong address",
                    info.ip
                );
            }
        }
        Err(e) => log::warn!("SoftAP '{ssid}' started; could not read its address ({e})"),
    }

    Ok(wifi)
}