Community project
WiFi-Connected Photo Carousel
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
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| ILI9488 3.5 inch SPI TFT Display | 1 | 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. |
| TP4056 Li-Ion/LiPo charger module with protection | 1 | 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. |
| LiPo 3.7V 1000mAh Battery | 1 | 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. |
Assembly
6 stepsGather 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.
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.
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.
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.
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.
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/*
* 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.