Schematik build

E-INK Live Flight Scanner

ESP32Beginner45 minutes
Photo of E-INK Live Flight Scanner

Schematik

Last updated September 13, 2026

This project turns an ESP32 with a PaperColor e-ink display into a live ADS-B flight radar that shows aircraft in real-time around a home location. The guide includes a wiring diagram, parts list, and step-by-step assembly instructions to get the radar up and running.

The system fetches live aircraft data over WiFi, plots planes on the e-ink display with their callsigns and altitudes, and updates every 15 seconds. Assembly is straightforward: configure your WiFi credentials, connect the PaperColor display, and use the built-in firmware update control to deploy the code.

Wiring diagram

Assemble it in 3 steps

1. Set the Wi‑Fi name and password

Open `src/secrets.h` in this project and replace the two empty quotation marks with the name and password of the Wi‑Fi network the PaperColor will use. This is needed because FlightScanner downloads aircraft information over Wi‑Fi.

  • Use the same spelling and capital letters as the Wi‑Fi name shown on your phone or computer.
  • The program will keep the last good radar picture if the weak signal drops out.
  • Do not share or publish the password stored in this file.

2. Connect the PaperColor

Place the M5Stack PaperColor where it can receive Wi‑Fi, then connect its USB cable to your computer. The display, buttons, power system, and antenna are already built into the PaperColor, so no jumper wires are needed.

  • Avoid covering the top or sides of the unit with metal objects because that can make weak Wi‑Fi reception worse.
  • Use a good USB cable; a charge-only cable cannot transfer the firmware.

3. Use the built-in update control

After deploying, press the PaperColor’s built-in Button A to request an immediate radar update. You can also use the serial command `u`; `i` prints a short health line; and `w` paints a white cleaning screen, waits, then redraws the radar.

  • A normal colour e-paper repaint takes about 17 seconds and flashes. Wait for it to finish before judging the result.
  • Do not repeatedly press the button during a repaint; the display is intentionally slow and each redraw uses a full refresh.

Deploy the firmware

#include <Arduino.h>
#include <M5Unified.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <time.h>
#include <esp_task_wdt.h>
#include <lgfx/v1/panel/Panel_ED2208.hpp>
#include "secrets.h"

#define EPD_RST_PIN 12

struct Aircraft { char call[18], type[8]; float lat, lon, km, bearing, track, alt, gs; bool hasAlt; };


// Forward declarations
static bool epdWake(const char* tag);
static float radiansf(float x);
static float distanceKm(float a, float o, float b, float p);
static float bearingTo(float a,float o,float b,float p);
static String clockText();
static String ageText();
static bool connectWiFi();
static bool parseTraffic(const String& body);
static bool getTraffic();
static uint16_t colour(const Aircraft& a);
static void dart(float x,float y,float deg,uint16_t c);
static void drawScreen();
static void refresh(bool fetch);
static void whiteFlush();

static constexpr float HOME_LAT = 48.2188f;
static constexpr float HOME_LON = 11.6247f;
static constexpr float RANGE_KM = 75.0f;
static constexpr int FETCH_NM = 41;
static constexpr uint32_t UPDATE_INTERVAL_MS = 15UL * 1000UL;
static constexpr int EPD_BUSY_PIN = 11;
static constexpr int W = 400, H = 600, MAX_AC = 40;


static Aircraft planes[MAX_AC]; static int planeCount = 0;
static bool haveData = false, forceUpdate = true;
static uint32_t nextUpdate = 0, lastGood = 0, bootMs = 0;
static int failures = 0; static String lastError = "Waiting for WiFi";
static M5Canvas canvas(&M5.Display);

// The PMIC can keep an ED2208 asleep across reflashes, so wake it before M5.begin.
static bool epdWake(const char* tag) {
  pinMode(EPD_BUSY_PIN, INPUT_PULLUP); pinMode(EPD_RST_PIN, OUTPUT);
  digitalWrite(EPD_RST_PIN, HIGH); delay(5); digitalWrite(EPD_RST_PIN, LOW); delay(20); digitalWrite(EPD_RST_PIN, HIGH);
  uint32_t t0 = millis(); while (!digitalRead(EPD_BUSY_PIN) && millis() - t0 < 3000) delay(10);
  bool ok = digitalRead(EPD_BUSY_PIN);
  Serial.printf("[epd] wake(%s): BUSY=%d after %u ms\n", tag, (int)ok, (unsigned)(millis()-t0)); return ok;
}
static float radiansf(float x) { return x * PI / 180.0f; }
static float distanceKm(float a, float o, float b, float p) { float d1=radiansf(b-a), d2=radiansf(p-o), x=sin(d1/2)*sin(d1/2)+cos(radiansf(a))*cos(radiansf(b))*sin(d2/2)*sin(d2/2); return 6371.0f*2*atan2(sqrt(x),sqrt(1-x)); }
static float bearingTo(float a,float o,float b,float p) { float y=sin(radiansf(p-o))*cos(radiansf(b)); float x=cos(radiansf(a))*sin(radiansf(b))-sin(radiansf(a))*cos(radiansf(b))*cos(radiansf(p-o)); float q=atan2(y,x)*180/PI; return q<0?q+360:q; }
static String clockText() { struct tm t; if (getLocalTime(&t,20)) { char s[8]; strftime(s,sizeof s,"%H:%M",&t); return s; } return "--:--"; }
static String ageText() { return lastGood ? String((millis()-lastGood)/60000UL) : "--"; }
static bool connectWiFi() {
  if (WiFi.status()==WL_CONNECTED) return true;
  Serial.printf("[wifi] connecting to %s\n", WIFI_SSID); WiFi.mode(WIFI_STA); WiFi.setSleep(false); WiFi.begin(WIFI_SSID,WIFI_PASSWORD);
  uint32_t t=millis(); while(WiFi.status()!=WL_CONNECTED && millis()-t<25000) { delay(250); esp_task_wdt_reset(); }
  if (WiFi.status()!=WL_CONNECTED) { lastError="WiFi connection timed out"; Serial.println("[wifi] timeout"); return false; }
  Serial.printf("[wifi] connected, RSSI %d dBm\n",WiFi.RSSI()); configTzTime("CET-1CEST,M3.5.0,M10.5.0/3","pool.ntp.org","time.google.com"); return true;
}
static bool parseTraffic(const String& body) {
  JsonDocument filter; JsonObject f=filter["ac"].add<JsonObject>();
  for (const char* k : {"hex","flight","r","t","lat","lon","alt_baro","alt_geom","gs","track"}) f[k]=true;
  JsonDocument doc; DeserializationError e=deserializeJson(doc,body,DeserializationOption::Filter(filter));
  if(e) { lastError=String("JSON: ")+e.c_str(); return false; }
  JsonArray ac=doc["ac"].as<JsonArray>(); if(ac.isNull()) { lastError="API returned no aircraft list"; return false; }
  Aircraft fresh[MAX_AC]; int count=0;
  for(JsonObject a:ac) {
    if(a["lat"].isNull()||a["lon"].isNull()) continue;
    JsonVariant ab=a["alt_baro"]; if(ab.is<const char*>() && String(ab.as<const char*>())=="ground") continue;
    Aircraft q{}; q.lat=a["lat"]|0.0f; q.lon=a["lon"]|0.0f; q.km=distanceKm(HOME_LAT,HOME_LON,q.lat,q.lon); if(q.km>RANGE_KM) continue;
    q.bearing=bearingTo(HOME_LAT,HOME_LON,q.lat,q.lon); q.track=a["track"]|q.bearing; q.gs=a["gs"]|0.0f;
    String name=String((const char*)(a["flight"]|"")); name.trim(); if(!name.length()) name=(const char*)(a["r"]|""); if(!name.length()) name=(const char*)(a["hex"]|"?"); name.toCharArray(q.call,sizeof q.call);
    String typ=(const char*)(a["t"]|""); typ.toCharArray(q.type,sizeof q.type);
    q.hasAlt=ab.is<float>()||ab.is<int>()||ab.is<long>(); q.alt=q.hasAlt?ab.as<float>(): (a["alt_geom"]|0.0f); if(!q.hasAlt && !a["alt_geom"].isNull()) q.hasAlt=true;
    if(count<MAX_AC) fresh[count++]=q; else { int far=0; for(int i=1;i<count;i++)if(fresh[i].km>fresh[far].km)far=i; if(q.km<fresh[far].km)fresh[far]=q; }
  }
  for(int i=0;i<count;i++)for(int j=i+1;j<count;j++)if(fresh[j].km>fresh[i].km){Aircraft z=fresh[i];fresh[i]=fresh[j];fresh[j]=z;}
  memcpy(planes,fresh,sizeof fresh); planeCount=count; haveData=true; failures=0; lastGood=millis(); return true;
}
static bool getTraffic() {
  if(!connectWiFi()) return false;
  const char* hosts[]={"api.adsb.lol","api.airplanes.live"};
  for(const char* host:hosts) for(int n=0;n<2;n++) {
    String url=String("https://")+host+"/v2/point/"+String(HOME_LAT,4)+"/"+String(HOME_LON,4)+"/"+FETCH_NM;
    WiFiClientSecure client; client.setInsecure(); client.setHandshakeTimeout(15); HTTPClient http; http.setTimeout(18000); http.setConnectTimeout(10000); http.setUserAgent("M5PaperColor-FlightScanner/1.0"); http.useHTTP10(true);
    uint32_t t=millis(); Serial.printf("[adsb] try %s (%d)\n",host,n+1);
    if(http.begin(client,url)) { int code=http.GET(); String body=code==200?http.getString():""; http.end(); Serial.printf("[adsb] HTTP %d, %u bytes, %u ms\n",code,(unsigned)body.length(),(unsigned)(millis()-t)); if(code==200 && parseTraffic(body)) return true; if(code!=200) lastError=String(host)+" HTTP "+code; }
    else lastError=String("HTTPS begin failed: ")+host;
    delay(2000); esp_task_wdt_reset();
  } return false;
}
static uint16_t colour(const Aircraft& a) { if(!a.hasAlt)return TFT_BLACK; return a.alt<10000?TFT_GREEN:(a.alt<=25000?TFT_BLUE:TFT_RED); }
static void dart(float x,float y,float deg,uint16_t c) { float r=radiansf(deg); auto X=[&](float f,float s){return(int)lround(x+f*sin(r)+s*cos(r));}; auto Y=[&](float f,float s){return(int)lround(y-f*cos(r)+s*sin(r));}; int nx=X(11,0),ny=Y(11,0),lX=X(-8,-7),lY=Y(-8,-7),rX=X(-8,7),rY=Y(-8,7),kX=X(-4,0),kY=Y(-4,0); canvas.fillTriangle(nx,ny,lX,lY,kX,kY,c); canvas.fillTriangle(nx,ny,kX,kY,rX,rY,c); canvas.drawTriangle(nx,ny,lX,lY,kX,kY,TFT_BLACK); canvas.drawTriangle(nx,ny,kX,kY,rX,rY,TFT_BLACK); }
static void drawScreen() {
  uint32_t t=millis();
  canvas.fillSprite(TFT_WHITE);
  // Paper-style masthead: retain high contrast because Spectra-6 has no grey ink.
  canvas.fillRect(0,0,W,80,TFT_BLACK);
  canvas.setTextColor(TFT_WHITE,TFT_BLACK);
  canvas.setFont(&fonts::Font0);
  canvas.setTextDatum(top_left);
  canvas.drawString("FLIGHTSCANNER",10,10);
  canvas.setFont(&fonts::FreeSansBold12pt7b);
  canvas.drawString("MUNICH AIRSPACE",10,35);
  canvas.setTextDatum(top_right);
  canvas.drawString(clockText(),388,35);
  canvas.setFont(&fonts::Font0);
  canvas.drawString(String(planeCount)+" AC / PAPER SERIES",388,12);
  canvas.setTextColor(TFT_BLACK,TFT_WHITE);
  canvas.setTextDatum(top_left);
  canvas.drawString(String(HOME_LAT,4)+" N / "+String(HOME_LON,4)+" E",12,94);
  canvas.setTextDatum(top_right);
  canvas.drawString("06  REFRESH",388,94);
  if(!haveData) { canvas.fillRect(0,0,W,80,TFT_RED); canvas.setTextColor(TFT_WHITE,TFT_RED); canvas.setFont(&fonts::FreeSansBold12pt7b); canvas.setTextDatum(top_left); canvas.drawString("FLIGHTSCANNER",10,30); canvas.setTextColor(TFT_BLACK,TFT_WHITE); canvas.setTextDatum(middle_center); canvas.drawString("No flight data",200,270); canvas.setFont(&fonts::FreeSans9pt7b); canvas.drawString(lastError,200,305); canvas.drawString("Retrying every 60 s",200,340); canvas.pushSprite(0,0); Serial.printf("[epd] error refresh %u ms\n",(unsigned)(millis()-t)); return; }
  if(failures) { canvas.fillRect(5,108,390,25,TFT_YELLOW); canvas.drawRect(5,108,390,25,TFT_BLACK); canvas.setTextColor(TFT_BLACK,TFT_YELLOW); canvas.setFont(&fonts::Font0); canvas.setCursor(10,117); canvas.printf("STALE - data %s min old (%d fails)",ageText().c_str(),failures); }
  const int cx=200,cy=270,R=160;
  canvas.setTextColor(TFT_BLACK,TFT_WHITE); canvas.setFont(&fonts::Font0);
  for(int i=1;i<=3;i++){int rr=R*i/3; canvas.drawCircle(cx,cy,rr,TFT_BLACK); canvas.setCursor(cx+5,cy+rr-10); canvas.printf("%d",i*25);}
  // Short perimeter ticks give the radar its instrument-dial appearance.
  for(int d=0; d<360; d+=5) { float a=radiansf((float)d); int inner=(d%30==0)?R-12:R-6; canvas.drawLine(cx+(int)lround(inner*sin(a)),cy-(int)lround(inner*cos(a)),cx+(int)lround((R+5)*sin(a)),cy-(int)lround((R+5)*cos(a)),TFT_BLACK); }
  canvas.drawLine(cx-R,cy,cx+R,cy,TFT_BLACK);canvas.drawLine(cx,cy-R,cx,cy+R,TFT_BLACK);
  canvas.setTextDatum(middle_center); canvas.drawString("N",cx,cy-R-12);canvas.drawString("S",cx,cy+R+14);canvas.drawString("W",cx-R-14,cy);canvas.drawString("E",cx+R+14,cy);canvas.drawString("75 km",cx+25,cy-R+3);
  for(int i=0;i<planeCount;i++){Aircraft&a=planes[i];float d=a.km/RANGE_KM*R,br=radiansf(a.bearing);dart(cx+d*sin(br),cy-d*cos(br),a.track,colour(a));}
  for(int i=0;i<min(5,planeCount);i++){Aircraft&a=planes[i];float d=a.km/RANGE_KM*R,br=radiansf(a.bearing),x=cx+d*sin(br),y=cy-d*cos(br); canvas.setTextDatum(x>330?top_right:top_left);canvas.drawString(a.call,x+(x>330?-10:10),y+(i&1?8:-12));}
  canvas.drawCircle(cx,cy,18,TFT_BLACK); canvas.fillCircle(cx,cy,6,TFT_BLACK);canvas.drawPixel(cx,cy,TFT_WHITE); canvas.setTextDatum(middle_center); canvas.drawString("MUNICH",cx,cy+29);
  canvas.setFont(&fonts::Font0); const uint16_t cs[]={TFT_GREEN,TFT_BLUE,TFT_RED}; const char* ls[]={"< 10k ft","10-25k ft","> 25k ft"}; for(int i=0;i<3;i++){int x=12+i*128;canvas.fillRect(x,446,9,9,cs[i]);canvas.drawRect(x,446,9,9,TFT_BLACK);canvas.setCursor(x+13,447);canvas.print(ls[i]);}
  canvas.drawLine(8,465,392,465,TFT_BLACK);canvas.setCursor(9,468);canvas.print("CALLSIGN TYPE DIST       ALT ft    KT"); canvas.setFont(&fonts::FreeSansBold9pt7b); for(int row=0;row<5;row++){int y=486+row*17;if(row>=planeCount){if(row==0){canvas.setTextDatum(middle_center);canvas.drawString("no traffic in range",200,525);}break;}Aircraft&a=planes[row];canvas.fillRect(9,y,8,8,colour(a));canvas.drawRect(9,y,8,8,TFT_BLACK);String n=a.call;n=n.substring(0,min((int)n.length(),7));canvas.setTextDatum(top_left);canvas.drawString(n,22,y-3);canvas.drawString(String(a.type).substring(0,4),92,y-3);canvas.drawString(String(a.km,1)+" km",137,y-3);canvas.drawString(a.hasAlt?String((int)a.alt):"--",218,y-3);canvas.drawString(String((int)a.gs),313,y-3);}
  canvas.drawLine(8,578,392,578,TFT_BLACK);canvas.setFont(&fonts::Font0);canvas.setCursor(8,584);canvas.printf("Updated %s  |  RSSI %d dBm  |  every 15s  |  Allianz Arena",clockText().c_str(),WiFi.status()==WL_CONNECTED?WiFi.RSSI():0); canvas.pushSprite(0,0); Serial.printf("[epd] refresh %u ms\n",(unsigned)(millis()-t));
}
static void refresh(bool fetch) { if(fetch){Serial.println("[adsb] update cycle");if(!getTraffic()) failures++;} Serial.println("[draw] painting full 400x600 sprite");drawScreen();nextUpdate=millis()+UPDATE_INTERVAL_MS; }
static void whiteFlush() { canvas.fillSprite(TFT_WHITE); canvas.pushSprite(0,0); Serial.println("[epd] white anti-ghosting flush");delay(10000);refresh(false); }
void setup() {
  Serial.begin(115200);
  delay(300);
  bootMs=millis();
  epdWake("pre");
  auto cfg=M5.config();
  cfg.clear_display=false;
  M5.begin(cfg);
  if(!digitalRead(EPD_BUSY_PIN)) {
    epdWake("post");
    static_cast<lgfx::Panel_ED2208*>(M5.Display.getPanel())->init(false);
  }
  M5.Display.setEpdMode(epd_mode_t::epd_quality);
  M5.Display.setRotation(0);
  // A 400 x 600 sprite needs PSRAM. Retry in the panel's native 4-bit mode if it cannot be allocated.
  if (!canvas.createSprite(W, H)) {
    Serial.println("[draw] full-depth sprite allocation failed; retrying 4-bit sprite");
    canvas.setColorDepth(4);
    if (!canvas.createSprite(W, H)) {
      Serial.println("[draw] FATAL: unable to allocate display sprite");
      delay(1000);
      ESP.restart();
    }
  }
  const esp_task_wdt_config_t wdtConfig = {
    .timeout_ms = 300000,
    .idle_core_mask = 0,
    .trigger_panic = true,
  };
  // Arduino starts the task watchdog. Reconfigure it rather than attempting a second initialization.
  esp_err_t wdtResult = esp_task_wdt_reconfigure(&wdtConfig);
  if (wdtResult != ESP_OK) {
    Serial.printf("[wdt] reconfigure failed: %d\n", (int)wdtResult);
  }
  esp_task_wdt_add(NULL);
  Serial.println("[epd] FlightScanner ready");
}
void loop() { esp_task_wdt_reset(); M5.update(); if(Serial.available()){char c=Serial.read();if(c=='u')forceUpdate=true;else if(c=='w')whiteFlush();else if(c=='i')Serial.printf("[status] haveData=%d planes=%d fails=%d age=%s min wifi=%d rssi=%d heap=%u uptime=%lu s\n",haveData,planeCount,failures,ageText().c_str(),WiFi.status()==WL_CONNECTED,WiFi.RSSI(),ESP.getFreeHeap(),(millis()-bootMs)/1000); } if(M5.BtnA.wasPressed())forceUpdate=true; if(forceUpdate||millis()>=nextUpdate){forceUpdate=false;refresh(true);} delay(20); }

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik