Community project

Wi-Fi Retro Photo Viewer

ESP32
Photo of Wi-Fi Retro Photo Viewer
Generated with AI

Tanmay sharma

Published October 31, 2026

This Wi-Fi retro photo viewer displays images on a 320x240 TFT touchscreen powered by an ESP32. Users upload photos via a web interface, which the device automatically resizes and converts to a five-color palette for a nostalgic, lo-fi aesthetic. The guide includes a complete wiring diagram, parts list, and step-by-step assembly instructions for integrating the screen, battery charging system, boost converter, and control buttons.

The project runs on a rechargeable 18650 lithium-ion battery with integrated charging and power management. Two push buttons let you navigate between stored photos, while the touchscreen provides an interactive interface. Assembly involves connecting ready-made modules, configuring the boost converter to 5 volts, and wiring the ESP32 to the display and input controls. The included firmware handles Wi-Fi connectivity, photo upload, image processing, and display rendering.

Wiring diagram

Loading diagram…

Gather all the parts

QtyComponent
1

ILI9341 TFT Touchscreen

240x320 SPI TFT display using the ILI9341 LCD controller with an XPT2046 resistive touch controller sharing the SPI bus

1

Push Button

Photo button

Momentary push button switch

1

Push Button

Photo/palette button

Momentary push button switch

1

Universal USB / DC / Solar Lithium Ion/Polymer charger

Single-cell LiPo/Li-ion charger board built on the TI bq24074. It charges from USB-C, a 2.1 mm DC jack or a 6-10 V solar panel (5-10 V input, 28 V protected) at 0.5, 1.0 (default) or 1.5 A, and powers a load from the OUT pin or Load Out JST at up to 4.4 V while sharing power with the battery. CHG and PGOOD open-drain status pins and CE/THERM/ISET control pins are on a 0.1 in header.

1

Boost Converter

Adjusted to 5.0 V

Small adjustable MT3608-style DC-DC boost converter module for stepping a lower DC input up to a higher rail such as 5V, 9V, or 12V. It is a power-path module with VIN/VOUT terminals, not a GPIO peripheral.

1

Protected 3.7 V 18650 Li-ion battery with lead

3.7 V nominal, 2600 mAh or larger

A protected single-cell rechargeable Li-ion battery that stores energy for portable use.

1

SPST panel-mount slide power switch

A two-terminal on/off switch that disconnects the regulated supply from the viewer.

Assemble it in 9 steps

1. Mount the ready-made modules

Fit the 2.8-inch screen, ESP32 board, USB-C charger board, 5 V boost converter, slide switch, and two buttons into a non-metal enclosure or on a breadboard. Keep the battery where it cannot be crushed or rubbed by sharp screw heads.

  • Leave the charger USB-C socket and the ESP32 USB socket reachable from outside the case.
  • Do not put a loose Li-ion cell in a case where its metal body can touch bare wires; a short circuit can make the battery hot or start a fire.

2. Connect the protected battery to the charger

With the slide switch OFF and no USB cable connected, connect the red BAT+ lead from battery_18650 to the LIPO terminal on charger_bq24074. Connect the black BAT- lead to the charger GND_3 terminal.

  • Use a protected 18650 holder with red and black leads, or a protected single-cell pack with a matching connector.
  • Red and black must not be swapped; reversed battery power can damage the charger and battery. Never connect an unprotected bare cell.

3. Make the charger-to-boost link

Connect charger_bq24074 OUT to boost_5v VIN+. Connect boost_5v VIN- to charger_bq24074 GND_2. These two wires carry battery or USB power through the charger's load-sharing output.

  • Use short, thicker wires for this pair because the screen and Wi-Fi can draw short bursts of current.
  • Do not connect the battery straight to the boost converter; use the charger's OUT terminal so USB charging and running the viewer share power safely.

4. Set the boost converter to 5 volts

Turn the slide switch OFF. Temporarily power charger_bq24074 from its USB-C charging socket, then use a multimeter across boost_5v VOUT+ and VOUT-. Turn the small adjustment screw until the meter reads 5.0 V. Unplug USB afterward.

  • Mark the adjusted screw with a paint dot so it is easy to spot later.
  • Do not connect the ESP32 until this reads 5.0 V; a higher setting can damage the ESP32 or screen.

5. Wire the on/off switch to the ESP32

Connect boost_5v VOUT+ to power_switch IN. Connect power_switch OUT to the ESP32 VIN or 5V pin. Connect boost_5v VOUT- to an ESP32 GND pin. The switch now removes power from the viewer while leaving the battery able to charge.

  • Place the switch in the positive wire only; leave all ground wires connected together.
  • Do not feed the 5 V boost output into the ESP32 3V3 pin; that bypasses the board regulator and can damage the board.

6. Power the screen from the ESP32

Connect tft_ili9341 VCC to the ESP32 3V3 pin and tft_ili9341 GND to an ESP32 GND pin. Connect tft_ili9341 TFT_BL to the same 3V3 pin so the backlight stays on.

  • The display logic is 3.3 V, matching the ESP32, so no signal-level converter is needed.
  • Make sure VCC and GND are not swapped; swapped power can damage the screen. Do not connect this display VCC to the 5 V boost rail.

7. Wire the screen data pins

Connect screen SCK to ESP32 GPIO18 (clock), MOSI to GPIO23 (data to the screen), MISO to GPIO19 (shared read wire), TFT_CS to GPIO4 (screen select), TFT_DC to GPIO27 (screen command/data), TFT_RST to GPIO26 (screen reset), and TOUCH_CS to GPIO25 (keeps the unused touch chip quiet). Leave TOUCH_IRQ unconnected.

  • Keep these signal wires short and route them away from the boost converter coil to reduce screen glitches.
  • Do not use the screen's 5 V-labelled power option when building this design; its data pins must stay at safe 3.3 V levels.

8. Wire the two photo buttons

For next_button, connect one leg to ESP32 GPIO32 (signal) and the other leg to GND (ground). For view_button, connect one leg to ESP32 GPIO33 (signal) and the other leg to GND (ground). The firmware supplies the needed pull-up inside the ESP32, so no extra resistors are required.

  • On a four-leg tactile switch, use two legs on opposite sides of the switch; the two legs on one side are already connected together.
  • Do not connect either button to 3V3; this design expects a press to connect the GPIO pin to ground.

9. Check before closing the case

With the power switch OFF, check that every module ground joins the ESP32 GND connection. Turn the switch ON and confirm the screen lights. Connect a USB-C cable to charger_bq24074 to charge the battery; its charge indicator should show charging when needed.

  • The viewer can run while charging because the charger board has a separate load output.
  • Use a normal 5 V USB supply for charging. Stop immediately if the battery, charger, or wires become hot.

Review all connections

1. Connections between "tft_ili9341" and "ESP32"

Functiontft_ili9341ESP32
powerVCC → 3.3 V display power3V3
groundGND → display groundGND
spiMOSI → SPI data to displayGPIO 23
spiMISO → shared SPI read line for touch controllerGPIO 19
spiSCK → SPI clockGPIO 18
digitalTFT_CS → display selectGPIO 4
digitalTFT_DC → display data/commandGPIO 27
digitalTFT_RST → display resetGPIO 26
digitalTOUCH_CS → keeps unused touch controller deselectedGPIO 25
powerTFT_BL → backlight always on3V3

2. Connections between "next_button" and "ESP32"

Functionnext_buttonESP32
groundGND → button groundGND
digitalSIGNAL → next-photo input with internal pull-upGPIO 32

3. Connections between "view_button" and "ESP32"

Functionview_buttonESP32
groundGND → photo/palette input with internal pull-upGND
digitalSIGNAL → view-toggle input with internal pull-upGPIO 33

4. Connections between "battery_18650" and "ESP32"

Functionbattery_18650ESP32
powerBAT+ → Universal USB / DC / Solar Lithium Ion/Polymer charger LIPOEXT
groundBAT- → Universal USB / DC / Solar Lithium Ion/Polymer charger GND_3EXT

5. Connections between "charger_bq24074" and "ESP32"

Functioncharger_bq24074ESP32
groundGND → common power groundGND
powerOUT → Boost Converter VIN+EXT

6. Connections between "boost_5v" and "ESP32"

Functionboost_5vESP32
groundVIN- → Universal USB / DC / Solar Lithium Ion/Polymer charger GND_2EXT
powerVOUT+ → SPST panel-mount slide power switch INEXT
groundVOUT- → regulated-output groundGND

7. Connections between "power_switch" and "ESP32"

Functionpower_switchESP32
powerOUT → switched 5 V to ESP32 VINVIN

Deploy the firmware

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <SPI.h>
#include <FS.h>
#include <SPIFFS.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>

#define TFT_CS 4
#define TFT_DC 27
#define TFT_RST 26
#define TFT_TOUCH_CS 25
#define NEXT_BUTTON 32
#define VIEW_BUTTON 33
#define TFT_SCK 18
#define TFT_MISO 19
#define TFT_MOSI 23

constexpr uint16_t SCREEN_W = 320;
constexpr uint16_t SCREEN_H = 240;
constexpr uint8_t MAX_PHOTOS = 10;
constexpr size_t PHOTO_BYTES = SCREEN_W * SCREEN_H * 2;

Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);
WebServer server(80);
File uploadFile;
int uploadSlot = 0;
int photoCount = 0;
int currentPhoto = 0;
bool paletteMode = false;
bool lastNext = HIGH;
bool lastView = HIGH;
unsigned long lastNextPress = 0;
unsigned long lastViewPress = 0;

const char PAGE[] PROGMEM = R"HTML(
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{font:16px sans-serif;max-width:520px;margin:20px auto;padding:0 14px;background:#202028;color:#fff}input,button{font:inherit;margin:8px 0;padding:10px;width:100%;box-sizing:border-box}button{background:#ffb000;border:0;border-radius:6px;font-weight:bold}small{color:#c8c8d0}</style></head><body>
<h2>Retro Viewer</h2><p>Choose a photo and a numbered picture slot. The phone resizes it to the screen and chooses five representative colours.</p>
<label>Picture slot (0–9)</label><input id="slot" type="number" min="0" max="9" value="0">
<input id="file" type="file" accept="image/*"><button onclick="send()">Send picture and palette</button><p id="msg"></p>
<small>After sending, press the photo button to move to the next stored picture. The second button shows its palette.</small>
<script>
async function send(){const f=document.getElementById('file').files[0],msg=document.getElementById('msg');if(!f){msg.textContent='Choose a picture first.';return}const slot=Math.max(0,Math.min(9,+document.getElementById('slot').value||0));msg.textContent='Resizing picture…';const im=new Image();im.src=URL.createObjectURL(f);await im.decode();const c=document.createElement('canvas');c.width=320;c.height=240;const x=c.getContext('2d');x.drawImage(im,0,0,320,240);const d=x.getImageData(0,0,320,240).data;const raw=new Uint8Array(320*240*2);let p=0;for(let i=0;i<d.length;i+=4){let v=((d[i]&248)<<8)|((d[i+1]&252)<<3)|(d[i+2]>>3);raw[p++]=v&255;raw[p++]=v>>8}let pal=new Uint8Array(10);for(let n=0;n<5;n++){let px=Math.floor((n+.5)*320/5),py=120,i=(py*320+px)*4,v=((d[i]&248)<<8)|((d[i+1]&252)<<3)|(d[i+2]>>3);pal[n*2]=v&255;pal[n*2+1]=v>>8}try{await fetch('/upload?slot='+slot,{method:'POST',body:raw});await fetch('/palette?slot='+slot,{method:'POST',body:pal});msg.textContent='Saved in slot '+slot+'.'}catch(e){msg.textContent='Transfer failed; stay connected to RetroViewer and try again.'}}
</script></body></html>
)HTML";

String photoPath(int slot) { return "/photo" + String(slot) + ".raw"; }
String palettePath(int slot) { return "/palette" + String(slot) + ".bin"; }

void scanPhotos() {
  photoCount = 0;
  for (int i = 0; i < MAX_PHOTOS; ++i) {
    if (SPIFFS.exists(photoPath(i))) photoCount++;
  }
  if (photoCount == 0) currentPhoto = 0;
  else if (!SPIFFS.exists(photoPath(currentPhoto))) {
    for (int i = 0; i < MAX_PHOTOS; ++i) if (SPIFFS.exists(photoPath(i))) { currentPhoto = i; break; }
  }
}

int nextExistingPhoto(int slot) {
  for (int n = 1; n <= MAX_PHOTOS; ++n) {
    int candidate = (slot + n) % MAX_PHOTOS;
    if (SPIFFS.exists(photoPath(candidate))) return candidate;
  }
  return slot;
}

void showStatus(const char *line1, const char *line2) {
  tft.fillScreen(ILI9341_BLACK);
  tft.setTextColor(ILI9341_ORANGE, ILI9341_BLACK);
  tft.setTextSize(3);
  tft.setCursor(20, 65); tft.print(line1);
  tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
  tft.setTextSize(2);
  tft.setCursor(20, 120); tft.print(line2);
}

void showPhoto() {
  if (!SPIFFS.exists(photoPath(currentPhoto))) { showStatus("RETRO VIEWER", "Connect to RetroViewer Wi-Fi"); return; }
  File image = SPIFFS.open(photoPath(currentPhoto), FILE_READ);
  if (!image || image.size() != PHOTO_BYTES) { showStatus("PICTURE ERROR", "Upload this slot again"); return; }
  static uint16_t pixels[320];
  tft.startWrite();
  tft.setAddrWindow(0, 0, SCREEN_W, SCREEN_H);
  bool first = true;
  while (image.available()) {
    size_t got = image.read(reinterpret_cast<uint8_t *>(pixels), sizeof(pixels));
    if (got == 0) break;
    tft.writePixels(pixels, got / 2, true, false);
    first = false;
  }
  tft.endWrite();
  image.close();
}

void showPalette() {
  uint16_t colors[5] = {ILI9341_DARKGREY, ILI9341_DARKGREY, ILI9341_DARKGREY, ILI9341_DARKGREY, ILI9341_DARKGREY};
  File palette = SPIFFS.open(palettePath(currentPhoto), FILE_READ);
  if (palette && palette.size() == 10) palette.read(reinterpret_cast<uint8_t *>(colors), 10);
  if (palette) palette.close();
  tft.fillScreen(ILI9341_BLACK);
  tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
  tft.setTextSize(2); tft.setCursor(12, 12); tft.printf("PALETTE  %d", currentPhoto + 1);
  for (int i = 0; i < 5; ++i) {
    int x = 12 + i * 62;
    tft.fillRoundRect(x, 52, 52, 125, 7, colors[i]);
    tft.drawRoundRect(x, 52, 52, 125, 7, ILI9341_WHITE);
    uint8_t r = ((colors[i] >> 11) & 0x1F) * 255 / 31;
    uint8_t g = ((colors[i] >> 5) & 0x3F) * 255 / 63;
    uint8_t b = (colors[i] & 0x1F) * 255 / 31;
    tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
    tft.setTextSize(1); tft.setCursor(x, 190); tft.printf("#%02X%02X%02X", r, g, b);
  }
  tft.setTextSize(1); tft.setCursor(12, 222); tft.print("Press view button to return to photo");
}

void refreshDisplay() { if (paletteMode) showPalette(); else showPhoto(); }

void beginUpload(const char *extension) {
  HTTPUpload &upload = server.upload();
  if (upload.status == UPLOAD_FILE_START) {
    uploadSlot = constrain(server.arg("slot").toInt(), 0, MAX_PHOTOS - 1);
    String path = String(extension) == ".raw" ? photoPath(uploadSlot) : palettePath(uploadSlot);
    if (SPIFFS.exists(path)) SPIFFS.remove(path);
    uploadFile = SPIFFS.open(path, FILE_WRITE);
  } else if (upload.status == UPLOAD_FILE_WRITE && uploadFile) {
    uploadFile.write(upload.buf, upload.currentSize);
  } else if (upload.status == UPLOAD_FILE_END && uploadFile) {
    uploadFile.close();
  }
}

void handleImageUpload() { beginUpload(".raw"); }
void handlePaletteUpload() { beginUpload(".bin"); }

void setup() {
  pinMode(NEXT_BUTTON, INPUT_PULLUP);
  pinMode(VIEW_BUTTON, INPUT_PULLUP);
  pinMode(TFT_TOUCH_CS, OUTPUT); digitalWrite(TFT_TOUCH_CS, HIGH);
  SPI.begin(TFT_SCK, TFT_MISO, TFT_MOSI, TFT_CS);
  tft.begin(); tft.setRotation(1);
  if (!SPIFFS.begin(true)) { showStatus("FLASH ERROR", "Could not start photo storage"); }
  else showStatus("RETRO VIEWER", "Starting Wi-Fi...");
  WiFi.mode(WIFI_AP);
  WiFi.softAP("RetroViewer", "picturebox");
  server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", PAGE); });
  server.on("/upload", HTTP_POST, []() { scanPhotos(); paletteMode = false; refreshDisplay(); server.send(200, "text/plain", "Picture saved"); }, handleImageUpload);
  server.on("/palette", HTTP_POST, []() { server.send(200, "text/plain", "Palette saved"); }, handlePaletteUpload);
  server.begin();
  scanPhotos();
  refreshDisplay();
}

void loop() {
  server.handleClient();
  unsigned long now = millis();
  bool nextNow = digitalRead(NEXT_BUTTON);
  bool viewNow = digitalRead(VIEW_BUTTON);
  if (lastNext == HIGH && nextNow == LOW && now - lastNextPress > 180) {
    lastNextPress = now;
    if (photoCount > 0) { currentPhoto = nextExistingPhoto(currentPhoto); paletteMode = false; refreshDisplay(); }
  }
  if (lastView == HIGH && viewNow == LOW && now - lastViewPress > 180) {
    lastViewPress = now;
    if (photoCount > 0) { paletteMode = !paletteMode; refreshDisplay(); }
  }
  lastNext = nextNow;
  lastView = viewNow;
  delay(2);
}

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