SDK for TESAIoT Dev Kit
API reference & tutorials (ModusToolbox)
Loading...
Searching...
No Matches
C1 — WiFi: the two credential stores and boot auto-connect

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
/* Wait for mpy_main.c to load credentials from LFS into globals.
* g_boot_wifi_creds_count is set by MicroPython task after VFS mount. */
printf("[WiFi-Boot] Waiting for credentials from MicroPython...\r\n");
for (int wait = 0; wait < 100; wait++) { /* 100 x 100ms = 10s */
if (g_boot_wifi_creds_count != 0) break;
vTaskDelay(pdMS_TO_TICKS(100));
}
#else
/* No VM, so no mpy_main.c to fill the globals. Read the same file
* through the C store; bento_storage_init() ran from main(). */
{
extern int lfs_wifi_creds_read(qspi_wifi_entry_t *entries, int max_entries);
int n = lfs_wifi_creds_read(g_boot_wifi_creds, QSPI_WIFI_CREDS_MAX);
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;
}
/* Targeted scan per saved SSID: send a directed probe request for each
* saved network. This verifies the AP exists before attempting connect,
* avoiding 25+ second timeouts on unreachable networks.
*
* Timeout: 8s per SSID to cover 5GHz channels (iPhone hotspots etc.)
* where directed probe responses take longer than 2.4GHz. */
for (int i = 0; i < count; i++) {
/* Snapshot the credential into stack locals under the lock so
* the rest of the iteration body (scan, connect, retry loop,
* NTP sync) runs against an immutable view — concurrent writers
* on the BLE worker / IPC handler can't tear the strncpy of a
* password mid-copy. ~100 bytes of stack per iteration; the
* SensorAuto task has plenty of headroom. */
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;
/* Targeted SSID scan: probe for this specific network */
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) {
/* 8s timeout — covers 5GHz channel scan for iPhone hotspots */
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(&params, 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);
/* WPA3_WPA2_PSK (SAE + PMF) — see IPC_CMD_WIFI_CONNECT handler above. */
params.ap_credentials.security = (pass_local[0] == '\0')
: CY_WCM_SECURITY_WPA3_WPA2_PSK;
cy_rslt_t r = wifi_connect_robust(&params, &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; /* Connected — done */

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();
int n = lfs_wifi_creds_read(g_boot_wifi_creds, QSPI_WIFI_CREDS_MAX);
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.
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));
}

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;
}
/*******************************************************************************
* Robust WCM connect — rides out the marginal COLD first-join after boot.
* See BENTO-TESAIoT-Game-libraries/docs/WIFI_NTP_COLD_JOIN_KNOWHOW.md for the full
* root-cause writeup. In short: on a cold boot the CYW55500 RX buffer pool
* (WHD_DEF_MAX_RXBUFPOST=14 x ~2KB) is malloc'd fresh from the ~81KB shared newlib
* heap during the association burst; a momentary buffer shortfall drops the frame
* that sets JOIN_SSID_SET (WLC_E_SET_SSID) and the join returns WHD_JOIN_IN_PROGRESS
* (0x020003FF). Each cy_wcm_connect_ap() tears its half-join down on failure, so
* re-issuing (with disconnect + settle between) is the only recovery. Warm reconnect
* succeeds on the first try. Applies to every STA connect path.
******************************************************************************/
#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(); /* clear WHD join FSM for a clean retry */
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.

/* ...context: inside the IPC_CMD_WIFI_CONNECT success path ... */
#if !BENTO_HAS_MPY
/* No VM means no mpy_main.c and therefore no deferred flusher — the
* "requires MicroPython task context" above is about the Python-VFS
* store, not this one. This runs in the WiFi worker task, and the C
* store is plain lfs2 over serial memory, so write it out now.
* Snapshot under the lock, write outside it: the flash write takes
* milliseconds and the BLE worker shares these globals. */
{
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();
extern bool lfs_wifi_creds_write(const qspi_wifi_entry_t *entries, int count);
if (lfs_wifi_creds_write(snap, n)) {
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:

if (lfs_wifi_creds_write(g_boot_wifi_creds, count)) {
g_boot_wifi_creds_dirty = false;
} else {
/* Silently retry on next REPL iteration */
}
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).