User Guide¶
Overview¶
chumicro-kvstore is a tiny mutable key-value store for runtime state that needs to survive a reboot (boot counters, last-seen timestamps, retry budgets, refreshed access tokens). It exposes a familiar dict-shaped API (store[key] = value, del store[key], "key" in store, iteration) plus three explicit lifecycle methods: commit, commit_if_changed, reload.
State is read-write at runtime, flat, and lives in the right per-runtime persistent backend (CP NVM with CRC framing, MP NVS, MP LittleFS, or in-memory). You write to the in-memory dict as you go and call a commit* method when you want the change to survive the next reboot.
Getting started¶
The "boot counter that survives reboot" pattern in five lines:
from chumicro_kvstore import KVStore
from chumicro_timing import ticks_ms # separate install: chumicro-timing
store = KVStore(backend="auto")
store["boot_count"] = store.get("boot_count", 0) + 1
store["last_seen_ms"] = ticks_ms()
store.commit_if_changed()
backend="auto" picks the right backend per runtime (see below). Reads are pure-memory after the constructor's auto-load, with no I/O on the hot path. Writes update the in-memory dict immediately; persistence happens on the next commit* call.
Backends and auto selection¶
| Backend | Where the bytes live | Selected on |
|---|---|---|
nvm |
CircuitPython microcontroller.nvm byte slab with CRC32 framing |
CircuitPython on every supported board |
nvs |
MicroPython esp32.NVS namespaced K-V (single payload blob in the chu_kv namespace) |
MicroPython on ESP32-family boards (auto-detected via import esp32) |
littlefs |
MicroPython LittleFS file at /_chu_kv.msgpack, atomic via tmp-file + rename |
MicroPython on non-NVS boards (Pi Pico W, etc.) |
memory |
In-process bytes that does not survive process exit |
CPython default, plus FakeKVStore for tests |
The auto-select ladder is one short function:
import sys
def _select_backend():
if sys.implementation.name == "circuitpython":
return CpNvmBackend()
if sys.implementation.name == "micropython":
try:
import esp32 # ESP32-family probe
return MpNvsBackend()
except ImportError:
return MpLittlefsBackend()
return MemoryBackend() # CPython
If you want a specific backend regardless of runtime (for tests, or to force littlefs on an ESP32 with NVS issues), pass it by name:
Or pass a Backend instance directly (a MemoryBackend, or your own load/save pair). In tests, construct a FakeKVStore() in place of the whole store; it is a KVStore, not a backend (see Testing Helpers).
Commit semantics¶
Three lifecycle methods, distinct intents:
| Method | What it does | When to use |
|---|---|---|
commit() |
Always re-encode + write. | After a logical change you know is significant. |
commit_if_changed() |
Re-encode; skip the write if bytes match the last persisted payload. | Hot loops or once-per-tick "save current state" calls, the first-line defense against flash wear on the raw NVM backend. |
reload() |
Discard in-memory state, reread from backend, raise on corruption. | Recovery: explicit re-read after suspicion of external write or corruption. |
# Safe to call every tick; only writes when something actually changed.
runner.add_periodic(store.commit_if_changed, period_ms=1000)
Sizing and full-store handling¶
Each backend exposes a capacity (bytes-of-encoded-payload). KVStore.bytes_used reports the current encoded size; KVStore.capacity reports the backend's limit.
CP NVM is the smallest, typically 256 bytes on SAMD21 boards, ~4 KB on RP2040 (the reference Pi Pico W), and 8 KB on SAMD51 / ESP32 boards (it's per-chip; check your board's microcontroller.nvm). After CRC framing overhead (10 bytes), you have your usable budget. commit() raises KVStoreFull if the encoded payload won't fit; the in-memory dict is unchanged so you can drop a key and retry:
A few keys with short string / int values (boot counters, timestamps, simple flags) easily fit in 256 B. Larger state (captured sensor traces, queued telemetry) wants the LittleFS or NVS backend, which give you tens of KB.
Corruption handling¶
The CP NVM backend is the only one with explicit framing (magic b"CKVS" + length + CRC32 + payload). A blank slab from storage.erase_filesystem() reads as empty. A bad-magic or CRC-mismatch reads as corrupt.
Construction never raises on corruption: the store resets to empty and reports the event via is_corrupt (a backend that cannot reach its substrate at all, e.g. NVM missing, still raises):
store = KVStore(backend="auto")
if store.is_corrupt:
log.warning("kvstore was corrupt; starting fresh")
# store["boot_count"] etc. starts at 0
reload() is the explicit form that does raise (KVStoreCorrupt); use it when you want to surface the failure rather than silently reset.
NVS is atomic-on-commit at the backend level (no CRC needed). LittleFS uses tmp-file + rename for atomicity (no CRC needed). Memory backend can't corrupt.
Iteration and update¶
Standard mapping API works:
for key, value in store.items():
print(key, value)
store.update({"counter_a": 42, "counter_b": 99})
store.commit_if_changed()
clear(), pop(key, default), keys(), values(), items() follow dict semantics. commit is not implied by mutating methods; you call it explicitly when you want the change persisted.
Value types¶
Values are stored via msgpack in the chumicro subset: None, bool, int (32-bit), str, bytes, and nested list / dict up to 8 levels deep. Floats encode as 32-bit (float32), so a value like a time.time() timestamp loses precision through a commit / reload round-trip (e.g. 1751414400.5 reads back as 1751414400.0). Store timestamps and durations as integer milliseconds or seconds, not floats.
Platform notes¶
| Runtime | Backend chosen by auto |
Capacity (typical) |
|---|---|---|
| CircuitPython | nvm (with CRC framing) |
256 B – 8 KB depending on chip |
| MicroPython on ESP32-family | nvs (single blob in chu_kv namespace) |
512 B default; raise it with KVStore(backend=MpNvsBackend(capacity=N)) (~24 KB partition headroom) |
| MicroPython on Pi Pico W (rp2) | littlefs (atomic file at /_chu_kv.msgpack) |
16 KB default; raise it with KVStore(backend=MpLittlefsBackend(capacity=N)) (then filesystem-bounded) |
| CPython | memory (in-process bytes) |
unbounded |
MemoryBackend is lazy-imported, so device runtimes that resolve auto to nvm / nvs / littlefs never pay the ~700 B import cost.
USB-MSC read-only window (CircuitPython)¶
While the host has CIRCUITPY mounted, the device can't storage.remount(readonly=False) to write to flash. The default nvm backend on CircuitPython writes to NVM (not the FAT volume) so it's unaffected; the boot-counter pattern continues to persist across reboots even with the drive mounted.
Examples¶
| Example | What it shows |
|---|---|
examples/boot_counter.py |
Boot-counter pattern: commit_if_changed, bytes_used, backend_name. Runs on every runtime; on CPython the count resets each invocation, on a real device it survives reboot. |