/*********
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(¤tTime);
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(¤tTime);
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);
}
Comments