Learning goal
The five entry points of bento_storage; why the on-flash geometry is a numeric contract with the MicroPython port; the deliberate no-format-on-error divergence; and how the six lfs_wifi_creds_* names are re-provided in C so that a board moved between variants keeps its saved networks. By the end you can read and write a file on the mtb-only volume without breaking the mtb-mpy board that will read it next.
The real firmware sequence
Everything in this chapter ships as source in the template: bento_libs/claw/common/storage_c/bento_storage.{h,c}, storage_c/lfs_wifi_creds_c.c, and the two #if !BENTO_HAS_MPY I/O blocks in bento_libs/claw/common/modules/tesaiot_config/tesaiot_config_store.c.
The five entry points
bento_storage.h:31-48:
| Function | Contract (from the header) |
| bool bento_storage_init(void) | "Bring up SMIF serial memory and mount the LittleFS volume. Call once from main() before anything reads config or credentials. Returns true when the volume is mounted. Safe to call twice." |
| bool bento_storage_ready(void) | "Mounted and usable." |
| bool bento_storage_format(void) | "mkfs + mount. Destroys every file on the volume. Never called by init." |
| int bento_storage_read_file(const char*, void*, size_t) | "Read a whole file into buf. Returns bytes read, or -1. Paths are as the Python side writes them, e.g. "/.tesaiot_config"." |
| bool bento_storage_write_file(const char*, const void*, size_t) | "Atomically replace a file: write path.tmp, remove path, rename." |
bento_storage_init() is called at proj_cm33_ns/main.c:306 in the mtb-only branch (Chapter B1, step 7).
The geometry contract
bento_storage.c:5-14 — each number annotated with the MicroPython source it must equal:
read/prog size 0x200 vfs_mount_script kwargs
block size 0x40000 EXT_FLASH_SECTOR_SIZE (KIT_PSE84_AI)
block count 208 EXT_FLASH_SIZE / block size
block_cycles 100 MicroPython extmod/vfs_lfsx.c
cache size 2048 MIN(block, 4*MAX(read,prog)) — same file
lookahead 32 MicroPython extmod/vfs_lfs.c default
flash base 0x00C00000 EXT_FLASH_BASE — above every XIP region
"These numbers must equal what the MicroPython port passes to VfsLfs2, or the
two variants stop reading each other's volumes." The mpy side is mpy_main.c:485-492 (os.VfsLfs2(bdev, progsize=0x200, readsize=0x200)). And bento_storage.h:10-12: "Compiled ONLY when BENTO_HAS_MPY=0. Under
mtb-mpy the VM owns the mount, and a second lfs2 instance on the same flash
would corrupt it."
Mount — and the deliberate no-format
cy_rslt_t r = mtb_serial_memory_setup(&s_serial_mem,
MTB_SERIAL_MEMORY_CHIP_SELECT_1,
CYBSP_SMIF_CORE_0_XSPI_FLASH_hal_config.base,
CYBSP_SMIF_CORE_0_XSPI_FLASH_hal_config.clock,
&s_smif_ctx, &s_smif_info, &smif0BlockConfig);
if (r != CY_RSLT_SUCCESS) {
printf("storage: SMIF setup failed 0x%08lx\r\n", (unsigned long)r);
return false;
}
memset(&s_cfg, 0, sizeof(s_cfg));
s_cfg.read = bs_read;
s_cfg.prog = bs_prog;
s_cfg.erase = bs_erase;
s_cfg.sync = bs_sync;
s_cfg.read_size = BS_RW_SIZE;
s_cfg.prog_size = BS_RW_SIZE;
s_cfg.block_size = BS_BLOCK_SIZE;
s_cfg.block_count = BS_BLOCK_COUNT;
s_cfg.block_cycles = BS_BLOCK_CYCLES;
s_cfg.cache_size = BS_CACHE_SIZE;
s_cfg.lookahead_size = BS_LOOKAHEAD;
s_cfg.read_buffer = s_read_buf;
s_cfg.prog_buffer = s_prog_buf;
s_cfg.lookahead_buffer = s_lookahead_buf;
int err = lfs2_mount(&s_lfs, &s_cfg);
if (err != LFS2_ERR_OK) {
printf("storage: mount failed (%d); volume left untouched\r\n", err);
return false;
}
s_mounted = true;
return true;
}
The divergence, stated by the header (bento_storage.h:13-17): "the Python
path formats the volume on ANY mount failure (bare except: → mkfs). Here a
failed mount is an error and the volume is left alone — a wiped /main.py and
config is a worse outcome than a boot with storage marked unavailable.
bento_storage_format() exists for when erasing is what the caller actually
means." The mpy side is mpy_main.c:489-490; variants/mtb-only.mk:22-26 repeats the decision.
Atomic write — tmp, remove, rename
bs_lock();
bool ok = false;
lfs2_file_t f;
struct lfs2_file_config fcfg = { .buffer = s_file_cache };
int err = lfs2_file_opencfg(&s_lfs, &f, tmp,
LFS2_O_WRONLY | LFS2_O_CREAT | LFS2_O_TRUNC,
&fcfg);
if (err == LFS2_ERR_OK) {
lfs2_ssize_t n = lfs2_file_write(&s_lfs, &f, buf, len);
err = lfs2_file_close(&s_lfs, &f);
if (n == (lfs2_ssize_t)len && err == LFS2_ERR_OK) {
lfs2_remove(&s_lfs, path);
ok = (lfs2_rename(&s_lfs, tmp, path) == LFS2_ERR_OK);
}
}
if (!ok) lfs2_remove(&s_lfs, tmp);
bs_unlock();
return ok;
The header's promise (bento_storage.h:45-47): "The same sequence the Python
path uses, so a power cut mid-save leaves either the old file or the new
one, never a torn half." bs_lock()/bs_unlock() around it is the only file-level lock in either variant.
The C credential store — same names, same bytes
lfs_wifi_creds_c.c provides the six names wifi_init.c links against (lfs_wifi_creds_ready/read/write/init/deinit/needs_resave) so that callers are identical in both variants. On-disk format (:10-18):
0 4 magic 0x57494649 "WIFI"
4 2 version 1
6 2 count 0..6
8 600 entries 6 x sizeof(qspi_wifi_entry_t) = 100
608 4 CRC32 (poly 0xEDB88320) of bytes 0..607
"so a board can move between variants and keep its saved networks." (:18)
if (entries == NULL || max_entries <= 0) return 0;
if (!bento_storage_ready()) return 0;
int n = bento_storage_read_file(CREDS_FILE, s_buf, sizeof(s_buf));
if (n < CREDS_TOTAL_SIZE) return 0;
uint32_t magic; memcpy(&magic, &s_buf[0], 4);
uint16_t version; memcpy(&version, &s_buf[4], 2);
uint16_t count; memcpy(&count, &s_buf[6], 2);
if (magic != QSPI_WIFI_CREDS_MAGIC) return 0;
if (version != QSPI_WIFI_CREDS_VERSION) return 0;
if (count > QSPI_WIFI_CREDS_MAX) return 0;
uint32_t stored; memcpy(&stored, &s_buf[CREDS_HEADER_SIZE + CREDS_ENTRIES_SIZE], 4);
if (stored != calc_crc32(s_buf, CREDS_HEADER_SIZE + CREDS_ENTRIES_SIZE)) {
if (stored != calc_xor32(s_buf, CREDS_HEADER_SIZE + CREDS_ENTRIES_SIZE)) return 0;
}
int out = (count < (uint16_t)max_entries) ? count : max_entries;
memcpy(entries, &s_buf[CREDS_HEADER_SIZE], (size_t)out * sizeof(qspi_wifi_entry_t));
for (int i = 0; i < out; i++) {
entries[i].ssid[32] = '\0';
entries[i].password[64] = '\0';
}
return out;
}
if (count < 0 || count > QSPI_WIFI_CREDS_MAX) return false;
if (count > 0 && entries == NULL) return false;
if (!bento_storage_ready()) return false;
memset(s_buf, 0, sizeof(s_buf));
uint32_t magic = QSPI_WIFI_CREDS_MAGIC;
uint16_t version = QSPI_WIFI_CREDS_VERSION;
uint16_t cnt = (uint16_t)count;
memcpy(&s_buf[0], &magic, 4);
memcpy(&s_buf[4], &version, 2);
memcpy(&s_buf[6], &cnt, 2);
if (count > 0) {
memcpy(&s_buf[CREDS_HEADER_SIZE], entries,
(size_t)count * sizeof(qspi_wifi_entry_t));
}
uint32_t crc = calc_crc32(s_buf, CREDS_HEADER_SIZE + CREDS_ENTRIES_SIZE);
memcpy(&s_buf[CREDS_HEADER_SIZE + CREDS_ENTRIES_SIZE], &crc, 4);
return bento_storage_write_file(CREDS_FILE, s_buf, sizeof(s_buf));
}
Mapping notes: lfs_wifi_creds_init is a no-op here (bento_storage_init() owns bring-up); lfs_wifi_creds_ready is bento_storage_ready(); lfs_wifi_creds_needs_resave always returns false; the read side accepts the legacy XOR-32 checksum as a fallback exactly as the mtb-mpy reader does; the write side always emits CRC32. Boot read: sensor_auto_task.c:960-962. Save: sensor_auto_task.c:844-849 — immediate, from the WiFi worker, because the REPL-idle flusher the mpy variant uses does not exist here (variants/mtb-only.mk:6-10). The cross-task shape is snapshot-under-lock, write-outside-lock, because the flash write takes milliseconds.
Config on top of it
Only the two I/O blocks are variant-conditional; the parser and serialiser are shared. Load:
#if !BENTO_HAS_MPY
static char read_buf[2048];
int n = bento_storage_read_file(TESAIOT_CONFIG_FILE_PATH,
read_buf, sizeof(read_buf));
bool loaded = false;
if (n > 0) {
xSemaphoreTake(s_mutex, portMAX_DELAY);
config_set_defaults(&s_config);
config_parse_content(&s_config, read_buf, (size_t)n);
s_config._version = TESAIOT_CONFIG_VERSION;
xSemaphoreGive(s_mutex);
loaded = true;
}
Save:
#if !BENTO_HAS_MPY
bool ok = bento_storage_write_file(TESAIOT_CONFIG_FILE_PATH,
save_buf, (size_t)pos);
Public entry, load-or-write-defaults:
bool tesaiot_config_init(void)
{
if (s_initialized) return true;
s_mutex = xSemaphoreCreateMutexStatic(&s_mutex_buf);
config_set_defaults(&s_config);
s_initialized = true;
if (!tesaiot_config_load()) {
printf("[TESAIOT_CFG] no config file, creating defaults\r\n");
tesaiot_config_save();
}
return true;
}
Step-by-step
Step 1 — Boot and read the silence
Flash mtb-only (Chapter A2), power-cycle, watch the console.
- What you should observe
- No storage: line. Failure is loud: storage: SMIF setup failed 0x%08lx (bento_storage.c:108) or storage: mount failed (d); volume left
untouched (:131), followed by ERROR: storage unavailable — config and
WiFi credentials will use defaults (main.c:306-308). Then no ERROR: tesaiot_config_init failed (main.c:310-312) — its absence means the config loaded (or defaults were written on first boot). Then [HB] t=lus tasks=u every 10 s — the board is scheduling. Three silences and a heartbeat is a healthy storage boot. The former [TESAIOT_CFG] loaded: … line is muted by tesaiot_config_store.c:26 and cannot be used.
Step 2 — Write a file and read it back from C
In your own CM33_NS task (after the scheduler, after bento_storage_ready() is true):
static const char msg[] = "hello from mtb-only\n";
if (bento_storage_ready()) {
bool ok = bento_storage_write_file("/hello.txt", msg, sizeof(msg) - 1);
char buf[64];
int n = bento_storage_read_file("/hello.txt", buf, sizeof(buf));
(void)ok; (void)n;
}
- What you should observe
- ok true and n equal to the length written. Nothing prints; success is silent. Pull the USB during the write and the header's promise holds: on the next boot the file is either the old one or the new one.
Step 3 — Move the board between variants
Save a WiFi network on mtb-only (Chapter C2's UI path). Flash mtb-mpy. Power-cycle.
- What you should observe
- The saved network is still listed and the boot auto-connect uses it — the signals are C1 Step 3's: [MPY] GC heap … proves the VM is up, then the topbar WiFi glyph appears (the worker's own [WiFiIPC] lines are muted). No [wifi-glue] line appears: that tag prints only inside app_wifi_connect_direct() (wifi_init.c:225-242), whose sole caller is the archived BLE radio scheduler under ENABLE_PAGE_BENTO_BUDDY=1 (Chapter I1) — no default-build path reaches it. Flash mtb-only again: still there. This is the byte compatibility lfs_wifi_creds_c.c:18 promises, and it is what the mtb-only dist README's acceptance recorded: "storage read data the mtb-mpy variant
had written."
Step 4 — Contrast panel: what mtb-mpy does instead
- What you should observe — mtb-mpy
- No bento_storage at all: the VM mounts the same flash from Python (mpy_main.c:485-492), and lfs_wifi_creds_* come from libbento_mpy.a doing its file I/O by executing Python — which is why lfs_wifi_creds_init there must run from the MicroPython task after the VFS mount, with a gc_collect() before it (mpy_main.c:625-650). A mount failure there formats the volume (mpy_main.c:489-490). Credential writes are deferred to a REPL-idle flusher (mpy_main.c:169-190), which a looping /main.py never reaches (Appendix X #11).
Traps
- A second lfs2 instance on the same flash corrupts it (bento_storage.h:11-12). Never link storage_c into an mtb-mpy build.
- Changing any geometry number breaks cross-variant reads silently; the volume mounts on one side and not the other (bento_storage.c:5-14).
- Calling bento_storage_format() to "recover" destroys /main.py and the config — the exact outcome the no-format policy exists to avoid.
- Appendix X #11 — mtb-mpy credential flush never runs under a looping /main.py; mtb-only writes immediately. Same file, different timing.
- Appendix X #18 — two unlocked credential writers. modwifi.c:261-285 (mtb-mpy) and wifi_init.c:261-302 (both variants) read-modify-write the LFS file directly without wifi_creds_lock; they can race a concurrent lfs_wifi_creds_write on the file. bs_lock() in bento_storage.c is the only file-level lock and it is mtb-only.
- Expecting [TESAIOT_CFG] on the console. Muted (tesaiot_config_store.c:26). The signal is the absence of ERROR: tesaiot_config_init failed.
- Config port is stored but unused for MQTT (Appendix X #15, mqtt_client_config.c:105-118); editing it in /.tesaiot_config changes nothing.
Variant applicability
- Variant
- mtb-only — with contrast panels for mtb-mpy (Step 4). bento_storage is compiled only when BENTO_HAS_MPY=0. The six lfs_wifi_creds_* names exist in both variants; their mtb-only implementation is this chapter's lfs_wifi_creds_c.c, and the reduced storage/credentials reference set for this variant is homed with this chapter.