Sprint 4.5 Retro: Async HTTP (Dual-Core)
Goal: Move blocking HTTP requests off the render loop onto ESP32’s second core. Eliminate the ~300ms stutter every poll cycle.
Verdict: Two bugs fixed with one semaphore. The progress bar is butter-smooth now, but the real surprise was the title scrolling. LVGL’s circular scroll animation was getting stomped every 3 seconds by the blocking HTTP request, causing visible “flashes” where the scroll position would glitch. We didn’t even know that was a bug until it vanished. The ESP32 has two cores and we’re finally using both of them like nature intended ╰(°▽°)╯
What We Built
A FreeRTOS dual-core architecture. Core 0 does the dirty work (HTTPS requests), Core 1 stays pristine for rendering, input, and interpolation. The API surface didn’t change at all. spotifyPoll() still returns 1/0/-1. Callers have no idea they just went async.
The Architecture
Core 1 (Arduino loop) Core 0 (FreeRTOS task)
───────────────────── ─────────────────────
spotifyPoll() spotifyPollTask()
├─ consumePollResult() ├─ xSemaphoreTake (sleep)
│ └─ mutex read ├─ spotifyGetNowPlaying()
├─ interval/rate checks │ └─ HTTPS + JSON (blocking)
└─ xSemaphoreGive (wake) └─ mutex write (result)
└─ back to sleep
nowPlayingTick() ← every frame
lv_timer_handler() ← every frame
encoder polling ← every frame
Three moving parts:
- Binary semaphore (
pollWakeSemaphore): Core 1 gives, Core 0 takes. The task sleeps onportMAX_DELAYwhen idle. Zero busy-polling, zero CPU waste. - Mutex (
sharedResultMutex): Protects thePollResultstruct. Core 0 locks to write track data + result code, Core 1 locks to read and clear the flag. Mutex hold time is a struct copy, nothing more. - Busy flag (
pollTaskBusy): Volatile bool. Core 1 checks before signaling to avoid queue buildup if the previous request is still in flight.
The Invisible API Change
spotifyPoll() went from “block for 300ms and return with data” to “return immediately with whatever’s ready.” The transition:
Before: interval elapsed → spotifyGetNowPlaying() (blocks) → return result
After: check for completed result from Core 0 → if interval elapsed, signal Core 0 → return 0 (data arrives next frame)
The one-frame delay between trigger and result is invisible. At 30fps that’s 33ms. The old blocking path was 300ms. That’s a 9x improvement in responsiveness, and the data is only “late” by one frame.
The Surprise Bug Fix (☆▽☆)
We came here to fix the progress bar stutter. We got that. But we also accidentally fixed LVGL’s title scroll animation.
LVGL’s LV_LABEL_LONG_SCROLL_CIRCULAR uses internal timers driven by lv_timer_handler(). Every 300ms, when the HTTP request blocked, those timers couldn’t fire. The scroll animation would freeze, then jump to catch up, creating a visible “flash” every poll cycle. We thought it was a bug in LVGL’s scroll implementation. Nope. We were just choking the event loop every 3 seconds.
Lesson: if your animations are glitchy, check if something is blocking the render loop before blaming the animation library.
Things That Just Worked
- FreeRTOS on ESP32.
xTaskCreatePinnedToCore()is a one-liner. The ESP32’s dual-core FreeRTOS is genuinely ergonomic. Binary semaphore + mutex pattern is textbook and it just works. - The API boundary. Because
spotifyPoll()already had a clean return contract (1/0/-1), the async transformation was purely internal.main.cpp’s loop didn’t change.now_playing.cppdidn’t change.ui.cppdidn’t change. Zero collateral edits. - Stack sizing. 16KB for the HTTPS task. WiFiClientSecure + ArduinoJson + all the String allocations fit comfortably. No stack overflows, no watchdog triggers.
By the Numbers
| Metric | Value |
|---|---|
| Flash usage | 96.1% (1,259 KB / 1.3 MB) |
| RAM usage | 18.0% (59.0 KB / 327 KB) |
| Flash increase from Sprint 4 | +0.1% (+668 bytes for FreeRTOS task code) |
| RAM increase from Sprint 4 | +144 bytes (task handle, semaphores, PollResult struct) |
| Task stack size | 16,384 bytes |
| Task core | Core 0 |
| Task priority | 1 (same as Arduino loop) |
| Files modified | 3 (spotify_api.h, spotify_api.cpp, main.cpp) |
| Files created | 0 |
| Lines added | ~105 |
| Lines removed | ~10 |
| Bugs fixed (expected) | 1 (progress bar stutter) |
| Bugs fixed (surprise) | 1 (title scroll flashing) |
What’s Next
Sprint 5: Encoder Input & Playback Control. The ESP32 has eyes and a brain. Now it gets hands. Encoder rotation for volume, click for play/pause, long-press for menu. The async architecture we just built means encoder polling runs every frame with zero network interference. Snappy input from day one.
Sprint 4.5 complete. The smallest sprint by line count, the biggest by impact. One semaphore, one mutex, one busy flag. Two bugs fixed, zero API changes, and 144 bytes of RAM. Sometimes the best code is the code that makes other code stop tripping over itself. ᕙ(⇀‸↼‶)ᕗ