SDK for TESAIoT Dev Kit
API reference & tutorials (ModusToolbox)
Loading...
Searching...
No Matches
I2 — The NUS protocol surface
Variant
mtb-mpy and mtb-only
Warning
Group prerequisite — read before anything else. The entire Bento Buddy / BLE module is compiled out of the default build: ENABLE_PAGE_BENTO_BUDDY ?= 0 (proj_cm33_ns/Makefile:64, :305), and the whole BLE block including the [boot] prints sits inside #if ENABLE_PAGE_BENTO_BUDDY (main.c:36, :325). First step: make getlibs in proj_cm33_ns (fetches btstack and the BT firmware), then rebuild with ENABLE_PAGE_BENTO_BUDDY=1. Without the libraries the build halts with the Makefile $(error) at :353-359 — that error is itself the taught observable for the missing-prerequisite state: ENABLE_PAGE_BENTO_BUDDY=1 requires btstack-integration. Run 'make getlibs' to fetch the AIROC BLE host stack. Nothing in this chapter or I1 — BLE bring-up and the single-RF rule is observable on a default build.

Learning goal

Send a framed payload, emit an event, acknowledge a verb, cross to CM55 through the shared-memory bridge, and know which parts of the exported surface are real and which are dead. 77 of the 87 exported symbols have no caller in template/; only three ble_nus .c files ship as source (bento_time.c, character_lottie.c, character_lottie_default.c). Everything else is in libbento_secure.a; the excerpts marked archived are lifted verbatim from those sources and each states its origin (file:lines — compiled into libbento_secure.a, not shipped as source).

Real firmware sequence

Framing a send. The shipped exemplar is the time-sync ack: caller frames the JSON with a trailing \n, bounds-checks snprintf, passes an explicit length, and (void)-casts the result:

/* ...context: inside the bento.time.sync ack emitter ... */
int w = snprintf(tx, sizeof(tx),
"{\"ack\":\"bento.time.sync\",\"ok\":true,\"n\":0,"
"\"synced\":true,\"boot_epoch_ms\":%s,\"uptime_ms_at_sync\":%s}\n",
boot_buf, up_buf);
if (w > 0 && w < (int)sizeof(tx)) {
(void)ble_nus_send((const uint8_t *)tx, (size_t)w);
}

ble_nus_send returns −1 unless init succeeded, state is CONNECTED and the peer enabled TX notifications (ble_nus.c:878-885); fragmentation is internal (MTU−3, capped at 180 B); the library mallocs and copies, so a stack buffer is legal (:893-905). A weak stub at ble_nus.c:65 makes the symbol safe to reference before init. Its true fan-in is ~80+ through the NUS_SEND macro.

Polling state. The firmware deliberately polls instead of trusting the on_state callback — the bonded-pair fast-resume path was observed to miss GATT_CONNECTION_STATUS_EVT (2026-05-10):

/* ...context: inside the sensor auto-push loop ... */
#if defined(BENTO_HAS_BLE_NUS) && (BENTO_HAS_BLE_NUS == 1)
/* BLE NUS host-link state poll (every 5th cycle = ~500ms).
* Why poll instead of using on_state callback: the callback
* registration window depends on the AIROC stack delivering
* GATT_CONNECTION_STATUS_EVT, which we observed was missing on
* the bonded-pair fast-resume path on 2026-05-10 — the desktop
* was actively serving fw.query verbs over the GATT link but
* ble_nus_get_state() still read ADVERTISING. Polling closes
* the loop deterministically: whatever the stack actually
* thinks the state is, the LCD topbar will reflect it within
* 500 ms of any change. */
if ((now_ms - last_ble_ms) >= 500u) {
last_ble_ms = now_ms;
static int8_t last_pushed_ble = -1; /* −1 = uninitialized */
int8_t now_connected = (ble_nus_get_state() == BLE_NUS_STATE_CONNECTED) ? 1 : 0;
if (now_connected != last_pushed_ble) {
sensor_auto_push_ble_state(now_connected != 0);
last_pushed_ble = now_connected;
}
}

ble_nus_get_state() is a lock-free plain read, safe from any task and safe before init (weak stub returns BLE_NUS_STATE_OFF).

Emitting an event. nus_emit_event(json) takes NUL-terminated JSON with no trailing newline (it appends one); strlen(json) + 2 <= 256 or it returns −1 and drops — no truncation; −1 also covers NULL and link-down. The shipped caller (mod_dualband.c:196-213, mtb-mpy source; declared via a local extern at :44-46) formats {"evt":"bento.net.down","n":lu,"reason":"s"}, checks the snprintf bound, then raises OSError("nus_emit_event failed (d)") on non-zero.

Acknowledging a verb — archived caller, with the sensor_stream_start interval clamp to [10, 5000]:

Origin
Lifted from nus_commands.c:770-784 (compiled into the prebuilt archive; not shipped as source).

The verb is hard-truncated at 32 chars; ack_cmd_len == 0 means strlen; ok is derived (both NULL ⇒ true; a non-NULL error flips it false and appends "error"); void — a send failure is invisible; runs on the NUS RX dispatch task, not arbitrary context.

Crossing to CM55. ipc_bento_buddy_send writes one static .cy_shared_socmem message — not reentrant; data_len silently clamped to IPC_DATA_MAX_LEN; bounded retry 50 × 1 ms so task only, never ISR; pass len+1 so the receiver gets the NUL and the excl-NUL count in value:

Origin
Lifted from ipc_bento_buddy_bridge.c:43-70 (compiled into the prebuilt archive; not shipped as source).
Origin
Lifted from nus_agent.c:189-204 (compiled into the prebuilt archive; not shipped as source).

Radio state on the wire — radio_mode_str returns a static literal ("invalid" out of range, so s is unconditionally safe); vocabulary unknown | ble_adv | ble_paired | switching_to_wifi | wifi_active | switching_to_ble | wifi_failed:

Origin
Lifted from nus_commands.c:1443-1469 (compiled into the prebuilt archive; not shipped as source).

Devmode. The secret is RAM-only and regenerated every boot — desktops must re-provision after every reboot; bento_devmode_init is idempotent and self-called by every public entry:

What this challenge/response actually protects. The HMAC-SHA256 step itself is implemented correctly — TTL'd, one-shot nonce, plus a failed-attempt rate limiter. The secret, however, is handed out in plaintext: bento_devmode_emit_provision() sends {"evt":"bento.devmode.provision","secret":"<64 hex>"} to the first peer that connects (ble_nus_lazy.c:99-109) over the unencrypted TX characteristic.

The secret also comes from an xorshift32 seeded with the FreeRTOS tick, a stack address and one constant (bento_devmode.c:54-66) — under 32 bits of entropy, not the OPTIGA RNG. Together that means the gate keeps out a peer that connects second and no one else. Treat it as a deterrent, not an enforcement boundary.

Origin
Lifted from bento_devmode.c:90-108 (compiled into the prebuilt archive; not shipped as source).

Agent state is a single in-flight ask owned by one NUS RX task:

Origin
Lifted from nus_agent.c:115-133 (compiled into the prebuilt archive; not shipped as source).

Step by step

Step 1 — Pair and watch the topbar

Connect from Bento Desktop Buddy. Advertising is open and the device accepts any central, so the connection needs no pairing first. If the desktop does initiate pairing it is Just Works (BTM_IO_CAPABILITIES_NONE + BTM_LE_AUTH_REQ_SC_BOND, no MITM bit): no passkey appears on the screen and there is no MITM protection. The DisplayOnly 6-digit passkey screen is not yet implemented.

What you should observe. The topbar BLE glyph changes within 500 ms of the connection — that is the poll cadence above.

Pairing is optional, not required. The shipped GATT database sets no GATTDB_PERM_AUTH_* bit on RX, TX or the CCCD (checked in libbento_secure.a member bento_core_11.o, 2026-08-29 — see GATT data). An unbonded peer writes RX and enables notifications normally and does not get GATT_INSUF_AUTHENTICATION. Treat the link as unauthenticated.

Step 2 — Time sync ack

Let the desktop send bento.time.sync.

What you should observe on the desktop: a JSON line {"ack":"bento.time.sync","ok":true,"n":0,"synced":true,"boot_epoch_ms":…,"uptime_ms_at_sync":…} — the exact frame the snippet builds.

Step 3 — Radio state event

Trigger a mode change (bento_buddy.stop() on mtb-mpy, or the CM55 page button).

What you should observe on the desktop: {"event":"bento.radio.state","mode":"…","ssid":"…","ipv4":"…","ble_paired":…,"wifi_fail_count":…} with mode drawn from the vocabulary above.

Step 4 — Stream and ack

Send bento.sensor.stream.start with an unknown id.

What you should observe. An ack with "ok":false,"error":"unknown_id"; with a valid id and interval_ms outside [10, 5000], the stream runs at the clamped interval.

Which exports are dead — 13 authored

These are exported from libbento_secure.a with no caller in either tree; their examples are authored for the documentation (no shipped call site) and labelled as such: NUS_UUID_CHAR_TX, NUS_UUID_CHAR_RX (present only as raw bytes inside nus_gatt_database[], nus_gatt_db.c:83), voice_capture_is_running, radio_scheduler_set_boot_mode, radio_scheduler_get_boot_mode, nus_protocol_tick, nus_protocol_send_permission, nus_fp_is_active, nus_events_pending_ack_count, nus_agent_note_ask (body at nus_agent.c:122-133 — a definition, not a call), nus_agent_buffer_len, fw_hash_prefix8, bento_buddy_auto_start_install (superseded; main.c:344-347). A page that shows any of them as part of the shipping protocol path is wrong.

The five overridable.txt symbols are the opposite case — WEAK hooks you implement, defined in wifi_init.c: :193 app_wifi_connect_direct, :248 app_wifi_disconnect, :256 app_wifi_get_ipv4, :261 lfs_save_wifi_creds, :304 lfs_load_wifi_creds:

int lfs_save_wifi_creds(const char *ssid, const char *password, const char *security)
{
if (!lfs_wifi_creds_ready()) return -1;
qspi_wifi_entry_t saved[QSPI_WIFI_CREDS_MAX];
int count = lfs_wifi_creds_read(saved, QSPI_WIFI_CREDS_MAX);
if (count < 0) count = 0;
/* Upsert: find existing slot for this SSID, else append (LRU eviction
* when full). */
int idx = -1;
for (int i = 0; i < count; i++) {
if (strncmp(saved[i].ssid, ssid, sizeof(saved[i].ssid)) == 0) {
idx = i;
break;
}
}
if (idx < 0) {
idx = (count < QSPI_WIFI_CREDS_MAX) ? count : 0;
if (count < QSPI_WIFI_CREDS_MAX) count++;
}
memset(&saved[idx], 0, sizeof(qspi_wifi_entry_t));
strncpy(saved[idx].ssid, ssid, sizeof(saved[idx].ssid) - 1);
if (password != NULL) {
strncpy(saved[idx].password, password, sizeof(saved[idx].password) - 1);
}
/* security field is uint8_t — store the low byte of the cy_wcm enum so
* we stay binary-compatible with modwifi.c's saved entries (which also
* writes the truncated enum). The low byte distinguishes the common
* cases: 0=OPEN, 4=WPA2_AES (also matches WPA3_SAE's low byte — WPA3
* is treated as WPA2 on the load path for now; explicit WPA3 storage
* requires bumping the struct format). */
bool is_open = (security != NULL && strcmp(security, "OPEN") == 0)
|| (password == NULL || password[0] == '\0');
saved[idx].security = is_open
? (uint8_t)(CY_WCM_SECURITY_OPEN & 0xFFu)
: (uint8_t)(CY_WCM_SECURITY_WPA2_AES_PSK & 0xFFu);
saved[idx].flags = 0x01; /* auto_connect */
return lfs_wifi_creds_write(saved, count) ? 0 : -2;
}

Traps

Warning
Two concurrent ipc_bento_buddy_send callers corrupt each other — one shared message.
nus_emit_event drops oversize payloads silently to the wire; check its return.
lfs_save_wifi_creds takes no wifi_creds_lock — it read-modify-writes the LFS file directly and can race a concurrent lfs_wifi_creds_write (Appendix X #18).
ble_nus_init from any task but the chip-power task fails with state=ERROR.

Variant box

mtb-mpy mtb-only
Protocol handlers identical (archived) identical
nus_emit_event caller shown mod_dualband.c (shipped in this package) not shipped; the contract is the same
Topbar state push sensor_auto_task.c:1440-1459 same file ships in both packages