Learning goal
The three IPC contracts every CM55 page relies on: the setup order (from B2), the deferred container binding that lets IPC handlers write into a page that exists, and the single-consumer snapshot that feeds every page at 33 ms. Plus the two idioms built on them — edge-detected topbar state and the console/widget toggle. By the end you can write a page that binds and unbinds correctly, and you know which of these calls may be made from where.
The real firmware sequence
Contract 1 — the ordering prologue
tesaiot_display.c:223-238 (compiled into libbento_cm55.a; source in the Kit tree): cm55_ipc_communication_setup() → ipc_sensorhub_init() → ipc_service_init() → (deepcraft_task_init() if model-link) → … display steps … → ipc_only: → (void)ipc_lcd_init(NULL) → if
(display_ok) (void)ipc_ui_init(NULL). Chapter B2 walked it; here the point is the two NULLs: both handlers are initialised without a container.
Contract 2 — deferred container binding
The Playground page is the consumer of the IPC LCD console and the IPC widget manager. It binds on create, ui-then-lcd:
s_uxui_container = content;
s_terminal_container = content;
and it must NULL both on destroy, in the same order:
void page_playground_destroy(void)
{
s_uxui_container = NULL;
s_terminal_container = NULL;
ipc_ui_set_container() is a one-line forward to ui_widget_mgr_set_parent() (ipc_ui.c:918-921). Why the NULL matters: the page is torn down by lv_screen_load_anim(..., auto_del); if the IPC handlers still hold the old container, the next command from CM33_NS writes into freed LVGL objects — a use-after-free that surfaces as a HardFault some time later, on an unrelated command. ui_widget_mgr_get_parent() is nullable for exactly this window (between set_container(NULL) and the next page's bind) and every one of its five callers null-checks it (ipc_ui.c:443,516-524,560,859).
The related teardown-before-restart idiom: widgets are cleared before the restart command goes to CM33_NS, so the far side cannot push into a half-cleared table:
if (cmd == UXUI_CTRL_RESTART) {
send_ipc_cmd(IPC_CMD_RESTART_SCRIPT);
}
Contract 3 — the single-consumer snapshot
The 33 ms timer (armed at the end of sensorhub_ui_init, B2 step 16) takes one snapshot into a stack-owned sensorhub_snapshot_t and fans it out through pm_render():
static void sensorhub_timer_cb(lv_timer_t *timer)
{
(void)timer;
pm_render(&s_pm, &snap);
if (!s_pm.animating) {
pm_update_topbar(&s_pm);
}
}
ipc_sensorhub.h:66-67: "Safe to call from any task context" — but "Clears the 'changed' flags after reading." Therefore exactly one consumer per tick: a page that takes its own snapshot inside render_cb steals the changed flags from every other page. Pages receive the snapshot as an argument; they do not call ipc_sensorhub_snapshot() themselves.
Idiom — edge-detected topbar state
ipc_sensorhub_wifi_connected() is polled; there is no callback API. The topbar compares against the current LVGL flag and only adds/clears on a change — an unconditional add/clear every tick causes visible flicker:
if (pm->wifi_lbl) {
bool hidden = lv_obj_has_flag(pm->wifi_lbl, LV_OBJ_FLAG_HIDDEN);
if (connected && hidden) {
lv_obj_clear_flag(pm->wifi_lbl, LV_OBJ_FLAG_HIDDEN);
} else if (!connected && !hidden) {
lv_obj_add_flag(pm->wifi_lbl, LV_OBJ_FLAG_HIDDEN);
}
}
The clock is gated on ipc_sensorhub_ntp_synced() before the RTC is read at all (ipc_sensorhub.h:99: Cy_RTC_GetDateAndTime only after the CM33_NS NTP notification). True is necessary, not sufficient — the fields are still range-validated, and the label is strcmp-checked before lv_label_set_text to avoid card-row flicker, at roughly 6 fps and skipped while animating:
static const char * const dow[] = {
"", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"
};
static const char * const mon[] = {
"", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
};
cy_stc_rtc_config_t rtc;
Cy_RTC_GetDateAndTime(&rtc);
if (rtc.month >= 1 && rtc.month <= 12 &&
rtc.dayOfWeek >= 1 && rtc.dayOfWeek <= 7) {
char buf[32];
snprintf(buf, sizeof(buf), "%s %d %s %02d:%02d",
dow[rtc.dayOfWeek], (int)rtc.date,
mon[rtc.month], (int)rtc.hour, (int)rtc.min);
if (strcmp(lv_label_get_text(pm->time_lbl), buf) != 0) {
lv_label_set_text(pm->time_lbl, buf);
}
Idiom — the console/widget toggle
ipc_lcd_toggle_panel() is a blind toggle with no idempotent set form. Always guard it with ipc_lcd_is_panel_visible(); console and widgets are mutually exclusive; the order is asymmetric (hide widgets then show console, but hide console then show widgets); the unread counter is cleared explicitly only on the transition into console mode:
static void console_toggle_cb(lv_event_t *e)
{
(void)e;
s_console_mode = !s_console_mode;
if (s_console_mode) {
if (s_console_badge) {
lv_obj_add_flag(s_console_badge, LV_OBJ_FLAG_HIDDEN);
}
}
} else {
}
}
if (s_console_btn_lbl) {
lv_label_set_text(s_console_btn_lbl,
s_console_mode ? LV_SYMBOL_EYE_OPEN : LV_SYMBOL_LIST);
}
}
ipc_lcd_has_unread() is a pure read — it does not clear — and is only meaningful while the console is hidden, polled from the per-tick render hook:
{
(void)snap;
if (s_console_badge && !s_console_mode) {
lv_obj_remove_flag(s_console_badge, LV_OBJ_FLAG_HIDDEN);
}
}
}
All of the above runs in GFX-task context (LVGL event callbacks and LVGL timers). The IPC ISR half only enqueues; LVGL work happens on the 50 ms timer (ipc_ui.h:7-8).
Step-by-step
Step 1 — Watch the topbar WiFi glyph un-hide on connect
Connect the board to WiFi by whichever route you have (the UI page, Chapter C2; or on mtb-mpy wifi.connect(...) at the REPL, Chapter C1).
- What you should observe
- The topbar WiFi glyph appears once, on the transition, and stays. It does not blink. On disconnect it disappears once. On mtb-mpy the REPL path prints [WiFi] Connecting… / [WiFi] Connected! from modwifi.c:240-282. The UI path prints nothing — the glyph itself is the observable. Do not wait for a [wifi-glue] line: those print only inside app_wifi_connect_direct() (wifi_init.c:225-242), which nothing on the REPL, UI or boot path calls — its only caller is the archived BLE radio scheduler, linked under ENABLE_PAGE_BENTO_BUDDY=1 (Chapter I1). [WiFi-Boot] and [WiFiIPC] are muted and will not appear.
Step 2 — Watch the clock appear only after NTP
- What you should observe
- No clock text in the topbar until CM33_NS has completed an NTP sync and notified CM55. Then the time appears and updates about six times a second (not every 33 ms tick). If the board has no route to an NTP server the clock never appears; that is the gate working, not a fault.
Step 3 — Exercise the unread badge on the Playground page
Open the Playground page. Run something on the mtb-mpy REPL that prints to the IPC console (any print() while the console panel is hidden).
- What you should observe
- With the console hidden, the unread indicator becomes visible on the next render tick (ipc_lcd_has_unread() polled from the render hook). Toggle the console: the indicator is cleared on the transition into console mode (ipc_lcd_clear_unread() is explicit and separate from the read). Toggle back: widgets return, console hides — in that order.
On mtb-only there is no REPL to print from; the console panel and toggle still exist and the mechanics apply, but this step has no driver on that variant.
Step 4 — Read the negative case; do not perform it
Read the unbind snippet again and imagine it absent.
- What you should observe
- Nothing — this is a described failure, not a performed one. What would happen: navigate away from Playground, then send any widget command from CM33_NS; the handler writes through a container pointer to an LVGL object that lv_screen_load_anim(auto_del) already freed. The fault arrives later and elsewhere, as LED1+LED2 in groups of three and 0xDEAD0003 at 0x28000000 (Chapter A3). There is no safe recipe for it and none is given.
Traps
- Appendix X #7 — blind ipc_lcd_toggle_panel() without is_panel_visible() inverts state. There is no set form; guard every toggle.
- Appendix X #8 — forgetting set_container(NULL) on page destroy = use-after-free. Both ipc_ui_set_container(NULL) and ipc_lcd_set_container(NULL), in the destroy callback, in the same ui-then-lcd order as the bind.
- Appendix X #9 — ipc_sensorhub_snapshot clears changed flags; one consumer per tick. Pages take the snapshot they are handed.
- Appendix X #14 — PAGE_ID ordinals are ABI. The archive hard-codes PAGE_ID_PLAYGROUND's ordinal (dist/ipc_core/PROVENANCE.txt); a project with a different page_id_t order links with no diagnostic and compares the wrong page.
- Calling ui_widget_mgr_init() yourself. ipc_ui_init() calls it (ipc_ui.c:890); a second call double-initialises the widget table.
- Treating the ui_widget_mgr_* primitives as a C API. Their only caller is the IPC command switch process_ui_command(); the real callers are MicroPython scripts crossing IPC (Group F, Chapter F2).
- Calling cm55_ipc_communication_setup() twice (wifi_manager.c:65).
- LVGL from the wrong task. Every call in this chapter is GFX-task context. Widget creation from an IPC ISR, or from any other task, is a HardFault.
Variant applicability
- Variant
- mtb-mpy and mtb-only. Every function in this chapter is in libbento_ipc.a and CM55 is variant-agnostic. Step 3's driver (printing to the IPC console) is a MicroPython action and so has no mtb-only equivalent; the CM55-side mechanics are identical.