Skip to content

LVGL ownership

LVGL is single-threaded by contract. Every lv_* call on this firmware must happen on the UI task OR under g_lvglMutex held by the calling task. Sources: src/main.cpp (taskUI), src/ui/widgets/widget_tag_pool.cpp, src/diag/lvgl_assert_lock.h.

Who runs what

TaskCoreLVGL access policy
taskUI1Owns LVGL by default; takes g_lvglMutex around lv_task_handler and around widget updates
taskCAN0Never touches LVGL. Writes only to SignalStore (its own portMUX)
taskUSB1Must take g_lvglMutex before any LVGL call. The PUT_CONFIG burn path holds it for the full storage write
taskBLE1Same rule as USB: take the mutex before any LVGL call
Arduino loopTask (boot only)Owns LVGL implicitly because taskUI hasn’t spawned yet

The boot phase is single-threaded by construction (taskUI is spawned at the end of BootSequence::run), so WidgetTagPool::allocRaw accepts a null mutex holder during boot via the explicit holder == nullptr || holder == self check in assertUiThreadHoldsLvglMutex(). Stripping the null-tolerant branch (#1058) regressed boot (#1061) and was re-added.

The mutex contract

g_lvglMutex is a binary mutex held across:

  • lv_task_handler() in taskUI’s main loop
  • Any lv_* call from a non-UI task (USB transport, BLE transport, settings, etc.)
  • Page navigation triggered from a non-UI task

The wait threshold is 10 ms (xSemaphoreTake(g_lvglMutex, pdMS_TO_TICKS(10)) at the taskUI site). PerfCounters::recordSample emits a LOG_WARN("PERF", "lvgl mutex wait %u µs (>%u)") when the wait crosses PERF_MUTEX_WAIT_WARN_US = 5000 µs — half the timeout, so a crossing means taskUI is at risk of skipping its own frame.

Why not lv_lock?

LVGL 8.3 ships an opt-in lv_lock/lv_unlock shim that wraps a user-provided mutex. We don’t use it — g_lvglMutex is taken directly so the wait time can be measured via PerfCounters and so the boot-phase null-holder exception is expressible. If we ever migrate to LVGL 9 the shim becomes mandatory; the call sites then need a one-line swap from xSemaphoreTake(g_lvglMutex, …) to lv_lock_timeout(…).

Touch events route through the indev layer, not LVGL events

GestureController::checkGestures() reads lv_indev_get_gesture_dir(indev) from taskUI directly instead of listening for LV_EVENT_GESTURE. Reason: buttons frequently absorb LV_EVENT_GESTURE before it bubbles to the screen — the indev layer is the only reliable cross-button gesture source.

SettingsPage drag tracker

SettingsPage drag is implemented at the indev layer too — GestureController::updateDrag polls the indev state. After release (LV_INDEV_STATE_RELEASED) when drag was tracking, the controller calls lv_indev_reset_long_press + lv_indev_reset to clear LVGL’s velocity- based gesture latch. Without this, a swipe-up that closes settings also opens the diag drawer because both react to LV_DIR_TOP on the same finger motion.

lv_refr_now and task coupling

BurnOverlay::show() calls lv_refr_now(nullptr) to force a synchronous redraw before the caller’s long storage write blocks rendering. The flush callback (DisplayDriver::flushCallback) runs on the calling task — for the PUT_CONFIG path that caller is taskUSB. This is safe because LVGL is single-threaded as long as g_lvglMutex is held, and handlePutConfig explicitly takes the mutex around the entire burn window. SPI writes from taskUSB work the same as from taskUI.

If LVGL is ever reworked for partial-buffer + SPI-DMA, the flush-complete semaphore becomes bound to the calling task and this assumption breaks — revisit both BurnOverlay::show() and handlePutConfig at that point.

Theme rebuild fast path

PageManager::updateWidgets() distinguishes between a full reload (config changed) and a theme rebuild (s_rebuildRequested):

  • Reload tears down every page screen and reruns buildAllPages().
  • Theme rebuild calls reapplyThemeAllPages() which walks the existing widget tree and updates style colours in place. The destructive theme- toggle rebuild used to push touch→click latency past 200 ms; #1257’s in-place reskin brings it to sub-ms.

LVGL_ASSERT_LOCKED() canary

PageManager::navigateTo and several other entry points assert that g_lvglMutex is held by the calling task. The assertion uses xSemaphoreGetMutexHolder and tolerates the holder == nullptr boot case (same rule as WidgetTagPool). buildUI() in boot_sequence.cpp takes the mutex briefly around PageManager::navigateTo to honour the contract even though boot is single-threaded.