The result
Swipe through three portraits with LVGL’s Tile View.
The firmware shows Michael Chan’s GitHub, React Conf, and React Advanced portraits, one at a time.
Each day is a standalone firmware image; you can start here without flashing earlier days.
What you need
- Board: Waveshare ESP32-S3-Touch-AMOLED-1.8 V2: ESP32-S3, CO5300 panel, CST816-family touch, 16 MB flash, 8 MB PSRAM.
- Connection: a USB data cable and a computer with access to the serial port.
- For the download: uv supplies the
uvx command below. ESP-IDF is not needed.
- For source builds: ESP-IDF v5.5 with its environment activated. Dependencies are declared in the firmware project.
The display is 368 × 448 pixels.
Flashing replaces the firmware currently on the board.
Run it
Download day-04-carousel.bin and open a terminal in the download directory.
This is a merged image containing the bootloader, partition table, and application with its image assets.
Find your serial port:
# macOS
ls /dev/cu.usbmodem*
# Linux
ls /dev/ttyACM*
On Windows, use the board’s COM port from Device Manager.
Replace PORT with your port, then flash the image at 0x0:
uvx esptool --chip esp32s3 --port PORT \
write-flash 0x0 day-04-carousel.bin
Close any serial monitor using that port before flashing.
When flashing completes, the board restarts into this lesson.
Check the result
- The first slide shows the GitHub portrait. Swipe left to React Conf, then React Advanced.
- Swipe right to return. The first and last slides stop at their outer edges.
- Release halfway through a swipe and check that the view snaps to a slide.
- The serial monitor prints
SWIPE slide=2, SWIPE slide=3, or SWIPE slide=1 as the selected tile changes.
- Repeat the movement and watch for rendering or touch failures.
READY day04 confirms creation of the UI. It does not verify physical touch.
Recorded evidence · September 4, 2026: The gallery reached READY with unchanged heap readings over 20 seconds. Physical swipes were not captured in that check.
How it works
This is the UI excerpt from the firmware source.
The full program first initializes power, releases the V2 panel reset, starts the display, and sets brightness.
const lv_image_dsc_t *slides[] = { &github, &react_conf, &react_advanced };
bsp_display_lock(0);
lv_obj_t *carousel = lv_tileview_create(lv_screen_active());
lv_obj_set_size(carousel, lv_pct(100), lv_pct(100));
lv_obj_set_style_pad_all(carousel, 0, 0);
lv_obj_set_style_border_width(carousel, 0, 0);
lv_obj_set_style_bg_color(carousel, lv_color_black(), 0);
lv_obj_set_scrollbar_mode(carousel, LV_SCROLLBAR_MODE_OFF);
for (uint8_t i = 0; i < 3; i++) {
lv_dir_t directions = LV_DIR_NONE;
if (i > 0) directions |= LV_DIR_LEFT;
if (i < 2) directions |= LV_DIR_RIGHT;
lv_obj_t *tile = lv_tileview_add_tile(carousel, i, 0, directions);
lv_obj_set_style_pad_all(tile, 0, 0);
lv_obj_t *image = lv_image_create(tile);
lv_image_set_src(image, slides[i]);
lv_obj_center(image);
}
lv_obj_add_event_cb(carousel, slide_changed, LV_EVENT_VALUE_CHANGED, NULL);
bsp_display_unlock();
The saved portraits and their source attribution live in the repository.
The converted assets are included; building does not need to fetch a current avatar from the internet.
With Python 3 and FFmpeg installed, regenerate the media lessons from the repository root:
python3 scripts/make-media.py
This regenerates all media assets, including later lessons.
Commit the source image and conversion changes together if you replace a portrait.
Three 240 × 240 RGB565 portraits consume 345,600 bytes before descriptors and program code.
CONFIG_LV_USE_TILEVIEW=y enables the widget.
Each tile contains one image and names its available neighbors.
Build and change it
Clone the repository once:
git clone https://github.com/chantastic/esptember.git
cd esptember
With the ESP-IDF environment activated, run from the repository root:
cd days/day-04-carousel/firmware
idf.py build
idf.py size
idf.py -p PORT flash monitor
Replace PORT with the board’s serial port.
Exit the monitor with Ctrl+].
To create the single downloadable image, run idf.py merge-bin in the same firmware directory.
The output is build/merged-binary.bin.
Keep pmu_init() and panel_reset_release() before display startup when changing the UI.
If it goes wrong
| Symptom | Check / fix |
|---|
| Swipe does nothing | While holding the display lock, try lv_tileview_set_tile_by_index(carousel, 1, 0, LV_ANIM_ON). If it moves, investigate touch input. |
| Gallery moves beyond an end | Check each tile’s allowed directions: right on the first, both on the middle, left on the last. |
| App no longer fits after adding images | Inspect idf.py size and partitions.csv. This project reserves 3 MiB for the app; total flash capacity is a different budget. |
Take it with you
Showing one image still requires storing all three.
The container handles navigation; the descriptors and pixel data remain the same as a static image.
Read the build story for the investigation and lessons behind this version.
The implementation
Firmware source
Read the files here or open the project on GitHub ↗.
CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esptember-day-04-carousel)
main/CMakeLists.txt
idf_component_register(SRCS "main.c" EMBED_FILES "github.rgb565" "react_conf.rgb565" "react_advanced.rgb565")
main/idf_component.yml
dependencies:
waveshare/esp32_s3_touch_amoled_1_8: "==2.0.3"
espressif/esp_io_expander_tca9554: "*"
lvgl/lvgl: "==9.5.0"
main/main.c
// ESPtember Day 04 — A Carousel
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2c_master.h"
#include "bsp/esp-bsp.h"
#include "lvgl.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
// --- AXP2101 power management ------------------------------------------
// The BSP doesn't configure the board's AXP2101 PMU at all. Its default
// VBUS input limit is 500 mA — once battery charging ramps up, the
// ESP32-S3 + AMOLED + charger exceed that and the PMU cuts system power
// (screen freezes/blanks, USB drops, ~1–4 min in). Raise the input
// limit and cap the charge current so the budget always fits.
#define AXP2101_ADDR 0x34
static i2c_master_dev_handle_t pmu;
static uint8_t pmu_read(uint8_t reg)
{
uint8_t val = 0;
i2c_master_transmit_receive(pmu, ®, 1, &val, 1, 100);
return val;
}
static void pmu_write(uint8_t reg, uint8_t val)
{
uint8_t buf[2] = {reg, val};
i2c_master_transmit(pmu, buf, 2, 100);
}
static void pmu_init(void)
{
bsp_i2c_init();
i2c_device_config_t cfg = {
.device_address = AXP2101_ADDR,
.scl_speed_hz = 100000,
};
ESP_ERROR_CHECK(
i2c_master_bus_add_device(bsp_i2c_get_handle(), &cfg, &pmu));
// 0x16 bits[2:0]: VBUS input current limit — 2 = 900 mA
pmu_write(0x16, (pmu_read(0x16) & 0xF8) | 0x02);
// 0x62 bits[4:0]: battery charge current — 9 = 300 mA
pmu_write(0x62, (pmu_read(0x62) & 0xE0) | 0x09);
}
// --- V2 panel reset --------------------------------------------------
// On V2 boards (CO5300 panel, CST816-family touch) the panel's reset
// line sits behind a TCA9554 I/O expander that neither the BSP nor the
// examples ever drive. Left floating, the panel may come up — and then
// randomly drop dark minutes later while every esp_lcd call still
// returns ESP_OK. Pulse reset like the factory firmware does, then
// hold it high. Must run BEFORE bsp_display_start().
// https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-1.8/issues/12
#include "esp_io_expander_tca9554.h"
static void panel_reset_release(void)
{
esp_io_expander_handle_t expander = NULL;
ESP_ERROR_CHECK(esp_io_expander_new_i2c_tca9554(
bsp_i2c_get_handle(), ESP_IO_EXPANDER_I2C_TCA9554_ADDRESS_000,
&expander));
const uint32_t reset_pins =
IO_EXPANDER_PIN_NUM_0 | IO_EXPANDER_PIN_NUM_1 | IO_EXPANDER_PIN_NUM_2;
ESP_ERROR_CHECK(
esp_io_expander_set_dir(expander, reset_pins, IO_EXPANDER_OUTPUT));
ESP_ERROR_CHECK(esp_io_expander_set_level(expander, reset_pins, 1));
vTaskDelay(pdMS_TO_TICKS(100));
ESP_ERROR_CHECK(esp_io_expander_set_level(expander, reset_pins, 0));
vTaskDelay(pdMS_TO_TICKS(300));
ESP_ERROR_CHECK(esp_io_expander_set_level(expander, reset_pins, 1));
}
static void board_start(void)
{
pmu_init();
panel_reset_release();
if (!bsp_display_start()) abort();
ESP_ERROR_CHECK(bsp_display_backlight_on());
}
static void heartbeat(const char *lesson)
{
while (1) {
ESP_LOGI("media", "%s alive: uptime=%llds heap=%u psram=%u", lesson,
esp_timer_get_time() / 1000000,
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
vTaskDelay(pdMS_TO_TICKS(10000));
}
}
extern const uint8_t github_start[] asm("_binary_github_rgb565_start");
static const lv_image_dsc_t github = {
.header = { .magic = LV_IMAGE_HEADER_MAGIC, .cf = LV_COLOR_FORMAT_RGB565,
.w = 240, .h = 240, .stride = 480 },
.data_size = 240 * 240 * 2, .data = github_start,
};
extern const uint8_t react_conf_start[] asm("_binary_react_conf_rgb565_start");
static const lv_image_dsc_t react_conf = {
.header = { .magic = LV_IMAGE_HEADER_MAGIC, .cf = LV_COLOR_FORMAT_RGB565,
.w = 240, .h = 240, .stride = 480 },
.data_size = 240 * 240 * 2, .data = react_conf_start,
};
extern const uint8_t react_advanced_start[] asm("_binary_react_advanced_rgb565_start");
static const lv_image_dsc_t react_advanced = {
.header = { .magic = LV_IMAGE_HEADER_MAGIC, .cf = LV_COLOR_FORMAT_RGB565,
.w = 240, .h = 240, .stride = 480 },
.data_size = 240 * 240 * 2, .data = react_advanced_start,
};
static void slide_changed(lv_event_t *event)
{
lv_obj_t *carousel = lv_event_get_target(event);
lv_obj_t *tile = lv_tileview_get_tile_active(carousel);
ESP_LOGI("media", "SWIPE slide=%ld", (long)lv_obj_get_index(tile) + 1);
}
void app_main(void)
{
board_start();
const lv_image_dsc_t *slides[] = { &github, &react_conf, &react_advanced };
bsp_display_lock(0);
lv_obj_t *carousel = lv_tileview_create(lv_screen_active());
lv_obj_set_size(carousel, lv_pct(100), lv_pct(100));
lv_obj_set_style_pad_all(carousel, 0, 0);
lv_obj_set_style_border_width(carousel, 0, 0);
lv_obj_set_style_bg_color(carousel, lv_color_black(), 0);
lv_obj_set_scrollbar_mode(carousel, LV_SCROLLBAR_MODE_OFF);
for (uint8_t i = 0; i < 3; i++) {
lv_dir_t directions = LV_DIR_NONE;
if (i > 0) directions |= LV_DIR_LEFT;
if (i < 2) directions |= LV_DIR_RIGHT;
lv_obj_t *tile = lv_tileview_add_tile(carousel, i, 0, directions);
lv_obj_set_style_pad_all(tile, 0, 0);
lv_obj_t *image = lv_image_create(tile);
lv_image_set_src(image, slides[i]);
lv_obj_center(image);
}
lv_obj_add_event_cb(carousel, slide_changed, LV_EVENT_VALUE_CHANGED, NULL);
bsp_display_unlock();
ESP_LOGI("media", "READY day04: swipe between chantastic GitHub, React Conf, React Advanced portraits");
heartbeat("day04");
}
partitions.csv
# Name, Type, SubType, Offset, Size, Flags
nvs,data,nvs,0x9000,0x6000,
phy_init,data,phy,0xf000,0x1000,
factory,app,factory,0x10000,0x300000,
sdkconfig.defaults
# ESPtember Day 04
CONFIG_IDF_TARGET="esp32s3"
# Board: Waveshare ESP32-S3-Touch-AMOLED-1.8 (16MB quad flash, 8MB octal PSRAM)
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_SPEED_80M=y
# Console over the board's USB-C port (USB-Serial/JTAG peripheral)
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
# Optimize for size
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
# LVGL dark theme: white text, black background — zero styling code,
# and on an AMOLED, black pixels are simply off.
CONFIG_LV_THEME_DEFAULT_DARK=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_LV_USE_TILEVIEW=y