Bits and Droids logo
Mail icon

Creating our first ESP32 flight display graphics

3/30/2024 | by Bits and Droids

In our last article, we focused on the wiring and underlying protocols of our custom ESP32 primary flight display. It's time to start bringing this contraption to life using code. We will focus on displaying our first graphics, layer shapes, and 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 download the ZIP file directly from their GitHub pages linked above or through the library manager within the Arduino IDE.

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. However, we will start with a COM 1 radio screen to fully understand the basics. 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 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 interrupted by text with a black background
  • The 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. In portrait mode, our screen is 480 pixels high by 320 pixels wide (a total of 153.600 pixels). We need to flip these values around since we will use the screen horizontally.

If we want to draw a black background, we must 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. The TFT_eSPI divides our pixels into a grid to make this process easier. An X-axis marks the horizontal axis (the columns), while a Y-axis marks the rows. Because we use the screen horizontally, the top left corner has the coordinates X0 and Y0. The bottom-right coordinates will then be? You guessed it correctly: X480, Y320. This grid system will be the foundation of our graphics. Did you want to draw something in the middle? Divide the maximum height and width by 2, and you'll get the middle coordinates.

Setting up the TFT_eSPI library

Defining user preferences

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.

We first need to assess 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, the TFT_eSSPI library handles these differences. We 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.

cpp
//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

Scrolling down further, we find a block with 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 on all these lines.

  • comment out all three #defines until it looks like the example below (add the //).
cpp
// 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 until we encounter the following line:

cpp
// ###### 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 connect our TFT screen to our ESP32. In the previous article, we explored the wiring deeply. If you want to read up on the subject, I've enclosed the wiring diagram in the image below to make things easier.

esp32 lcd wiring diagram

We aim to match the setup block with the pins we've used on our board. Luckily, we've already used the mandatory pins (the dedicated SPI pins) recommended by the library. All we have to do is comment out the first six #defines.

cpp
// ###### 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 until we encounter the line defining 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.
cpp
#define TOUCH_CS 21 // Chip select pin (T_CS) of touch screen

Last and important!

  • Save your file (hit ctrl+s) to ensure 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, we need to include the TFT_eSPI library and initiate a TFT object.

  • Include the TFT_eSPI library
  • Create a TFT object
cpp
//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 uses the SPI interface. The SPI interface on our ESP32 needs to be manually enabled by including this header file; otherwise, both components can't communicate.

To enable the screen, we need to initialize the display. We only want to initialize the screen once we add this code to the setup block, which is only executed when our board boots (or when it is 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)
cpp
//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. 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 for green, and 8 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 to create almost all colors (16 777 216).

The 16-bit approach takes a slightly different method. It reserves 5 bits for red, 6 for green, and 5 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 can also find all the available functions 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. Studying libraries' code could also teach you a trick or two (it has taught me a ton).

If you want to create your custom colors, I recommend using an RGB565 color picker. You can find one on the site of barth-dev (RGB565 Color Picker—Barth Development (barth-dev.de)). You need 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 using the color TFT_BLACK. TFT_BLACK is like an alias; the library is made for the color black. We could also pass the color hex value directly (0x0000). We might want to change our background color later, 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
cpp
... //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 will 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, it will only draw a one-pixel-wide square. If we want a wider rectangle, we could draw multiple rectangles inside 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 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 screen of a different size. We will use basic geometry and math to create a "responsive" design. With responsive, we usually refer to an element that scales with our screen size. 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 to 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. We can calculate the width by taking the screen width and subtracting 20 times 2 to make it responsive.

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 0,0). Instead of calculating this starting point each time, we save the margin as a variable and pass it directly.

  • Add a variable for the margins (I had to offset the margin by +10 due to the case bezel)
  • Add a variable for the screen width
cpp
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 a diet class.

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

This allows us to create a square variable where each setting can be retrieved individually. Let's first focus on creating our grey square.

cpp
... 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 using a new function we will call initializeRadio. We can call this function each time we want to draw the radio screen.

cpp
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 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 just to pass the struct directly. This will save us from messy code later on.

cpp
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 one place to define our parameters (where we initialize the greySquare variable). This makes it simple to alter any values we need without digging 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. We use the same structural 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, and the X and Y coordinates can be calculated by adding the desired frame width(4). For the width and height, we take the greySquare width/height and subtract the width times 2.

cpp
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; the width will be altered everywhere.

Adding the ATC buttons

For our 10 ATC buttons, we won't be using the fillRect function. We want 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 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. Drawing this out could give us more insight into our formula.

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 will store each button in an array. This way, we can loop through the array 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 need to create a new struct since we want 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 simultaneously (remember they share the x per column and y per row).
  • Store these buttons in the array.
cpp
//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; } }

We can use another loop in the initializeRadio function we created to draw these squares. Like normal squares, we want to create a drawing function that considers our struct a parameter.

  • Create a function that draws a rounded rectangle and takes your struct as a parameter.
  • Loop through your array and draw each button
cpp
... 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 repeating ourselves. Want a bigger button? No worries—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 math skills to determine where our buttons must be placed.

We could calculate the middle of the screen by dividing the total by two, but for 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. 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 farmost right X-coordinate will be innerSquare.Start + innerSquare.width. To calculate the middle, we could divide the width by two and add the result to the innerSquare X start. If we use this as the 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.

We just offset the X starting coordinate with the button to achieve this.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)

We do the same for the Y-axis 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 swap button
  • Center the button in the middle of the inner black square
  • Draw the button with the function we've created earlier
cpp
... 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 coordinate sets (X and Y coordinates). By changing the coordinates, each point can be moved. We could also create a schematic for this.

Coordinates schematic

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

cpp
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 structure for our triangles, so let us add that as well. 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 reference point
  • Add a function that draws the triangle
  • Draw all the shapes with the functions you've created
cpp
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 is calling the initializeRadio function from our setup block. It may seem trivial, but our screen comes to life after uploading this last adjustment.

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

Text and data

We've finally got all the graphical elements in place. It's 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, we store it in the appropriate place. To display text, we need to add the font file as well.

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

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

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 also redrawing 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 will put "124.850" in both places.

We want to draw the frequency in the same spot every time, so we might as well create a function for that as well. We've got frequencies 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 1000 to get 124.850
cpp
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 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 take a sneak-peek at the final code.

We already loop through our button 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, so 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.
cpp
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.
cpp
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. We add a function called the radio mode to save our ESP32 from a gruesome job. If the radio mode is active, we will initialize the radio screen once and display the data if new data comes 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 change (remember we already have a function to draw our frequencies)
cpp
... //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've made it this far, congratulations! Remember 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 below!

cpp
#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(); } }