//! 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);
}
}
neiam
/neiam-portal
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
6.2 KiB · text
History
b85db9e