Learning goal
Why one UART line every 10 seconds exists on mtb-only, what it is not (an LED, an MQTT publish, the CM55 counter), and the debugger-attach trap it was created to expose. By the end you treat [HB] as the variant's liveness instrument and you do not attach a debugger to a running board.
The real firmware sequence
The whole feature is eleven lines of task plus one xTaskCreate, in proj_cm33_ns/main.c, mtb-only branch only.
The task (main.c:105-115):
static void bento_heartbeat_task(void *arg)
{
(void)arg;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(10000));
printf("[HB] t=%lus tasks=%u\r\n",
(unsigned long)(xTaskGetTickCount() / configTICK_RATE_HZ),
(unsigned)uxTaskGetNumberOfTasks());
}
}
Its creation, first thing in the #else (mtb-only) block — before storage, before config, before the IPC pipe — with the rationale comment the design requires verbatim (main.c:292-303):
xTaskCreate(bento_heartbeat_task, "HB", 256, NULL, 1, NULL);
The comment, quoted in full so it survives any future edit to the snippet:
/* Liveness heartbeat over UART, every 10 s.
*
* This variant has no REPL, so with the boot prints muted the only sign of
* life used to be the debugger — and attaching the debugger to a running
* board parks CM33 in a boot-ROM loop (measured 2026-08-28; the mpy
* variant tolerates the same attach, cause not established). One line
* every ten seconds is what let that be discovered at all: a heartbeat
* that kept beating for six minutes detached, after an hour of postmortems
* that all blamed the firmware. Keep it until the variant has an
* instrument that is not also the murder weapon. */
xTaskCreate(bento_heartbeat_task, "HB", 256, NULL, 1, NULL);
Parameters: 256 words of stack, priority 1 (the lowest above idle), no handle kept. It cannot starve anything and nothing can be blocked on it.
What it is not
- Not the CM55 app_task_beat. proj_cm55/main.c increments a volatile uint32_t app_task_beat every 500 ms in app_task; that is a counter surfaced on the Joystick diagnostics page — no UART, no LED. The source says the LED blink was removed "so users can control LED1 from
Python without conflict."
- Not an MQTT heartbeat. tesaiot_mqtt.c has a keepalive config field (tesaiot_config_store.c:127) and a telemetry topic default (device/s/telemetry, tesaiot_mqtt.c:24), but no periodic heartbeat publish task exists in this tree. MQTT keepalive is the broker session's PINGREQ, handled inside the MQTT library; it says nothing about the rest of the firmware.
- Not present on mtb-mpy. The task lives inside #if BENTO_HAS_MPY …
#else. The mtb-mpy liveness signal is the REPL and the single [MPY] GC heap u KB @ p in s line at boot (mpy_main.c:552-554).
Step-by-step
Step 1 — Establish the cadence
Power-cycle an mtb-only board with the console open at 115200.
- What you should observe
- Ten seconds after the scheduler starts, [HB] t=lus tasks=u (main.c:110-112) — the mtb-only dist README's example is [HB] t=10s tasks=… (:57). Then every 10 s, t advancing by 10, tasks steady once boot has settled (it rises during the first few seconds as the WiFi, MQTT and HSM tasks are created). It is the only positive boot signal on this variant; every other successful step is silent (Chapter A2).
Step 2 — Use it as the instrument during a postmortem
Something on the board appears to have stopped — the screen froze, a page does not respond. Before reaching for the debugger, look at the console.
- What you should observe
- If [HB] is still arriving every 10 s, CM33_NS is scheduling: the problem is in a task, a page, or on CM55 — not a dead core. The comment's own history is the lesson: "a heartbeat that kept beating for six minutes
detached, after an hour of postmortems that all blamed the firmware." If [HB] has stopped, CM33_NS is halted or looping with the scheduler stalled — now the SRAM fault markers and LED codes (Chapter A3) are the next read, after a power-cycle if you need the core back.
Step 3 — The trap, stated and not performed
- Warning
- Do not attach a debugger to a running mtb-only board. It parks CM33 in a boot-ROM loop (main.c:292-302, measured 2026-08-28). The mpy variant tolerates the same attach; the cause of the difference is not established. This is Appendix X #16 and is hoisted into Chapter A2 so it is read at the first flash.
- What you should observe
- Nothing — this step is an instruction not to do something. Had you attached: [HB] stops at once and does not resume on detach; the screen stays as it was (CM55 keeps running its last frame); only a power-cycle restores the core, and the heartbeat then restarts from t=10s. If you need a debugger on this variant, flash and halt-on-reset from a fresh programming session rather than attaching to a live board.
Traps
- Appendix X #16 — Debugger attach parks a running mtb-only CM33 in a boot-ROM loop (main.c:292-302).
- Mistaking silence for death. With every boot print muted or compiled out, an mtb-only board that is perfectly healthy prints nothing at all except [HB]. A console with nothing on it for nine seconds is normal.
- Mistaking [HB] for more than it is. It proves the scheduler runs and the UART works. It does not prove WiFi, MQTT, storage or CM55 — each has its own signal (Chapters G1, C3, B2).
- Looking for it on mtb-mpy. It is not there; see What it is not.
- Counting tasks as a health metric. It is uxTaskGetNumberOfTasks(), useful for spotting a task that was never created or one that leaked, not a load figure.
Variant applicability
- Variant
- mtb-only. The heartbeat task is compiled only under BENTO_HAS_MPY=0.