ESP32-Cam Headless Controller

ESP32-Cam Headless Controller

Brian Lough created a video on YouTube titled “WiFiManager – An Essential ESP32 library!”

This video pointed out that hard coded settings on the ESP32 can be a limitation. It went on to demonstrate this with wireless network connectivity and the inherent dependency when preset values are used. The solution rested on a library that gives the ESP32 the ability to operate as a WiFi access point and for the ESP32 to provide a web portal for an attached device. The web page could then take form entries to set variables on the ESP32.

In this post, I’ll be using this concept with the intent to set the time and date on the ESP32-Cam module. There are serveral demonstrations of setting time on the ESP32 online that utilize the network time protocol (here are two examples, see next video clip and https://randomnerdtutorials.com/esp32-date-time-ntp-client-server-arduino/). However, this is dependent on an internet connection to a NTP host. I’ll be using the ESP32Time library, https://www.arduino.cc/reference/en/libraries/esp32time/, which has no need for an external RTC module or NTP time synchronization. This will allow the ESP32 module to be operated offline with a minimal set of hardware.

The ability to control the ESP32 module using a webpage on a mobile device also reduces the need for control and indication hardware on the ESP32, which was a requirement on my earlier flight data logger, https://www.cloudacm.com/?p=3713

Much of the working code is based off of the work of Rui Santos, https://randomnerdtutorials.com/esp32-email-alert-temperature-threshold/. There have been some adaptations, such as the webpage content and its appearence. The data logging function also expands on the base code, with the timestamp information available in both the file properties and also its content.

Here is an example of the code used in this project.

// Title and Description

/** 
ESP32-Cam_AP_WebServer_Head-Controller_Example

Code Base for ESP32-Cam module to allow it to operate in AP mode
and provide a web portal used as a control interface
via a mobile device, such as a phone or tablet.
Thu May 26 2022 13:01:55 GMT+0000 (1653570115)
**/


// Notes
/** 
This code contains serial debug which will need to be removed before final deploy

**/



// Credit and Sources

/*********
  Rui Santos
  Complete project details at https://RandomNerdTutorials.com/esp32-email-alert-temperature-threshold/
  
  Permission is hereby granted, free of charge, to any person obtaining a copy
  of this software and associated documentation files.
  
  The above copyright notice and this permission notice shall be included in all
  copies or substantial portions of the Software.
*********/

/** 
  Unix Epoch Time Calculator  
  https://unixtime.org/
  NIST Offical U.S. Time
  https://time.gov/
**/

/** 
  WiFiManager - An Essential ESP32 library!
  
**/

// Libraries

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ESP32Time.h>
#include <esp_wifi.h>
#include <SD_MMC.h>
#include "esp_camera.h"



// Definitions

ESP32Time rtc;
AsyncWebServer server(80);

// Pins for ESP32-CAM
#define FLASH_PIN         4

// Pins for Image Sensor
#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



// Variables and Constants


const char* TIME_INPUT = "inputTime";
const char* TIME_COMMIT = "inputTimeCommit";
const char* SCAN_TIME = "ScanTime";
const char* SCAN_COMMIT = "ScanCommit";

// Interval between sensor readings. Learn more about timers: https://RandomNerdTutorials.com/esp32-pir-motion-sensor-interrupts-timers/
unsigned long previousMillis = 0;     
const long interval = 1000;  

unsigned long ScanSeconds = 1800;
unsigned long ScanMillis = 0;    
unsigned long previousScanMillis = 0;     
const long Scaninterval = 1000;  

const char* FileName = "/datalogger.csv";

// Set your new MAC Address
// Example mac address A1:B2:C3:D4:E5:F6
uint8_t newMACAddress[] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6};

// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid     = "ESP32-Cam-AP1";

// Default Value
// Thu May 26 2022 13:01:55 GMT+0000 https://unixtime.org/
int epoch = 1653570115;
String inputTime = "1653570115";
String CurrentTime;
String EpochValue;
String inputTimeCommit;
String ScanCommit;
String ScanTime;
String ScanEnabled;
bool DisableAP = 0;
bool EnableAP = 0;

// int epochInt = inputTime.toInt(); - Example of converting string to int
// String epochString = String(epoch); - Example of converting long to string

// HTML Code - This should remain in the Constants and Variables section of the code
// HTML web page to handle 3 input fields (email_input, enable_email_input, inputTime)
const char index_html[] PROGMEM = R"rawliteral(


<!DOCTYPE HTML><html>
<head>
  <title>Headend Controller</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="data:,">


  <style>
  html {
    font-family: Arial, Helvetica, sans-serif; 
    text-align: center;
  }
  h1 {
    font-size: 1.8rem;
    color: rgba(238,238,238,1);
  }
  h2{
    font-size: 1.5rem;
    font-weight: bold;
    color: rgba(238,238,238,1);
  }
  .topnav {
    overflow: hidden;
    background-color: green;
  }
  body {
    margin: 0;
    background-color: rgba(71,71,71,1);
  }
  .content {
    padding: 30px;
    max-width: 600px;
    margin: 0 auto;
  }
  .card {
    background-color: rgba(51,51,51,1);
    border: 2px solid rgba(85,85,85,1);
    padding-top:10px;
    padding-bottom:20px;
  }
  .button {
    padding: 15px 50px;
    font-size: 24px;
    text-align: center;
    outline: none;
    color: #fff;
    background-color: grey;
    border: none;
    border-radius: 5px;
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    -khtml-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
    -webkit-tap-highlight-color: rgba(0,0,0,0);
   }
   /*.button:hover {background-color: green}*/
   .button:active {
     background-color: green;
     box-shadow: 2 2px #CDCDCD;
     transform: translateY(2px);
   }
   .state {
     font-size: 1.5rem;
     color:rgba(238,238,238,1);
     font-weight: bold;
   }
   .timeinfo {
     font-size: 1rem;
     color:rgba(238,238,238,1);
     font-weight: bold;
   }
  </style>
<title>Headend Controller</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
</head>
<body>

  <div class="topnav">
    <h1><img src="data:image/jpg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/4SmGRXhpZgAASUkqAAgAAAAKAA8BAgAGAAAAhgAAABABAgAKAAAAjAAAABIBAwABAAAAAQAAABoBBQABAAAAlgAAABsBBQABAAAAngAAACgBAwABAAAAAgAAADEBAgANAAAApgAAADIBAgAUAAAAtAAAABMCAwABAAAAAQAAAGmHBAABAAAAyAAAAJYGAABBcHBsZQBpUGhvbmUgNnMASAAAAAEAAABIAAAAAQAAAEdJTVAgMi4xMC4xOAAAMjAyMjowNToyNSAwNjoyNTo1NwAgAJqCBQABAAAATgIAAJ2CBQABAAAAVgIAACKIAwABAAAAAgAAACeIAwABAAAAQAAAAACQBwAEAAAAMDIyMQOQAgAUAAAAXgIAAASQAgAUAAAAcgIAAAGRBwAEAAAAAQIDAAGSCgABAAAAhgIAAAKSBQABAAAAjgIAAAOSCgABAAAAlgIAAASSCgABAAAAngIAAAeSAwABAAAAAwAAAAmSAwABAAAAGAAAAAqSBQABAAAApgIAABSSAwAEAAAArgIAAHySBwCWAwAAtgIAAJGSAgAEAAAANTQ3AJKSAgAEAAAANTQ3AACgBwAEAAAAMDEwMAGgAwABAAAAAQAAAAKgBAABAAAAwA8AAAOgBAABAAAA0AsAABeiAwABAAAAAgAAAAGjBwABAAAAAQAAAAKkAwABAAAAAAAAAAOkAwABAAAAAAAAAAWkAwABAAAAHQAAAAakAwABAAAAAAAAADKkBQAEAAAATAYAADOkAgAGAAAAbAYAADSkAgAjAAAAcgYAAAAAAAABAAAAHgAAAAsAAAAFAAAAMjAxOTowNDoyNiAxNTo1NjozNQAyMDE5OjA0OjI2IDE1OjU2OjM1AFxKAQBTQwAAlfQCAJBMAQAnXgAA2h0AAAAAAAABAAAAUwAAABQAAABmCigH8QL0AkFwcGxlIGlPUwAAAU1NABEAAQAJAAAAAQAAAAoAAgAHAAACLgAAAOAAAwAHAAAAaAAAAw4ABAAJAAAAAQAAAAEABQAJAAAAAQAAAIkABgAJAAAAAQAAAJgABwAJAAAAAQAAAAEACAAKAAAAAwAAA3YACQAJAAAAAQAAERMADgAJAAAAAQAAAAAAFAAJAAAAAQAAAAQAFwAJAAAAAQAAAAAAGQAJAAAAAQAAAAAAHwAJAAAAAQAAAAAAJQAJAAAAAQAAAAAAJgAJAAAAAQAAAAAAJwAKAAAAAQAAA44AAAAAYnBsaXN0MDBPEQIA+QP6A/4CSQOiA9sC+gP6AzID+gOgA2MBhAByAFQAWgD2AjwDCAPzAokDZQKKA9ICLQLfAgsCcABXAE0AOQBAAEACRgJJAlMC1QLlAW4BEwHwABMBSQHOAKwAZQBEAD0AIgIrAiMCLwLaAS4BIgHzAAIBHAEVAQABmwERAvcBxQEEAjQCGwIjAgMBxgABAfoABAGvAEoAZwDBAO0BTwJZAucAKAIqAg8C6wB8AM4A3QDJAM4AyACvAMgAJAEvAjgCpgHyAQYC4gHJAL8AlQCbALMApQCiAKYAbwCCABcCDwLiAfkB/AHWAasAuQBRADkAYQCTAIYAfgB6AEkA5wEkAt4B+AH/AfwBlgDHAKkAkACJAH0AdQBoAGUAfAC5AQkCxAH1AQcCDgLxAJQAsACAAGQAWQBQAEwASQBiAMEB+AHTAfoBCgLqAV0BjACAAHUAaQBHAEEAWQBRAMkA5gH7Ae0B+QH5AbgBeQEhAZYAYABkAGAAXABVAJsA0AECAgkCCQINAukBoAFRAS8B5wB8AFAASQBQAHwA2wDsATECMgIdAg4C4gGmAXsBPQEMAewAsgCAAHIAnQDmAC0CgAJfAicCEQLnAaIBdQFGASMBBAHdAMwAugC/AE4BlwLPAp8C/gH6Ad4BowF5AVYBKwEKAeoA3wDWAOsArAFSArQCiwIACAAAAAAAAAIBAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAIMYnBsaXN0MDDUAQIDBAUGBwhVZmxhZ3NVdmFsdWVZdGltZXNjYWxlVWVwb2NoEAETAAACOXHTLQoSO5rKABAACBEXHSctLzg9AAAAAAAAAQEAAAAAAAAACQAAAAAAAAAAAAAAAAAAAD///7VSAABgR////ZYAAgPn//8rEQABTr4AAAAAAAAAAVMAAAAUAAAAUwAAABQAAAALAAAABQAAAAsAAAAFAAAAQXBwbGUAaVBob25lIDZzIGJhY2sgY2FtZXJhIDQuMTVtbSBmLzIuMgAACAAAAQQAAQAAAAABAAABAQQAAQAAAMwAAAACAQMAAwAAAPwGAAADAQMAAQAAAAYAAAAGAQMAAQAAAAYAAAAVAQMAAQAAAAMAAAABAgQAAQAAAAIHAAACAgQAAQAAAHwiAAAAAAAACAAIAAgA/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCADMAQADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDS8A+AdK8VaFPfX1xeRyx3LQgQOoXAVT3U8/Ma6r/hTfh7/n81T/v7H/8AEUfBv/kULv8A6/3/APRcdeh1xYfD0pUotxPps3zfHUcdUp06jST0R55/wpvw9/z+ap/39j/+Io/4U34e/wCfzVP+/sf/AMRXodFbfVqP8p539uZj/wA/Weef8Kb8Pf8AP5qn/f2P/wCIo/4U34e/5/NU/wC/sf8A8RXodFH1aj/KH9uZj/z9Z55/wpvw9/z+ap/39j/+Io/4U34e/wCfzVP+/sf/AMRXodVp7+2t8CSZAT23DNH1Wj/KH9uZj/z9Zwv/AApvw9/z+ap/39j/APiKP+FN+Hv+fzVP+/sf/wARXT3HiCAKfLZgfov+NZdxrRk/5aHp6LT+qUv5Rf27mH/P1mS/wf8ADiAk3mq8eksf/wARWdc/Dnwpa533OtHBx8rxf/E1ryXocklv5VEbwYxu4/Cn9Uo/yh/buY/8/Wc3L4S8GxMQZte4/wBqH/CoT4Z8GA/63Xv++of8K6c3I9f5U1p1YYJ4o+qUf5Q/t3Mf+fzOdh8LeC5pCol18YGeWh/wrVsfhx4Uv4o5IrnWgJDgbni9cf3andYHOWXP41C1jYOPmhz6fOev50fVKP8AKH9u5j/z+ZfPwi8NgE/bNV4Gf9bH/wDEU2P4R+G5Ol3q34yR/wDxFZwtEhYNbbUYdCWJqxHd6nEwzdIYwc7Qozjv2pfVKP8AKH9u5j/z9Zf/AOFN+Hv+fzVP+/sf/wARR/wpvw9/z+ap/wB/Y/8A4ipE164CgRO6OByzIuKlh8R6jFLma53x/wB1I1z/ACpfVaX8o/7czH/n8yt/wpvw9/z+ap/39j/+Io/4U34e/wCfzVP+/sf/AMRXSWfivT50AlMkTj5WMu1QSB169K14r6znUtDdQSKDglJARn8KPq1H+UP7czH/AJ+s4T/hTfh7/n81T/v7H/8AEUf8Kb8Pf8/mqf8Af2P/AOIr0Oij6tR/lD+3Mx/5+s88/wCFN+Hv+fzVP+/sf/xFH/Cm/D3/AD+ap/39j/8AiK9Doo+rUf5Q/tzMf+frPPP+FN+Hv+fzVP8Av7H/APEUf8Kb8Pf8/mqf9/Y//iK9Doo+rUf5Q/tzMf8An6zzsfB3w6wyLzVP+/sf/wARS/8ACm/D3/P5qn/f2P8A+Ir0Oij6tR/lD+3Mx/5+s88/4U34e/5/NU/7+x//ABFcD8QPCVh4UuLOOxmuZBNv3eeynGAuMYA/vGvoGvHfjN/x+6Z/21/lHXNi6FOFJuK1PZyDNMZiMfCnVqNxd9Pkzf8Ag3/yKF3/ANf7/wDouOvQ688+Df8AyKF3/wBf7/8AouOvQ66cN/BieNnn/Ixq+oUUUhIAJJwB1NbnlC1Uv9RhsIGd3jLgAiMuFJBOKxfEHiNLS3MNttllYkExTYZMEen41xN5qVxPL5k0kr5GfnkJ289OaSd3oNq250t14qunbMJEYx0yrf0rIuNUnuHDSuCwGM8D+lZPnZ6N+tAkHfn8a0SJbLjXreoP4iozdsaqsAeQfwFVbq6FrGWYdieWx0piNE3TUxr0L1ZR9SK5C818sxCZQAdpfasibWZif9a5Gf8AnqaLgegtqkK9Zoh9XFRNrcC/8tYj/wBtBXm8mpSOTkt/33UDXch53t/31SuFj03+3oP70X/f0VImt2zHBlhH/bUV5YbqQj/WOP8AgVOF5Ip5Zj/wKi4WPWE1O1fpcwf9/BUy3UTj5Zkb6MDXkqak6sMBv++6vQapdnHl+eATxtc0XA9O809jSeafWuM0+/v3kCkXLDHdmPatlb+eMDfbSEY6kn/CgDWcq+cj9adHe31nD5dldeSC24/u1bP5/QVnJfxPjc6I390uM1MJAejA/Q0rDO70fxxHqOqy2l1ZrYwpFvW4ln+VjkfKMqBnk9+1dRb3MF5As9rPHPC2dskThlODg4I46ivGywPXBrU8OahLYalbxfa3jstwXyfMKxoCwJOOnr+ZpMZ6rRUcM0VxGJIZUkjPRkYEH8RUmc0gCiiigAooooAK8d+M3/H7pn/bX+UdexV478Zv+P3TP+2v8o65cb/BZ7/DP/Iyh6P8mb/wb/5FC7/6/wB//Rcdeh1558G/+RQu/wDr/f8A9Fx16HV4b+DE5c8/5GNX1CuX8QeIYoUe2tnZ5CJI5CpK+Wenpz36elbWq30dnYylpNkjRvs4PUD2/CvKb7UA87s7/MzMScHk5rV+87HmLTUdJNlizMWYnJYnmoHkDZzzVcz7uQ1MMh9a0WhDFIdDuUlgO2cU5LhX6Gmb6jZM8rlfpxVCLYk9zSP5coxLGrjp8wzVNZmU4c4qUSZ6GgCneaHb3e7Z5cOQR8sQ44rIfwac/LfZ/wC2P/2VdKH96duz3pWC5y48Fuf+X7/yF/8AZU9fA5PXUMf9sf8A7KunVznrUo+YcE5osguc/F4RgjB3XKP9YP8A69XY9AskPzRQP9YBWgWZfvcUb/eiwEKaTpq9bC1P/bFf8KkFjYr9yzt1A6YiA/pTvM96Qygd6AHrHFGcpEi/RQKViGGCARVZ7qNer/oahbUIB1k/8dNAE0ttC+SEQN/e2jNUjbXMBykzyZ7bsf1qb+0ID/y0/Q04XEb9Gz+BoGQDVBHxcR+WR/tbv5CrUdykgBRsj8aheKGT70aH3K1SltpIcvbvISP4S3FIDq9L8QXOnyRDzpjCjhvKEpCkZyRj3r0vSNXtdWhMlvJlwqtImD8hPbJAz0PT0rweHUGUhLghJM4wAT/ntW3pmt3mmzpLbTuq7lZl3MFcA9CAeRUNa3KTPb6KydC1m21i0DwzB5lRDOoVgEYjoM9sg+vStahaiYUUUUwCvHfjN/x+6Z/21/lHXsVeO/Gb/j90z/tr/KOuXG/wWe/wz/yMoej/ACZv/Bv/AJFC7/6/3/8ARcdeh1558G/+RQu/+v8Af/0XHXe3kzW9jcTqAWjjZwD0yBmrw38GPoc2ef8AIxq+pw3jXUWfUBaHZtgBxwc/MqnmuD1HmBn9FY/pWpr+oSX2rzXEiqHbbkKOOFA9fas5/wB7bSL6oR+lbw2ueVLexy8GrLFOVYoDnA+U1tW9/HKiksMn0BrhdURoLuVTggyMOPrUFvqElr9xVK5zyOf507iPSw2acGrk9P19XIAjPU/w+31roLe7SYHaCMeoqkyS8cMMGoWUxnKjI96UNTt2aYCJMDwTzUoaoHQP61FvaHrgr+tAF3dSh8VVSYOMgGnb6ALyXBAxx+VDxoykxklvSqO+nCQjnFAGbq+pS2IZdqZBA+YE9Rnsa5m51iSYkuIxx2B/xru5JFnhaGQEKwIJXrzXOah4Oiu3ea0mcORyJWGM4wOi+1J3A5d75CfvD8jURvFzwR+Ro1LSLjTD+/eJuSPkJPQ+4HrWaXxUFGl9s57fkaet8B3X8jWT5nPSlEntQB0lvrLIPk8s8d1NblpryyFVmZFz6K3rXAb81NDcNGRgDFFwPSy0N5EdrkqQRkDH86pHzrNvlQeXnktzwK5ex1Aq2VUZBB5H/wBeujtdWWYBHQgnA4H/ANemB0fh/XDpuoxXsPlsUBPzqSOVI7fWva9Kvl1HTLe6BBaSNWbaCAGKgkDP1r5ymh+zMbiM5ySSG9//ANddj4L8atowW2lgDWTSNJKUTMmSgHy/MBjIHX3qLWdx3urHtFFFIPeqELXjvxm/4/dM/wC2v8o69irx34zf8fumf9tf5R1y43+Cz3+Gf+RlD0f5M3/g3/yKF3/1/v8A+i467PXZUh0G/Z3Vc28gXJxk7TwPeuM+Df8AyKF3/wBf7/8AouOtzx5KYvDhwcbpNv8A441Og7UInPnSvmVX1PKJ5d8pYnrSQyANjPXFVnfmmiQg5rpR5D3MnxRpjPGs8SZwWLYyepFcXIGRyCMEGvVD5V3CYpcYIHf/AD6Vzd/4SSW4eSK5KqxJ2iMnGT65ptCOOyD061o6fq7W5YSuSD2AHtWqvg1iebwr/wBsf/r1Yg8EQRkmW+Ent5e3/wBmosxmpZ3vmor5LIehAGOtaCyqw4NRQ2Fpa2ywxsoVc4G78fWkIji+4yn8aokshqUnI5qotwM4JA/GpfMyM8UwGyw5JZcZqISvEcPkgegqbzKY+HHNAD1nVujD86dvHrVFoinMZx+GaRLk5w4x7k0gL/mD1oEuO9VRID0I/OjzKAL4ujjazZXuOKr3FjpN8hW6ty4PJ+cj+RqDfSeZQBWl8J6PJnyYI0z03TP/AI1Rk8FRsf3cloPrM1a/mmkM7r0NKwzmW8B6jn5buwx/11b/AOJoXwLqYYE3dhj/AK6N/wDE10f22QfxD9KPtsp/i/QUrAZlr4QMRQzyWrFTkkSt6/SuigtbO1i2jZ90A4cnp+NZ/wBrkPU/yppnY96BluZouidPrWXOjQzedH93qQOSSaseYTTWbIwaQHrfw98X/wBrRT2d/c77pWaUSEIq7PkGOMc5J7V31fNOkarNoF68sB4eMqScdyPUH0r6QtrmO6jLxsrAHGVbNSt7FPuSg56GvHvjN/x+6Z/21/lHXsIAUcV478ZebzTP+2v8o65sb/BZ7vDP/Izh8/yZ0Hwb/wCRQu/+v9//AEXHWt8RDjw2v/Xcf+gPWT8G/wDkULv/AK/3/wDRcdbHxCTf4a/3Zc/+OPTou1BHPnKvmVX1PHWbmm7qY55pm6upHkMnSRgeDilN1Mpxu/QVAGqQFZFx3qhAbyb+9+gphupT1b9BUbqU6iq0twkWC7YoAtmZj1NN3n1rJfWrNDgzf+Ot/hUZ16y/56/o3+FFwNktSrcspweRWMNdsz/y1/Rv8KmTUraX7smfwP8AhRcDaWYNzkfnS+Z71lLOOqsSPyqdLlSMHrTuBe35qKSNXHvTPMo30CIyXiPBJHpilWfd1wD6ZpSwIxUMkeeRwaALHmUm+qXmNH97JHrmnLOrdDSGW/MpRIO/86q+YKQvQBbMKSDKuF9utVmMkZ+ZWA9SMU0SEdyPxqwtyjLtkG7PduaQEHnKBksB+NRPexL/AMtE/wC+hUOpWU0gMtq5ZQpJUfLjj6/WuXkndXZGJ3AkH5u9S3YpI6n+04weqf8AfYqRL+J8fMg/4GK47zm/vH86kS4Zccn86nmHY7CVVnUAMOD25r1z4WeJrjVIrmxvGDzKzTBsgHb8gxtAHcnmvCLLUSmdxZh7sa7zwDqi6d4gnuFJ2NasnBI53qf6VMpW1KjG+h9Bnoa8c+MnN5pnGP8AW/yjr2OvHfjN/wAfumf9tf5R1jjf4LPb4Z/5GUPn+TN/4N/8ihd/9f7/APouOtX4iuY/DGR3lwf++HrK+Df/ACKF3/1/v/6LjrU+JCs3hY7RnEuT/wB8PVUP4COfOv8AkZVfU8XL7uabuqpHcYupYmPTaB+IqcmuhHksk3Uocg9ai3UZqriLgZZRggZ+lcz4kt7qPy2haQLwDh8c810MsM9q2JF2nGev+FOKQ3ibZUVsHOCuf5/WjcR5e8jnqzH6mhSxHU/nW9rOhG1LyRRt5YI6svpWIqkduKTGIGYdzT1uJV6SOPoxp6he9BRfSgCSPUbmPGJnI9Cx/wAa0bXWn4ErKPfB9KyCi+ppoX5qQHb296sgOHyPoatCTI61xNvc3MGdrEg+rH/Gt+01DzQBuGe4wfWqTA191JuqBZg3el3e9MQ9wGHNVZEK8qT+dSl/ems2etAEInYcMeal8w+tQuoOcdagLuh68e5pAXTJ70eYfWqomB70vmUhltbh14zkdwehqvd6XaX0ZdF2TAEgRgLuY+vHrTN59aVZmQ5DEUAcxd289nKUlTbyQOQen0qNXyOtddLDbajH5c6KG7Oq/N6nkj2rmtQsJLK5YBT5XVSSDkZIHSpaKTC3kIJGeK6rw7cSC4kVXIbaTkE56iuNViOc1uWEzQysyuVJXGQfpUMpM+wa8c+M3/H7pn/bX+Udeu2s8VzbpLC5eNs4YgjPOO9eR/Gb/j90z/tr/KOscY70G/Q9vhnTM4fP8mb/AMG/+RQu/wDr/f8A9Fx11Hi2zW88M325iPJhklGD1IRv8a5f4N/8ihd/9f7/APouOu21dGl0W+jVSzPbyKABycqarD29gr9jmzr/AJGVX1PlzVswagsnXkH8gKuxSebErY6qDV7xPpsgu5Y2iKvGp+VgQRlQelczZXDW0pilJGWCjjpW0XdJnltWdjazg1f0mNZdQiDZ79Poazshhkcg0qSNG4ZThhVCN6aWTWblUUKqFdpzweMn3rJlja2kCtgkjPFaX9tb7V0iLiYn5TtHt/8AXqXUrNpEWGFCWyGJ5IxyKSdgauZyyrOhRgRkHOKzbzw5DdyGRZJAx9WGOn0qWVWglKN94UqXTqMbjj6CruSY03hO5B/dzRY/2mP+FVm8N3S8eZD/AN9H/CumF+F+9u/IU7+0Y/Vv0o0A5X/hHrn/AJ6Q/wDfR/wpR4euv+ekP/fR/wAK6n+0YvU/pTTqcXq36UWQHPR+HbrOfMhx/vH/AAq/baK8T7pHXGP4T/8AWq8+px/7X5Cqz6jnoW/IUaAOktNhyp/M1E2U4OPwqGS+kbPzH8hVdpmY8nP4UXAtF6QvVQSEd6d5me9K4yctTCc1HvpN3vQA1lwcimiTnBpxaoWeM8b1z9aQE4ejdVYMR9KeJAe9K4ybfirK3CyQG3kB2N1K9eP/ANVUN1ODUXAp3GkNbShd4IIz1/8ArVq/2U69HX8//rU61uEXKy5K9cD1rbMqP8wYEdOtLQZ9DeHTnQrY/wC9/wChGvMfjN/x+6Z/21/lHXqGgxmLRbdCQSN3I/3jXl3xm/4/dM/7a/yjrkxP+7fce/w5/wAjWP8A29+TOg+Df/IoXf8A1/v/AOi469CZQylT0Iwa89+Df/IoXf8A1/v/AOi469DrfDfwYnHnn/Ixq+p4v8Q7GS28TTzNEywT7fLYg4bCKDgnrzXnl9pRkfzIAoIJbkmve/H+hf2tpq3IZgbKGaQKELbuAcdePu14m7vGxBByDgg9qpS5Xys85q6uilb+ase2RWBUAZK4B+lSE1LJKHXnH51ATV3JHJIUYMpwRW/HqxuZNsDMjAZJcDGK5vNPineFyyHBIxSeoHQX9nBP5jRqPOxnfuODx2/SuflR4JCj8MK2Yr0Om5MMccRBskf5/rUU9p9rjadoXSUqeMEnPb+VClbcbjfYxiQ3XrUTAj6VJNG8D7XVlP8AtDFR7+x5FWQRk00mpGQHkH8KhbIJoAQnNMJqtPexwkAsmTzgsBS+ejDiVfzFS5JbjsTEijNV2kPZwfpTHllGNqls+gpc6CxZLUbsVRa4lX7ykfUUxr5kGSmR9aOZBY0g3vRmqEV+j4ztX6tVlZQ3RgfoadwsJdTGOMFTznFZQuHLZDc/StSVRIuD65rHKmKUBgcAjOeKALkV2R8spJ/AVaVgRlSCKpeWkyFkIUgcgHNLBKyPsPQkClcZfDU4GoSccinBs0rjsTBq0rOaR8oCT3xistTXSeEtOOpajLFhjtiLfKpPdR2+tZVJWRpBXZ9OW8UUECxwgCMZwAc968i+M3/H7pn/AG1/lHXsKqEUKowBXj3xm/4/dM/7a/yjqcZpQfyPY4Z/5GcPn+TN/wCDf/IoXf8A1/v/AOi469DxXnnwb/5FC7/6/wB//Rcdeh1phv4MTlzz/kY1fUbJGksbRyKrIwKsrDIIPYivE/HPhWXSb57qPaYbmWaQKqhRGoIOOvPB9ule3VR1bSrbV7Ca2niiLPE8aSPGHMZYYyM/h+VaTjzLTc82ErPU+ZW4qMmuk8U+FL/w7cn7Qn7iSRxDJlRvUEc4BOOo4PrXMt1rNMtoXNNJpCaQmruTYkjmaKQOpII9DitEamXtGHmusm0/xHrWOTSbvei1xJ2HyTSStmR2c+rHNRk0hNMJqiR28g9TS71YcioiaTdTuFjP1HS2uHV4mGQMYx9fesc+fbna4bI/2q6jfUE9rFMhGxAx77QaNGIwBeuvZv8AvqpF1Bh1LD23GrUmkNyUYN7bQP61VewmT70YH4ilyodxzXyvjcx/Ek1DNOrIQvJppgK9QBR5Ixxj8qXKkFyIuTjAxx61ZglkQnBY+26mrByOhq1FZTuTsjz+IphctRXSyDHf0pZoVlU8AH1xVm30/Y2ZMJx/dBprxlWODkUmrDRmeTLC3yk49jiiNHaQMR3z1q8y56ikCgdAKkY4dBRjFApwGalspD0Ga9Y+Dmml9Wu7t41ePyHiwwB53RmuB8N+GtS8R3cltp0HmyIhkI3KvAIH8RH94V9GeFPDlr4dspYIJI5neQuZFgEZAIUY6n0qEnKSKbSizoK8d+M3/H7pn/bX+UdexV478Zv+P3TP+2v8o6Mb/BZ7HDP/ACMoej/Jm/8ABv8A5FC7/wCv9/8A0XHXodeefBv/AJFC7/6/3/8ARcdeh1eG/gxOXPP+RjV9Qooorc8ozda0Kx1+xNrfRBh/DIFUunIJ2kg4zgZryTXvhfq0d9L/AGPZSz2+SVaSeIfxH3HbFe20VEoJ6lKTR8pz200H+sTbxnqDVYmvq68tY721e3kLBHxkqeeDn+lMsdPi0+No4mdgTn5yD6e3tUqEkU5I+UiaaT719W6ppkOr2ElncNIscgIJjIB5BHcH1ryjxd8JhCq3Wi3eQEd7gXsnYAbdu1P97Ofam00JNM8nLU3NO1GJ9Lv5bO4KtJHjJj5HIB749arrMr9AaSkNxJSaQ0zdSbqdxWHZpNxpuaaTTuKxIHp2Y2+8AfqKgzRmi4WJ/Ktz1hjP/ABSeVb/APPGL/vgVATSbqOYLFnZAP8AljF/3xSmRF+6FX6DFVCabmjmDlLTXGf4j+tQtITUdJmpbHYUnJoFFSRRGWVI1IBZgoz71LZSQ0Ctzw94bv8AxBexQWluZA7MvEiqchd38Rru/Cvwiurv7PfaheQi1ljWRRbynfhlJGcpjuP1r2LRNHg0PTUsrd5HRccyEE9AOwHpSUZS9B8yiUfDfhHS/CsEiafEWkkYkzSqpkwQPl3AD5flBx61uqqr91QPoKdRW6SWxk3cK8d+M3/H7pn/AG1/lHXsJGa8d+MoxeaYP+uv8o65cb/BZ73DP/Iyh8/yZ0Hwb/5FC7/6/wB//Rcdeh1558G/+RQu/wDr/f8A9Fx16HWmG/gxOXPP+RjV9Qooorc8oKKKKACiiigAooooAp3ul2eoSQvdQ+Y0JJQ7iMHj0PsK4Xxp8KtN1rTrh9ItYLfVJH3rPPPLtBLgsccjkbu3evRs0UmkxptHyF4k0DUPCOqjTr2eCSUx+ZmAllxuK9wD/CazEuxwG3E/QV9oVzup+BfDestM1/p3nGZt0n7+RcnOezDvUcnYrn7nyuHDdM0ua9e8R/BJ5beP+wo7K3k3DcZJ5myMHPZvauCj+G3iw3b24sJQFGfMMEmw9OAdnXn9DU2aK0ZzucCkJrrR8L/FPe1P/fqT/wCIpf8AhWHifvaH/v1J/wDEUagchRXRSeAvE8cjJ/Y1820kbltZCD7j5apv4Q8Tq7L/AMI5rBwcZFjJg/8AjtAGRmiuh0zwH4o1K/itP7GvrXzM/vrq1kSNcAnltvHTH1Ir1rw78HdNtjINftba6znZ5NxMMdMf3f8Aa/OhJsHZHglORC5wMV9NL8LPBajjRsf9vU3/AMXXWwQR20KxRLtRegzn3p8jFzI8Z+GPw3sNT0dtX1u2huorgg2gSaRWj2M6vuAwOSFxyenavZba2is7SG1gTZDCixxrknCgYAyeegqWitIxsiG7hRRRTEFFFFABXjvxm/4/dM/7a/yjr2KvHfjN/wAfumf9tf5R1y43+Cz3+Gf+RlD0f5M3/g3/AMihd/8AX+//AKLjr0Ovlqz1nVdOhMNjqV5axFtxSCdkUnpnAPXgflVj/hKfEP8A0HtU/wDAyT/GuSljowgotbHvZhwvWxWJnXjUSUnfZn07RXzF/wAJT4h/6D2qf+Bkn+NH/CU+If8AoPap/wCBkn+Naf2jH+U4/wDU6v8A8/V9zPp2ivmL/hKfEP8A0HtU/wDAyT/Gj/hKfEP/AEHtU/8AAyT/ABo/tGP8of6nV/8An6vuZ9O0V8xf8JT4h/6D2qf+Bkn+NH/CU+If+g9qn/gZJ/jR/aMf5Q/1Or/8/V9zPp2ivmL/AISnxD/0HtU/8DJP8aP+Ep8Q/wDQe1T/AMDJP8aP7Rj/ACh/qdX/AOfq+5n07RXzF/wlPiH/AKD2qf8AgZJ/jR/wlPiH/oPap/4GSf40f2jH+UP9Tq//AD9X3M+naK+Yv+Ep8Q/9B7VP/AyT/Gj/AISnxD/0HtU/8DJP8aP7Rj/KH+p1f/n6vuZ9O0V8xf8ACU+If+g9qn/gZJ/jR/wlPiH/AKD2qf8AgZJ/jR/aMf5Q/wBTq/8Az9X3M+naK+Yv+Ep8Q/8AQe1T/wADJP8AGj/hKfEP/Qe1T/wMk/xo/tGP8of6nV/+fq+5n07RXzF/wlPiH/oPap/4GSf40f8ACU+If+g9qn/gZJ/jR/aMf5Q/1Or/APP1fcz6do718xf8JT4h/wCg9qn/AIGSf40f8JT4h/6D2qf+Bkn+NH9ox/lD/U6v/wA/V9zPp3vRXzF/wlPiH/oPap/4GSf40f8ACU+If+g9qn/gZJ/jR/aMf5Q/1Or/APP1fcz6dor5i/4SnxD/ANB7VP8AwMk/xo/4SnxD/wBB7VP/AAMk/wAaP7Rj/KH+p1f/AJ+r7mfTtFfMX/CU+If+g9qn/gZJ/jR/wlPiH/oPap/4GSf40f2jH+UP9Tq//P1fcz6dor5i/wCEp8Q/9B7VP/AyT/Gj/hKfEP8A0HtU/wDAyT/Gj+0Y/wAof6nV/wDn6vuZ9O1478Zv+P3TP+2v8o64T/hKfEP/AEHtU/8AAyT/ABqne6nf6kytfX1zdFM7TPKz4z1xk+w/Ksa+MjVpuCR6WU8N1cDio15TTSv36qx//9n/4gKwSUNDX1BST0ZJTEUAAQEAAAKgbGNtcwQwAABtbnRyUkdCIFhZWiAH5gAFABkADQAUAAphY3NwQVBQTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWxjbXMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1kZXNjAAABIAAAAEBjcHJ0AAABYAAAADZ3dHB0AAABmAAAABRjaGFkAAABrAAAACxyWFlaAAAB2AAAABRiWFlaAAAB7AAAABRnWFlaAAACAAAAABRyVFJDAAACFAAAACBnVFJDAAACFAAAACBiVFJDAAACFAAAACBjaHJtAAACNAAAACRkbW5kAAACWAAAACRkbWRkAAACfAAAACRtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACQAAAAcAEcASQBNAFAAIABiAHUAaQBsAHQALQBpAG4AIABzAFIARwBCbWx1YwAAAAAAAAABAAAADGVuVVMAAAAaAAAAHABQAHUAYgBsAGkAYwAgAEQAbwBtAGEAaQBuAABYWVogAAAAAAAA9tYAAQAAAADTLXNmMzIAAAAAAAEMQgAABd7///MlAAAHkwAA/ZD///uh///9ogAAA9wAAMBuWFlaIAAAAAAAAG+gAAA49QAAA5BYWVogAAAAAAAAJJ8AAA+EAAC2xFhZWiAAAAAAAABilwAAt4cAABjZcGFyYQAAAAAAAwAAAAJmZgAA8qcAAA1ZAAAT0AAACltjaHJtAAAAAAADAAAAAKPXAABUfAAATM0AAJmaAAAmZwAAD1xtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAEcASQBNAFBtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEL/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wgARCABQAGQDAREAAhEBAxEB/8QAHAAAAQUBAQEAAAAAAAAAAAAACAMEBQYHAAIB/8QAHAEAAgMBAQEBAAAAAAAAAAAAAQIAAwYIBAUH/9oADAMBAAIQAxAAAAEqvl7uz+n4jKSmOlUYQ5kkpnVN1Vp+TpOkDTHdIGXseb4u/wCEPvqpjXXNVMSJPSXYzlbcKX3GDjAWw/UpW6vnsQPdn2VtUm4VgdyQCnJ639CWeEoqnJF1BbD9S7lqefRd+lm8gE2C1H5DWT0JFK1bUqAzak4EYYcV1HrWm5/Gb6eZRcUFStJa3VQhkp9AslOdee4pVZbIdPa3qefRH9ueshGaulYUuJL04aCRylJS1VtJou0rJdOk/qefQE9Hwl2V8JV7qo0GKrZqQqZXUMyj7xTdbMt00Xmt56wZfkiQw9sIm2qMK1dIxkkmLimxxVadElFy3TRea3nq2+vP5vRbQmAn025RB5I+EJI1xRzFdSd9nmBfD9SkrpPxWz+n4nSdJ0mfI2dI0fDo7LptiepOkDTHdIf/xAAmEAAABgIBBAIDAQAAAAAAAAAAAgMEBQYBBwgRExY2EhQQFSEj/9oACAEBAAEFAqTSYZ1TPAYIPKlW48jzNZSDv9UsDMmfVJRkmEX0AIiIqs4bwGCHgMEPAYIb9imsPd6D6Lc7riAxIWBZ66cOTGSkVbCooSJsS5mURPo5ameoo5mMtz0bZaeUfxyP9/qr8sXrS63FRs7jrAhKp/L4gjzrkrzGRYJpzFILbSdmyhtBfrG2ZtIjUmy8rOByP9/m5D6GkJVmSdjUanMMnEeo4Qa5XIsQzkyIJIiQYMZXLmlw5wxrEUwVdqkVGqrka6VXkd7/AHP+aQ7/APXSnbRUvzYpkLw2OZnNJvSKnwbJn2U84f8AQSCh1EM2V2ktxrtRlLnyO9/u7E7zQLV7h0gkyN9GZpJJBY9MflDGryTRXHeQSO5+WDqZIMPOofoJS5NBNXUTsjkf7/Dw+LBqZxFZjZFjJ5YLSjYhymeKhaTVCrz+/sCYB3P+aMmoYEX6jQWTvth8j/f6D6LvDWSixzKBCUVZlOt1zIN05FJSLdt8qtXiwi41ykZyyx3EydC8bdduYZLkf7/QfRRO6kr9icWrjpDSjG56ksFIa4dFUHd6D7RgZcxhSNfymwZDXPG5pXFxyO9/pN2hmtM8+gh59BDz6CHnsELTFUO5ET1DrohHGlNfrGr1Z19XGJb3AkL59BDz6CG/ZVrMXf8A/8QAMREAAQICBwgCAgEFAAAAAAAAAQACAwUEERIWVJPRBgcQITVzsbIgMSVRYyIwMkFE/9oACAEDAQE/AdptppxCnFLhQqXEDREeAA91QFo8hzV7J3jIuY/VXsneMi5j9Ve6d4yLmP1V7p5jIuY/VXtnmMi5j9Ve2eYyLmP1V7Z3jIuY/VXsneMi5j9Veyd4yLmP1V7J3jIuY/VXsneMi5j9Vump9JpcldFpcRz3W3c3Ek/Tf2trOt0zuxPcoVAVlHhyXL4Dnx3NdBd3HeGraoVzymd2J7lONZR+AVSq4fY4bmugu7jvDVtQPzdNP8kT3PCv+wE4Bbmugu7jvDVtOPzVO7kT3R4VKpVfAIAIgWStzXQXdx3hq2oP5um92J7lEKyq1Wq/iEz+qsFbmugu7jvDVtQ6zPaYf5YnsU9haajwcz/Y4VKyVUqkEAg2ppctzXQXdx3hq2s63TO7E9yob7QslFqrIRCqVZVpAoJoUZ4DbAW5roLu47w1bWdbpndie54CM76QiftCyfpFisqwg1GpvMox6v8AHhua6C7uO8NW02zM4izilxYVEiFpiPIIY6oi0eY5K6c7wcXLforpzvBxct+iunO8HFy36K6c7wcXLfohsvPR/wAcXLforsT3Bxct+iuzPMFFy36I7MT3Bxct+iunO8HFy36K6c7wcXLforpzvBxct+i3TUCk0SSuhUuG5jrbuTgQfpv7X//EADMRAAECAwQIBAUFAAAAAAAAAAEAAgMEERIUU5EGECExNHGx0QUVUXITJEFSYSAiQrLw/9oACAECAQE/AZKSlzLsJYNw+g9FcJbDbkFcJbDbkEPDZfDbkF5bK4YyC8tlcNuQXlsrhjII+HS2G3IK4S2GMgrhLYbcgrhLYbcgrhLYbcgvHoTIczZhimxSHDQ/aOi/cTZamimokratuop2zXpFxQ5d1JmkrD9o6JoshA/oKtq0q1W46tIuKHLupQ/LQh+B0qhtVCgSq6qohWQqBFNJ3LSLihy7qWPy8DkP6oarStoO1VVU78IlyBNsLSLihy7qUHy0LkOiBVvaqVVkoNOquqqKiVbRw9R1WkXFDl3Uq21KMH4CZEDhUI7U1/0Kqi5fFb6q2FaRKJRfV7Wf7ZtWkXFDl3Uhw0P2joosOwbY3Jr0aFVVUYbShCA3ItTk5yl4bi/4h3LSLihy7qQ4aH7R01GAytQnQvtRDm71bVpWyi9CrjQIStTV+rSLihy7qSnZcS7AXjcPqPRX+WxG5hX+WxG5hX+WxG5hX+WxG5hOm5Q/zbmFeJTEGYRmJTEGYQmZP725hX+WxG5hX+WxG5hX+WxG5hePRWRJm1DNdi//xAA/EAABAgMDBA8GBQUAAAAAAAABAgMABBEFEiETIjFBEBQjMjVRYXF0kZShssHRBkJScoGzFTRikqJDgsLh8f/aAAgBAQAGPwKyHXbIsxxxySZUpSpVBKjcGJwjgWyeyN+kVesqxm+eVbx7o3KxLKVymUb9IzLLsln5ZFrzSYzJazfrZ8uf8IzrF9nnR0FKD5wMp7N2VTXdl269VPOLsvZdkFwCpQZNAUO7ljgWyeyN+kcC2T2Rv0jgWyeyN+kNtSksxKtbVQq4y2EJreVjQRYvQGPtiMgxdVMqGvQ3/uFOPuFZX70KyZF/Ve0QQhsXf0LTGJUj5nhGM8wkcq1HyjdX2nV/JSEl1KmighSVjEA8ddUNyk+6pStDb6jUEfqPn17LfQ0eJUWZMLpdZs1pWmldzGECadz0zCze5DFW1hXJGb1RxGMe6MoxLmZRrUn3fpGAbH0jdG21DmirKtruH3TvVQmyJ9V1WhhSj/Hl5OrYb6GjxKizBrfk5ZvmzEnyhyWUbqjig8RirTJCgdOUSAe+EbZU1lKZ11VabHxp74qkxWZlGXFH3t6rrEbybZ+R31EBY226R8bgp3CBkxkije01Qh16m25dWRf5Tx/UQ30NHiVFhmtKMy32tguY3Uip5IoEuq+kaVt84iqFg80VGBjP645IJlSA78B180EKUQoaUkQpitEzjBSpOoqTnA+LrhvoaPEqLOU3pl5SWc/gB5wFjXp5IS83VZxKhqA0U54L0kUIUd82Y/LdShF5Dakf3CBlbtaY0Oxm9WxRzNdG9c9YsxwpzS6po46ikiG+ho8SokJPCr9mNJSToCsmKd8PAbniQ40rApVFd8n4a01UjLS2sVKR5RmuK64xdV1xnKMb8QSnE0wiqxhyaRsSDRqptu+4eSiT50hvoaPEqLF6Ax9sQ5bNnt3sKzTaRj8/r18ewoIWQFDjisXXK4aCNIjcnb474zkL/ZGNUJ13oJGaTxa4A4octmcQWjNNXJdChiUHG99aYQ30NHiVFi9AY+2NgOv2c2lwHEsktX+e7/2FfhuUs2aAzTfU42r5grHqMbYmpPLyoreelTlUt6cVawMNNKRgQdjTGmCxZzF/J0LrqzdbaB1k+QxwMNzdruN2hNINUspG4JP132w30NHiVFkNO2vZjbjckylaVTSAUkIGBxjhqye1t+scNWT2tv1jhqye1t+scNWT2tv1he3n7AU6v+siZbbd/cDWAPxdlVNZtRGMVT7RlrkTaTHmISyh32emSBi7NPtPOKPHU+VIAFs2QAMABNt4d8cNWT2tv1jhqye1t+sNuykyxNNbVQm+y4FprVWFRH//xAAmEAEAAgEDAwUBAQEBAAAAAAABABEhMUFhEFFxgZGhscHwINHh/9oACAEBAAE/IUwee+6jZV36BaRqrKz4GTDEV7MRmnlrBnheM+kVKl2+mHwQb/JuKr7I3YwvxQtOnDJjP+BgwKqbMRlbQC8Ht0Q6nOLkLopvw9fL3Ku31x9RaxeLc/TM7ZCQD8j7y+l9/wAKsPPYCvthMZ/4Pc/5HKNBRNgGUObxLuMDdBQal87u8t3qVyfFjoK7cuPWXx4wOSZh7W2SDzeO6FQ4NnWDVjzFZULuPrGfXSYXv97yt7jqn7HMlZzN+fEMSlZ/e19ju/4DoVxJhbf8Iz1gt8i7M0v1H9gJd00GU/Zarvk1In8uyXdj2dIn1SEX9lfWBWZWy/ZCRYsPqiWIf4XiLW30YpBK8g9rvTpRQhoPmMIAdTNxhLyqh+yyXvKPtPVM1yxHObypYdhozZIrUcjBJbdx5nfzEbbVAnFS5IKnsD7gDoorpXN//eZwPB3bzN1lFrKctTXaZOrroviPVR5R+wO33cfrmD42Aw6mkYjaxHhMMUnozJx3H1AQijodma630KqVC4JYW1sAY8lqgoGmvXaDqp25Rarecw6xWgVFZRrUEexghi7ZnWeQ3L+n5ajNBkcpakS0sOBnXPmBYnS4vXbqFSNeXstrablfDkKnMrXaQR6+YyJVc3KX2VqpO8z8PSmotRy1bbMAZHhPbWKQcJdEVzsomq1UWBPsUdyZ0S+pVDcexvgWqBu9eGYNb5dL3DoswVd50irUFojQUVrWostHEF+cjrjEN03DKPrIBlHutRFA0y4HKVLRdW1yBwyulE8HlvtBsI7f5GDRlzPJdmrd3WWe9mvdiocKbXlrKOS1tTxlcQY2AjW5UF7EcQu30ADt1DBgpswGF9hLye8//9oADAMBAAIAAwAAABAjd7PSBBd096oTr4kj4iUQEfdIxsia/FUEC/jjYzC/7WJ2AuQPjqyD2DEqSGCB8GCL/8QAJREBAAIBAgYCAwEAAAAAAAAAAQARIRAxIEFRYXGRscGh0fDx/9oACAEDAQE/EDAbhgMAIAFAYDBo5RLmXk8GTNmyPw5YuXLkcKLBQwWlotousujkbz6H77ePZGuhFxjQhMOfBbpX3Yy+iWz/AHaB4BcNIlmTc/vfz530t4s1X5w+4tREcuJVSpUFgsuJGyYCbOlMG9/2wctTLWBAlSnOPxBE9k+Ph0BkH1+S+B0FpcKbwMRLvaVAlaQdiX0L9aUxU2rfCb+JyddYFNkwwqFtolynfLwNXLckqbkfOPvWm5Mt/l37eenXbczXtADWgs2gUtdsphHaZcTnUvPauX9tXXbW25GKb85fe/12jXzks0/eIhGaQekARAUc9Xc8fvPamXpTcBuGhxBiI2JhMnC5cuXO3xKkqHCLbB8BwXLlyuFNgpIaA0001WGf/8QAJBEAAgECBQUBAQAAAAAAAAAAAAERIfAxQVFhwRBxkaGxgdH/2gAIAQIBAT8QYvtubcjcKuhaXAmOFdbGbsti7uC2uB5FtsJYWWw0Y32xaXBaXBaXAmIrFwkksXki06B5MWb07LXOtMKORCQToZHpgRqIO68V8u6VJ6ep+ideWlYQv10FJSvV/opogz6O1kNjeQEONYP0/wCfO2B6n6IIUz+MVvQkIYluggq9ElWIh4jCAVOg5LYr5d1PU/Q5QLV8hzcpEghjaH0JQEniNNW6faJnyl5Z6H6GNqwUvr9aKYowY1qQ9ISwKrEaDegw6eI2yTyif09T9DU2M6yqkmp2lVE6lsQSCNimyGpS2MCdgyoRMMhoyScG1Z+Wz1P0W3QNbiqvZ5vs8Xo5blOkiEhnIVJF0ZVoHciTYkqMiRhAhTec9ttZ0ifU/RadHRpER2ovGH7juIjIxFK+BIxaek3NjiXIbKWSwffVbU0cog9T9CF9pTTQ00lHUtLktLktLktLkxG63Eu+9jByyhu7u5LS5LS5LS5ExFYqU01i80f/xAAmEAEAAgICAgEDBQEAAAAAAAABESEAMUFRYXGBEKGxIJHB0fHw/9oACAEBAAE/EHYEtJEqxVKqq/R41m8QUb2TwC4evTCf8E/xhuOoYjInkStSSYEre+MOr+lf7Z3xJBrE1Q2ju/iCfgx1W7UomKEbJAmfq1ahhf5yD7sAEJKACxKDr6EXfAV+UTYWNKhUhidE08HhBAAoAAAEax01uxPJCD4clLzHrykuGTFGr319kwNp2XfufdgcYLJMdSCPmXrFSUHHoyAAQhBGTKmh6gL4gQJXaEOQyfVm5cLmGwFpgFNinWORVIJHSPbXijjB0MQ9jkT+MPs8mr4eMbM7VY/v4yDCtTQns0/9ebOUA+mY8opy4gtcR/dWQRhyHHs/g4XPMJlOEqT2C96wT10dqO6FTMiF5yTcc/RmIDQnCyGOZgPbjIVO6/Qnw6fbjUmICB5UGEj1o21Q+Eel27wkgRpIXw7HJIqXAg/Jz+feACKrcCvyONN8YseVlezloRRK9f78sbQMeEnnvrDbaE1l17aTUejBygRK0aiQICEQQDnFxYpIebGPuvxjdkYcCSYkhBWi0jA4Q0SP7y+2GJO3+8pOHhbyIX/uHHD3/wBxzmrXX3V1kSdOEB5MGSk2vE6vCj2axe/5URtIp+MkxnYC9BsnXthjlx9HPDajBFsrih9LuAiFw0Ph/jJ5EnMUyHakC6PtQot+216B6a8mscNhUOfMmRM9mAZ0G5iKJQwHuP8AFx8lJ2Yyl8018dY0iUOOTHxvRItR/wB/DpeGSEsRJEKhsmFEr6MZ2WohZSZKgZBpxwEk9KTowRUa3UZfLs2YhjQFDE/nFmiPgI2kA0kqLuZB3XO+T13jsMXYJesJS9Syq7w0qWNL74hgkEyKKMNhV9C+Tr2fOOaUAE0HnIMkkFmlwJZc9Q4MxE+LBApy2Is2wEQkskcDMUxPTo0KQFEgd4tEpJlXlnAHFDxoDh+cYq1oQeRT7LhSvKVYeYR1g83DoZuG29GB1Zfpdyecv4xJzWAu+4JjLiI5MB36LMiXbkQcgQqcslAqBhN8KoakLIBmgiMhHz8yGS6jcBIwmHhCx7N4qUj4cKqM7xG1PeCWNctsZtsA8CiDepiUaA6g5pK8CLBE9YYtXHCgbcgShEUCIj+hq0ahkKZViTT5dsizDSZCAhdylMESgEu6A6DFKGQhFrTtC57XBdOrkh3IjIdAgDXvA0IAEQCoPq1av7sAFJCAiTIDvP/Z"><br>Headend Controller</h1>
  </div>

  <div class="content">
    <div class="card">
    
      <p class="timeinfo"><form action="/get"></p>
    
      <p class="state">Current Time (GMT): <span id="state">%EPOCH%</span></p>
    
      <p class="timeinfo">Enter New Unix Timestamp <input type="number" step="0.1" name="inputTime" value="%TIME%" required></p>  
      <p class="timeinfo">Entered Epoch: <span id="state">&zwj;%TIME%</span></p>    
      <p class="timeinfo">Use New Timestamp <input type="checkbox" name="inputTimeCommit" value="true" %TIME_COMMIT%></p>
    
      <p class="timeinfo">Scan Duration (Minutes) <input type="number" step="0.1" name="ScanTime" value="%SCAN_TIME%" max="120"></p>
      <p class="timeinfo">Entered Duration: <span id="state">&zwj;%SCAN_TIME%</span></p>    
        
      <p class="timeinfo">Start Wifi Scan <input type="checkbox" name="ScanCommit" value="true" %SCAN_COMMIT%></p>
      <p class="timeinfo"><input type="submit" value="Submit"></p>
    
      <p class="timeinfo"></form></p>
      <p class="timeinfo"><iframe style="display:none" name="hidden-form"></iframe></p>
    
      </span></p>
    </div>
  </div>
  
</body></html>

)rawliteral";


// Processor variable section to handle values from web form submitted by mobile device
String processor(const String& var){
  if(var == "EPOCH"){
    return EpochValue;
  }
  else if(var == "TIME"){
    return inputTime;
  }
  else if(var == "TIME_COMMIT"){
    return inputTimeCommit;
  }
  else if(var == "SCAN_COMMIT"){
    return ScanCommit;
  }
  else if(var == "SCAN_TIME"){
    return ScanTime;
  }
  return String();
}



// Routines and Subroutines



// Start MicroSD Subroutine
bool startMicroSD() {
  Serial.print("Starting microSD... ");

  // Pin 13 needs to be pulled-up
  // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/peripherals/sd_pullup_requirements.html#pull-up-conflicts-on-gpio13
  pinMode(13, OUTPUT);
  digitalWrite(13, HIGH);

  if(SD_MMC.begin("/sdcard", true)) {
    Serial.println("OKAY");
    return true;
  } else {
    Serial.println("FAILED");
    return false;
  }
}



// Start Camera Subroutine
bool startCamera() {
  // Turn off the flash - The flash still occurs when files are read or written to the microSD
  pinMode(FLASH_PIN, OUTPUT);
  digitalWrite(FLASH_PIN, LOW);

  // Initialize the camera hardware
  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;
  
  // Set resolution based on whether we have extra memory
  if(psramFound()){
    Serial.println("PSRAM found. Maximum XGA resolution supported.");
    config.frame_size = FRAMESIZE_XGA;
    config.jpeg_quality = 10;
    config.fb_count = 2;
  } else {
    Serial.println("PSRAM not found. Maximum SVGA resolution supported.");
    config.frame_size = FRAMESIZE_SVGA;
    config.jpeg_quality = 12;
    config.fb_count = 1;
  }

  // Start the camera
  Serial.print("Starting camera... ");
  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.println("FAILED");
    return false;
  } else {
    Serial.println("OKAY");
    return true;
  }
}


//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 {
    }
}

// Printout Header Routine
void PrintHeader()
{
    appendFile(SD_MMC, FileName, "WifiScan-Results\n");
}



// WebServer Fault Subroutine
void notFound(AsyncWebServerRequest *request) {
  request->send(404, "text/plain", "Not found");
} 


// Camera Operate and Image Save Subroutine
void takePhoto(String filename) { 
  // Take a photo and get the data

  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) {
    Serial.println("Unable to take a photo");
    return;
  }

  // Make sure it is a JPEG
  if (fb->format != PIXFORMAT_JPEG) {
     Serial.println("Capture format not JPEG");
     esp_camera_fb_return(fb); // Return the photo data
     return;
  }

  // Save the picture to the SD card

  File file = SD_MMC.open(filename.c_str(), "w");
  if(file) {
    Serial.println("Saving " + filename);
    file.write(fb->buf, fb->len);
    file.close();
  } else {
    Serial.println("Unable to write " + filename);
  }

  // Return the picture data
  esp_camera_fb_return(fb);
}



// Capture Photo Routine
void CapturePhoto() {
  
    
        // Keep a count of the number of photos we have taken
        static int number = 0;
      
        // Keep a count of the number of photos we have taken
        number++;
      
        // Construct a filename that looks like "/1653482900.jpg"
        String filename = "/";
        filename += String(rtc.getEpoch());
        filename += ".jpg"; 
        takePhoto(filename);

        
        if (ScanSeconds > 0) {
           unsigned long ScanMillis = millis();
           if (ScanMillis - previousScanMillis >= Scaninterval) {
              previousScanMillis = ScanMillis;              
              
              String countdown = "Seconds remaining: ";
              countdown += String(ScanSeconds);
              Serial.println(countdown);
                            
                            
              // Partial Wifi Scan code
              if (DisableAP == 1) {
                 // Disconnect WiFi to allow scanning
                 WiFi.disconnect();
                 delay(100);    
                 DisableAP = 0;
                 EnableAP = 1;
              }
              // WiFi.scanNetworks will return the number of networks found
              
              String TimeStamp = String(rtc.getEpoch());
              appendFile(SD_MMC, FileName, TimeStamp.c_str()); 
              appendFile(SD_MMC, FileName, ",");
              TimeStamp = String(rtc.getDateTime());
              appendFile(SD_MMC, FileName, TimeStamp.c_str()); 
              appendFile(SD_MMC, FileName, ",");
              int n = WiFi.scanNetworks();  // This number can be staticly set to limit results          
              String StringWifiNetworks = String(n);
              appendFile(SD_MMC, FileName, StringWifiNetworks.c_str()); 
              appendFile(SD_MMC, FileName, ",");
              for (int i = 0; i < n; ++i) {
                  // Print SSID and RSSI for each network found - Maybe append to string so a single write occurs
                  String StringSSID = String(WiFi.SSID(i));
                  appendFile(SD_MMC, FileName, StringSSID.c_str()); 
                  appendFile(SD_MMC, FileName, ",");
                  String StringRSSI = String(WiFi.RSSI(i));
                  appendFile(SD_MMC, FileName, StringRSSI.c_str()); 
                  appendFile(SD_MMC, FileName, ",");
                  String StringEncryptionType = String((WiFi.encryptionType(i) == WIFI_AUTH_OPEN)?" ":"*");
                  appendFile(SD_MMC, FileName, StringEncryptionType.c_str()); 
                  appendFile(SD_MMC, FileName, ",");   
              }
              appendFile(SD_MMC, FileName, "\n");  
              /**
              String networksfound = "WiFi Networks Found: ";
              networksfound += String(n);
              Serial.println(networksfound);
              **/
              
              ScanSeconds--;
           }
          
        }

        if (ScanSeconds == 0) {
          if (EnableAP == 1)  {
            WiFi.softAP(ssid);
            delay(100); 
            EnableAP = 0;  
          }
          ScanEnabled = "Standby";
        }

        
}

// Setup Routine
void setup() {

  ScanEnabled = "Standby";
  
  rtc.setTime(epoch);
    
  // Initialize the peripherals
  Serial.begin(115200);
  while(!Serial) delay(100);
  
  startMicroSD();
  startCamera(); 
  PrintHeader();

  delay(5000); // Delay 5 seconds before first photo
  
  WiFi.mode(WIFI_AP);

  esp_wifi_set_mac(WIFI_IF_AP, &newMACAddress[0]);
  
  WiFi.softAP(ssid);
  delay(100);
  
  IPAddress IP = WiFi.softAPIP();

  IPAddress Ip(192, 168, 100, 1);
  IPAddress NMask(255, 255, 255, 0);
  WiFi.softAPConfig(Ip, Ip, NMask);

  IPAddress myIP = WiFi.softAPIP();

  // Send web page to client
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send_P(200, "text/html", index_html, processor);
  });

  // Receive an HTTP GET request at <ESP_IP>/get?email_input=<inputMessage>&enable_email_input=<inputMessage2>&inputTime=<inputTime>
  server.on("/get", HTTP_GET, [] (AsyncWebServerRequest *request) {
      // GET inputTime value on <ESP_IP>/get?inputTime=<inputTime>

  if (request->hasParam(TIME_INPUT)) {
        inputTime = request->getParam(TIME_INPUT)->value();
    if (request->hasParam(TIME_COMMIT)) {
      // Pass the input and set the RTC time if the commit check box is checked.
        rtc.setTime(inputTime.toInt()); 
      }
    }

  if (request->hasParam(SCAN_TIME)) {
         ScanTime = request->getParam(SCAN_TIME)->value();
         ScanSeconds = ScanTime.toInt();
         ScanSeconds = 10*ScanSeconds; // Change ScanTime minutes to actual seconds (was 60, changed because wifi scan takes 6 seconds per 1 second)
    if (request->hasParam(SCAN_COMMIT)) {
      // Call the wifiscan function, this will break the Headend Controller AP mode
      // The wifiscan function can have a nested image capture function and write to MicroSD function.
      // Start WifiScan if this variable changes from the default via the web interface controller.
      ScanEnabled = "Enabled";   
      int DisableAP = 1;         
      }
    }
    
    // request->send(200, "text/html", "HTTP GET request sent to your ESP.<br><a href=\"/\">Return to Home Page</a>");
    request->send_P(200, "text/html", index_html, processor);
  });
  server.onNotFound(notFound);
  server.begin();
}



// Super Loop Routine
void loop() {
  unsigned long currentMillis = millis();
  if (currentMillis - previousMillis >= interval) {
    previousMillis = currentMillis;
    EpochValue = String(rtc.getTime("%A, %B %d %Y %H:%M:%S"));
    // CurrentTime = String(rtc.getTime());

      if (ScanEnabled.equals("Enabled")) {
        CapturePhoto();
      }
  
      else {
        Serial.println(ScanEnabled);
      }
  }
}

Here is a video of what the mobile device setup looks like and its ease of use as a controller of the ESP32 module.

It demonstrates how to set the time and initiate the scan. It also shows the use of SensorLog, this is a separate app ran on the iphone to log other data. The timestamps and data from both devices can be coorelated to allow all of the data to be combined, which is the primary goal of this topic.

Here is a list of items that were changed in the code that are customizable for any needed application.

1. Set your new MAC Address
// Located in Variables and Constants Function
// Example mac address A1:B2:C3:D4:E5:F6
uint8_t newMACAddress[] = {0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6};
// Located in Setup Function
esp_wifi_set_mac(WIFI_IF_AP, &newMACAddress[0]);

2. Set your IP address
// Located in Setup Function
IPAddress Ip(192, 168, 100, 1);
IPAddress NMask(255, 255, 255, 0);
WiFi.softAPConfig(Ip, Ip, NMask);

3. Set ESP32-Cam up as a wifi AP
// Located in Variables and Constants Function
// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid     = "ESP32-Cam-AP1";
// Located in Setup Function
WiFi.mode(WIFI_AP);  
WiFi.softAP(ssid);

4. File name of datascan log file
// Located in Variables and Constants Function
// This variable is called in several of the functions
const char* FileName = "/datalogger.csv";

5. File name that changes with time for photos
// Located in the Capture Photo Routine
String filename = "/";
filename += String(rtc.getEpoch());
filename += ".jpg"; 
takePhoto(filename);

6. Some numbers on mobile devices are seen as phone numbers which hyperlink
// Located in the HTML code
// &zwj; prevents the hyperlinking and just displays the numbers.
<p class="timeinfo">Entered Epoch: <span id="state">&zwj;%TIME%</span></p> 

7. Embedded image using Base64
// Located in the HTML code
<img src="data:image/jpg;base64,....
I used a linux system to encode in Base64 using this script.

Image2Base64.sh

#!/bin/bash
# https://stackoverflow.com/questions/32698451/how-do-i-convert-a-base64-image
# instead of uploading to a third party - https://www.base64-image.de/

# <img src="<the results here>">

{ echo "data:image/jpg;base64,"; openssl enc -base64 -in uvex.jpg; } > uvex.txt

# text file contents are pasted in HTML code, inside quotes of this tag <img src="">

Although not done on this code, the icon for mobile devices of the website can use an embedded icon file using a similar approach.  Here are a couple of examples of code that would be placed in the HTML code.

...
<link rel="icon" type="image/jpg" href="data:image/jpg;base64..." />
...

 or
 
...
<link rel="icon" type="image/png" href="data:image/png;base64..." />
...

Here is a video that was compiled from the series of images taken during the scan duration. The script to create it is below the video

# Image2Video.sh

#!/bin/bash

cd 'Path containing images'
ls *jpg > imagelist1.txt
sed "s/1653/file \'1653/" imagelist1.txt > imagelist2.txt
sed "s/jpg/jpg\'/" imagelist2.txt > imagelist3.txt
ffmpeg -y -r 10 -f concat -safe 0 -i "imagelist3.txt" -c:v libx264 -vf "fps=10,format=yuv420p" "ESP32-Cam_Timelapse.mp4"
rm *.txt

 

Comments are closed.