GPS Dashboard for ESP32-S3
This is a GPS dashboard/speed meter developed using LVGL9.2 and based on the ESP32S3 as the main control UI framework .
It was only ported and completed on the 16th. I'm going out for the Mid-Autumn Festival, so the casing isn't finished yet; I'll work on it when I have time.
1. Hardware Design
Integrated charging chip, supporting 3.7V battery charging, automatic power path management.
Integrated fuel gauge chip.
Designed with a one-button power on/off circuit.
Uses Zhongke Micro's GPS module.
The main control is ESP32-S3R8.
Built-in PSRAM 8 MB (Octal SPI).
External Flash 16M.
Designed with SD/TF card with SDIO interface.
Uses ESP32-USB peripheral debugging.
Three user buttons, one of which is the power button.


1.1 Automatic Power On/Off

This is a common circuit design, using a PMOS to control the positive terminal of the battery to connect to the system.
By default, the gate (G) of the PMOS is pulled high by a 1K resistor, cutting off conduction. In reality, it's difficult for a PMOS to be completely cut off. A larger pull-up resistor results in a larger leakage current; even with a 1K resistor, there's still leakage current, approximately 1.5~2V.
When the KEY1 button is pressed, it conducts to GND, pulling the gate (G) of the PMOS low, effectively short-circuiting the source and drain. At this point, the ESP32 microcontroller is powered on at 3.3V via the DC-DC step-down circuit. Once powered on, the ESP32 sets a high level on the POWER-EN pin, turning on the NMOS and pulling the PMOS's gate low. Releasing the button keeps the PMOS conducting. To power off, the ESP32 sets a low level on the POWER-EN pin.
1.2 Charging IC

: A single-cell lithium battery 3.7V charging IC is selected. When a power source is detected, BQ-PG is pulled low, allowing the microcontroller to detect the power supply connection. This can be linked to the charging state switching of the LVGL.
1.3 Fuel Gauge

This fuel gauge uses an I2C peripheral driver. The GAS-INT pin is pulled low when the battery is low, sending a low battery signal to the ESP32.
Linking with LVGL allows for battery level detection, and linking with the charging IC provides a comprehensive overview of charging information.
2. Software Design
and Development Editor: VS Code
Framework: ESP-IDF V5.3.1
Simulator: VS Code + SDL
UI: LVGL 9.2
UI Design The UI


project framework is designed modularly, using CMake to manage
the LVGL source code and project UI code (framework). One codebase is used by both the SDL simulator and ESP32IDF. CMake environment variables distinguish different environment build files.
The script folder provides a font generator, image generator, and image compositer (multiple images are generated into a single .bin file). All scripts are written in Python.
2.1 LVGL Page Framework
The core algorithm of the page management framework is a doubly linked dynamic list for management.
Functions include
various lifecycle callback controls,
page animation controls,
dynamic parameters,
a message bus (EventBus),
page operation tools, methods,
components, memory management
(lv_group), input event grouping management
(lv_indev), redefining input callbacks, key types
, etc.

2.2 LVGL Font Design
A font generation tool was written in the Script folder, generating a .c file by default. When using it, you only need to write a .json file, for example, the following:
{
"desc": "The tool for building fonts",
"outputPath": "output",
"fontFileConfigs": [
{
"fontPath": "./font/DigitalNumbers-Regular.ttf",
"generators": [
{
"size": 64,
"fileName": "font_dignumber_64.c",
"bpp": 4,
"symbols": "-",
"range": ""
},
{
"size": 16,
"fileName": "font_dignumber_16.c",
"bpp": 4,
"symbols": "KM/h",
"range": ""
}
]
},
{
"fontPath": "./font/DouyinSansBold.otf",
"generators": [
{
"size": 12,
"fileName": "font_douyin_12.c",
"bpp": 4,
"symbols": "When riding is not enabled, the automatic shutdown time is set to the maximum speed, average temperature, and maximum range of a single mileage. The range is: minutes, seconds, etc.",
"range": ""
},
{
"size": 16,
"fileName": "font_douyin_16.c",
"bpp": 4,
"symbols": "Exit settings hour minutes seconds tech black minimalist white close version official website cooperation contact user manual: good not connected cumulative mileage riding longitude latitude altitude current speed antenna status satellites available single highest average temperature dial style automatic sleep record positioning information on machine",
"range": ""
},
{
"size": 24,
"fileName": "font_douyin_24.c",
"bpp": 4,
"symbols": "Set dial style automatic sleep riding record positioning information about this machine shutdown",
"range": ""
}
]
}
]
}
2.3 LVGL image resource design
image generation also has small tools that can be used, in the img_script folder
tool usage instructions:
The `image` folder contains original files such as .png and
.jpg. The `img_scriptgen_img.py` script reads the images from the `image` folder
and generates all the `.bin` files. The `img_scriptgen_bin.py` script reads all the `.bin` files, merges them into one `.bin` file, and outputs a `.c` file containing a map address and a `mergin.bin` file.
An example of the generated map file is shown below
: `#include "mergin_bin.h"
struct mergin_bin_t bin_infos[] = {
{
.item_name = "battery.bin",
.start_address = 0,
.end_address = 812
} ,
{
.item_name = "dark_style_bg.bin", .start_address = 812, .end_address = 135224 }, { .item_name = "fire.bin", .start_address = 135224, .end_address = 136370 } ........................ };` size_t bin_infos_len = 16; 2.3.1 SDL simulator uses merge.bin #include #include #include #include "mergin_fsys.h" #ifndef BIN_DIR #define BIN_DIR "" #endif struct mergin_bin_t bin_infos[] = { { .item_name = "battery.bin", .start_address = 0, .end_address = 812 }, { .item_name = "dark_style_bg.bin", .start_address = 812, .end_address = 135224 }, .....................omitted }; size_t bin_infos_len = 16; void* bin_open_cb(lv_fs_drv_t* drv, const char* path,lv_fs_mode_t mode) { struct mergin_fs_t* fs = lv_malloc(sizeof(struct mergin_fs_t)); if (fs == NULL) { return NULL; } fs->mode = mode;
fs->path = path;
fs->mergin_bin = find_bin_by_name(strrchr(path, '/') + 1);
if (fs->mergin_bin == NULL) {
return NULL;
}
fs->user_data = fopen(BIN_DIR, "rb");
if (fs->user_data == NULL) {
lv_log("Cannot find/merge.bin file and failed to open");
lv_free(fs);
return NULL;
}
fseek(fs->user_data, fs->mergin_bin->start_address, SEEK_SET);
return fs;
}
lv_fs_res_t bin_close_cb(lv_fs_drv_t* drv, void* file_p) {
struct struct mergin_fs_t* fs = (struct mergin_fs_t*)file_p;
if (fs->user_data != NULL) {
fclose(fs->user_data);
}
lv_free(fs);
return LV_FS_RES_OK;
}
lv_fs_res_t bin_read_cb(lv_fs_drv_t* drv,
void* file_p,
void* buf,
uint32_t btr,
uint32_t* br) {
struct mergin_fs_t* fs = (struct mergin_fs_t*)file_p;
*br = fread(buf, 1, btr, fs->user_data);
return (*br > 0) ? LV_FS_RES_OK : LV_FS_RES_UNKNOWN; // Return result
}
lv_fs_res_t bin_seek_cb(lv_fs_drv_t* drv,
void* file_p,
uint32_t pos,
lv_fs_whence_t whence) {
struct mergin_fs_t* fs = (struct mergin_fs_t*)file_p;
if (whence == LV_FS_SEEK_SET) {
return fseek(fs->user_data, fs->mergin_bin->start_address + pos,
SEEK_SET) != 0
? LV_FS_RES_UNKNOWN
: LV_FS_RES_OK;
} else if (whence == LV_FS_SEEK_END) {
return fseek(fs->user_data, fs->mergin_bin->end_address - pos,
SEEK_SET) != 0
? LV_FS_RES_UNKNOWN
: LV_FS_RES_OK;
} else if (whence == LV_FS_SEEK_CUR) {
return fseek(fs->user_data, pos, SEEK_CUR) != 0 ? LV_FS_RES_UNKNOWN
: LV_FS_RES_OK;
}
return LV_FS_RES_UNKNOWN;
}
lv_fs_res_t bin_tell_cb(lv_fs_drv_t* drv, void* file_p, uint32_t* pos_p) {
struct mergin_fs_t* fs = (struct mergin_fs_t*)file_p;
long pos = ftell(fs->user_data);
*pos_p = pos - fs->mergin_bin->start_address;
return *pos_p < fs->mergin_bin->end_address ? LV_FS_RES_OK
: LV_FS_RES_UNKNOWN;
}
2.3.2. ESP-IDF uses mergein.bin
When using merge.bin with ESP-IDF, this file needs to be added to CMakeLists.txt. The ESP-IDF tool will package this file into the .rodata section of Flash.
First, you need to edit the contents of CMakeLists.txt in the main folder and add the path to merge.bin:
idf_component_register(SRC_DIRS "." "font/"
INCLUDE_DIRS "include"
EMBED_FILES "../../scripts/img_script/merge_output/merge.bin")
It is recommended to use relative paths.
Use variables to reference data in the .rodata section in the code. Core code:
// Get files in the .rodata section of Flash
extern const uint8_t merge_start[] asm("_binary_merge_bin_start");
extern const uint8_t merge_end[] asm("_binary_merge_bin_end");
The complete code for writing the Flash virtual file system is as follows. After writing the code below, you can use the images.
#include
#include
#include
#include "mergin_fsys.h"
// Get the file in the .rodata section in Flash
extern const uint8_t merge_start[] asm("_binary_merge_bin_start");
extern const uint8_t merge_end[] asm("_binary_merge_bin_end");
struct mergin_bin_t bin_infos[] = {
{.item_name = "battery.bin", .start_address = 0, .end_address = 812},
{.item_name = "dark_style_bg.bin",
.start_address = 812,
.end_address = 135224},
{.item_name = "fire.bin", .start_address = 135224, .end_address = 136370},
{.item_name = "gps.bin", .start_address = 136370, .end_address = 136894},
{.item_name = "power.bin", .start_address = 136894, .end_address = 137706},
{.item_name = "record_start.bin",
.start_address = 137706,
.end_address = 138968},
{.item_name = "record_stop.bin",
.start_address = 138968,
.end_address = 140230},
{.item_name = "setting.bin",
.start_address = 140230,
.end_address = 141394},
{.item_name = "set_active.bin",
.start_address = 141394,
.end_address = 142206},
{.item_name = "set_back.bin",
.start_address = 142206,
.end_address = 143018},
{.item_name = "set_gps.bin",
.start_address = 143018,
.end_address = 143830},
{.item_name = "set_info.bin",
.start_address = 143830,
.end_address = 144642},
{.item_name = "set_log.bin",
.start_address = 144642,
.end_address = 145454},
{.item_name = "set_ok.bin", .start_address = 145454, .end_address = 146266},
{.item_name = "set_sleep.bin",
.start_address = 146266,
.end_address = 147078},
{.item_name = "set_style.bin",
.start_address = 147078,
.end_address = 147890},
};
size_t bin_infos_len = 16;
void* bin_open_cb(lv_fs_drv_t* drv, const char* path, lv_fs_mode_t mode) {
struct mergin_fs_t* fs = lv_malloc(sizeof(struct mergin_fs_t));
if (fs == NULL) {
return NULL;
}
fs->mode = mode;
fs->path = path;
fs->mergin_bin = find_bin_by_name(strrchr(path, '/') + 1);
if (fs->mergin_bin == NULL) {
LV_LOG_ERROR("ERROR: Error BIN file resource not found");
return NULL;
}
uint32_t* pos = lv_malloc_zeroed(sizeof(uint32_t));
*pos = 0;
fs->user_data = pos;
return fs;
}
lv_fs_res_t bin_close_cb(lv_fs_drv_t* drv, void* file_p) {
struct merge_fs_t* fs = (struct mergin_fs_t*)file_p;
if (fs->user_data != NULL) {
lv_free(fs->user_data);
}
lv_free(fs);
return LV_FS_RES_OK;
}
lv_fs_res_t bin_read_cb(lv_fs_drv_t* drv,
void* file_p,
void* buf,
uint32_t btr,
uint32_t* br) {
struct mergin_fs_t* fs = (struct mergin_fs_t*)file_p;
// Calculate the start and end addresses of the data
const uint8_t* data_start = merge_start + fs->mergin_bin->start_address +
*((uint32_t*)fs->user_data);
const uint8_t* data_end = data_start + btr;
// Calculate the length of the data to be read
size_t length = data_end - data_start;
if (length >
(fs->mergin_bin->end_address - fs->mergin_bin->start_address)) {
return LV_FS_RES_UNKNOWN;
}
// Copy data
memcpy(buf, data_start, length);
*br = btr;
return (*br > 0) ? LV_FS_RES_OK : LV_FS_RES_UNKNOWN; // Return result
}
lv_fs_res_t bin_seek_cb(lv_fs_drv_t* drv,
void* file_p,
uint32_t pos,
lv_fs_whence_t whence) {
struct mergin_fs_t* fs = (struct mergin_fs_t*)file_p;
if (pos > (fs->mergin_bin->end_address - fs->mergin_bin->start_address)) {
return LV_FS_RES_UNKNOWN;
}
if (whence == LV_FS_SEEK_SET) {
*((uint32_t*)fs->user_data) = pos;
return LV_FS_RES_OK;
} else if (whence == LV_FS_SEEK_END) {
uint32_t diff = fs->mergin_bin->end_address - pos;
if (diff < fs->mergin_bin->start_address) {
return LV_FS_RES_UNKNOWN;
}
*((uint32_t*)fs->user_data) = diff;
return LV_FS_RES_OK;
} else if (whence == LV_FS_SEEK_CUR) {
if (*((uint32_t*)fs->user_data) + pos > fs->mergin_bin->end_address) {
return LV_FS_RES_UNKNOWN;
}
*((uint32_t*)fs->user_data) += pos;
return LV_FS_RES_OK;
}
return LV_FS_RES_UNKNOWN;
}
lv_fs_res_t bin_tell_cb(lv_fs_drv_t* drv, void* file_p, uint32_t* pos_p) {
struct mergin_fs_t* fs = (struct mergin_fs_t*)file_p;
uint32_t pos = *((uint32_t*)fs->user_data);
*pos_p = pos;
return *pos_p < fs->mergin_bin->end_address? LV_FS_RES_OK
: LV_FS_RES_UNKNOWN;
}
2.3.2 LVGL Load Image
page_view = lv_obj_create(NULL);
lv_group_add_obj(ui_dat->group, page_view);
lv_obj_clear_flag(page_view, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_img_src(page_view, "S:/img/dark_style_bg.bin",
LV_PART_MAIN | LV_STATE_DEFAULT);
icon_gps = lv_img_create(page_view);
lv_img_set_src(icon_gps, "S:/img/gps.bin");
2.3.3 LVGL Screen Driver
The screen driver uses the eSPI_TFT adaptation code built into LVGL. We only need to import the eSPI_TFT library and configure the pins and drivers.
The core code is as follows:
/**
* Screen size
*/
#define MY_DISP_HOR_RES 240
#define MY_DISP_VER_RES 280
#define BYTE_PER_PIXEL (LV_COLOR_FORMAT_GET_SIZE(LV_COLOR_FORMAT_RGB565))
static lv_display_t* hal_init(int32_t w, int32_t h) {
// LVGL needs system ticks to know the elapsed time of animations and other tasks.
lv_tick_set_cb(xTaskGetTickCount);
// Create draw buffer - double buffer
size_t len = MY_DISP_HOR_RES * MY_DISP_VER_RES * BYTE_PER_PIXEL;
void* buf1 = heap_caps_malloc(len, MALLOC_CAP_SPIRAM);
// void* buf2 = heap_caps_malloc(len, MALLOC_CAP_SPIRAM);
lv_group_set_default(lv_group_create());
lv_display_t* display =
lv_tft_espi_create(MY_DISP_HOR_RES, MY_DISP_VER_RES, buf1, NULL, len);
lv_indev_t* indev = (lv_indev_t*)create_btn_key_indev();
lv_indev_set_group(indev, lv_group_get_default());
lv_indev_set_display(indev, display);
lv_display_set_default(display);
// Initialize the screen's PWM dimming
pinMode(IO_PWM_PIN, OUTPUT);
return display;
}
In actual testing, if lvgl_demo runs smoothly, double buffering needs to be enabled.
// Create drawing buffers - double buffer
size_t len = MY_DISP_HOR_RES * MY_DISP_VER_RES * BYTE_PER_PIXEL;
void* buf1 = heap_caps_malloc(len, MALLOC_CAP_SPIRAM);
void* buf2 = heap_caps_malloc(len, MALLOC_CAP_SPIRAM);
To prevent screen tearing, you can choose full refresh: LV_DISPLAY_RENDER_MODE_FULL
lv_display_set_buffers(disp, buf, buf2, buf_size_bytes, LV_DISPLAY_RENDER_MODE_FULL);
3. Image Gallery
-
3.




Source Code
Github Address: https://github.com/ccy-studio/CCY-GPS-ESP32S3.git
To pull the Git repository containing submodules, please follow these steps:
Clone the repository containing submodules:
git clone --recurse-submodules https://github.com/ccy-studio/CCY-GPS-ESP32S3.git
If you have already cloned a repository without submodules, you can run the following command to initialize and update the submodules:
git submodule update --init --recursive
If you need to update existing submodules, you can enter the submodule directory and pull the latest code:
cd path/to/submodule
git pull
4. Refer to the Simple Tutorial
https://oshwhub.com/article/tarzan-nas-lvgl9
5. Other
5.1 IDF compilation speed is very slow, but 100% CPU usage

is due to Microsoft Defender. Turn off this built-in antivirus program.
5.2 Using sdkconfig.defaults
In some cases, such as sdkconfig When a file is under version control, the build system may find it inconvenient to modify the `sdkconfig` file. To avoid this, you can create an `sdkconfig.defaults` file within the build system. This file can be created manually or automatically, and the build system will never modify it. It contains all the important options that differ from the defaults and has the same format as the `sdkconfig` file. If the user remembers all the changed configurations, they can create `sdkconfig.defaults` manually or automatically by running the `idf.py save-defconfig` command.
After `sdkconfig.defaults` is created, the user can delete `sdkconfig` or add it to the ignore list of the version control system (e.g., Git's `.gitignore` file). The project build target will automatically create the `sdkconfig` file, populate the settings in the `sdkconfig.defaults` file, and configure other settings to their default values. Note that settings in `sdkconfig.defaults` will not override existing settings in `sdkconfig` during the build process. For more information, see Customizing sdkconfig Defaults.