ESP32-Cam Data Retention
This post will show how to use the ESP32-Cam module microSD media as non volatile storage for variables. Flash write cycles have a limitation which can limit the life of the module if overused. MicroSD media can be easily replaced as needed and offers a more suitable alternative when the module is repeatedly power cycled.
The module will be used to capture images of the interior of a vehicle when in operation. Each time the vehicle is operated, a new folder is created with a number variable referenced from the microSD media. Images are then stored to that new folder. With each successive trip, new folders are created and images for each trip are stored in its respective folder.
Along with image capture, the ESP32-Cam module WiFi library will be used to scan for available networks it can detect. Those results will also be stored for each trip when the vehicle is operated. Additional modules were added to expand the logging to support GPS, Barometric (Altitude and Temperature), Triple Axis Magnetometer (Compass readings along 3 planes), and Triple Axis Gyroscope readings. These added modules are handled by an Arduino Pro Mini due to the IO limits of the ESP32-Cam module. The following diagram shows how each are interconnected with a breadboard, the final build mirrors this.
Here is a list of the parts for the build.
- ESP32-Cam Module
- Arduino Pro Mini 8Mhz 3.3V
- NEO06MV2 GPS Module (Warning below)
- GY-521 3-Axis Gyroscope
- GY-271 3-Axis Compass
- GY-68 Barometer
The GPS data is a serial stream at 9600 baud and is sent to Pin 2 of the Arduino Pro Mini. The Pro Mini sends its data to the ESP32-Cam module as a serial stream at 9600 baud. The remaining sensors use the I2C protocol and interface with the Pro Mini using its standard data and clock pins.
Power is supplied by the vehicle through a 12v to 5v USB adapter rated at 2 amps. The ESP32-Cam module includes a programming breakout board with a USB port. This then splits out and supplies power to the GPS, Pro Mini, and ESP32-Cam modules. The Pro Mini provides the 3.3 volt supply for the I2C sensors.
The ESP32-Cam module can be removed from its breakout board to be serviced, updated, or replaced as needed. The Pro Mini has header pins to allow it to be updated with new firmware if needed. All of the items are contained in a housing that is mounted overhead on the front cabin light console, with the exception of the GPS antenna, which is mounted forward facing.
Here is the code used for the ESP32-Cam module.
// Libraries #include "FS.h" // SD Card ESP32 #include "SD_MMC.h" // SD Card ESP32 #include "WiFi.h" #include "esp_camera.h" // Variables and Constants String GPSReadings = "/GPS_I2C_Readings.csv"; String WifiScanReadings = "/Wifi_Readings.csv"; String BootCounter = "/BootCounter.txt"; String StringBootReference = ""; String RetentionDirectory = "Data_"; String InboundSerialDataString = ""; String MillisString; //boolean stringComplete = false; // whether the string is complete unsigned long ExecuteInterval = 2000; unsigned long LastInterval = 0; unsigned long CurrentInterval = 0; int HoldInterval = 0; unsigned long last = 0UL; // For stats that happen every 5 seconds float OnboardSeconds; // Pin definition for CAMERA_MODEL_AI_THINKER #define PWDN_GPIO_NUM 32 #define RESET_GPIO_NUM -1 #define XCLK_GPIO_NUM 0 #define SIOD_GPIO_NUM 26 #define SIOC_GPIO_NUM 27 #define Y9_GPIO_NUM 35 #define Y8_GPIO_NUM 34 #define Y7_GPIO_NUM 39 #define Y6_GPIO_NUM 36 #define Y5_GPIO_NUM 21 #define Y4_GPIO_NUM 19 #define Y3_GPIO_NUM 18 #define Y2_GPIO_NUM 5 #define VSYNC_GPIO_NUM 25 #define HREF_GPIO_NUM 23 #define PCLK_GPIO_NUM 22 // Routines and Subroutines // Hardware Initialization Routine void HardwareInit() { //Default baud of NEO-6M is 9600 Serial.begin(9600); delay(100); StartupCamera(); delay(100); StartupMicroSD(); delay(100); } void StartupCamera() { 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; //YUV422,GRAYSCALE,RGB565,JPEG config.frame_size = FRAMESIZE_XGA; // FRAMESIZE_ + QVGA (320 x 240) | CIF (352 x 288) |VGA (640 x 480) | SVGA (800 x 600) |XGA (1024 x 768) |SXGA (1280 x 1024) |UXGA (1600 x 1200) config.jpeg_quality = 10; // 10-63 lower number means higher quality config.fb_count = 1; // Init Camera esp_err_t err = esp_camera_init(&config); if (err != ESP_OK) { return; } sensor_t * s = esp_camera_sensor_get(); s->set_whitebal(s, 0); // 0 = disable , 1 = enable s->set_awb_gain(s, 0); // 0 = disable , 1 = enable } void StartupMicroSD() { if(!SD_MMC.begin()){ return; } uint8_t cardType = SD_MMC.cardType(); if(cardType == CARD_NONE){ return; } } //Append to the end of file in SD card void appendFile(fs::FS &fs, const char * path, const char * message){ File file = fs.open(path, FILE_APPEND); if(!file){ return; } if(file.print(message)){ } else { } } //Read variable from file in SD card int readBootReference(fs::FS &fs, const char *path) { File file = fs.open(path); if (!file) { return -1; } int value = file.parseInt(); file.close(); return value; } //Write variable to file in SD card void writeBootReference(fs::FS &fs, const char *path, int value) { File file = fs.open(path, FILE_WRITE); if (!file) { return; } file.print(value); file.close(); } //Create directory in SD card void createDir(fs::FS &fs, const char * path){ if(fs.mkdir(path)){ } else { } } void BootCounterCheck() { int BootReference = readBootReference(SD_MMC, BootCounter.c_str()); if (BootReference == -1) { BootReference = 1; writeBootReference(SD_MMC, BootCounter.c_str(), BootReference); StringBootReference = String(BootReference); } else { BootReference++; writeBootReference(SD_MMC, BootCounter.c_str(), BootReference); StringBootReference = String(BootReference); } } void PrintHeader() { RetentionDirectory = "/Data_" + StringBootReference; createDir(SD_MMC, RetentionDirectory.c_str()); GPSReadings = RetentionDirectory + "/" + StringBootReference + "_GPS_I2C_Readings.csv"; WifiScanReadings = RetentionDirectory + "/" + StringBootReference + "_Wifi_Readings.csv"; appendFile(SD_MMC, GPSReadings.c_str(), "Arduino_ProMini_GPS_I2C_ver4"); appendFile(SD_MMC, GPSReadings.c_str(), "\n"); appendFile(SD_MMC, GPSReadings.c_str(), "OnboardSeconds,Lat,Long,Date,Time,MPH,Course,Altitude,Home,Bearing,Cardinal,Temperature,Pressure,Ralated Atmosphere,Altitude,Compass-X,Compass-Y,Compass-Z,Pitch,Roll,Yaw,SampleCount"); appendFile(SD_MMC, GPSReadings.c_str(), "\n"); appendFile(SD_MMC, WifiScanReadings.c_str(), "ESP32-Cam_Car-Interior-Camera-Sensor_ver4"); appendFile(SD_MMC, WifiScanReadings.c_str(), "\n"); appendFile(SD_MMC, WifiScanReadings.c_str(), "OnboardSeconds,Networks Found,SSID,dBm,AP Mac Address,Channel,Security"); appendFile(SD_MMC, WifiScanReadings.c_str(), "\n"); } // https://mischianti.org/esp32-practical-power-saving-manage-wifi-and-cpu-1/ void disableWiFi(){ // Switch WiFi off WiFi.mode(WIFI_OFF); // Switch WiFi off delay(100); } void enableWiFi(){ // Set WiFi to station mode and disconnect from an AP if it was previously connected. WiFi.mode(WIFI_STA); WiFi.disconnect(); delay(100); } void ScanWifiNetworks() { // WiFi.scanNetworks will return the number of networks found. int IntWifiNetworks = WiFi.scanNetworks(/*async=*/false, /*hidden=*/true); if (IntWifiNetworks == 0) { } else { for (int NetworkCount = 0; NetworkCount < IntWifiNetworks; ++NetworkCount) { OnboardSeconds = millis(); OnboardSeconds = OnboardSeconds/1000; MillisString = String(OnboardSeconds, 3); appendFile(SD_MMC, WifiScanReadings.c_str(), MillisString.c_str()); appendFile(SD_MMC, WifiScanReadings.c_str(), ","); String StringWifiNetworks = String(IntWifiNetworks); // read string until meet newline character appendFile(SD_MMC, WifiScanReadings.c_str(), StringWifiNetworks.c_str()); appendFile(SD_MMC, WifiScanReadings.c_str(), ","); String StringSSID = String(WiFi.SSID(NetworkCount)); if(StringSSID != NULL) { appendFile(SD_MMC, WifiScanReadings.c_str(), StringSSID.c_str()); } else { appendFile(SD_MMC, WifiScanReadings.c_str(), "*.*Hidden*.*"); } appendFile(SD_MMC, WifiScanReadings.c_str(), ","); String StringRSSI = String(WiFi.RSSI(NetworkCount)); appendFile(SD_MMC, WifiScanReadings.c_str(), StringRSSI.c_str()); appendFile(SD_MMC, WifiScanReadings.c_str(), ","); String StringBSSIDstr = String(WiFi.BSSIDstr(NetworkCount)); appendFile(SD_MMC, WifiScanReadings.c_str(), StringBSSIDstr.c_str()); appendFile(SD_MMC, WifiScanReadings.c_str(), ","); String StringChannel = String(WiFi.channel(NetworkCount)); appendFile(SD_MMC, WifiScanReadings.c_str(), StringChannel.c_str()); appendFile(SD_MMC, WifiScanReadings.c_str(), ","); printEncryptionType(WiFi.encryptionType(NetworkCount)); appendFile(SD_MMC, WifiScanReadings.c_str(), "\n"); } } // Delete the scan result to free memory for code below. WiFi.scanDelete(); appendFile(SD_MMC, WifiScanReadings.c_str(), "\n"); } void printEncryptionType(int thisType) { // read the encryption type and print out the name: switch (thisType) { case WIFI_AUTH_OPEN: appendFile(SD_MMC, WifiScanReadings.c_str(), "open"); break; case WIFI_AUTH_WEP: appendFile(SD_MMC, WifiScanReadings.c_str(), "WEP"); break; case WIFI_AUTH_WPA_PSK: appendFile(SD_MMC, WifiScanReadings.c_str(), "WPA"); break; case WIFI_AUTH_WPA2_PSK: appendFile(SD_MMC, WifiScanReadings.c_str(), "WPA2"); break; case WIFI_AUTH_WPA_WPA2_PSK: appendFile(SD_MMC, WifiScanReadings.c_str(), "WPA+WPA2"); break; case WIFI_AUTH_WPA2_ENTERPRISE: appendFile(SD_MMC, WifiScanReadings.c_str(), "WPA2-EAP"); break; case WIFI_AUTH_WPA3_PSK: appendFile(SD_MMC, WifiScanReadings.c_str(), "WPA3"); break; case WIFI_AUTH_WPA2_WPA3_PSK: appendFile(SD_MMC, WifiScanReadings.c_str(), "WPA2+WPA3"); break; case WIFI_AUTH_WAPI_PSK: appendFile(SD_MMC, WifiScanReadings.c_str(), "WAPI"); break; default: appendFile(SD_MMC, WifiScanReadings.c_str(), "unknown"); } } void CaptureImage() { camera_fb_t * fb = NULL; // Take Picture with Camera fb = esp_camera_fb_get(); if(!fb) { return; } // Construct a filename that looks like "/photo_0000000001.jpg" unsigned long pictureNumber = millis(); pictureNumber = pictureNumber / 1000; String filename = RetentionDirectory + "/" + StringBootReference + "_photo_"; if(pictureNumber < 1000000000) filename += "0"; if(pictureNumber < 100000000) filename += "0"; if(pictureNumber < 10000000) filename += "0"; if(pictureNumber < 1000000) filename += "0"; if(pictureNumber < 100000) filename += "0"; if(pictureNumber < 10000) filename += "0"; if(pictureNumber < 1000) filename += "0"; if(pictureNumber < 100) filename += "0"; if(pictureNumber < 10) filename += "0"; filename += pictureNumber; filename += ".jpg"; // Path where new picture will be saved in SD Card String imagefile = String(filename); fs::FS &fs = SD_MMC; File file = fs.open(imagefile.c_str(), FILE_WRITE); file.write(fb->buf, fb->len); // payload (image), payload length // file.close(); esp_camera_fb_return(fb); } // Inbound Serial Data from GPS Module for Processing void InboundSerialData() { // Wait for serial data input, then perform internal functions if(Serial.available()) // if there is data comming { if(Serial.read() == '~') { InboundSerialDataString = Serial.readStringUntil('^'); // read string until meet newline character appendFile(SD_MMC, GPSReadings.c_str(), InboundSerialDataString.c_str()); appendFile(SD_MMC, GPSReadings.c_str(), "\n"); InboundSerialDataString = ""; } } } void setup() { HardwareInit(); BootCounterCheck(); PrintHeader(); } void loop() { InboundSerialData(); CurrentInterval = millis(); if (CurrentInterval - LastInterval >= ExecuteInterval) { if (HoldInterval == 0){ LastInterval = CurrentInterval; HoldInterval = 1; } } if (CurrentInterval - LastInterval >= ExecuteInterval) { if (HoldInterval == 1){ LastInterval = CurrentInterval; enableWiFi(); ScanWifiNetworks(); disableWiFi(); CaptureImage(); HoldInterval = 0; } } }
Here is the code used for the Arduino Pro Mini module. This mirrors the serial stream from the GPS, then it polls and includes the I2C device data in the stream.
// Libraries #include <SoftwareSerial.h> #include <TinyGPS++.h> #include <Wire.h> // Variables and Constants // Set these based on your needs // GPS Home, https://www.google.com/maps static const double SalmonBayPark_LAT = 47.679477, SalmonBayPark_LON = -122.382035; TinyGPSPlus gps; String MillisString; String GpsDataString; String GpsString = ""; // a String to hold incoming data boolean stringComplete = false; // whether the string is complete unsigned long last = 0UL; // For stats that happen every 5 seconds int PadSeconds; int PadMinutes; int PadDays; int PadMonths; float OnboardSeconds; #define SoftRX 2 #define SoftTX 3 SoftwareSerial mySerial(SoftRX, SoftTX); // RX, TX #include "GY521.h" GY521 sensor(0x68); uint32_t counter = 0; #include "BMP085.h" BMP085 myBarometer; float temperature; float pressure; float atm; float altitude; #include <QMC5883LCompass.h> QMC5883LCompass compass; int CompassX = 0; int CompassY = 0; int CompassZ = 0; int SampleCounter = 0; // Routines and Subroutines // Hardware Initialization Routine void HardwareInit() { //Default baud of NEO-6M is 9600 Serial.begin(9600); mySerial.begin(9600); Wire.begin(); delay(100); while (sensor.wakeup() == false) { Serial.print(millis()); Serial.println("\tCould not connect to GY521: please check the GY521 address (0x68/0x69)"); delay(1000); } sensor.setAccelSensitivity(2); // 8g sensor.setGyroSensitivity(1); // 500 degrees/s sensor.setThrottle(); myBarometer.init(); compass.init(); // set calibration values from calibration sketch. sensor.axe = 0.574; sensor.aye = -0.002; sensor.aze = -1.043; sensor.gxe = 10.702; sensor.gye = -6.436; sensor.gze = -0.676; } // Printout Seconds Since Bootup Timestamp void PrintOutMillis() { Serial.print("~"); OnboardSeconds = millis(); OnboardSeconds = OnboardSeconds/1000; Serial.print(OnboardSeconds, 3); Serial.print(","); } // Printout Location Routine void PrintOutLocation() { Serial.print(gps.location.lat(), 6); Serial.print(","); Serial.print(gps.location.lng(), 6); Serial.print(","); } // Printout Date Routine void PrintOutDate() { PadMonths = gps.date.month(); if (PadMonths < 10) { Serial.print("0"); } Serial.print(gps.date.month()); Serial.print("/"); PadDays = gps.date.day(); if (PadDays < 10) { Serial.print("0"); } Serial.print(gps.date.day()); Serial.print("/"); Serial.print(gps.date.year()); Serial.print(","); } // Printout Time Routine void PrintOutTime() { Serial.print(gps.time.hour()); Serial.print(":"); PadMinutes = gps.time.minute(); if (PadMinutes < 10) { Serial.print("0"); } Serial.print(gps.time.minute()); Serial.print(":"); PadSeconds = gps.time.second(); if (PadSeconds < 10) { Serial.print("0"); } Serial.print(gps.time.second()); Serial.print(","); } // Printout Speed Routine void PrintOutSpeed() { Serial.print(gps.speed.mph()); Serial.print(","); } // Printout Course Routine void PrintOutCourse() { Serial.print(gps.course.deg()); Serial.print(","); } // Printout Altitude Routine void PrintOutAltitude() { Serial.print(gps.altitude.feet()); Serial.print(","); } // Reference Bearing Routine void BearingReference() { double distanceToSalmonBayPark = TinyGPSPlus::distanceBetween( gps.location.lat(), gps.location.lng(), SalmonBayPark_LAT, SalmonBayPark_LON); double courseToSalmonBayPark = TinyGPSPlus::courseTo( gps.location.lat(), gps.location.lng(), SalmonBayPark_LAT, SalmonBayPark_LON); Serial.print(distanceToSalmonBayPark/1609, 6); Serial.print(","); Serial.print(courseToSalmonBayPark, 6); Serial.print(","); Serial.print(TinyGPSPlus::cardinal(courseToSalmonBayPark)); Serial.print(","); } void I2CData() { sensor.read(); float x = sensor.getAngleX(); float y = sensor.getAngleY(); float z = sensor.getAngleZ(); temperature = myBarometer.bmp085GetTemperature( myBarometer.bmp085ReadUT()); //Get the temperature, bmp085ReadUT MUST be called first pressure = myBarometer.bmp085GetPressure(myBarometer.bmp085ReadUP());//Get the temperature /* To specify a more accurate altitude, enter the correct mean sea level pressure level. For example, if the current pressure level is 1019.00 hPa enter 101900 since we include two decimal places in the integer value。 */ altitude = myBarometer.calcAltitude(101900); atm = pressure / 101325; // Read compass values compass.read(); // Return XYZ readings CompassX = compass.getX(); CompassY = compass.getY(); CompassZ = compass.getZ(); Serial.print(temperature, 2); //display 2 decimal places Serial.print(","); Serial.print(pressure, 0); //whole number only. Serial.print(","); Serial.print(atm, 4); //display 4 decimal places Serial.print(","); Serial.print(altitude, 2); //display 2 decimal places Serial.print(","); Serial.print(CompassX); Serial.print(","); Serial.print(CompassY); Serial.print(","); Serial.print(CompassZ); Serial.print(","); Serial.print(x, 1); Serial.print(','); Serial.print(y, 1); Serial.print(','); Serial.print(z, 1); Serial.print(','); SampleCounter++; Serial.print("C-"); Serial.print(SampleCounter); Serial.println('^'); } // Inbound Serial Data from GPS Module for Processing void InboundSerialData() { while (mySerial.available() > 0) gps.encode(mySerial.read()); if (gps.altitude.isUpdated()) { PrintOutGlobalReadings(); } else if (millis() - last > 5000) { if (gps.location.isValid()) last = millis(); } } // Printout GPS Results Routine void PrintOutGlobalReadings() { PrintOutMillis (); PrintOutLocation(); PrintOutDate(); PrintOutTime(); PrintOutSpeed(); PrintOutCourse(); PrintOutAltitude(); BearingReference(); I2CData(); } void setup() { HardwareInit(); } void loop() { InboundSerialData(); }
There data stream timing doesn’t always line up, this results in some rows of data being misaligned with the column header. Two columns are used to sort the data, “SampleCount” and “OnboardSeconds”. This will group the malformed rows so they can be discarded. The “OnboadSeconds” value in each row can be crossreferenced with the WiFi readings and image captures.
Here is a warning for those of you considering using the GY-GPS6MV2 module. I discovered lapses in quality on how these modules were constructed from the batch I ordered. This lead to delays in the development of this project. It’s my hope to spare anyone else this trouble. There were instaces of misaligned surface mounted components where adjacent pads were shorted. In addition to this, the amount of solder paste used to surface mount was excesive causing pooled solder to short adjacent pads as well. Of the 6 modules purchsed, only 3 were usable.
A good rule of thumb is test your components before you assemble. It’s not enough to validate a build on the bench in a breadboard. If you procure parts, test them for quality before commiting with a supplier and proceeding with manufacturing. It’s too late to find out that 50 percent of the parts you ordered that were assembled in the factory are faulty.