Learning goal
The GFX task owns everything; IPC comes up before the display; and there is one clean proof that the first frame was flushed. By the end you can name the step at which a CM55 boot stopped from the LED alone, and you know why a display failure does not take the IPC link with it.
The real firmware sequence
Two files are readable in the template — proj_cm55/main.c and the consumer code it hands off to. The display controller between them (tesaiot_display.c) is compiled into lib/cm55_core/…/libbento_cm55.a; its source lives in TESAIoT_KIT_PSE84_AI-Micropython-BentoClaw/proj_cm55/modules/lvgl_display/controller/ and is cited from there. The internal order inside the archive is attested by that source, by lib/cm55_core/consumer_must_provide.txt:64-65, and by bento_libs/claw/APIs/08_IPC_API.md:714-716 — state this plainly: a template consumer cannot read it.
- main() — cybsp_init(), __enable_irq(); failure is LED1 at 50 ms (proj_cm55/main.c:180-188).
- tesaiot_display_init() — one xTaskCreate, not an init (tesaiot_display.c:210-213: xTaskCreate(tesaiot_display_task,
GFX_TASK_NAME, GFX_TASK_STACK_SIZE, NULL, GFX_TASK_PRIORITY,
&rtos_cm55_gfx_task_handle); name "TESAIoT Gfx Task", priority configMAX_PRIORITIES - 1, tesaiot_display.h:41-43). Compare against pdPASS, not CY_RSLT_SUCCESS; failure is LED2 at 50 ms:
int main(void)
{
cy_rslt_t result;
#if TESAIOT_ENABLE_FACE_RUNTIME && TESAIOT_ENABLE_FACE_RUNTIME_BOOT
if (face_mode_runtime_requested()) {
face_mode_launch_runtime();
}
#endif
result = cybsp_init();
if (CY_RSLT_SUCCESS != result)
{
for (;;) {
Cy_GPIO_Inv(CYBSP_USER_LED1_PORT, CYBSP_USER_LED1_PIN);
Cy_SysLib_Delay(50);
}
}
__enable_irq();
if (pdPASS != xResult) {
for (;;) {
Cy_GPIO_Inv(CYBSP_USER_LED2_PORT, CYBSP_USER_LED2_PIN);
Cy_SysLib_Delay(50);
}
}
Nothing else happens on CM55 before the scheduler. app_task is created (:217-224) and the scheduler starts (:232).
- GFX task entry: the IPC pipe FIRST — tesaiot_display.c:223-227: "IPC Pipe — initialize FIRST so IPC works even if display init fails.
cm55_ipc_communication_setup() calls Cy_IPC_Pipe_Init()."* The implementation ships as source:
void cm55_ipc_communication_setup(void)
{
static const cy_stc_ipc_pipe_config_t cm55_ipc_pipe_config =
{
{
.ipcNotifierNumber = CY_IPC_INTR_CYPIPE_EP2,
.ipcNotifierPriority = CY_IPC_INTR_CYPIPE_PRIOR_EP2,
.ipcNotifierMuxNumber = CY_IPC_INTR_CYPIPE_MUX_EP2,
.epAddress = CM55_IPC_PIPE_EP_ADDR,
{
.epChannel = CY_IPC_CHAN_CYPIPE_EP2,
.epIntr = CY_IPC_INTR_CYPIPE_EP2,
.epIntrmask = CY_IPC_CYPIPE_INTR_MASK
}
},
{
.ipcNotifierNumber = CY_IPC_INTR_CYPIPE_EP1,
.ipcNotifierPriority = CY_IPC_INTR_CYPIPE_PRIOR_EP1,
.ipcNotifierMuxNumber = CY_IPC_INTR_CYPIPE_MUX_EP1,
.epAddress = CM33_IPC_PIPE_EP_ADDR,
{
.epChannel = CY_IPC_CHAN_CYPIPE_EP1,
.epIntr = CY_IPC_INTR_CYPIPE_EP1,
.epIntrmask = CY_IPC_CYPIPE_INTR_MASK
}
},
.endpointClientsCount = CY_IPC_CYPIPE_CLIENT_CNT,
.endpointsCallbacksArray = ep2_cb_array,
.userPipeIsrHandler = &Cy_SysIpcPipeIsrCm55
};
Cy_IPC_Pipe_Config(cm55_ipc_pipe_array);
Cy_IPC_Pipe_Init(&cm55_ipc_pipe_config);
{
cy_stc_sysint_t ep2_intr_cfg = {
.intrSrc = (IRQn_Type)CY_IPC_INTR_CYPIPE_MUX_EP2,
.intrPriority = (uint32_t)CY_IPC_INTR_CYPIPE_PRIOR_EP2
};
(void)Cy_SysInt_Init(&ep2_intr_cfg, &Cy_SysIpcPipeIsrCm55);
NVIC_EnableIRQ((IRQn_Type)CY_IPC_INTR_CYPIPE_MUX_EP2);
The rule, verbatim from 08_IPC_API.md:708: "cm55_ipc_communication_setup()
must be called before any Cy_IPC_Pipe_RegisterCallback(). Without this,
RegisterCallback accesses uninitialized pipe endpoints, causing a HardFault
at boot." The fault table at :765-771 gives the symptom and the fix: "Always call cm55_ipc_communication_setup() first in GFX task."* This function is consumer-provided (consumer_must_provide.txt) — you own the call. Never call it twice (wifi_manager.c:65).
- IPC clients register — ipc_sensorhub_init() (:228; header contract ipc_sensorhub.h:58: "Must be called AFTER
cm55_ipc_communication_setup()."), ipc_service_init() (:229; ipc_service.h:19), then deepcraft_task_init() under BENTO_HAS_MODEL_LINK (:231, "model-link peer — after pipe setup
(ordering rule)"). 5-10. Six LED-coded display steps — tesaiot_display.c:139 and :248: 1=GFXSS 2=DC_IRQ 3=GPU_IRQ 4=I2C 5=Panel 6=VGLite. Each failure sets disp_debug_step = N and jumps to ipc_only: (:270, :277, :285, :306/:313, :325, :345). Step 5's why (:295-297): "Panel
controller at 0x45 only responds AFTER GFXSS/DSI powers the display."
- LVGL init — lv_init(); lv_port_disp_init(); lv_port_indev_init(); (:349-357). lv_port_disp_init is template source (proj_cm55/modules/lvgl_display/core/lv_port_disp.c:267-285): one display, full render mode, disp_flush as the flush callback. The LVGL tick comes from the FreeRTOS tick hook (proj_cm55/main.c:53-62 → tesaiot_display_tick() → lv_tick_inc(1)).
- Audio, then the deterministic backlight — bento_audio_init(), a 1000 ms codec PLL settle, ws_panel_power_up(&bl_cfg) (:375-424). A NAKed panel-MCU write is 4 LED2 blinks (:432).
- UI creation — the handoff to consumer code (:435-443): the active screen is filled dark blue 0x003366 and sensorhub_ui_init(scr) is called.
- Page manager init + registration — template source, proj_cm55/modules/page-components/_core/sensorhub_ui.c: tesaiot_ui_styles_init(); pm_init(&s_pm); pm_set_instance(&s_pm); then the pm_register calls, one per page. The Edge AI registration is the exemplar (guarded BENTO_HAS_EDGE_AI; the page manager owns the lifecycle):
#if defined(BENTO_HAS_EDGE_AI) && (BENTO_HAS_EDGE_AI == 1)
{
page_def_t def = {
.name = "Edge AI",
.subtitle = "On-device inference",
.accent_color = UI_COLOR_ACCENT_PURPLE,
};
pm_register(&s_pm, PAGE_ID_EDGE_AI, &def);
}
- Home page created and loaded — page_home_create() then lv_screen_load(home_scr) (sensorhub_ui.c, end of sensorhub_ui_init); each card is bound to its page_id_t at page_home.c:622-625.
- 33 ms render timer — lv_timer_create(sensorhub_timer_cb, 33, NULL); the callback takes one snapshot and fans it out (Chapter B3).
- ipc_only: label, deferred binding, ready flag — tesaiot_display.c:470-479: (void)ipc_lcd_init(NULL); unconditionally, (void)ipc_ui_init(NULL); only if (display_ok), then tesaiot_display_ready = 1. Both take NULL — the Playground page binds the real containers later (Chapter B3). app_task waits on that flag, bounded:
static void app_task(void *arg)
{
CY_UNUSED_PARAMETER(arg);
uint32_t wait_count = 0;
{
vTaskDelay(pdMS_TO_TICKS(100));
wait_count++;
if (wait_count > 150) {
break;
}
}
- First frame — the GFX loop calls lv_timer_handler(), which invokes disp_flush; the DC interrupt completes the frame and notifies the GFX task:
static void LV_ATTRIBUTE_FAST_MEM disp_flush(lv_display_t *disp_drv,
const lv_area_t *area,
uint8_t *color_p)
{
CY_UNUSED_PARAMETER(area);
s_flush_disp = disp_drv;
s_flush_start_tick = xTaskGetTickCount();
s_flush_start_count++;
Cy_GFXSS_Set_FrameBuffer((GFXSS_Type*) GFXSS, (uint32_t*) color_p,
&gfx_context);
}
void lv_port_disp_flush_ready(void)
{
lv_display_t *d = (lv_display_t *)s_flush_disp;
s_flush_disp = NULL;
if (d != NULL) {
s_flush_ready_count++;
lv_display_flush_ready(d);
with a 500 ms missed-IRQ safety net that force-completes a flush:
void lv_port_disp_check_flush_timeout(void)
{
lv_display_t *d = (lv_display_t *)s_flush_disp;
if (d != NULL) {
TickType_t elapsed = xTaskGetTickCount() - s_flush_start_tick;
if (elapsed >= FLUSH_TIMEOUT_TICKS) {
s_flush_disp = NULL;
lv_display_flush_ready(d);
Step-by-step
Step 1 — Watch LED2 through bring-up
Power-cycle and watch LED2 only.
- What you should observe
- One blink then solid ON while Cy_GFXSS_Init runs (step 1, tesaiot_display.c:262-263), OFF when it returns; then OFF for good once display_ok = true; debug_led_off(); (:459-468, "LED2 OFF = display init
fully succeeded"). If instead LED2 settles into N quick blinks every 2 s, bring-up stopped at step N and the task is in the ipc_only loop (:581-582). Four blinks is the backlight NAK (:432).
Step 2 — Watch the screen
- What you should observe
- A solid dark-blue 0x003366 fill (:440) replaced within a frame or two by the Home card grid on its dark shell (page_home.c:366-373), with the version badge. On a board that stops at step 13 you would see only the blue fill; that state has not been observed on shipped firmware and has no recipe.
Step 3 — Prove the first frame (per variant)
- What you should observe — both variants
- LED2 OFF and the Home grid visible. That is the human-eye proof.
- What you should observe — mtb-mpy only
- At the REPL, ui._diag() (modui.c:1470) returns the GFX task's own counters via ipc_ui_platform_diag() (tesaiot_display.c:593-609): DC IRQ status, flush_start_count, flush_ready_count, flush_timeout_count, GFX stack high-water mark, idle percent. flush_start_count incrementing between two calls is the cleanest "first frame worked" assertion on this variant — it is disp_flush being entered (lv_port_disp.c:157). A non-zero flush_timeout_count means the 500 ms safety net fired: the DC interrupt was missed at least once.
- What you should observe — mtb-only
- There is no REPL, so no ui._diag(). The programmatic equivalent is a C-side call to ipc_ui_platform_diag(out, max_words) with max_words >= 10 from your own CM55 code (it returns the same ten words). Otherwise: LED2 OFF (:459-468) plus the Home grid.
Step 4 — Prove IPC survives a display failure
Do not induce one. Reason from the code instead: ipc_lcd_init(NULL) sits after the ipc_only: label and is not gated on display_ok; ipc_ui_init(NULL) is gated because it creates an LVGL timer (ipc_ui.c:911). tesaiot_display_ready therefore becomes 1 even when the panel never came up, and app_task proceeds.
- What you should observe
- On mtb-mpy, on a board with a display step failure (LED2 N-blinks), the console still works and import ui still succeeds — the pipe was set up at step 3, before anything display-related. tesaiot_display_ready's tri-state (proj_cm55/main.c:138: 0=pending, 1=display+IPC, 2=IPC-only
(headless)) is why consumers test == 0, never == 1.
Traps
- HardFault if step 3 is reversed. RegisterCallback before cm55_ipc_communication_setup() — 08_IPC_API.md:708, :765-771. The observable is LED1+LED2 in groups of three (HardFault, proj_cm55/main.c:246-254) and 0xDEAD0003 at 0x28000000.
- 08_IPC_API.md:711-717 prescribes an order the code does not follow. It lists ipc_lcd_init(parent) third and ipc_service_init() fourth with real parents; the shipped task calls ipc_service_init() at step 4 and both ipc_lcd_init(NULL)/ipc_ui_init(NULL) at step 17 with NULL — deliberate deferred binding (tesaiot_display.c:471-476). Only rule #1 (setup first) is load-bearing.
- Appendix X #1 — CM55 printf is a silent no-op once libbento_edge_ai.a is linked (ai_engine.c:276-288). Do not add prints to trace this sequence; use LED2 or ui._diag().
- Appendix X #13 — unbounded I2C timeouts on the shared display bus. The GFX task runs at configMAX_PRIORITIES - 1; a wedged clock-stretching device on the touch/panel bus with a block-forever timeout starves every lower task (cm55_sensor_poll.c:49-53).
- Appendix X #14 — PAGE_ID ordinals are ABI. Step 14's registrations use page_id_t values baked into libbento_ipc.a; renumbering compiles and misbehaves (page_id_ordinal_assert.c:22-25).
- Console ban. CM33_NS: Booting CM55... is BOOT_VERBOSE. Do not use it as the "CM55 was released" marker; the Home screen is.
- ui._diag() on mtb-only. It does not exist there. A tutorial step that requires it is an mtb-mpy step.
Variant applicability
- Variant
- mtb-mpy and mtb-only. CM55 is variant-agnostic — steps 1-18 are the same image. The Verify split is in Step 3: ui._diag() is a MicroPython REPL call and exists only on mtb-mpy; mtb-only verifies with LED2 OFF, the Home grid, or a C-side ipc_ui_platform_diag() call.