Desk Pal plane radar

A north-up ADS-B scope on the round Desk Pal puck. You sit at the center; live traffic paints as heading triangles with callsigns.

Desk Pal plane radar
DifficultyIntermediate
Build time1h 30m
Est. cost$32
Parts4
Files6
Steps4

Things you'll need

QtyPartType
1
Waveshare ESP32-S3-Touch-LCD-1.28 (Desk Pal puck)
Waveshare ESP32-S3-Touch-LCD-1.28 (Desk Pal puck) Round 240×240 GC9A01, CST816S touch, QMI8658 IMU, USB-C. Amazon B0CM68M8LR.
electronics
1
Radar scope housing
Radar scope housing Round CRT-style bezel with a sun hood. Print face bezel-down, 0.2 mm, 3 walls.
printed
1
USB-C cable Data-capable cable for flash and power.
electronics
1
PLA or PETG About 15–25 g. PETG if the puck lives in a bag.
consumable

Tools & software

1
PlatformIO or Arduino IDE Flash over USB-C. Serial 115200.

Files & downloads

Download all

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.

What you're building

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
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.

Print the scope shells
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.

Flash the puck
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.

Read the scope
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:

One airplane, as JSON

example.json
{
  "hex": "a12bc3",
  "flight": "UAL182",
  "lat": 41.91,
  "lon": -87.65,
  "track": 270,
  "gs": 420
}

How to read that

  • flight is the callsign painted on the triangle.
  • lat and lon are where the plane is on Earth.
  • 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:

  1. Measures how far the plane is from HOME_LAT / HOME_LON (in kilometers).
  2. Measures the bearing — which compass direction that is.
  3. 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

Licensed under CC BY-SA 4.0. Check with the author before commercial use.