Skip to main content

NVS and the Settings Schema

Every setting, favorite, card binding, schedule, and the resume record survives power loss through NVS (non-volatile storage), ESP-IDF's key-value store in flash. This chapter is the concept, the schema, and the policies.

NVS from zero​

Flash memory erases in 4 KB sectors and each sector endures a finite number of erases (~100k). A naive "write the settings struct at a fixed address" design would erase the same sector on every change and kill it within months. NVS solves this with a log-structured design: writes append entries; old values are obsoleted, not erased in place; when pages fill, live entries are compacted onto fresh pages. Wear spreads across the whole 64 KB partition, and a power cut mid-write leaves the previous value intact because the new entry was never marked valid. Reads are indexed and fast; writes are cheap until a compaction amortizes.

The API is namespaced key-value with typed entries (integers, strings, blobs). All of Safi's access is wrapped in state_manager's nvs_manager.c, so the rest of the firmware never touches raw NVS handles; one file owns every key.

The schema​

Storage spreads across several namespaces. Most belong to nvs_manager.c; the timezone and the API token live in their own namespaces beside the code that owns them.

Namespace safi_config holds the everyday settings:

KeyTypeMeaning
volumeu8Startup volume
card_removalu8NFC removal policy
auto_resumeu8Boot resume switch
time_fmtu8Clock format: 0 for 12-hour (default), 1 for 24-hour
displayblobBrightness, auto-off minutes, invert colors
sleepblobSleep-timer minutes and audio fade
pb_stateblobThe resume record: path or URL, position, flags

The rest sits in dedicated namespaces:

NamespaceKey(s)Meaning
safi_radioradio_0, radio_1, ...Saved station presets, one blob each
safi_wifiwifi_credsSSID and password as one blob
settingstimezonePOSIX TZ rule (written by time_sync.c)
api_authtokenThe dashboard bearer token (written by api_auth.c)
favoritesentriesFavorites with play counts
schedulerentriesSchedule entries

Schema rules the wrapper enforces:

  • Every key has a default: a missing key is a normal state (fresh device, post-reset), never an error path. Reads return the default; nothing writes defaults back, so an untouched device performs zero settings writes.
  • Blobs carry a version byte so a future firmware can migrate or discard old shapes explicitly instead of misparsing them.
  • Factory reset = nvs_reset_all_settings() erases the namespaces, and first boot regenerates what must exist (notably a fresh API token).

Write policies​

Flash wear is a budget, so when to write is a design decision per datum:

DatumPolicyWhy
Volume, brightness, settingsOn changeHuman-rate, trivially rare
Resume recordEvery 30 s while playing, plus on stop/pause, plus in the shutdown pathThe 30 s cadence caps power-loss regression; per-second writing would multiply wear ~30x for imperceptible benefit
Favorites, cards, schedules, stationsOn mutation via the APIHuman-rate
Play countsBatched with the favorite's recordNot worth independent writes

The battery-critical path calls the resume save synchronously before deep sleep, one of the few justified uses of the event bus's synchronous mode (see power).

Encryption​

The production profile enables NVS encryption: entries are encrypted with keys stored in the nvs_key partition, which is itself protected by flash encryption. Development builds skip it for debuggability (idf.py nvs-dump and friends). What each layer defends against is laid out in security; the schema and code are identical either way, which is the point of doing it at the storage layer.

Debugging NVS​

  • nvs_manager_get_stats() surfaces used/free entries (also visible via GET /api/system).
  • A full partition manifests as failed writes long before reads suffer; the stats exist because "resume stopped working" should be diagnosable from the dashboard, not by reflashing with logging.
  • The wrapper logs every failed operation with the key name; NVS errors are rare enough that each one deserves a line.