Community project

ESP32 Inertial Reference Display

ESP32
Photo of ESP32 Inertial Reference Display
Generated with AI

Ledkos SL

Published October 18, 2026

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.

Open in Schematik