Overview
The firmware is one Arduino-ESP32 binary that wakes up four FreeRTOS tasks, each owning a clearly-defined slice of the runtime. Nothing in here is exotic — the load-bearing decisions are heap budgets, mutex ownership, and a handful of sequencing rules. Get those right and the rest is widgets.
The runtime, in one picture
┌────────────────────────────────────────┐
│ taskUI (core 1) │
│ lv_task_handler · widget tick · gesture│
│ owns g_lvglMutex │
└──┬──────────────┬────────────────┬─────┘
│ │ │
reads │ reads │ reads │ tick / draw
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ SignalStore │ │ ErrorStore │ │ PageManager │
│ portMUX │ │ portMUX │ │ LVGL trees │
└──────▲──────┘ └──────▲───────┘ └──────────────┘
│ writes │ writes
┌───────────────┴────────────┐ │
│ │ │
┌──────┴─────────┐ ┌────────┴──┴────────┐ ┌──────────────────┐
│ taskCAN core 0 │ │ taskUSB core 0 │ │ taskBLE core 0 │
│ TWAI → decode │ │ JSON lines · 10 Hz │ │ NimBLE GATT │
│ ECU profiles │ │ PUT_CONFIG · scan │ │ telemetry mirror │
└────────┬───────┘ └─────────┬───────────┘ └────────┬─────────┘
│ │ │
▼ ▼ ▼
CAN bus Web Serial (tuner) Mobile app
Tasks on core 0 handle I/O — CAN, USB, BLE. Core 1 is reserved for the UI loop so a stalled host link cannot cost a frame. Cross-task state lives in the two stores (SignalStore, ErrorStore) and is guarded by portMUX_TYPE — RMW under a critical section, no LOG / no ALLOC / no LOCK inside it.
Where to look next
- Step 1 · cold start
Boot sequence
Heap reservation order, the contiguous-block budget, why
lv_initgoes after BLE and before USB rxBuf.Read → - Step 2 · concurrency
LVGL ownership
Who may call
lv_*, who must takeg_lvglMutex, and where the deadlocks live.Read → - Step 3 · the UI
Page lifecycle
Build, lazy build, release. Why a fresh page hesitates and how the swipe stays smooth.
Read → - Step 4 · host link
USB transport
Sinks, the per-task dispatch shortcut, the PUT_CONFIG burn flow, and why a custom brace walk parses the envelope.
Read → - Step 5 · mobile link
BLE transport
NimBLE topology, GATT layout, and the stop-race snapshot pattern that keeps disconnect deterministic.
Read → - Step 6 · a hard widget
Cruise template
The L-shape buttons, the LVGL convex-polygon workaround, and the per-corner Bezier sampling.
Read → - Cross-cutting
ErrorStore
The ring buffer that backs the ErrorBar, with the same critical-section invariant as SignalStore.
Read → - Cross-cutting
SignalStore
Runtime signal table —
taskCANwrites, the rest read. Critical-section invariant in detail.Read →
Three rules that explain most of the source
- Heap order
- Anything contiguous > 16 KB must be reserved before
lv_init. The post-init largest free block drops to ~13–18 KB on a no-PSRAM WROOM. - LVGL ownership
- Any
lv_*call from outsidetaskUImust holdg_lvglMutex. taskUI never needs to take it — it owns the lock by being the only writer. - Store critical sections
- SignalStore and ErrorStore mutate under
portMUX_TYPE. No LOG, no ALLOC, no LOCK insideportENTER_CRITICAL.
Reference shortcuts
- Pinout — GPIO contract for the reference build.
- Build flags — compile-time switches (panel, CAN baud, BLE on/off).
- Signals — every signal the firmware knows about, with units and source.
- Dev setup — PlatformIO env, clang-format gate, monorepo wiring.