DIY Avionics Simulator with ESP32 - Artificial Horizon, Compass & Altimeter

The project successfully demonstrates the integration of graphical user interfaces, I2C sensors, and the ESP32-S3 microcontroller into a compact avionics instrument simulator.

The inspiration for this project comes from classical aircraft cockpit instruments used for navigation, orientation, and flight monitoring. In aviation, instruments such as the Artificial Horizon, Magnetic Compass, Altimeter, and Attitude Indicators play a crucial role by providing pilots with real-time information about the aircraft's orientation, heading, and altitude.

The system is built around the CrowPanel 2.1-inch HMI ESP32 Rotary Display, which integrates an ESP32-S3 microcontroller, a 480×480 pixel IPS round display, a rotary encoder with push-button functionality, and expansion interfaces for external peripherals.

These features make the module particularly suitable for the implementation of compact instrumentation and graphical control systems.
This project is sponsored by PCBWay . From concept to production, PCBWay provide cutting-edge electronic design solutions for global innovators, Including hardware design, software development, mechanical design, product testing and certification. PCBWay engineering team consists of experienced engineers in electronics, embedded systems, and product development. They successfully delivered hundreds of projects across industries such as medical devices, industrial automation, consumer electronics, smart home, and IoT.

The MPU6050 six-axis accelerometer and gyroscope module provides information about the pitch and roll orientation of the model. These measurements are processed and converted into graphical movements on the display.

In addition, support for a QMC5883L/HMC5883L magnetic compass sensor was implemented to provide heading information for the compass instrument.
Artificial Horizon Instrument:

The first instrument implemented is the Artificial Horizon, also known as the Attitude Indicator. It displays a blue upper area representing the sky and a brown lower area representing the ground. The MPU6050 sensor continuously measures the pitch and roll angles of the model, and these values are used to dynamically rotate and shift the horizon line. A fixed yellow aircraft symbol remains centered on the display while the horizon moves relative to the aircraft, closely replicating the behavior of a real aviation attitude indicator. Additional visual elements include pitch reference marks, a horizon scale, a virtual runway representation, and a roll-angle indicator located at the top of the instrument. When the nose of the model is raised, the horizon moves downward on the display. Conversely, when the nose is lowered, the horizon moves upward. Rolling the model left or right causes the entire horizon to rotate accordingly.
Magnetic Compass Instrument:

The second instrument is a graphical magnetic compass. It consists of a circular compass card displaying cardinal directions and angular markings around its circumference. A fixed aircraft symbol is positioned at the center of the display, while the compass card rotates according to heading information obtained from the magnetic field sensor. Although the current implementation serves primarily as a simulator rather than a precision navigation device, it demonstrates the integration of a digital magnetometer via I2C communication and the graphical representation of heading information on a circular display.
Altimeter Instrument:

The third instrument is an analog-style aircraft altimeter inspired by traditional mechanical altitude indicators. It features a circular scale and two pointers similar to those found in a clock mechanism. The simulated altitude is initialized at 6300 feet and then dynamically changes according to the pitch angle measured by the MPU6050 sensor. Raising the nose of the model gradually increases the indicated altitude, while lowering the nose decreases it. The rate of altitude change is proportional to the measured pitch angle, creating a realistic simulation of aircraft climb and descent behavior. This approach demonstrates how sensor data can be transformed into meaningful aviation-style instrument indications without requiring an actual altitude sensor.
Aircraft Attitude Visualization Screen:

A fourth display mode was implemented to provide a direct graphical representation of the MPU6050 measurements. This screen displays a simplified aircraft model that rotates according to the measured roll angle and moves vertically according to the pitch angle.
This visualization serves both as a demonstration and as a diagnostic tool, allowing easy verification of sensor operation and aircraft orientation in real time.
User Interface and Control:
The rotary encoder integrated into the CrowPanel module is used for adjusting display backlight brightness through PWM control. The encoder push-button allows the user to switch between the different instrument screens, transforming the device into a multifunction avionics display. This approach provides an intuitive and practical user interface while requiring minimal external hardware.
Software Architecture:

The software was developed using the Arduino framework for ESP32-S3. Graphics are rendered using the Arduino_GFX library together with a custom framebuffer implementation, enabling smooth drawing of circular instruments and dynamic graphical elements. The program structure is organized around multiple display modes, each represented by a dedicated drawing function. This modular approach simplifies future expansion and maintenance of the code. Additional instruments can easily be added by implementing new display screens and linking them to the existing menu system. Sensor acquisition, graphical rendering, user input handling, and display updates are executed in separate logical sections of the program, improving code readability and scalability.
Conclusion:
The project successfully demonstrates the integration of graphical user interfaces, I2C sensors, and the ESP32-S3 microcontroller into a compact avionics instrument simulator.

This CrowPanel Display proved to be an excellent platform for this type of application thanks to its integrated circular display, rotary encoder, processing power, and expandability through external sensors.

CODE
// by miercemk May, 2026

#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <Wire.h>

#define DISPLAY_WIDTH  480
#define DISPLAY_HEIGHT 480
#define CX 240
#define CY 240

#define BL_PIN     6
#define PANEL_CS   16
#define PANEL_SCK  2
#define PANEL_SDA  1
#define PCLK_NEG   1

#define ENCODER_CLK 4
#define ENCODER_DT  42

#define I2C_SDA 38
#define I2C_SCL 39
#define PCF8574_ADDR 0x21
#define MPU6050_ADDR 0x68
#define QMC5883L_ADDR 0x0D

Arduino_DataBus       *panelBus = nullptr;
Arduino_ESP32RGBPanel *rgbpanel = nullptr;
Arduino_RGB_Display   *gfx      = nullptr;
uint16_t              *fb       = nullptr;

float pitchDeg = 0;
float rollDeg  = 0;

float compassHeadingDeg = 0;
unsigned long lastCompassRead = 0;

float altitudeFt = 6300.0f;
unsigned long lastAltitudeUpdate = 0;

float lastDrawPitch = 999;
float lastDrawRoll  = 999;

volatile int lastEncoded = 0;
volatile long encoderValue = 0;
long lastEncoderValue = 0;

int brightnessLevel = 255;

unsigned long lastMPURead = 0;

extern const uint8_t st7701_type7_init_operations[];

uint16_t rgb565(uint8_t r, uint8_t g, uint8_t b) {
  return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}

const uint16_t COL_BLACK      = 0x0000;
const uint16_t COL_WHITE      = 0xFFFF;
const uint16_t COL_YELLOW     = 0xFFE0;
const uint16_t COL_ORANGE     = 0xFD20;
const uint16_t COL_SKY        = rgb565(20, 165, 225);
const uint16_t COL_GROUND     = rgb565(150, 95, 45);
const uint16_t COL_RING_OUTER = rgb565(170, 170, 170);
const uint16_t COL_RING_INNER = rgb565(45, 45, 45);
const uint16_t COL_RING_EDGE  = rgb565(15, 15, 15);

enum ScreenMode {
  SCREEN_ATTITUDE = 0,
  SCREEN_COMPASS,
  SCREEN_ALTIMETER,
  SCREEN_AIRPLANE,
  SCREEN_COUNT
};

ScreenMode currentScreen = SCREEN_ATTITUDE;

static inline void putpix(int x, int y, uint16_t c) {
  if ((unsigned)x < DISPLAY_WIDTH && (unsigned)y < DISPLAY_HEIGHT) {
    fb[y * DISPLAY_WIDTH + x] = c;
  }
}

void clearFB(uint16_t color = COL_BLACK) {
  for (int i = 0; i < DISPLAY_WIDTH * DISPLAY_HEIGHT; i++) fb[i] = color;
}

void drawLine(int x0, int y0, int x1, int y1, uint16_t col) {
  int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
  int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
  int err = dx + dy;

  while (true) {
    putpix(x0, y0, col);
    if (x0 == x1 && y0 == y1) break;

    int e2 = 2 * err;
    if (e2 >= dy) { err += dy; x0 += sx; }
    if (e2 <= dx) { err += dx; y0 += sy; }
  }
}

void drawThickLine(int x0, int y0, int x1, int y1, uint16_t col, int t) {
  for (int i = -t / 2; i <= t / 2; i++) {
    drawLine(x0, y0 + i, x1, y1 + i, col);
  }
}

void drawCircle(int cx, int cy, int r, uint16_t col) {
  int x = r, y = 0, err = 0;

  while (x >= y) {
    putpix(cx + x, cy + y, col);
    putpix(cx + y, cy + x, col);
    putpix(cx - y, cy + x, col);
    putpix(cx - x, cy + y, col);
    putpix(cx - x, cy - y, col);
    putpix(cx - y, cy - x, col);
    putpix(cx + y, cy - x, col);
    putpix(cx + x, cy - y, col);

    y++;
    if (err <= 0) err += 2 * y + 1;
    if (err > 0) {
      x--;
      err -= 2 * x + 1;
    }
  }
}

void fillCircle(int cx, int cy, int r, uint16_t col) {

  int r2 = r * r;

  for (int y = -r; y <= r; y++) {
    for (int x = -r; x <= r; x++) {

      if (x * x + y * y <= r2) {
        putpix(cx + x, cy + y, col);
      }
    }
  }
}

void fillTriangle(int x1, int y1, int x2, int y2, int x3, int y3, uint16_t col) {
  int minX = min(x1, min(x2, x3));
  int maxX = max(x1, max(x2, x3));
  int minY = min(y1, min(y2, y3));
  int maxY = max(y1, max(y2, y3));

  int area = (x2 - x1) * (y3 - y1) - (y2 - y1) * (x3 - x1);

  for (int y = minY; y <= maxY; y++) {
    for (int x = minX; x <= maxX; x++) {
      int w1 = (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1);
      int w2 = (x3 - x2) * (y - y2) - (y3 - y2) * (x - x2);
      int w3 = (x1 - x3) * (y - y3) - (y1 - y3) * (x - x3);

      if ((area >= 0 && w1 >= 0 && w2 >= 0 && w3 >= 0) ||
          (area < 0 && w1 <= 0 && w2 <= 0 && w3 <= 0)) {
        putpix(x, y, col);
      }
    }
  }
}

void fillRing(int cx, int cy, int rInner, int rOuter, uint16_t col) {
  int ri2 = rInner * rInner;
  int ro2 = rOuter * rOuter;

  for (int y = -rOuter; y <= rOuter; y++) {
    for (int x = -rOuter; x <= rOuter; x++) {
      int d2 = x * x + y * y;
      if (d2 >= ri2 && d2 <= ro2) {
        putpix(cx + x, cy + y, col);
      }
    }
  }
}

void fillArtificialHorizonDisc() {
  const int R = 206;
  const int r2 = R * R;

  float rollRad = rollDeg * PI / 180.0f;

  float cr = cos(rollRad);
  float sr = sin(rollRad);

  float pitchOffset = pitchDeg * 4.0f;

  // sky / ground
  for (int y = -R; y <= R; y++) {
    for (int x = -R; x <= R; x++) {

      if (x * x + y * y <= r2) {

        float yr = x * sr + y * cr;

        if (yr + pitchOffset < 0)
          putpix(CX + x, CY + y, COL_SKY);
        else
          putpix(CX + x, CY + y, COL_GROUND);
      }
    }
  }

  // helper transform
  auto H = [&](float lx, float ly, int &sx, int &sy) {

    sx = CX + lx * cr + ly * sr;
    sy = CY - lx * sr + ly * cr;
  };

  // --------------------------------------------------
  // VIRTUAL RUNWAY
  // --------------------------------------------------

  {
    int x1, y1, x2, y2, x3, y3;

    H(0, 10 - pitchOffset, x1, y1);
    H(-95, 115 - pitchOffset, x2, y2);
    H(95, 115 - pitchOffset, x3, y3);

    fillTriangle(
      x1, y1,
      x2, y2,
      x3, y3,
      rgb565(80, 38, 25)
    );

    int xa, ya, xb, yb;

    H(0, 10 - pitchOffset, xa, ya);
    H(-32, 115 - pitchOffset, xb, yb);
    drawThickLine(xa, ya, xb, yb, COL_ORANGE, 2);

    H(0, 10 - pitchOffset, xa, ya);
    H(32, 115 - pitchOffset, xb, yb);
    drawThickLine(xa, ya, xb, yb, COL_ORANGE, 2);

    H(-32, 115 - pitchOffset, xa, ya);
    H(32, 115 - pitchOffset, xb, yb);
    drawThickLine(xa, ya, xb, yb, COL_ORANGE, 2);
  }

  // --------------------------------------------------
  // MAIN HORIZON LINE
  // --------------------------------------------------

  {
    int x1, y1, x2, y2;

    H(-R, -pitchOffset, x1, y1);
    H(R,  -pitchOffset, x2, y2);

    drawThickLine(x1, y1, x2, y2, COL_WHITE, 3);
  }

  // --------------------------------------------------
  // PITCH LADDER
  // --------------------------------------------------

  int step = 32;

  for (int i = -4; i <= 4; i++) {

    if (i == 0) continue;

    int value = abs(i) * 5;

    int len =
      (value == 10 || value == 20)
      ? 120
      : 58;

    int localY = i * step - pitchOffset;

    int x1, y1, x2, y2;

    H(-len / 2, localY, x1, y1);
    H( len / 2, localY, x2, y2);

    drawThickLine(x1, y1, x2, y2, COL_WHITE, 3);

    if (value == 10 || value == 20) {

      int xl, yl;
      int xr, yr;

      H(-len / 2 - 50, localY - 12, xl, yl);
      H( len / 2 + 24, localY - 12, xr, yr);

      drawSmallNumber(value, xl, yl, COL_WHITE);
      drawSmallNumber(value, xr, yr, COL_WHITE);
    }
  }
}

void drawSegDigit(int d, int x, int y, int s, uint16_t col) {
  bool seg[7];

  switch (d) {
    case 0: seg[0]=1; seg[1]=1; seg[2]=1; seg[3]=1; seg[4]=1; seg[5]=1; seg[6]=0; break;
    case 1: seg[0]=0; seg[1]=1; seg[2]=1; seg[3]=0; seg[4]=0; seg[5]=0; seg[6]=0; break;
    case 2: seg[0]=1; seg[1]=1; seg[2]=0; seg[3]=1; seg[4]=1; seg[5]=0; seg[6]=1; break;
    case 3: seg[0]=1; seg[1]=1; seg[2]=1; seg[3]=1; seg[4]=0; seg[5]=0; seg[6]=1; break;
    case 4: seg[0]=0; seg[1]=1; seg[2]=1; seg[3]=0; seg[4]=0; seg[5]=1; seg[6]=1; break;
    case 5: seg[0]=1; seg[1]=0; seg[2]=1; seg[3]=1; seg[4]=0; seg[5]=1; seg[6]=1; break;
    case 6: seg[0]=1; seg[1]=0; seg[2]=1; seg[3]=1; seg[4]=1; seg[5]=1; seg[6]=1; break;
    case 7: seg[0]=1; seg[1]=1; seg[2]=1; seg[3]=0; seg[4]=0; seg[5]=0; seg[6]=0; break;
    case 8: seg[0]=1; seg[1]=1; seg[2]=1; seg[3]=1; seg[4]=1; seg[5]=1; seg[6]=1; break;
    case 9: seg[0]=1; seg[1]=1; seg[2]=1; seg[3]=1; seg[4]=0; seg[5]=1; seg[6]=1; break;
  }

  int w = 5 * s;
  int h = 9 * s;
  int t = s;

  if (seg[0]) drawThickLine(x, y, x + w, y, col, t);
  if (seg[1]) drawThickLine(x + w, y, x + w, y + h / 2, col, t);
  if (seg[2]) drawThickLine(x + w, y + h / 2, x + w, y + h, col, t);
  if (seg[3]) drawThickLine(x, y + h, x + w, y + h, col, t);
  if (seg[4]) drawThickLine(x, y + h / 2, x, y + h, col, t);
  if (seg[5]) drawThickLine(x, y, x, y + h / 2, col, t);
  if (seg[6]) drawThickLine(x, y + h / 2, x + w, y + h / 2, col, t);
}

void drawSmallNumber(int value, int x, int y, uint16_t col) {
  if (value == 10) {
    drawSegDigit(1, x, y, 2, col);
    drawSegDigit(0, x + 14, y, 2, col);
  }

  if (value == 20) {
    drawSegDigit(2, x, y, 2, col);
    drawSegDigit(0, x + 14, y, 2, col);
  }
}

void drawPitchScaleLine(int y, int value, uint16_t col) {
  int longLen  = 120;
  int shortLen = 58;

  bool major = (value == 10 || value == 20);
  int len = major ? longLen : shortLen;

  drawThickLine(CX - len / 2, y, CX + len / 2, y, col, 3);

  if (value == 10 || value == 20) {
    drawSmallNumber(value, CX - len / 2 - 50, y - 12, col);
    drawSmallNumber(value, CX + len / 2 + 24, y - 12, col);
  }
}

void drawPitchLadder() {
  int step = 32;

  float rollRad = rollDeg * PI / 180.0f;
  float cr = cos(rollRad);
  float sr = sin(rollRad);

  float pitchOffset = pitchDeg * 4.0f;

  for (int i = -4; i <= 4; i++) {
    if (i == 0) continue;

    int value = abs(i) * 5;
    int len = (value == 10 || value == 20) ? 120 : 58;

    float localY = i * step - pitchOffset;

    float x1r = -len / 2;
    float y1r = localY;
    float x2r =  len / 2;
    float y2r = localY;

    int x1 = CX + x1r * cr - y1r * sr;
    int y1 = CY + x1r * sr + y1r * cr;
    int x2 = CX + x2r * cr - y2r * sr;
    int y2 = CY + x2r * sr + y2r * cr;

    drawThickLine(x1, y1, x2, y2, COL_WHITE, 3);
  }
}

void drawRotatedLine(float x1, float y1, float x2, float y2, float angleDeg, uint16_t col, int thick) {
  float a = angleDeg * PI / 180.0f;
  float cr = cos(a);
  float sr = sin(a);

  int sx1 = CX + x1 * cr - y1 * sr;
  int sy1 = CY + x1 * sr + y1 * cr;
  int sx2 = CX + x2 * cr - y2 * sr;
  int sy2 = CY + x2 * sr + y2 * cr;

  drawThickLine(sx1, sy1, sx2, sy2, col, thick);
}

void drawScreen_AirplaneDemo() {
  clearFB(rgb565(10, 18, 28));

// light gray frame like other instruments
fillRing(CX, CY, 229, 239, COL_RING_OUTER);
fillRing(CX, CY, 218, 228, COL_RING_INNER);

drawCircle(CX, CY, 217, COL_RING_EDGE);
drawCircle(CX, CY, 228, COL_RING_EDGE);
drawCircle(CX, CY, 239, COL_WHITE);

// inner dark display area
fillCircle(CX, CY, 217, rgb565(10, 18, 28));

  // background reference grid
  drawCircle(CX, CY, 210, rgb565(80, 80, 80));
  drawCircle(CX, CY, 140, rgb565(50, 50, 50));
  drawThickLine(CX - 210, CY, CX + 210, CY, rgb565(60, 60, 60), 1);
  drawThickLine(CX, CY - 210, CX, CY + 210, rgb565(60, 60, 60), 1);

  // pitch moves airplane slightly up/down
  int oldCY = CY;
  int pitchMove = constrain((int)(pitchDeg * 3.0f), -90, 90);

  // local center offset
  int baseY = CY + pitchMove;

  float roll = rollDeg;

  auto L = [&](float x1, float y1, float x2, float y2, uint16_t col, int t) {
    float a = roll * PI / 180.0f;
    float cr = cos(a);
    float sr = sin(a);

    int sx1 = CX + x1 * cr - (y1 + pitchMove) * sr;
    int sy1 = CY + x1 * sr + (y1 + pitchMove) * cr;
    int sx2 = CX + x2 * cr - (y2 + pitchMove) * sr;
    int sy2 = CY + x2 * sr + (y2 + pitchMove) * cr;

    drawThickLine(sx1, sy1, sx2, sy2, col, t);
  };

  uint16_t bodyCol = rgb565(255, 180, 40);
  uint16_t wingCol = rgb565(40, 220, 255);
  uint16_t tailCol = rgb565(255, 80, 80);

  // airplane body
  L(0, -120, 0, 110, bodyCol, 8);

  // nose
  L(0, -120, -22, -75, bodyCol, 5);
  L(0, -120,  22, -75, bodyCol, 5);

  // main wings
  L(-20, -25, -145, 35, wingCol, 8);
  L( 20, -25,  145, 35, wingCol, 8);

  // wing tips
  L(-145, 35, -120, 55, wingCol, 5);
  L( 145, 35,  120, 55, wingCol, 5);

  // tail wings
  L(-15, 80, -75, 125, tailCol, 6);
  L( 15, 80,  75, 125, tailCol, 6);

  // center dot
  fillCircle(CX, CY + pitchMove, 8, COL_WHITE);

  gfx->draw16bitRGBBitmap(0, 0, fb, DISPLAY_WIDTH, DISPLAY_HEIGHT);
}

void drawRollScaleOnRing() {
  for (int a = -50; a <= 50; a += 10) {
    float rad = (a - 90) * PI / 180.0;

    int r1 = 211;
    int r2 = 224;

    if (a % 30 == 0) r1 = 207;

    int x1 = CX + cos(rad) * r1;
    int y1 = CY + sin(rad) * r1;
    int x2 = CX + cos(rad) * r2;
    int y2 = CY + sin(rad) * r2;

    drawThickLine(x1, y1, x2, y2, COL_WHITE, 3);
  }

  // smaller static orange triangle
  fillTriangle(CX, CY - 210, CX - 11, CY - 229, CX + 11, CY - 229, COL_ORANGE);

  // smaller moving white triangle
float rollRad = rollDeg * PI / 180.0f;
float cr = cos(rollRad);
float sr = sin(rollRad);

auto ROLL = [&](float lx, float ly, int &sx, int &sy) {
  sx = CX + lx * cr + ly * sr;
  sy = CY - lx * sr + ly * cr;
};

int x1, y1, x2, y2, x3, y3;

ROLL(0, -200, x1, y1);
ROLL(-12, -181, x2, y2);
ROLL(12, -181, x3, y3);

fillTriangle(x1, y1, x2, y2, x3, y3, COL_WHITE);
}

void drawVirtualRunway() {
  // dark runway body
  fillTriangle(
    CX, CY + 10,
    CX - 95, CY + 115,
    CX + 95, CY + 115,
    rgb565(80, 38, 25)
  );

  // orange runway perspective lines
  drawThickLine(CX, CY + 10, CX - 32, CY + 115, COL_ORANGE, 2);
  drawThickLine(CX, CY + 10, CX + 32, CY + 115, COL_ORANGE, 2);
  drawThickLine(CX - 32, CY + 115, CX + 32, CY + 115, COL_ORANGE, 2);
}

void drawFrameLetter(char c, int x, int y, int s, uint16_t col) {
  switch (c) {
    case 'N':
      drawThickLine(x, y + 28*s, x, y, col, s);
      drawThickLine(x, y, x + 18*s, y + 28*s, col, s);
      drawThickLine(x + 18*s, y + 28*s, x + 18*s, y, col, s);
      break;

    case 'E':
      drawThickLine(x, y, x, y + 28*s, col, s);
      drawThickLine(x, y, x + 18*s, y, col, s);
      drawThickLine(x, y + 14*s, x + 15*s, y + 14*s, col, s);
      drawThickLine(x, y + 28*s, x + 18*s, y + 28*s, col, s);
      break;

    case 'S':
      drawThickLine(x + 18*s, y, x, y, col, s);
      drawThickLine(x, y, x, y + 14*s, col, s);
      drawThickLine(x, y + 14*s, x + 18*s, y + 14*s, col, s);
      drawThickLine(x + 18*s, y + 14*s, x + 18*s, y + 28*s, col, s);
      drawThickLine(x + 18*s, y + 28*s, x, y + 28*s, col, s);
      break;

    case 'W':
      drawThickLine(x, y, x + 4*s, y + 28*s, col, s);
      drawThickLine(x + 4*s, y + 28*s, x + 9*s, y + 12*s, col, s);
      drawThickLine(x + 9*s, y + 12*s, x + 14*s, y + 28*s, col, s);
      drawThickLine(x + 14*s, y + 28*s, x + 18*s, y, col, s);
      break;
  }
}

void drawFrameNumberText(const char *txt, int x, int y, int s, uint16_t col) {
  for (int i = 0; txt[i]; i++) {
    int dx = i * 14 * s;
    drawSegDigit(txt[i] - '0', x + dx, y, s, col);
  }
}

void drawCompassText(const char *txt, int cx, int cy, int s, uint16_t col) {
  int len = strlen(txt);
  int w;

  if (txt[0] >= '0' && txt[0] <= '9') {
    w = len * 14 * s;
    drawFrameNumberText(txt, cx - w / 2, cy - 9 * s, s, col);
  } else {
    w = 20 * s;
    drawFrameLetter(txt[0], cx - w / 2, cy - 14 * s, s, col);
  }
}

void drawAircraftSymbol() {
  // longer and thicker horizontal yellow wings
  drawThickLine(CX - 140, CY, CX - 35, CY, COL_YELLOW, 8);
  drawThickLine(CX + 35, CY, CX + 140, CY, COL_YELLOW, 8);

  // thicker central inverted V
  drawThickLine(CX - 35, CY, CX, CY + 28, COL_YELLOW, 8);
  drawThickLine(CX, CY + 28, CX + 35, CY, COL_YELLOW, 8);

  // small center point / hub
  fillTriangle(CX, CY - 4, CX - 7, CY + 6, CX + 7, CY + 6, COL_YELLOW);
}

void drawConcentricBezel() {
  // dark ring is thin, and horizon disc touches it directly
  fillRing(CX, CY, 207, 224, COL_RING_INNER);

  // bright outer ring
  fillRing(CX, CY, 225, 239, COL_RING_OUTER);

  drawCircle(CX, CY, 206, COL_RING_EDGE);
  drawCircle(CX, CY, 224, COL_RING_EDGE);
  drawCircle(CX, CY, 239, COL_WHITE);
}

void drawCompassDot(int x, int y, uint16_t col) {
  fillCircle(x, y, 2, col);
}

void drawCompassTicks(float headingDeg = 0) {
  const int R_OUT  = 216;  // речиси до сивиот обрач
  const int R_IN   = 194;  // подолги црти
  const int R_DOT  = 194;  // точки на средина од цртите

  // 36 црти = на секои 10 степени
  // помеѓу N и E има точно 9 црти: 10,20,30,40,50,60,70,80,90
  for (int deg = 0; deg < 360; deg += 10) {
    float a = (deg - headingDeg - 90) * PI / 180.0;

    int x1 = CX + cos(a) * R_IN;
    int y1 = CY + sin(a) * R_IN;
    int x2 = CX + cos(a) * R_OUT;
    int y2 = CY + sin(a) * R_OUT;

    drawThickLine(x1, y1, x2, y2, COL_WHITE, 3);
  }

  // дискретни точки точно на средина помеѓу секои две црти
  // значи на 5,15,25...
  for (int deg = 5; deg < 360; deg += 10) {
    float a = (deg - headingDeg - 90) * PI / 180.0;

    int x = CX + cos(a) * R_DOT;
    int y = CY + sin(a) * R_DOT;

    fillCircle(x, y, 2, COL_WHITE);
  }
}
void drawCompassLetters(float headingDeg = 0) {
  struct Mark {
    int deg;
    const char* txt;
    uint16_t col;
    int scale;
    int radius;
  };

  Mark marks[] = {
    // smaller N/E/S/W
    {0,   "N",  COL_YELLOW, 1, 158},
    {90,  "E",  COL_YELLOW, 1, 158},
    {180, "S",  COL_YELLOW, 1, 158},
    {270, "W",  COL_YELLOW, 1, 158},

    // degree labels
    {30,  "3",  COL_WHITE, 2, 160},
    {60,  "6",  COL_WHITE, 2, 160},
    {120, "12", COL_WHITE, 2, 160},
    {150, "15", COL_WHITE, 2, 165},
    {210, "21", COL_WHITE, 2, 155},
    {240, "24", COL_WHITE, 2, 150},
    {300, "30", COL_WHITE, 2, 150},
    {330, "33", COL_WHITE, 2, 150}
  };

  for (int i = 0; i < 12; i++) {
    float a = (marks[i].deg - headingDeg - 90) * PI / 180.0;

    int x = CX + cos(a) * marks[i].radius;
    int y = CY + sin(a) * marks[i].radius;

    drawCompassText(marks[i].txt, x, y, marks[i].scale, marks[i].col);
  }
}

void drawCompassAirplane() {
  // static yellow airplane symbol
  uint16_t c = COL_ORANGE;

  // nose / fuselage
 drawThickLine(CX, CY - 194, CX, CY + 65, c, 5);

  // nose sides
  drawThickLine(CX, CY - 130, CX - 28, CY - 40, c, 4);
  drawThickLine(CX, CY - 130, CX + 28, CY - 40, c, 4);

  // wings
  drawThickLine(CX - 28, CY - 40, CX - 88, CY + 10, c, 4);
  drawThickLine(CX + 28, CY - 40, CX + 88, CY + 10, c, 4);
  drawThickLine(CX - 88, CY + 10, CX - 88, CY + 35, c, 4);
  drawThickLine(CX + 88, CY + 10, CX + 88, CY + 35, c, 4);
  drawThickLine(CX - 88, CY + 35, CX - 20, CY + 10, c, 4);
  drawThickLine(CX + 88, CY + 35, CX + 20, CY + 10, c, 4);

  // body lower part
  drawThickLine(CX - 20, CY + 10, CX - 20, CY + 85, c, 4);
  drawThickLine(CX + 20, CY + 10, CX + 20, CY + 85, c, 4);

  // tail
  drawThickLine(CX - 20, CY + 85, CX - 55, CY + 110, c, 4);
  drawThickLine(CX + 20, CY + 85, CX + 55, CY + 110, c, 4);
  drawThickLine(CX - 55, CY + 110, CX - 55, CY + 130, c, 4);
  drawThickLine(CX + 55, CY + 110, CX + 55, CY + 130, c, 4);
  drawThickLine(CX - 55, CY + 130, CX, CY + 108, c, 4);
  drawThickLine(CX + 55, CY + 130, CX, CY + 108, c, 4);
}

void drawScreen_Compass() {
  clearFB(COL_BLACK);

  // thinner gray frame - половина од претходната дебелина
  fillRing(CX, CY, 229, 239, COL_RING_OUTER);
  fillRing(CX, CY, 218, 228, COL_RING_INNER);

  drawCircle(CX, CY, 217, COL_RING_EDGE);
  drawCircle(CX, CY, 228, COL_RING_EDGE);
  drawCircle(CX, CY, 239, COL_WHITE);

  fillCircle(CX, CY, 217, rgb565(42, 50, 52));

  float heading = compassHeadingDeg;

  drawCompassTicks(heading);
  drawCompassLetters(heading);
  drawCompassAirplane();

  gfx->draw16bitRGBBitmap(0, 0, fb, DISPLAY_WIDTH, DISPLAY_HEIGHT);
}

void drawScreen_AttitudeIndicator() {
  
  clearFB(COL_BLACK);

  drawConcentricBezel();

  fillArtificialHorizonDisc();

  drawAircraftSymbol();
  drawRollScaleOnRing();

  gfx->draw16bitRGBBitmap(0, 0, fb, DISPLAY_WIDTH, DISPLAY_HEIGHT);
}

void drawAltimeterNumbers() {
  const int R_NUM = 155;

  for (int n = 0; n <= 9; n++) {
    float deg = n * 36.0;
    float a = (deg - 90) * PI / 180.0;

    int x = CX + cos(a) * R_NUM;
    int y = CY + sin(a) * R_NUM;

    drawSegDigit(n, x - 8, y - 14, 3, COL_WHITE);
  }
}

void drawAltimeterTicks() {
  const int R_OUT = 205;
  const int R_IN_MAJOR = 178;
  const int R_IN_MINOR = 192;

  for (int i = 0; i < 100; i++) {
    float deg = i * 3.6;
    float a = (deg - 90) * PI / 180.0;

    bool major = (i % 10 == 0);
    bool medium = (i % 5 == 0);

    int r1 = major ? R_IN_MAJOR : (medium ? 185 : R_IN_MINOR);
    int r2 = R_OUT;

    int x1 = CX + cos(a) * r1;
    int y1 = CY + sin(a) * r1;
    int x2 = CX + cos(a) * r2;
    int y2 = CY + sin(a) * r2;

    drawThickLine(x1, y1, x2, y2, COL_WHITE, major ? 4 : 2);
  }
}

void drawAltimeterText() {
  // simple static text with lines, framebuffer-safe
  // ALT
// ALT
drawFrameLetter('A', CX - 48, CY - 78, 1, COL_WHITE);

// L
drawThickLine(CX - 10, CY - 78, CX - 10, CY - 50, COL_WHITE, 1);
drawThickLine(CX - 10, CY - 50, CX + 8, CY - 50, COL_WHITE, 1);

// T
drawFrameLetter('T', CX + 28, CY - 78, 1, COL_WHITE);

  // small “x1000 ft” imitation
  drawThickLine(CX - 28, CY - 38, CX + 28, CY - 38, COL_WHITE, 1);
  drawThickLine(CX - 18, CY - 30, CX + 18, CY - 30, COL_WHITE, 1);
}

void drawAltimeterHand(float angleDeg, int length, int width, uint16_t col) {
  float a = (angleDeg - 90) * PI / 180.0;

  int tipX = CX + cos(a) * length;
  int tipY = CY + sin(a) * length;

  float px = -sin(a);
  float py = cos(a);

  int leftX  = CX + px * width;
  int leftY  = CY + py * width;
  int rightX = CX - px * width;
  int rightY = CY - py * width;

  fillTriangle(leftX, leftY, rightX, rightY, tipX, tipY, col);
}

void drawAltimeterHands(int altitudeFt) {

  // LONG thin hand
  // one full rotation = 1000 ft
  float longAngle =
      ((altitudeFt % 1000) / 1000.0f) * 360.0f;

  // SHORT thick hand
  // one full rotation = 10000 ft
  float shortAngle =
      ((altitudeFt % 10000) / 10000.0f) * 360.0f;

  // short thick hand
  drawAltimeterHand(shortAngle, 95, 11, COL_WHITE);

  // long thin hand
  drawAltimeterHand(longAngle, 175, 4, COL_WHITE);

  fillCircle(CX, CY, 14, rgb565(120,120,120));
  fillCircle(CX, CY, 7, COL_WHITE);
}

void drawAltimeterSmallWindow() {
  // small striped reference window at bottom, like aircraft altimeters
  int x0 = CX - 42;
  int y0 = CY + 72;

 fillCircle(CX, CY + 88, 38, rgb565(25, 25, 25));

  for (int i = 0; i < 5; i++) {
    drawThickLine(x0 + i * 16, y0 + 35, x0 + i * 16 + 35, y0, COL_WHITE, 5);
  }
}

void drawScreen_Altimeter() {
  clearFB(COL_BLACK);

  // concentric frame, same style as compass
  fillRing(CX, CY, 229, 239, COL_RING_OUTER);
  fillRing(CX, CY, 218, 228, COL_RING_INNER);

  drawCircle(CX, CY, 217, COL_RING_EDGE);
  drawCircle(CX, CY, 228, COL_RING_EDGE);
  drawCircle(CX, CY, 239, COL_WHITE);

  // dial background
  fillCircle(CX, CY, 217, rgb565(25, 28, 30));
  fillCircle(CX, CY, 105, rgb565(36, 40, 42));

  drawAltimeterTicks();
  drawAltimeterNumbers();
  drawAltimeterText();
  drawAltimeterSmallWindow();

  
  int altitudeDisplay = (int)altitudeFt;

  drawAltimeterHands(altitudeDisplay);

  gfx->draw16bitRGBBitmap(0, 0, fb, DISPLAY_WIDTH, DISPLAY_HEIGHT);

  
}

void drawCurrentScreen() {
  switch (currentScreen) {
    case SCREEN_ATTITUDE:
      drawScreen_AttitudeIndicator();
      break;

    case SCREEN_COMPASS:
      drawScreen_Compass();
      break;

    case SCREEN_ALTIMETER:
      drawScreen_Altimeter();
      break;

   case SCREEN_AIRPLANE:
      drawScreen_AirplaneDemo();
      break;     

    default:
      drawScreen_AttitudeIndicator();
      break;
  }
}  

void init_display() {
 pinMode(BL_PIN, OUTPUT);
ledcAttach(BL_PIN, 5000, 8);
ledcWrite(BL_PIN, brightnessLevel);

  panelBus = new Arduino_SWSPI(
    GFX_NOT_DEFINED, PANEL_CS, PANEL_SCK, PANEL_SDA, GFX_NOT_DEFINED
  );

  rgbpanel = new Arduino_ESP32RGBPanel(
      40,7,15,41,
      46,3,8,18,17,
      14,13,12,11,10,9,
      5,45,48,47,21,
      1,50,10,50,
      1,30,10,30,
      PCLK_NEG,6000000UL
  );

  gfx = new Arduino_RGB_Display(
    DISPLAY_WIDTH, DISPLAY_HEIGHT, rgbpanel, 0, true,
    panelBus, GFX_NOT_DEFINED,
    st7701_type7_init_operations, sizeof(st7701_type7_init_operations)
  );

  gfx->begin(8000000);

  fb = (uint16_t*)ps_malloc(DISPLAY_WIDTH * DISPLAY_HEIGHT * 2);
  if (!fb) {
    Serial.println("Framebuffer allocation failed!");
    while (1);
  }
}

void pcf8574_init() {
  Wire.begin(I2C_SDA, I2C_SCL);

  Wire.beginTransmission(PCF8574_ADDR);
  Wire.write(0xFF);        // all pins high = inputs with pullups
  Wire.endTransmission();
}

uint8_t pcf8574_read() {
  Wire.requestFrom(PCF8574_ADDR, (uint8_t)1);

  if (Wire.available()) {
    return Wire.read();
  }

  return 0xFF;
}

bool isEncoderButtonPressed() {
  uint8_t state = pcf8574_read();

  // P5 low = button pressed
  return !(state & (1 << 5));
}

bool mpu6050_init() {
  Wire.beginTransmission(MPU6050_ADDR);
  Wire.write(0x6B);      // PWR_MGMT_1
  Wire.write(0x00);      // wake up
  if (Wire.endTransmission() != 0) return false;

  delay(100);

  // accelerometer ±2g
  Wire.beginTransmission(MPU6050_ADDR);
  Wire.write(0x1C);
  Wire.write(0x00);
  Wire.endTransmission();

  return true;
}

bool mpu6050_readAccel(float &ax, float &ay, float &az) {
  Wire.beginTransmission(MPU6050_ADDR);
  Wire.write(0x3B); // ACCEL_XOUT_H
  if (Wire.endTransmission(false) != 0) return false;

  Wire.requestFrom(MPU6050_ADDR, (uint8_t)6);
  if (Wire.available() < 6) return false;

  int16_t rawX = (Wire.read() << 8) | Wire.read();
  int16_t rawY = (Wire.read() << 8) | Wire.read();
  int16_t rawZ = (Wire.read() << 8) | Wire.read();

  ax = rawX / 16384.0f;
  ay = rawY / 16384.0f;
  az = rawZ / 16384.0f;

  return true;
}

void updateMPU6050() {
  float ax, ay, az;

  if (!mpu6050_readAccel(ax, ay, az)) return;

  float newRoll  = atan2(ay, az) * 180.0f / PI;
  float newPitch = atan2(-ax, sqrt(ay * ay + az * az)) * 180.0f / PI;

  // smoothing
  rollDeg  = rollDeg  * 0.85f + newRoll  * 0.15f;
  pitchDeg = pitchDeg * 0.85f + newPitch * 0.15f;
}

bool qmc5883l_init() {
  Wire.beginTransmission(QMC5883L_ADDR);
  Wire.write(0x0B);      // SET/RESET period register
  Wire.write(0x01);
  Wire.endTransmission();

  Wire.beginTransmission(QMC5883L_ADDR);
  Wire.write(0x09);      // control register
  Wire.write(0x1D);      // continuous, 200Hz, 2G, 512 OSR
  if (Wire.endTransmission() != 0) return false;

  return true;
}

bool qmc5883l_readHeading(float &headingDeg) {
  Wire.beginTransmission(QMC5883L_ADDR);
  Wire.write(0x00);
  if (Wire.endTransmission(false) != 0) return false;

  Wire.requestFrom(QMC5883L_ADDR, (uint8_t)6);
  if (Wire.available() < 6) return false;

  int16_t x = Wire.read() | (Wire.read() << 8);
  int16_t y = Wire.read() | (Wire.read() << 8);
  int16_t z = Wire.read() | (Wire.read() << 8);

  float heading = atan2((float)y, (float)x) * 180.0f / PI;

  if (heading < 0) heading += 360.0f;
  if (heading >= 360) heading -= 360.0f;

  headingDeg = heading;

  Serial.print("QMC X=");
  Serial.print(x);
  Serial.print(" Y=");
  Serial.print(y);
  Serial.print(" Z=");
  Serial.print(z);
  Serial.print(" Heading=");
  Serial.println(headingDeg);

  return true;
}

void updateQMC5883L() {
  float h;

  if (!qmc5883l_readHeading(h)) {
    Serial.println("QMC5883L read failed");
    return;
  }

  float diff = h - compassHeadingDeg;

  if (diff > 180.0f)  diff -= 360.0f;
  if (diff < -180.0f) diff += 360.0f;

  compassHeadingDeg += diff * 0.15f;

  if (compassHeadingDeg < 0) compassHeadingDeg += 360.0f;
  if (compassHeadingDeg >= 360.0f) compassHeadingDeg -= 360.0f;
}

void updateSimulatedAltitude() {
  unsigned long now = millis();

  if (lastAltitudeUpdate == 0) {
    lastAltitudeUpdate = now;
    return;
  }

  float dt = (now - lastAltitudeUpdate) / 1000.0f;
  lastAltitudeUpdate = now;

  // dead zone за да не “плива” кога е речиси рамно
  float p = pitchDeg;

  if (abs(p) < 2.0f) {
    p = 0;
  }

  // брзина на промена на висина
  // 1 степен pitch ≈ 12 ft/sec
  float climbRateFtPerSec = -p * 12.0f;

  altitudeFt += climbRateFtPerSec * dt;

  // ограничување
  if (altitudeFt < 0) altitudeFt = 0;
  if (altitudeFt > 9999) altitudeFt = 9999;
}

void IRAM_ATTR updateEncoder() {
  int MSB = digitalRead(ENCODER_CLK);
  int LSB = digitalRead(ENCODER_DT);

  int encoded = (MSB << 1) | LSB;
  int sum = (lastEncoded << 2) | encoded;

  if (sum == 0b1101 || sum == 0b0100 || sum == 0b0010 || sum == 0b1011)
    encoderValue++;

  if (sum == 0b1110 || sum == 0b0111 || sum == 0b0001 || sum == 0b1000)
    encoderValue--;

  lastEncoded = encoded;
}

void handleBrightnessEncoder() {
  noInterrupts();
  long currentValue = encoderValue;
  interrupts();

  if (currentValue != lastEncoderValue) {
    int diff = currentValue - lastEncoderValue;

    brightnessLevel += diff * 4;

    if (brightnessLevel < 20) brightnessLevel = 20;
    if (brightnessLevel > 255) brightnessLevel = 255;

    ledcWrite(BL_PIN, brightnessLevel);

    lastEncoderValue = currentValue;

    Serial.print("Brightness: ");
    Serial.println(brightnessLevel);
  }
}

void setup() {
  Serial.begin(115200);

  pinMode(ENCODER_CLK, INPUT_PULLUP);
  pinMode(ENCODER_DT, INPUT_PULLUP);

  attachInterrupt(digitalPinToInterrupt(ENCODER_CLK), updateEncoder, CHANGE);
  attachInterrupt(digitalPinToInterrupt(ENCODER_DT), updateEncoder, CHANGE);

  init_display();

  pcf8574_init();

//  scanI2C();
  
  if (mpu6050_init()) {
  Serial.println("MPU6050 OK");
} else {
  Serial.println("MPU6050 NOT FOUND");
}

if (qmc5883l_init()) {
  Serial.println("QMC5883L OK");
} else {
  Serial.println("QMC5883L NOT FOUND");
}

  drawCurrentScreen();
}

void loop() {

  handleBrightnessEncoder();
  
  static bool lastBtn = false;

  bool btn = isEncoderButtonPressed();

  if (btn && !lastBtn) {
    currentScreen = (ScreenMode)((currentScreen + 1) % SCREEN_COUNT);
    drawCurrentScreen();
    delay(250);
  }

  lastBtn = btn;

  if (currentScreen == SCREEN_ATTITUDE && millis() - lastMPURead > 120) {
    lastMPURead = millis();
    updateMPU6050();
    drawCurrentScreen();
  }
 if (currentScreen == SCREEN_COMPASS && millis() - lastCompassRead > 120) {
  lastCompassRead = millis();
  updateQMC5883L();
  drawCurrentScreen();
}


if (currentScreen == SCREEN_ALTIMETER && millis() - lastMPURead > 120) {
  lastMPURead = millis();

  updateMPU6050();
  updateSimulatedAltitude();

  drawCurrentScreen();
}

if (currentScreen == SCREEN_AIRPLANE && millis() - lastMPURead > 120) {
  lastMPURead = millis();
  updateMPU6050();
  drawCurrentScreen();
}

}
License
All Rights
Reserved
licensBg
0