◀ sprint-2

← esp32-spotify-display

Sprint 2 Retro: Spotify Authentication

Goal: Get the ESP32 authenticated with Spotify so it can actually talk to the API instead of getting 401’d like an uninvited guest.

Verdict: The 401 is dead. Long live the 200. The ESP32 introduced itself to Spotify, got a hall pass, and immediately used it to find out that Furkan’s Mac mini was just sitting there not playing anything. ╰(°▽°)╯


What We Built

A complete OAuth2 token flow spanning two platforms:

  1. A Python script (tools/get_token.py) that runs once on the Mac to perform the OAuth2 authorization code dance. Opens your browser, you log into Spotify, it catches the callback, hands you a refresh token. Pure stdlib, zero dependencies, run and forget.

  2. An ESP32 auth module (src/spotify/) that uses that refresh token to autonomously get fresh access tokens. Every hour, forever, no human intervention. Token stored in RAM (it dies in an hour anyway), expiry tracked in millis, auto-refresh kicks in 60 seconds before death.

The boot sequence now goes: splash → WiFi → NTP → Spotify auth → API test → clock. The old HTTPS test that proudly showed “401 (TLS works, no token yet)” has been replaced by an actual authenticated request that returns real data.


The Debugging Stories

The Polite Disconnect ◝(⁰▿⁰)◜

First authenticated API call to Spotify… hung. Forever. Serial printed GET https://api.spotify.com/v1/me/player and then silence. The screen stayed on “Authenticated” like a loading screen from 2003.

What happened: We have a single static WiFiClientSecure shared across all HTTPS requests. The token refresh POST goes to accounts.spotify.com. The API test GET goes to api.spotify.com. Different hosts, same TLS socket. The socket still had the previous TLS session state from accounts.spotify.com and got confused trying to start a new handshake to api.spotify.com. Classic connection reuse gone wrong.

First fix: http.setReuse(false) on every HTTPClient. This worked but the SSL layer threw a tantrum: (-76) UNKNOWN ERROR CODE (004C) on every request. The TLS connection was being terminated without a proper close_notify handshake. Like hanging up the phone mid-sentence. Functional but rude.

The real fix: Remove setReuse(false), add secureClient.stop() after http.end() in every request function. stop() sends a proper TLS close_notify, waits for acknowledgment, then closes the socket. Same result, zero error logs, slightly faster too. Manners matter, even in embedded TLS (◕‿◕)

Takeaway: If you’re sharing a WiFiClientSecure across requests to different hosts, you need to fully close the TLS session between them. http.end() alone isn’t enough. secureClient.stop() is the polite way. setReuse(false) is the rude way. Both work, but one doesn’t yell at you in the logs.

The Mac Mini That Wouldn’t Sleep ◉_◉

Expected behavior when nothing is playing on Spotify: HTTP 204 (No Content). What we actually got: HTTP 200 with 3373 bytes of JSON saying "is_playing": false and "device": "Furkan Mac mini".

Turns out Spotify considers a device “active” even when it’s not playing anything, as long as it was recently used. 204 only happens when there’s genuinely no active device anywhere. So “not playing” and “no active playback” are two different things in Spotify’s world.

This matters for Sprint 3 when we start polling the player state. We can’t just check for 204 as “nothing playing.” We need to check is_playing in the response body.

Not a bug, just Spotify being Spotify.


Architecture Decisions

Error Categorization Over Boolean Returns

spotifyAuth() started as bool (true/false). Then we needed error handling and quickly realized: a 400 (bad token, give up forever) and a network timeout (try again in a minute) need very different responses. So spotifyAuth() returns a SpotifyAuthError enum:

enum SpotifyAuthError {
    SPOTIFY_AUTH_OK,            // We're in
    SPOTIFY_AUTH_BAD_TOKEN,     // 400/401, token is dead, stop trying
    SPOTIFY_AUTH_NETWORK_ERROR, // Transient, try again later
    SPOTIFY_AUTH_PARSE_ERROR,   // JSON was weird
    SPOTIFY_AUTH_SERVER_ERROR,  // Spotify is having a day
};

This lets spotifyNeedsRefresh() make smart decisions: bad token flags permanent failure (don’t hammer Spotify), network errors retry once then back off (try again next cycle). The distinction between “your credentials are wrong” and “the internet hiccupped” is worth encoding in the type system.

The Redirect URI That Wasn’t

Spotify tightened their OAuth2 redirect URI rules in 2025. localhost is no longer allowed for new apps. You have to use the explicit loopback IP: http://127.0.0.1:8888/callback. Same destination, pickier bouncer. Our docs and script use 127.0.0.1 everywhere.

Pure Stdlib Python

The auth helper script uses zero external packages. No requests, no flask, no spotipy. Just urllib, http.server, webbrowser, and secrets from the standard library. For a run-once tool, a pip install requirement would be overkill. If Python can open a browser and run a web server out of the box, let it.


Things That Just Worked

  • Token refresh on the ESP32. First attempt, HTTP 200, 357 bytes, 3600 second expiry. No drama. The POST body was assembled using compile-time string literal concatenation ("&client_id=" SPOTIFY_CLIENT_ID), which means no runtime string building overhead.
  • ArduinoJson v7 parsing. JsonDocument doc; deserializeJson(doc, response); and done. Pulled access_token and expires_in out of the response JSON in two lines.
  • The Python auth script. Ran once, browser opened, logged in, callback caught, refresh token printed. Zero debugging needed. The one tool that has to work perfectly on the first try, and it did.
  • The full boot sequence. WiFi → NTP → auth → API test → clock. Five network operations in sequence, all successful, all with screen feedback. The ESP32 went from “powered on” to “authenticated and talking to Spotify” in about 6 seconds.

By the Numbers

MetricValue
Flash usage80.8% (1034 KB / 1.3 MB)
RAM usage14.8% (47 KB / 327 KB)
Flash increase from Sprint 1+7.2% (ArduinoJson + auth logic)
Token refresh time~1.5 seconds
API test response size3373 bytes
Access token lifetime3600 seconds (1 hour)
Modules6 (display, input, led, network, spotify, main)
External Python dependencies0
OAuth2 scopes2 (read playback, modify playback)
401 responses this sprint0 (finally)

What’s Next

Sprint 3: Now Playing API Integration. The ESP32 can authenticate. Now it needs to actually do something with that auth. Poll the player state, parse track info, and start showing what’s playing. The display is about to earn its name.


Sprint 2 complete. The 401 era is over. The ESP32 has credentials, an access token, and the confidence to ask Spotify what you’re listening to. ᕙ(⇀‸↼‶)ᕗ