Home ยป Creating our first ESP32 flight display graphics

Creating our first ESP32 flight display graphics

by Bits and Droids
Published: Last Updated on 0 comment

In our last article, we’ve focused on the wiring and underlying protocols of our custom ESP32 primary flight display. It’s time to start bringing this contraption to life with the use of code. We will focus on displaying our first graphics, layer shapes, and display game data and position elements relative to each other.

Click here if you want to skip to the final code

Libraries needed

Before we get started, we need to add 2 libraries to our Arduino IDE.

You can either download the ZIP file directly from their Github pages linked above or through the library manager within the Arduino IDE.

Download zip from Github

Why do we need ESP32 specific libraries?

The ESP32, in its core, is vastly different from an Arduino (while similar at the same time). Most of the libraries that we’ve been using in the past will work perfectly fine interchangeably between an ESP32 and an Arduino. There are certain instances where a custom ESP32 library is needed to drive certain components or handle board-specific logic. The TFT screen is one of those components that requires a specific ESP32 oriented library. But why do we use a special library for the encoder then? Even though the default Arduino libraries might sometimes work on an ESP32, there are cases where a board-specific library optimizes and streamlines the process for us.

The starting point

Before we start coding, it’s wise to determine what we want to achieve. We could start with a fully animated interface with fancy movements, gauges, or over-the-top effects. But to fully understand the basics, we are going to start with a COM 1 radio screen. The data only updates when we change the frequency, we can add touchscreen buttons to control the ATC, and the graphics can be easily broken down and analyzed.

Sketch of graphical end result
Sketch of the desired graphical result

The sketch of our desired results contains several elements.

  • Black background
  • Text elements (white, black, big, small)
  • Grey/silvery square (that gets interupted by text with a black background
  • Black square in the grey square
  • Colored squares with a rounded corner
  • Switch arrow in a white square

To understand how we can display these elements, we first need to understand how our screen displays data.

Pixels

If we simplify the concept and bring it back to its essentials, our screen consists of tiny individual pixels that can each light up in the desired color. Our screen is 480 pixels high by 320 pixels wide in portrait mode (a total of 153.600 pixels). Since we will use the screen in a horizontal position, we need to flip these values around.

If we want to draw a black background, we need to set every pixel to black. We can address each pixel individually, so we don’t have to refresh the entire frame to display a new element. To make this process easier, the TFT_eSPI divides our pixels into a grid. An X-axis marks the horizontal axis (the columns), while a Y-axis marks the rows. Because we use the screen in a horizontal position the top left corner has the coordinates X0, Y0. The bottom-right coordinates will then be? You guessed it correctly X480, Y320. This grid system will be the foundation of our graphics. Went to draw something in the middle? Just divide the maximum height and width by 2 and you’ll get the middle coordinates.

Setting up the TFT_eSPI library

Defining user prefferences

The TFT_eSPI needs to be set up to fit our specific needs.

  • Go to your Arduino folder (where your sketches and libraries are saved).
  • Open the libraries folder.
  • Open the TFT_eSPI folder.
  • Open the User.Setup.h file.

The first thing we need to assess is which driver chip our TFT screen uses. For this, it’s best to check the store page of the screen you’ve bought.

Mine clearly states that it uses an ST7796S chip. Other common chips are the ILI9341 or the ILI19488. The wiring doesn’t differentiate between these screens it is just the low-level commands that differ. Luckily these differences get handled by the TFT_eSSPI library. We just need to make sure that we tell the library what type of chip we use.

  • Go to the User_Setup.h file we opened earlier.
  • Uncomment the line where your chip number is mentioned (remove the //).

In the code example below you’ll see that I uncommented the #define ST7796_DRIVER (once again yours might differ). This ensures the library uses the correct commands to match our screen.

//THIS CAN BE FOUND IN THE USER_SETUP.H FILE

//#define ILI9341_DRIVER       // Generic driver for common displays
//#define ILI9341_2_DRIVER     // Alternative ILI9341 driver, see https://github.com/Bodmer/TFT_eSPI/issues/1172
//#define ST7735_DRIVER      // Define additional parameters below for this display
//#define ILI9163_DRIVER     // Define additional parameters below for this display
//#define S6D02A1_DRIVER
//#define RPI_ILI9486_DRIVER // 20MHz maximum SPI
//#define HX8357D_DRIVER
//#define ILI9481_DRIVER
//#define ILI9486_DRIVER
//#define ILI9488_DRIVER     // WARNING: Do not connect ILI9488 display SDO to MISO if other devices share the SPI bus (TFT SDO does NOT tristate when CS is high)
//#define ST7789_DRIVER      // Full configuration option, define additional parameters below for this display
//#define ST7789_2_DRIVER    // Minimal configuration option, define additional parameters below for this display
//#define R61581_DRIVER
//#define RM68140_DRIVER

#define ST7796_DRIVER

//#define SSD1351_DRIVER
//#define SSD1963_480_DRIVER
//#define SSD1963_800_DRIVER
//#define SSD1963_800ALT_DRIVER
//#define ILI9225_DRIVER
//#define GC9A01_DRIVER

If we scroll down further we find a block that has specific pins for an ESP8266 (see the code block below). We will be using an ESP32 and not an ESP8266 (the precursor of the ESP32). Therefore we want to comment all these lines out.

  • comment out all three #defines untill it looks like the example below (add the //).
// For NodeMCU - use pin numbers in the form PIN_Dx where Dx is the NodeMCU pin designation
//#define TFT_CS   PIN_D8  // Chip select control pin D8
//#define TFT_DC   PIN_D3  // Data Command control pin
//#define TFT_RST  PIN_D4  // Reset pin (could connect to NodeMCU RST, see next line)
//#define TFT_RST  -1    // Set TFT_RST to -1 if the display RESET is connected to NodeMCU RST or 3.3V

Once we’ve got that out of the way we scroll down again until we encounter the following line.

// ###### EDIT THE PIN NUMBERS IN THE LINES FOLLOWING TO SUIT YOUR ESP32 SETUP   ######

The block underneath this header lets us define the pins where we’ve connected our TFT screen to our ESP32. In the previous article, we’ve taken a deep dive into the wiring if you want to read up on the subject. To make things easier I’ve enclosed the wiring diagram in the image below.

Our goal is to match the setup block with the pins that we’ve used on our board. Luckily we’ve already used the pins that either are mandatory (the dedicated SPI pins) or are recommended by the library. All we have to do is comment out the first six #defines.

// ###### EDIT THE PIN NUMBERS IN THE LINES FOLLOWING TO SUIT YOUR ESP32 SETUP   ######

// For ESP32 Dev board (only tested with ILI9341 display)
// The hardware SPI can be mapped to any pins

#define TFT_MISO 19
#define TFT_MOSI 23
#define TFT_SCLK 18
#define TFT_CS   15  // Chip select control pin
#define TFT_DC    2  // Data Command control pin
#define TFT_RST   4  // Reset pin (could connect to RST pin)

LEAVE THIS COMMENTED OUT
//#define TFT_RST  -1  // Set TFT_RST to -1 if display RESET is connected to ESP32 board RST

We also want to utilize the touchscreen so scroll down a tiny bit until we encounter the line that defines the touch pin.

  • Find the line that says //#define TOUCH_CS 21.
  • Uncomment this line (remove the //). It should now look exactly like the code block below.
#define TOUCH_CS 21     // Chip select pin (T_CS) of touch screen

Last and definitely important!

  • Save your file (hit ctrl+s) to make sure that your changes are applied.

We’ve now set up our library to be used in our code.

Initializing our TFT screen

For this, we’re going to create a new sketch.

  • Create a new sketch.
  • Save the file with a name that you’d like.

First off we need to include the TFT_eSPI library and initiate a TFT object.

  • Include the TFT_eSPI library
  • Create a TFT object
//Always include the libraries at the top of our file

#include <SPI.h>
#include <TFT_eSPI.h>

//Here we create the TFT object. 
//The tft object is of the type TFT_eSPI. 
//By referencing this variable we can perform actions (excecute functions) with our TFT screen

TFT_eSPI tft = TFT_eSPI();

Our screen makes use of the SPI interface. The SPI interface on our ESP32 needs to be manually enabled by including this header file or else both components can’t communicate.

To enable the screen we need to initialize the display. Because we only want to initialize the screen once we add this code to the setup block. The setup block only gets executed when our board boots (or when it gets reset). The loop block will contain all of our code that gets executed repeatedly.

  • Initialize our TFT screen in the setup block. The library includes a function called .init()
  • Rotate the screen 1 tick if we want to use it horizontally (use the setRotation() function)
//These 3 dots mean that this code block is a snippet taken from a bigger picture
//There is still code above these ... that we've coded up earlier (the include etc.)
//
...

void setup() {
  tft.setRotation(1);
  tft.init();
}

Colors in RGB565

We could already upload this code but our screen would still be white as snow. Our screen isn’t a high-end product. But in our case, it doesn’t have to be. The downside is that it has a smaller color range. Most non-budget screens can produce a 24-bit color range while ours can only display a 16-bit color range.

The 24-bit range is divided into 8 bits for red, 8 bits for green, and 8 bits for blue. The higher each color value is the more present it will be. 255,255,255 will be white while 0,0,0 will be black. We could mix these values up to create almost all colors (16 777 216 colors in total).

The 16-bit approach takes a slightly different method. It reserves 5 bits for red, 6 bits for green, and 5 bits for blue. This reduces the number of colors we can create (65 536) but is also less taxing due to the smaller size.

The TFT_eSPI library packs several default colors. I’d recommend taking a look at the TFT_eSPI.h file that is located in the TFT_eSPI library folder. Besides the colors, you are also able to find all the available functions that we can utilize. It’s good practice to do this each time you use a new library. Even though you might not understand everything that happens under the hood it gives you a good overview of all the functionalities. Just studying the code of libraries could also learn you a trick or two (it has learned me a ton).

If you want to create your own custom colors I’d recommend using an RGB565 color picker. You’re able to find one on the site of barth-dev (RGB565 Color Picker – Barth Development (barth-dev.de)). The value you need is the hex value (see the image below).

RGB565 hex color value

Our screen is still white by default. We want to have a black background on our screen instead of white. We could paint each pixel black one at a time but that would be quite cumbersome. Luckily the library has a .fillScreen(color) function. The text “color” can be found between the (). This tells us that color is a parameter required for the fillScreen function. If we want to paint our screen black we could pass this function the color TFT_BLACK. TFT_BLACK is like an alias the library made for the color black. We could also pass the color hex value directly (0x0000). We might want to change our background color at a later point so it’s wise to create a variable that holds your background value (trust me you’ll thank me later).

  • Create a variable to hold your main background color (an int)
  • Fill the screen with this color in the setup block
...

//This creates a variable that holds our main background color
//We need the bakground color in multiple places
//With a variable we only need to change it in one place
//You can play around with the color to your taste

int16_t mainBgColor = TFT_BLACK;

void setup(){

 tft.init();
 //Paint the entire canvas our main BG color
 tft.fillScreen(mainBgColor);

}

Adding our first square

To add our silver square to simulate a chrome-like trim around the comm data we’re going to draw a solid filled square. There are two options we can utilize in the TFT_eSPI library. The first is the drawRect function. While this may seem like the logical thing to do but it will only draw a one-pixel wide square. If we want a wider rectangle we could draw multiple rectangles inside of each other for every pixel we want.

Another option would be to use the fillRect function. This will draw a solid square filled with the color you pass as a parameter. We don’t want to draw a solid grey square but we could just draw a solid square that matches the background color in the middle of the grey square.

The trim we want to recreate

The screen I’m using is 480 pixels wide by 320 pixels high. There is a chance that you’re using a different-sized screen. That is why we’re going to use some basic geometry and math to create a “responsive” design. With responsive, we usually refer to an element that scales with the size of our screen. Let us sum up what we need for this.

  • Our screen width is 480 pixels wide
  • We want to center our square in the middle
  • We want an equal amount of spacing on both sides
  • The square shouldn’t touch the top (this is just more pleasing on the eye)

The fillRect function takes five parameters. fillRect(xStartingPoint, yStartingPoint, width, height, color). This is where our coordinate grid system comes into play. As a starting point, we will add margins of 20 pixels on both sides of the grey square. To make it responsive we can calculate the width by taking the screen width, and subtract 20 times 2.

To get the screen width from our library we use the getViewportHeight function. This might sound counterintuitive since we want to retrieve the width. The library we’re using has portrait mode as the default mode. Because we flip our screens horizontally the width becomes the height and vice versa.

Square width = Total screen width – (margin * 2)

The X and Y starting points can easily be calculated by adding the margins to 0 (since the upper left corner will be coordinate 0,0). Instead of calculating this starting point each time we just save the margin as a variable and pass this directly.

  • Add a variable for the margins (I had to offset xMargin by +10 due to the case bezel)
  • Add a variable for the screen width
int margin = 20;
int xMargin = 30;
int screenWidth = tft.getViewportHeight();

Struct

We will be using a struct to hold our square variables. A struct can hold multiple variables in a single container. It’s basically a diet class.

struct square{
  int xStart;
  int yStart;
  int height;
  int width;
  int16_t color;
};

This gives us the possibility to create a square variable where each setting can be retrieved individually. Let’s first focus on creating our grey square.

...
int margin = 20;
int xMargin = 30;
int screenWidth = tft.getViewportHeight();

//Here we've created our struct

struct square{
  int xStart;
  int yStart;
  int height;
  int width;
  int16_t color;
};

//We take our square struct and use this to initiate a variable called greySquare
//Each variable gets passed in the order we defined in our struct
//This makes our greySquare start at the 20,20 coordinates
//A width of the screenwidth - the margin * 2, a set height of 150, and finally a color to your liking. I went with TFT_SILVER

square greySquare = {
  xMargin,
  margin,
  screenWidth - margin * 2,
  150,
  TFT_SILVER
};
...

We will draw this square in a new function we will call initializeRadio. This way we can call this function each time we want to draw the radio screen.

void initializeRadio(){
  tft.fillRect(greySquare.xStart, greySquare.yStart, greySquare.width, greySquare.height, greySquare.color);
}

This isn’t the only square we want to draw so we might as well create a function that we can reuse. The fillRect function is already a function on itself so why do we need another one? Because we create structs it would be easier if we could just pass the struct directly. This will save us from messy code later on.

void drawFillSquare(square toDraw){
  tft.fillRect(toDraw.xStart, toDraw.yStart, toDraw.width, toDraw.height, toDraw.color);
}

//We can replace the fillRect function in the initializeRadio function with the following line
void initializeRadio(){
  //this looks way cleaner, especially if we add more squares
  drawFillSquare(greySquare);
}

Can you already see the potential? We could store all our squares in an array and draw them dynamically. We only have a single place where we define our parameters (where we initialize the greySquare variable). This makes it simple to alter any values we need without having to dig through heaps of code.

We don’t just want a silver square we also want to add a second background colored square to create a frame effect. For this, we take the same struct approach we used for the greySquare. Once again we want to center this square in our silver square. This is a matter of taste but I want a border that’s 4 pixels wide on every side. The math is simple on this one the X and Y coordinates can be calculated by adding the desired frame width(4). And for the width and height, we take the greySquare width/height and subtract the width times 2.

square greySquare = {
  margin,
  margin,
  screenWidth - margin * 2,
  150,
  TFT_SILVER
};

byte frameWidth = 4;
//The greySquare will be our reference point for almost all the inner values.
square innerSquare = {
  greySquare.xStart + frameWidth,
  greySquare.yStart + frameWidth,
  greySquare.width - frameWidth * 2,
  greySquare.height - frameWidth * 2,
  mainBgColor  
};

void initializeRadio(){
  drawFillSquare(greySquare);
  //We can draw the square similar to the previous one 
  drawFillSquare(innerSquare);
}

Why do we derive everything from the greySquare instead of passing the values directly? With our reference approach, you can change the size of the greySquare and the innerSquare will automatically scale with it. Want a wider frame? Just alter the frame value once and it will alter the width everywhere.

Adding the ATC buttons

For our 10 ATC buttons, we won’t be using the fillRect function. We want to have some nice rounded corners to create a more aesthetically pleasing look. Luckily we don’t have to perform any special tricks because there is a handy dandy function called fillRoundedRect. It just takes an extra parameter for the corner radius. We want to spread the buttons out evenly so we could use a mathematical approach for this. Drawing this out could give us more insight into our formula for this.

The X starting point of every square is the same for each column and the Y starting coordinate is the same for each button in the same row. The width, height, and color will be shared as well. By starting with these 3 values we can do some calculations to retrieve the rest. Because we need these coordinates later on for our touchscreen (to see if the button is pressed) we are going to store each button in an array. This way we can loop through the array when we want to see if the button is pressed. This will also keep our code tidy since we have a single formula that adds each button. We do need to create a new struct since we want to have rounded corners.

  • Create a rounded square struct
  • Create an array that can hold 10 rounded square struct objects
  • Loop 5 times to add 2 buttons at a time (remember they share the x per column and y per row)
  • Store these buttons in the array
//we create a new struct to handle our cornerRadius
struct roundedSquare {
  int xStart,
  int yStart,
  int width,
  int height,
  int cornerRadius,
  int color
}; 

//We create an array to hold our buttons
//The {{0}} will let our compiler fill it up with empty structs
//Because we can't increment a value outside of a function we need to add the squares in the setup block
//Our compiler wont let our code be uploaded if we don't fill the array with data 
//Adding it with nothing still fills it with something if that makes sense

roundedSquare buttonArray[10] = {{0}};

...

void setup{
  ...
  //We will add 2 buttons every loop and we want 10 buttons total
  //Therefor we want to loop through this 5 times
  for(int i = 0; i < 5; i++){
    
    //These values are shared accross each button
    int height = 30;
    int width = 40;
    int color = ;
    byte cornerRadius = 4;

    //Because each column has the same X starting coordinate
    //We can create a new button for the top and bottom row everytime we loop throught this function
    //How higher the i (amnt of loops) is the more squares have been added already
    //By knowing the amount we then know the new starting position 
    //by multiplying i by the width + margins in between buttons
    roundedSquare newButtonTop = {
      xMargin +((width * i) + (margin * i)),
      250,
      width,
      height,
      cornerRadius,
      color
      
    }
    //Most values are the same as the top row except for the starting Y
    //For this we take the start of Y from the first button and add the height + margin
    roundedSquare newButtonBottom = {
      xMargin +((width * i) + (margin * i)),
      newButtonTop.yStart + height + margin,
      width,
      height,
      cornerRadius,
      color
    }
    
    //We store these values in our array
    //I want them to be organized, but in our loop we've added the button that will hold 1 and 6
    //Because we are maintaining the same patern we can add the second button at i + 5
    //In the end this will ensure that our array holds 1,2...9,0 in order
    buttonArray[i] = newButtonTop;
    buttonArray[i+5] = newButtonBottom;
  }
}

To draw these squares we can use another loop in the initializeRadio function we’ve created earlier. Just like normal squares we want to create a drawing function that takes our struct as a parameter.

  • Create a function that draws a rounded rectangle and takes your struct as parameter
  • Loop through your array and draw each buttonI
...
void drawFillRoundRect(roundedSquare toDraw){
  tft.fillRoundRect(
       toDraw.xStart,
       toDraw.yStart,
       toDraw.width,
       toDraw.height,
       toDraw.cornerRadius,
       toDraw.color
 }
}

void initializeRadio(){
  ...
  //This loop will run through every element in our array
  //It takes these values to draw a new element every loop
  for (int i = 0; i < buttonArray.size(); i++){
    drawFillRoundRect(buttonArray[i]);
  }
}

What may seem like an extra step we took earlier will be the same thing that saves us time here. This array contains everything we need in a single place and lets us draw every element on the screen without having to repeat ourselves. Want a bigger button? No worries we just change the parameters and we’re all set.

Drawing the swap radio button

Last but not least we need to draw the white radio swap button with the arrow in the middle. The button looks the nicest when it’s centered in the innerSquare. Luckily we can use our maths skills to determine where our button has to be placed.

We could just calculate the middle of the screen by dividing the total by two but for the sake of this example, we won’t. Just in case you want to apply the same trick with an element that isn’t perfectly centered like ours.

Calculating the center

First, we determine the width and height of our button. Personally, I went with 30 pixels wide and 20 high. Now we determine the absolute center of our square. Let’s start with the X-axis. Our starting point will be innerSquare.xStart and the far most right X-coordinate will be innerSquare.Start + innerSquare.width. To calculate the middle we could just take the width divide it by two and add the result to the innerSquare X start. If we use this as our starting point for our button the upper-left corner will be centered. We don’t want that, we want the center of our button to be centered in our square.

To achieve this we just offset the X starting coordinate by the button.width / 2. This will equally divide the button to the left and right from our center.

innerSquare X starting point = innerSquare.xStart
innerSquare width = innerSquare.width

Button width = 30


Button square X start = innerSquare.xStart + (Inner square width / 2) – (button.width / 2)

For the Y-axis we do exactly the same but swap the X values for the Y values.

innerSquare Y starting point = innerSquare.yStart
innerSquare height = innerSquare.height

button height = 20

button square Y start = innerSquare.yStart + (innerSquare.height / 2) – (button.height / 2)

Once again we’ll use the rounded square struct we’ve made earlier.

  • Create a swapbutton
  • Center the button in the middle of the inner black square
  • Draw the button with the function we’ve created earlier
...

byte swapWidth = 40;
byte swapHeight = 25;

roundedSquare swapButton = {
  innerSquare.xStart + (innerSquare.width / 2) - (swapWidth / 2),
  innerSquare.yStart + (innerSquare.height / 2) - (swapHeight / 2),
  swapWidth,
  swapHeight,
  4,
  0xF693
}

...

//Don't forget to draw the square in the initialize function
void initializeRadio(){
  ...
  tft.drawFillRoundRect(swapButton);
} 

The little black swap arrow in the middle consists of 3 elements. 1 rectangle and 2 triangles.

Swap button elements

Once again the library will come to our rescue with the fillTriangle function. To create a triangle we need to coordinate sets (X and Y coordinates). Each point can be moved by changing the coordinates. For this, we could also create a schematic.

Coordinates schematic

Two X-coordinates of the triangles are shared with the left and right edges of the center square. To make our life easier we start with this square using the same logic as the button itself.

byte swapGraphSquareWidth = 6;
byte swapGraphSquareHeight = 3;
 
square swapGraphicSquare = {
  swapButton.xStart + (swapButton.width / 2) - (swapGraphSquareWidth / 2),
  swapButton.yStart + (swapButton.height / 2) - (swapGraphSquareHeight / 2),
  swapGraphSquareWidth,
  swapGraphSquareHeight,
  TFT_BLACK
}
  

We start with the left-pointing triangle. Because we’ve just defined our square we already know everything we need for our triangles. Currently, there is no struct for our triangles so let us add that as well. Just as previously it’s best to create a function that draws the shape for us with the triangle struct as a parameter.

  • Create a triangle struct
  • Create 2 triangles using the square as your refference point
  • Add a function that draws the triangle
  • Draw all the shapes with the functions you’ve created
struct triangle{
  int point1X,
  int point1Y,
  int point2X,
  int point2Y,
  int point3X,
  int point3Y,
  int color
};


//This wil ensure our triangle has equal sides
byte triangleBoundaries = 14; 

triangle leftPointTriangle = {
  //First point here move to the left (subtract) to create the left facing point
  swapGraphicSquare.xStart - triangleBoundaries,
  swapGraphicSquare.yStart + (swapGraphicSquare.height / 2),
  //Second point
  swapGraphicSquare.xStart,
  swapGraphicSquare.yStart - triangleBoundaries / 2,
  //Third point
  swapGraphicSquare.xStart,
  swapGraphicSquare.yStart + triangleBoundaries / 2
};

triangle rightPointTriangle = {
  //First point here move to the right (add) to create the right facing point
  swapGraphicSquare.xStart + swapGraphicSquare.width + triangleBoundaries,
  swapGraphicSquare.yStart + (swapGraphicSquare.height / 2),
  //Second point
  swapGraphicSquare.xStart + swapGraphicSquare.width,
  swapGraphicSquare.yStart - triangleBoundaries / 2,
  //Third point
  swapGraphicSquare.xStart + swapGraphicSquare.width,
  swapGraphicSquare.yStart + triangleBoundaries / 2
}

...
//To make our life easier we add a drawFillTriangle function
//We can just pass our struct and it will draw the rest
void drawFillTriangle(triangle toDraw){
    tft.fillTriangle(
    toDraw.point1X,
    toDraw.point1Y,
    toDraw.point2X,
    toDraw.point2Y,
    toDraw.point3X,
    toDraw.point3Y,
    toDraw.color
  );
}

//Now we draw the triangles and the center square on our screen
void initializeRadio(){
  ...
   drawFillSquare(swapGraphicSquare);
   drawFillTriangle(rightPointTriangle);
   drawFillTriangle(leftPointTriangle);
}

When we upload this code to our board nothing will be displayed. The final step we haven’t added yet is calling the initializeRadio function from our setup block. It may seem trivial but after uploading this last adjustment our screen comes to life.

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

Text and data

We’ve finally got all the graphical elements in place. Time to display some actual information that we can use. Our first step will be to include the Bits and Droids library and create an object. The data handling function will be the first code we add to our loop function. This function ensures that when we receive data from our connector and we store it in the appropriate place. To display text we need to add the font file as well.

...
#include <BitsAndDroidsFlightConnector.h>
#include "Fonts.h"
BitsAndDroidsFlightConnector connector(false);
...
void loop(){
  connector.dataHandling();
}

When we display text there are a few things to keep in mind. First of all, how does our screen display text? It prints every letter at the desired location. In an ideal world going from 0 – 2, this would look like this in each frame.

But we draw something we don’t erase something so the result would look like this.

It will just draw the character on top of the previous character resulting in a graphical mess. To avoid this we need to draw the character while we also redraw the space around the character with the same color as the background. The TFT_eSPI library has a function for this called drawString() and drawFloat(). It won’t just display the text it also takes care of the background.

The biggest star of our show will be the comm 1 active and standby data. So why not start with that. We want a big readable font with some bold characters. As a placeholder, we’re going to put “124.850” in both places.

We want to draw the frequency on the same spot every time so we might as well create a function for that as well. We’ve got a frequency on the left and right but ideally, we want one function to keep things tidy that takes a parameter to determine where to draw the characters.

  • Create a function that takes a parameter to determine where to draw the text and the text itself as a float
  • Set the font to Sans Serif, Bold, size 24
  • Set the text color to white and the background to black
  • Note that the com is stored as an int(124850) so we have to divide by a 1000 to get 124.850
void setCom(byte place,float text){
  tft.setFreeFont(FSSB24);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);

  byte yText = greySquare.yStart + (greySquare.height/2) - 16;
  
  if(place == 0){
    tft.drawFloat(text / 1000,3,xMargin+15,yText);
  } else{
    tft.drawFloat(text/ 1000,3,swapButton.xStart + swapButton.width+15,yText);
  }
}

If you want to practice this concept I’d suggest adding a header in the top left corner that displays the current screen. I went with a header that says “COM 1”.

If you get stuck just take a sneak-peek at the final code.

We already loop through our buttons array to draw each button. If we hijack this loop to draw the text inside the buttons we avoid having to implement a second loop. Our buttons are stored in order, to keep things easy we will create a new array that holds the text for our buttons.

  • Create an array to store the button texts. Go with a small data size if possible.
char buttonTextArray[10] = {'1','2','3','4','5','6','7','8','9','0'};
  • Add the text on top of the buttons in the same loop where we draw the buttons
for (int i = 0; i < sizeof(buttonArray) / sizeof(buttonArray[0]); i++){
    drawFillRoundRect(buttonArray[i]);
    tft.drawString((String)buttonTextArray[i], buttonArray[i].xStart + buttonArray[i].width / 2-6, buttonArray[i].yStart + buttonArray[i].height / 2 - 9);
  }

Radio mode on

The initialization of our radio elements is something we want to perform only once. There is no need to draw every element over and over again. To save our ESP32 from a gruesome job we add a function that we’ll call the radio mode. If the radio mode is active we will initialize the radio screen once and display the data if new data came in. This modular approach lets us easily swap screens at a later point. Now we add a radio mode, next time add a fuel gauge mode, and so on. By doing this we only have to implement the logic that swaps modes and we’re set.

  • Create a function for our radio mode
  • Only initialize the radio when we haven’t drawn the elements yet
  • Update the variables only if they changed (remember we already have a function to draw our frequencies)
...
//bool that will later tell us if the radio mode is already present on screen
bool radioDrawn = false;
//This array gives us something to check against.
//oldComs[0] will be the active com and oldComs[1] will be the standby com
float oldComs[2];

void radioMode(){
  //here we use that same boolean that triggers to true only on the first loop
  //The second time radioDrawn will be true and this part will be skipped.
  if(!radioDrawn){
    initializeRadio();
    radioDrawn = true;
  }

  float com1Active = connector.getActiveCom1();
  float com1Standby = connector.getStandbyCom1();
  if(com1Active != oldComs[0]){
    setCom(0,com1Active);
    oldComs[0] = com1Active;
  }
  if(com1Standby != oldComs[1]){
    setCom(1,com1Standby);
    oldComs[1] = com1Standby;
  }
}

The final code

If you made it this far, hats of to you. Don’t forget to add your personal flair to the end product to really make it a piece of art you’ll love. Some of these concepts can be streamlined even further but for the sake of this guide, I wanted to keep things comprehensible. If you’ve got any suggestions or spot any mistakes do let me know in the comments down below!

#include <SPI.h>
#include <TFT_eSPI.h>
#include <BitsAndDroidsFlightConnector.h>
#include "Free_Fonts.h"
//Here we create the TFT object. 
//The tft object is of the type TFT_eSPI. 
//By referencing this variable we can perform actions (excecute functions) with our TFT screen

TFT_eSPI tft = TFT_eSPI();
BitsAndDroidsFlightConnector connector(false);

enum MODES{
  RADIO,
  GPS
};
byte mode = 0;
bool radioDrawn = false;
float oldComs[2];

int16_t mainBgColor = TFT_BLACK;

//We can replace the fillRect function in the initializeRadio function with the following line

int margin = 20;
int xMargin = 30;
int screenWidth = tft.getViewportHeight();

//Here we've created our struct

struct square{
  int xStart;
  int yStart;
  int width;
  int height;
  int16_t color;
};

struct roundedSquare {
  int xStart;
  int yStart;
  int width;
  int height;
  byte cornerRadius;
  int16_t color;
}; 

struct triangle{
  int point1X;
  int point1Y;
  int point2X;
  int point2Y;
  int point3X;
  int point3Y;
  int16_t color;
};

void drawFillSquare(square toDraw){
  tft.fillRect(toDraw.xStart, toDraw.yStart, toDraw.width, toDraw.height, toDraw.color);
}

void drawFillTriangle(triangle toDraw){
    tft.fillTriangle(
    toDraw.point1X,
    toDraw.point1Y,
    toDraw.point2X,
    toDraw.point2Y,
    toDraw.point3X,
    toDraw.point3Y,
    toDraw.color
  );
}

void drawFillRoundRect(roundedSquare toDraw){
  tft.fillRoundRect(
       toDraw.xStart,
       toDraw.yStart,
       toDraw.width,
       toDraw.height,
       toDraw.cornerRadius,
       toDraw.color
  );
}

roundedSquare buttonArray[10] = {{0}};
char buttonTextArray[10] = {'1','2','3','4','5','6','7','8','9','0'};
square greySquare = {
  xMargin,
  margin,
  screenWidth - margin * 2,
  150,
  TFT_SILVER
};

byte frameWidth = 4;
//The greySquare will be our reference point for almost all the inner values.
square innerSquare = {
  greySquare.xStart + frameWidth,
  greySquare.yStart + frameWidth,
  greySquare.width - frameWidth * 2,
  greySquare.height - frameWidth * 2,
  mainBgColor  
};

square comTextSquare = {
  xMargin,
  margin,
  90,
  30,
  TFT_BLACK
};

byte swapWidth = 40;
byte swapHeight = 25;

roundedSquare swapButton = {
  innerSquare.xStart + (innerSquare.width / 2) - (swapWidth / 2),
  innerSquare.yStart + (innerSquare.height / 2) - (swapHeight / 2),
  swapWidth,
  swapHeight,
  4,
  0xF693
};

byte swapGraphSquareWidth = 10;
byte swapGraphSquareHeight = 7;

square swapGraphicSquare = {
  swapButton.xStart + (swapButton.width / 2) - (swapGraphSquareWidth / 2),
  swapButton.yStart + (swapButton.height / 2) - (swapGraphSquareHeight / 2),
  swapGraphSquareWidth,
  swapGraphSquareHeight,
  TFT_BLACK
};

//This wil ensure our triangle has equal sides
byte triangleBoundaries = 14; 

triangle leftPointTriangle = {
  //First point here move to the left (subtract) to create the left facing point
  swapGraphicSquare.xStart - triangleBoundaries,
  swapGraphicSquare.yStart + (swapGraphicSquare.height / 2),
  //Second point
  swapGraphicSquare.xStart,
  swapGraphicSquare.yStart+swapGraphicSquare.height/2 - triangleBoundaries / 2,
  //Third point
  swapGraphicSquare.xStart,
  swapGraphicSquare.yStart+swapGraphicSquare.height/2 + triangleBoundaries / 2
};

triangle rightPointTriangle = {
  //First point here move to the right (add) to create the right facing point
  swapGraphicSquare.xStart + swapGraphicSquare.width + triangleBoundaries,
  swapGraphicSquare.yStart + (swapGraphicSquare.height / 2),
  //Second point
  swapGraphicSquare.xStart + swapGraphicSquare.width,
  swapGraphicSquare.yStart+swapGraphicSquare.height/2 - triangleBoundaries / 2,
  //Third point
  swapGraphicSquare.xStart + swapGraphicSquare.width,
  swapGraphicSquare.yStart+swapGraphicSquare.height/2 + triangleBoundaries / 2
};

void addButtons(){
  for(int i = 0; i < 5; i++){
    
    //These values are shared accross each button
    int height = 40;
    int width = 40;
    int16_t color = 0xF693;
    byte cornerRadius = 4;

    //Because each column has the same X starting coordinate
    //We can create a new button for the top and bottom row everytime we loop throught this function
    //How higher the i (amnt of loops) is the more squares have been added already
    //By knowing the amount we then know the new starting position 
    //by multiplying i by the width + margins in between buttons
    roundedSquare newButtonTop = {
      xMargin +((width * i) + (margin * i)),
      greySquare.yStart + greySquare.height + margin,
      width,
      height,
      cornerRadius,
      color
      
    };
    //Most values are the same as the top row except for the starting Y
    //For this we take the start of Y from the first button and add the height + margin
    roundedSquare newButtonBottom = {
      xMargin +((width * i) + (margin * i)),
      newButtonTop.yStart + height + margin,
      width,
      height,
      cornerRadius,
      color
    };
    
    //We store these values in our array
    //I want them to be organized, but in our loop we've added the button that will hold 1 and 6
    //Because we are maintaining the same patern we can add the second button at i + 5
    //In the end this will ensure that our array holds 1,2...9,0 in order
    buttonArray[i] = newButtonTop;
    buttonArray[i+5] = newButtonBottom;
  }
}

void setCom(byte place,float text){
  tft.setFreeFont(FSSB24);
  tft.setTextColor(TFT_WHITE, TFT_BLACK);

  byte yText = greySquare.yStart + (greySquare.height/2) - 16;
  
  if(place == 0){
    tft.drawFloat(text / 1000,3,xMargin+15,yText);
  } else{
    tft.drawFloat(text/ 1000,3,swapButton.xStart + swapButton.width+15,yText);
  }
}

void setHeader(char* header){
  tft.setFreeFont(FSSB12);
  tft.setTextColor(TFT_WHITE, TFT_BLACK); 
  tft.drawString(header , xMargin, margin);
}

void initializeRadio(){
  //this looks way cleaner, especially if we add more squares
  addButtons();

  tft.fillScreen(TFT_BLACK);
  drawFillSquare(greySquare);
  drawFillSquare(innerSquare);
  drawFillRoundRect(swapButton);
  drawFillSquare(swapGraphicSquare);
  drawFillTriangle(rightPointTriangle);
  drawFillTriangle(leftPointTriangle);
  drawFillSquare(comTextSquare);

  setHeader("COM 1");
  tft.setFreeFont(FSSB12);
  tft.setTextColor(TFT_BLACK, buttonArray[0].color);
 
  for (int i = 0; i < sizeof(buttonArray) / sizeof(buttonArray[0]); i++){
    drawFillRoundRect(buttonArray[i]);
    tft.drawString((String)buttonTextArray[i], buttonArray[i].xStart + buttonArray[i].width / 2-6, buttonArray[i].yStart + buttonArray[i].height / 2 - 9);
  }


  
  setCom(0,124850);
  setCom(1,124850);
}

void radioMode(){
  if(!radioDrawn){
    initializeRadio();
    radioDrawn = true;
  }
  float com1Active = connector.getActiveCom1();
  float com1Standby = connector.getStandbyCom1();
  if(com1Active != oldComs[0]){
    setCom(0,com1Active);
    oldComs[0] = com1Active;
  }
  if(com1Standby != oldComs[1]){
    setCom(1,com1Standby);
    oldComs[1] = com1Standby;
  }
}

void setup(){
 Serial.begin(115200);
  mode = RADIO;
 tft.setRotation(1);
 tft.init();

 initializeRadio();
}

void loop(){
  connector.dataHandling();
  if(mode == RADIO){
    radioMode();
  }
}

You may also like

Leave a Comment

This website uses cookies to improve your experience. We'll assume you're ok with this, but you can opt-out if you wish. Accept