September 05 / 2026 · Day 05

A GIF

Make Homer disappear into the bushes. A short loop is the whole assignment — and the whole memory lesson.

ESP-IDF v5.5 + Waveshare BSP (LVGL 9.5)Full-screen playback and fills visually confirmed; extended soak pending
On this page
Illustration of the Waveshare Touch AMOLED 1.8 development kit

Used in this project

Waveshare Touch AMOLED 1.8

ESP32-S3-Touch-AMOLED-1.8 V2

1.8″ AMOLED · 368 × 448 · ESP32-S3

Run this project

Firmware for ESP32-S3-Touch-AMOLED-1.8 V2

Download firmware ↓
Or flash from your browser

Connect the ESP32-S3-Touch-AMOLED-1.8 V2 with a USB data cable. Use a desktop Chromium browser with Web Serial support. Installation replaces the firmware on the connected board.

This browser does not support Web Serial. Use the download and terminal instructions below.

The result

Make Homer Simpson disappear into the bushes. A short loop is the whole assignment: the carousel moved between separate images, and a GIF brings its images and their timing in one file. The firmware plays a 29-frame full-screen loop forever, edges clean, colors intact. 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, 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, plus FFmpeg to convert your own GIF.

The display is 368 × 448 pixels. Flashing replaces the firmware currently on the board.

Run it

Download day-05-gif.bin and open a terminal in the download directory. This is a merged image containing the bootloader, partition table, and application.

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-05-gif.bin

Close any serial monitor using that port before flashing. When flashing completes, Homer starts backing up.

How it works

LVGL 9.5’s GIF widget owns the playback — its timer advances the frames, so the application never writes an animation loop:

lv_obj_t *animation = lv_gif_create(lv_screen_active());
// Complete opaque frames need no alpha; use the panel's native format.
lv_gif_set_color_format(animation, LV_COLOR_FORMAT_RGB565);
lv_gif_set_src(animation, &esptember_loop);

The color-format line is the day’s money line. At 368 × 448, LVGL’s default ARGB8888 decode canvas needs 659,456 bytes; selecting RGB565 — honest for opaque frames on an RGB565 panel — halves it to 329,728. Either is bigger than day 02’s fixed 64 KiB LVGL heap, and the board’s 8 MB of PSRAM doesn’t enlarge that private pool automatically. Two lines of sdkconfig route LVGL through the C library allocator, where PSRAM is reachable:

CONFIG_LV_USE_GIF=y
CONFIG_LV_USE_CLIB_MALLOC=y

The asset ships as its original encoded bytes (EMBED_FILES), wrapped in a descriptor with LV_COLOR_FORMAT_RAW — converting a GIF to a still image would lose the animation, which is the whole point.

scripts/make-media.py prepares the loop: scale the source GIF to cover the screen, crop, rebuild the palette with FFmpeg — and, crucially, encode every frame complete and opaque (-gifflags 0, no transparent palette entry). scripts/check-gif-frames.py rejects any asset that breaks that rule before a build can ship it.

What went wrong

Where the fills went

The first frame looked right. Then the bushes, the wall, and Homer’s white shirt turned black while the edges remained.

The encoded GIF marked unchanged pixels as transparent in 28 of its 29 frames — disposal method 1, “keep the previous image underneath” — a standard GIF space optimization. But the installed LVGL 9.5 drawing code sets those pixels’ alpha to zero instead of preserving what was under them, exposing the black screen. The fix wasn’t in the firmware at all: the conversion now flattens every decoded frame to complete, opaque RGB, and the frame validator makes the rule permanent.

Check the result

  • Homer backs into the bushes, full screen, and the loop repeats indefinitely.
  • The bushes, wall, and shirt hold their colors through every frame — no black fills.
  • The framing sits shifted 26 pixels left, centering Homer.
  • Serial logs a READY line with the asset’s byte counts on boot.

Recorded evidence · September 4, 2026: The transparency failure and its fix were observed on hardware; the revised full-screen opaque encoding was visually confirmed (fills correct, playback smooth), with loop callbacks ~3.6 s apart under RGB565 and stable heap/PSRAM across observed heartbeats. An extended soak run remains open, noted in NOTES.md.

Used resources

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-05-gif/firmware
idf.py build
idf.py -p PORT flash monitor

To create the single downloadable image, run idf.py merge-bin in the same firmware directory. For your own GIF: replace firmware/main/homer.gif, run the converter and the frame checker, and rebuild — the checker will refuse any file with transparent or partial frames, which is it doing you a favor.

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-05-gif)
main/CMakeLists.txt
idf_component_register(SRCS "main.c" EMBED_FILES "homer.gif")
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 05 — A GIF
#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, &reg, 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 gif_start[] asm("_binary_homer_gif_start");
extern const uint8_t gif_end[] asm("_binary_homer_gif_end");
static lv_image_dsc_t esptember_loop;
static unsigned loops;

static void loop_finished(lv_event_t *event)
{
    (void)event;
    ESP_LOGI("media", "GIF loop=%u", ++loops);
}

void app_main(void)
{
    board_start();
    esptember_loop = (lv_image_dsc_t) {
        .header = { .magic = LV_IMAGE_HEADER_MAGIC, .cf = LV_COLOR_FORMAT_RAW,
                    .w = 368, .h = 448 },
        .data_size = gif_end - gif_start, .data = gif_start,
    };
    bsp_display_lock(0);
    lv_obj_set_style_bg_color(lv_screen_active(), lv_color_black(), 0);
    lv_obj_t *animation = lv_gif_create(lv_screen_active());
    // Complete opaque frames need no alpha; use the panel's native format.
    lv_gif_set_color_format(animation, LV_COLOR_FORMAT_RGB565);
    lv_gif_set_src(animation, &esptember_loop);
    if (lv_gif_is_loaded(animation)) {
        lv_gif_set_loop_count(animation, 0);
        lv_obj_center(animation);
        lv_obj_add_event_cb(animation, loop_finished, LV_EVENT_READY, NULL);
        ESP_LOGI("media", "READY day05: Homer GIF loaded; bytes=%lu; RGB565 canvas=329728 bytes",
                 (unsigned long)esptember_loop.data_size);
    } else {
        ESP_LOGE("media", "GIF load failed");
        lv_obj_t *error = lv_label_create(lv_screen_active());
        lv_label_set_text(error, "GIF failed to load");
        lv_obj_center(error);
    }
    bsp_display_unlock();
    heartbeat("day05");
}
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 05
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_CLIB_MALLOC=y
# CONFIG_LV_USE_BUILTIN_MALLOC is not set
CONFIG_LV_USE_GIF=y