Community project
ESP32 Inertial Reference Display
This project builds an inertial reference display on an ESP32 microcontroller with a 3.2-inch ST7796 touchscreen. The display simulates a flight instrument showing artificial horizon, altitude, and airspeed readouts in a clean, aviation-inspired interface. The guide includes a wiring diagram, complete parts list, and step-by-step assembly instructions to get the display running.
The firmware runs in demo mode, generating realistic instrument data without requiring external sensors like an MPU-6050 or GPS module. Builders will learn how to configure the TFT_eSPI library for the ST7796 display, render animated graphics with sprite layers, and structure a responsive instrument panel on embedded hardware. Perfect for aviation enthusiasts, flight sim builders, or anyone interested in real-time graphics on microcontrollers.
Wiring diagram
Assemble it in 2 steps
1. Coloca la placa sobre una superficie firme
Deja la Cheap Yellow Display con la pantalla hacia arriba y sin tocar la parte metálica inferior. Esta versión funciona en modo demostración, así que no conectes ningún sensor externo.
- La placa muestra primero “Calibrando sensores...” aunque no haya sensores conectados; es una secuencia visual.
- No conectes cables a los pines de la pantalla mientras la placa esté alimentada: un cable desplazado puede provocar un cortocircuito.
2. Alimenta la placa por USB
Conecta un cable USB de datos entre el conector USB de la placa y el ordenador. El cable alimenta la placa y permite cargar el programa.
- Si la pantalla no se ilumina, prueba otro cable USB que sepas que también sirve para transferir datos.
- No alimentes la placa a la vez desde el USB y desde una fuente externa.
Deploy the firmware
// Adaptación del método de Silicium628 para ESP32-3248S035C / ST7796.
// Modo DEMO: no necesita MPU-6050, GPS ni sensor de presión.
#define USER_SETUP_LOADED
#define ST7796_DRIVER
#define TFT_WIDTH 320
#define TFT_HEIGHT 480
#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 27
#define TFT_BACKLIGHT_ON HIGH
#define SPI_FREQUENCY 40000000
#define SPI_READ_FREQUENCY 20000000
#include <Arduino.h>
#include <SPI.h>
#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();
TFT_eSprite horizonSprite = TFT_eSprite(&tft);
constexpr int W = 480, H = 320, CX = W / 2, CY = 148, BOTTOM_Y = 278;
constexpr int SPRITE_W = 720, SPRITE_H = 34;
constexpr uint16_t BLACK = 0x0000, WHITE = 0xFFFF, SKY = 0x4D9F;
constexpr uint16_t GROUND = 0x6A60, YELLOW = 0xFFE0, PANEL = 0x10A2, CYAN = 0x07FF;
constexpr int SPEED_X = 15, ALT_X = 407, BOX_Y = 112, BOX_W = 58, BOX_H = 47;
unsigned long bootStarted, lastFrame, lastReadout;
float phase = 0.0f;
int shownSpeed = -1, shownAltitude = -1;
bool flightScreenReady = false;
bool bankMarkerDrawn = false;
int lastBankMarkerX = 0, lastBankMarkerY = 0;
void drawCalibrationScreen() {
tft.fillScreen(BLACK);
tft.setTextColor(WHITE, BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(2);
tft.drawString("Calibrando sensores...", CX, H / 2);
tft.setTextDatum(TL_DATUM);
}
void drawStaticFlightScreen() {
tft.fillRect(0, 0, W, CY, SKY);
tft.fillRect(0, CY, W, BOTTOM_Y - CY, GROUND);
tft.fillRect(0, BOTTOM_Y, W, H - BOTTOM_Y, BLACK);
}
void drawReadoutFrame(int x, const char *unit) {
tft.fillRoundRect(x, BOX_Y, BOX_W, BOX_H, 4, PANEL);
tft.drawRoundRect(x, BOX_Y, BOX_W, BOX_H, 4, WHITE);
tft.setTextColor(CYAN, PANEL);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(1);
tft.drawString(unit, x + BOX_W / 2, BOX_Y + 7);
tft.setTextDatum(TL_DATUM);
}
void drawReadoutValue(int x, const char *unit, int value) {
drawReadoutFrame(x, unit);
tft.fillRect(x + 3, BOX_Y + 15, BOX_W - 6, 27, BLACK);
tft.drawRect(x + 3, BOX_Y + 15, BOX_W - 6, 27, YELLOW);
char text[8];
snprintf(text, sizeof(text), "%d", value);
tft.setTextColor(YELLOW, BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(2);
tft.drawString(text, x + BOX_W / 2, BOX_Y + 28);
tft.setTextDatum(TL_DATUM);
}
void drawAircraft() {
tft.drawFastHLine(CX - 49, CY, 35, YELLOW);
tft.drawFastHLine(CX + 14, CY, 35, YELLOW);
tft.drawFastVLine(CX - 14, CY, 12, YELLOW);
tft.drawFastVLine(CX + 14, CY, 12, YELLOW);
tft.drawFastHLine(CX - 8, CY, 16, YELLOW);
tft.drawFastVLine(CX, CY - 7, 14, YELLOW);
}
void drawPitchMarks(float roll, float pitchPixels) {
const float slope = tanf(roll * DEG_TO_RAD);
for (int mark = -20; mark <= 20; mark += 10) {
if (mark == 0) continue;
const int y = (int)(CY + pitchPixels - mark * 2.2f);
const int x1 = CX - 19, x2 = CX + 19;
const int y1 = (int)(y + (x1 - CX) * slope);
const int y2 = (int)(y + (x2 - CX) * slope);
if (y1 > 6 && y1 < BOTTOM_Y && y2 > 6 && y2 < BOTTOM_Y) tft.drawLine(x1, y1, x2, y2, WHITE);
}
}
void drawBankScale(float roll) {
const int radius = 118;
for (int degrees = -30; degrees <= 30; degrees += 10) {
const float angle = (degrees - 90) * DEG_TO_RAD;
const int length = (degrees % 30 == 0) ? 12 : 7;
const int x1 = CX + (int)(cosf(angle) * radius);
const int y1 = CY + (int)(sinf(angle) * radius);
const int x2 = CX + (int)(cosf(angle) * (radius - length));
const int y2 = CY + (int)(sinf(angle) * (radius - length));
tft.drawLine(x1, y1, x2, y2, WHITE);
}
// El triángulo se dibuja aparte, después de borrar solamente su posición anterior.
// Así queda debajo del arco y no deja marcas amarillas al moverse.
}
void pushHorizonClipped(int x, int y, int w, int h, int pivotX, int pivotY, float roll) {
// vpDatum=false keeps the pivot in absolute screen coordinates while clipping writes.
tft.setViewport(x, y, w, h, false);
tft.setPivot(pivotX, pivotY);
horizonSprite.pushRotated(-roll);
tft.resetViewport();
}
void drawRotatedHorizon(float roll, float pitchPixels) {
const int pivotX = CX + (int)(pitchPixels * sinf(roll * DEG_TO_RAD));
const int pivotY = CY + (int)(pitchPixels * cosf(roll * DEG_TO_RAD));
// Paint every part of the attitude area except the two compact readout windows.
// Their pixels remain untouched between value changes, preventing visible flicker.
pushHorizonClipped(0, 0, W, BOX_Y, pivotX, pivotY, roll);
pushHorizonClipped(0, BOX_Y, SPEED_X, BOX_H, pivotX, pivotY, roll);
pushHorizonClipped(SPEED_X + BOX_W, BOX_Y, ALT_X - (SPEED_X + BOX_W), BOX_H, pivotX, pivotY, roll);
pushHorizonClipped(ALT_X + BOX_W, BOX_Y, W - (ALT_X + BOX_W), BOX_H, pivotX, pivotY, roll);
pushHorizonClipped(0, BOX_Y + BOX_H, W, BOTTOM_Y - (BOX_Y + BOX_H), pivotX, pivotY, roll);
// El triángulo vive completamente en la zona azul, separada de la regla blanca.
// Primero se repone el pequeño parche azul donde estuvo en el fotograma anterior.
if (bankMarkerDrawn) {
tft.fillRect(lastBankMarkerX - 11, lastBankMarkerY - 11, 23, 23, SKY);
}
// Las marcas centrales se desactivan por ahora: al no formar parte del sprite
// dejaban rastros blancos sobre el cielo y el suelo. El horizonte queda limpio.
drawBankScale(roll);
// Marcador de bank: triángulo amarillo bajo el arco, siguiendo su curvatura.
const float markerAngle = (-90.0f - roll) * DEG_TO_RAD;
const int tipRadius = 101;
const int baseRadius = 86;
const int tipX = CX + (int)(cosf(markerAngle) * tipRadius);
const int tipY = CY + (int)(sinf(markerAngle) * tipRadius);
const int leftX = CX + (int)(cosf(markerAngle - 0.09f) * baseRadius);
const int leftY = CY + (int)(sinf(markerAngle - 0.09f) * baseRadius);
const int rightX = CX + (int)(cosf(markerAngle + 0.09f) * baseRadius);
const int rightY = CY + (int)(sinf(markerAngle + 0.09f) * baseRadius);
tft.fillTriangle(tipX, tipY, leftX, leftY, rightX, rightY, YELLOW);
lastBankMarkerX = tipX;
lastBankMarkerY = tipY;
bankMarkerDrawn = true;
drawAircraft();
}
void drawBottom(float roll, float pitch) {
tft.fillRect(0, BOTTOM_Y + 1, W, H - BOTTOM_Y - 1, BLACK);
tft.setTextColor(WHITE, BLACK);
tft.setTextSize(2);
tft.setCursor(86, 294);
tft.printf("ROLL %d", (int)roll);
tft.setCursor(274, 294);
tft.printf("PITCH %d", (int)pitch);
}
void setup() {
pinMode(TFT_BL, OUTPUT);
digitalWrite(TFT_BL, TFT_BACKLIGHT_ON);
tft.init();
tft.setRotation(1);
drawCalibrationScreen();
bootStarted = millis();
horizonSprite.setColorDepth(16);
horizonSprite.createSprite(SPRITE_W, SPRITE_H);
horizonSprite.fillSprite(SKY);
horizonSprite.fillRect(0, SPRITE_H / 2, SPRITE_W, SPRITE_H / 2, GROUND);
horizonSprite.setPivot(SPRITE_W / 2, SPRITE_H / 2);
}
void loop() {
const unsigned long now = millis();
if (now - bootStarted < 2500) { delay(10); return; }
if (!flightScreenReady) {
drawStaticFlightScreen();
drawReadoutFrame(SPEED_X, "KT");
drawReadoutFrame(ALT_X, "FT");
flightScreenReady = true;
}
if (now - lastFrame >= 50) {
lastFrame = now;
phase += 0.045f;
const float roll = 22.0f * sinf(phase * 0.72f);
const float pitch = 10.0f * sinf(phase);
drawRotatedHorizon(roll, pitch * 2.2f);
drawBottom(roll, pitch);
}
if (now - lastReadout >= 180) {
lastReadout = now;
const int speed = 118 + (int)(7.0f * sinf(phase * 0.43f));
const int altitude = 1850 + (int)(80.0f * sinf(phase * 0.28f));
if (speed != shownSpeed) { shownSpeed = speed; drawReadoutValue(SPEED_X, "KT", speed); }
if (altitude != shownAltitude) { shownAltitude = altitude; drawReadoutValue(ALT_X, "FT", altitude); }
}
delay(1);
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




