Community project
SpaceX Launch Tracker
This project turns an ESP32 into a real-time SpaceX launch tracker that displays upcoming mission details on a color display. The tracker fetches live launch data from The Space Devs API, showing vehicle names, cargo information, launch dates, and weather conditions—all updated automatically every six hours.
The guide provides a complete parts list, wiring diagram, and step-by-step assembly instructions. After preparing the display and powering up the device, simply connect it to your home Wi-Fi through the built-in setup mode, and the launch screen will begin displaying the next scheduled SpaceX mission.
Wiring diagram
Assemble it in 4 steps
1. Prepare the Color Paper
Place the M5Stack Color Paper on a dry, stable surface with the screen facing up. Its display, Wi-Fi radio, and three front buttons are already built in, so do not connect anything to the yellow-and-white Grove port for this project.
- Keep the screen clear while it refreshes; color e-paper changes more slowly than a phone screen.
- Do not force wires into any port while the unit is powered; a misplaced wire can damage the board.
2. Power the tracker
Plug a USB cable into the Color Paper and a suitable USB power source. The cable supplies power while you set up and update the tracker.
- Use a reliable USB cable that also carries data so Schematik can send the firmware to the board.
- Do not use a damaged cable or wet connector — damaged power connections can overheat or harm the board.
3. Connect it to your home Wi-Fi
After deploying the firmware, the first screen shows a QR code. With your phone, join the temporary Wi-Fi network named SpaceX-Tracker-Setup, scan the code, and enter the name and password of the Wi-Fi network that has internet access. The tracker restarts by itself after saving them.
- Your phone may say that the temporary setup network has no internet; stay connected long enough to open the setup page at 192.168.4.1.
- Enter the Wi-Fi password carefully; a wrong password leaves the tracker unable to receive launch updates.
4. Use the launch screen
When the launch screen appears, press the left front button A to ask for the newest SpaceX launch details. Press the middle front button B if you need to erase and replace the saved Wi-Fi details.
- The tracker also checks for changes every six hours to reduce unnecessary e-paper refreshes.
- Do not press the buttons repeatedly during a screen refresh; e-paper needs a moment to finish changing the picture.
Deploy the firmware
#include <Arduino.h>
#include <M5Unified.h>
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <Preferences.h>
// Forward declarations
String quotedValueAfter(const String& text, int start, const char* key);
String primitiveValueAfter(const String& t, int s, const char* key);
static const char* SETUP_AP_NAME = "SpaceX-Tracker-Setup";
static const char* LAUNCH_API = "https://ll.thespacedevs.com/2.2.0/launch/upcoming/?lsp__name=SpaceX&limit=1&ordering=net";
static const uint32_t REFRESH_INTERVAL_MS = 6UL * 60UL * 60UL * 1000UL;
Preferences preferences; WebServer server(80);
String wifiSsid, wifiPassword, vehicleName = "SpaceX vehicle", cargoName = "Not announced";
String launchDate = "Connect to Wi-Fi, then press A", launchPlace = "Location pending", weatherText = "Weather not available";
bool setupMode = false; uint32_t lastRefreshMs = 0;
String quotedValueAfter(const String& text, int start, const char* key) {
int p = text.indexOf(key, start); if (p < 0) return "";
int c = text.indexOf(':', p + strlen(key)), q1 = c < 0 ? -1 : text.indexOf('"', c + 1);
if (q1 < 0) return ""; int q2 = q1 + 1;
while ((q2 = text.indexOf('"', q2)) >= 0 && q2 > 0 && text[q2 - 1] == '\\') ++q2;
if (q2 < 0) return ""; String v = text.substring(q1 + 1, q2); v.replace("\\\"", "\""); return v;
}
String primitiveValueAfter(const String& t, int s, const char* key) {
int p=t.indexOf(key,s); if(p<0) return ""; int c=t.indexOf(':',p+strlen(key)); if(c<0) return ""; int b=c+1;
while(b<(int)t.length() && (t[b]==' '||t[b]=='\n'||t[b]=='\r')) ++b; int e=b;
while(e<(int)t.length() && t[e]!=','&&t[e]!='}'&&t[e]!=']') ++e; String v=t.substring(b,e); v.trim(); return v;
}
String arrayItem(const String& t, int start, int wanted) {
int p=t.indexOf('[',start); if(p<0) return ""; ++p;
for(int i=0;i<=wanted;i++) { while(p<(int)t.length() && (t[p]==' '||t[p]=='\n'||t[p]=='\r'||t[p]=='"')) ++p; int e=p; while(e<(int)t.length()&&t[e]!=','&&t[e]!=']') ++e; if(i==wanted){String v=t.substring(p,e);v.replace("\"","");v.trim();return v;} p=e+1; } return "";
}
String formatUtc(const String& iso) { return iso.length() < 16 ? iso : iso.substring(0,10) + " " + iso.substring(11,16) + " UTC"; }
String weatherDescription(int c) { if(c==0)return "clear sky"; if(c<=3)return "partly cloudy"; if(c==45||c==48)return "fog"; if(c<=57)return "drizzle"; if(c<=67)return "rain"; if(c<=77)return "snow"; if(c<=82)return "rain showers"; if(c<=86)return "snow showers"; return "thunderstorms"; }
void drawWrapped(const String& text, int x, int y, int width, int step, uint16_t color) {
M5.Display.setTextColor(color);
String line;
int start = 0;
while (start < (int)text.length()) {
while (start < (int)text.length() && text[start] == ' ') ++start;
if (start >= (int)text.length()) break;
int end = text.indexOf(' ', start);
if (end < 0) end = text.length();
String word = text.substring(start, end);
String candidate = line.length() ? line + " " + word : word;
// Start a new line before drawing a word that would cross the right edge.
if (line.length() && M5.Display.textWidth(candidate) > width) {
M5.Display.drawString(line, x, y);
y += step;
line = word;
} else {
line = candidate;
}
start = end + 1;
}
if (line.length()) M5.Display.drawString(line, x, y);
}
void beginFrame() { M5.Display.setEpdMode(epd_mode_t::epd_fastest); M5.Display.setTextDatum(textdatum_t::top_left); M5.Display.fillScreen(TFT_BLACK); }
void drawBootScreen() { beginFrame(); M5.Display.setTextColor(TFT_WHITE); M5.Display.setTextSize(2); M5.Display.drawString("SPACEX LAUNCH TRACKER",20,40); M5.Display.setTextSize(1); M5.Display.setTextColor(TFT_YELLOW); M5.Display.drawString("Starting the display before Wi-Fi...",20,90); }
void drawField(const char* label,const String& value,int y) { M5.Display.setTextSize(1); M5.Display.setTextColor(TFT_CYAN); M5.Display.drawString(label,22,y); M5.Display.setTextSize(2); drawWrapped(value,22,y+21,M5.Display.width()-44,25,TFT_WHITE); }
void drawVehicleField(int y) { M5.Display.setTextSize(1); M5.Display.setTextColor(TFT_CYAN); M5.Display.drawString("LAUNCH VEHICLE",22,y); M5.Display.setTextSize(4); drawWrapped(vehicleName,22,y+21,M5.Display.width()-44,45,TFT_WHITE); }
void drawTracker() { int w=M5.Display.width(),h=M5.Display.height(); beginFrame(); M5.Display.fillRect(0,0,w,62,TFT_RED); M5.Display.setTextColor(TFT_WHITE);M5.Display.setTextSize(2);M5.Display.drawString("SPACEX · NEXT LAUNCH",18,18); drawVehicleField(88);drawField("MAJOR CARGO / MISSION",cargoName,190);drawField("LAUNCH TIME (UTC)",launchDate,292);drawField("LAUNCH LOCATION",launchPlace,394);drawField("FORECASTED WEATHER",weatherText,496);M5.Display.drawFastHLine(18,h-57,w-36,TFT_DARKGREY);M5.Display.setTextSize(1);M5.Display.setTextColor(TFT_LIGHTGREY);M5.Display.drawString("A: refresh B: Wi-Fi setup",18,h-39); }
void drawSetupScreen(){int w=M5.Display.width(),h=M5.Display.height();beginFrame();M5.Display.setTextColor(TFT_WHITE);M5.Display.setTextSize(2);M5.Display.drawString("SPACEX LAUNCH TRACKER",20,22);M5.Display.setTextSize(1);M5.Display.setTextColor(TFT_YELLOW);M5.Display.drawString("Set up Wi-Fi once to receive launch updates.",20,58);int q=min(w-80,h-190);M5.Display.qrcode("http://192.168.4.1",(w-q)/2,95,q,3);M5.Display.setTextColor(TFT_WHITE);M5.Display.drawString("1. Join Wi-Fi: "+String(SETUP_AP_NAME),20,h-78);M5.Display.drawString("2. Scan this code, then enter your home Wi-Fi details.",20,h-54);}
void handleRoot(){server.send(200,"text/html","<!doctype html><meta name='viewport' content='width=device-width,initial-scale=1'><body style='font-family:sans-serif;max-width:32rem;margin:2rem auto;padding:1rem'><h1>SpaceX Tracker</h1><form method='POST' action='/save'>Wi-Fi name<br><input required name='ssid' style='width:100%'><br><br>Wi-Fi password<br><input required type='password' name='password' style='width:100%'><br><br><button>Save and restart tracker</button></form></body>");}
void handleSave(){String ssid=server.arg("ssid");if(!ssid.length()){server.send(400,"text/plain","Wi-Fi name is required.");return;}preferences.putString("ssid",ssid);preferences.putString("password",server.arg("password"));server.send(200,"text/html","<h2>Saved.</h2><p>The tracker is restarting.</p>");delay(1200);ESP.restart();}
void configureLowPowerWiFi(){WiFi.setSleep(true);WiFi.setTxPower(WIFI_POWER_8_5dBm);}
void startSetupMode(){setupMode=true;WiFi.mode(WIFI_AP);configureLowPowerWiFi();WiFi.softAP(SETUP_AP_NAME);server.on("/",HTTP_GET,handleRoot);server.on("/save",HTTP_POST,handleSave);server.begin();drawSetupScreen();}
bool connectToWiFi(){if(!wifiSsid.length())return false;WiFi.mode(WIFI_STA);configureLowPowerWiFi();WiFi.begin(wifiSsid.c_str(),wifiPassword.c_str());uint32_t started=millis();while(WiFi.status()!=WL_CONNECTED&&millis()-started<20000){M5.update();delay(100);}return WiFi.status()==WL_CONNECTED;}
bool fetchWeather(const String& lat,const String& lon,const String& date){if(!lat.length()||!lon.length()||!date.length())return false;String url="https://api.open-meteo.com/v1/forecast?latitude="+lat+"&longitude="+lon+"&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max&timezone=UTC&forecast_days=16";HTTPClient http;http.setTimeout(12000);if(!http.begin(url))return false;int status=http.GET();if(status!=HTTP_CODE_OK){http.end();return false;}String json=http.getString();http.end();int times=json.indexOf("\"time\"");int match=-1;for(int i=0;i<16;++i)if(arrayItem(json,times,i)==date){match=i;break;}if(match<0){weatherText="Forecast becomes available within 16 days of launch";return false;}int codes=json.indexOf("\"weather_code\"");int hi=json.indexOf("\"temperature_2m_max\"");int lo=json.indexOf("\"temperature_2m_min\"");int rain=json.indexOf("\"precipitation_probability_max\"");int wind=json.indexOf("\"wind_speed_10m_max\"");weatherText=weatherDescription(arrayItem(json,codes,match).toInt())+", "+arrayItem(json,lo,match)+"–"+arrayItem(json,hi,match)+" C; rain "+arrayItem(json,rain,match)+"%; wind "+arrayItem(json,wind,match)+" km/h";return true;}
bool fetchLaunch(){if(WiFi.status()!=WL_CONNECTED)return false;HTTPClient http;http.setTimeout(12000);if(!http.begin(LAUNCH_API))return false;int status=http.GET();if(status!=HTTP_CODE_OK){http.end();return false;}String json=http.getString();http.end();int first=json.indexOf('{',json.indexOf("\"results\""));if(first<0)return false;String mission=quotedValueAfter(json,first,"\"name\"");String net=quotedValueAfter(json,first,"\"net\"");int pad=json.indexOf("\"pad\"",first),location=json.indexOf("\"location\"",pad),config=json.indexOf("\"configuration\"",first);if(!mission.length()||!net.length())return false;vehicleName=config>=0?quotedValueAfter(json,config,"\"name\""):"SpaceX vehicle";cargoName=mission;launchDate=formatUtc(net);launchPlace=location>=0?quotedValueAfter(json,location,"\"name\""):"Location pending";String lat=location>=0?primitiveValueAfter(json,location,"\"latitude\""):"",lon=location>=0?primitiveValueAfter(json,location,"\"longitude\""):"";weatherText="Weather not available";fetchWeather(lat,lon,net.substring(0,10));lastRefreshMs=millis();return true;}
void setup(){auto cfg=M5.config();cfg.clear_display=false;M5.begin(cfg);M5.Display.setRotation(0);drawBootScreen();delay(1500);preferences.begin("spacex",false);wifiSsid=preferences.getString("ssid","");wifiPassword=preferences.getString("password","");if(!wifiSsid.length()){startSetupMode();return;}if(connectToWiFi()&&fetchLaunch())drawTracker();else{vehicleName="Could not reach launch updates";cargoName="Check your Wi-Fi connection";launchDate="Then press A to refresh";launchPlace="Press B to replace saved Wi-Fi";weatherText="Weather not available";drawTracker();}}
void loop(){M5.update();if(setupMode){server.handleClient();delay(10);return;}if(M5.BtnA.wasClicked()){if(WiFi.status()!=WL_CONNECTED)connectToWiFi();if(fetchLaunch())drawTracker();}if(M5.BtnB.wasClicked()){preferences.clear();WiFi.disconnect(true);delay(200);startSetupMode();}if(WiFi.status()==WL_CONNECTED&&millis()-lastRefreshMs>=REFRESH_INTERVAL_MS)if(fetchLaunch())drawTracker();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.




