◀ sprint-5

← esp32-spotify-display

Sprint 5 Retro: Encoder Input & Playback Control

Goal: Give the encoder a job. Click toggles play/pause, double-click skips tracks, rotation adjusts volume with a purple arc overlay. All commands route through Core 0’s async task so Core 1 stays butter-smooth.

Verdict: The EC11 encoder went from “prints to Serial” to fully functional Spotify remote control. Click, double-click, rotate. Every input is instant on Core 1, every HTTP command fires on Core 0. The purple volume arc is a chef’s kiss moment on this tiny screen. Spotify said “no” to iPhone volume control (VOLUME_CONTROL_DISALLOW), but that’s Apple being Apple, not our problem ╰(°▽°)╯


What We Built

Async Command Queue

Core 1 (render loop) can’t touch HTTP. Core 0 (poll task) owns the WiFiClientSecure. We built a single-slot, last-writer-wins command queue that bridges them.

Core 1 (loop)                      Core 0 (task)
─────────────                      ────────────
encoder event                      xSemaphoreTake (sleep)
  └─ spotifyQueueCommand()           ├─ consumePendingCommand()
      ├─ write command + param       │   └─ mutex read + clear
      └─ xSemaphoreGive (wake)      ├─ executeCommand()
                                    │   └─ HTTP request (blocking)
volumeOverlayShow() ← instant      ├─ spotifyGetNowPlaying()
                                    │   └─ immediate poll after command
                                    └─ back to sleep

The existing pollWakeSemaphore wakes Core 0 for both polls AND commands. No second semaphore needed. Commands get a dedicated commandMutex because the data flows the opposite direction from poll results. Clean separation.

Double-Click State Machine

The encoder’s button state machine learned multi-click detection:

  1. Release after short press → increment clickCount, start 250ms timer
  2. Second release within 250msENC_DOUBLE_CLICK fires immediately
  3. Timer expires (no second click)ENC_CLICK fires
  4. Held 800ms+ENC_LONG_PRESS fires, cancels any pending click

The 250ms delay on single-click is the cost of double-click detection. It’s a design tradeoff. The knob’s physical feedback makes it feel right.

Volume Debounce

Every detent adjusts localVolume by 5% instantly. The API call waits. A 300ms debounce timer fires after the last rotation, sending one CMD_SET_VOLUME instead of spamming Spotify with 20 requests while you spin the knob.

Poll results sync localVolume back to reality, but only when there’s no pending debounce. Otherwise a stale poll would clobber the user’s in-progress adjustment.

Volume Arc Overlay

A 100x100px LVGL arc on lv_layer_top(). 270-degree sweep with the gap at the bottom. Purple accent fill on dark grey. Percentage label centered inside. Full-screen dimming panel underneath. Shows instantly on rotation, fades out with a 200ms opacity animation after 1.5s of inactivity.

Using lv_layer_top() means the overlay works across all future screens (menu, idle, etc.) without per-screen wiring. The fade-out uses LVGL’s animation system, so it runs inside lv_timer_handler() with zero manual frame management.


The Module Split

Sprint 5 was also when spotify_api.cpp got too fat. It was handling raw HTTP wrappers, JSON parsing, async task coordination, and command dispatch all in one file. We split it:

FileResponsibility
spotify_api.h/.cppRaw HTTP wrappers, JSON parsing, playback commands
spotify_task.h/.cppCore 0 task, shared state, command queue, polling

spotify_task.cpp calls into spotify_api.h for HTTP work. spotify_api.cpp calls spotifyGetCurrentTrack() from spotify_task.h for toggle/cycle state. Headers don’t include each other. Clean dependency graph.


Bugs We Hit

The 411 (Length Required)

Our first click on the device: Pause failed: 411. Spotify rejected our bodyless PUT request because Content-Length: 0 wasn’t being sent. ESP32’s HTTPClient::PUT() with a zero-length payload apparently doesn’t add the header automatically. One line in network.cpp fixed both PUT and POST.

This bug had been dormant since Sprint 3 when we wrote the playback commands but never tested them on hardware. Latent bugs love bodyless HTTP methods.

The 200 That Isn’t 204

Spotify docs say play/pause returns 204. Ours returned 200 with a 27-byte body. Every toggle said “failed” in serial even though it was working perfectly. The play/pause icon toggled on screen, the music played and paused, but our logs were screaming “FAILED” on every click.

Fix: accept both 200 and 204 as success. isCommandSuccess() checks both. The Spotify API documentation and the Spotify API implementation have a casual relationship with each other.

The iPhone Volume Wall

PUT /v1/me/player/volume returns 403: VOLUME_CONTROL_DISALLOW on iPhone. iOS handles volume at the system level and Spotify can’t override it via the API. Works fine on desktop. Not our bug, not our fix. The volume arc overlay still works visually regardless.

The Breadboard Ghost (╯°□°)╯︵ ┻━┻

During testing, clicks “stopped working” for ~18 seconds. No [INPUT] logs, no events, nothing. Cue frantic race condition analysis, FreeRTOS queue audits, semaphore traces. Root cause: a jumper wire got nudged while spinning the encoder on a breadboard. The encoder button’s GPIO connection went intermittent. Software was fine. Hardware was vibing.

Lesson: if your state machine is “stuck” and you’re running on a breadboard, wiggle the wires before rewriting the code.


Things That Just Worked

  • The wake semaphore. spotifyQueueCommand() gives the same pollWakeSemaphore as poll-interval wakeups. Core 0 always checks for commands before polling. If both arrive while busy, the binary semaphore latches and the task picks them up next iteration. No special cases.
  • LVGL lv_layer_top(). One line to parent the overlay, and it floats above everything forever. No z-index wrestling, no per-screen creation.
  • LVGL lv_anim_t for fade. Five lines to animate opacity from 255 to 0 with a completion callback that hides the widget. LVGL’s animation system is genuinely pleasant.
  • The arc visual. 270-degree sweep with gap at bottom feels exactly like a physical knob. The purple fill against the dimmed now-playing screen looks like it belongs there.

By the Numbers

MetricValue
Flash usage96.5% (1,265 KB / 1.3 MB)
RAM usage18.0% (59.1 KB / 327 KB)
Flash increase from Sprint 4.5+0.4% (+5,448 bytes)
RAM increase from Sprint 4.5+40 bytes
Files modified8
Files created4 (spotify_task.h/cpp, volume_overlay.h/cpp)
New modules2 (spotify_task, volume_overlay)
Command types4 (toggle, next, previous, set_volume)
Volume step5% per detent
Debounce delay300ms (volume), 250ms (double-click)
Arc fade-out1.5s delay + 200ms animation
Latent bugs fixed2 (Content-Length: 0, 200 vs 204)
Minutes debugging breadboard wiresToo many

What’s Next

Sprint 6: Album Art. JPEG decode + purple-tinted Bayer dithering. The left zone of the now-playing screen has been a purple placeholder rectangle since Sprint 4. Time to fill it with actual album art that looks like it belongs in our purple-black world.


Sprint 5 complete. The encoder has a job, the arc has a glow, and the breadboard has a grudge. Click, spin, skip. This thing is starting to feel like a product. ᕙ(⇀‸↼‶)ᕗ