Sprint 0 Retro: Project Setup & Hardware Verification
Goal: Get every piece of hardware talking before writing a single line of application logic.
Verdict: All hardware alive. Modular architecture in place. Ready for WiFi. ╰(°▽°)╯
What We Built
A LilyGo T-Display ESP32 running a full integration test. The ST7789 screen shows a boot splash and fades in via PWM backlight, the EC11 rotary encoder drives an interactive UI with rotation/click/long-press detection, and two bare LEDs respond to encoder input.
All orchestrated from a thin main.cpp that owns nothing and delegates everything.
Five modules, clean separation:
display/owns TFT_eSPI + backlight GPIOinput/owns encoder pins + quadrature ISRled/owns LED pins + PWM channelsmain.cppis pure orchestration
The Debugging Stories
The Invisible Text Mystery ◝(⁰▿⁰)◜
First screen test. Navy blue background appeared. Colored corner rectangles rendered perfectly. But zero text. None. The screen was just… vibing with shapes and colors, ignoring our words completely.
No error. No warning. No crash. Just silence where words should be.
What happened: TFT_eSPI’s -DUSER_SETUP_LOADED=1 flag tells the library “I’ll handle all configuration.” And it means all of it. Including font loading. Without explicit -DLOAD_GLCD=1, -DLOAD_FONT2=1, etc. in build flags, the font data simply isn’t compiled in. The library doesn’t complain. It just renders nothing. Thanks for the heads up, TFT_eSPI (¬_¬)
Takeaway: When a library says “user setup loaded,” it means the entire default config is skipped. Read what that default config actually does before overriding it.
The Boot Flash ◉_◉
After getting the display working beautifully, a white flash appeared during boot. A bright square blinking before our content rendered. Not great for a project that’s supposed to look polished.
First attempt: Move PWM setup before tft.init(). This broke the fade-in entirely. Oops.
Down the rabbit hole: We dug into TFT_eSPI’s source and cross-referenced with the Bruce firmware (an ESP32 project known for solid T-Display support). Found the culprit: TFT_eSPI’s init() internally calls digitalWrite(TFT_BL, HIGH) when TFT_BL is defined. It just… blasts the backlight on at full brightness during the ST7789 reset sequence. The framebuffer is full of garbage colors at that point, so you get a lovely flash of nonsense.
The fix: Remove -DTFT_BL=4 from build flags entirely. TFT_eSPI doesn’t know the backlight pin exists. We hold GPIO 4 LOW before init, let TFT_eSPI do its thing in the dark, draw our content onto the invisible screen, then smoothly fade the backlight in via LEDC PWM. Boot goes from “camera flash in your face” to “gentle sunrise” ꒰ᐢ⸝⸝•‧̫•⸝⸝ᐢ꒱
Takeaway: Libraries are great for protocols and algorithms, but never hand a library a GPIO pin without understanding exactly what it’ll do with it. This became our design principle: own the hardware layer.
The Encoder That Couldn’t Count (⊙_⊙)
First encoder implementation: simple ISR on CLK falling edge, read DT to determine direction. Clockwise worked perfectly. Counter-clockwise had phantom CW events mixed in. The encoder was gaslighting us.
Second attempt: Proper quadrature state machine with a 16-entry lookup table (same approach used by mathertel/RotaryEncoder). More robust! But then nothing printed. Position counter stayed at zero. We went from wrong answers to no answers. Progress? (╥﹏╥)
What actually happened: The EC11 encoder produces 4 raw state transitions per physical detent click. The code was dividing the delta by 4 and clearing it each loop iteration. But individual deltas were always 1 or 2, never reaching 4. Integer division by 4 on values less than 4 equals zero. Every single time. The math was technically correct and completely useless.
The fix: Track the absolute raw position in the ISR. In the main loop, read position / 4 to get the detent count. Compare detents between iterations, not raw ticks. Reliable at any rotation speed, slow or fast.
Takeaway: Understand the physical characteristics of your hardware. An EC11’s “one click” is actually four quadrature state transitions. If your math doesn’t account for that, the firmware and the hardware will politely disagree about reality.
Architecture Decisions
Separation of Concerns from Day One
Started with everything in main.cpp. Refactored into dedicated modules early, before the file got fat. Each module owns its hardware pins and internal state, exposing only a clean API through its header. main.cpp calls init functions and reads module APIs. It never touches a GPIO directly.
This isn’t over-engineering. It’s the minimum viable architecture for a project that will eventually juggle WiFi, HTTPS, Spotify API polling, UI rendering, encoder input, and LED status on a microcontroller. Future-you will thank past-you for not stuffing 800 lines into one file (シ_ _)シ
LVGL Adoption (Future)
During encoder testing, text clipped at the screen edge. Raw TFT_eSPI has no layout engine, no text wrapping, no “please don’t draw outside the box” logic. This prompted the decision to adopt LVGL from Sprint 4 onward for proper layout management, text scrolling, animations, and encoder-driven navigation. Sprints 0-3 stay with raw TFT_eSPI for hardware verification and API work where a full UI framework would be overkill.
LED Brightness: 1 out of 255
The initial plan called for LED brightness around 60-80 out of 255. After testing the full range with bare LEDs at desk distance, the winning value was 1 out of 255. One. Bare LEDs with no diffuser at arm’s length are far brighter than you’d expect. A status indicator should whisper, not interrogate you at 2am (¬‿¬)
By the Numbers
| Metric | Value |
|---|---|
| Flash usage | 25.2% (330 KB / 1.3 MB) |
| RAM usage | 6.9% (22 KB / 327 KB) |
| GPIO pins used | 11 (6 TFT + 1 backlight + 3 encoder + 2 LED) |
| LEDC PWM channels | 3 (backlight, green LED, red LED) |
| ISR-driven pins | 2 (encoder CLK + DT) |
| Modules | 4 (display, input, led, main) |
| Bugs that silently did nothing | 2 (invisible text, zero-division encoder) |
| Bugs that screamed in your face | 1 (boot flash) |
What’s Next
Sprint 1: WiFi & Network Foundation. Connect to home WiFi, sync time via NTP, establish HTTPS capability. The ESP32 learns to talk to the internet. And with NTP, it’ll know what time it is when Spotify isn’t playing.
Sprint 0 complete. All hardware alive. Time to go online. ᕙ(⇀‸↼‶)ᕗ