SDK for TESAIoT Dev Kit
API reference & tutorials (ModusToolbox)
Loading...
Searching...
No Matches
G1 — bento_storage and the C credential store

Learning goal

The 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 entry points

bento_storage.h:31-55:

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."
void bento_storage_guard_arm(void) "Turn on the XIP guard. Call from main() immediately before starting CM55." See "Above every XIP region" does not mean a write leaves CM55 alone.

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."

"Above every XIP region" does not mean a write leaves CM55 alone

That placement keeps the volume from overwriting code. It does not keep an erase or a program from stalling code: the volume is on the same chip that CM33_S, CM33_NS and CM55 execute from, and while the chip erases a 256 KB sector (typically ~0.8 s, at most 5.9 s) it answers no reads. The PDL protects only the core that issues the command (interrupts off, driver in SRAM). CM55 is protected by the XIP guard: bs_prog() and bs_erase() ask CM55 to park in ITCM, wait until it has, and only then drive the chip; if CM55 does not answer within a second they return LFS2_ERR_IO and the write fails instead of stopping the core. main() arms the guard with bento_storage_guard_arm() just before it starts CM55; writes before that need no park, because nothing else is executing from the chip yet. The CM55 half is proj_cm55/modules/xip_guard/, called first in every 33 ms UI tick. See trap 30 (30. A CM33 flash write stops CM55 unless it goes through bento_storage).

Mount — and the deliberate no-format

/* ...context: inside bento_storage_init() ... */
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) {
/* NOT formatted here on purpose — see the header. */
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

/* ...context: inside bento_storage_write_file() ... */
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_rename() replaces an existing destination atomically. No
* remove first: if the rename then failed -- and a write refused by
* the XIP guard is a normal failure now -- the old file would be
* gone and the new one deleted below, leaving nothing. */
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)

void lfs_wifi_creds_init(void) { /* bento_storage_init() owns bring-up */ }
void lfs_wifi_creds_deinit(void) { }
bool lfs_wifi_creds_ready(void) { return bento_storage_ready(); }
/* The mtb-mpy store can inherit a pre-LFS sector image that wants rewriting;
* this store writes only the current format, so never. */
bool lfs_wifi_creds_needs_resave(void) { return false; }
int lfs_wifi_creds_read(qspi_wifi_entry_t *entries, int max_entries) {
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)) {
/* Legacy XOR-32 fallback, exactly as the mtb-mpy reader does. */
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));
/* Defensive termination, same as the mtb-mpy reader. */
for (int i = 0; i < out; i++) {
entries[i].ssid[32] = '\0';
entries[i].password[64] = '\0';
}
return out;
}
bool lfs_wifi_creds_write(const qspi_wifi_entry_t *entries, int count) {
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 write can take seconds: every erase and program first waits for CM55 to park ("Above every XIP region" does not mean a write leaves CM55 alone).

Config on top of it

Only the two I/O blocks are variant-conditional; the parser and serialiser are shared. Load:

/* ...context: inside tesaiot_config_load() ... */
#if !BENTO_HAS_MPY
/* Read the file through the C lfs2 mount. Same file, same parse. */
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:

/* ...context: inside tesaiot_config_save() ... */
#if !BENTO_HAS_MPY
/* Write through the C lfs2 mount. bento_storage_write_file() is the same
* .tmp + remove + rename sequence the Python block performs. */
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;
/* Try to load from LittleFS; if missing, create default file */
if (!tesaiot_config_load()) {
#if defined(BENTO_TEST_BENCH) && BENTO_TEST_BENCH
/* Bench image: never replace a board's configuration. */
printf("[TESAIOT_CFG] no config file (bench image: not creating one)\r\n");
#else
printf("[TESAIOT_CFG] no config file, creating defaults\r\n");
tesaiot_config_save();
#endif
}
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));
/* ok == true, n == (int)(sizeof(msg) - 1) */
(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.