Community project

WiFi-Connected Photo Carousel

ESP32

Hussain Shaheen

Published July 14, 2026

This WiFi-connected photo carousel displays rotating images on a 3.5-inch color TFT screen powered by an ESP32. The device creates its own wireless access point, allowing photos to be uploaded and managed through a web interface without needing an external network.

The guide covers the complete build: wiring the ILI9488 display to the ESP32 via SPI, connecting the battery charging circuit, and uploading firmware that handles WiFi connectivity, JPEG image decoding, and automatic slideshow playback. Builders will receive a full parts list, detailed wiring diagram, step-by-step assembly instructions, and ready-to-flash Arduino code with web UI.

Wiring diagram

Wiring diagram for WiFi-Connected Photo Carousel

Gather all the parts

QtyComponent
1

ILI9488 3.5 inch SPI TFT Display

3.5 inch 480x320 TFT LCD module using the ILI9488 controller over 4-wire SPI. It uses MOSI, SCK, CS, DC, and RESET for the display, with optional MISO/readback, PWM backlight control, and XPT2046 resistive touch pins on touch variants. Common ESP32 projects use TFT_eSPI with the ILI9488 driver selected.

1

TP4056 Li-Ion/LiPo charger module with protection

TP4056 single-cell Li-Ion/LiPo linear charger module, 5V USB input, 1A charge current (programmable). Common variants ship with DW01 protection. Pair with battery_lipo_storage for the cell.

1

LiPo 3.7V 1000mAh Battery

Single-cell LiPo pack, nominal 3.7 V, 1000 mAh. Default rechargeable choice for portable ESP32 / Pico projects. Pair with a TP4056 charger for safe USB recharging.

Assemble it in 6 steps

1. Gather components

Lay out: ESP32 DevKit v1, ILI9488 3.5" SPI TFT module, TP4056 Type-C charger module (6-pad, with protection), LiPo 3.7V 1000mAh battery, breadboard or PCB, and jumper wires.

  • Confirm your TP4056 module has 6 pads (IN+/IN−/B+/B−/OUT+/OUT−) — the protected variant.

2. Power chain — Battery to Charger

Connect the LiPo battery positive (+V) wire to the TP4056 B+ pad, and the battery negative (GND) wire to the TP4056 B− pad. Observe polarity carefully.

  • Reverse polarity will destroy the charger and may damage the battery. Double-check before connecting.

3. Power chain — Charger OUT to ESP32 VIN

Connect TP4056 OUT+ to the ESP32 VIN pin. Connect TP4056 OUT− (and IN−) to the ESP32 GND pin. The ESP32 onboard regulator steps 3.7–4.2V down to 3.3V for the MCU and TFT.

  • All GND connections share a common rail — connect them together on a breadboard GND rail.
  • Do NOT connect battery directly to the ESP32 5V/VIN without the TP4056; no over-discharge protection.

4. TFT SPI wiring

Connect the TFT display to the ESP32 as follows: • VCC → 3V3 • GND → GND • SCK → GPIO 18 • MOSI (SDI) → GPIO 23 • MISO (SDO) → GPIO 19 • CS → GPIO 4 • DC → GPIO 21 • RESET → GPIO 22 • LED (backlight) → GPIO 25

  • The ILI9488 module runs on 3.3V logic — no level shifters needed with the ESP32.
  • LED pin tied to GPIO25 lets the firmware control backlight; alternatively jumper it to 3V3 for always-on.
  • Do not connect VCC to 5V if your module has no onboard regulator — check your module's label.

5. USB-C charging cable

The TP4056 IN+ and IN− accept a USB-C cable. When plugged in, the onboard red/blue LEDs indicate charging and full charge respectively. The ESP32 continues to run from the battery while charging.

  • The TP4056 charges at up to 1A. A standard 5V/1A USB adapter or power bank is sufficient.

6. First power-on check

Before connecting the battery, visually inspect all wiring. Then connect the battery. The TFT should light up with a 'Starting...' message, followed by 'No photos found' and WiFi AP instructions.

  • If nothing appears, check the TFT VCC and GND connections first, then verify CS/DC/RST pins.
  • If the board gets hot immediately, disconnect and recheck for short circuits.

Review all connections

1. Connections between "tft" and "ESP32"

FunctiontftESP32
powerVCC3V3
groundGNDGND
spiCSGPIO 4
digitalRESETGPIO 22
digitalDCGPIO 21
spiMOSIGPIO 23
spiSCKGPIO 18
spiMISOGPIO 19
pwmLEDGPIO 32

2. Connections between "battery" and "ESP32"

FunctionbatteryESP32
power+VTP4056 Li-Ion/LiPo charger module with protection B+EXT
groundGNDTP4056 Li-Ion/LiPo charger module with protection B-EXT

3. Connections between "charger" and "ESP32"

FunctionchargerESP32
powerIN+USB-C power inputEXT
groundIN-GND
powerOUT+VIN
groundOUT-GND

Deploy the firmware

firmware.cppOpen in Schematik
/*
 * Rotating Digital Photo Frame
 * ESP32 DevKit v1 + ILI9488 3.5" SPI TFT (480x320)
 * WiFi AP + Web UI — uses built-in WebServer (no AsyncWebServer needed)
 */

#include <Arduino.h>
#include <SPI.h>
#include <SPIFFS.h>
#include <WiFi.h>
#include <WebServer.h>
#include <TFT_eSPI.h>
#include <JPEGDEC.h>
#include <vector>
#include "html_page.h"

// ─── Pin constants ────────────────────────────────────────────────
#define TFT_CS_PIN   4
#define TFT_DC_PIN   21
#define TFT_RST_PIN  22
#define TFT_BL_PIN   32

// ─── Config ───────────────────────────────────────────────────────
const char* AP_SSID  = "PhotoFrame";
const char* AP_PASS  = "photoframe123";
const int   SLIDE_MS = 5000;

// ─── Forward declarations ─────────────────────────────────────────
int  jpegDrawCB(JPEGDRAW *pDraw);
void rebuildList();
void showPhoto(const String& path);
void showNoPhotos();
void handleRoot();
void handleList();
void handleDelete();
void handleUpload();
void handleUploadBody();

// ─── Globals ──────────────────────────────────────────────────────
TFT_eSPI    tft;
JPEGDEC     jpeg;
WebServer   server(80);

std::vector<String> photoList;
int           currentIndex  = 0;
unsigned long lastSlide     = 0;
bool          photosChanged = false;

// ─── JPEG draw callback ───────────────────────────────────────────
int jpegDrawCB(JPEGDRAW *pDraw) {
    tft.pushImage(pDraw->x, pDraw->y, pDraw->iWidth, pDraw->iHeight,
                  (uint16_t*)pDraw->pPixels);
    return 1;
}

// ─── Rebuild photo list from SPIFFS ───────────────────────────────
void rebuildList() {
    photoList.clear();
    File root = SPIFFS.open("/photos");
    if (!root || !root.isDirectory()) return;
    File f = root.openNextFile();
    while (f) {
        String nm = String(f.name());
        if (nm.endsWith(".jpg") || nm.endsWith(".jpeg") ||
            nm.endsWith(".JPG") || nm.endsWith(".JPEG")) {
            photoList.push_back(nm);
        }
        f = root.openNextFile();
    }
}

// ─── Display helpers ──────────────────────────────────────────────
void showPhoto(const String& path) {
    tft.fillScreen(TFT_BLACK);
    File f = SPIFFS.open(path, "r");
    if (!f) {
        tft.setTextColor(TFT_WHITE, TFT_BLACK);
        tft.setCursor(10, 150); tft.setTextSize(2);
        tft.print("Cannot open: "); tft.println(path);
        return;
    }
    size_t sz = f.size();
    uint8_t* buf = (uint8_t*)malloc(sz);
    if (!buf) {
        f.close();
        tft.setCursor(10, 150);
        tft.setTextColor(TFT_RED, TFT_BLACK); tft.setTextSize(2);
        tft.println("Out of RAM");
        return;
    }
    f.read(buf, sz);
    f.close();
    jpeg.openRAM(buf, (int)sz, jpegDrawCB);
    jpeg.setPixelType(RGB565_BIG_ENDIAN);
    jpeg.decode(0, 0, 0);
    jpeg.close();
    free(buf);
}

void showNoPhotos() {
    tft.fillScreen(TFT_BLACK);
    tft.setTextColor(TFT_WHITE, TFT_BLACK);
    tft.setTextSize(2); tft.setCursor(60, 140);
    tft.println("No photos found.");
    tft.setTextSize(1); tft.setCursor(60, 170);
    tft.println("Connect to WiFi: PhotoFrame");
    tft.setCursor(60, 185);
    tft.println("Open: http://192.168.4.1");
}

// ─── HTTP handlers ────────────────────────────────────────────────
void handleRoot() {
    server.send_P(200, "text/html", HTML_PAGE);
}

void handleList() {
    String json = "[";
    for (int i = 0; i < (int)photoList.size(); i++) {
        if (i) json += ",";
        json += "\"" + photoList[i] + "\"";
    }
    json += "]";
    server.send(200, "application/json", json);
}

void handleDelete() {
    if (!server.hasArg("name")) {
        server.send(400, "text/plain", "Missing name"); return;
    }
    String nm = server.arg("name");
    nm.replace("..", "");
    if (!nm.startsWith("/")) nm = "/photos/" + nm;
    if (SPIFFS.remove(nm)) {
        rebuildList();
        currentIndex = 0; photosChanged = true;
        server.send(200, "text/plain", "Deleted: " + nm);
    } else {
        server.send(404, "text/plain", "Not found: " + nm);
    }
}

// Upload handler — WebServer multipart file upload
static File uploadFile;
void handleUploadBody() {
    HTTPUpload& upload = server.upload();
    if (upload.status == UPLOAD_FILE_START) {
        String filename = upload.filename;
        filename.replace("..", "");
        if (!filename.endsWith(".jpg") && !filename.endsWith(".jpeg") &&
            !filename.endsWith(".JPG") && !filename.endsWith(".JPEG")) return;
        uploadFile = SPIFFS.open("/photos/" + filename, "w");
    } else if (upload.status == UPLOAD_FILE_WRITE) {
        if (uploadFile) uploadFile.write(upload.buf, upload.currentSize);
    } else if (upload.status == UPLOAD_FILE_END) {
        if (uploadFile) {
            uploadFile.close();
            rebuildList();
            currentIndex = 0; photosChanged = true;
        }
    }
}

void handleUpload() {
    server.send(200, "text/plain", "Upload complete!");
}

// ─── Setup ────────────────────────────────────────────────────────
void setup() {
    Serial.begin(115200);

    pinMode(TFT_BL_PIN, OUTPUT);
    digitalWrite(TFT_BL_PIN, HIGH);

    tft.init();
    tft.setRotation(1);
    tft.fillScreen(TFT_BLACK);
    tft.setTextColor(TFT_WHITE, TFT_BLACK);
    tft.setTextSize(2); tft.setCursor(60, 140);
    tft.println("Starting...");

    if (!SPIFFS.begin(true)) {
        tft.setCursor(10, 160);
        tft.setTextColor(TFT_RED, TFT_BLACK);
        tft.println("SPIFFS failed!");
        while (1) delay(1000);
    }

    // Ensure /photos directory exists
    if (!SPIFFS.exists("/photos")) {
        File dir = SPIFFS.open("/photos", "w");
        if (dir) dir.close();
    }

    WiFi.softAP(AP_SSID, AP_PASS);
    Serial.println("AP IP: " + WiFi.softAPIP().toString());

    server.on("/",       HTTP_GET,  handleRoot);
    server.on("/list",   HTTP_GET,  handleList);
    server.on("/delete", HTTP_GET,  handleDelete);
    server.on("/upload", HTTP_POST, handleUpload, handleUploadBody);
    server.begin();

    rebuildList();
    if (!photoList.empty()) showPhoto(photoList[0]);
    else                    showNoPhotos();
    lastSlide = millis();
}

// ─── Loop ─────────────────────────────────────────────────────────
void loop() {
    server.handleClient();

    unsigned long now = millis();
    if (now - lastSlide >= (unsigned long)SLIDE_MS || photosChanged) {
        photosChanged = false;
        rebuildList();
        if (!photoList.empty()) {
            currentIndex = (currentIndex + 1) % (int)photoList.size();
            showPhoto(photoList[currentIndex]);
        } else {
            showNoPhotos();
        }
        lastSlide = now;
    }
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik