Fig.1. The iron robot final result
Important informations
Source and CAD model :
https://github.com/intellar/Dual_Display_ESP32
Demo videos:
The esp32 is a very capable microcontroller that can drive multiple devices simultaneously at a good frequency, like displays and sensors. In this project, I connect two spi tft display to the esp32-s3, along with a ToF sensor that allow the esp32 to "see" its surrounding.
Ther first section of the blog describe how to connect the display and run the first example of two animated eyes. The second section is about interaction. I add the ToF and show how to recover the data, while maintaining the update of the displays.
so without more introduction, lets jump in the rendering and animations.
The core of the system is a simple animation trick. The esp32 loads a 350x350 image from the flash memory. This image is larger than the display so only a part of the image can be rendered at any single time in full resolution, making it like looking through a circular tube window over the full image. By moving the window, we can create a virtual movement of the eye in the window. So actually, the eye is never moving, it's the window that moves in the opposite direction.
In the code, I made a special movement of the window that mimics how a real eye moves, in saccade, It move quickly in a direction and then slow down as it approach the wanted gaze position. This is described in the next section.
To make the eyes appear more natural and alive, I've implemented a behavior that mimics a biological phenomenon known as saccadic eye movement. In humans and animals, saccades are the extremely rapid, voluntary or involuntary jumps the eyes make between two points of fixation. Think about how your eyes move when you read a line of text or scan a room—those quick, darting motions are saccades.
In this project, the saccade behavior serves as the "idle" or "daydreaming" state for the eyes. It activates whenever the Time-of-Flight (ToF) sensor does not detect a person or object to focus on. This prevents the eyes from looking static and robotic, giving them a more organic and convincing presence.
How It Works
The saccade logic is governed by a simple yet effective set of rules:
Activation Condition: The idle saccade behavior begins only when the ToF sensor has not detected a valid target for a specific period (defined as SACCADE_DELAY_AFTER_TRACK_MS). This creates a smooth and natural transition from actively tracking an object to the idle, random-gazing state.
Timed Intervals: Once in the idle state, the system generates a new random target for the eyes to look at in regular intervals (defined as SACCADE_INTERVAL_MS). Every few moments, the eyes will dart to a new, randomly chosen point within their field of view.
Generating a New Target: A new target position is calculated by generating random horizontal (x) and vertical (y) coordinates. These coordinates are normalized between -1.0 and 1.0, representing the full extent of the eye's possible movement (e.g., far left to far right, and top to bottom).
Smooth, Interpolated Movement: Instead of instantly snapping to the new random position, the eye's movement is smoothed using Linear Interpolation (LERP). The eye's current position is gradually moved towards the new target position in each frame. This creates a fluid, accelerated motion that closely resembles the ballistic, yet controlled, nature of a biological saccade, rather than an abrupt, mechanical jump.
By combining these timed triggers, random targets, and smoothed animation, the eyes exhibit a convincing and continuous illusion of life, even when they have nothing specific to look at
To achieve a fluid and natural motion for the eyes, we use a common animation technique called Linear Interpolation, or LERP. The goal is to move the eye from its current position to a new target position smoothly over several frames, rather than having it instantly "snap" to the new spot, which would look robotic and unnatural.
How It Works in the Code
In each frame of the main loop, the code calculates the next small step the eye should take to get closer to its destination. This is done with a simple but powerful formula:
cpp
// From main.cpp
eyes[i].x += (final_target_x - eyes[i].x) * LERP_SPEED;
eyes[i].y += (final_target_y - eyes[i].y) * LERP_SPEED;
Let's break down this calculation for the horizontal (x) position:
(final_target_x - eyes[i].x): First, the code calculates the total distance between the target position (final_target_x) and the eye's current position (eyes[i].x).
* LERP_SPEED: It then takes a small fraction of that total distance. The LERP_SPEED is a constant value (e.g., 0.1, which is 10%) that determines how "fast" the eye moves towards its target. A larger value results in quicker, more abrupt movement, while a smaller value creates a slower, smoother glide.
eyes[i].x += ...: Finally, this small, calculated step is added to the eye's current position.
This process repeats for both the x and y coordinates in every single frame.
The Result: Natural Easing
The beauty of this method is that it creates an automatic "easing" effect. When the eye is far from its target, the distance is large, so the step it takes is also large, causing it to move quickly at the start. As it gets closer to the target, the distance decreases, making each subsequent step smaller and smaller. This causes the eye to naturally and smoothly slow down as it approaches its final destination, perfectly mimicking the way organic eyes focus on a new point of interest.
The hardware for the project is straightforward. You need a fast esp32 dev board. I used the esp32-s3, with N16R8 (16mb of flash and 8 mb of psram) to have enough space to store multiple eye images (Fig. 2C). You will also need a lcd display, here I used the 1.28" lcd running the GC9A01 IC driver (Fig. 2B). It is supported by the TFT_eSPI library and has a good speed. To make an emphasis on the eyes and to give more presence, I added a plano convex lens over the display (Fig. 2A). A 37mm diam lens is used to cover slightly more than the display, to avoid border effect. You can see the hardware on the next figure:
Fig. 2A Acrylic Plano-convex Lens Diam 37mm
Fig. 2B 1.28 Inch (32mm) TFT LCD Display Module Round RGB 240*240 GC9A01
Fig. 2C ESP32-S3 N16R8.
In this section I give a quick description of the wiring between the displays and the ESP32. There are two displays to connect, and most of the pins of on displays share the same pins on the ESP32. The SPI lines (SCL,SDA), the chip data/command (DC) and reset (RST) are shared. Both screen are reset at the same time. But the CS pins must be separated because we might not necessarily draw the same image on both display. In the code, we need to switch the active display through manual pin assignement, it is not supported directly by the espi library. The following table resume the pins connections :
Fig.3 Wiring diagram following the pinout in Table 1. The pins dc, rst, sda, scl are shared by the display. Chip select (CS) must be adressed separatly by the controller to communicate with the right screen.
To connect the devices, I went with old school wirewrapping wire. Wire wrap is a classic method used in electronics to build circuits without soldering. It involves wrapping 30 AWG wire around square metal posts (typically on IC sockets or headers), creating a reliable connection. it's much better than jumper wire especially for fast spi communication. . The wire is stripped at the end and then wrapped using a specialized tool looking like a screwdriver. You can see it in the video in Fig. 4A. Other examples in Fig.4 B and a close up of the result in Fig. 4C.
Fig. 4B You literally wrap/screw the wire on the pin header.
Fig. 4C The result is a solid and reliable link between the headers.
The final setup is shown in Fig.5A and Fig. 5B. Wire wrap does not give the clean result of a pcb but it does the job to validate the concept of this project. Next section is about the software.
Fig. 5A Front view of the setup. this is a proof that wirewrap is rarely clean.
Fig. 5B Back view of the setup, The pins shared are easy to spot. Wirewrap make this sharing very straightforward, when compared to jumper wire.
The project is built using PlatformIO, which handles the toolchain and library management. It relies on a few key libraries:
Framework: arduino
Libraries:
bodmer/TFT_eSPI: A powerful library for display control and rendering.
sparkfun/SparkFun VL53L5CX Arduino Library: Used to interface with the 8x8 Time-of-Flight distance sensor.
LittleFS: Used to store and retrieve image assets from the ESP32's flash memory.
PlatformIO will automatically install these libraries when you build the project.
To get the project running on your own hardware, follow these steps:
Clone the Project To download the project to your local machine, open a terminal and run:
git clone https://github.com/intellar/Dual_Display_ESP32
cd Dual_Display_ESP32
Configure Hardware Pins Open src/config.h and adjust the pin definitions to match your hardware wiring:
PIN_CS1 and PIN_CS2: Set these to the chip-select pins for your left and right displays.
PIN_TOF_SCL and PIN_TOF_SDA: Set these to the I2C pins connected to your VL53L5CX sensor.
Prepare Image Assets The eye textures are stored as 350x350 raw 16-bit RGB565 (.bin) files. A Python-based tool is provided in the image_tools folder to help you convert your own images into this format.
Upload Filesystem to ESP32 Place your generated .bin files into the data folder at the root of the project. Then, use the PlatformIO "Upload Filesystem Image" task to transfer the assets to the ESP32's LittleFS partition.
Compile and Upload Using PlatformIO, compile and upload the project to your ESP32. The build configuration in platformio.ini is already set up for the specified display driver and pinout.
Note on Configuration: All display settings (like GC9A01_DRIVER, TFT_WIDTH, TFT_MOSI, etc.) are defined as build flags in the platformio.ini file. You do not need to edit the User_Setup.h file from the TFT_eSPI library, as these flags will override it.
You can easily create your own eye designs using the provided conversion tool.
Tool Location: image_tools/gui-image-tools.py
Requirements: Python and the Pillow library (pip install Pillow).
Usage:
Run the script: python image_tools/gui-image-tools.py.
Load your source image (e.g., a PNG or BMP).
Ensure the image dimensions match what is defined in config.h (e.g., 350x350).
Click "Generate Binary File" and save the output (.bin) into the data folder of your project.
Make sure the filename matches one of the paths defined in config.h (e.g., EYE_IMAGE_NORMAL_PATH, which defaults to "/image_giant.bin").
The config.h file is the central control panel for customizing the project's behavior without digging into the main source code. Here you can adjust:
Hardware Pinout: Define chip-select and sensor pins.
Display & Image Settings: Set image dimensions and the transparent color key.
Asset Paths: Specify the filenames for the eye texture assets stored in LittleFS.
Animation Behavior: Fine-tune the eye's movement range (MAX_2D_OFFSET_PIXELS), interpolation speed (LERP_SPEED), and the timing of the idle saccade movements.
Sensor Behavior: Enable/disable the ToF sensor, activate calibration mode, and set the maximum tracking distance.
This modular configuration makes it easy to adapt the project to different hardware setups or to experiment with new animation styles.
To achieve a smooth frame rate, especially on a microcontroller, I can't just redraw things directly to the screen. Doing so would cause noticeable flickering as different parts of the image are drawn one by one. Instead, I use a professional graphics technique called double buffering (or more generally, off-screen rendering).
The entire drawing process for each frame is broken down into four distinct steps:
Clear Buffers: Before drawing anything new, I completely clear two off-screen memory blocks, called framebuffers, to a solid black color. Each framebuffer is a complete 240x240 pixel image stored in the ESP32's PSRAM, one for each eye. All drawing happens on these hidden buffers first.
Draw Eye Images: I then render the main eye image onto each framebuffer. This is the most performance-intensive step, and it's heavily optimized. Instead of naively drawing a square image, my draw_eye_image function:
Uses Pre-Calculated Scanlines: The screens are circular. To avoid complex calculations in every frame, I pre-calculate the start and end points of every horizontal line that fits within the circular display area during startup. When drawing, the code only renders pixels within this pre-calculated visible area, saving a huge amount of processing time.
Handles Transparency: The eye image asset has parts that should be transparent (the corners of the square image). I defined a specific color (0x0000, or pure black) to act as a "transparent color key." When the drawing function encounters a pixel of this color in the source image, it simply skips it, leaving the background of the framebuffer untouched.
Clips to Eyelids: The function also includes logic for eyelid movement (though not fully implemented yet), which would allow it to skip drawing the top and bottom portions of the eye image to simulate blinking or squinting.
Draw Overlays: After the main eye is drawn, I render any additional information on top. This includes the FPS counter and the 8x8 ToF sensor debug grid, which are drawn only on one of the screens.
Push to Displays: Only when both framebuffers are complete with the final image for the frame do I push them to the physical screens. The display_all_buffers() function sends the entire 240x240 image from each framebuffer to its corresponding display in a single, high-speed operation.
This entire process ensures that the user only ever sees a complete, finished frame, resulting in a smooth, flicker-free animation.
To make the eye's state immediately obvious, I don't just rely on its movement. I use two different eye textures to visually communicate whether it is idly "daydreaming" or actively tracking an object.
Normal Eye (image_giant.bin): This is the default texture. It's used whenever the ToF sensor does not have a valid target. This texture represents the eye in its relaxed, idle state, which is paired with the random saccade movement.
"Bad" or Tracking Eye (image_giant_bad.bin): I switch to this alternate texture the moment the ToF sensor locks onto a valid target. This texture could be designed to look more focused, intense, or even a different color (like a "hostile" red eye). This provides instant visual feedback that the eye has "seen" something and is now in tracking mode.
In the main loop, I check the target.is_valid flag from the ToF sensor.
If true, I tell the drawing function to use the EYE_IMAGE_BAD texture.
If false, I tell it to use the EYE_IMAGE_NORMAL texture.
This simple change in assets makes the eye's behavior much more expressive and easier to understand at a glance.
Achieving a smooth, flicker-free animation on a microcontroller requires more than just drawing an image to the screen. I've implemented a pipeline of several key optimizations that work together to maximize performance and ensure the eye movement looks fluid and realistic.
1. Double Buffering with PSRAM
The most fundamental optimization is the use of double buffering. Instead of drawing directly to the physical screens, which would cause visible tearing and flickering as the image is built piece by piece, all rendering happens in the background on two off-screen canvases called framebuffers.
How it works: For each frame, I first draw the complete eye image for the left eye into one framebuffer and the right eye into another. Only when both images are 100% complete do I push them to the physical displays in a single, high-speed operation. The user only ever sees a finished frame.
PSRAM is Key: A single 240x240 16-bit color framebuffer requires 115,200 bytes of memory. With two framebuffers, plus the large eye image assets, this would quickly exhaust the ESP32's limited internal RAM. To solve this, I allocate all these large buffers in the external PSRAM using ps_malloc. This frees up the precious and fast internal RAM for the main program logic, preventing crashes and slowdowns.
2. Pre-Calculated Scanlines for Circular Clipping
The displays are circular, but the eye images are square. Drawing a square image and then "clipping" it to a circle in every frame would be computationally expensive, likely involving a sqrt() calculation for every pixel to check its distance from the center.
To eliminate this runtime cost, I use a pre-calculation strategy. During the setup() function, I run precalculate_scanlines() once. This function iterates through all 240 vertical lines of the screen and, for each one, calculates the exact start and end X-coordinates that fall within the circular display area.
The Result: These 240 "scanlines" are stored in an array. When it's time to draw the eye in the main loop, my draw_eye_image function doesn't need to do any math. It simply looks up the pre-calculated start and end points for the current line and only draws pixels between them. This turns a complex geometry problem into a simple, lightning-fast array lookup.
3. A Highly Optimized Drawing Loop (draw_eye_image)
The draw_eye_image function is where the most critical, pixel-by-pixel work happens. This function is optimized down to the metal.
Pointer Arithmetic: Instead of calculating the memory address for each pixel with (y * width) + x inside the tightest loop, I calculate a pointer to the beginning of each horizontal line in the source image and the destination framebuffer just once per line. The inner loop then just increments these pointers, which is significantly faster.
Inlined drawPixel: Calling a function for every single pixel (over 50,000 of them per frame) adds a lot of overhead. I avoid this by "inlining" the logic. Instead of calling a generic drawPixel() function, I write the color data directly into the framebuffer memory: framebuffer_line[dest_x] = ....
Efficient Transparency: The eye image assets use pure black (0x0000) as a transparent color. The drawing loop checks for this specific color and, if it finds it, simply continues to the next pixel without writing anything. This is far more efficient than full alpha blending, which would require reading the destination pixel and performing a mathematical blend for every single transparent pixel.
4. Fast Buffer Clearing
Even clearing the screen can be optimized. My clear_buffer function has a special fast path. When clearing to a grayscale color (like black, where the high and low bytes of the 16-bit color value are identical), it uses the standard C function memset(). This function is written in highly optimized assembly language and is dramatically faster at filling a block of memory with a single byte value than a software for loop would be.
By combining these techniques—off-screen rendering, smart memory management, pre-calculation, and low-level loop optimizations—I can ensure the processor spends its time on what matters most: calculating the eye's position and getting the final image to the screen as quickly as possible.
Two animated eyes is nice, but this project is more involving than that. We need to add a way to interact with the eyes. And by interacting, I don't mean to have a keyboard or a bluetooth smartphone interface. I want the system to be able to detect and look at the person interacting with it. So I added a ToF sensor, VL53L5CX, from the hand-eye motion project.
As the complexity was increasing, I needed to create a support for the electronics. I sketched some idea, and then, to have another view of the problem, I asked two AI, copilot gpt-5 and gemini 2.5flash, what this support could look like. Both were given the same prompt, and the same pictures of the raw setup.
Copilot cam back with a cartoon like render (Fig.6a) , with a strange way if integrating the esp32 in the support. Gemini made a more realistic suggestion, with a nice integration of all components, Fig.6b. Personnaly I find the proposition from gemini more interesting. Still, it was kind of boring. I made a, according to me, much better support by designing the iron robot.
Fig.6a Copilot smart (gpt-5)
Fig.6b Gemini flash 2.5
The iron robot head is a very simple shape to create in freecad. It consist of a cylinder and an sphere, with pocket for the eyes. We working with cad software, constructing the shape is a like a puzzle to solve. The head design is available on the github repo here
https://github.com/intellar/Dual_Display_ESP32
So to draw the head, yes, you can draw a cylinder and half a sphere. but you won't exploit the full potential of cad software. In the case of the iron robot, I use a simple shape that will then create the complete solid with a 360 revolution. A much better solution is 180deg revolution of the basis shape with a mirror plane to avoid making two eyes and all their details to hold the display. Note that I am not a mechanical designer, my academic formation is on computer engineering with a phd in 3d computer vision. I am learning cad design through these projects.
I wont go through all the details of the head design, this is not a freecad tutorial. At the end, I designed the head, with support for the tft display, the nose hole that also is the support for the ToF sensor. I added a jaw with the bolts, and a base plate to close and hold the esp32 s3. The parts were 3d printed on my old ender5 printer. The head is way to sturdy with its 4mm thick walls and took 17hours to print. I needs to be reduced to 2mm.
This section describe how to interface the ToF sensor, recover the measurement matrix use it to direct the gaze in the right direction.
To give the eyes the ability to "see" and track objects, I integrated a VL53L5CX Time-of-Flight (ToF) sensor. This sensor works by projecting a wide, invisible infrared laser signal onto the scene in front of it. This signal covers a square 45x45 degree field of view Fig.7a. The sensor then measures the time it takes for the light to bounce off an object and return to its 8x8 grid of collectors. By measuring this "time of flight," it can accurately calculate the distance to objects in each of the 64 zones independently of the object's color or surface texture, Fig.7b.
Unlike a simple distance sensor that measures a single point, this advanced sensor provides a complete 8x8 grid of distance measurements, creating a low-resolution depth map of whatever is in front of it, Fig.7c. This allows me not only to detect if something is present but also to determine its position within the sensor's field of view.
Fig.7a. Field of view of the VL53L5CX ToF sensor.
Fig 7b. View of the 2d infrared signal projected by the ToF sensor.
Fig 7c. the 8x8 collector grid of the ToF sensor
The process begins by getting the latest data from the sensor. In my main loop, I continuously check if the sensor has a new set of measurements ready.
Initialization: In the setup() function, I initialize the sensor, set its resolution to 8x8 (for a total of 64 measurement zones), and command it to start ranging continuously.
Data Check: In the main loop(), I call my update_tof_sensor_data function. Inside this function, I first check myImager.isDataReady(). This prevents the program from trying to read old or incomplete data.
Data Retrieval: Once new data is available, I call myImager.getRangingData(&measurementData). This function populates a structure named measurementData with the latest information, which includes a distance_mm array containing the 64 distance values and a target_status array indicating the validity of each measurement.
A raw 8x8 grid of distances can be noisy. A simple approach, like finding the single pixel with the absolute closest distance, is not robust. It can be easily fooled by a single faulty reading or a small, unimportant object, causing the eye to ignore a larger, more legitimate target.
To solve this, I implemented a more comprehensive algorithm in my process_measurement_data function that evaluates every potential target area to find the most stable one.
Here’s how the improved logic works:
Iterate Through All Potential Targets: Instead of greedily picking the closest pixel, the algorithm iterates through all 64 pixels of the grid, treating each one as a potential center of a target.
Evaluate a 3x3 "Window": For each of these 64 potential centers, it examines the 3x3 "window" of pixels surrounding it.
Find Reliable Pixels: Within each window, it counts the number of "reliable" pixels and calculates their average distance. A pixel is considered reliable only if:
Its status code from the sensor is 5, which means the sensor got a valid reading.
Its measured distance is closer than a predefined maximum (MAX_DIST_TOF, set to 400mm). This filters out the background.
Identify Candidate Regions: To be considered a valid candidate, a window must contain a minimum number of reliable pixels (MIN_RELIABLE_PIXELS_IN_WINDOW, set to 4). This ensures the algorithm is looking at a solid object, not just a few random points of noise.
Select the Best Overall Region: The algorithm compares all valid candidate regions found across the entire grid. It keeps track of the one that has the lowest average distance. This region represents the closest, most solid object detected by the sensor.
Calculate Final Coordinates: If a "best" region is found, the target is marked as valid. The center pixel of that best window is used to calculate the final coordinates. Its (x, y) grid position is converted into a normalized format ranging from -1.0 to 1.0. These normalized coordinates are then fed directly into the eye's movement logic, telling it exactly where to look.
If no window meets the minimum criteria after checking the entire grid, the system concludes that there is no valid target to track, and the eyes revert to their idle "saccade" behavior. This robust method ensures the eyes reliably lock onto a person or object and are not distracted by noisy or irrelevant data.
To ensure the eye-tracking logic is robust and accurate without needing a physical object to be constantly present, I've built a special Calibration Mode. This is a powerful debugging feature that can be enabled in the firmware by setting the TOF_CALIBRATION_MODE flag in the config.h file.
#define TOF_CALIBRATION_MODE 1
When this mode is active, the system completely ignores the real Time-of-Flight (ToF) sensor. Instead, it generates a synthetic 8x8 distance matrix with a predictable, moving target. This allows me to test, calibrate, and debug the entire target-finding and eye-movement pipeline in a controlled and repeatable environment.
How the Simulation Works
The calibration mode is driven by the run_calibration_simulation() function in tof_sensor.cpp. Here’s what it does:
Pre-defined Test Pattern: I created a set of 13 specific coordinates on the 8x8 grid. These points cover the corners, edges, and the center of the sensor's field of view, ensuring the tracking logic is tested across its entire range. It also cover edge case by placing the pattern just outside the matrix.
Timed Movement: Every two seconds (CALIB_INTERVAL_MS), the simulated target automatically moves to the next position in the pre-defined pattern. This creates a predictable path for the eyes to follow.
Synthetic Data Generation: For each frame, the function generates a new measurementData matrix. It creates a clear "object" by setting the distance values:
Target Pixel: A single pixel at the current test position is given a close distance (e.g., 200mm).
Neighboring Pixels: The pixels immediately surrounding the target are given a slightly further distance (e.g., 300mm) to simulate the rounded surface of an object.
Background: All other pixels are set to a far distance (e.g., 1000mm) to represent the background.
Status: All simulated pixels are marked as "valid" to ensure the processing algorithm can see them.
Disabling Idle Behavior: While in calibration mode, the random "saccade" (or daydreaming) eye movement is disabled. This is crucial because it forces the eyes to only react to the simulated target, making it easy to verify that the tracking algorithm is working as expected.
By using this mode, I can confirm that the sliding window algorithm correctly identifies the center of the simulated object and that the eyes smoothly follow it as it moves across the 9 test points. It's an essential tool for fine-tuning the tracking performance and ensuring the system is reliable.
// Define test positions: 9 inside and 4 on the corners to test partial detection
const int calib_positions[NUM_CALIB_POSITIONS][2] = {
// --- Fully visible patterns ---
{1, 1}, {1, 4}, {1, 6}, // Top row
{4, 1}, {4, 4}, {4, 6}, // Middle row
{6, 1}, {6, 4}, {6, 6}, // Bottom row
// --- Partially visible patterns (center of pattern is on the corner pixel) ---
{0, 0}, // Top-left corner (only 2x2 of the 3x3 pattern is visible)
{0, 7}, // Top-right corner
{7, 0}, // Bottom-left corner
{7, 7} // Bottom-right corner
};
The following video shows the calibration mode in action :
I've made a significant enhancement to my development process while creating this iron robot project by transitioning from the traditional Arduino IDE to a more robust and modern setup: Visual Studio Code (VS Code) combined with the PlatformIO IDE extension. This move was driven by the need for better performance and a more feature-rich development environment.
While the Arduino IDE is an excellent tool for beginners and straightforward projects, my needs evolved toward more complex applications, which is where VS Code and PlatformIO truly excel.
Key advantages of this new setup include:
Faster Compilation: PlatformIO's build system features caching, which significantly speeds up compilation times, especially for large projects. This is a huge time-saver during the development and debugging cycles.
Superior IDE Experience: VS Code is a powerful and versatile code editor. When paired with PlatformIO, it offers a professional-grade experience with features like:
IntelliSense: Smart code completion and suggestions that help write code faster and with fewer errors.
Advanced Debugging: The ability to use a debugger to set breakpoints in the code and inspect variables is invaluable for troubleshooting complex issues.
Efficient Library Management: PlatformIO has a powerful Library Manager that handles dependencies on a per-project basis, which helps avoid version conflicts.
Automatic Port Detection: It automatically detects the COM port your board is connected to.
This transition has streamlined my workflow, enabling me to develop more sophisticated and reliable applications with increased efficiency and confidence. While there can be a learning curve, the benefits for anyone serious about embedded systems development are well worth it.
A key library in my project is TFT_eSPI by Bodmer, which is an excellent, high-performance graphics library for ESP32 and other microcontrollers. However, configuring this library in the Arduino IDE can be cumbersome. The standard method involves directly editing the User_Setup.h or User_Setup_Select.h files located inside the library's installation folder.
This approach has several drawbacks:
Configuration is global: Any changes to User_Setup.h affect all projects that use the library.
Updates are risky: Updating the TFT_eSPI library through the Arduino Library Manager can overwrite your custom configurations, forcing you to re-apply them.
Poor version control: Project-specific hardware configurations are not stored with the project's source code, making it difficult to track changes or share the project with others.
The PlatformIO Solution: Project-Specific Configuration
PlatformIO solves this problem elegantly by allowing all configuration, including library settings, to be defined within a single file in your project's root directory: platformio.ini.
Instead of modifying the library files, you can provide hardware-specific definitions to the TFT_eSPI library using the build_flags option in platformio.ini. This is a cleaner, non-destructive, and more portable way to manage settings.
By first defining -D USER_SETUP_LOADED=1, we tell TFT_eSPI to ignore its internal User_Setup.h files and instead use the definitions provided directly by the build system. After that, we can specify all necessary parameters like the display driver, pin connections, and SPI frequency.
Here is an example from my platformio.ini file, showing how I configure the TFT_eSPI library for one of my displays:
platformio.ini
[env:esp32-s3-devkitc-1-n16r8v]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
board_upload.flash_size = 16MB
board_upload.maximum_size = 16777216
board_build.arduino.memory_type = qio_opi
board_build.filesystem = littlefs
build_flags =
-DBOARD_HAS_PSRAM
; TFT_eSPI configuration
-D USER_SETUP_LOADED=1
-D GC9A01_DRIVER=1
-D TFT_WIDTH=240
-D TFT_HEIGHT=240
-D TFT_MOSI=11
-D TFT_SCLK=13
-D TFT_MISO=17
-D TFT_DC=4
-D TFT_RST=6
-D USE_HSPI_PORT=1
-D TFT_CS=-1 ; Using manual CS
-D SPI_FREQUENCY=80000000
-D SMOOTH_FONT=1
lib_deps =
sparkfun/SparkFun VL53L5CX Arduino Library@^1.0.3
bodmer/TFT_eSPI@^2.5.43
As you can see in the build_flags section, I define everything the library needs to know about my specific hardware setup. This has to be tailored to your setup.
The advantages of this approach are significant:
Project-Contained: All hardware settings are saved with the project
No Modified Libraries: The original library files in the .pio directory remain untouched, which means I can safely update libraries without losing my settings.
Multiple Configurations: It's easy to manage different hardware setups for different projects, or even for different environments within the same project, as each can have its own platformio.ini.