Lucas Rose
Published © GPL3+

Aircraft Approach Visualization

This uses a short LED strip to show where planes are on an approach and their altitude in real time.

IntermediateShowcase (no instructions)8 hours79
Aircraft Approach Visualization

Things used in this project

Hardware components

WS2812 Addressable LED Strip
Digilent WS2812 Addressable LED Strip
Any individually addressable LED strip works. You should probably know how to solder to make the exact length you want.
×1
Photon 2
Particle Photon 2
×1

Software apps and online services

opensky API

Story

Read more

Schematics

Diagram

Tinker CAD didnt have a Photon 2. The wiring is still the same just replace the UNO with a Photon 2.

Code

Main Particle Code

C/C++
Push this code to your particle. This does everything. It parses the incoming JSON data and, synthesizes, sorts, and then turns on the lights accordingly. Everything in here is pretty well explained in the comments.
#include "Particle.h"
#include <vector>
#include <array>
#include <neopixel.h>
#include <ArduinoJson.h>
SYSTEM_MODE(AUTOMATIC);

Adafruit_NeoPixel strip(8, SPI, WS2812B);

class Airplane { //airplane object is created to make my life simpler
public:
    float lon;
    int alt;
    String callsign;

    Airplane(float pLon, int pAlt, const char* pClsgn) { //only stores the 2 needed values
        lon = pLon;
        alt = pAlt * 3.281;
        callsign = String(pClsgn);
    }

    Airplane() { //really obscure object thats not possible. Makes it super obvious its not real (important later)
        lon = -1;
        alt = -1000;
        callsign = "None";
    }
};

std::vector<Airplane> planes; //creates a vector of planes. This will be used to hold all plane object before sorting. Important this is dynamic because more or less planes might come
bool flow = true; //True if ORD is in west flow
StaticJsonDocument<10000> doc; //creates a json thingy that is later given pourpose
std::array<Airplane, 8> lights; //creates a vector for the lights with set size. This will hold planes after sorting and represent the light strip digitally
String tempArray[8]; //Just for testing pourpses to act like the lights. 


std::vector<Airplane> objectify(JsonDocument& docu){ //takes every airplane that comes from the JSON (that i want) and makes it an object
    JsonArray states = doc["states"];
    std::vector<Airplane> tempPlanes;
    bool something = false;

    if (states.isNull()){
        Serial.println("No states!");
    } else {
        for (int i = 0; i < states.size(); i++){
            if (states[i][7] < 1524 && states[i][7] > 230 && states[i][8] == false && states[i][11] < 0){  //makes sure its not an over-flight or on the ground
                const char* clsgn = states[i][1].as<const char*>(); //DELETE EVENTUALLY
                Airplane temp(states[i][5], states[i][7], clsgn); //creates the object
                tempPlanes.push_back(temp);  //adds it to the temp array
    
                something = true;
                Serial.print("Filled ");
                Serial.println(states[i][1].as<const char*>());
            }
        }
        if (something == false) {
            strip.clear();
        }
    }
    
    Serial.print("Size: ");
    Serial.println(tempPlanes.size());
        
    return tempPlanes; //makes the temp the actual array
}


void extractInfo(const char *event, const char *data){ //extracts the data from the hook response
    Serial.println("Handler starting...");
    
    Serial.print("JSON Capacity Used: ");
    Serial.println(doc.memoryUsage());

    DeserializationError error = deserializeJson(doc, data);

    if (error) {
        Serial.print("deserializeJson() failed: "); //handles errors
        Serial.println(error.c_str());
        return;
    }

    planes = objectify(doc); //makes all of them objects
}

/** This is going to sort all of the planes into an imaginary 1.5mi grid so that 
 * all the planes are displayed in the correct order as they are in the air this 
 * is important because openSky doesnt sort them in the way I want so I have to 
 * sort them myself. It takes the longitude and just places it in the right array
 * spot that corresponds with that longitude. **/


std::array<Airplane,8> fill(const std::vector<Airplane>& planez){
    Serial.println("sorting planes");
    
    std::array<Airplane,8> arr;
    bool count[8] = { false }; //just is going to count which has a plane in it, will be useful later
    
    for (int i = 0; i < planez.size(); i++){
        Serial.println("Sorting plane");
        Airplane plane = planez[i];
        
        if (flow) {
            if (plane.lon < -87.86127 && plane.lon > -87.90042) { //if the plane is less than this longitude, then put it in the array slot for that longitude
                arr[0] = plane;
                count[0] = true; //indicates a plane has been placed in this slot
            } else if (plane.lon < -87.83208) {
                arr[1] = plane;
                count[1] = true;
            } else if (plane.lon < -87.80288) {
                arr[2] = plane;
                count[2] = true;
            } else if (plane.lon < -87.77372) {
                arr[3] = plane;
                count[3] = true;
            } else if (plane.lon < -87.74453) {
                arr[4] = plane;
                count[4] = true;
            } else if (plane.lon < -87.71526) {
                arr[5] = plane;
                count[5] = true;
            } else if (plane.lon < -87.68595) {
                arr[6] = plane;
                count[6] = true;
            } else {
                arr[7] = plane;
                count[7] = true;
            }
        }
        else {
            if (plane.lon > -87.96222 && plane.lon < -87.92343) {
                arr[7] = plane;
                count[7] = true;
            } else if (plane.lon > -87.99143) {
                arr[6] = plane;
                count[6] = true;
            } else if (plane.lon > -88.02056) {
                arr[5] = plane;
                count[5] = true;
            } else if (plane.lon > -88.04988) {
                arr[4] = plane;
                count[4] = true;
            } else if (plane.lon > -88.07914) {
                arr[3] = plane;
                count[3] = true;
            } else if (plane.lon > -88.10827) {
                arr[2] = plane;
                count[2] = true;
            } else if (plane.lon > -88.13742) {
                arr[1] = plane;
                count[1] = true;
            } else {
                arr[0] = plane;
                count[0] = true;
            }
        }
    }
    
    for (int i = 0; i < 8; i++){
        if (count[i] == false){  //checks for all empty slots
            arr[i] = Airplane(); //makes a super obscure airplane object
        }
    }
    
    Serial.print("Sorted array: ");
    
    for (int i = 0; i < planez.size(); i++){
        Serial.print(planez[i].lon);
        Serial.print(", ");
    }
    
    Serial.println();
    
    Serial.println("Sorted all planes");
    return arr;
}

void checkFlow(Airplane plane){ //checks if the passed plane is on the east or west side of airport and updates flow variable accordingly
    flow = (plane.lon > -87.9090);
    Serial.print("West flow? ");
    Serial.println(flow);
}

void lightHandler() {
    for (int i = 0; i < 8; i++) {
        int altKey = lights[i].alt / 1000;

        // Handle empty/null planes first
        if (lights[i].alt <= -1000) {
            tempArray[i] = "OFF";
            strip.setPixelColor(i, 0, 0, 0);
            continue;
        }

        switch (altKey) {
            case 0:
                tempArray[i] = lights[i].callsign;
                strip.setPixelColor(i, 245, 237, 7); // Yellow
                break;
            case 1:
                tempArray[i] = lights[i].callsign;
                strip.setPixelColor(i, 250, 137, 0); // Orange
                break;
            case 2:
                tempArray[i] = lights[i].callsign;
                strip.setPixelColor(i, 252, 82, 3);  // Deep Orange
                break;
            case 3:
            default: // This catches case 3 AND anything higher (4, 5, etc.)
                if (altKey >= 3) {
                    tempArray[i] = lights[i].callsign;
                    strip.setPixelColor(i, 255, 0, 0); // Red
                } else {
                    tempArray[i] = "ERR";
                    strip.setPixelColor(i, 0, 0, 0);
                }
                break;
        }
    }
    strip.show();
}


void setup() {
    Serial.begin(9600);
    
    while(Particle.disconnected()); //stalls the particle until its on and able to subscribe to something
    
    Serial.println("Particle connected to cloud!");
    Particle.subscribe("RawOpenSkyData", extractInfo); //subscribes to get flights and calls the extract function
    
    strip.begin();
    strip.show();
    strip.setBrightness(50);
}

void loop() {
    //Serial.println("Published event");
    
    if (planes.size() > 0) { //makes sure theres something there to use
        checkFlow(planes[0]);
        lights = fill(planes);
        lightHandler();
        
        for (int i = 0; i < 8; i++){
            Serial.print(tempArray[i]);
            Serial.print(", ");
        }
        
        Serial.println();
        
        Serial.println("Operations complete");
        
        Serial.println();
        
        delay(5000); //waits a minute. Might wanna decrease
    } else {
        strip.clear();
        //Serial.println("No planes returned!");
    }
    
    delay(1000); //stops API spamming at a stupid rate. (No longer needed because pi limits it, just keeping it in here to stop any problem with particle looping)
}

Raspberry Pi API Relay Python

Python
For some reason unknown to me, the Particle webservice gets blocked by OpenSky. Therefore, I cant call the API from just my Particle and need to call it from the Pi which then pushes it to the particle. I posted on multiple forums about this problem and got nothing. If anyone figures it out let me know.

Make sure to use a webservice on your pi. I honestly dont know how this works and just used Gemini because adding a 3rd language to my project seemed excessive. Have fun :).
import requests 
import json 
import time 
from datetime import datetime, timedelta

# --- Configuration (Replace with your actual credentials) ---
OPENSKY_CLIENT_ID = "yaboyluke2-api-client"
OPENSKY_CLIENT_SECRET = "Hb7FIWChc73PLqqSwvOlQrUuUopNen07"
PARTICLE_ACCESS_TOKEN = "4388b3788171c10e5c44a210025f176a0810af36" # Your long-lived Particle token

# --- OpenSky Token Management Variables ---
OPENSKY_TOKEN = None
TOKEN_EXPIRY_TIME = datetime.now() 

# --- OpenSky Authentication Endpoint ---
AUTH_URL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"
OPENSKY_URL = "https://opensky-network.org/api/states/all"
PARTICLE_PUBLISH_URL = "https://api.particle.io/v1/devices/events"
PARTICLE_EVENT_NAME = "RawOpenSkyData"

# Example Bounding Box
OPENSKY_PARAMS = {
    'lamin': 41.98759,
    'lomin': -88.22404,
    'lamax': 41.98901,
    'lomax': -87.59579,
}

# --- 1. TOKEN RETRIEVAL FUNCTION ---
def get_new_opensky_token():
    """Fetches a new access token and updates global variables."""
    global OPENSKY_TOKEN, TOKEN_EXPIRY_TIME
    
    print("Fetching new OpenSky access token...")
    
    try:
        response = requests.post(
            AUTH_URL,
            data={
                "grant_type": "client_credentials",
                "client_id": OPENSKY_CLIENT_ID,
                "client_secret": OPENSKY_CLIENT_SECRET
            }
        )
        response.raise_for_status() # Raises an HTTPError for bad responses (4xx or 5xx)
        
        token_data = response.json()
        OPENSKY_TOKEN = token_data.get("access_token")
        
        # OpenSky tokens typically last 1800 seconds (30 minutes).
        expires_in = token_data.get("expires_in", 1800) 
        
        # Set expiry time to a few seconds BEFORE the actual expiry to preemptively refresh.
        TOKEN_EXPIRY_TIME = datetime.now() + timedelta(seconds=expires_in - 5) 
        
        print(f"SUCCESS: New token retrieved. Expires at: {TOKEN_EXPIRY_TIME.strftime('%H:%M:%S')}")
        
    except requests.exceptions.HTTPError as e:
        print(f"ERROR: Failed to retrieve token. Check credentials. Status: {e.response.status_code}")
        OPENSKY_TOKEN = None
    except Exception as e:
        print(f"ERROR: An error occurred during token fetch: {e}")
        OPENSKY_TOKEN = None

# --- 2. MAIN FETCH AND PUBLISH LOOP ---
def fetch_and_publish_opensky():
    global OPENSKY_TOKEN, TOKEN_EXPIRY_TIME
    
    # CHECK 1: Token existence and expiry
    if OPENSKY_TOKEN is None or datetime.now() >= TOKEN_EXPIRY_TIME:
        get_new_opensky_token()
        if OPENSKY_TOKEN is None:
            print("Cannot proceed without a valid OpenSky token.")
            return

    # --- STEP A: CALL OPENSKY API using the Bearer Token ---
    print("Fetching data from OpenSky...")
    
    # Set the Authorization Header with the Bearer Token
    headers = {
        "Authorization": f"Bearer {OPENSKY_TOKEN}",
        "Accept": "application/json"
    }

    try:
        response = requests.get(OPENSKY_URL, params=OPENSKY_PARAMS, headers=headers)
        response.raise_for_status() # Check for errors
            
        raw_json_string = response.text
        print(f"OpenSky JSON length: {len(raw_json_string)} bytes")

        # --- STEP B: POST RAW JSON TO PARTICLE CLOUD (with 255-byte warning) ---
        
        publish_data = raw_json_string
        if len(publish_data) > 255:
            # IMPORTANT: Truncate or use a different method if this is often exceeded.
            print("WARNING: Payload exceeds 255 bytes. Data will likely be truncated or fail.")
            # For demonstration, we'll try to send the full payload as requested, risking failure.
        
        particle_payload = {
            'name': PARTICLE_EVENT_NAME,
            'data': publish_data,
            'private': 'true',
            'ttl': 60,
            'access_token': PARTICLE_ACCESS_TOKEN
        }
        
        publish_response = requests.post(PARTICLE_PUBLISH_URL, data=particle_payload)
        
        if publish_response.status_code == 200:
            print("Particle Event Published Successfully.")
        else:
            print(f"Error publishing to Particle. Status: {publish_response.status_code}")
            print(publish_response.text)

    except requests.exceptions.HTTPError as e:
        print(f"HTTP Error during data fetch: {e}")
        # If the API returns a 401 Unauthorized, we force a token refresh on the next loop.
        if e.response.status_code == 401:
            print("Token appears invalid, forcing refresh next time.")
            TOKEN_EXPIRY_TIME = datetime.now()
            
    except Exception as e:
        print(f"An unexpected error occurred during API communication: {e}")

# --- 3. EXECUTION ---
if __name__ == '__main__':
    # Initial token fetch before starting the main loop
    get_new_opensky_token() 
    
    # If the initial fetch failed, the loop won't start effectively
    if OPENSKY_TOKEN:
        while True:
            fetch_and_publish_opensky()
            time.sleep(10) # Wait 10 seconds before fetching/publishing again

Credits

Lucas Rose
2 projects • 0 followers
Just a student in a Physical Computing Class.

Comments