Community project
ESP32 Plane Radar Map
This project turns an ESP32 and a 2.8-inch TFT display into a real-time plane radar map. The device connects to Wi-Fi, fetches live aircraft data from public aviation APIs, and displays nearby planes on a circular radar display with their callsigns, altitudes, and headings. The guide includes a complete wiring diagram, parts list, and step-by-step assembly instructions.
Builders will learn how to interface the ILI9341 display with the ESP32 over SPI, set up Wi-Fi connectivity with automatic configuration, parse JSON flight data, and render a functional radar visualization. The firmware handles geolocation calculations to determine aircraft distance and bearing relative to a home position, updating the display every 30 seconds with current traffic information.
Wiring diagram

Gather all the parts
| Qty | Component |
|---|---|
| 1 | 2.8-inch ILI9341 SPI TFT module 2.8-inch, 240 × 320 SPI The colour screen that shows aircraft around Clearwater as a radar-style map. |
Assemble it in 5 steps
1. Keep the power off while wiring
Leave the Adafruit Metro ESP32-S2 unplugged from USB. Place it beside the 2.8-inch screen with the printed pin labels facing up, then add one jumper wire at a time.
- Use seven female-to-female jumper wires if both boards have header pins.
- Do not connect the screen to the Metro’s 5V pin: this design uses 3.3V power and 3.3V signals, and 5V can damage a 3.3V-only screen.
2. Connect screen power
Connect screen VCC to the Metro 3V3 pin (power). Connect screen GND to any Metro GND pin (ground). These wires power the screen safely and give both boards the same electrical reference.
- A red wire for VCC and black wire for GND make later checks easier.
- Make sure VCC and GND are not swapped — swapped power can damage the screen.
3. Connect the shared picture wires
Connect screen SCK, sometimes labelled CLK, to the Metro SCK pin / GPIO36 (clock). Connect screen MOSI, sometimes labelled SDA or DIN, to the Metro MOSI pin / GPIO35 (data). These wires carry the picture information to the screen.
- Do not connect the screen MISO pin if it has one; this screen only needs data flowing from the Metro to the display.
- Do not swap SCK and MOSI — the screen will stay blank if the picture data cannot reach it.
4. Connect the three screen control wires
Connect screen CS to Metro SS / GPIO42 (screen select), screen DC to GPIO21 (picture-data control), and screen RST to GPIO38 (screen reset). Check the printed labels before pushing in each wire.
- Use three different wire colours for CS, DC, and RST so each wire is easy to trace.
- A wire one pin away can leave the screen blank even though its backlight turns on.
5. Power the radar and set up Wi-Fi
Plug the Metro into USB. On its first start, join the temporary Wi-Fi network named Plane-Radar-Setup from a phone or computer, then choose your normal home Wi-Fi network and enter its password. The screen will load aircraft around Clearwater, Florida.
- The radar covers a 50-nautical-mile circle around Clearwater ZIP code 33755 and requests fresh aircraft data about every 30 seconds.
- This needs an internet-connected Wi-Fi network; without it, the screen can show the radar grid but cannot receive live aircraft positions.
Review all connections
1. Connections between "tft_ili9341_1" and "ESP32"
| Function | tft_ili9341_1 | ESP32 |
|---|---|---|
| power | VCC | 3V3 |
| ground | GND | GND |
| spi | SCK | GPIO 36 |
| spi | MOSI | GPIO 35 |
| digital | CS | GPIO 42 |
| digital | DC | GPIO 21 |
| digital | RST | GPIO 38 |
Deploy the firmware
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WiFiManager.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
struct Plane {
float lat;
float lon;
int altitude;
int speed;
int heading;
String callSign;
bool valid;
};
// Forward declarations
float radiansf(float degrees);
float distanceNm(float lat1, float lon1, float lat2, float lon2);
float bearingDegrees(float lat1, float lon1, float lat2, float lon2);
void drawRadarBase(const char *status);
void drawPlane(const Plane &plane);
bool fetchAndDrawPlanes();
constexpr int TFT_CS = 42;
constexpr int TFT_DC = 21;
constexpr int TFT_RST = 38;
constexpr int TFT_SCK = 36;
constexpr int TFT_MOSI = 35;
constexpr float HOME_LAT = 27.9660f;
constexpr float HOME_LON = -82.8001f;
constexpr int RADIUS_NM = 50;
constexpr unsigned long UPDATE_INTERVAL_MS = 30000UL;
constexpr uint16_t RADAR_GREEN = 0x07E0;
constexpr uint16_t RADAR_DIM = 0x03E0;
Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);
unsigned long lastUpdate = 0;
float radiansf(float degrees) { return degrees * PI / 180.0f; }
float distanceNm(float lat1, float lon1, float lat2, float lon2) {
const float dLat = radiansf(lat2 - lat1);
const float dLon = radiansf(lon2 - lon1);
const float a = sinf(dLat / 2) * sinf(dLat / 2) +
cosf(radiansf(lat1)) * cosf(radiansf(lat2)) *
sinf(dLon / 2) * sinf(dLon / 2);
return 3440.1f * 2.0f * atan2f(sqrtf(a), sqrtf(1.0f - a));
}
float bearingDegrees(float lat1, float lon1, float lat2, float lon2) {
const float dLon = radiansf(lon2 - lon1);
const float y = sinf(dLon) * cosf(radiansf(lat2));
const float x = cosf(radiansf(lat1)) * sinf(radiansf(lat2)) -
sinf(radiansf(lat1)) * cosf(radiansf(lat2)) * cosf(dLon);
float bearing = atan2f(y, x) * 180.0f / PI;
if (bearing < 0) bearing += 360.0f;
return bearing;
}
void drawRadarBase(const char *status) {
tft.fillScreen(ILI9341_BLACK);
tft.setTextWrap(false);
tft.setTextSize(2);
tft.setTextColor(ILI9341_CYAN);
tft.setCursor(8, 6);
tft.print("CLEARWATER PLANE RADAR");
const int cx = 120;
const int cy = 135;
const int outer = 92;
tft.drawCircle(cx, cy, outer, RADAR_GREEN);
tft.drawCircle(cx, cy, outer * 2 / 3, RADAR_DIM);
tft.drawCircle(cx, cy, outer / 3, RADAR_DIM);
tft.drawFastHLine(cx - outer, cy, outer * 2, RADAR_DIM);
tft.drawFastVLine(cx, cy - outer, outer * 2, RADAR_DIM);
tft.drawFastHLine(cx - 4, cy, 9, ILI9341_WHITE);
tft.drawFastVLine(cx, cy - 4, 9, ILI9341_WHITE);
tft.setTextSize(1);
tft.setTextColor(ILI9341_GREEN);
tft.setCursor(cx - 5, cy - outer - 10); tft.print("N");
tft.setCursor(cx + outer + 4, cy - 3); tft.print("E");
tft.setCursor(cx - 4, cy + outer + 3); tft.print("S");
tft.setCursor(cx - outer - 10, cy - 3); tft.print("W");
tft.setCursor(8, 226);
tft.setTextColor(ILI9341_YELLOW);
tft.print(status);
}
void drawPlane(const Plane &plane) {
const float range = distanceNm(HOME_LAT, HOME_LON, plane.lat, plane.lon);
if (range > RADIUS_NM) return;
const float bearing = radiansf(bearingDegrees(HOME_LAT, HOME_LON, plane.lat, plane.lon));
const float scaled = range * 92.0f / RADIUS_NM;
const int x = 120 + (int)(sinf(bearing) * scaled);
const int y = 135 - (int)(cosf(bearing) * scaled);
tft.fillTriangle(x, y - 5, x - 4, y + 4, x + 4, y + 4, ILI9341_YELLOW);
tft.setTextSize(1);
tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
tft.setCursor(x + 6, y - 4);
if (plane.callSign.length()) tft.print(plane.callSign);
else tft.print("AIR");
}
bool fetchAndDrawPlanes() {
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
const String url = "https://api.adsb.lol/v2/point/27.9660/-82.8001/50";
if (!http.begin(client, url)) return false;
const int code = http.GET();
if (code != HTTP_CODE_OK) {
http.end();
return false;
}
DynamicJsonDocument doc(30000);
DeserializationError error = deserializeJson(doc, http.getStream());
http.end();
if (error) return false;
JsonArray aircraft = doc["ac"].as<JsonArray>();
const int total = aircraft.size();
drawRadarBase((String("LIVE: ") + total + " aircraft / 50 NM").c_str());
int drawn = 0;
for (JsonObject ac : aircraft) {
if (drawn >= 24 || ac["lat"].isNull() || ac["lon"].isNull()) continue;
Plane plane;
plane.lat = ac["lat"] | 0.0f;
plane.lon = ac["lon"] | 0.0f;
plane.altitude = ac["alt_baro"].is<int>() ? ac["alt_baro"].as<int>() : 0;
plane.speed = ac["gs"] | 0;
plane.heading = ac["track"] | 0;
plane.callSign = String((const char *)(ac["flight"] | ""));
plane.callSign.trim();
plane.valid = true;
drawPlane(plane);
drawn++;
}
return true;
}
void setup() {
SPI.begin(TFT_SCK, -1, TFT_MOSI, TFT_CS);
tft.begin();
tft.setRotation(1);
drawRadarBase("CONNECTING TO WI-FI...");
WiFiManager wifiManager;
wifiManager.setConfigPortalTimeout(180);
if (!wifiManager.autoConnect("Plane-Radar-Setup")) {
drawRadarBase("WI-FI SETUP TIMED OUT");
delay(3000);
ESP.restart();
}
drawRadarBase("WI-FI CONNECTED; LOADING...");
delay(500);
fetchAndDrawPlanes();
lastUpdate = millis();
}
void loop() {
if (WiFi.status() != WL_CONNECTED) {
drawRadarBase("WI-FI LOST; RECONNECTING...");
WiFi.reconnect();
delay(1000);
return;
}
if (millis() - lastUpdate >= UPDATE_INTERVAL_MS) {
if (!fetchAndDrawPlanes()) {
drawRadarBase("AIRCRAFT FEED UNAVAILABLE");
}
lastUpdate = millis();
}
}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.




