[{"data":1,"prerenderedAt":621},["ShallowReactive",2],{"blog-eink-spotify-weather-clock":3,"blog-post-nav":583},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"body":11,"_type":577,"_id":578,"_source":579,"_file":580,"_stem":581,"_extension":582},"/blog/eink-spotify-weather-clock","blog",false,"","Building an E-Ink Clock That Shows Spotify, Weather and the Time","A multi-screen e-ink dashboard on a Heltec Vision Master E290, with a Go server handling Spotify's OAuth so the ESP32 doesn't have to.","2026-05-05",{"type":12,"children":13,"toc":569},"root",[14,22,29,34,110,137,210,216,221,235,246,252,257,262,285,309,315,320,332,370,375,492,521,527,532,552,558,563],{"type":15,"tag":16,"props":17,"children":18},"element","p",{},[19],{"type":20,"value":21},"text","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.",{"type":15,"tag":23,"props":24,"children":26},"h2",{"id":25},"the-hardware",[27],{"type":20,"value":28},"The hardware",{"type":15,"tag":16,"props":30,"children":31},{},[32],{"type":20,"value":33},"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:",{"type":15,"tag":35,"props":36,"children":40},"pre",{"className":37,"code":38,"language":39,"meta":7,"style":7},"language-cpp shiki shiki-themes github-dark","#define EINK_MOSI  1\n#define EINK_SCK   2\n#define EINK_CS    3\n#define EINK_DC    4\n#define EINK_RST   5\n#define EINK_BUSY  6\n#define VEXT_PIN   18   // pull HIGH to power the panel\n","cpp",[41],{"type":15,"tag":42,"props":43,"children":44},"code",{"__ignoreMap":7},[45,56,65,74,83,92,101],{"type":15,"tag":46,"props":47,"children":50},"span",{"class":48,"line":49},"line",1,[51],{"type":15,"tag":46,"props":52,"children":53},{},[54],{"type":20,"value":55},"#define EINK_MOSI  1\n",{"type":15,"tag":46,"props":57,"children":59},{"class":48,"line":58},2,[60],{"type":15,"tag":46,"props":61,"children":62},{},[63],{"type":20,"value":64},"#define EINK_SCK   2\n",{"type":15,"tag":46,"props":66,"children":68},{"class":48,"line":67},3,[69],{"type":15,"tag":46,"props":70,"children":71},{},[72],{"type":20,"value":73},"#define EINK_CS    3\n",{"type":15,"tag":46,"props":75,"children":77},{"class":48,"line":76},4,[78],{"type":15,"tag":46,"props":79,"children":80},{},[81],{"type":20,"value":82},"#define EINK_DC    4\n",{"type":15,"tag":46,"props":84,"children":86},{"class":48,"line":85},5,[87],{"type":15,"tag":46,"props":88,"children":89},{},[90],{"type":20,"value":91},"#define EINK_RST   5\n",{"type":15,"tag":46,"props":93,"children":95},{"class":48,"line":94},6,[96],{"type":15,"tag":46,"props":97,"children":98},{},[99],{"type":20,"value":100},"#define EINK_BUSY  6\n",{"type":15,"tag":46,"props":102,"children":104},{"class":48,"line":103},7,[105],{"type":15,"tag":46,"props":106,"children":107},{},[108],{"type":20,"value":109},"#define VEXT_PIN   18   // pull HIGH to power the panel\n",{"type":15,"tag":16,"props":111,"children":112},{},[113,119,121,127,129,135],{"type":15,"tag":42,"props":114,"children":116},{"className":115},[],[117],{"type":20,"value":118},"VEXT_PIN",{"type":20,"value":120}," is the gotcha. If you don't drive it high in ",{"type":15,"tag":42,"props":122,"children":124},{"className":123},[],[125],{"type":20,"value":126},"setup()",{"type":20,"value":128},", the panel stays dark and you spend an hour assuming your SPI config is wrong (I did). I'm driving the display with ",{"type":15,"tag":130,"props":131,"children":132},"strong",{},[133],{"type":20,"value":134},"GxEPD2",{"type":20,"value":136},", and I bring up a dedicated HSPI instance rather than using the default pins:",{"type":15,"tag":35,"props":138,"children":140},{"className":37,"code":139,"language":39,"meta":7,"style":7},"pinMode(VEXT_PIN, OUTPUT);\ndigitalWrite(VEXT_PIN, HIGH);\ndelay(100);\n\nhspi.begin(EINK_SCK, -1, EINK_MOSI, EINK_CS);\ndisplay.epd2.selectSPI(hspi, SPISettings(4000000, MSBFIRST, SPI_MODE0));\ndisplay.init(115200, false, 50, false);  // initial=false skips the heavy boot refresh\ndisplay.setRotation(3);\n",[141],{"type":15,"tag":42,"props":142,"children":143},{"__ignoreMap":7},[144,152,160,168,177,185,193,201],{"type":15,"tag":46,"props":145,"children":146},{"class":48,"line":49},[147],{"type":15,"tag":46,"props":148,"children":149},{},[150],{"type":20,"value":151},"pinMode(VEXT_PIN, OUTPUT);\n",{"type":15,"tag":46,"props":153,"children":154},{"class":48,"line":58},[155],{"type":15,"tag":46,"props":156,"children":157},{},[158],{"type":20,"value":159},"digitalWrite(VEXT_PIN, HIGH);\n",{"type":15,"tag":46,"props":161,"children":162},{"class":48,"line":67},[163],{"type":15,"tag":46,"props":164,"children":165},{},[166],{"type":20,"value":167},"delay(100);\n",{"type":15,"tag":46,"props":169,"children":170},{"class":48,"line":76},[171],{"type":15,"tag":46,"props":172,"children":174},{"emptyLinePlaceholder":173},true,[175],{"type":20,"value":176},"\n",{"type":15,"tag":46,"props":178,"children":179},{"class":48,"line":85},[180],{"type":15,"tag":46,"props":181,"children":182},{},[183],{"type":20,"value":184},"hspi.begin(EINK_SCK, -1, EINK_MOSI, EINK_CS);\n",{"type":15,"tag":46,"props":186,"children":187},{"class":48,"line":94},[188],{"type":15,"tag":46,"props":189,"children":190},{},[191],{"type":20,"value":192},"display.epd2.selectSPI(hspi, SPISettings(4000000, MSBFIRST, SPI_MODE0));\n",{"type":15,"tag":46,"props":194,"children":195},{"class":48,"line":103},[196],{"type":15,"tag":46,"props":197,"children":198},{},[199],{"type":20,"value":200},"display.init(115200, false, 50, false);  // initial=false skips the heavy boot refresh\n",{"type":15,"tag":46,"props":202,"children":204},{"class":48,"line":203},8,[205],{"type":15,"tag":46,"props":206,"children":207},{},[208],{"type":20,"value":209},"display.setRotation(3);\n",{"type":15,"tag":23,"props":211,"children":213},{"id":212},"partial-refresh-or-how-to-stop-the-screen-flashing",[214],{"type":20,"value":215},"Partial refresh, or: how to stop the screen flashing",{"type":15,"tag":16,"props":217,"children":218},{},[219],{"type":20,"value":220},"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:",{"type":15,"tag":35,"props":222,"children":224},{"className":37,"code":223,"language":39,"meta":7,"style":7},"bool useFullRefresh = forceFullRefresh || (partialRefreshCount >= FULL_REFRESH_EVERY);\n",[225],{"type":15,"tag":42,"props":226,"children":227},{"__ignoreMap":7},[228],{"type":15,"tag":46,"props":229,"children":230},{"class":48,"line":49},[231],{"type":15,"tag":46,"props":232,"children":233},{},[234],{"type":20,"value":223},{"type":15,"tag":16,"props":236,"children":237},{},[238,244],{"type":15,"tag":42,"props":239,"children":241},{"className":240},[],[242],{"type":20,"value":243},"FULL_REFRESH_EVERY",{"type":20,"value":245}," 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.",{"type":15,"tag":23,"props":247,"children":249},{"id":248},"dont-block-the-screen-with-network-calls",[250],{"type":20,"value":251},"Don't block the screen with network calls",{"type":15,"tag":16,"props":253,"children":254},{},[255],{"type":20,"value":256},"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.",{"type":15,"tag":16,"props":258,"children":259},{},[260],{"type":20,"value":261},"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:",{"type":15,"tag":35,"props":263,"children":265},{"className":37,"code":264,"language":39,"meta":7,"style":7},"void requestSpotifyUpdate() { spotifyUpdateRequested = true; }\nvoid requestWeatherUpdate() { weatherUpdateRequested = true; }\n",[266],{"type":15,"tag":42,"props":267,"children":268},{"__ignoreMap":7},[269,277],{"type":15,"tag":46,"props":270,"children":271},{"class":48,"line":49},[272],{"type":15,"tag":46,"props":273,"children":274},{},[275],{"type":20,"value":276},"void requestSpotifyUpdate() { spotifyUpdateRequested = true; }\n",{"type":15,"tag":46,"props":278,"children":279},{"class":48,"line":58},[280],{"type":15,"tag":46,"props":281,"children":282},{},[283],{"type":20,"value":284},"void requestWeatherUpdate() { weatherUpdateRequested = true; }\n",{"type":15,"tag":16,"props":286,"children":287},{},[288,290,296,298,307],{"type":20,"value":289},"The task on the other core picks those up, does the blocking ",{"type":15,"tag":42,"props":291,"children":293},{"className":292},[],[294],{"type":20,"value":295},"updateSpotifyFromAPI()",{"type":20,"value":297},", and the loop notices the data changed and redraws. Weather comes from ",{"type":15,"tag":299,"props":300,"children":304},"a",{"href":301,"rel":302},"https://open-meteo.com/",[303],"nofollow",[305],{"type":20,"value":306},"Open-Meteo",{"type":20,"value":308}," (no API key, which is lovely), current temperature, apparent temperature, humidity, wind and a daily high/low for my corner of Portsmouth.",{"type":15,"tag":23,"props":310,"children":312},{"id":311},"the-spotify-problem",[313],{"type":20,"value":314},"The Spotify problem",{"type":15,"tag":16,"props":316,"children":317},{},[318],{"type":20,"value":319},"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.",{"type":15,"tag":16,"props":321,"children":322},{},[323,325,330],{"type":20,"value":324},"So I wrote a small ",{"type":15,"tag":130,"props":326,"children":327},{},[328],{"type":20,"value":329},"Go companion server",{"type":20,"value":331}," that holds the secret and does the OAuth dance. The flow:",{"type":15,"tag":333,"props":334,"children":335},"ol",{},[336,342,347,352,365],{"type":15,"tag":337,"props":338,"children":339},"li",{},[340],{"type":20,"value":341},"The clock shows a QR code on its Spotify screen.",{"type":15,"tag":337,"props":343,"children":344},{},[345],{"type":20,"value":346},"You scan it, the phone opens Spotify, you authorise.",{"type":15,"tag":337,"props":348,"children":349},{},[350],{"type":20,"value":351},"The server exchanges the code for a refresh token and stores it per-device.",{"type":15,"tag":337,"props":353,"children":354},{},[355,357,363],{"type":20,"value":356},"The clock polls ",{"type":15,"tag":42,"props":358,"children":360},{"className":359},[],[361],{"type":20,"value":362},"/device/token?session=XXX",{"type":20,"value":364}," until it gets an access token back.",{"type":15,"tag":337,"props":366,"children":367},{},[368],{"type":20,"value":369},"From then on the clock calls the Spotify API directly, and the server refreshes the token when it expires.",{"type":15,"tag":16,"props":371,"children":372},{},[373],{"type":20,"value":374},"The endpoints are tiny:",{"type":15,"tag":376,"props":377,"children":378},"table",{},[379,403],{"type":15,"tag":380,"props":381,"children":382},"thead",{},[383],{"type":15,"tag":384,"props":385,"children":386},"tr",{},[387,393,398],{"type":15,"tag":388,"props":389,"children":390},"th",{},[391],{"type":20,"value":392},"Endpoint",{"type":15,"tag":388,"props":394,"children":395},{},[396],{"type":20,"value":397},"Method",{"type":15,"tag":388,"props":399,"children":400},{},[401],{"type":20,"value":402},"Description",{"type":15,"tag":404,"props":405,"children":406},"tbody",{},[407,430,451,471],{"type":15,"tag":384,"props":408,"children":409},{},[410,420,425],{"type":15,"tag":411,"props":412,"children":413},"td",{},[414],{"type":15,"tag":42,"props":415,"children":417},{"className":416},[],[418],{"type":20,"value":419},"/device/start?session=XXX",{"type":15,"tag":411,"props":421,"children":422},{},[423],{"type":20,"value":424},"GET",{"type":15,"tag":411,"props":426,"children":427},{},[428],{"type":20,"value":429},"Kicks off auth, redirects to Spotify",{"type":15,"tag":384,"props":431,"children":432},{},[433,442,446],{"type":15,"tag":411,"props":434,"children":435},{},[436],{"type":15,"tag":42,"props":437,"children":439},{"className":438},[],[440],{"type":20,"value":441},"/device/callback",{"type":15,"tag":411,"props":443,"children":444},{},[445],{"type":20,"value":424},{"type":15,"tag":411,"props":447,"children":448},{},[449],{"type":20,"value":450},"Receives Spotify's callback",{"type":15,"tag":384,"props":452,"children":453},{},[454,462,466],{"type":15,"tag":411,"props":455,"children":456},{},[457],{"type":15,"tag":42,"props":458,"children":460},{"className":459},[],[461],{"type":20,"value":362},{"type":15,"tag":411,"props":463,"children":464},{},[465],{"type":20,"value":424},{"type":15,"tag":411,"props":467,"children":468},{},[469],{"type":20,"value":470},"Device polls for its token",{"type":15,"tag":384,"props":472,"children":473},{},[474,483,487],{"type":15,"tag":411,"props":475,"children":476},{},[477],{"type":15,"tag":42,"props":478,"children":480},{"className":479},[],[481],{"type":20,"value":482},"/device/refresh?device_id=XXX",{"type":15,"tag":411,"props":484,"children":485},{},[486],{"type":20,"value":424},{"type":15,"tag":411,"props":488,"children":489},{},[490],{"type":20,"value":491},"Device asks for a fresh access token",{"type":15,"tag":16,"props":493,"children":494},{},[495,497,503,505,511,513,519],{"type":20,"value":496},"It's written with ",{"type":15,"tag":42,"props":498,"children":500},{"className":499},[],[501],{"type":20,"value":502},"zmb3/spotify",{"type":20,"value":504}," and ",{"type":15,"tag":42,"props":506,"children":508},{"className":507},[],[509],{"type":20,"value":510},"godotenv",{"type":20,"value":512},", and I run it as a systemd service so it survives reboots. The secret lives in a ",{"type":15,"tag":42,"props":514,"children":516},{"className":515},[],[517],{"type":20,"value":518},".env",{"type":20,"value":520}," 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.",{"type":15,"tag":23,"props":522,"children":524},{"id":523},"screens-buttons-and-a-setup-portal",[525],{"type":20,"value":526},"Screens, buttons and a setup portal",{"type":15,"tag":16,"props":528,"children":529},{},[530],{"type":20,"value":531},"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.",{"type":15,"tag":16,"props":533,"children":534},{},[535,537,542,544,550],{"type":20,"value":536},"WiFi setup is handled by ",{"type":15,"tag":130,"props":538,"children":539},{},[540],{"type":20,"value":541},"WiFiManager",{"type":20,"value":543},", first boot (or holding the user button for three seconds) spins up a ",{"type":15,"tag":42,"props":545,"children":547},{"className":546},[],[548],{"type":20,"value":549},"EinkClock-Setup",{"type":20,"value":551}," access point with a captive portal. No hardcoded SSIDs, which means I can actually give one of these to someone else.",{"type":15,"tag":23,"props":553,"children":555},{"id":554},"worth-it",[556],{"type":20,"value":557},"Worth it?",{"type":15,"tag":16,"props":559,"children":560},{},[561],{"type":20,"value":562},"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.",{"type":15,"tag":564,"props":565,"children":566},"style",{},[567],{"type":20,"value":568},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":7,"searchDepth":58,"depth":58,"links":570},[571,572,573,574,575,576],{"id":25,"depth":58,"text":28},{"id":212,"depth":58,"text":215},{"id":248,"depth":58,"text":251},{"id":311,"depth":58,"text":314},{"id":523,"depth":58,"text":526},{"id":554,"depth":58,"text":557},"markdown","content:blog:eink-spotify-weather-clock.md","content","blog/eink-spotify-weather-clock.md","blog/eink-spotify-weather-clock","md",[584,588,592,596,597,601,605,609,613,617],{"_path":585,"title":586,"date":587},"/blog/deploying-nuxt-to-cloudflare-workers","Deploying this Nuxt site to Cloudflare Workers","2026-06-06",{"_path":589,"title":590,"date":591},"/blog/building-forever-llm","Building forever-llm: three takes on a model that never stops","2026-05-20",{"_path":593,"title":594,"date":595},"/blog/laravel-cortex-adhd-productivity","Building Cortex: An ADHD Productivity App in Laravel + Inertia","2026-05-12",{"_path":4,"title":8,"date":10},{"_path":598,"title":599,"date":600},"/blog/hetzner-k8s-cluster","Building a K3s Cluster on Hetzner with Terraform and GitOps","2026-04-20",{"_path":602,"title":603,"date":604},"/blog/arduino-uno-q-forza-rev-gauge","A Forza Rev Gauge on the Arduino UNO Q's LED Matrix","2026-04-15",{"_path":606,"title":607,"date":608},"/blog/modular-go-echo-gorm","A Modular Go Web App Pattern with Echo, GORM and golang-migrate","2026-04-05",{"_path":610,"title":611,"date":612},"/blog/self-hosted-ios-web-push","Self-hosting iOS push notifications with Web Push and PWAs","2026-03-25",{"_path":614,"title":615,"date":616},"/blog/lit-web-components-astro-docs","Building a framework-free web component library with Lit and Astro","2026-03-10",{"_path":618,"title":619,"date":620},"/blog/welcome-to-my-blog","About this site","2024-01-15",1781294950572]