Community project

WiFi-Connected Photo Carousel

Hussain Shaheen

Published July 14, 2026

ESP323 components6 assembly steps
Remix this project

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

Interactive · read-only
Wiring diagram for WiFi-Connected Photo Carousel

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
ILI9488 3.5 inch SPI TFT Display13.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.
TP4056 Li-Ion/LiPo charger module with protection1TP4056 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.
LiPo 3.7V 1000mAh Battery1Single-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.

Assembly

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.

    • Tip: 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.

    • Tip: All GND connections share a common rail — connect them together on a breadboard GND rail.
    • Tip: 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

    • Tip: The ILI9488 module runs on 3.3V logic — no level shifters needed with the ESP32.
    • Tip: 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.

    • Tip: 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.

    • Tip: 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.

Firmware

ESP32
firmware.cppDeploy to device
/*
 * 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;
    }
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

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