Flash writes a precompiled image over USB-C in Chrome or Edge. The .ino below is the source — you do not need Arduino IDE unless you want to change the sketch.
You are turning a round desk toy into a live air-traffic scope. You sit at the center of the screen. Nearby airplanes appear as little triangles pointing the way they fly, with their callsign (like UAL182) next to them.
The puck asks the internet “what planes are near me?”, reads the answer, and draws a radar sweep. If the internet is empty or you have not put in Wi-Fi yet, it still shows demo traffic so you can learn the display.
What you will learn
What Wi-Fi is, and why this board only hears 2.4 GHz.
How a program stores settings with #define (labeled boxes at the top of the file).
What an HTTP request is — asking a website a question.
What JSON is — a packing list the computer can read.
How latitude and longitude become x/y pixels on a circle.
Why the download happens “in the background” so the sweep never freezes.
Meet the Desk Pal puck
This project uses the Waveshare ESP32-S3-Touch-LCD-1.28 — a round gadget a little wider than a bottle cap (about 39 mm across).
A 240×240 color screen (think 240 dots across and 240 down).
A touch glass, so a finger is a mouse.
A tiny motion sensor (IMU) is on the board. This radar program does not read shakes or flips — a short tap or the BOOT button changes the zoom.
Wi-Fi that only speaks 2.4 GHz. School guest networks that are 5 GHz only will be invisible to it.
A USB-C port for power and for loading the program.
The program you load is a .ino file — that is Arduino-style C++. You do not need to understand every line. This guide teaches the ideas, then shows the few lines that make each idea real.
Note
Word to know — firmware. That is just the program living on the chip. Flashing firmware is like installing an app, except the chip can only run one app at a time.
The finished gadget
Desk Pal plane radar on Desk Pal puck (ESP32-S3).
Build it
1
Print the scope shells
PLA or PETG, 0.2 mm layers, 3 walls. Print the face bezel-down so the sun hood looks clean. Snap the puck in — USB-C at the bottom. The cavity is keyed; do not force it in backwards.
2
Fill in your network and your home
Open plane-radar.ino. Replace YOUR_WIFI_SSID_HERE and YOUR_WIFI_PASSWORD_HERE with a 2.4 GHz network (home, not school 5 GHz guest). Set HOME_LAT and HOME_LON to where you are sitting. Search “my latitude longitude” if you do not know them. The chip always downloads a 25 km circle; zoom only changes how zoomed-in the picture is.
3
Flash the puck
Flash means copy the program onto the chip. Easiest path: use the attached plane-radar.bin with the on-page Flash to board button (Chrome or Edge, USB-C). To change the code later: Arduino IDE → ESP32S3 Dev Module, OPI PSRAM, 16 MB flash, USB CDC On Boot — or unzip platformio.zip next to the .ino and run python3 -m platformio run -t upload. The TFT setup must include USE_HSPI_PORT or the round screen stays black.
4
Read the scope
The scope opens at 25 km. Tap the glass or short-press BOOT to cycle 5 → 10 → 15 → 25 km. The footer says LIVE or DEMO. Open serial at 115200 and type help. status tells you the same thing.
Warning
The published project has no real Wi-Fi password. Until you edit those two strings and reflash, the puck shows a setup screen on purpose so it never joins a fake network named YOUR_WIFI_SSID_HERE.
Lesson 1 — Wi-Fi is a radio, not magic
Wi-Fi is a radio that sends short messages through the air. Radios are tuned to a frequency, the way a music station is 97.1 FM. Most home routers offer two stations:
2.4 GHz — slower, but it goes through walls. This puck only has this radio.
5 GHz — faster, shorter range. This chip cannot hear it.
If the screen sits on “joining Wi-Fi” and then switches to “Wi-Fi failed,” you probably typed a 5 GHz name. Make a 2.4 GHz network (or use the 2.4 GHz name of a dual-band router) and try again.
Lesson 2 — `#define` is a labeled box
At the top of almost every Arduino program you will see #define NAME value. That is not a secret code. It is a sticky note: “whenever you see WIFI_SSID, pretend I wrote this text instead.” Changing the sticky note and flashing again is how you personalize the project without hunting through 1,000 lines.
Your four sticky notes
plane-radar.ino
#define WIFI_SSID "YOUR_WIFI_SSID_HERE" // the network name you tap on your phone
#define WIFI_PASSWORD "YOUR_WIFI_PASSWORD_HERE"
#define HOME_LAT 41.8781f // degrees north (Chicago default — change this)
#define HOME_LON -87.6298f // degrees east; west is negative
Lesson 3 — An HTTP request is a question
When you open a website, your browser sends a request and the site sends a response. The puck does the same thing, without a browser window.
It asks opendata.adsb.fi: “give me aircraft near this latitude and longitude.” That ask is an HTTP GET — GET means “please send me data,” not “please change something.”
If the ask is slow, a naive program would freeze the radar sweep (the screen would hitch). So this firmware asks on a background task — a second to-do list on the chip — while the main loop keeps drawing. You can think of it as one student drawing the poster while another student checks the mailbox.
Lesson 4 — JSON is a packing list computers like
The website does not send a paragraph. It sends JSON — text with labels and values. A plane might look like this:
track is the heading in degrees (0 is north, 90 is east, 270 is west).
gs is ground speed in knots.
The program walks the list, skips any plane missing lat/lon, and copies the rest into a small table in memory. If the list is empty, it fills that table with fake demo planes so the lesson still works offline.
The program looking up labels
plane-radar.ino
// Pseudocode of the real parser:
// for each object in the JSON array "ac" (or "aircraft"):
// if lat or lon is missing, skip
// save lat, lon, track, gs
// callsign = flight, or the hex code if flight is blank
Lesson 5 — From Earth to the round screen
Earth positions are latitude (north/south) and longitude (east/west). The screen is x (right) and y (down). The firmware:
Measures how far the plane is from HOME_LAT / HOME_LON (in kilometers).
Measures the bearing — which compass direction that is.
Places the triangle: north is up, distance is “how far from the center.”
Zoom (5 / 10 / 15 / 25 km) only changes the picture. The download is always the 25 km circle, so zooming in does not need a new request.
Try this
Change HOME_LAT / HOME_LON to an airport you know and reflash. The triangles should cluster there.
Type status on serial. If it says demo, your Wi-Fi or the feed failed — that is a great debugging moment, not a broken toy.
Type range 10 and watch the same planes sit farther from the edge.
Talking to the board (serial)
Your computer can talk to the board over the same USB-C cable. That text chat is called serial. In Arduino IDE or PlatformIO, open the Serial Monitor at 115200 baud (that number is the speed — both sides must match).
Type a word, press Enter, and the board answers. Try help first. Then try status.
Commands for this project:help · status · range [5|10|15|25|next]
Tip
If you see nothing, pick the right USB port, set 115200, and press the board’s RESET once. On a Mac the port often looks like /dev/cu.usbmodem….
Tip
Black screen after flash almost always means the TFT_eSPI setup is missing USE_HSPI_PORT. Use the TFT_eSPI_Setup.h in the downloads.
The complete program (reference)
Everything above taught the ideas. Below is the full sketch so you can search, copy, and tinker. It is long on purpose — that is a real program, not a toy snippet. Scroll inside the box. The downloadable .ino is the same file.
Wi-Fi and API fields are placeholders (YOUR_WIFI_SSID_HERE, YOUR_WIFI_PASSWORD_HERE). Never paste a real password into a public copy.
plane-radar.ino (complete)
plane-radar.ino
// Live ADS-B plane radar — Waveshare ESP32-S3-Touch-LCD-1.28
// 1.28" round GC9A01, CST816S touch (Amazon B0CM68M8LR).
//
// North-up scope, you at center. Aircraft are heading triangles + callsign.
// Fetches opendata.adsb.fi on a background task so the sweep never stalls.
// Falls back to demo traffic if live ADS-B is empty. Short tap or BOOT zooms
// the scope 5 → 10 → 15 → 25 km (ADS-B is always pulled at 25 km).
//
// Edit the four #defines below, then flash.
// Serial 115200: help | status | range [5|10|15|25|next]
//
// Arduino IDE: ESP32S3 Dev Module, OPI PSRAM, 16MB flash, USB CDC On Boot.
// TFT_eSPI: apply board-sketches/plane-radar/firmware/TFT_eSPI_Setup.h
// (USE_HSPI_PORT — without it the GC9A01 stays black).
// Sim: python .cursor/skills/board-firmware-sim/scripts/serve_sim.py board-sketches/plane-radar
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <Wire.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <TFT_eSPI.h>
#include <math.h>
#include <cstring>
#include <strings.h>
#include "../../../shared/s3_puck_pins.h"
#ifndef WIFI_SSID
#define WIFI_SSID "YOUR_WIFI_SSID_HERE"
#endif
#ifndef WIFI_PASSWORD
#define WIFI_PASSWORD "YOUR_WIFI_PASSWORD_HERE"
#endif
#ifndef HOME_LAT
#define HOME_LAT 41.8781f
#endif
#ifndef HOME_LON
#define HOME_LON -87.6298f
#endif
// BOOT is GPIO0 (Arduino-ESP32 3.2+ already defines BOOT_PIN).
static const uint32_t FETCH_MS = 20000;
static const uint32_t FETCH_BACKOFF_MS = 45000;
static const uint32_t DRAW_MS = 80;
static const uint32_t WIFI_RETRY_MS = 20000;
static const uint32_t WIFI_GIVE_MS = 12000;
static const int MAX_AC = 40;
static const int DRAW_AC = 24;
static const int PARSE_CAP = 80;
static const int RANGES_KM[] = {5, 10, 15, 25};
static const int RANGE_N = 4;
static const int FETCH_RANGE_KM = 25; // always pull the widest ring; zoom is display-only
static const float KM_PER_NM = 1.852f;
static const float SWEEP_MS = 12000.0f;
static const int CX = 120;
static const int CY = 120;
static const int SCOPE_R = 119;
#define RGB565(r, g, b) (uint16_t)((((r) & 0xF8) << 8) | (((g) & 0xFC) << 3) | ((b) >> 3))
static const uint16_t COL_BG = RGB565(3, 26, 16);
static const uint16_t COL_RING = RGB565(13, 74, 42);
static const uint16_t COL_RING2 = RGB565(20, 107, 60);
static const uint16_t COL_SWEEP = RGB565(31, 168, 90);
static const uint16_t COL_AC = RGB565(92, 255, 154);
static const uint16_t COL_YOU = RGB565(200, 255, 106);
static const uint16_t COL_TEXT = RGB565(143, 255, 184);
static const uint16_t COL_HUD = RGB565(61, 204, 122);
static const uint16_t COL_DIM = RGB565(42, 120, 72);
static const uint16_t COL_WARN = RGB565(255, 204, 102);
static const uint16_t COL_ERR = RGB565(255, 120, 72);
enum UiState : uint8_t { ST_WIFI, ST_SETUP, ST_RADAR };
struct Plane {
char call[9];
char hex[7];
float lat, lon;
float tgtLat, tgtLon;
float track; // degrees, 0 = north
float gs; // knots
float distKm;
float brg; // degrees, 0 = north
bool live;
uint8_t misses;
};
TFT_eSPI tft;
TFT_eSprite spr(&tft);
static const char *const DEMO_CALLS[] = {
"UAL182", "SWA441", "AAL77", "DAL219", "JBU1280", "FFT333",
"N17MX", "FDX130", "UPS582", "SKW3891", "ENY3644", "RPA3472",
};
static const int DEMO_CALL_N = 12;
static UiState ui = ST_WIFI;
static int rangeIdx = 3; // 25 km — more traffic than 15
static Plane planes[MAX_AC];
static Plane drawSnap[DRAW_AC];
static int drawSnapN = 0;
static int planeN = 0;
static bool touchOk = false;
static bool wifiOk = false;
static bool fetchOk = false;
static volatile bool fetching = false;
static bool usingMocks = false;
static uint32_t lastFetchAt = 0;
static uint32_t nextWifiTry = 0;
static uint32_t lastHttpCode = 0;
static char lastErr[48] = "";
static SemaphoreHandle_t planesMu = nullptr;
static TaskHandle_t fetchTaskHandle = nullptr;
static int preferUrl = 0; // 0 = v2 (known-good), 1 = v3
static volatile bool touchIrq = false;
static bool fingerDown = false;
static bool longPressFired = false;
static uint32_t fingerDownAt = 0;
static int16_t touchX0 = 0, touchY0 = 0, touchX = 0, touchY = 0;
static bool bootWasDown = false;
static String serialLine;
static int rangeKm() { return RANGES_KM[rangeIdx]; }
static void lockPlanes() {
if (planesMu) xSemaphoreTake(planesMu, portMAX_DELAY);
}
static bool tryLockPlanes(uint32_t ms) {
return !planesMu || xSemaphoreTake(planesMu, pdMS_TO_TICKS(ms)) == pdTRUE;
}
static void unlockPlanes() {
if (planesMu) xSemaphoreGive(planesMu);
}
static float clampf(float v, float a, float b) {
if (v < a) return a;
if (v > b) return b;
return v;
}
static float wrap360(float d) {
while (d < 0) d += 360.0f;
while (d >= 360.0f) d -= 360.0f;
return d;
}
static float lerpAng(float a, float b, float t) {
const float d = wrap360(b - a + 180.0f) - 180.0f;
return wrap360(a + d * t);
}
static bool placeholderWifi() {
return WIFI_SSID[0] == '\0' ||
strcmp(WIFI_SSID, "YOUR_WIFI_SSID_HERE") == 0 ||
strcmp(WIFI_SSID, "YOUR_WIFI_SSID") == 0 ||
strcmp(WIFI_SSID, "your-ssid") == 0;
}
static void geoToPolar(float lat, float lon, float *distKm, float *brgDeg) {
const float rlat1 = HOME_LAT * DEG_TO_RAD;
const float rlat2 = lat * DEG_TO_RAD;
const float dlat = (lat - HOME_LAT) * DEG_TO_RAD;
const float dlon = (lon - HOME_LON) * DEG_TO_RAD;
const float a = sinf(dlat * 0.5f) * sinf(dlat * 0.5f) +
cosf(rlat1) * cosf(rlat2) * sinf(dlon * 0.5f) * sinf(dlon * 0.5f);
const float c = 2.0f * atan2f(sqrtf(a), sqrtf(1.0f - a));
*distKm = 6371.0f * c;
const float y = sinf(dlon) * cosf(rlat2);
const float x = cosf(rlat1) * sinf(rlat2) - sinf(rlat1) * cosf(rlat2) * cosf(dlon);
*brgDeg = wrap360(atan2f(y, x) * RAD_TO_DEG);
}
static void polarToXY(float distKm, float brgDeg, int *px, int *py) {
const float u = clampf(distKm / (float)rangeKm(), 0.0f, 1.35f);
const float r = u * (float)(SCOPE_R - 10);
const float rad = brgDeg * DEG_TO_RAD;
*px = (int)lroundf(CX + sinf(rad) * r);
*py = (int)lroundf(CY - cosf(rad) * r);
}
static bool inDisk(int x, int y, int r = SCOPE_R) {
const int dx = x - CX;
const int dy = y - CY;
return dx * dx + dy * dy <= r * r;
}
// Corners stay black from fillSprite — skip per-pixel clip (it lagged the S3).
static void clipBezel() {}
static void cycleRange() {
rangeIdx = (rangeIdx + 1) % RANGE_N;
Serial.printf("range %d km\n", rangeKm());
}
static bool setRangeKm(int km) {
for (int i = 0; i < RANGE_N; i++) {
if (RANGES_KM[i] == km) {
rangeIdx = i;
Serial.printf("range %d km\n", km);
return true;
}
}
return false;
}
static void destPoint(float lat, float lon, float distKm, float brgDeg, float *olat,
float *olon) {
const float delta = distKm / 6371.0f;
const float theta = brgDeg * DEG_TO_RAD;
const float phi1 = lat * DEG_TO_RAD;
const float lam1 = lon * DEG_TO_RAD;
const float phi2 =
asinf(sinf(phi1) * cosf(delta) + cosf(phi1) * sinf(delta) * cosf(theta));
const float lam2 = lam1 + atan2f(sinf(theta) * sinf(delta) * cosf(phi1),
cosf(delta) - sinf(phi1) * sinf(phi2));
*olat = phi2 * RAD_TO_DEG;
*olon = lam2 * RAD_TO_DEG;
}
static void sortByDistN(Plane *arr, int n) {
for (int i = 1; i < n; i++) {
Plane key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j].distKm > key.distKm) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
static void publishPlanes(const Plane *src, int n, bool mocks) {
if (n < 0) n = 0;
if (n > MAX_AC) n = MAX_AC;
lockPlanes();
if (n) memcpy(planes, src, sizeof(Plane) * (size_t)n);
planeN = n;
usingMocks = mocks;
fetchOk = n > 0;
lastFetchAt = millis();
unlockPlanes();
}
static int findPlaneUnlocked(const char *hex, const char *call) {
if (hex && hex[0]) {
for (int i = 0; i < planeN; i++) {
if (planes[i].hex[0] && strcasecmp(planes[i].hex, hex) == 0) return i;
}
}
if (call && call[0]) {
for (int i = 0; i < planeN; i++) {
if (planes[i].call[0] && strcmp(planes[i].call, call) == 0) return i;
}
}
return -1;
}
static void alongCrossKm(float lat, float lon, float tlat, float tlon, float trackDeg,
float *along, float *cross) {
const float dN = (tlat - lat) * 111.32f;
const float clat = cosf(lat * DEG_TO_RAD);
const float dE = (tlon - lon) * 111.32f * (fabsf(clat) < 0.05f ? 0.05f : clat);
const float rad = trackDeg * DEG_TO_RAD;
const float fwdN = cosf(rad);
const float fwdE = sinf(rad);
*along = dN * fwdN + dE * fwdE;
*cross = -dN * fwdE + dE * fwdN;
}
static int farthestUnlocked() {
int idx = 0;
for (int i = 1; i < planeN; i++) {
if (planes[i].distKm > planes[idx].distKm) idx = i;
}
return idx;
}
// Keep each tail number. ADS-B is a few seconds late, so don't snap onto the
// new fix — coast and ease heading / a little cross-track.
static void mergeLive(const Plane *src, int n) {
if (n < 0) n = 0;
lockPlanes();
if (usingMocks) planeN = 0;
for (int i = 0; i < planeN; i++) {
if (planes[i].misses < 250) planes[i].misses++;
}
for (int j = 0; j < n; j++) {
const Plane &in = src[j];
int i = findPlaneUnlocked(in.hex, in.call);
if (i < 0) {
if (planeN >= MAX_AC) {
const int drop = farthestUnlocked();
if (in.distKm >= planes[drop].distKm && planes[drop].misses == 0) continue;
planes[drop] = planes[planeN - 1];
planeN--;
}
if (planeN >= MAX_AC) continue;
planes[planeN] = in;
planes[planeN].tgtLat = in.lat;
planes[planeN].tgtLon = in.lon;
planes[planeN].misses = 0;
planeN++;
continue;
}
Plane &p = planes[i];
p.misses = 0;
p.live = true;
if (in.hex[0]) {
strncpy(p.hex, in.hex, 6);
p.hex[6] = '\0';
}
if (in.call[0]) {
strncpy(p.call, in.call, 8);
p.call[8] = '\0';
}
if (in.gs >= 1.0f) {
p.gs = 0.65f * p.gs + 0.35f * in.gs;
if (!(in.track == 0.0f && p.gs > 50.0f)) p.track = lerpAng(p.track, in.track, 0.28f);
}
float along = 0, cross = 0;
alongCrossKm(p.lat, p.lon, in.lat, in.lon, p.track, &along, &cross);
if (along < -0.25f && along > -8.0f) {
const float side = wrap360(p.track + (cross >= 0.0f ? 90.0f : -90.0f));
destPoint(p.lat, p.lon, fabsf(cross) * 0.28f, side, &p.tgtLat, &p.tgtLon);
} else {
p.tgtLat = p.lat + (in.lat - p.lat) * 0.18f;
p.tgtLon = p.lon + (in.lon - p.lon) * 0.18f;
}
}
int w = 0;
for (int i = 0; i < planeN; i++) {
if (planes[i].misses < 3) planes[w++] = planes[i];
}
planeN = w;
sortByDistN(planes, planeN);
usingMocks = false;
fetchOk = planeN > 0;
lastFetchAt = millis();
unlockPlanes();
}
static void seedMocks() {
Plane buf[MAX_AC];
const int n = 10;
for (int i = 0; i < n; i++) {
Plane p;
memset(&p, 0, sizeof(p));
const float brg = (float)((i * 137) % 360);
const float dist = 2.2f + i * 2.35f; // ~2–23 km so every zoom has traffic
destPoint(HOME_LAT, HOME_LON, dist, brg, &p.lat, &p.lon);
p.track = wrap360(brg + 18.0f);
p.gs = 210.0f + i * 18.0f;
p.live = false;
strncpy(p.call, DEMO_CALLS[i % DEMO_CALL_N], 8);
p.call[8] = '\0';
snprintf(p.hex, sizeof(p.hex), "D%05d", i + 1);
p.tgtLat = p.lat;
p.tgtLon = p.lon;
geoToPolar(p.lat, p.lon, &p.distKm, &p.brg);
buf[i] = p;
}
sortByDistN(buf, n);
publishPlanes(buf, n, true);
}
static void coast(float *lat, float *lon, float track, float gs, float dt) {
if (gs < 1.0f) return;
const float km = gs * KM_PER_NM / 3600.0f * dt;
const float rad = track * DEG_TO_RAD;
const float dlat = (km * cosf(rad)) / 111.32f;
const float clat = cosf(*lat * DEG_TO_RAD);
const float dlon = (km * sinf(rad)) / (111.32f * (fabsf(clat) < 0.05f ? 0.05f : clat));
*lat += dlat;
*lon += dlon;
}
static void deadReckon(float dt) {
if (!tryLockPlanes(2)) return;
const float k = 1.0f - expf(-dt / 1.8f);
for (int i = 0; i < planeN; i++) {
Plane &p = planes[i];
coast(&p.lat, &p.lon, p.track, p.gs, dt);
coast(&p.tgtLat, &p.tgtLon, p.track, p.gs, dt);
p.lat += (p.tgtLat - p.lat) * k;
p.lon += (p.tgtLon - p.lon) * k;
geoToPolar(p.lat, p.lon, &p.distKm, &p.brg);
}
unlockPlanes();
}
static int parseAircraftInto(JsonDocument &doc, Plane *dest, int cap) {
JsonVariant v = doc["ac"];
if (!v.is<JsonArray>()) v = doc["aircraft"];
if (!v.is<JsonArray>()) return -1;
JsonArray arr = v.as<JsonArray>();
int n = 0;
for (JsonObject o : arr) {
if (n >= cap) break;
if (o["lat"].isNull() || o["lon"].isNull()) continue;
const float lat = o["lat"].as<float>();
const float lon = o["lon"].as<float>();
Plane p;
memset(&p, 0, sizeof(p));
p.lat = lat;
p.lon = lon;
p.track = o["track"].isNull() ? 0.0f : o["track"].as<float>();
p.gs = o["gs"].isNull() ? 0.0f : o["gs"].as<float>();
p.live = true;
p.tgtLat = lat;
p.tgtLon = lon;
const char *flight = o["flight"] | "";
const char *hex = o["hex"] | "";
strncpy(p.hex, hex, 6);
p.hex[6] = '\0';
int w = 0;
for (const char *c = flight; *c && w < 8; c++) {
if (*c != ' ') p.call[w++] = *c;
}
p.call[w] = '\0';
if (p.call[0] == '\0') {
strncpy(p.call, p.hex, 8);
p.call[8] = '\0';
}
geoToPolar(p.lat, p.lon, &p.distKm, &p.brg);
dest[n++] = p;
}
sortByDistN(dest, n);
return n;
}
static void fillAdsbFilter(JsonDocument &filter) {
const char *roots[] = {"ac", "aircraft"};
for (const char *root : roots) {
filter[root][0]["hex"] = true;
filter[root][0]["flight"] = true;
filter[root][0]["lat"] = true;
filter[root][0]["lon"] = true;
filter[root][0]["track"] = true;
filter[root][0]["gs"] = true;
}
}
static void setLastErr(const char *s) {
lockPlanes();
strncpy(lastErr, s, sizeof(lastErr) - 1);
lastErr[sizeof(lastErr) - 1] = '\0';
unlockPlanes();
}
static bool fetchOne(const char *fmt) {
char url[200];
const float nm = FETCH_RANGE_KM / KM_PER_NM;
snprintf(url, sizeof(url), fmt, (double)HOME_LAT, (double)HOME_LON, (double)nm);
WiFiClientSecure client;
client.setInsecure();
client.setHandshakeTimeout(4);
HTTPClient http;
http.setConnectTimeout(2500);
http.setTimeout(4000);
http.useHTTP10(true); // avoid chunked bodies that break stream JSON
http.setReuse(false);
http.setUserAgent("Mozilla/5.0 (PrintPal-PlaneRadar)");
if (!http.begin(client, url)) {
setLastErr("http begin");
return false;
}
http.addHeader("Accept", "application/json");
const int code = http.GET();
lastHttpCode = (uint32_t)code;
if (code != HTTP_CODE_OK) {
char buf[24];
snprintf(buf, sizeof(buf), "http %d", code);
setLastErr(buf);
http.end();
return false;
}
const String body = http.getString();
http.end();
if (body.length() < 8) {
setLastErr("empty body");
return false;
}
JsonDocument filter;
fillAdsbFilter(filter);
JsonDocument doc;
DeserializationError err =
deserializeJson(doc, body, DeserializationOption::Filter(filter));
if (err && body.length() < 24000) err = deserializeJson(doc, body);
if (err) {
char buf[32];
snprintf(buf, sizeof(buf), "json %s", err.c_str());
setLastErr(buf);
return false;
}
static Plane buf[PARSE_CAP];
int n = parseAircraftInto(doc, buf, PARSE_CAP);
if (n < 0) {
setLastErr("no ac/aircraft");
return false;
}
if (n == 0) {
setLastErr("0 ac");
return false;
}
mergeLive(buf, n);
setLastErr("");
return true;
}
static bool fetchAircraft() {
fetching = true;
lastHttpCode = 0;
if (WiFi.status() != WL_CONNECTED) {
fetching = false;
setLastErr("wifi down");
lockPlanes();
const int n = planeN;
const bool mocks = usingMocks;
unlockPlanes();
if (n == 0) seedMocks();
return mocks || n > 0;
}
static const char *const kUrls[] = {
"https://opendata.adsb.fi/api/v2/lat/%.5f/lon/%.5f/dist/%.2f",
"https://opendata.adsb.fi/api/v3/lat/%.5f/lon/%.5f/dist/%.2f",
};
const int first = preferUrl ? 1 : 0;
const int second = 1 - first;
bool ok = fetchOne(kUrls[first]);
if (ok) {
preferUrl = first;
} else if (fetchOne(kUrls[second])) {
ok = true;
preferUrl = second;
}
fetching = false;
if (!ok) {
Serial.printf("fetch fail %s\n", lastErr);
lockPlanes();
const int n = planeN;
const bool mocks = usingMocks;
unlockPlanes();
if (n == 0) {
Serial.println(F("demo traffic"));
seedMocks();
}
}
return ok;
}
static void adsbFetchTask(void *) {
for (;;) {
while (ui != ST_RADAR || WiFi.status() != WL_CONNECTED) {
vTaskDelay(pdMS_TO_TICKS(200));
}
const bool ok = fetchAircraft();
lockPlanes();
const bool mocks = usingMocks;
unlockPlanes();
const uint32_t wait = (ok && !mocks) ? FETCH_MS : FETCH_BACKOFF_MS;
vTaskDelay(pdMS_TO_TICKS(wait));
}
}
static bool connectWifi() {
if (placeholderWifi()) {
strncpy(lastErr, "edit WIFI_SSID", sizeof(lastErr) - 1);
return false;
}
WiFi.mode(WIFI_STA);
WiFi.setHostname("plane-radar");
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
const uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < WIFI_GIVE_MS) {
delay(80);
}
if (WiFi.status() != WL_CONNECTED) {
strncpy(lastErr, "assoc timeout", sizeof(lastErr) - 1);
WiFi.disconnect(true);
return false;
}
lastErr[0] = '\0';
return true;
}
// ------------------------------------------------------------------- touch
static void touchReset() {
pinMode(S3_TP_RST_PIN, OUTPUT);
pinMode(S3_TP_INT_PIN, INPUT_PULLUP);
digitalWrite(S3_TP_RST_PIN, LOW);
delay(12);
digitalWrite(S3_TP_RST_PIN, HIGH);
delay(80);
}
static bool touchWrite(uint8_t reg, uint8_t val) {
Wire.beginTransmission(S3_CST816S_ADDR);
Wire.write(reg);
Wire.write(val);
return Wire.endTransmission() == 0;
}
static bool touchBegin() {
Wire.beginTransmission(S3_CST816S_ADDR);
Wire.write(0xA7);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom((int)S3_CST816S_ADDR, 1) != 1) return false;
Wire.read();
touchWrite(0xFE, 0x01);
touchWrite(0xFA, 0x60);
return true;
}
static bool readTouch(uint8_t *fingers, int16_t *x, int16_t *y) {
Wire.beginTransmission(S3_CST816S_ADDR);
Wire.write(0x02);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom((int)S3_CST816S_ADDR, 5) < 5) return false;
*fingers = Wire.read() & 0x0F;
const uint8_t xh = Wire.read();
const uint8_t xl = Wire.read();
const uint8_t yh = Wire.read();
const uint8_t yl = Wire.read();
*x = ((xh & 0x0F) << 8) | xl;
*y = ((yh & 0x0F) << 8) | yl;
return true;
}
static void IRAM_ATTR onTouchIrq() { touchIrq = true; }
static void onShortTap() {
if (ui == ST_SETUP) {
nextWifiTry = 0;
ui = ST_WIFI;
return;
}
cycleRange();
}
static void handleTouch(uint32_t now) {
if (!touchOk) return;
const bool irq = touchIrq || digitalRead(S3_TP_INT_PIN) == LOW;
touchIrq = false;
if (!irq && !fingerDown) return;
uint8_t fingers = 0;
int16_t x = 0, y = 0;
const bool ok = readTouch(&fingers, &x, &y);
const bool down = ok && fingers > 0;
if (down) {
touchX = x;
touchY = y;
if (!fingerDown) {
fingerDown = true;
fingerDownAt = now;
touchX0 = x;
touchY0 = y;
longPressFired = false;
} else if (!longPressFired && now - fingerDownAt > 700 &&
abs(x - touchX0) < 24 && abs(y - touchY0) < 24) {
longPressFired = true; // ignored — no portal
}
return;
}
if (fingerDown) {
fingerDown = false;
if (longPressFired) return;
const int16_t dx = touchX - touchX0;
const int16_t dy = touchY - touchY0;
if (abs(dx) < 40 && abs(dy) < 40 && now - fingerDownAt < 600) onShortTap();
}
}
static void handleBoot(uint32_t now) {
(void)now;
const bool down = digitalRead(BOOT_PIN) == LOW;
if (down && !bootWasDown) {
if (ui == ST_SETUP) {
nextWifiTry = 0;
ui = ST_WIFI;
} else if (ui == ST_RADAR) {
cycleRange();
}
}
bootWasDown = down;
}
// ------------------------------------------------------------------ draw
static void drawPlane(const Plane &p) {
if (p.distKm > rangeKm() * 1.02f) return;
int x, y;
polarToXY(p.distKm, p.brg, &x, &y);
if (!inDisk(x, y, SCOPE_R - 8)) return;
const float rad = p.track * DEG_TO_RAD;
const float c = cosf(rad);
const float s = sinf(rad);
const float nose = 7.0f;
const float wing = 4.5f;
const int x1 = (int)lroundf(x + s * nose);
const int y1 = (int)lroundf(y - c * nose);
const int x2 = (int)lroundf(x - c * wing - s * 4.0f);
const int y2 = (int)lroundf(y - s * wing + c * 4.0f);
const int x3 = (int)lroundf(x + c * wing - s * 4.0f);
const int y3 = (int)lroundf(y + s * wing + c * 4.0f);
if (inDisk(x1, y1, SCOPE_R - 2) && inDisk(x2, y2, SCOPE_R - 2) && inDisk(x3, y3, SCOPE_R - 2)) {
spr.fillTriangle(x1, y1, x2, y2, x3, y3, COL_AC);
}
spr.setTextDatum(TL_DATUM);
spr.setTextColor(COL_TEXT);
spr.setTextFont(1);
int tx = x + 6;
int ty = y - 8;
const int tw = spr.textWidth(p.call);
if (!inDisk(tx + tw, ty, SCOPE_R - 6)) tx = x - tw - 4;
if (!inDisk(tx, ty, SCOPE_R - 6)) ty = y + 6;
if (inDisk(tx, ty, SCOPE_R - 4) && inDisk(tx + tw, ty + 8, SCOPE_R - 4)) {
spr.drawString(p.call, tx, ty, 1);
}
}
static void drawRadar(uint32_t now) {
spr.fillSprite(TFT_BLACK);
spr.fillCircle(CX, CY, SCOPE_R, COL_BG);
spr.drawCircle(CX, CY, SCOPE_R - 1, COL_RING2);
for (int i = 1; i <= 4; i++) {
const int r = (SCOPE_R - 10) * i / 4;
spr.drawCircle(CX, CY, r, i == 4 ? COL_RING2 : COL_RING);
}
spr.drawFastHLine(CX - (SCOPE_R - 10), CY, (SCOPE_R - 10) * 2, COL_RING);
spr.drawFastVLine(CX, CY - (SCOPE_R - 10), (SCOPE_R - 10) * 2, COL_RING);
spr.setTextDatum(MC_DATUM);
spr.setTextColor(COL_YOU, COL_BG);
spr.setTextFont(2);
spr.drawString("N", CX, 16, 2);
const float sweep = fmodf((float)now / SWEEP_MS, 1.0f) * 360.0f;
for (int k = 0; k < 3; k++) {
const float a = (sweep - k * 7.0f) * DEG_TO_RAD;
const uint16_t col = k == 0 ? COL_SWEEP : COL_RING2;
const int x2 = CX + (int)lroundf(sinf(a) * (SCOPE_R - 11));
const int y2 = CY - (int)lroundf(cosf(a) * (SCOPE_R - 11));
spr.drawLine(CX, CY, x2, y2, col);
}
int inRange = 0;
if (tryLockPlanes(4)) {
const float lim = rangeKm() * 1.02f;
drawSnapN = 0;
inRange = 0;
for (int i = 0; i < planeN; i++) {
if (planes[i].distKm > lim) continue;
inRange++;
if (drawSnapN < DRAW_AC) drawSnap[drawSnapN++] = planes[i];
}
unlockPlanes();
} else {
const float lim = rangeKm() * 1.02f;
for (int i = 0; i < drawSnapN; i++) {
if (drawSnap[i].distKm <= lim) inRange++;
}
}
int shown = 0;
for (int i = 0; i < drawSnapN; i++) {
drawPlane(drawSnap[i]);
shown++;
}
if (inRange > shown) shown = inRange;
spr.fillCircle(CX, CY, 3, COL_YOU);
spr.drawFastHLine(CX - 6, CY, 13, COL_YOU);
spr.drawFastVLine(CX, CY - 6, 13, COL_YOU);
spr.setTextDatum(MC_DATUM);
spr.setTextColor(COL_HUD, COL_BG);
spr.setTextFont(2);
char hud[24];
snprintf(hud, sizeof(hud), "%d km", rangeKm());
spr.drawString(hud, CX, 214, 2);
spr.setTextFont(1);
spr.setTextColor(COL_DIM, COL_BG);
char sub[32];
bool mocks = usingMocks;
bool ok = fetchOk;
bool sync = fetching && shown == 0;
char err[48];
err[0] = '\0';
if (tryLockPlanes(2)) {
mocks = usingMocks;
ok = fetchOk;
sync = fetching && planeN == 0;
strncpy(err, lastErr, sizeof(err) - 1);
err[sizeof(err) - 1] = '\0';
unlockPlanes();
}
if (sync) snprintf(sub, sizeof(sub), "SYNC");
else if (mocks) snprintf(sub, sizeof(sub), "DEMO %d ac", shown);
else if (!ok) snprintf(sub, sizeof(sub), "HOLD %s", err[0] ? err : "ads-b");
else snprintf(sub, sizeof(sub), "LIVE %d ac", shown);
spr.drawString(sub, CX, 198, 1);
clipBezel();
spr.pushSprite(0, 0);
}
static void drawWifiWait() {
spr.fillSprite(TFT_BLACK);
spr.fillCircle(CX, CY, SCOPE_R, COL_BG);
spr.drawCircle(CX, CY, SCOPE_R - 1, COL_RING2);
spr.setTextDatum(MC_DATUM);
spr.setTextColor(COL_TEXT, COL_BG);
spr.setTextFont(2);
spr.drawString("PLANE RADAR", CX, 78, 2);
spr.setTextColor(COL_HUD, COL_BG);
spr.drawString("joining Wi-Fi", CX, 112, 2);
spr.setTextFont(1);
spr.setTextColor(COL_DIM, COL_BG);
spr.drawString(WIFI_SSID, CX, 140, 1);
clipBezel();
spr.pushSprite(0, 0);
}
static void drawSetup() {
spr.fillSprite(TFT_BLACK);
spr.fillCircle(CX, CY, SCOPE_R, COL_BG);
spr.drawCircle(CX, CY, SCOPE_R - 1, COL_ERR);
spr.setTextDatum(MC_DATUM);
spr.setTextColor(COL_WARN, COL_BG);
spr.setTextFont(2);
spr.drawString("Wi-Fi failed", CX, 42, 2);
spr.setTextColor(COL_TEXT, COL_BG);
spr.setTextFont(1);
spr.drawString("Edit #defines at top of", CX, 68, 1);
spr.drawString("plane-radar.ino", CX, 80, 1);
spr.setTextColor(COL_HUD, COL_BG);
spr.drawString("WIFI_SSID", CX, 104, 1);
spr.drawString("WIFI_PASSWORD", CX, 116, 1);
spr.drawString("HOME_LAT HOME_LON", CX, 128, 1);
spr.setTextColor(COL_DIM, COL_BG);
char line[40];
snprintf(line, sizeof(line), "ssid %s", WIFI_SSID);
spr.drawString(line, CX, 152, 1);
snprintf(line, sizeof(line), "home %.3f %.3f", (double)HOME_LAT, (double)HOME_LON);
spr.drawString(line, CX, 164, 1);
if (lastErr[0]) {
spr.setTextColor(COL_ERR, COL_BG);
spr.drawString(lastErr, CX, 180, 1);
}
spr.setTextColor(COL_WARN, COL_BG);
spr.drawString("tap to retry", CX, 204, 1);
clipBezel();
spr.pushSprite(0, 0);
}
// ----------------------------------------------------------------- serial
static void printHelp() {
Serial.println(F("plane-radar commands:"));
Serial.println(F(" help"));
Serial.println(F(" status"));
Serial.println(F(" range print current km"));
Serial.println(F(" range next cycle 5 > 10 > 15 > 25"));
Serial.println(F(" range 5|10|15|25 set km"));
Serial.println(F("tap scope or BOOT to cycle range"));
}
static void printStatus() {
lockPlanes();
const int n = planeN;
const bool mocks = usingMocks;
const bool ok = fetchOk;
const uint32_t age = lastFetchAt ? millis() - lastFetchAt : 0;
char err[48];
strncpy(err, lastErr, sizeof(err) - 1);
err[sizeof(err) - 1] = '\0';
unlockPlanes();
Serial.printf("state %s\n", ui == ST_WIFI ? "wifi" : ui == ST_SETUP ? "setup" : "radar");
Serial.printf("wifi %s ssid=%s ip=%s\n",
wifiOk && WiFi.status() == WL_CONNECTED ? "up" : "down",
WIFI_SSID,
WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString().c_str() : "-");
Serial.printf("home %.5f %.5f\n", (double)HOME_LAT, (double)HOME_LON);
Serial.printf("range %d km\n", rangeKm());
Serial.printf("ac %d %s http=%lu err=%s\n",
n, mocks ? "demo" : (ok ? "live" : "fail"),
(unsigned long)lastHttpCode, err);
Serial.printf("touch %s\n", touchOk ? "ok" : "off");
if (age) Serial.printf("age %lu ms\n", (unsigned long)age);
}
static void handleSerial() {
while (Serial.available()) {
const char c = (char)Serial.read();
if (c == '\r') continue;
if (c != '\n') {
if (serialLine.length() < 80) serialLine += c;
continue;
}
serialLine.trim();
serialLine.toLowerCase();
if (serialLine == "help" || serialLine == "?") {
printHelp();
} else if (serialLine == "status") {
printStatus();
} else if (serialLine == "range") {
Serial.printf("range %d km (range next | range 5|10|15|25)\n", rangeKm());
} else if (serialLine == "range next") {
cycleRange();
} else if (serialLine.startsWith("range ")) {
const int km = serialLine.substring(6).toInt();
if (!setRangeKm(km)) Serial.println(F("range must be 5, 10, 15, or 25"));
} else if (serialLine.length()) {
Serial.println(F("? (try help)"));
}
serialLine = "";
}
}
// ------------------------------------------------------------------- main
void setup() {
Serial.begin(115200);
pinMode(S3_LCD_BL_PIN, OUTPUT);
digitalWrite(S3_LCD_BL_PIN, HIGH);
pinMode(BOOT_PIN, INPUT_PULLUP);
tft.init();
tft.setRotation(0);
tft.fillScreen(TFT_BLACK);
spr.setColorDepth(16);
if (spr.createSprite(S3_LCD_SIZE, S3_LCD_SIZE) == nullptr) {
Serial.println(F("sprite alloc failed"));
while (true) delay(1000);
}
Wire.begin(S3_I2C_SDA_PIN, S3_I2C_SCL_PIN);
Wire.setClock(400000);
touchReset();
touchOk = touchBegin();
if (touchOk) attachInterrupt(digitalPinToInterrupt(S3_TP_INT_PIN), onTouchIrq, FALLING);
Serial.println(touchOk ? F("CST816S ready") : F("CST816S not found — tap off, use BOOT or serial"));
Serial.println(F("plane-radar ready (type help)"));
printHelp();
planesMu = xSemaphoreCreateMutex();
xTaskCreatePinnedToCore(adsbFetchTask, "adsb", 16384, nullptr, 1, &fetchTaskHandle, 0);
ui = ST_WIFI;
nextWifiTry = 0;
}
void loop() {
const uint32_t now = millis();
static uint32_t lastMs = now;
const float dt = clampf((now - lastMs) / 1000.0f, 0.001f, 0.25f);
lastMs = now;
handleSerial();
handleTouch(now);
handleBoot(now);
if (ui == ST_WIFI) {
drawWifiWait();
wifiOk = connectWifi();
if (wifiOk) {
if (planeN == 0) seedMocks();
ui = ST_RADAR;
Serial.printf("wifi up ip=%s\n", WiFi.localIP().toString().c_str());
} else {
ui = ST_SETUP;
nextWifiTry = now + WIFI_RETRY_MS;
Serial.printf("wifi fail %s\n", lastErr);
}
return;
}
if (ui == ST_SETUP) {
drawSetup();
if ((int32_t)(now - nextWifiTry) >= 0) {
ui = ST_WIFI;
}
delay(30);
return;
}
if (WiFi.status() != WL_CONNECTED) {
wifiOk = false;
ui = ST_SETUP;
nextWifiTry = now + WIFI_RETRY_MS;
strncpy(lastErr, "wifi dropped", sizeof(lastErr) - 1);
return;
}
deadReckon(dt);
static uint32_t lastDrawAt = 0;
if ((int32_t)(now - lastDrawAt) < (int32_t)DRAW_MS) {
delay(4);
return;
}
lastDrawAt = now;
drawRadar(now);
}
Board and housing
Desk Pal puck (ESP32-S3)Radar scope housingCAD of the snap-fit shell.