Learning goal
After this chapter you can say, for any WiFi credential on the board, which store it lives in, which core wrote it, and whether it will survive a reboot.
There are two credential stores, and conflating them is the single most likely tutorial error in this whole SDK:
| LFS boot store | OPTIGA UI store |
| API family | lfs_wifi_creds_* | wifi_saved_* |
| Where | LittleFS file /.wifi_creds on the QSPI flash | OPTIGA Trust M NVM, Type 3 data objects, slots 5, 6, 8, 9, 10, 11 (wifi_saved.h:5-10) |
| Core | CM33_NS | CM55 (reached over IPC CRED_READ/WRITE/ERASE) |
| Who reads it at boot | wifi_boot_auto_connect() — this chapter | nobody. It is not on the boot path |
| Who shows it on screen | nobody | the WiFi Connect page's saved-networks list — chapter C2 |
| Format | magic "WIFI", version 1, up to 6 × 100-byte entries, CRC32 (lfs_wifi_creds_c.c:10-18) | 106-byte entry per OPTIGA object, LRU eviction |
A network you add from the LCD lands in the OPTIGA store and (via the CM33 worker) in the LFS store; a network you add from Python wifi.connect() lands in the LFS store only. The board auto-connects from the LFS store. Keep that sentence in mind for every step below.
The real firmware sequence
Owner: the WiFiIPC worker task (sensor_auto_task.c, wifi_ipc_worker_task), not a dedicated WiFi task. Its first act, before it enters its queue loop, is wifi_boot_auto_connect() — inline, because a separate task would cost 4 KB of RAM (comment at sensor_auto_task.c:1101-1104).
Boot auto-connect
static void wifi_boot_auto_connect(void)
{
#if BENTO_HAS_MPY
printf("[WiFi-Boot] Waiting for credentials from MicroPython...\r\n");
for (int wait = 0; wait < 100; wait++) {
if (g_boot_wifi_creds_count != 0) break;
vTaskDelay(pdMS_TO_TICKS(100));
}
#else
{
if (n > 0) g_boot_wifi_creds_count = n;
}
#endif
int count = (int)g_boot_wifi_creds_count;
if (count <= 0) {
printf("[WiFi-Boot] No saved credentials — skipping\r\n");
return;
}
printf("[WiFi-Boot] %d saved network(s)\r\n", count);
if (!wifi_ensure_ready()) {
printf("[WiFi-Boot] SDIO/WCM init failed (0x%08lX)\r\n",
(unsigned long)s_wifi_last_error);
return;
}
for (int i = 0; i < count; i++) {
char ssid_local[33];
char pass_local[65];
bool slot_empty;
wifi_creds_lock();
slot_empty = (g_boot_wifi_creds[i].ssid[0] == '\0');
if (!slot_empty) {
strncpy(ssid_local, g_boot_wifi_creds[i].ssid,
sizeof(ssid_local) - 1);
ssid_local[sizeof(ssid_local) - 1] = '\0';
strncpy(pass_local, g_boot_wifi_creds[i].password,
sizeof(pass_local) - 1);
pass_local[sizeof(pass_local) - 1] = '\0';
}
wifi_creds_unlock();
if (slot_empty) continue;
static wifi_scan_ctx_t boot_scan;
memset(&boot_scan, 0, sizeof(boot_scan));
boot_scan.done = xSemaphoreCreateBinary();
bool found = false;
if (boot_scan.done != NULL) {
memset(&filter, 0, sizeof(filter));
filter.
mode = CY_WCM_SCAN_FILTER_TYPE_SSID;
strncpy((
char *)filter.
param.SSID, ssid_local,
sizeof(filter.
param.SSID) - 1);
cy_rslt_t sr = cy_wcm_start_scan(wifi_scan_callback, &boot_scan, &filter);
if (sr == CY_RSLT_SUCCESS) {
if (xSemaphoreTake(boot_scan.done, pdMS_TO_TICKS(8000)) == pdTRUE) {
found = (boot_scan.count > 0);
} else {
cy_wcm_stop_scan();
found = (boot_scan.count > 0);
}
}
vSemaphoreDelete(boot_scan.done);
boot_scan.done = NULL;
}
if (!found) {
printf("[WiFi-Boot] '%s' not found in scan — skipped\r\n",
ssid_local);
continue;
}
printf("[WiFi-Boot] '%s' found (RSSI=%d) — connecting...\r\n",
ssid_local,
(boot_scan.count > 0) ? boot_scan.entries[0].rssi : 0);
cy_wcm_connect_params_t params;
cy_wcm_ip_address_t ip;
memset(¶ms, 0, sizeof(params));
memset(&ip, 0, sizeof(ip));
strncpy((char *)params.ap_credentials.SSID, ssid_local,
sizeof(params.ap_credentials.SSID) - 1);
strncpy((char *)params.ap_credentials.password, pass_local,
sizeof(params.ap_credentials.password) - 1);
params.ap_credentials.security = (pass_local[0] == '\0')
: CY_WCM_SECURITY_WPA3_WPA2_PSK;
cy_rslt_t r = wifi_connect_robust(¶ms, &ip, "WiFi-Boot");
if (CY_RSLT_SUCCESS == r) {
#ifdef BOOT_VERBOSE
uint32_t v4 = ip.ip.v4;
printf("[WiFi-Boot] Connected to '%s'! IP=%lu.%lu.%lu.%lu\r\n",
ssid_local,
(unsigned long)(v4 & 0xFF), (unsigned long)((v4 >> 8) & 0xFF),
(unsigned long)((v4 >> 16) & 0xFF), (unsigned long)((v4 >> 24) & 0xFF));
#endif
s_wifi_state.mode = AUTO_WIFI_MODE_STA;
s_wifi_state.connected = true;
strncpy(s_wifi_state.ssid, ssid_local,
sizeof(s_wifi_state.ssid) - 1);
wifi_update_ip_from_wcm();
push_wifi_state_to_cm55(true);
if (ntp_sync_rtc()) {
s_ntp_synced = true;
push_time_to_cm55();
tesaiot_bridge_ntp_synced();
#ifdef BOOT_VERBOSE
printf("[WiFi-Boot] NTP synced + pushed to CM55\r\n");
#endif
}
return;
Read the fork at the top of that function carefully. lfs_wifi_creds_read() is the reader in both variants; what differs is who calls it and which implementation answers:
mtb-mpy — the MicroPython task calls it from mpy_main.c after the VFS mount (the store executes a Python open() under the hood, so it needs the VM task and roughly 16 KB of free GC heap), fills g_boot_wifi_creds, and the WiFi worker waits up to 10 s (100 × 100 ms) for g_boot_wifi_creds_count to become non-zero.
The boot read is mpy_main.c:633-661, marker [mpy_lfs_wifi_creds_boot_read] (mtb-mpy zip only — this file is not in the mtb-only package). After a gc_collect() and lfs_wifi_creds_init(), its core is:
wifi_creds_lock();
g_boot_wifi_creds_count = n;
int lfs_wifi_creds_read(qspi_wifi_entry_t *entries, int max_entries)
Fill the caller's array; returns the entry count, 0 on any validation failure.
- mtb-only — no VM, so the WiFi worker calls lfs_wifi_creds_read() itself, and the implementation is the C store storage_c/lfs_wifi_creds_c.c (:69) on top of bento_storage (chapter G1). Same header, same names, same bytes on flash.
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));
}
Then, per saved SSID: snapshot the entry under wifi_creds_lock() (a concurrent writer on the BLE worker or the IPC handler must not tear a password mid-copy — sensor_auto_task.c:989-994), run a targeted scan for exactly that SSID with an 8 s timeout — 5 GHz channels, iPhone hotspots in particular, answer directed probes slowly (:981-986) — skip the SSID if it is not seen, and only then attempt the join.
The join itself
static bool wifi_ensure_ready(void)
{
if (app_wifi_is_ready()) {
return true;
}
cy_rslt_t r = app_wifi_init();
if (r != CY_RSLT_SUCCESS) {
s_wifi_last_error = r;
return false;
}
s_wifi_last_error = CY_RSLT_SUCCESS;
return true;
}
#define WIFI_CONNECT_MAX_ATTEMPTS (6)
#define WIFI_CONNECT_SETTLE_MS (1500)
static cy_rslt_t wifi_connect_robust(cy_wcm_connect_params_t *params,
cy_wcm_ip_address_t *ip, const char *tag)
{
cy_rslt_t r = CY_RSLT_TYPE_ERROR;
for (int attempt = 1; attempt <= WIFI_CONNECT_MAX_ATTEMPTS; attempt++) {
r = cy_wcm_connect_ap(params, ip);
if (CY_RSLT_SUCCESS == r || cy_wcm_is_connected_to_ap()) {
return CY_RSLT_SUCCESS;
}
printf("[%s] attempt %d/%d failed (0x%08lX)\r\n",
tag, attempt, WIFI_CONNECT_MAX_ATTEMPTS, (unsigned long)r);
if (attempt < WIFI_CONNECT_MAX_ATTEMPTS) {
cy_wcm_disconnect_ap();
vTaskDelay(pdMS_TO_TICKS(WIFI_CONNECT_SETTLE_MS));
}
}
return r;
}
Six attempts, 1500 ms apart, with cy_wcm_disconnect_ap() between them. The comment above the function records why: on a cold boot the WHD RX buffer pool is short, WLC_E_SET_SSID is dropped, and the first join returns 0x020003FF. Do not "fix" this loop down to one attempt.
Saving a credential — where the variants part ways
Two save paths write the same /.wifi_creds file, but with opposite timing.
mtb-only: immediate, from the WiFi worker.
#if !BENTO_HAS_MPY
{
qspi_wifi_entry_t snap[QSPI_WIFI_CREDS_MAX];
int n;
wifi_creds_lock();
n = (int)g_boot_wifi_creds_count;
if (n > QSPI_WIFI_CREDS_MAX) n = QSPI_WIFI_CREDS_MAX;
memcpy(snap, g_boot_wifi_creds, (size_t)n * sizeof(qspi_wifi_entry_t));
wifi_creds_unlock();
wifi_creds_lock();
g_boot_wifi_creds_dirty = false;
wifi_creds_unlock();
printf("[WiFiIPC] Credentials persisted (%d entries)\r\n", n);
} else {
printf("[WiFiIPC] ERROR: credential save failed — kept dirty\r\n");
}
}
#endif
Snapshot under the lock, write outside it — the flash write takes milliseconds and the BLE worker shares those globals.
mtb-mpy: deferred to a flusher that runs only when the REPL is idle.
In mtb-mpy the worker cannot write: lfs_wifi_creds_write() requires MicroPython task context (sensor_auto_task.c:799). So the worker only updates g_boot_wifi_creds and sets g_boot_wifi_creds_dirty; the write happens in wifi_creds_flush_if_dirty() at mpy_main.c:169-192, marker [mpy_lfs_wifi_creds_flush_if_dirty] (mtb-mpy zip only — this file is not in the mtb-only package). Its tail — the write, the flag policy, the unlock:
g_boot_wifi_creds_dirty = false;
} else {
}
wifi_creds_unlock();
bool lfs_wifi_creds_write(const qspi_wifi_entry_t *entries, int count)
Overwrite /.wifi_creds with CRC32; snapshot under the lock, write outside it.
wifi_creds_flush_if_dirty() is called from exactly two places in mpy_main.c: the top of the REPL loop and the pre-soft-reboot teardown (:680, :699 on the shipped file). Note the whole write is inside the lock here — the opposite policy from the mtb-only path above. Both are correct for their context.
- Warning
- The looping-/main.py credential-loss hazard (mtb-mpy only). If /main.py runs forever — a sensor loop, a game, a while True: — the VM never returns to the REPL prompt, the flusher never runs, and a credential saved from the LCD's WiFi page is never persisted. The board connects now and forgets the network on the next power cycle. mtb-only has no such hazard: it writes immediately. If your product's /main.py loops, either return to the REPL periodically or save credentials from Python with wifi.connect(), which writes the file directly (see the trap below). (Appendix X, item 11.)
Step-by-step
Step 1 — Put a credential in the LFS store
Choose one of these, and know which store you just wrote:
- From the LCD (both variants): Home → WiFi → scan → pick the network → enter the password → Connect. This writes the OPTIGA UI store (chapter C2) and stages the LFS entry through the CM33 worker.
- From Python (mtb-mpy only): import wifi; wifi.connect("ssid", "password"). This writes the LFS store directly, without the lock and without touching g_boot_wifi_creds (modwifi.c:261-285).
What you should observe.
- mtb-mpy, Python path, on the UART: [WiFi] Connecting to 's'... then [WiFi] Connected! IP=lu.lu.lu.lu then [WiFi] Credentials saved to QSPI (d entries) (modwifi.c:243, :253, :285 — live, unmuted file).
- Both variants, on screen: the topbar WiFi glyph un-hides. It is driven by IPC_CMD_WIFI_STATE_PUSH from the worker's push_wifi_state_to_cm55(true) and read on CM55 by ipc_sensorhub_wifi_connected() with edge-detection (sensorhub_ui.c:114-124).
- Both variants, on screen, a few seconds later: the topbar clock appears once NTP has landed (ntp_sync_rtc() → push_time_to_cm55()), formatted Thu 28 Aug 14:06-style by sensorhub_ui.c:127-142.
Step 2 — Make sure the write actually reached flash
- mtb-only: nothing to do. The write ran in the worker.
- mtb-mpy: if /main.py is running, stop it and return to the REPL prompt (Ctrl-C from the serial console, or let it exit). The flusher runs at the top of the REPL loop. There is no UART line for the flush itself — the [WiFiIPC] Credentials persisted string in that file is compiled to nothing (see Traps).
What you should observe. Nothing on the UART. The proof is Step 3.
Step 3 — Power-cycle and watch the auto-connect
Unplug and replug the board (a debugger reset is not a power cycle — chapter A1/A2). Boot auto-connect starts as soon as the WiFi worker task runs.
What you should observe.
- mtb-only: [HB] t=lus tasks=u every 10 s (proj_cm33_ns/main.c:107-109) proves CM33_NS is scheduling. Within roughly 8–20 s the topbar WiFi glyph appears, then the clock.
- mtb-mpy: [MPY] GC heap u KB @ p in s (mpy_main.c:552-554) proves the VM is up; the WiFi worker has been waiting for the VM to fill the globals; then the same glyph and clock.
- Either variant, if the BLE/desktop glue is what drove the connect instead (a build with ENABLE_PAGE_BENTO_BUDDY=1 and a desktop-driven radio switch, Chapter I1 — the only path that calls app_wifi_connect_direct()): [wifi-glue] connecting SSID=s sec=d → [wifi-glue] connected, IP=lu.lu.lu.lu or [wifi-glue] attempt d/3 failed (0x%08lX) (wifi_init.c:228, :237, :244). On a default build these lines never print.
- Hardware bring-up failures are loud and unmuted: [WiFi] ERROR: SDIO interrupt init failed, [WiFi] ERROR: SDIO setup failed (0x%08lX), [WiFi] ERROR: Host wake interrupt init failed, [WiFi] ERROR: WCM init failed (0x%08lX) (wifi_init.c:84, :94-95, :122, :146-147).
If the glyph never appears and no ERROR line printed: the LFS store is empty (Step 2 was skipped on mtb-mpy), or the SSID was not seen in the targeted scan.
Step 4 — Prove which store you read
Move the same board between variants: flash mtb-only over an mtb-mpy image that had a saved network. It reconnects. The C store and the VM store read and write the same bytes (lfs_wifi_creds_c.c:18 — "so a board can move between
variants and keep its saved networks"). The OPTIGA UI list, meanwhile, is unaffected by reflashing either way — it lives in the secure element.
Traps
- Trap 1 — Every [WiFi-Boot] and [WiFiIPC] line is dead.
- sensor_auto_task.c:36 defines printf(...) to ((void)0) for the whole file. [WiFi-Boot] 2 saved network(s), [WiFi-Boot] 's' not found in scan — skipped, [WiFiIPC] Credentials persisted (d entries), [WiFiIPC] ERROR: credential
save failed — kept dirty and every sibling are compiled to nothing. A tutorial or a support ticket that tells you to watch for them is wrong. If you want them during your own bring-up, comment out sensor_auto_task.c:36, rebuild, and put it back before you ship — the mute exists because UART contention corrupts binary frames.
- Trap 2 — wifi_saved_* is not the boot store.
- wifi_saved_load(), wifi_saved_load_all(), wifi_saved_add() live on CM55 and talk to OPTIGA slots. Nothing on the boot path calls them. If your product "saves a network" through wifi_saved_add() alone (say, from a custom CM55 page), the board will show it in the list and never auto-connect to it.
- Trap 3 — Two writers do not take the lock.
- modwifi.c:261-285 (Python wifi.connect()) and wifi_init.c:261-302 (lfs_save_wifi_creds(), the BLE glue) read-modify-write /.wifi_creds directly and never touch g_boot_wifi_creds. They comply with the header contract (sensor_auto_task.h:92-95 — lock any multi-byte access to the three globals) by not touching the globals — but they can still race the file against a concurrent lfs_wifi_creds_write(). There is no file-level lock outside bento_storage.c's bs_lock(), and that exists only on mtb-only. (Appendix X, item 18.)
- Trap 4 — On failure the dirty flag is left set on purpose.
- wifi_creds_flush_if_dirty() clears g_boot_wifi_creds_dirty only after lfs_wifi_creds_write() returns true. A failed write is retried on the next REPL-idle pass. Do not add a "clear the flag anyway" line.
- Trap 5 — lfs_wifi_creds_init() needs the VM and heap (mtb-mpy).
- It must run from the MicroPython task after the VFS mount, and the shipped code calls gc_collect() immediately before it. On mtb-only the same name is a no-op (lfs_wifi_creds_c.c:60) because bento_storage_init() owns bring-up from main().
Variant
- Variant
- mtb-mpy and mtb-only
| mtb-mpy | mtb-only |
| Who calls lfs_wifi_creds_read() at boot | MicroPython task, mpy_main.c | WiFi worker, sensor_auto_task.c:961 |
| Implementation | libbento_mpy.a (Python-VFS-backed) | storage_c/lfs_wifi_creds_c.c |
| Save timing | deferred flusher — REPL idle or soft reboot | immediate, in the worker |
| Lock policy on write | whole write inside wifi_creds_lock() | snapshot inside, write outside |
| Loop-forever /main.py loses the save | yes | n/a |
| Volume format-on-mount-failure | yes (mpy_main.c, bare except: → mkfs) | never (bento_storage.c, volume left untouched) |
| [WiFi] … Python-path prints | yes | no Python, no such prints |
| [wifi-glue] … prints | compiled in, but only a ENABLE_PAGE_BENTO_BUDDY=1 BLE-driven connect reaches it | same |
The six lfs_wifi_creds_* functions appear in mpy_secure/api.txt, but on mtb-only they are provided by shipped C source, not by the archive — libbento_mpy.a is not linked under BENTO_HAS_MPY=0 (proj_cm33_ns/Makefile:458-462).