Introduction: ESP32 (ESP32-CAM With OV2640) Using SIP(HSPI) for TFT and SD-card at the Same Time (inc. PSRAM, Camera and Switch)

The project uses an ESP32-CAM with OV2640 camera, and an Adafruit "2.2inch 18-bit color TFT LCD display with microSD card breakout" (ILI9340C 320x240pixels).

The project takes one or several photos and stores them on the SD card. Then, when not-taking-photos, it automatically displays the stored photos on the TFT screen.

A quick-press of the large-button takes a photo. A long-press of the large-button deletes all the photos.

(The small-button on the proto-type is connected to GPIO-0 and puts the ESP32 into Sketch-upload-mode.) 

The PCB shown is my proto-type.

I also have a boxed-unit which is used by my elderly parents. Their grandchildren take photos (of themselves) whenever they visit, so my parents can see/remember who has visited recently.

Step 1: Connections

The Adafruit-TFT is connected to the ESP32-Cam as follows:

  •  Gnd   to 0v
  •  Vin   to 3.3v
  •  D/C   to GPIO-2
  •  RST   not-connected
  •  SD-CS to GPIO-1 via 1k-resistor for safety
  •  LCD-CS to GPIO-15
  •  MOSI  to GPIO-13
  •  MISO  to GPIO-12
  •  SCK   to GPIO-14
  •  Light not-connected

The large-button (normally open) is conneted to GPIO-3 via a 1k-resistor for safety, and to ground. (This is used for taking photos.)

The small-button (normally open) (on the proto-type only) is conneted to GPIO-0, and to ground. (This is used for programming the ESP32.)

Note that GPIO-16 is internally connected to the PSRAM-enable (but might be able to use or something else as well if careful)

Note that GPIO-0 is internally connected to the OV2640-camera (can't use for anything else after boot if you are using the camera)

Note also that this project uses the SD-card on the TFT-display, because this one is connected in SPI mode to the same HSPI bus as the TFT.

(The SD-card included on the ESP32-cam-board is connected in SD-4-bit-mode, which uses some of the HSPI pins, so I don't think can be used at the same time as using a TFT on the HSPI bus.)

Step 2: Uploading Software

Pressing the small-button at Boot puts the ESP32 into Sketch-upload-mode.

The 3 visible 0.1" pins on the proto-type are for connecting a Serial-to-USB cable for uploading the Sketch.

Step 3: Power Usage

The 2 visible 0.1" pins on the proto-type are for conncting +5V and 0V power supply (current draw when TFT is showing photos is typically 120 to 140mA)

Step 4: Software

// NB. I am using the Arduino IDE v1.8.19,
// and the ESP32 software from "https://github.com/espressif/arduino-esp32", BUT the ESP32 software version downloaded on 28May21.
// (Note that the version as at now (19Aug22) seems to have a bug in it that stops the camera giving a valid photo.)
// I am using Board="AI Thinker ESP32-CAM"

#include "FS.h"
#include <SD.h>
#include <SPI.h>
#include <Adafruit_GFX.h>    // https://github.com/adafruit/Adafruit-GFX-Library
#include <Adafruit_ILI9341.h> // https://github.com/adafruit/Adafruit_ILI9341
#include <TJpg_Decoder.h>    // https://github.com/Bodmer/TJpg_Decoder
#include "esp_camera.h"
#define CAMERA_MODEL_AI_THINKER // Has PSRAM
#include "camera_pins.h"
#define BLOCK_SERIAL // comment-out to use GPIO-1 & GPIO-3 for Serial-output instead (useful for debugging Camera & TFT)

#ifdef BLOCK_SERIAL
 #define Serial tft
 #define SD_CS 1     // via 1k for safety
 #define SWITCH_PIN 3 // via 1k for safety
#else
 // GPIO-1 is ESP32-Tx
 // GPIO-3 is ESP32-Rx
#endif

#define RED_LED 33
#define SPI_MOSI 13
#define SPI_MISO 12
#define SPI_CLK 14
#define TFT_DC   2 // Data Command control pin
#define TFT_CS  15 // Chip select control pin
#define TFT_RST -1 // or can connect to GPIO-16 via 1k for safety (but GPIO also used for the PSRAM)

SPIClass MySPI(HSPI); // Declare an HSPI bus object (to use or BOTH TFT and SD-card)
Adafruit_ILI9341 tft = Adafruit_ILI9341(&MySPI, TFT_DC, TFT_CS, TFT_RST);
// Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, SPI_MOSI, SPI_CLK, TFT_RST,SPI_MISO);
#define TFT_ROTATION 0 // 0 to 3
boolean camera_ok;

// **************************************************
void setup() { // **************************************
 tft.begin();
 tft.setRotation(TFT_ROTATION);
 tft_clear();
 tft.println("Hello World");
 #ifdef RED_LED
   pinMode(RED_LED, OUTPUT);
   LED_flash(2);
 #endif
 #ifdef BLOCK_SERIAL
   #ifdef SWITCH_PIN
     pinMode(SWITCH_PIN, INPUT_PULLUP);
   #endif
   #ifdef SD_CS
     Serial.print("SDcard: ");
     boolean success=SD.begin(SD_CS,MySPI);
     if (success) {Serial.println("OK");} // works!
     else {Serial.println("Fail: check formatted SD-card is in!"); return;} // dont do the rest of setup()!
   #endif
 #else
   Serial.begin(115200);
   Serial.println("ESP32-CAM WITHOUT SD-Card");
 #endif
 camera_ok=camera_init();
 TJpgDec.setJpgScale(1);
 TJpgDec.setCallback(tft_output); // "tft_output" is passed to the decoder
 Serial.print("\nRunning ");
 if (camera_ok) Serial.println("OK"); else Serial.println("but without Camera");
 delay(5000);
} // end of setup() // ***********************************
// **************************************************

// **************************************************
void loop() { //****************************************
 #define photo_count_max 12 // 0 to 11
 static int photo_next_free=photo_count_max; // 0=none, -1=none-already-reported // set to "photo_count_max" on boot so will count number of photos!
 #ifdef SWITCH_PIN
   boolean push_switch=!digitalRead(SWITCH_PIN);
   if (push_switch) {
     tft_clear();
     Serial.println("Release for photo OR");
     Serial.println("Hold 3s = delete ALL");
     int x;
     for (x=0;x<12;x++) {delay(250); if (digitalRead(SWITCH_PIN)) break;} // allow for switch bounce
     if (x==12) {push_switch=false; sd_wipe_all(); photo_next_free=0;}
   }
 #else
   delay(5000);
   boolean push_switch=true;
 #endif
 if (push_switch) photo_next_free=take_picture(photo_next_free);
 else if (photo_next_free>0) {photo_next_free=sd_load_and_display(photo_next_free);} // NEXT-EMPTY photo-count
 else if (photo_next_free==0) {photo_next_free=-1; tft_clear(); Serial.println("No photos!");} // only report once
} // end of loop() // ************************************
// **************************************************

#ifdef RED_LED
 void LED_flash(int count) {
   count*=2;
   boolean state=LOW; // LOW is ON
   while (count--) {digitalWrite(RED_LED,state); state=!state; delay(1000);}
 }
#endif

// **************************************************

#if ((TFT_ROTATION&B1)==1)
 #define tft_text_row 0
#else
 #define tft_text_row 260 // 240x320
#endif

void tft_top() {tft.setCursor(0,0);}
void tft_text() {tft.setCursor(0,tft_text_row);}

void tft_clear() {
 tft.fillScreen(ILI9341_BLACK);
 tft.setCursor(0,0); // top
 tft.setTextColor(ILI9341_WHITE);
 tft.setTextSize(2); // 1 to 5 // was 4
}

boolean tft_output(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t* bitmap) {
 if (y>=tft.height()) return false;
 tft.drawRGBBitmap(x, y, bitmap, w, h);
 return true;
}

// **************************************************

char* sd_build_file_name(int photo) {
 if (photo>999) photo=999;
 static char text[]="/photoXXX.jpg";
 snprintf(text+6,3+1,"%d",photo);
 char *p1=text+strlen(text);
 strcpy(p1,".jpg");
 return text;
}

void sd_wipe_all() {
 tft_clear();
 Serial.println("Deleting photos:");
 boolean found=false;
 fs::FS &fs = SD;
 for (int x=(photo_count_max-1);x>=0;x--) {
   char *path=sd_build_file_name(x);
   boolean success=fs.remove(path);
   if (success) found=true;
   if (found) {
     Serial.print(path+1); Serial.print("->");
     if (success) Serial.println("Deleted"); else Serial.println("Failed");
 } }
 if (!found) Serial.println("No photos to delete!");
 delay(5000);
}

int sd_load_and_display(int photo_next) { // returns NEXT_EMPTY_photo_count (ie. 0 if no photos)
 #ifdef BLOCK_SERIAL
   if ((photo_next<0) or (photo_next>=photo_count_max)) photo_next=0;
   int first_blank=photo_count_max;
   for (int y=0;y<photo_count_max;y++) {
     char *path=sd_build_file_name(photo_next);
     uint16_t w=0, h=0;
     TJpgDec.getSdJpgSize(&w,&h,path);
     tft_clear();
     if (w) {
       TJpgDec.drawSdJpg(0,0,path);
       #if (tft_text_row==0)
         delay(5000); // bodge!
       #endif
       tft_text();
       Serial.println(path+1);
       if (!camera_ok) Serial.println("(camera failed)");
       boolean quit=false;
       for (uint32_t x=0;x<20;x++) {if (!digitalRead(SWITCH_PIN)) {quit=true; break;} delay(500);} // show for upto 10secs
       if (quit) break;
     } else if (first_blank==photo_count_max) first_blank=photo_next;
     if (photo_next>=(photo_count_max-1)) photo_next=0; else photo_next++; // next photo
   }
   return first_blank; // NEXT_EMPTY photo_count (0 if empty, photo_count_max if full)
 #else
   Serial.println("Can't use SD-Card as uses GPIO-1");
   return 0;
 #endif
}

// **************************************************

boolean camera_init() {
 camera_config_t config;
 config.ledc_channel = LEDC_CHANNEL_0;
 config.ledc_timer = LEDC_TIMER_0;
 config.pin_d0 = Y2_GPIO_NUM;
 config.pin_d1 = Y3_GPIO_NUM;
 config.pin_d2 = Y4_GPIO_NUM;
 config.pin_d3 = Y5_GPIO_NUM;
 config.pin_d4 = Y6_GPIO_NUM;
 config.pin_d5 = Y7_GPIO_NUM;
 config.pin_d6 = Y8_GPIO_NUM;
 config.pin_d7 = Y9_GPIO_NUM;
 config.pin_xclk = XCLK_GPIO_NUM;
 config.pin_pclk = PCLK_GPIO_NUM;
 config.pin_vsync = VSYNC_GPIO_NUM;
 config.pin_href = HREF_GPIO_NUM;
 config.pin_sscb_sda = SIOD_GPIO_NUM;
 config.pin_sscb_scl = SIOC_GPIO_NUM;
 config.pin_pwdn = PWDN_GPIO_NUM;
 config.pin_reset = RESET_GPIO_NUM;
 config.xclk_freq_hz = 20000000;
 config.pixel_format = PIXFORMAT_JPEG;
 Serial.print("PSRAM: ");
 if (psramFound()){ // larger bufer
   Serial.println("OK");
   config.frame_size = FRAMESIZE_QVGA;
   config.jpeg_quality = 10;
   config.fb_count = 1;
 } else { // smaller buffer
   Serial.println("FAILED");
   config.frame_size = FRAMESIZE_QQVGA;
   config.jpeg_quality = 12;
   config.fb_count = 1;
 }
 esp_err_t camera_status;
 for (int x=0;x<2;x++) { // try twice as sometimes get error 0x20004
   delay(2000);
   camera_status = esp_camera_init(&config);
   Serial.print("Camera init: ");
   if (camera_status!=ESP_OK) {Serial.print("failed with error 0x"); Serial.println(camera_status,HEX);}
   else {Serial.println("OK"); break;}
 }
 if (camera_status!=ESP_OK) return false; // exit
 sensor_t *sensor = esp_camera_sensor_get();
 if (sensor->id.PID == OV3660_PID) Serial.println("*** WARNING: Old-PID Sensor Detected - need to adjust settings ***");
 return true;
}

int take_picture(int photo_next) {
 int photo_try=photo_next;
 if ((photo_try<0) or (photo_try>=photo_count_max)) photo_try=0;
 boolean success=false;
 Serial.print("Taking photo...");
 #ifndef BLOCK_SERIAL
   tft_clear();
   tft.println("Taking photo...");
 #endif
 delay(1000);
 camera_fb_t * fb=nullptr;
 fb = esp_camera_fb_get();
 if (!fb) Serial.println("Fail?\nTry rebooting!");
 else {
   Serial.println();
   char *path=sd_build_file_name(photo_try);
   #ifdef BLOCK_SERIAL
     fs::FS &fs = SD;
     File file = fs.open(path, FILE_WRITE); // will overwrite any existing file!!
     if (file) {
       file.write(fb->buf, fb->len);
       photo_try++; photo_next=photo_try; // can give "photo_count_max" and return this to calling sub!
       success=true;
     }
     file.close();
   #else
     success=true;
   #endif
   uint16_t w=0, h=0;
   TJpgDec.getJpgSize(&w, &h, fb->buf, fb->len);
   TJpgDec.drawJpg(0, 0, fb->buf, fb->len); // Display photo
   #if (tft_text_row==0)
     delay(5000); // bodge
   #endif
   if (success) tft_text();
   #ifdef BLOCK_SERIAL // ensure following photo is deleted (so on re-start we know where to start)!
    Serial.print(path+1);
    Serial.print("->");
    if (success) {Serial.println("Saved"); char *path=sd_build_file_name(photo_next); fs.remove(path);} else Serial.println("Fail");
   #else
     tft.print(path+1);
     tft.println("->No Save");
   #endif
 }
 delay(5000);
 return photo_next;
}


Step 5: Addendum

The final boxed-version is shown in the photo. This uses a bigger 4” tft which uses the ST7796S controller chip, and I use the https://github.com/prenticedavid/Adafruit_ST7796S_kbv library.

Note however that the ST7796S boards which I used needed a modification (D1 on TFT_CS needs shorting-out) before they can be used with an SPI-SD-Card on the same SPI bus, as described in https://github.com/Bodmer/TFT_eSPI/discussions/898.

PS. I also use a 1k resistor between the TFT MISO and the SD MISO so if one is trying to pull MISO high at the same time the other is pulling MISO low, then this wont damage any drivers. (Note also that the ESP32 GPIO-12 (MISO) is said to have a weak(~24k) pulldown.)

I also changed some of the pins as follows:

TFT_CS to GPIO-1

TFT_DC to GPIO-3

SD_CS to GPIO-15

Switches to GPIO-2 (which can be AnalogRead)

Step 6: Pin-Multi-Use

Also note that due to the low number of pins on the ESP32-CAM boards I was using, I added the following external circuits:

(1)           An auto-reset circuit on the TFT-rst-pin.

(2)           An external TFT-BackLight-circuit to allow the disconnection of 3v3 power to the TFT-backlight-pin (LED-pin) when I put the ESP32 to sleep (uses the TFT-DC-pin to turn the backlight on when active, which is normally ON all the time as in use TFT-DC is high most of the time).

(3)           A resistor chain to allow 4 pushswitches on the single ESP32-GPIO2 pin (configured so any one of the switches will cause a digitalRead-low to trigger an interrupt, and then my software uses analogRead to work out which button was pressed.

(4)   I also added a buzzer on ESP32-GPIO4 (the high-intensity-LED pin)

 The above partial schematic shows these circuits.

 The code I use to put the TFT to sleep (and switch off the backlight) is:

void tft_sleep_now() {
 uint8_t end_data = 0x08; // end-of-transmission!??
 tft.sendCommand(ST7796S_SLPIN, &end_data, 1);
 delay(150);
 digitalWrite(TFT_DC, LOW); // when active this pin is HIGH for most of the time
}

void tft_wake_now() {
 // digitalWrite(TFT_DC,HIGH); // not nec.as is taken high as soon as anything is written
 uint8_t end_data = 0x08; // end-of-transmission!??
 tft.sendCommand(ST7796S_SLPOUT, &end_data, 1);
 delay(150);
}