Community project
ISS Mercator Tracker
The ISS Mercator Tracker displays the real-time location of the International Space Station on a live map using an ESP32 and M5 Paper display. The project fetches ISS position data over Wi-Fi and renders it on a Mercator projection map, showing the station's current coordinates, ground speed, and the geographic location or ocean below it.
This guide provides a complete parts list, wiring diagram, and step-by-step assembly instructions. After setup, the tracker connects to your home Wi-Fi and continuously updates the ISS position on the e-ink display, making it a compelling real-time visualization of orbital mechanics.
Wiring diagram
Assemble it in 4 steps
1. Place the Paper Color where it can see Wi-Fi
Set the M5Stack Paper Color on a flat, dry surface near your usual Wi-Fi router. This tracker uses the screen, buttons, and Wi-Fi already inside the unit, so there are no loose electronic parts to connect.
- Keep it away from splashes and direct sunlight so the e-paper screen stays easy to read.
- Do not open the case or connect wires to the display connector — the display is already connected inside the unit.
2. Power the tracker
Plug a USB-C cable into the Paper Color and a normal USB power source. The cable provides power while you set up and use the tracker.
- Use a cable that carries both power and data so Schematik can send the finished program to the Paper Color.
- Do not force the USB-C plug; it should slide in either way without pressure.
3. Connect the tracker to your home Wi-Fi
After deploying, scan the QR code shown on the Paper Color with your phone. Join the network named ISS-Tracker-Setup using password trackiss, then follow the page your phone opens to choose your normal Wi-Fi network.
- If the phone does not open the setup page by itself, tap the Wi-Fi network notification or open a web page while still connected to ISS-Tracker-Setup.
- The temporary setup network is only for entering Wi-Fi details; do not use it as your normal internet connection.
4. Use the live map
Wait for the live map to appear. The red dot is where the ISS is now and the blue line is its next ground track. Press the built-in A button to refresh immediately; press the built-in C button if you want to erase saved Wi-Fi and start setup again.
- E-paper changes more slowly than a phone screen because it redraws the complete page at once; the tracker refreshes automatically about every 90 seconds.
- Do not unplug power while the screen is changing; let the e-paper refresh finish first.
Deploy the firmware
#include <Arduino.h>
#include <M5Unified.h>
#include <WiFiManager.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <qrcode.h>
#include <math.h>
#include "mercator_map.h"
// Forward declarations
int mapLocalX(float longitude);
int mapLocalY(float latitude);
int mapX(float longitude);
int mapY(float latitude);
void drawGeographicOutline(const float points[][2], size_t pointCount);
void fillGeographicLandmass(const float points[][2], size_t pointCount);
void drawQrCode(const char *text, int x, int y, int scale);
void drawWrappedText(const String &text, int x, int y, int maxWidth, int lineHeight, int maxLines);
void showWifiScreen();
void colorizeMapCanvas();
void copyCanvasToPage(M5Canvas &canvas, int destinationX, int destinationY, int width, int height);
void drawTrajectory();
void drawThickLine(int x1, int y1, int x2, int y2, uint16_t color);
void drawDynamicMap();
void drawDynamicInfo(bool dataOk);
void refreshLiveAreas(bool dataOk);
String oceanName(float lat, float lon);
String lookupPlace(float lat, float lon);
bool fetchIss();
void showTracker(bool dataOk);
M5Canvas page(&M5.Display);
// These two small canvases are the only areas redrawn during a live refresh.
M5Canvas mapCanvas(&M5.Display);
M5Canvas infoCanvas(&M5.Display);
WiFiManager wifiManager;
static const char *AP_NAME = "ISS-Tracker-Setup";
static const char *AP_PASSWORD = "trackiss";
static const uint32_t UPDATE_INTERVAL_MS = 90000;
static const int SCREEN_W = 400;
static const int SCREEN_H = 600;
static const int MAP_X = 18;
static const int MAP_Y = 155;
static const int MAP_W = 364;
static const int MAP_H = 300;
float issLat = 0.0f;
float issLon = 0.0f;
float issSpeed = 0.0f;
String placeName = "Waiting for a location";
static const int TRACK_POINTS = 10;
float trackLat[TRACK_POINTS];
float trackLon[TRACK_POINTS];
bool trackAvailable = false;
uint32_t lastUpdate = 0;
int mapX(float longitude) {
return MAP_X + (int)((longitude + 180.0f) * MAP_W / 360.0f);
}
int mapY(float latitude) {
latitude = constrain(latitude, -85.0f, 85.0f);
float radians = latitude * DEG_TO_RAD;
float mercator = logf(tanf(PI / 4.0f + radians / 2.0f));
float normalized = (1.0f - mercator / PI) * 0.5f;
return MAP_Y + (int)(normalized * MAP_H);
}
// Coordinates inside the smaller map canvas, rather than the full page canvas.
int mapLocalX(float longitude) {
return (int)((longitude + 180.0f) * MAP_W / 360.0f);
}
int mapLocalY(float latitude) {
latitude = constrain(latitude, -85.0f, 85.0f);
float radians = latitude * DEG_TO_RAD;
float mercator = logf(tanf(PI / 4.0f + radians / 2.0f));
float normalized = (1.0f - mercator / PI) * 0.5f;
return (int)(normalized * MAP_H);
}
// Breaks at spaces so text never runs beyond the selected page area.
void drawWrappedText(const String &text, int x, int y, int maxWidth, int lineHeight, int maxLines) {
String line;
int lineNumber = 0;
int start = 0;
while (start < (int)text.length() && lineNumber < maxLines) {
int space = text.indexOf(' ', start);
String word = (space < 0) ? text.substring(start) : text.substring(start, space);
String candidate = line.length() ? line + " " + word : word;
if (line.length() && page.textWidth(candidate) > maxWidth) {
page.drawString(line, x, y + lineNumber * lineHeight);
line = word;
++lineNumber;
} else {
line = candidate;
}
if (space < 0) break;
start = space + 1;
}
if (line.length() && lineNumber < maxLines) page.drawString(line, x, y + lineNumber * lineHeight);
}
void fillGeographicLandmass(const float points[][2], size_t pointCount) {
if (pointCount < 3 || pointCount > 32) return;
int px[32], py[32];
for (size_t i = 0; i < pointCount; ++i) {
px[i] = mapX(points[i][0]);
py[i] = mapY(points[i][1]);
}
int minY = MAP_Y + MAP_H - 1;
int maxY = MAP_Y;
for (size_t i = 0; i < pointCount; ++i) {
minY = min(minY, py[i]);
maxY = max(maxY, py[i]);
}
minY = max(minY, MAP_Y + 1);
maxY = min(maxY, MAP_Y + MAP_H - 2);
for (int y = minY; y <= maxY; ++y) {
int crossings[32];
int count = 0;
for (size_t i = 0, j = pointCount - 1; i < pointCount; j = i++) {
if ((py[i] > y) != (py[j] > y) && count < 32) {
crossings[count++] = px[i] + (int)((long)(y - py[i]) * (px[j] - px[i]) / (py[j] - py[i]));
}
}
for (int i = 0; i < count - 1; ++i) {
for (int j = i + 1; j < count; ++j) {
if (crossings[j] < crossings[i]) { int t = crossings[i]; crossings[i] = crossings[j]; crossings[j] = t; }
}
}
for (int i = 0; i + 1 < count; i += 2) {
int left = max(MAP_X + 1, crossings[i]);
int right = min(MAP_X + MAP_W - 2, crossings[i + 1]);
if (right >= left) page.drawFastHLine(left, y, right - left + 1, TFT_LIGHTGREY);
}
}
}
void drawGeographicOutline(const float points[][2], size_t pointCount) {
if (pointCount < 2) return;
for (size_t i = 1; i < pointCount; ++i) {
int x1 = mapX(points[i - 1][0]);
int x2 = mapX(points[i][0]);
if (abs(x2 - x1) < MAP_W / 2) {
page.drawLine(x1, mapY(points[i - 1][1]), x2, mapY(points[i][1]), TFT_DARKGREY);
}
}
}
void drawQrCode(const char *text, int x, int y, int scale) {
QRCode qr;
uint8_t qrData[qrcode_getBufferSize(4)];
qrcode_initText(&qr, qrData, 4, ECC_LOW, text);
page.fillRect(x - 4, y - 4, qr.size * scale + 8, qr.size * scale + 8, TFT_WHITE);
for (uint8_t row = 0; row < qr.size; ++row) {
for (uint8_t column = 0; column < qr.size; ++column) {
if (qrcode_getModule(&qr, column, row)) page.fillRect(x + column * scale, y + row * scale, scale, scale, TFT_BLACK);
}
}
}
void showWifiScreen() {
page.fillSprite(TFT_WHITE);
page.setTextColor(TFT_BLACK, TFT_WHITE);
page.setTextDatum(textdatum_t::top_center);
// Font6 on this display does not contain the full alphabet reliably.
// Font4 is the same known-good family used by the tracker screen.
page.setFont(&fonts::Font4);
page.drawString("ISS Tracker", SCREEN_W / 2, 24);
page.drawString("ISS Tracker", SCREEN_W / 2 + 1, 24); // small offset gives a bold look
page.setFont(&fonts::Font4);
drawWrappedText("Wi-Fi setup", 200, 70, 360, 27, 1);
page.setFont(&fonts::Font2);
drawWrappedText("Scan this code with your phone, then join the setup network. Your phone should open a page where you choose home Wi-Fi.", 200, 105, 350, 19, 3);
drawQrCode("WIFI:T:WPA;S:ISS-Tracker-Setup;P:trackiss;;", 104, 178, 7);
page.setFont(&fonts::Font4);
page.drawString("Network: ISS-Tracker-Setup", 200, 398);
page.drawString("Password: trackiss", 200, 429);
page.setFont(&fonts::Font2);
drawWrappedText("After it connects, this screen changes to the live map.", 200, 480, 350, 20, 2);
page.pushSprite(0, 0);
}
void colorizeMapCanvas() {
// Treat every near-white pixel in the uploaded image as water. Using a
// brightness test instead of an exact white-value comparison also catches
// palette-converted whites, leaving no pale background pixels behind.
for (int y = 0; y < MAP_H; ++y) {
for (int x = 0; x < MAP_W; ++x) {
uint16_t source = mapCanvas.readPixel(x, y);
uint8_t red = (source >> 11) & 0x1F;
uint8_t green = (source >> 5) & 0x3F;
uint8_t blue = source & 0x1F;
uint16_t brightness = red * 8 + green * 4 + blue * 8;
mapCanvas.drawPixel(x, y, brightness > 500 ? TFT_BLACK : TFT_LIGHTGREY);
}
}
}
// Copy an already-rendered off-screen area into the full-page off-screen canvas.
// The e-paper receives that complete page only after all of its pixels are ready.
void copyCanvasToPage(M5Canvas &canvas, int destinationX, int destinationY, int width, int height) {
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
page.drawPixel(destinationX + x, destinationY + y, canvas.readPixel(x, y));
}
}
}
// Draw a 5-pixel-wide path into the small off-screen map canvas.
void drawThickLine(int x1, int y1, int x2, int y2, uint16_t color) {
mapCanvas.drawLine(x1, y1, x2, y2, color);
if (abs(x2 - x1) >= abs(y2 - y1)) {
for (int offset = -2; offset <= 2; ++offset) mapCanvas.drawLine(x1, y1 + offset, x2, y2 + offset, color);
} else {
for (int offset = -2; offset <= 2; ++offset) mapCanvas.drawLine(x1 + offset, y1, x2 + offset, y2, color);
}
}
void drawTrajectory() {
if (!trackAvailable) return;
float previousLat = issLat;
float previousLon = issLon;
for (int i = 0; i < TRACK_POINTS; ++i) {
int x1 = mapLocalX(previousLon), x2 = mapLocalX(trackLon[i]);
int y1 = mapLocalY(previousLat), y2 = mapLocalY(trackLat[i]);
// Do not connect across the map's left/right date-line edge.
if (abs(x2 - x1) < MAP_W / 2) drawThickLine(x1, y1, x2, y2, TFT_BLUE);
previousLat = trackLat[i];
previousLon = trackLon[i];
}
}
void drawDynamicMap() {
// Restore the uploaded map first; this erases the previous red dot and blue path.
mapCanvas.pushImage(0, 0, MERCATOR_MAP_WIDTH, MERCATOR_MAP_HEIGHT, MERCATOR_MAP_DATA);
colorizeMapCanvas();
mapCanvas.drawRect(0, 0, MAP_W, MAP_H, TFT_LIGHTGREY);
drawTrajectory();
mapCanvas.fillCircle(mapLocalX(issLon), mapLocalY(issLat), 6, TFT_RED);
mapCanvas.drawCircle(mapLocalX(issLon), mapLocalY(issLat), 7, TFT_BLACK);
}
void drawDynamicInfo(bool dataOk) {
// A true-black information panel avoids unreliable charcoal palette rendering.
infoCanvas.fillSprite(TFT_BLACK);
infoCanvas.setTextColor(TFT_YELLOW, TFT_BLACK);
infoCanvas.setTextDatum(textdatum_t::top_left);
infoCanvas.setFont(&fonts::Font4);
infoCanvas.drawString("Speed: " + String(issSpeed, 0) + " km/h", 0, 6);
infoCanvas.drawString("Over:", 0, 42);
String displayedPlace = placeName;
displayedPlace.trim();
if (!displayedPlace.length() || displayedPlace == "Waiting for a location") displayedPlace = oceanName(issLat, issLon);
int labelWidth = infoCanvas.textWidth("Over: ");
// Keep the current place beside Over. The map begins below this canvas, so a long name has room to wrap.
if (infoCanvas.textWidth(displayedPlace) <= MAP_W - labelWidth) {
infoCanvas.drawString(displayedPlace, labelWidth, 42);
} else {
infoCanvas.drawString(displayedPlace.substring(0, displayedPlace.lastIndexOf(' ')), labelWidth, 42);
}
}
void refreshLiveAreas(bool dataOk) {
drawDynamicInfo(dataOk);
drawDynamicMap();
// Only these two rectangles are transferred for subsequent updates; the title and legends stay untouched.
infoCanvas.pushSprite(18, 60);
mapCanvas.pushSprite(MAP_X, MAP_Y);
}
String oceanName(float lat, float lon) {
if (lat > 66.0f) return "Arctic Ocean";
if (lat < -60.0f) return "Southern Ocean";
if (lon > 20.0f && lon < 120.0f && lat < 30.0f && lat > -50.0f) return "Indian Ocean";
if (lon > -70.0f && lon < 20.0f && lat > 0.0f && lat < 65.0f) return "North Atlantic Ocean";
if (lon > -70.0f && lon < 20.0f) return "South Atlantic Ocean";
if (lon >= 120.0f || lon < -70.0f) return lat >= 0.0f ? "North Pacific Ocean" : "South Pacific Ocean";
return "Open ocean";
}
String lookupPlace(float lat, float lon) {
HTTPClient http;
String url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&zoom=3&lat=" + String(lat, 4) + "&lon=" + String(lon, 4);
http.setUserAgent("M5PaperColor-ISSTracker/1.0");
if (!http.begin(url)) return oceanName(lat, lon);
int result = http.GET();
if (result != HTTP_CODE_OK) { http.end(); return oceanName(lat, lon); }
JsonDocument doc;
DeserializationError error = deserializeJson(doc, http.getString());
http.end();
if (error || !doc["address"]["country"].is<const char *>()) return oceanName(lat, lon);
String country = String(doc["address"]["country"].as<const char *>());
country.trim();
return country.length() ? country : oceanName(lat, lon);
}
bool fetchIss() {
HTTPClient http;
if (!http.begin("https://api.wheretheiss.at/v1/satellites/25544")) return false;
int result = http.GET();
if (result != HTTP_CODE_OK) { http.end(); return false; }
JsonDocument doc;
DeserializationError error = deserializeJson(doc, http.getString());
http.end();
if (error) return false;
issLat = doc["latitude"] | 0.0f;
issLon = doc["longitude"] | 0.0f;
issSpeed = doc["velocity"] | 0.0f;
uint32_t timestamp = doc["timestamp"] | 0UL;
placeName = lookupPlace(issLat, issLon);
// Use real future samples so the path follows the ISS's actual direction.
trackAvailable = false;
if (timestamp > 0) {
String times;
for (int i = 0; i < TRACK_POINTS; ++i) {
if (i) times += ",";
times += String(timestamp + (i + 1) * 240UL);
}
HTTPClient trackHttp;
String trackUrl = "https://api.wheretheiss.at/v1/satellites/25544/positions?timestamps=" + times;
if (trackHttp.begin(trackUrl) && trackHttp.GET() == HTTP_CODE_OK) {
JsonDocument trackDoc;
DeserializationError trackError = deserializeJson(trackDoc, trackHttp.getString());
if (!trackError && trackDoc.is<JsonArray>() && trackDoc.size() == TRACK_POINTS) {
for (int i = 0; i < TRACK_POINTS; ++i) {
trackLat[i] = trackDoc[i]["latitude"] | issLat;
trackLon[i] = trackDoc[i]["longitude"] | issLon;
}
trackAvailable = true;
}
}
trackHttp.end();
}
return true;
}
void showTracker(bool dataOk) {
// This complete page is used once when entering the tracker screen.
page.fillSprite(TFT_WHITE);
// Use true black for every page background area.
page.fillRect(0, 0, SCREEN_W, SCREEN_H, TFT_BLACK);
page.fillRect(12, 10, 376, 38, TFT_RED);
page.setTextColor(TFT_WHITE, TFT_RED);
page.setTextDatum(textdatum_t::top_center);
page.setFont(&fonts::Font4);
// Draw twice one pixel apart to give this fixed-width bitmap font a bold look.
page.drawString("ISS Tracker", SCREEN_W / 2, 17);
page.drawString("ISS Tracker", SCREEN_W / 2 + 1, 17);
// Build the live map and information in their own off-screen canvases first.
// They are copied into this full-page canvas before the single initial transfer.
drawDynamicInfo(dataOk);
drawDynamicMap();
copyCanvasToPage(infoCanvas, 18, 60, MAP_W, MAP_Y - 60);
copyCanvasToPage(mapCanvas, MAP_X, MAP_Y, MAP_W, MAP_H);
// The legend sits on the same true-black background as the live information.
page.fillRect(12, 455, 376, 69, TFT_BLACK);
page.setTextDatum(textdatum_t::top_left);
page.setFont(&fonts::Font4);
page.setTextColor(TFT_BLUE, TFT_BLACK);
page.drawString("Blue: predicted 36-minute track", 18, 464);
page.setTextColor(TFT_RED, TFT_BLACK);
page.drawString("Red: ISS now", 18, 493);
page.setTextColor(TFT_YELLOW, TFT_BLACK);
page.setFont(&fonts::Font2);
drawWrappedText(dataOk ? "A: refresh now C: change Wi-Fi" : "Could not reach ISS data. Press A to try again.", 18, 544, 364, 19, 2);
// Every background pixel, including water around the coastlines, is now black
// in the full-page canvas. Send this completed first page in one pass.
page.pushSprite(0, 0);
}
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
M5.Display.setRotation(0);
page.setColorDepth(8);
page.createSprite(SCREEN_W, SCREEN_H);
// Allocate the two partial-refresh canvases before any live data is drawn.
// The info canvas covers the area below the title and above the map.
mapCanvas.setColorDepth(8);
mapCanvas.createSprite(MAP_W, MAP_H);
infoCanvas.setColorDepth(8);
infoCanvas.createSprite(MAP_W, MAP_Y - 60);
showWifiScreen();
wifiManager.setConfigPortalTimeout(0);
wifiManager.autoConnect(AP_NAME, AP_PASSWORD);
bool gotData = fetchIss();
showTracker(gotData);
lastUpdate = millis();
}
void loop() {
M5.update();
if (M5.BtnC.wasClicked()) {
showWifiScreen();
wifiManager.resetSettings();
delay(200);
ESP.restart();
}
if (M5.BtnA.wasClicked() || millis() - lastUpdate >= UPDATE_INTERVAL_MS) {
bool gotData = fetchIss();
// The fixed title, legend, and map frame remain on the panel. Only the
// information and live map overlay are rebuilt and transferred.
refreshLiveAreas(gotData);
lastUpdate = millis();
}
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.




