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.
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:
// 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.
#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.
// 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.
#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.
// 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.
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.
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.
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.
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.