6.2 KiB · text History b85db9e
//! Config persistence in NVS, as a single JSON blob.
//!
//! Generic over the consumer's [`PortalConfig`](crate::config::PortalConfig)
//! type. Also carries the consecutive-boot-failure counter that lets a device
//! escape back to provisioning after repeatedly failing to join WiFi (the
//! recovery path for a wrong password).

use anyhow::Result;
use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs};
use serde::de::DeserializeOwned;
use serde::Serialize;

/// NVS namespace used unless overridden.
pub const DEFAULT_NAMESPACE: &str = "cfg";

const CONFIG_KEY: &str = "config";
const BOOTFAIL_KEY: &str = "bootfail";

/// Read buffer for the serialized config.
///
/// A blob larger than this fails to load (and falls back to `Default`), so it
/// is sized well above any plausible config. `nfc-522` used 1024; 2048 leaves
/// room for consumers that add their own fields.
const BLOB_CAP: usize = 2048;

/// Handle to the NVS namespace holding a device's config.
///
/// Cheap to clone — it holds a partition handle, and each operation opens its
/// own NVS handle (matching how `nfc-522` did it, and avoiding a long-lived
/// handle across the provisioning reboot).
#[derive(Clone)]
pub struct ConfigStore {
    partition: EspDefaultNvsPartition,
    namespace: String,
}

impl ConfigStore {
    pub fn new(partition: EspDefaultNvsPartition) -> Self {
        Self::with_namespace(partition, DEFAULT_NAMESPACE)
    }

    pub fn with_namespace(partition: EspDefaultNvsPartition, namespace: &str) -> Self {
        Self {
            partition,
            namespace: namespace.to_string(),
        }
    }

    /// The underlying partition, for handing to `EspWifi::new` and friends.
    pub fn partition(&self) -> &EspDefaultNvsPartition {
        &self.partition
    }

    /// Load the config, falling back to `Default` if absent or unreadable.
    ///
    /// Never fails: an unparseable blob on a device in the field should drop it
    /// into provisioning, not brick the boot.
    pub fn load<T: DeserializeOwned + Default>(&self) -> T {
        let store = match EspNvs::new(self.partition.clone(), &self.namespace, true) {
            Ok(s) => s,
            Err(e) => {
                log::warn!("nvs open failed ({e}); using default config");
                return T::default();
            }
        };

        let mut buf = [0u8; BLOB_CAP];
        match store.get_blob(CONFIG_KEY, &mut buf) {
            Ok(Some(bytes)) => serde_json::from_slice(bytes).unwrap_or_else(|e| {
                log::warn!("config parse failed ({e}); using default config");
                T::default()
            }),
            Ok(None) => T::default(),
            Err(e) => {
                log::warn!("config read failed ({e}); using default config");
                T::default()
            }
        }
    }

    /// Load the config, falling back to a compile-time `seed` when NVS holds
    /// nothing usable.
    ///
    /// This is the escape hatch for development boards: a firmware can carry
    /// bench credentials (e.g. from a `cfg.toml` via `toml-cfg`) and skip the
    /// captive portal entirely, without giving up runtime provisioning in the
    /// field.
    ///
    /// **The seed only applies to an unprovisioned device.** Once anything is
    /// stored — whether seeded on a previous boot or submitted through the
    /// portal — NVS wins and the seed is ignored. That means editing the seed
    /// and reflashing does *not* change an already-provisioned device; erase
    /// the NVS partition (or re-provision) to pick up a new one. The
    /// alternative, letting a stale compile-time value silently override what
    /// someone configured on the device, is the worse failure.
    ///
    /// A seed that is not itself `is_provisioned()` is ignored, so an empty
    /// `cfg.toml` leaves the portal to do its job.
    pub fn load_or_seed<T: crate::config::PortalConfig>(&self, seed: Option<T>) -> T {
        let stored = self.load::<T>();
        if stored.is_provisioned() {
            return stored;
        }

        match seed {
            Some(seed) if seed.is_provisioned() => {
                log::info!(
                    "nvs unprovisioned; seeding from compile-time config (wifi='{}')",
                    seed.wifi().wifi_ssid
                );
                if let Err(e) = self.save(&seed) {
                    // Non-fatal: the device still runs on the seed this boot,
                    // it just re-seeds on the next one.
                    log::warn!("failed to persist seed config ({e})");
                }
                seed
            }
            _ => stored,
        }
    }

    /// Persist the config as a JSON blob.
    pub fn save<T: Serialize>(&self, config: &T) -> Result<()> {
        let store = EspNvs::new(self.partition.clone(), &self.namespace, true)?;
        let json = serde_json::to_vec(config)?;
        anyhow::ensure!(
            json.len() <= BLOB_CAP,
            "serialized config is {} bytes, over the {BLOB_CAP}-byte cap it can be read back at",
            json.len()
        );
        store.set_blob(CONFIG_KEY, &json)?;
        Ok(())
    }

    /// Consecutive failed WiFi bring-ups since the last success (0 if unset).
    pub fn boot_fail_count(&self) -> u8 {
        match EspNvs::new(self.partition.clone(), &self.namespace, true) {
            Ok(store) => store.get_u8(BOOTFAIL_KEY).unwrap_or(None).unwrap_or(0),
            Err(e) => {
                log::warn!("nvs open failed ({e}); assuming 0 boot failures");
                0
            }
        }
    }

    /// Persist the counter. Best-effort: a write failure is logged, not fatal
    /// (worst case the provisioning fallback is delayed by one boot).
    pub fn set_boot_fail_count(&self, n: u8) {
        match EspNvs::new(self.partition.clone(), &self.namespace, true) {
            Ok(store) => {
                if let Err(e) = store.set_u8(BOOTFAIL_KEY, n) {
                    log::warn!("failed to persist boot-fail counter ({e})");
                }
            }
            Err(e) => log::warn!("nvs open failed ({e}); boot-fail counter not saved"),
        }
    }

    /// Reset the counter. Call once WiFi is actually up.
    pub fn clear_boot_fail_count(&self) {
        self.set_boot_fail_count(0);
    }
}