← Back to LED Launch Sign

Source Code

LED Launch Sign · cpp

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Adafruit_GFX.h>
#include <Adafruit_NeoMatrix.h>
#include <Adafruit_NeoPixel.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <time.h> // Required for NTP syncing

// --- Configuration ---
const char* ssid = "SSID";
const char* password = "PASSWORD";
const char* apiUrl = "APIURL";

// --- Hardware Pins ---
#define LED_PIN 15 // GP15 (Physical Pin 20)

// --- OLED Setup ---
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1 
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// --- LED Matrix Setup (4 Units: 128x8) ---
const int MATRIX_WIDTH = 128;
Adafruit_NeoMatrix matrix = Adafruit_NeoMatrix(MATRIX_WIDTH, 8, LED_PIN,
  NEO_MATRIX_TOP     + NEO_MATRIX_LEFT +
  NEO_MATRIX_COLUMNS + NEO_MATRIX_ZIGZAG, 
  NEO_GRB            + NEO_KHZ800);       

// --- Global Variables ---
String mainText = "Loading...";
unsigned long launchTimestamp = 0; 
long timeUntilLaunch = 0;
bool isCountingDown = false;
bool postLaunchFetchTriggered = false;
unsigned long lastTickTime = 0;
unsigned long zeroHitTime = 0;
int cursorX = MATRIX_WIDTH; 

String consoleLines[5] = {"", "", "", "", ""};

// --- OLED Scrolling Variables ---
String globalLaunchName = "";
bool displayScrollingName = false;
bool needsOledScroll = false;
int oledCursorX = 0;
unsigned long lastOledUpdateTime = 0;
unsigned long dataSyncedTime = 0;
bool pendingNameScroll = false;

// --- Function Prototypes ---
void printConsole(String msg);
void updateOLED();
void connectWiFi();
void fetchLaunchData();
String formatCountdown(long seconds);
void updateDisplay();

void setup() {
  delay(2000); 
  Serial.begin(115200);
  
  Wire.setSDA(4);
  Wire.setSCL(5);
  Wire.begin();
  
  // Speed up I2C to 400kHz so the OLED animation doesn't lag the LED matrix
  Wire.setClock(400000); 
  
  if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { 
    Serial.println(F("SSD1306 allocation failed"));
    for(;;); 
  }
  
  display.setTextWrap(false);

  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  printConsole("System Booting...");
  
  matrix.begin();
  matrix.setTextWrap(false);
  matrix.setBrightness(40); // 0-255
  
  connectWiFi();
  fetchLaunchData();
}

void loop() {
  unsigned long currentMillis = millis();

  // 1. Check if it's time to swap "Data Synced" to the scrolling name
  if (pendingNameScroll && (currentMillis - dataSyncedTime >= 2000)) {
    pendingNameScroll = false;
    displayScrollingName = true;
    // Check if the name is wider than the 128px screen (6 pixels per char)
    needsOledScroll = (globalLaunchName.length() * 6) > 128;
    oledCursorX = needsOledScroll ? 128 : 0; // Start at right edge if scrolling, else lock to left
    updateOLED(); // Force an immediate draw
  }

  // 2. Handle OLED Name Scrolling (Updates every 50ms if scrolling is needed)
  if (displayScrollingName && needsOledScroll && (currentMillis - lastOledUpdateTime >= 50)) {
    lastOledUpdateTime = currentMillis;
    oledCursorX--;
    int nameWidth = globalLaunchName.length() * 6;
    if (oledCursorX < -nameWidth) {
      oledCursorX = 128; 
    }
    updateOLED();
  }

  // 3. Handle Internal Countdown via Real-World Time (1 Hz Tick)
  if (isCountingDown && (currentMillis - lastTickTime >= 1000)) {
    lastTickTime = currentMillis;
    
    time_t now = time(nullptr);
    
    if (launchTimestamp > now) {
      timeUntilLaunch = launchTimestamp - now;
    } else {
      timeUntilLaunch = 0;
    }

    if (timeUntilLaunch == 0 && !postLaunchFetchTriggered) {
      zeroHitTime = currentMillis;
      postLaunchFetchTriggered = true;
      printConsole("LIFTOFF! Awaiting API.");
    }
    
    // Only trigger the 1-second OLED refresh here if it isn't ALREADY 
    // being refreshed every 50ms by the scrolling animation above.
    if (!(displayScrollingName && needsOledScroll)) {
       updateOLED(); 
    }
  }

  // 4. Fetch next launch 60 seconds after zero
  if (postLaunchFetchTriggered && (currentMillis - zeroHitTime >= 60000)) {
    printConsole("Fetching next launch...");
    fetchLaunchData();
    postLaunchFetchTriggered = false; 
  }

  // 5. Render Main LED Matrix
  updateDisplay();
  
  delay(40); 
}

// --- Helper Functions ---

void updateOLED() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  
  display.setTextSize(1);
  
  // 1. Draw the top 4 console lines (0 through 3)
  for(int i = 0; i < 4; i++) {
    display.setCursor(0, i * 8); 
    display.println(consoleLines[i]);
  }
  
  // 2. Draw either the 5th console line OR the scrolling JSON name
  if (displayScrollingName) {
    display.setCursor(oledCursorX, 32); 
    display.print(globalLaunchName);
  } else {
    display.setCursor(0, 32);
    display.println(consoleLines[4]);
  }
  
  // 3. Draw separator and Countdown Timer
  // display.drawLine(0, 44, 128, 44, SSD1306_WHITE);
  
  display.setCursor(4, 49); 
  display.setTextSize(2); 
  display.print(formatCountdown(timeUntilLaunch));
  
  display.setTextSize(1); 
  display.display();
}

void printConsole(String msg) {
  Serial.println(msg); 
  
  // If a new console message arrives, turn off the scrolling name
  // so the user can see what the console is trying to tell them.
  displayScrollingName = false;
  pendingNameScroll = false;
  
  for(int i = 0; i < 4; i++) {
    consoleLines[i] = consoleLines[i+1];
  }
  consoleLines[4] = msg;
  
  updateOLED();
}

void connectWiFi() {
  printConsole("Starting WiFi...");

  WiFi.disconnect(true);
  delay(1000); 
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  
  matrix.fillScreen(0);
  matrix.setCursor(0, 0);
  matrix.setTextColor(matrix.Color(0, 0, 255));
  matrix.print("WiFi...");
  matrix.show();

  int attempts = 0;
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    attempts++;
    if(attempts % 4 == 0) printConsole("Still connecting...");
    
    if (attempts > 20) {
      printConsole("Radio stuck. Retrying...");
      WiFi.disconnect(true);
      delay(1000);
      WiFi.begin(ssid, password);
      attempts = 0; 
    }
  }
  
  printConsole("WiFi Connected!");
  printConsole("IP: " + WiFi.localIP().toString());
  
  // -- NTP Time Sync --
  printConsole("Syncing NTP Time...");
  configTime(0, 0, "pool.ntp.org", "time.nist.gov"); 
  
  time_t now = time(nullptr);
  while (now < 24 * 3600) {
    delay(500);
    now = time(nullptr);
  }
  printConsole("Time Synced!");
}

void fetchLaunchData() {
  if (WiFi.status() == WL_CONNECTED) {
    printConsole("Connecting to API...");
    
    WiFiClientSecure client;
    client.setInsecure(); 
    
    HTTPClient http;
    http.begin(client, apiUrl);
    int httpResponseCode = http.GET();

    if (httpResponseCode > 0) {
      String payload = http.getString();
      
      JsonDocument doc;
      DeserializationError error = deserializeJson(doc, payload);

      if (!error) {
        // Save the launch name to our global variable for the OLED scroller
        globalLaunchName = doc["name"].as<String>();
        
        String launchLoc = doc["location"].as<String>();
        String launchDateStr = doc["date"].as<String>();
        
        launchDateStr.replace("T", " ");
        launchDateStr.replace(":00Z", " UTC"); 

        mainText = globalLaunchName + " | " + launchLoc + " | " + launchDateStr + " | ";
        
        launchTimestamp = doc["timestamp"].as<unsigned long>();
        isCountingDown = true;
        
        // Print to console, then trigger the 2-second delay before scrolling starts
        printConsole("Data Synced.");
        dataSyncedTime = millis();
        pendingNameScroll = true;
        
      } else {
        printConsole("JSON Error!");
      }
    } else {
      printConsole("HTTP Error: " + String(httpResponseCode));
    }
    http.end();
  }
}

String formatCountdown(long seconds) {
  if (seconds <= 0) return "T-00:00:00"; 
  int h = seconds / 3600;
  int m = (seconds % 3600) / 60;
  int s = seconds % 60;
  
  char buf[20];
  sprintf(buf, "T-%02d:%02d:%02d", h, m, s);
  return String(buf);
}

void updateDisplay() {
  matrix.fillScreen(0); 

  // 1. Draw Scrolling Blue Text
  matrix.setCursor(cursorX, 0);
  matrix.setTextColor(matrix.Color(0, 0, 255)); 
  matrix.print(mainText);

  // 2. MASKING TRICK: Black box over the right side
  int countdownWidth = 64; 
  int countdownX = MATRIX_WIDTH - countdownWidth; 
  
  matrix.fillRect(countdownX, 0, countdownWidth, 8, matrix.Color(0, 0, 0));

  // 3. Draw Static Countdown on top of the black box
  if (timeUntilLaunch > 0) {
    matrix.setTextColor(matrix.Color(0, 255, 0)); 
  } else {
    matrix.setTextColor(matrix.Color(255, 0, 0)); 
  }
  matrix.setCursor(countdownX + 2, 0); 
  
  String countdownString = formatCountdown(timeUntilLaunch);
  matrix.print(countdownString);

  matrix.show();

  // 4. Handle scrolling math
  cursorX--;
  int textWidth = mainText.length() * 6;
  
  if (cursorX < -textWidth) {
    cursorX = MATRIX_WIDTH; 
  }
}