← Blog
/POST · ALFIE MILLS

Building an E-Ink Clock That Shows Spotify, Weather and the Time

I bought a Heltec Vision Master E290 on a whim. It's an ESP32-S3 with a 296x128 e-ink panel soldered onto the same board, and I wanted something on my desk that showed the time, the weather, and whatever I happened to be playing on Spotify. What started as a "HelloWorld" sketch turned into a multi-screen, multi-core firmware with its own companion auth server. Here's how it fits together.

The hardware

The display is wired over SPI, and on this board you have to power it up explicitly before it'll do anything. The pins I ended up using:

#define EINK_MOSI  1
#define EINK_SCK   2
#define EINK_CS    3
#define EINK_DC    4
#define EINK_RST   5
#define EINK_BUSY  6
#define VEXT_PIN   18   // pull HIGH to power the panel

VEXT_PIN is the gotcha. If you don't drive it high in setup(), the panel stays dark and you spend an hour assuming your SPI config is wrong (I did). I'm driving the display with GxEPD2, and I bring up a dedicated HSPI instance rather than using the default pins:

pinMode(VEXT_PIN, OUTPUT);
digitalWrite(VEXT_PIN, HIGH);
delay(100);

hspi.begin(EINK_SCK, -1, EINK_MOSI, EINK_CS);
display.epd2.selectSPI(hspi, SPISettings(4000000, MSBFIRST, SPI_MODE0));
display.init(115200, false, 50, false);  // initial=false skips the heavy boot refresh
display.setRotation(3);

Partial refresh, or: how to stop the screen flashing

E-ink is wonderful until you want it to update once a second for a clock. A full refresh blanks the whole panel black-then-white and looks awful as a tick. So everything draws through a partial window, and I only do a proper full refresh occasionally to clear ghosting:

bool useFullRefresh = forceFullRefresh || (partialRefreshCount >= FULL_REFRESH_EVERY);

FULL_REFRESH_EVERY is 300, so roughly every five minutes the panel gets a clean wipe and the rest of the time it does fast partial updates. It's a compromise, but it's the difference between a clock you can stand to look at and one you can't.

Don't block the screen with network calls

The first version fetched weather and Spotify data inline in the main loop, and every HTTP request froze the UI for a second or two. The ESP32-S3 has two cores, so the fix was to move all networking onto core 0 and keep the display and buttons on core 1.

The network task owns the HTTP calls and writes results into shared structs behind a FreeRTOS mutex. The main loop never makes a request directly, it just sets a flag:

void requestSpotifyUpdate() { spotifyUpdateRequested = true; }
void requestWeatherUpdate() { weatherUpdateRequested = true; }

The task on the other core picks those up, does the blocking updateSpotifyFromAPI(), and the loop notices the data changed and redraws. Weather comes from Open-Meteo (no API key, which is lovely), current temperature, apparent temperature, humidity, wind and a daily high/low for my corner of Portsmouth.

The Spotify problem

Spotify's API is the awkward part. It uses OAuth with a client secret, tokens that expire every hour, and a redirect-based login flow. You really don't want to bake a client secret into ESP32 firmware, and making someone type credentials into a tiny captive portal is grim.

So I wrote a small Go companion server that holds the secret and does the OAuth dance. The flow:

  1. The clock shows a QR code on its Spotify screen.
  2. You scan it, the phone opens Spotify, you authorise.
  3. The server exchanges the code for a refresh token and stores it per-device.
  4. The clock polls /device/token?session=XXX until it gets an access token back.
  5. From then on the clock calls the Spotify API directly, and the server refreshes the token when it expires.

The endpoints are tiny:

EndpointMethodDescription
/device/start?session=XXXGETKicks off auth, redirects to Spotify
/device/callbackGETReceives Spotify's callback
/device/token?session=XXXGETDevice polls for its token
/device/refresh?device_id=XXXGETDevice asks for a fresh access token

It's written with zmb3/spotify and godotenv, and I run it as a systemd service so it survives reboots. The secret lives in a .env on the server and never touches the device. There's also a manual fallback in the on-device web portal if you'd rather not run the server at all.

Screens, buttons and a setup portal

The firmware is a little state machine over a handful of screens, clock, Spotify, weather, about, plus some dev/test screens hidden behind a flag. Two buttons drive it: one opens a menu and selects, the other cycles items. Both go through interrupt handlers with a 450ms debounce, because mechanical buttons bounce and e-ink can't keep up with the spam otherwise.

WiFi setup is handled by WiFiManager, first boot (or holding the user button for three seconds) spins up a EinkClock-Setup access point with a captive portal. No hardcoded SSIDs, which means I can actually give one of these to someone else.

Worth it?

Completely. It sits on my desk drawing almost no power between refreshes, tells me whether I need a coat, and shows me what's playing without reaching for my phone. The two things I'd tell anyone copying this: power the panel via VEXT before you touch SPI, and never make a network call on the same core that draws your UI.