Pollux Labs
Published © MIT

The ISS Tracker is Back – with a New API

Predict when the ISS flies over you – and when you can acutally see it. A free drop-in successor to the retired open-notify API.

IntermediateFull instructions provided1 hour32
The ISS Tracker is Back – with a New API

Things used in this project

Hardware components

Arduino Nano ESP32
×1
Monochrome 0.91”128x32 I2C OLED Display with Chip Pad
DFRobot Monochrome 0.91”128x32 I2C OLED Display with Chip Pad
×1

Software apps and online services

Arduino IDE
Arduino IDE

Story

Read more

Schematics

ISS Tracker Schematic

How to set up the ISS Tracker on the breadboard

Code

ISS Tracker with an Arduino Nano ESP32

C/C++
/*********
  pollux labs, 2026
  https://polluxlabs.io

  ISS Tracker — Arduino Nano ESP32, using the new pollux labs
  ISS Pass API (https://iss-api.polluxlabs.io).

  Hardware:
    - Arduino Nano ESP32 (ESP32-S3)
    - SSD1306 OLED 128x32, I2C @ 0x3C, on A4 (SDA) / A5 (SCL)
    - LED: built-in (LED_BUILTIN)

  Display while waiting for the next pass:
    - 3 pages rotating every 5 s, page indicator dots bottom-right
        Page 0: countdown ("in 1h 23m") + date/time
        Page 1: rise -> set compass direction (large)
        Page 2: duration, max elevation, visibility flag
  During the pass, the ISS slides across the screen.
*********/

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <RTClib.h>
#include <time.h>


// ---------- WiFi credentials ----------
const char* ssid     = "YOUR NETWORK";
const char* password = "YOUR PASSWORD";

// ---------- Your location ----------
//This is the city of Karlsruhe, Germany. Adapt coordinates to your desired location
const float latitude   = 49.00;
const float longitude  = 8.40;
const float myAltitude = 115.00;

// ---------- Local timezone offset to UTC----------
// 7200 = CEST (summer), 3600 = CET (winter), -14400 = EDT
const long TZ_OFFSET_SEC = 7200;

// ---------- API endpoint ----------
const char* API_HOST = "iss-api.polluxlabs.io";
const char* API_PATH = "/iss-pass";

// ---------- Pins (Arduino Nano ESP32) ----------
const int LED_PIN = LED_BUILTIN;


// 'iss', 128x32px
const unsigned char iss [] PROGMEM = {
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xf8, 0x3f, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x0f, 0xe0, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x0f, 0xe0, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x1b, 0xfa, 0xaf, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
  0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xfa, 0xbf, 0xe0, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x0e, 0xe0, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x0e, 0xe0, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xfa, 0xbf, 0xe0, 0x40, 0x00, 0x01, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x84, 0x4c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x84, 0x4c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x7c, 0x7c, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xa0, 0x5c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0xa0, 0x5c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x7c, 0x7c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x84, 0x4c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x10,
  0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x01, 0x84, 0x4c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x04, 0x40, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00,
  0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x0f, 0xfa, 0xbf, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x0e, 0xe0, 0x20, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x0e, 0xe0, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xfa, 0xbe, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1b, 0xfa, 0xaf, 0xa0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x18, 0x0f, 0xe0, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x0f, 0xe0, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0xf8, 0x3f, 0xe0, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
  0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};


// ---------- Runtime state ----------
time_t riseTime    = 0;
time_t currentTime = 0;
int    duration    = 0;
String riseCompass = "";
String setCompass  = "";
bool   isVisible   = false;
int    maxElev     = 0;
char   dateTimeStr[20] = "";
char   durStr[12]      = "";

// ---------- Display page rotation ----------
const int           PAGE_COUNT       = 3;
const unsigned long PAGE_INTERVAL_MS = 5000;
int           displayPage    = 0;
unsigned long lastPageSwitch = 0;


// ---------- OLED Display ----------
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 32
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);


// Convert "2026-04-22T19:32:04Z" -> unix timestamp (UTC).
// Works because we set TZ to UTC0 in setup() before calling mktime().
time_t parseIsoUtc(const char* iso) {
  int Y, M, D, h, m, s;
  if (sscanf(iso, "%d-%d-%dT%d:%d:%dZ", &Y, &M, &D, &h, &m, &s) != 6) return 0;
  struct tm tmTime = {0};
  tmTime.tm_year = Y - 1900;
  tmTime.tm_mon  = M - 1;
  tmTime.tm_mday = D;
  tmTime.tm_hour = h;
  tmTime.tm_min  = m;
  tmTime.tm_sec  = s;
  return mktime(&tmTime);
}


// Format a duration in seconds as "1h 23m" / "23m 45s" / "45s"
String formatCountdown(long secs) {
  if (secs < 0) secs = 0;
  char buf[16];
  if (secs < 60) {
    snprintf(buf, sizeof(buf), "%lds", secs);
  } else if (secs < 3600) {
    snprintf(buf, sizeof(buf), "%ldm %02lds", secs / 60, secs % 60);
  } else {
    snprintf(buf, sizeof(buf), "%ldh %02ldm", secs / 3600, (secs % 3600) / 60);
  }
  return String(buf);
}


// Render the current display page (called once per second while waiting)
void renderPage(int page, long secsUntil) {
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(WHITE);

  switch (page) {
    case 0:  // Countdown + date/time
      display.setCursor(0, 0);
      display.print("NEXT PASS:");
      display.setCursor(0, 12);
      display.print(dateTimeStr);
      display.setCursor(0, 23);
      display.print("in ");
      display.print(formatCountdown(secsUntil));
      break;

    case 1:  // Rise -> Set direction (big)
      display.setCursor(0, 0);
      display.print("Rise -> Set");
      display.setTextSize(2);
      display.setCursor(0, 14);
      display.print(riseCompass);
      display.print(">");
      display.print(setCompass);
      break;

    case 2:  // Duration, elevation, visibility
      display.setCursor(0, 0);
      display.print("Pass details:");
      display.setCursor(0, 12);
      display.print(durStr);
      display.print(", max ");
      display.print(maxElev);
      display.write(0xF8);
      display.setCursor(0, 23);
      display.print(isVisible ? "*VISIBLE*" : "not visible");
      break;
  }

  // Page indicator dots (bottom-right)
  for (int i = 0; i < PAGE_COUNT; i++) {
    int x = 104 + i * 8;
    int y = 28;
    if (i == page) {
      display.fillCircle(x, y, 2, WHITE);
    } else {
      display.drawCircle(x, y, 2, WHITE);
    }
  }

  display.display();
}


void apiCall() {
  if (WiFi.status() != WL_CONNECTED) return;

  time(&currentTime);

  WiFiClientSecure client;
  client.setInsecure();   // skip TLS cert check (fine for hobby use)

  String url = String("https://") + API_HOST + API_PATH
             + "?lat=" + String(latitude, 4)
             + "&lon=" + String(longitude, 4)
             + "&alt=" + String(myAltitude, 0)
             + "&n=5";

  HTTPClient http;
  http.begin(client, url);
  int code = http.GET();
  Serial.println(code);

  if (code != 200) {
    Serial.println("Error on HTTP request");
    http.end();
    return;
  }

  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, http.getStream());
  http.end();

  if (err) {
    Serial.print(F("deserializeJson failed: "));
    Serial.println(err.c_str());
    return;
  }

  // Pick the first pass that hasn't started yet
  JsonArray passes = doc["passes"];
  for (JsonObject p : passes) {
    const char* riseStr = p["rise"]["time"];
    time_t t = parseIsoUtc(riseStr);
    if (t > currentTime) {
      riseTime    = t;
      duration    = p["duration_sec"].as<int>();
      riseCompass = p["rise"]["compass"].as<String>();
      setCompass  = p["set"]["compass"].as<String>();
      isVisible   = p["visible"].as<bool>();
      maxElev     = p["culmination"]["elevation_deg"].as<int>();
      break;
    }
  }

  // Pre-format the strings used by the display pages
  DateTime localTime((uint32_t)(riseTime + TZ_OFFSET_SEC));
  snprintf(dateTimeStr, sizeof(dateTimeStr), "%02d.%02d.%04d %02d:%02d",
           localTime.day(), localTime.month(), localTime.year(),
           localTime.hour(), localTime.minute());
  snprintf(durStr, sizeof(durStr), "%dm %02ds", duration / 60, duration % 60);

  // Debug
  Serial.print("Rise (unix UTC) = ");
  Serial.println((long)riseTime);
  Serial.println(dateTimeStr);
  Serial.print("Pass: "); Serial.print(riseCompass);
  Serial.print(" -> "); Serial.print(setCompass);
  Serial.print(", "); Serial.print(durStr);
  Serial.print(", max "); Serial.print(maxElev); Serial.print(" deg");
  Serial.println(isVisible ? ", VISIBLE" : ", not visible");

  // Reset page rotation so we always start with the countdown
  displayPage    = 0;
  lastPageSwitch = millis();
}


void setup() {
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  Serial.begin(115200);

  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println(F("SSD1306 allocation failed"));
    for (;;);
  }

  display.setTextSize(1);
  display.setTextColor(WHITE);
  display.cp437(true); //enable extended ASCII

  // Connect to WiFi
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    display.clearDisplay();
    display.setCursor(0, 10);
    display.println("Connecting to WiFi...");
    display.display();
  }

  // Sync clock via NTP. We work in UTC internally and convert to local
  // time only for display, via TZ_OFFSET_SEC.
  configTime(0, 0, "pool.ntp.org", "time.nist.gov");
  setenv("TZ", "UTC0", 1);
  tzset();
  while (time(nullptr) < 1700000000UL) {
    delay(200);
  }

  delay(500);
  display.clearDisplay();
  display.setCursor(0, 10);
  display.println("Hello, world!");
  display.display();
}


void loop() {
  apiCall();

  // Wait for the pass to start. Rotate through info pages every PAGE_INTERVAL_MS,
  // re-render each second so the countdown stays live, light the LED 60 s before rise.
  while (currentTime < riseTime) {
    delay(1000);
    time(&currentTime);

    long secsUntil = (long)(riseTime - currentTime);

    if (millis() - lastPageSwitch >= PAGE_INTERVAL_MS) {
      displayPage    = (displayPage + 1) % PAGE_COUNT;
      lastPageSwitch = millis();
    }

    renderPage(displayPage, secsUntil);

    if (secsUntil <= 60) {
      digitalWrite(LED_PIN, HIGH);
    }
  }

  // Pass starts: animate the ISS sliding across the OLED.
  int maxDuration = duration;
  for (; duration >= 0; duration--) {
    display.clearDisplay();
    int xOnDisplay = map(duration, maxDuration, 0, -94, 128);
    display.drawBitmap(xOnDisplay, 0, iss, 128, 32, WHITE);
    display.display();
    delay(1000);
  }
  digitalWrite(LED_PIN, LOW);
}

Credits

Pollux Labs
12 projects • 23 followers
I work in the field of user experience. Besides that, as a maker, I create projects focused on electronics, microcontrollers, and AI.

Comments