ESP32 CYD Radiosonde Telemetry Display.

FreeRTOS firmware for the ESP32-2432S028 "Cheap Yellow Display" that polls a local radiosonde_auto_rx instance and renders live sonde telemetry — altitude, burst state, ascent rate, frequency, and more — across a scrollable multi-sonde interface. Includes a dad-joke screensaver for when the skies go quiet.

Started early 2025 Status Complete Hardware ESP32-2432S028, Raspberry Pi 5 ESP32 FreeRTOS TFT_eSPI auto_rx 403 MHz

Overview

Once radiosonde_auto_rx is running and uploading to SondeHub, the natural next step is a dedicated display — something that shows you at a glance what's being received without SSH-ing into the Pi. The Cheap Yellow Display (CYD) is a perfect fit: cheap, self-contained, always-on, and powered from any USB socket.

The firmware runs two FreeRTOS tasks pinned to separate cores: one handles all display rendering, the other manages WiFi and HTTP polling. auto_rx exposes a JSON API on port 5000; the display polls it every few seconds and updates the screen. Multiple sondes in the air simultaneously are handled with left/right swipe navigation between them.

Radiosonde telemetry includes GPS position, altitude, ascent rate, pressure, temperature, humidity, and battery voltage. The sonde type (RS41, DFM09, M10 etc.) determines which fields are available — the firmware handles missing fields gracefully rather than crashing on a null.

Hardware — CYD Quirks

The ESP32-2432S028 is a well-specified board let down by inconsistent documentation. Several things will silently not work until you know the specific configuration this revision of hardware needs.

ILI9341_2_DRIVER — not ILI9341_DRIVER

The most important issue. The display controller is close to but not quite standard ILI9341. Using ILI9341_DRIVER in your TFT_eSPI User_Setup.h compiles fine and produces a picture — but colours are wrong, or the image is shifted, or the display flickers on fast writes. The fix is a single define change:

C User_Setup.h
// WRONG — builds fine, subtly broken
// #define ILI9341_DRIVER

// CORRECT for ESP32-2432S028
#define ILI9341_2_DRIVER

Separate SPI buses for display and touch

The display uses HSPI; the XPT2046 resistive touch controller uses VSPI. They cannot share a bus on this board. TFT_eSPI needs to know about both separately, and the touch library needs its own SPIClass instance pointed at the correct pins.

C User_Setup.h — complete working config
#define ILI9341_2_DRIVER

// Display — HSPI
#define TFT_MISO  12
#define TFT_MOSI  13
#define TFT_SCLK  14
#define TFT_CS    15
#define TFT_DC     2
#define TFT_RST   -1
#define TFT_BL    21

// Touch CS only — uses VSPI (18/19/23) for the bus
#define TOUCH_CS  33

#define TFT_WIDTH   240
#define TFT_HEIGHT  320

#define TFT_INVERSION_ON   // required — without this colours invert

#define SPI_FREQUENCY        55000000
#define SPI_TOUCH_FREQUENCY   2500000
#define SPI_READ_FREQUENCY    20000000

#define LOAD_GLCD
#define LOAD_FONT2
#define LOAD_FONT4
#define LOAD_FONT6
#define LOAD_FONT7
#define LOAD_FONT8
#define LOAD_GFXFF
#define SMOOTH_FONT

Screen width addressing

Drawing to x=319 on some CYD revisions wraps the pixel back to x=0 on the same row rather than clipping. Keep all draw calls within x=0–318 to avoid a faint artefact strip on the left edge.

Power supply

The ESP32 WiFi radio draws sharp current spikes during transmission. A poor USB supply (especially a long thin cable) causes brownout resets mid-poll. If the display spontaneously reboots when WiFi is active, try a shorter cable and a quality charger before looking at the code.

FreeRTOS Dual-Core Architecture

The ESP32 has two Xtensa LX6 cores. Keeping display rendering on one core and network I/O on the other avoids the WiFi stack blocking frame draws, which would cause visible stutter. A shared struct protected by a mutex carries the current sonde data between the two tasks.

C++ main.cpp — task setup
// Shared state — written by network task, read by display task
struct SondeData {
  char    serial[16];
  char    type[8];       // RS41, DFM09, M10 …
  float   lat, lon;
  float   altitude;     // metres
  float   ascentRate;   // m/s — negative = descending
  float   frequency;    // MHz
  float   tempC;
  float   humidity;
  float   battV;
  char    state[12];    // ascending / descending / burst / landed
  int64_t lastSeenMs;
  bool    fresh;
};

SondeData   sondes[8];   // up to 8 simultaneous sondes
int         sondeCount   = 0;
int         activeSonde  = 0;  // index currently shown
SemaphoreHandle_t dataMutex;

void setup() {
  dataMutex = xSemaphoreCreateMutex();

  // Network task — core 0 (same as WiFi stack)
  xTaskCreatePinnedToCore(
    networkTask, "net", 8192, nullptr, 1, nullptr, 0
  );

  // Display task — core 1
  xTaskCreatePinnedToCore(
    displayTask, "disp", 8192, nullptr, 1, nullptr, 1
  );
}

Network Task — Polling auto_rx

radiosonde_auto_rx serves a JSON summary of all currently tracked sondes on port 5000 at /api/v1/sonde_list. The network task hits this endpoint every 5 seconds, parses the response, and updates the shared struct under the mutex.

C++ network_task.cpp (simplified)
#include <HTTPClient.h>
#include <ArduinoJson.h>

const char* AUTO_RX_URL =
  "http://piradio.local:5000/api/v1/sonde_list";

void networkTask(void*) {
  for (;;) {
    if (WiFi.status() == WL_CONNECTED) {
      HTTPClient http;
      http.begin(AUTO_RX_URL);
      http.setTimeout(4000);

      if (http.GET() == 200) {
        JsonDocument doc;
        deserializeJson(doc, http.getStream());

        if (xSemaphoreTake(dataMutex, pdMS_TO_TICKS(200))) {
          sondeCount = 0;
          for (JsonObject s : doc.as<JsonArray>()) {
            if (sondeCount >= 8) break;
            SondeData& d = sondes[sondeCount++];
            strlcpy(d.serial,    s["id"]          | "",  sizeof(d.serial));
            strlcpy(d.type,      s["type"]        | "",  sizeof(d.type));
            strlcpy(d.state,     s["state"]       | "",  sizeof(d.state));
            d.lat        = s["lat"]         | 0.0f;
            d.lon        = s["lon"]         | 0.0f;
            d.altitude   = s["alt"]         | 0.0f;
            d.ascentRate = s["vel_v"]       | 0.0f;
            d.frequency  = s["freq"]        | 0.0f;
            d.tempC      = s["temp"]        | -999.0f;
            d.humidity   = s["humidity"]    | -1.0f;
            d.battV      = s["batt"]        | -1.0f;
            d.lastSeenMs = millis();
            d.fresh      = true;
          }
          xSemaphoreGive(dataMutex);
        }
      }
      http.end();
    }
    vTaskDelay(pdMS_TO_TICKS(5000));
  }
}

Display Task — Flicker-Free Rendering

Naively clearing the screen before each redraw produces an ugly flash — especially noticeable on a display this size. The solution is to only redraw the regions that have actually changed, and to overwrite old text with a background-coloured rectangle rather than blanking the whole screen.

C++ display_task.cpp — targeted redraws
// Erase a text field by overwriting with background colour,
// then draw new value in the same position
void updateField(int x, int y, int w, int h,
                 const char* newVal, uint16_t colour) {
  tft.fillRect(x, y, w, h, TFT_BLACK);
  tft.setTextColor(colour, TFT_BLACK);
  tft.drawString(newVal, x, y);
}

void displayTask(void*) {
  drawStaticLayout();   // labels, borders — drawn once

  char prevAlt[12] = "";
  char prevRate[10] = "";

  for (;;) {
    if (xSemaphoreTake(dataMutex, pdMS_TO_TICKS(50))) {
      if (sondeCount > 0) {
        SondeData& d = sondes[activeSonde];

        // Only redraw altitude if value has changed
        char altBuf[12];
        snprintf(altBuf, sizeof(altBuf), "%.0fm", d.altitude);
        if (strcmp(altBuf, prevAlt) != 0) {
          updateField(80, 60, 120, 28, altBuf, TFT_WHITE);
          strcpy(prevAlt, altBuf);
        }

        // Ascent rate — colour coded
        char rateBuf[10];
        snprintf(rateBuf, sizeof(rateBuf), "%+.1fm/s", d.ascentRate);
        if (strcmp(rateBuf, prevRate) != 0) {
          uint16_t col = d.ascentRate >= 0 ? 0x07E0  // green = climbing
                                              : 0xF800; // red   = falling
          updateField(80, 96, 120, 22, rateBuf, col);
          strcpy(prevRate, rateBuf);
        }
      }
      xSemaphoreGive(dataMutex);
    }
    vTaskDelay(pdMS_TO_TICKS(250));  // 4 fps is plenty
  }
}

Sonde State Icons

auto_rx reports a sonde state string: ascending, descending, burst, or landed. Each gets a drawn icon in the top-right corner of the display rather than just raw text — an upward arrow, downward arrow, starburst indicator, and a ground symbol respectively. The burst state is the most satisfying to catch live; the sonde transitions through it in seconds as it crosses peak altitude.

C++ state_icon.cpp
void drawStateIcon(const char* state, int x, int y) {
  // Clear icon area first
  tft.fillRect(x, y, 32, 32, TFT_BLACK);

  if (strcmp(state, "ascending") == 0) {
    // Green upward arrow
    tft.fillTriangle(x+16, y+4,  x+4, y+20,  x+28, y+20,  0x07E0);
    tft.fillRect(x+11, y+20, 10, 10, 0x07E0);

  } else if (strcmp(state, "descending") == 0) {
    // Yellow downward arrow
    tft.fillRect(x+11, y+2, 10, 10, 0xFFE0);
    tft.fillTriangle(x+16, y+28, x+4, y+12, x+28, y+12, 0xFFE0);

  } else if (strcmp(state, "burst") == 0) {
    // Red starburst — 8 lines from centre
    for (int i = 0; i < 8; i++) {
      float angle = i * PI / 4.0f;
      tft.drawLine(x+16, y+16,
                   x+16 + (int)(13*cos(angle)),
                   y+16 + (int)(13*sin(angle)),
                   0xF800);
    }
    tft.fillCircle(x+16, y+16, 3, 0xF800);

  } else if (strcmp(state, "landed") == 0) {
    // Grey ground symbol
    tft.drawFastHLine(x+4,  y+20, 24, 0x8410);
    tft.drawFastHLine(x+8,  y+24, 16, 0x8410);
    tft.drawFastHLine(x+12, y+28, 8,  0x8410);
    tft.fillRect(x+13, y+6, 6, 14, 0x8410);
  }
}

Multi-Sonde Navigation

It's not unusual to have two or more sondes in range simultaneously — particularly during morning launch windows when multiple stations are airborne at once. The display handles up to 8 concurrently, navigated with left/right touch zones on the screen edges.

A small dot indicator along the bottom of the screen shows which sonde is selected and how many are active — the same pattern as a phone home screen. Tapping the left or right 20% of the screen steps through the list. The sonde serial number and type update in the header to confirm the switch.

C++ touch_handler.cpp
void handleTouch(int tx, int ty) {
  // Screen is 320 wide in landscape orientation
  if (tx < 64) {
    // Left zone — previous sonde
    if (xSemaphoreTake(dataMutex, pdMS_TO_TICKS(100))) {
      activeSonde = (activeSonde - 1 + sondeCount) % sondeCount;
      xSemaphoreGive(dataMutex);
      forceRedraw();
    }
  } else if (tx > 256) {
    // Right zone — next sonde
    if (xSemaphoreTake(dataMutex, pdMS_TO_TICKS(100))) {
      activeSonde = (activeSonde + 1) % sondeCount;
      xSemaphoreGive(dataMutex);
      forceRedraw();
    }
  }
}

Dad Joke Screensaver

When no fresh sonde packets have been received for one hour, the display activates a screensaver rather than sitting on a stale telemetry screen. It shows a rotating selection of programming and radio-themed dad jokes, white text on black, slowly cycling every 30 seconds.

The joke list lives in a PROGMEM array to keep it out of the heap. On any new sonde packet arriving, the screensaver clears and the telemetry screen restores — forceRedraw() repaints the static layout before the dynamic fields catch up on the next display task cycle.

C++ screensaver.cpp
const char* const jokes[] PROGMEM = {
  "Why do programmers prefer dark mode?\n\nBecause light attracts bugs.",
  "I told my wife she should embrace\nher mistakes.\n\nShe gave me a hug.",
  "There are 10 types of people.\nThose who understand binary\nand those who don't.",
  "A SQL query walks into a bar,\nwalks up to two tables and asks:\n'Can I join you?'",
  "Why did the radiosonde break up\nwith the antenna?\n\nToo much static.",
  "It's not a bug, it's an\nundocumented feature.\n\n(It's a bug.)",
};
const int JOKE_COUNT = sizeof(jokes) / sizeof(jokes[0]);

const unsigned long IDLE_TIMEOUT_MS  = 3600000UL;  // 1 hour
const unsigned long JOKE_INTERVAL_MS = 30000UL;    // 30 seconds

bool          screensaverActive = false;
int           jokeIndex         = 0;
unsigned long lastJokeMs        = 0;

void tickScreensaver(unsigned long lastPacketMs) {
  unsigned long now = millis();

  if (!screensaverActive) {
    if (now - lastPacketMs > IDLE_TIMEOUT_MS) {
      screensaverActive = true;
      tft.fillScreen(TFT_BLACK);
      lastJokeMs = 0;  // force immediate first draw
    }
  }

  if (screensaverActive && (now - lastJokeMs > JOKE_INTERVAL_MS)) {
    tft.fillScreen(TFT_BLACK);
    tft.setTextColor(TFT_WHITE, TFT_BLACK);
    tft.setTextSize(1);
    tft.drawString(jokes[jokeIndex], 16, 80);
    jokeIndex = (jokeIndex + 1) % JOKE_COUNT;
    lastJokeMs = now;
  }
}

void dismissScreensaver() {
  screensaverActive = false;
  forceRedraw();
}

Issues & Lessons Learned

invertDisplay() vs TFT_INVERSION_ON

Calling tft.invertDisplay(true) at runtime and setting TFT_INVERSION_ON in User_Setup.h are not equivalent — the compile-time define is applied during init() before the display is fully ready, and on this hardware revision the runtime call alone was unreliable. Set both.

Stack size

deserializeJson with a large document needs more stack than you might expect — 8192 bytes per task is a reasonable starting point. If the ESP32 resets mid-parse with a stack canary error, increase the stack size in xTaskCreatePinnedToCore.

Mutex deadlock on timeout

Always use a timeout with xSemaphoreTake rather than portMAX_DELAY. If either task stalls holding the mutex, the other will block indefinitely and the watchdog will fire. A 200ms timeout lets the display task skip a frame gracefully rather than hanging.

auto_rx API field names

The auto_rx JSON API field names changed subtly between versions — notably vel_v for vertical velocity rather than ascent_rate in older docs. Check against the actual API response with curl http://piradio.local:5000/api/v1/sonde_list before assuming the field names match any documentation you've found.

Results

Running as a permanent fixture on the shelf above the radio bench. Boots in about 12 seconds from power-on to first data. The screensaver activates reliably overnight and dismisses immediately on the next sonde appearing in range in the morning.

The burst state icon is genuinely satisfying — catching the exact moment a sonde transitions from ascending to burst to descending in real time on a display you built yourself is one of those small wins that makes the whole hobby worthwhile.

The companion ADS-B display runs on a separate CYD simultaneously — both live on the same shelf, both polling the same Raspberry Pi 5 on different ports, coexisting without issue.