/* TL-CAM aka. "Time-Lapse Cam", aka. "Tender Loving Cam". Okay, not that. "Your Friendly Neighbourhood Time-Lapse Camera Server" That'll do for now. More information here: https://corz.org/ESP32/time-lapse-camera-server/ Enjoy! (c) me & the boys @ corz.org 2023 NOTE: A lot of late-night coding sessions here. Expect bugs! See License.txt in the same directory as this sketch (Apache). */ String version = "1.0.2.0"; // "That hidden part we still polish" // These libraries are built-in. // NOTE: double quotes means "search in the local directory first, then the PATH". // Angle braces means skip directly to searching the PATH. #include #include // For storing preferences.. #include #include // For Serial wake from light sleep.. #include // BE CAREFUL, turning this thing on.. #define FLASH_PIN 4 // Pin definition for CAMERA_MODEL_AI_THINKER ESP32-CAM // Change these pin definition if you are using a different camera module. // A list of common camera pin definitions is in the same directory as this sketch: "Camera Pins.txt" #define PWDN_GPIO_NUM 32 #define RESET_GPIO_NUM -1 // -1 == Not Used #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 // These pin definitions work fine for the other standard sensors, e.g. OV5640, which have higher // resolutions, but a common interface. I assume most sensors, would work right out-of-the-box, but // I haven't tested those. OV2640, OV3660 and OV5640 (+ AF) I have tested and they work 100%. /* Settings & Preferences.. */ /* Auto-Snap. The most basic switch for TL-CAM. When this is true, TL-CAM is capturing images at predetermined intervals and when set to false, it is not. The serial command to switch this is: ta This can be useful when setting up your camera and reviewing images, or using your TL-CAM as a manual camera or video streamer (you can capture images while video is streaming, no problem). */ bool autoSnap = true; /* Snap Interval Time These are the default settings. You can change this on-the-fly via Serial console using: s to set the interval in seconds, or.. m to set the interval in minutes. As well as h for hours and d for days. The interval time is saved to non-volatile storage, so it will be remembered after a reboot. Whichever convenient way you set it, TL-CAM will use milliseconds internally. You can also change this directly from the web settings panel. This setting is available from the web interface, saved to NVS and recalled on reboot. */ /* Set this to not-zero to use second intervals.. A two second interval works fine on my AI-Thinker module at 5MP resolution. To know your limits, insert and SD Card and run this command: benchmark */ uint16_t delaySeconds = 0; /* If you set delaySeconds, this is ignored. */ uint16_t delayMinutes = 60; /* The timer begins /after/ the first image, which is always acquired immediately on boot-up, or rather, as soon as various hardware modules are initialised and ready-to-go. It's a lot less trouble to delete an image than it is to capture an image from the past. Actually, that is beyond our current technology. So, we capture from the get-go. */ /* Capture Size. By default, your capture size is set to the maximum your sensor can handle. You can change this here or with the Serial (and in the web interface, rsn) command: size 21 "21" being FRAMESIZE_QSXGA, which will only work if you have a 5MP sensor. Here are the available sizes.. Variable name size C enum UNSUPPORTED 0 (this is used internally) FRAMESIZE_QQVGA, 160x120 1 FRAMESIZE_QCIF, 176x144 2 FRAMESIZE_HQVGA, 240x176 3 FRAMESIZE_240X240, 240x240 4 FRAMESIZE_QVGA, 320x240 5 FRAMESIZE_CIF, 400x296 6 FRAMESIZE_HVGA, 480x320 7 FRAMESIZE_VGA, 640x480 8 FRAMESIZE_SVGA, 800x600 9 FRAMESIZE_XGA, 1024x768 10 FRAMESIZE_HD, 1280x720 11 FRAMESIZE_SXGA, 1280x1024 12 FRAMESIZE_UXGA, 1600x1200 13 // 3MP Sensors FRAMESIZE_FHD, 1920x1080 14 FRAMESIZE_P_HD, 720x1280 15 FRAMESIZE_P_3MP, 864x1536 16 FRAMESIZE_QXGA, 2048x1536 17 // 5MP Sensors FRAMESIZE_QHD, 2560x1440 18 FRAMESIZE_WQXGA, 2560x1600 19 FRAMESIZE_P_FHD, 1080x1920 20 FRAMESIZE_QSXGA, 2560x1920 21 HOWEVER, you may change the raw image size on-the-fly, particularly if you want a picture-in- picture live stream running while you work with TL-CAM, and you definitely don't want you captures to be at 320x240, or whatever your live stream happens to be. So, we have a separate setting for the capture size, which will always be applied prior to capturing an image, regardless of you current sensor setting. */ uint16_t captureSize = FRAMESIZE_QSXGA; /* Image file name prefix. TL-CAM will construct the numeric part of the filename after this text here. For example, if you used.. photo_ .. TL-CAM would create file names like "photo_000027.jpg" or photo_01.50.53~03-05-23.jpg This String is removed before displaying file lists / thumbnails in the web controller. By default, it is blank. */ String fileNamePrefix = ""; /* If you need it, you can enable post-decimal-point precision with your file size displays. This applies to both serial and web displays, so yes, it will mean less /other/ information above your thumbnails, when enabled. Set this to false to ignore anything after the . i.e. 345kB Set this to true for an extra couple of digits after the decimal point. i.e. 345.34kB If you use small thumbnails, I recommend you leave this set to false, to display more useful information. That's more "useful information", not "more useful" information! But that too! At any rate, if you need it, here it is. */ bool showDecimalSize = false; /* Quick List as Default TL-CAM has mostly switched over to more clever ways to list files and calculate remaining image space and so on. This setting still exists so that, if required, we can force TL-CAM to produce a full text list of all image files with timestamps and sizes. In practice, this is rarely needed. As mentioned, listing a large amount of files is SLOW. This is because we need to open each file to retrieve its size and date information. For most operations, we can live without these data. If you regularly deal with large amounts of image files, you will likely want to leave this enabled. The time savings can be HUGE. Allow me to list my current image files both ways.. command: ql Quick List completed in 0.26 seconds Total Files: 1018 command: list Finished processing in 153.54 seconds Total files: 1018 That's right, the regular method is almost 58,953% slower. lol I definitely recommend you leave this set to true. See below. Toggle this from the serial console with the command: tq */ bool quickListings = true; /* NOTE: When this is set to true, you can still force a full listing (and accurate space calculation) in the serial/web console at any time with the "list" command. */ /* Initial File Interrogation Limit. At boot-up, TL-CAM checks the number of files on the card, space used, free space, etc.. If quickListings is DISABLED, this could potentially take a *LONG* time. Or not, if you only have a small number of files. Here you can limit the initial file interrogation to THIS size. (MB) If your files take up more space than this, initial file interrogation will be skipped altogether. Unless you regularly deal with a small number of files (less than say, 300), I recommend you leave quickListings enabled. If quickListings is enabled, this is ignored. */ uint16_t maxMBytesLimit = 300; /* Display Details.. In the web view, TL-CAM always uses quickListings mode automatically right up until the point where it starts to list the files you requested (e.g. ?start=300), then switches over to full mode for the duration of the file listing (so you can have dates and sizes, etc.). This gives you the best of both worlds, so to speak. This hybrid operation means a VASTLY reduced wait time to pages with ?start=*. This happens all the time, by default, and is not configurable, because to disable quickListings for the files /before/ ?start= is time-wasting madness. HOWEVER, once listing has begun, you may wish to /remain/ in quickListings mode, which will reduce the time it takes for your web page to appear, more so as the start number increases. If you have only a few hundred images it will only be a couple seconds. At 1000 images, longer. You obviously get less file information displayed, but it will be FAST. If you don't care about those details and you want your page to display RIGHT NOW, set this to false. Toggle this from the command line with: dd */ bool displayDetails = true; // NOTE: if quickListings = false, this is ignored, as details will always be shown. // * The built-in FS library is straining my desire to use only built-in libraries! /* LED FLASH This is super-bright and not recommended under any circumstances. Just Kidding. You can set the brightness here.. 0 - 255 A brightness of 1-2 (night-day) is useful as an indicator of when image acquisition is occurring. A brightness of 255 can take down aeroplanes. I am not kidding now. The AI-Thinker Cam uses a 3030 SMD, capable of over 180lM/W. A device commonly employed in street lighting and other industrial applications, and it is SUPER-BRIGHT. That is a technical term. It means; FFS! DON'T LOOK DIRECTLY INTO THE LED! Unless you like green spots in your vision and potentially permanent retinal damage. https://www.moon-leds.com/product-smd-led-chip-3030-3v-6v-160-180lm.html I Repeat: DO NOT look directly into the LED when brightness is > 128. You have been warned, twice. You will note from the product page that this SMD has a fairly poor CRI (Colour Rendering Index), so using flash will not get you accurate colours. This may not be important, but is good to know. This value can be changed on-the-fly (command: b) and that change is saved to NVS and restored after a reboot. When you set a new value for the LED brightness, it will momentarily flash *at* that new brightness, so you can see how bright it is. Set to zero to disable the LED Flash altogether. This can be set on-the-fly in the web prefs panel or via the serial/web console. In the web preference interface, values over 127 are shown in red because HOLY $HIT! THAT IS BRIGHT! If you look directly into the LED at anything over 20 you will probably get green spots in your eyes. At 128 and over the damage may be more lasting. This is me warning you, AGAIN. */ uint8_t ledBrightness = 2; /* Print "Extended" information to the Serial console. This will enable notification of all Web requests, and other stuff. You can toggle this setting from a console with the command: e This setting is stored in NVS and remembered across reboots. */ bool eXi = true; /* Overwrite mode. Overwrites previous images, regardless. Useful when you want to leave TL-CAM in a looping situation where only the most /recent/ images matter. If you plan to leave your TL-CAM somewhere with a mind to capturing some event, where you will access your TL-CAM soon after, this is a good option. TL-CAM will keep writing images so long as it has power, replacing the oldest images with the newest. A 4GB card can store 10,000+ Hi-Res (5MP) images before maxing out. That's around a week's worth of 5MP images at 1 image per minute. At lower resolutions and slower intervals you could obviously go longer. In short, when this is set to true, if TL-CAM detects it is out of space, instead of aborting operations, it will carry on; overwriting all previous files as it goes. BE CAREFUL! This setting is saved to NVS. NOTE: This setting only works with sequentially numbered images, not timestamped images, as time cannot loop in the reality in which ESP32 devices exist. */ bool overWrite = false; // NOTE: This has not been tested, but the code says it will work just fine. /* Sleep between captures This will help greatly with power consumption on battery-powered installations. Toggle Auto-Sleep with this command: ts Note: If your snap delayTime is less than 3 seconds, Light Sleep requests will be ignored. If your snap delayTime is less than 20 seconds, Deep Sleep requests will be ignored. During Light Sleep Mode, power consumption is around 0.8mA, compared to the usual 160-260+mA. In Deep sleep, it is around 10uA. Quality batteries* will last a /long/ time. I recommend Samsung or Sony VTC6 18650 batteries. I'd love to recommend the Molicel P26A for its incredible capacity and endurance, but the construction isn't on a par with the two I previously mentioned and you need to be *extremely* careful with them to avoid buckling the contacts. */ bool doSleep = false; /* Instead of light sleep, enter Deep Sleep. This uses even less power, but you cannot wake up the device from sleep via the serial terminal. In fact, if you have auto-sleep enabled, and this is set to true, the only way you are getting back in is if you re-upload the sketch with the setting disabled. Or else, see the next setting. Note: If your snap delayTime is less than 20 seconds, Deep Sleep requests will be ignored. Waking from Deep Sleep is very much like a reboot, so everything starts afresh. Of course the big advantage is HUGE power saving. 10uA, baby! */ bool deepSleep = false; /* Deep Sleep Delay In Deep Sleep mode, TL-CAM will keep the serial connexion open for this number of seconds after capturing an image, before returning to deep sleep. As there is no easy way to wake an ESP32-CAM from Deep Sleep, this will enable you to enter some commands over the serial connexion, e.g.. td Toggle Deep Sleep Mode! */ uint8_t deepSleepDelay = 10; /* NOTE: Deep Sleep is handled slightly differently from Light Sleep. Light Sleep occurs directly after you take a picture, if auto-sleep is enabled. You can send any character over the serial connexion to wake up TL-CAM and continue where you left off. And it will stay awake until the next image is captured, or you manually put it back to sleep with the "sleep" command. To enable a delay for you to enter commands, deep sleep state is checked on every loop(). The upshot of all this is that if you toggled auto-sleep while deep sleep was enabled, TL-CAM would most likely go *immediately* to sleep; as a) a picture has been taken at some point and b) the delay time has passed. This also means you get 10 seconds to enter commands at boot-up, before TL-CAM enters Deep Sleep. Once awake, you can use the "web" command to fire up the remote control server. */ /* Add the file dimensions (e.g. "2560x1920") to the file names. Insert some string you wish to append to the file name. Somewhere in that string, use the "%x" token, which will be replaced, at capture time, with the dimensions of the image file captured. e.g.. String addDimensions = "[%x]"; // [2560x1920] String addDimensions = "_%x"; // _2560x1920 String addDimensions = "--%x"; // --2560x1920 */ String addDimensions = ""; /* Remote Control.. This boolean gets flipped if your WiFi setup failed for some reason, to prevent us attempting to run web control features when there is no WiFi. This can also be toggled on-the-fly from the serial console with the 'tr' (Toggle Remote) command. NOTE: You can keep ONLINE defined (below), and set this to false to use internet time features but /not/ run an active web server, meaning serial connexions only. */ bool remControl = true; /* Local Timezone String https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html This string will setup the timezone with proper rules about when and for how long daylight saving time occurs. Google this exact string for a list of all the other zones. Set this to your /local/ timezone.. */ const char *timeZone = "GMT+0BST-1,M3.5.0/01:00:00,M10.5.0/02:00:00"; /* Use Real Time With the help of your local NTP server, we can set and retrieve real file creation times.. The "created" and "last modified" timestamps will now show the actual saved time, rather than some time in 1970 or whatever. In the absence on an internet connexion, we can fall-back to a user-set time: time 1689401015 The big number being the UTC timestamp. See: https://www.unixtimestamp.com/ Once TL-CAM has this, is will store and retrieve it automatically, updating with the *actual* time when an NTP server becomes available. */ bool useRealTime = true; /* Use date & time stamps for the file names.. */ bool timeStampFileNames = true; /* Time/Date Format. If timeStampFileNames = true, you can choose the format of the date/time string user for naming files. Remember, timestamp information is overlaid onto your thumbnails (as it is stored as the file's creation time). Also, overwrite mode cannot work with timestamped file names (obviously!) so enable this only if you really need/want date & time-stamped file names. Edit to your needs. NOTE: In thumbnail view, this name will be truncated to fit the thumbnail width. Although less relevant in this view, you can still get to the full title, as well as the file's numerical index in the filesystem, by hovering your pointer over the thumbnail. NOTE: putting spaces in here, while possible, would be stupid, as it will break things like HTML id's, which we use for delete events in the slideshow view. As short as possible, is the way. As mentioned, in thumbnail mode, the name is truncated to fit into the thumbnail view. Consider this. And see the next preference. With the default string, you will still see hours and minutes even with 50px thumbnails. The file extension is set elsewhere. */ const char *dateTimeString = "%H.%M.%S~%d-%m-%y"; // 01.50.53~03-05-23 /* You can include any literal characters which are safe for file names on a FAT32 file system. The following tokens are converted to their current time/date value.. %a Abbreviated weekday name %A Full weekday name %b Abbreviated month name %B Full month name %c Complete date and time representation for your locale %d Day of month as a decimal number (01-31) %H Hour in 24-hour format (00-23) %I Hour in 12-hour format (01-12) %j Day of year as decimal number (001-366) %m Month as decimal number (01-12) %M Minute as decimal number (00-59) %p A.M./P.M. indicator for 12-hour clock (in current locale) %S Second as decimal number (00-59) %U Week of year as decimal number, Sunday as first day of week (00-51) %w Weekday as decimal number (0-6; Sunday is 0) %W Week of year as decimal number, Monday as first day of week (00-51) %x Date representation for current locale %X Time representation for current locale %y Year without century, as decimal number (00-99) %Y Year with century, as decimal number (e.g. 2023) %z %Z Time-zone name or abbreviation, (blank if time zone is unknown) %% Literal Percent sign For more details (and locale tokens, etc.) see: https://cplusplus.com/reference/ctime/strftime/ Examples: "%A, %B %d %Y @ %H.%M.%S" => Tuesday, May 02 2023 @ 17.15.30 "%d-%m-%Y@%H.%M.%S" => 02-05-2023@18.13.00 (final name: photo_02-05-2023@18.13.00.jpg) "%H.%M.%S~%d-%m-%y" => 01.50.53~03-05-23 NOTE: The ESP32's built-in strftime does not support # - _ etc. modifiers, so we're stuck with zero padding unless I ever get around to caring enough to code up an alternative. */ /* Reserve memory for thumbnail / file list creation. This should be good up to 200+ thumbnails. If you need more, increase this number accordingly. Each thumbnail uses around 700 - 800 bytes. Then add a wee bit extra. */ uint32_t memReserve = 175000; /* Stream Recording File Name This is the name the downloaded MJPEG file will have in your downloads directory. You can start and stop recording a live stream at the current streaming resolution, by using (') (apostrophe) in the web interface, or by hitting /switch with a web-capable device. Once you have a recording, you can download it from the prefs panel (,). It's probably wise to keep the .mjpeg extension. Unlike most any other MJPEG file you will come across, this will happily play in VLC (desktop) and ffplay (and so also ffmpeg, for converting). I'll test other media players when I get a chance. You can initiate a recording from the console using: record And stop it with: stop */ String streamFileName = "TL-CAM.mjpeg"; /* About the downloaded video stream.. REMEMBER: This is RAW output and will very likely be speeded-up when you play it back in VLC. MJPEG has no built-in mechanism for frame-rate limiting. You could use TL-CAM's built-in frame- rate limiting mechanism, but you would simply be skipping frames, which is probably not what you want. By default we record ALL frames at whatever rate they arrive. In most any player, playback will default to 25 fps, and unless you are recording at a really low resolution, you will not achieve this kind of frame rate from your ESP32-CAM. If you want a video file with the "correct" frame-rate (so it plays back at the same speed it was recorded) you have two options: 1: record the stream with VLC (or whatever) at /stream. This might not have the fidelity of the raw stream, but it's a low-effort way to get what you want. 2: Use FFmpeg (there may be other tools capable of this, I dunno) to convert the video file's frame-rate. A simple command like this will slow it down to half speed: ffmpeg -y -f mjpeg -i /home/Downloads/TL-CAM.mjpeg -an -filter:v "setpts=2*PTS" -an ~/TMP/out.mp4 Replace the paths and what-not. You will likely want to mess with codecs and quality and bitrates and such, but that's the bare-bones and would work as-is. There will be cleverer ways, no doubt. Answers on a postcard. Or just email. NOTE: The live stream preview does NOT need to be open to record a live stream. The pulsing red recording dot will display in the bottom-right of main interface. */ // Comment out the following directive to completely disable WiFi, Time and Web Control features. #define ONLINE // If the above directive is commented-out, ALL the following prefs will be ignored.. /* WiFi + Remote Control Settings. When "ONLINE" is not defined (immediately above), the compiler will completely ignore all wifi/ web/time code/associated libraries/etc., so your binary will be smaller. This might be useful in some situations; maybe for super-quick compiles when testing non-remote code. */ #if defined ONLINE /* WiFi Connect time-out (in seconds, 1-255). This should never happen. */ uint8_t timeOut = 10; /* If you have enabled online features, enter your WiFi network credentials here.. */ const char *ssid = "YOUR-SSID"; const char *password = "YOUR-WIFI-PASSWORD"; /* Custom Host Name If you want to set a custom host name for your device, put it here. If this is empty TL-CAM won't attempt to setup a host name. */ const char *SGhostName = "espcam"; /* You will probably want to use this same host name on your router's (fixed IP) DHCP lease. */ /* Soft Access Point TL-CAM has a built-in Wifi Access Point. (Gateway IP: 192.168.4.1) Set the SSID for the Access Point here.. (this is what will appear in the list of available WiFi networks on your phone / PC / whatever) Make this blank to disable the Soft AP. */ String softAPSSID = "TL-CAM-AP"; /* Password for the AP. Leave this blank to have an open network (i.e. no security). This is /not/ recommended, as anyone could login and see your images, change settings, etc.. */ String softAPPass = "JustOpenUpFool"; /* AP Only mode In this mode TL-CAM will not attempt to connect to any network, but will establish its /own/ network only. You can connect via its WiFi Access Point (see above). */ bool onlyAP = false; /* NTP (Time) Server address Use your local time server to set the actual time. Now we can use proper file creation times and timestamp our files, if required. This won't work in AP-Only mode. You need to be connected to "da internet". Set this to the address of your nearest NTP server.. const char* ntpServer = "1.uk.pool.ntp.org"; const char* ntpServer = "pool.ntp.org"; When testing / hammering, you can use one of Google's time servers.. */ const char *ntpServer = "time1.google.com"; /* Real Time is REQUIRED.. If you set this to true, on NTP failure TL-CAM will keep trying the server until a correct time has been established, /before/ beginning image acquisition. To be clear, unless the correct time has been established, NO IMAGES WILL BE ACQUIRED. You can set a maximum number of retries, below. */ bool realTimeRequired = true; /* Fall-back to stored times. In the event of NTP failure, TL-CAM can fall-back to using stored times, which will have either been manually set by you: time or else supplied by the most recent contact with a time server. On reboot, TL-CAM stores the current UTC to be picked up again at boot-up. You can setup your cam where there is internet access and deploy where there isn't, yet still have accurate-ish timestamps. If this is set to false, TL-CAM will instead either: a) revert to using numerical filenames (realTimeRequired == false) or b) reboot and try again (realTimeRequired == true). Potentially for ever. */ bool useStoredTime = true; /* NTP Retries. When realTimeRequired == true, TL-CAM will retry the NTP server THIS many times before giving up and rebooting / falling-back to stored time. In my experience; on failure, the 2nd attempt will usually work just fine. */ uint8_t maxNTPRetries = 5; /* Truncation direction. If you have you files named, for example: 2023-05-02@22.13.04; when in thumbnail mode, you may want to truncate from the /start/ of the file, as opposed to the end. If so, set this to true. NOTE: There is no truncation in List View mode. */ bool truncateReverse = false; /* Web Page Settings.. We don't store any of the web view settings to NVS as everything is GET parameters and lives in the URL. You can bookmark, script, whatever to get the exact same setup back again at any time. It also means you can have more than one tab open in your browser with different views. */ /* Number of image links / thumbnails to list on the main page. You can use ?start= to start at a different number, like the pagination buttons do. This can be set dynamically from the web interface, so this here is the /minimum/ value that will appear in your drop-down list of values. The maximum is 200. If you need more than this, you will likely want to see memReserve, below. Also, congratulation on having a screen big enough for over 200 thumbnails! */ uint16_t imagesPerPage = 20; /* You can choose the step value used for creating the drop-down menu for images-per-page, and thereby tune it to your interface. If set to 10, your menu choices would be 20, 30, 40, etc.. up to the maximum (200). The first menu entry is always whatever you set for imagesPerPage, above, regardless of what your /current/ images-per-page setting happens to be. */ uint16_t perPageMenuStep = 10; /* Thumbnail size. Set the width of thumbnails. In pixels. Okay, it's actually the other way around. In fact we set the *height* to 3/4 of the user thumb width (so the thumbnails flow properly). Regular 4:3 images will scale perfectly - other ratios will scale to fit at *whatever* width. NOTE: The thumbnails you see are the full images, reduced by your browser, which these days happens in a nice way, with Lanczos scaling and what-not. Upside: when you click on them, being already cached, they will now load instantly in a new tab. Same story for pop-up previews, which use the same source (src=) image. Ditto for SlideShow. There is a drop-down menu in the web interface where you can select your desired thumbnail size. A fair bit of work has gone into enabling you to have *tiny* thumbnails, lots of them; the textual information resizing with the thumbnail, all the way down to 50 pixels. You should be able to fit twenty 50px thumbnails per row, even on a 1366x768 screen. HINT: If you have only so much thumbnails-per-page to fill less than half your browser viewport height, TL-CAM switches automatically to "Gallery Mode". Enjoy. */ uint16_t thumbWidth = 125; /* This can be set dynamically from the web interface or address bar/URL/script/cURL/whatever (&width=150) */ /* What do you call "Images"? These two Strings are used to create the main title at the top of the web page. If you use "Images" and "image", the title would be something like.. Images 1-100 (87 images) But you might prefer to call them something else (Photos, Captures, whatever), so I put these Strings up here where they are easy to get to. Sure, we could just perform operations on one single String; but hey! You might want to use two /different/ Strings.. */ String imgTitleName = "images"; // Plural. I like lower case here but it's your call. String imgTallyName = "image"; // (The one in parentheses: Singular; the "s" is added automatically, as required) /* Please wait. Similarly, you can set the "please wait.. / loading.. " message. This seemingly trivial feature provides a layer of debugging in the UX for folk messing with the JavaScript (me). If you (I) messed up somewhere, the page will sit there, "loading..". */ String loadingMsg = "loading.."; /* Rotating "loading" icon. I noticed that when I am real close to my AI Thinker ESP32-CAM, I can actually *hear* the SD card operations, which turn out to be useful data; e.g. when in list view with pop-up previews enabled, you can hear when the pre-loaded images have finished loading. It's a feature I miss when I'm *not* near the cam, so I made a visual indicator that performs the exact same function. The Reload button will slowly rotate its symbol until /all/ the images are loaded. If that sort of thing annoys you, disable it here. This can also be toggled from the prefs panel. */ bool loadingIcon = true; /* Looping PoP Previews.. When the PoP-Up Preview is in sticky mode, you can use arrow keys to navigate your images. (You can also hit "Delete" to delete the current image) When you reach the end, TL-CAM will thoughtfully pulse the link a couple times to let you know. Or else you can just have it loop right round the other end. This can be toggled from the prefs panel. It defaults to false so you can see the cool pulsing effect before switching to loop mode. */ bool loopPoP = false; /* How long (in milliseconds) to display messages in the web interface. 1500 - 2500 works well (1.5s - 2.5s) depending on how fast you read. NOTE: SD Space and memory information messages will display for 3x this time. */ uint16_t webMessageTime = 2000; /* You can put extra space between the [information] elements in the List View. Or else maximise the space to the erm, max, for /slightly/ larger PoP-Up Previews. You can adjust the size of the spacing in the .pad-left CSS class. (search:pad-left) Currently it is 0.2em. NOTE: This can be set dynamically from the prefs panel and is stored in a browser cookie. */ bool spacesInListView = true; /* Skicky PoP.. On mouse-enabled setups, when you hover over a thumbnail, the PoP-Up Previews pops up and when you move your mouse away from the thumbnail, it vanishes again. On touch devices, when you swipe a thumbnail, the PoP-Up preview pops up and "lingers". If you want this sticky behaviour in your desktop browser, set this to true.. */ bool stickyPoP = false; /* Rounded Corners on thumbnails, thumbnail boxes, pop-up previews and delete buttons? TL-CAM can display these things with tastefully rounded corners, or not. */ bool roundedCorners = true; /* If you want sharp corners on your control buttons, edit the CSS: .c-button class. */ /* Auto-Play SlideShow Timer. How long before we switch to the next image? In seconds. NOTE: This can be set dynamically from the web prefs panel and is saved to a browser cookie, so you can have different times on different setups/browsers. So this is the default setting, for when the user hasn't changed it. */ uint8_t slideTime = 3; /* In a slideshow, I personally like to have the DOWN arrow take me to the NEXT image and the UP arrow take me to the PREVIOUS image, the way a list works. If you consider that back-to-front, set this to true.. */ bool upDownRev = false; /* Image Caching Thumbnails/Images are cached by your browser to prevent stress on you and your ESP32 module if you are going backwards and forwards or refreshing in the thumbnail view, watching slideshows, etc... If you want the images to load completely afresh, you can use Ctrl+F5 (or your OS's equivalent). If you do a lot of deleting, or enjoy a slideshow, I recommend you leave this set to true, at least for a few minutes (see next pref). To disable caching altogether, so images *always* load afresh (which for 2MP and 3MP images at least, is actually pretty fast), set this to false. NOTE: This can be set from the web prefs panel and is saved to a browser cookie. */ bool cacheImages = true; /* Cache Time How long to cache images in your browser, in minutes. After this time, images will *always* re-load afresh. NOTE:!: If you enable caching and have the dev tools open in Chrome (F12), ensure you haven't checked the "Disable cache (while DevTools is open)" checkbox; which I believe is the default setting - click the gear icon to check / change this. There's three minutes I won't get back! NOTE: This can be set from the web preferences and is saved to a browser cookie. NOTE: The caching header is sent *with* the image, so if you set the cache time to 30m and load an image and then set the cache time to 60m, the image will be considered stale after 30m, not 60m. If you refresh the image, NOW it will be cached for 60m. */ uint16_t cacheTime = 30; /* When caching is enabled, you can hit REFRESH (F5, probably) and get your new thumbnails without forcing your ESP32 module to produce the entire page afresh (which means re-loading (streaming) every single image on the page). If you tend to go backwards and forwards a lot, or are deleting images, or use the SlideShow feature, caching is best. For my usage scenarios, caching is best. */ /* Dark Mode You can switch between light and dark modes from the prefs panel. When you switch modes, TL-CAM sets a cookie in your browser and reloads the page. This here is the default setting for a fresh install or new web browser with no stored cookie. darkMode can be one of three settings: LIGHT, DARK, and AUTO. */ bool darkMode = false; /* Automatic Dark Mode. TL-CAM can switch the interface over automatically, depending on the time of day. */ bool autoDark = true; /* For Automatic Dark Mode.. Set the hour in which day and night begin (in 24h notation). */ uint8_t dayBegins = 6; uint8_t nightBegins = 20; /* Background "color" for your web interface. There are two of these. One for light and the other for dark. This color applies to all backgrounds; page, buttons and controls, borders, and so on, and the darkMode switch (above) switches between the two. White is "#fff" (CSS short-form rgb hex for: 255, 255, 255) This is a nice light color: "#ddfda9;" */ // Light String lightBGColor = "#fff"; // And Dark String darkBGColor = "#1b1f15"; /* A note about thumbnail ordering; we don't. It's not feasible. The order you get thumbnails is the order the SD file system spits them out. If you insert a blank SD and take lots of pictures, the ordering will be /perfectly/ numerical, but if you delete any images from the start/middle of the set, and then take more pictures, the ordering will be decided by the machine-elves, or something. Or more likely by the first available allocation blocks. At any rate, if you need to see your files in some particular order, use a PC. Or let TL-CAM work without interruption. The only time this might be important is if you use the "wipe" command with a numerical index, e.g.. wipe 50 Where all file from 50-onwards will be deleted. BUT this does not mean "photo_00050.jpg" onwards, but "the file that got spat out in 50th position, onwards". Use the: list / l command to see the current file order. The numerical index of each file is printed out alongside the file information. This index is also posted in each thumbnail's pop-up title. */ /* SNAP! (yeah, but how?) TL-CAM offers /two/ commands to snap an image on-demand; ! (exclamation point) Snap an image right now and RESET the snap interval timer to NOW. . (regular point/full stop) Snap an image right now and DO NOT RESET the timer. Which variant would you like the /web interface/ to use? NOTE: This can be set from the web preferences and is saved to NVS. */ bool webSnapResetsTimer = false; /* Twin Streaming. aka. Simultaneous Streaming Preview AND Recording. By default, if you start recording the stream when the live stream preview is open, the live view pauses until recording completes. This makes for optimal frame-rates. But of course, you can't see the live stream as it happens. If you really need this, and don't mind the frame-rate hit (maybe 5-8%, not too bad), you can enable simultaneous streaming and recording right here. This can also be set from the prefs panel and is remembered in a cookie. */ bool streamANDRecord = true; /* NOTE: If you want to watch and record simultaneously, start the live stream /first/, then start recording. It won't work the other way around. */ /* Zoomed Mouse Move Acceleration When you click a stream to switch to full size, one of two things happens. If the stream size is smaller than the viewport, you can pick it up and move it around, rotate it (R), etc.. If the stream size is *bigger* than the viewport, it will fill the viewport and zoom-in to wherever you clicked. You can then move around the image by either zooming in and out to wherever you need, wheeling around (shift-wheel should move you sideways) or my favourite.. Drag the image around. I like it because the mouse motion is accelerated and you can whiz around a 5MP image like your pointer has a rocket-pack, or not. It's up to you. Any whole number between 1 and 100 is fine; the default being 10, which should enable you to get to every point of a 5MP image from clicking directly in the centre and dragging. Higher numbers increase acceleration. The farther your mouse travels, the faster it gets. 1 enables *extremely* small adjustments to the position, yet still enables a /reasonable/ speed for moving around (click-click would be faster). 100 is just silly. Technically, the upper limit here is 127 (uint8_t), but that's crazy; or else the basis of a fun new party game entitled, "Goan! Centre My Nose!". There is a slider control for this in the prefs pane. Settings are stored in a cookie so you can keep different setups for different systems/browsers. */ uint8_t zmAcelleration = 10; /* Hacks. */ /* On Chrome, the Streaming SlideShow will appear to run one number behind what is reported to the console, due to a bug they seem unwilling to fix, as it look like they want to end support for multipart streams. This is of course lazy and stupid on the part of Chrome devs, but there you have it. Firefox wins. If you are watching your console during a Streaming SlideShow in Chrome, this will be confusing, and so you can enable this hack. You will still need to wait for the /second/ image to load* before you /see/ the first, and so on, but at least the numbers in the console will match what you see. * If that is going to be a long time, you can right away send the command: next */ bool chromeHack = false; // End ONLINE Settings #endif /* End Prefs */ /* Basic internal variables setup */ // Non-blocking delay for snap timer.. uint64_t lastSnapTime = millis(); uint64_t delayTime; // Let's save a few calculations down the line.. const uint64_t MINUTE_MILLIS = 60000; const uint64_t HOUR_MILLIS = 3600000; const uint64_t DAY_MILLIS = 86400000; // I love how these all line up! // For sleep.. const uint64_t SECOND_MICROS = 1000000; // For Automatic Dark Mode.. bool nightTime = false; // Wear-levelling bytes used.. uint64_t levellingBytes; // Calculated from your current collection of image files.. uint64_t averageSize; // Also used offline.. uint32_t mostRecentPic = 0; // The attached sensor, as a String, e.g. "OV5660". String sensorType; bool supported; // Time.. uint64_t storedTime; struct tm timeinfo; bool doRecording = false, amStreaming = false, amRecording = false; // Remote Control.. #if defined ONLINE // WiFi/Web libraries.. #include #include // For the multipart live stream. Some string to use as a delimiter for the JPEG frames. #define PART_BOUNDARY "1z2z3z4z5z6z7z6z5z4z3z2z1" // The web server.. WebServer server(80); // We'll re-use these.. const String _HTML_ = "text/html"; // UTF-8 is the default for HTML5. const String _TEXT_ = "text/plain; charset=UTF-8"; // For text/plain, you need to specify if you want UTF-8 characters to work. const String noMoreSlides = " No more slides!"; // NOTE: The text/plain output is just that; there is no HTML, no styles. In theory, this means you // get plain text the way /you/ like it, in your preferred browser font and, at least with modern // versions of some browsers, automatic day/night colours, just like TL-CAM itself. // NTP.. uint64_t updateTime; uint8_t retryCount = 1; bool darkModeINIT = darkMode; bool autoDarkINIT = autoDark; bool amDark = false; uint8_t slideTimeINIT = slideTime; bool stickyPoPINIT = stickyPoP; bool loadingIconINIT = loadingIcon; bool loopPoPINIT = loopPoP; bool streamANDRecordINIT = streamANDRecord; uint8_t zmAcellerationINIT = zmAcelleration; bool spacesInListViewINIT = spacesInListView; bool cacheImagesINIT = cacheImages; uint16_t cacheTimeINIT = cacheTime; bool silentStream = false; bool thumbView = false, doPoP = false; uint32_t displayFrom = 1; // This is the highest number which will appear in the images-per-page drop-down menu // You could increase this, but do first edit memReserve (above). uint8_t perPageMax = 200; // Final background color String bgColor; // Remember initial user settings.. // (no web settings are saved to NVS, as you have GET parameters you can store) uint16_t INITimagesPerPage; uint16_t INITthumbWidth; // // The first time a new client loads the web console page they get the full list of command. // // We use this string to store "known" clients, so we don't repeat that on subsequent requests. // // This is reset on reboot. // String knownClients; // For live stream and streaming slideshow.. const char *headerData = "HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *\r\n"; const char *contentType = "Content-Type: multipart/x-mixed-replace; boundary=" PART_BOUNDARY "\r\n"; const char *boundaryMark = "\r\n--" PART_BOUNDARY "\r\n"; const uint8_t bmLen = strlen(boundaryMark); const char *frameType = "Content-Type: image/jpeg\r\nContent-Length: "; const uint8_t ctLen = strlen(frameType); // Streaming SlideShow Commands.. bool sShowPlaying = false; bool sssPause = false; int64_t sssSkip = 0; String currentSlide; // #else // bool useRealTime = false; #endif // Initialize NVS Preferences Instance.. Preferences prefs; // xCommand for command overrides. String xCommand, mostRecentCapture; // This string is set with the output from commands. It is the console "output", like you would get // in a serial console. In the Web Console, we use AJAX to fetch it directly after a command is sent. String LastMessage = "foo"; // Tallies and Counts for file listing/viewing.. uint32_t totalFileCount; uint32_t saveNumber; // Any of the JPEG extensions should work fine here.. String fileEXT = ".jpg", imgDimensions; // true when SD is ejected (properly, with "eject" command) bool ejected = false; // If an SD Card write fails, or some other fatal error, we immediately stop taking photos.. bool abortUserNotified = false; String ABORT = "0"; // The why. Reported to your console. Also acts as boolean for the ABORT state. // We'll re-use this.. uint32_t _1MB_ = 1048576; // 1 real MB, in bytes (1024 * 1024) // SD Benchmark.. uint32_t SDTestSize = _1MB_; // LED Brightness.. const uint8_t ledChannel = 1; // This is a GOOD channel! const uint16_t ledFreq = 8000; // PWM frequency - keep it high-ish for picture-taking. const uint8_t ledResolution = 8; // 8-bit, so we can use 0-255 for brightness. // Image Settings.. int16_t brightness, contrast, saturation, special_effect, awb, awb_gain, wb_mode, dcw; int16_t aec, aec2, ae_level, aec_value, agc, agc_gain, gainceiling, min_frame_time; int16_t raw_gma, bpc, wpc, lenc, vflip, hmirror, denoise, sharpness, colorbar, framesize; float_t max_fps; // We'll need / want to set these before reading user prefs.. int16_t xclk=20, quality=10; // Unless otherwise stated, it's VGA: size enum int16_t maxSize = FRAMESIZE_VGA; // 640x480 8 // Setup limits for different sensors. // These are the "standard" limits, e.g. OV2640 int16_t minBrightness = -2, maxBrightness = 2; int16_t minContrast = -2, maxContrast = 2; int16_t minSaturation = -2, maxSaturation = 2; int16_t minAELevel = -2, maxAELevel = 2; int16_t minSharpness = -2, maxSharpness = 2; int16_t minDenoise = 0, maxDenoise = 1; int16_t minGC = 0, maxGC = 6; String frameSizes[] = { "UNSUPPORTED", "QQVGA", "QCIF", "HQVGA", "240X240", "QVGA", "CIF", "HVGA", "VGA", "SVGA", "XGA", "HD", "SXGA", "UXGA", "FHD", "P_HD", "P_3MP", "QXGA", "QHD", "WQXGA", "P_FHD", "QSXGA" }; uint64_t avgSizes[] = { 1, 2007, 2652, 4413, 6021, 8028, 12380, 16056, 32112, 50176, 82216, 96384, 137031, 200724, 216780, 96384, 138741, 328867, 385392, 428216, 216780, 513853 }; /* 96x96 0 Ave kB Ave B 96x96 not supported. We use 0 internally. 160x120 1 1.96 2007 176x144 2 2.59 2652 240x176 3 4.31 4413 240x240 4 5.88 6021 320x240 5 7.84 8028 400x296 6 12.09 12380 480x320 7 15.68 16056 640x480 8 31.36 32112 800x600 9 49 50176 1024x768 10 80.29 82216 1280x720 11 94.09 96384 1280x1024 12 133.82 137031 1600x1200 13 196.02 200724 1920x1080 14 211.7 216780 720x1280 15 94.09 96384 864x1536 16 135.49 138741 2048x1536 17 321.16 328867 2560x1440 18 376.36 385392 2560x1600 19 418.18 428216 1080x1920 20 211.7 216780 2560x1920 21 501.81 513853 */ /* Gather up Preferences. Get and assign settings from Non-Volatile Storage.. USED: a, b, d (delayTime), e, o, p, q, r, s, t, u, w, z Doubles as help for the sensor settings. */ String gatherPrefs(bool init=false, bool final=true) { char buffer[420] = {'\0'}; // We always print this out (gathered elsewhere).. sprintf(buffer, " Current Settings:\n\n"); sprintf(buffer + strlen(buffer), " Snap Interval Time:\t%s\n", convertSeconds(delayTime/1000).c_str()); if (init) eXi = prefs.getBool("e", eXi); sprintf(buffer + strlen(buffer), " Extended Information:\t%s\n", (eXi ? "enabled" : "disabled")); if (init) remControl = prefs.getBool("r", remControl); sprintf(buffer + strlen(buffer), " Remote Control:\t%s\n", (remControl ? "enabled" : "disabled")); if (init) doSleep = prefs.getBool("z", doSleep); sprintf(buffer + strlen(buffer), " Auto-Sleep: \t%s\n", (doSleep ? "enabled" : "disabled")); if (init) autoSnap = prefs.getBool("a", autoSnap); sprintf(buffer + strlen(buffer), " Auto-Snap: \t%s\n", (autoSnap ? "enabled" : "disabled")); if (init) deepSleep = prefs.getBool("s", deepSleep); sprintf(buffer + strlen(buffer), " Deep Sleep: \t%s\n", (deepSleep ? "enabled" : "disabled")); if (init) ledBrightness = prefs.getUChar("b", ledBrightness); sprintf(buffer + strlen(buffer), " LED Brightness:\t%i\n", ledBrightness); if (init) overWrite = prefs.getBool("o", overWrite); sprintf(buffer + strlen(buffer), " Overwrite Mode:\t%s\n", (overWrite ? "enabled" : "disabled")); if (init) quickListings = prefs.getBool("q", quickListings); sprintf(buffer + strlen(buffer), " Quick Listing Mode:\t%s\n", (quickListings ? "enabled" : "disabled")); if (init) timeStampFileNames = prefs.getBool("t", timeStampFileNames); sprintf(buffer + strlen(buffer), " Timestamp file names:\t%s\n", (timeStampFileNames ? "enabled" : "disabled")); #if defined ONLINE if (init) webSnapResetsTimer = prefs.getBool("w", webSnapResetsTimer); sprintf(buffer + strlen(buffer), " Web snap resets timer:\t%s\n", (webSnapResetsTimer ? "true" : "false")); if (init) displayDetails = prefs.getBool("p", displayDetails); sprintf(buffer + strlen(buffer), " Display Details:\t%s\n", (displayDetails ? "enabled" : "disabled")); #endif if (final) sprintf(buffer + strlen(buffer), " %s\n", getFreeEntries().c_str()); return (String)buffer; } String gatherSensorPrefs(bool init=false) { static char buffer[3086] = {'\0'}; char *c = buffer; c += sprintf(c, "\n Sensor Settings:\n\n"); /* Sensor Settings.. It might seem simpler to use the two built-in NVS functions for this (which TL-CAM uses for the quick backup facility), and it may be, but TL-CAM prefers to keep its own set of all sensor variables. NOTE: The quick backup and restore facility does NOT save and restore the following settings: capture size clock frequency frame-limit */ // This is a separate setting, so you can have small live stream and full-size captures.. if (init) captureSize = prefs.getShort("size", captureSize); c += sprintf(c, " %s size: %i [%s] \tCapture Frame Size (2MP:0-13, 3MP:0-17, 5MP:0-21): \n", prefs.isKey("size") ? "*" : " ", captureSize, frameSizes[captureSize].c_str()); if (init) ae_level = prefs.getShort("ae_level", 0); c += sprintf(c, " %s ae_level: %i \tAuto Exposure Level (-2 to 2, OV3660/OV5640: -5 to 5)\n", prefs.isKey("ae_level") ? "*" : " ",ae_level); if (init) aec2 = prefs.getShort("aec2", 1); c += sprintf(c, " %s aec2: %i \tAutomatic Exposure Correction (0/1): \n", prefs.isKey("aec2") ? "*" : " ",aec2); if (init) aec = prefs.getShort("aec", 1); c += sprintf(c, " %s aec: %i \tExposure Control (0/1)\n", prefs.isKey("aec") ? "*" : " ",aec); if (init) aec_value = prefs.getShort("aec_value", 204); c += sprintf(c, " %s aec_value: %i \tAuto Exposure Amount (0-1200, OV3660: 0-1536, OV5640: 0-1920)\n", prefs.isKey("aec_value") ? "*" : " ",aec_value); if (init) agc = prefs.getShort("agc", 1); c += sprintf(c, " %s agc: %i \tExposure Gain Control (0/1)\n", prefs.isKey("agc") ? "*" : " ",agc); if (init) agc_gain = prefs.getShort("agc_gain", 0); c += sprintf(c, " %s agc_gain: %i \tAGC Gain Level (when agc=0) 0-30, OV3660/OV5640: 0-64\n", prefs.isKey("agc_gain") ? "*" : " ",agc_gain); if (init) awb = prefs.getShort("awb", 1); c += sprintf(c, " %s awb: %i \tAutomatic White Balance (0/1)\n", prefs.isKey("awb") ? "*" : " ",awb); if (init) awb_gain = prefs.getShort("awb_gain", 1); c += sprintf(c, " %s awb_gain: %i \tAutomatic White Balance Gain (0/1)\n", prefs.isKey("awb_gain") ? "*" : " ",awb_gain); if (init) bpc = prefs.getShort("bpc", 0); c += sprintf(c, " %s bpc: %i \tBlack Point Control (0/1)\n", prefs.isKey("bpc") ? "*" : " ",bpc); if (init) brightness = prefs.getShort("brightness", 0); c += sprintf(c, " %s brightness: %i \tBrightness (-2 to 2, OV3660/OV5640: -3 to 3)\n", prefs.isKey("brightness") ? "*" : " ",brightness); if (init) colorbar = prefs.getShort("colorbar", 0); c += sprintf(c, " %s colorbar: %i \tTest Colour Bars (0/1)\n", prefs.isKey("colorbar") ? "*" : " ",colorbar); if (init) contrast = prefs.getShort("contrast", 0); c += sprintf(c, " %s contrast: %i \tContrast (-2 to 2, OV3660/OV5640: -3 to 3)\n", prefs.isKey("contrast") ? "*" : " ",contrast); if (init) denoise = prefs.getShort("denoise", 0); c += sprintf(c, " %s denoise: %i \tDenoise (0/1, OV5640 = 0:auto to 8)\n", prefs.isKey("denoise") ? "*" : " ",denoise); if (init) dcw = prefs.getShort("dcw", 1); c += sprintf(c, " %s dcw: %i \tDownsize image to requested size (0/1)\n", prefs.isKey("dcw") ? "*" : " ",dcw); if (init) framesize = prefs.getShort("framesize", 0); c += sprintf(c, " %s framesize: %i [%s]\tLive Stream Frame Size (as 'size' - above): \n", prefs.isKey("framesize") ? "*" : " ",framesize, frameSizes[framesize].c_str()); if (init) gainceiling = (gainceiling_t)prefs.getShort("gainceiling", 3); c += sprintf(c, " %s gainceiling: %i \tGain Ceiling (0-6, OV3660/OV5640: 511)\n", prefs.isKey("gainceiling") ? "*" : " ",gainceiling); if (init) hmirror = prefs.getShort("hmirror", 0); // My OV5640 needs this set c += sprintf(c, " %s hmirror: %i \tHorizontal Mirror (0/1)\n", prefs.isKey("hmirror") ? "*" : " ",hmirror); if (init) lenc = prefs.getShort("lenc", 1); c += sprintf(c, " %s lenc: %i \tLens Correction (0/1)\n", prefs.isKey("lenc") ? "*" : " ",lenc); if (init) quality = prefs.getShort("quality", 10); c += sprintf(c, " %s quality: %i \tQuality (4-63, OV3660: 6?-63, OV5640: 8-63)\n", prefs.isKey("quality") ? "*" : " ",quality); if (init) raw_gma = prefs.getShort("raw_gma", 1); c += sprintf(c, " %s raw_gma: %i \tRaw Gamma (0/1)\n", prefs.isKey("raw_gma") ? "*" : " ",raw_gma); if (init) vflip = prefs.getShort("vflip", 0); c += sprintf(c, " %s vflip: %i \tVertical Mirror (0/1)\n", prefs.isKey("vflip") ? "*" : " ",vflip); if (init) saturation = prefs.getShort("saturation", 0); c += sprintf(c, " %s saturation: %i \tSaturation (-2 to 2, OV3660/OV5640: -4 to 4)\n", prefs.isKey("saturation") ? "*" : " ", saturation); if (init) sharpness = prefs.getShort("sharpness", 0); c += sprintf(c, " %s sharpness: %i \tSharpness (-2 to 2, OV3660/OV5640: -3 to 3)\n", prefs.isKey("sharpness") ? "*" : " ",sharpness); if (init) special_effect = prefs.getShort("special_effect", 0); c += sprintf(c, " %s special_effect: %i\tSpecial Effect", prefs.isKey("special_effect") ? "*" : " ", special_effect); c += sprintf(c, " (0-6) 0 nothing, 1 Negative, 2 Greyscale, 3 Red Tint, 4 Green Tint, 5 Blue Tint, 6 Sepia\n"); if (init) wb_mode = prefs.getShort("wb_mode", 0); c += sprintf(c, " %s wb_mode: %i \tWhite Balance Mode (if awb_gain enabled)", prefs.isKey("wb_mode") ? "*" : " ", wb_mode); c += sprintf(c, " (0-4) 0 - Auto, 1 - Sunny, 2 - Cloudy, 3 - Office, 4 - Home\n"); if (init) wpc = prefs.getShort("wpc", 1); c += sprintf(c, " %s wpc: %i \tWhite Point Control (0/1)\n", prefs.isKey("wpc") ? "*" : " ",wpc); if (init) xclk = prefs.getShort("xclk", 12); c += sprintf(c, " %s xclk: %i \tClock Frequency (4-30)\n", prefs.isKey("xclk") ? "*" : " ",xclk); // This setting isn't a part of the camera API, but you can set and save it using the sensor settings API. if (init) max_fps = prefs.getFloat("max_fps", 24); c += sprintf(c, " %s max_fps: %.2f \tLive Stream Frame Rate Limit (fps). (0.5 = 1 frame every 2 seconds) 0 to disable limiting.\n", prefs.isKey("max_fps") ? "*" : " ",max_fps); c += sprintf(c, "\n Settings marked * have been set and saved to NVS. Other settings are defaults.\n"); // For when switching sensors or whatever.. if (ae_level < minAELevel || ae_level > maxAELevel) { ae_level = 0; prefs.putShort("ae_level", 0); } if (brightness < minBrightness || brightness > maxBrightness) { brightness = 0; prefs.putShort("brightness", 0); } if (contrast < minContrast || contrast > maxContrast) { contrast = 0; prefs.putShort("contrast", 0);} if (denoise < minDenoise || denoise > maxDenoise) { denoise = 0; prefs.putShort("denoise", 0); } if (gainceiling < minGC || gainceiling > maxGC) { gainceiling = 3; prefs.putShort("gainceiling", 3); } if (saturation < minSaturation || saturation > maxSaturation) { saturation = 0; prefs.putShort("saturation", 0); } if (sharpness < minSharpness || sharpness > maxSharpness) { sharpness = 0; prefs.putShort("sharpness", 0); } c += sprintf(c, " %s\n", getFreeEntries().c_str()); return (String)buffer; } /* OKAY! Let's DO IT! */ /* Iterate files in the root directory (the only directory we deal with*). While here, we can create file listings, count files, delete files, and so on, so this function is re-used a fair bit. This function handles the listing part of the generation of plain text listing over the serial connexion, filelist-style plain text HTML listings, as well as thumbnails. While here for /full/ listings, we count the files and tally all file sizes to produce estimates about how many more images you might be able to save on your particular SD Card. Listings for the web can be /partial/, so we don't gather such data while serving web clients. You can wipe a *range* of files, supplying start, and optionally end indexes. These indexes are the number they would appear at if you used the command: list If required, you could wipe a single file like so: wipe 18-18, but using the web interface is much, much easier. When we need new functionality that involves iterating the files, we throw it in here. Returns a String. * If you are using one small part of your SD Card over and over, consider creating a directory in the root and then, over time, putting ever-increasingly large files in it, so you don't wear out that part of your SD Card, rendering the whole thing useless (at least, unless you created some tricky partition). Or you might be fortunate enough to own a 4GB microSD that does its own wear-levelling, but this is highly unlikely. NOTE: If you want to do your own wear-levelling, create a directory in the root. Put your wear- levelling files in there and they will be incorporated into the free-space calculations. */ String listDir(bool isSerial = false, bool webDisplay = false, bool webList = false, \ bool deleteFiles = false, bool INIT = false, uint64_t delX = 0, uint64_t delZ = UINT64_MAX) { if (ejected) return " SD Card is Ejected!"; uint32_t fileCount = 0; uint64_t currentTime = millis(); #if defined ONLINE String webString = ""; // We use the same String for either of these, but not both together. if (webDisplay || webList) webString.reserve(memReserve); #endif String serialString = ""; if (isSerial) serialString.reserve(memReserve); if ( (isSerial || webList) && !deleteFiles) serialString.concat("\n Listing Images..\n"); File root = SD_MMC.open("/"); if ( !root || !root.isDirectory()) { ABORT = " Problem with root directory. Check your SD Card."; return ABORT; } File file; String fileName; bool qlTMP = quickListings; #if defined ONLINE // If we are displaying a web page of thumbs/links, we skip full checking for all files UP // UNTIL the first file we are listing, then switch over to full details for only /those/ files. if (webDisplay) { qlTMP = (displayFrom != 1) ? true : false; // Dynamic quickList state. } #endif // We always use quickListings for deletions.. if (deleteFiles) qlTMP = true; if (qlTMP) { fileName = root.getNextFileName(); if (fileName == "") return ""; } else { file = root.openNextFile(); // <- Dig the slowness if (!file) return ""; } uint64_t dFiles = 0; String realName; while (true) { if (qlTMP) { realName = fileName.substring(1); } else { realName = (String)file.name(); } if (realName == "") break; if (realName.indexOf(fileEXT) == -1) { if (qlTMP) { fileName = root.getNextFileName(); if (fileName == "") break; } else { file.close(); file = root.openNextFile(); if (!file) break; } continue; } // We are deleting.. if (deleteFiles) { dFiles++; // All done with deletions.. if (dFiles > delZ) break; if (dFiles < delX) { fileName = root.getNextFileName(); if (fileName == "") break; continue; } if (SD_MMC.remove("/" + realName) ) { serialString.concat(" deleted: [" + (String)dFiles + "] " + realName + " OK!\n"); fileCount--; } else { serialString.concat(" FAILED to delete: " + realName + " OK!\n"); } } else { // Not deleting.. fileCount++; char fileSize[12] = {'\0'}; // Create a time + date strings for this file.. // We use two separate buffers so that we can shrink the display for small/tiny thumbnails. char dateBuffer[32] = {'\0'}; char timeBbuffer[32] = {'\0'}; if (!qlTMP) { float_t kbSize = file.size()/1024.00; if (showDecimalSize) { sprintf(fileSize, "%.2f", kbSize); } else { sprintf(fileSize, "%.0f", kbSize); } if (useRealTime) { time_t t= file.getLastWrite(); struct tm * tmstruct = localtime(&t); sprintf(dateBuffer, "%d-%02d-%02d", (tmstruct->tm_year)+1900, (tmstruct->tm_mon)+1, tmstruct->tm_mday); sprintf(timeBbuffer, "%02d:%02d:%02d", tmstruct->tm_hour , tmstruct->tm_min, tmstruct->tm_sec); } } /* Serial Console list.. */ if (isSerial) { serialString.concat(" [" + (String)fileCount + "] " + realName); if (!qlTMP) { serialString.concat(" [" + (String)fileSize + "kB]"); if (useRealTime) serialString.concat(" [" + (String)dateBuffer + " @ " + (String)timeBbuffer + "]"); } serialString.concat("\n"); } #if defined ONLINE if (webList) { webString.concat(" [" + (String)fileCount + "]\t" + realName); if (!qlTMP) { webString.concat("\t[" + (String)fileSize + "kB]"); if (useRealTime) webString.concat("\t[" + (String)dateBuffer + " @ " + (String)timeBbuffer + "]"); } webString.concat("\n"); } /* Main Web Page.. */ if (webDisplay && (fileCount >= displayFrom) && (fileCount < (displayFrom + imagesPerPage) ) ) { /* Here Be Thumbnails. NOTE: The actual, whole image is loaded into each thumbnail, scaled down by your browser. */ String displayName = realName; displayName.replace(fileEXT, ""); displayName.replace(fileNamePrefix, ""); String displaySize = (String)fileSize; uint16_t nLen = displayName.length(); // Spit out a thumbnail box for.. if (thumbView) { // Limit for how big thumbnails need to be before attempting to show certain elements // above the thumbnail, e.g. size information. uint8_t kBL = (showDecimalSize) ? 130 : 105; int16_t cutOFF; webString.concat("\n"); // Plain-ish index-type listing.. } else { String space = ""; if (spacesInListView) space = " pad-left"; webString.concat(""); } } // We can at least break out once we have enough files for web display purposes.. if (webDisplay && fileCount > (displayFrom + imagesPerPage) ) break; #endif } bool doFile = true, switching = false; #if defined ONLINE if (qlTMP) { doFile = false; // Switch to full listings mode for our selected file range.. if (displayDetails && webDisplay && (fileCount >= (displayFrom-1)) ) { qlTMP = false; doFile = true; switching = true; // Serial.printf("------->SWITCHING @ %.2f seconds\n", (float_t)(millis() - currentTime) / 1000); } else { fileName = root.getNextFileName(); if (fileName == "") break; } } #endif if (doFile) { if (!switching) file.close(); // would work fine without checking switching var, but this feels safer. file = root.openNextFile(); if (!file) break; } } // end while() loop // Stop the clock! float_t finishedAt = (float_t)(millis() - currentTime) / 1000; #if defined ONLINE // Return Web Page.. if (webDisplay) return webString; #endif String finished = "\n Finished processing in " + (String)finishedAt + " seconds\n"; if (isSerial) serialString.concat(finished); #if defined ONLINE if (webList) webString.concat(finished); #endif if (deleteFiles) { saveNumber = (delX == 0) ? 0 : delX-1; // we might save some time down the line if (isSerial) serialString.concat("\n Done\n"); } else { if (fileCount > 0) { totalFileCount = fileCount; if ((INIT || isSerial || webList) && fileCount != 0) { averageSize = ( SD_MMC.usedBytes() - levellingBytes ) / totalFileCount; } } if (INIT && totalFileCount > 0) saveNumber = totalFileCount; // A starting point only char buff[200] = {'\0'}; // sprintf(buff, "\n File system interrogated in %.2f seconds\n", finishedAt); sprintf(buff + strlen(buff), " Total files: %i\n", fileCount); uint64_t remainEst = (float_t)(SD_MMC.totalBytes() - SD_MMC.usedBytes() - levellingBytes) / averageSize; sprintf(buff + strlen(buff), " Space for approx. %llu (avg: %llukB) images", remainEst, (averageSize / 1024)); if (isSerial) serialString.concat(buff); #if defined ONLINE if (webList) webString.concat("\n " + (String)buff + "\n"); #endif if (INIT) return buff; } if (isSerial) return serialString; #if defined ONLINE if (webList) return webString; #endif return ""; // Never happens. } // Quicker way to list all the files. // No files are opened, no file sizes calculated or dates checked; it's just a list. But it is FAST. String quickList(bool report = true, bool allFiles = false) { if (ejected) return " SD Card is Ejected!"; File root = SD_MMC.open("/"); if ( !root || !root.isDirectory()) { ABORT = " Problem with root directory. Check your SD Card."; return ABORT; } String list = ""; // Should be enough for 10,000 images easy.. list.reserve(512000); if (report) list = " Current (quick) file list:\n\n"; uint32_t fileCount = 0; String file = root.getNextFileName(); if (file == "") return " Empty Card"; uint64_t currentTime = millis(); while (file != "") { if (file.indexOf(fileEXT) != -1 || allFiles) { fileCount++; if (report) list.concat(" [" + (String)fileCount + "] " + file.substring(1) + "\n"); } file = root.getNextFileName(); } list.concat("\n Quick List completed in " + (String)((float_t)(millis() -currentTime) / 1000) + " seconds\n"); list.concat(" Total Files: " + (String)fileCount + "\n"); if (fileCount > 0) { totalFileCount = fileCount; averageSize = ( SD_MMC.usedBytes() - levellingBytes ) / totalFileCount; } return list; } // For TEST purposes.. String notQuickLst(bool allFiles = false) { if (ejected) return " SD Card is Ejected!"; File root = SD_MMC.open("/"); if ( !root || !root.isDirectory()) { ABORT = " Problem with root directory. Check your SD Card."; return ABORT; } uint32_t fileCount = 0; String thisName; uint64_t currentTime = millis(); File file = root.openNextFile(); String list; list.reserve(512000); list = " Current (not quick) file list:\n\n"; while (file) { thisName = (String)file.name(); if (allFiles || thisName.indexOf(fileEXT) != -1) { fileCount++; list.concat(" [" + (String)fileCount + "] " + thisName.substring(1) + "\n"); } file.close(); file = root.openNextFile(); } list.concat("\n NOT Quick List completed in " + (String)((float_t)(millis() -currentTime) / 1000) + " seconds\n"); list.concat(" Total Files: " + (String)fileCount + "\n"); return list; } /* Wipe NVRAM/NVS aka. "Non Volatile Storage". You can do this from the serial console, with the command: nvswipe */ void WipeNVRAM() { esp_err_t ret = nvs_flash_init(); ESP_ERROR_CHECK(nvs_flash_erase()); // The actual wipe ret = nvs_flash_init(); ESP_ERROR_CHECK(ret); Serial.println(" NVRAM Erased."); } /* Memory information.. */ String printMemoryInfo(bool full = false) { char mbuf[380]; sprintf(mbuf, " Free memory: %d bytes\n", esp_get_free_heap_size()); sprintf(mbuf + strlen(mbuf), " Task High Tide Watermark: %d bytes\n", uxTaskGetStackHighWaterMark(NULL) ); if (full) { sprintf(mbuf + strlen(mbuf), " Available Internal Heap Size: %d\n", esp_get_free_internal_heap_size()); sprintf(mbuf + strlen(mbuf), " Minimum Free Heap Ever Available Size: %d\n", esp_get_minimum_free_heap_size()); } sprintf(mbuf + strlen(mbuf), " Total Heap: %d ~", ESP.getHeapSize()); sprintf(mbuf + strlen(mbuf), " Free Heap: %d\n", ESP.getFreeHeap()); return (String)mbuf; } /* Fire up the SD Card.. */ bool startMicroSD() { Serial.print("\n Mounting microSD Card.. "); // Pin 13 needs to be pulled-up. We can do this in software. // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/peripherals/sd_pullup_requirements.html#pull-up-conflicts-on-gpio13 pinMode(13, INPUT_PULLUP); if (SD_MMC.begin("/sdcard", true)) { Serial.println("OK!"); } else { Serial.println("FAILED!"); return false; } uint8_t cardType = SD_MMC.cardType(); if (cardType == CARD_NONE) { Serial.println(" No SD Card Attached!"); return false; } Serial.print(" SD Card Type: "); if (cardType == CARD_MMC) { Serial.println("MMC"); } else if (cardType == CARD_SD) { Serial.println("SDSC"); } else if (cardType == CARD_SDHC) { Serial.println("SDHC"); } else { Serial.println("UNKNOWN"); } Serial.printf(" SD Card Size: %llu MB\n", SD_MMC.cardSize() / (1024 * 1024)); // Check if user is doing manual wear-levelling and deduct this from the average size/free space calculations getWearLevellingBytes(); Serial.printf(" Checking SD Card contents.."); if (quickListings) { Serial.println(); Serial.println(quickList(false)); } else { if ( (SD_MMC.usedBytes() / (1024 * 1024) ) < ( maxMBytesLimit + ( levellingBytes / 1024 / 1024) ) ) { Serial.println(" (this could take a while)"); Serial.println(listDir(false, false, false, false, true)); } else { Serial.printf(" SD Card used space greater than your %i MB limit. Listing aborted. \n", maxMBytesLimit); } } Serial.println(printSpace()); return true; } // Manual Wear-Levelling // Calculate the space used by manual wear-levelling files (or any other files inside directories) // We simply do a quick list and check any file who's name doesn't include a dot (.). // If it is a directory, we dive inside and add bytes for all files within. void getWearLevellingBytes() { File dir = SD_MMC.open("/"); if (!dir || !dir.isDirectory()) return; levellingBytes = 4096; // Allow for "empty" FAT File file, testDir; String mySize, maybeDir = dir.getNextFileName(); while (maybeDir != "") { // No dot in the name - check if it is a directory.. if (maybeDir.indexOf(".") == -1) { testDir = SD_MMC.open(maybeDir); if (testDir && testDir.isDirectory()) { file = testDir.openNextFile(); while (file) { // %zu for size_t mySize = (String)(file.size()/1024) + "kB"; if (file.size() > (1024*1024)) mySize = (String)(file.size()/1024/1024) + "MB"; if (eXi) Serial.printf(" + %s wear-levelling: %s/%s\n", mySize.c_str(), maybeDir.c_str(), file.name()); levellingBytes += file.size(); file = testDir.openNextFile(); } } testDir.close(); } maybeDir = dir.getNextFileName(); } // For maximum accuracy, add bytes from the stream capture file, if it exists. String mjpegFileName = "/" + streamFileName; if (SD_MMC.exists(mjpegFileName)) { File videoFile = SD_MMC.open(mjpegFileName); levellingBytes += videoFile.size(); videoFile.close(); } } // Print out remaining free SD space.. // String printSpace() { char mbuf[164]; sprintf(mbuf, " Total Space: %llu MB\t", SD_MMC.totalBytes() / (1024 * 1024) ); uint64_t usedUserBytes = SD_MMC.usedBytes()-levellingBytes; if ( usedUserBytes < (1024 * 1024) ) { sprintf(mbuf + strlen(mbuf), " Used Space: %llu kB\n", usedUserBytes / 1024); } else { sprintf(mbuf + strlen(mbuf), " Used Space: %.2f MB\n", (float_t)(usedUserBytes / (1024 * 1024)) ); } float_t freeSpace = (SD_MMC.totalBytes() - usedUserBytes); sprintf(mbuf + strlen(mbuf), " Available Free Space: %.2f MB\n", freeSpace / (1024 * 1024)); sprintf(mbuf + strlen(mbuf), " Space for approx.: %llu (avg: %llukB) images", \ (uint64_t)(freeSpace / averageSize), (averageSize / 1024)); return (String)mbuf; } /* If you are considering hacking your ESP32-CAM's circuit board to accommodate 4-bit SD Card operation with non-flickering LED, I recommend you forget it. I temporarily hacked TL-CAM to test out the differences in speed. I suspect the 4-bit implementation isn't very clever and IMHO, a roughly 6.5% increase in speed is not worth it. The data: Total files: 456 List with quickListings DISABLED. 1-bit: benchmark: Wrote 1048576 bytes in 4388ms == 238.00 KB/s Read 1048576 bytes in 521ms == 2012.00 KB/s List: File system interrogated in 31.58 seconds 4-bit: (with super bright flashing LED throughout!) benchmark: Wrote 1048576 bytes in 4314ms == 243.00 KB/s Read 1048576 bytes in 369ms == 2841.00 KB/s List: File system interrogated in 29.59 seconds */ /* Simple SD Card Benchmark.. With this information, we can calculate your shortest possible snap interval time, as well as get an idea of the expected thumbnail generation speed. */ String benchmarkSD() { static char results[1024]; char *b = results; b += sprintf(b, "\n"); // Generate some test data (i.e. gibberish) uint8_t *buf = (uint8_t *)malloc(64 * 1024); const char *path = "/Speed-Test.bin"; uint64_t start_time = millis(); b += sprintf(b, "\n Writing test file: \"%s\"\n", path); File file = SD_MMC.open(path, "w"); // old-school way to specify write mode. if (!file) { b += sprintf(b, " Failed to open file for writing. Speed Test Aborted.\n"); return results; } // We use the exact same mechanism and settings as picture taking. if (!file.write(buf, SDTestSize)) { // Serial.println(" Write Failed!"); b += sprintf(b, " Write Failed!"); return results; } file.close(); // flush and close uint32_t runTime = millis() - start_time; b += sprintf(b, " Wrote %i bytes in %ims == %.2f KB/s\n\n", SDTestSize, runTime, (float_t)(SDTestSize/runTime) ); // Wait a sec.. delay(1000); b += sprintf(b, " Reading test file \"%s\"\n", path); start_time = millis(); file = SD_MMC.open(path); if (!file) { b += sprintf(b, " Failed to open file for reading!"); return results; } if (!file.read(buf, SDTestSize)) { b += sprintf(b, " Read failed!"); return results; } file.close(); runTime = millis() - start_time; b += sprintf(b, " Read %i bytes in %ims == %.2f KB/s\n\n", SDTestSize, runTime, (float_t)(SDTestSize/runTime) ); b += sprintf(b, " Benchmark complete.\n"); SD_MMC.remove("/Speed-Test.bin"); return results; } /* Camera Setup.. */ bool configCamera(bool init=false) { 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_sccb_sda = SIOD_GPIO_NUM; config.pin_sccb_scl = SIOC_GPIO_NUM; config.pin_pwdn = PWDN_GPIO_NUM; config.pin_reset = RESET_GPIO_NUM; config.pixel_format = PIXFORMAT_JPEG; config.frame_size = FRAMESIZE_UXGA; // 2MP config.jpeg_quality = quality; // temporary value config.xclk_freq_hz = (xclk * 1000000); // > MHz // ditto config.fb_count = 2; config.fb_location = CAMERA_FB_IN_PSRAM; // This ensures we see the latest images, instead of one from the buffer's queue, which does confuse. config.grab_mode = CAMERA_GRAB_LATEST; // Set parameters based on whether or not we have extra memory.. if (!psramFound()) { if (init) Serial.println("\n PSRAM NOT FOUND: Maximum supported resolution is SVGA. Bummer."); config.fb_location = CAMERA_FB_IN_DRAM; config.frame_size = FRAMESIZE_SVGA; config.fb_count = 1; } else { if (init) Serial.println("\n PSRAM found: Maximum resolution supported. Yippee!"); } if (init) Serial.print(" Initialising ESP32-CAM: "); esp_err_t err = esp_camera_init(&config); if (err == ESP_OK) { if (init) Serial.println("OK"); } else { if (init) Serial.println("FAIL"); return false; } return true; } // Initialise the camera.. // bool startCamera(bool init=true) { Serial.print("\n Starting Camera.. "); // Turn off the flash pinMode(FLASH_PIN, OUTPUT); digitalWrite(FLASH_PIN, LOW); // Initialize the hardware if (!configCamera(init)) return false; sensor_t *s = esp_camera_sensor_get(); // For more information about these settings, see: https://heyrick.eu/blog/index.php?diary=20210418 // For reference.. // {"lamp":0,"autolamp":0,"framesize":11,"quality":12,"xclk":20,"min_frame_time":0,"brightness":0,"contrast":0,"saturation":0,"special_effect":0,"wb_mode":0,"awb":1,"awb_gain":1,"aec":1,"aec2":1,"ae_level":1,"aec_value":204,"agc":1,"agc_gain":0,"gainceiling":1,"bpc":1,"wpc":1,"raw_gma":1,"lenc":1,"vflip":0,"hmirror":0,"dcw":1,"colorbar":0,"rotate":"-90"} /* LARGE Image Sizes. If you have PSRAM on-board (for buffering image data), you can use /large/ image sizes. The default (maximum) large size for the default sensor (OV2640) is UXGA (1600x1200): If you are using the OV5640 (5MP) sensor, you can go up to QSXGA (2560x1920): see: ~/.arduino15/packages/esp32/hardware/esp32//tools/sdk/esp32/include/esp32-camera/driver/include/sensor.h NOTE: in the absence of actual saved images, this setting will also be used to estimate the remaining image space. */ Serial.print(" Detected camera module: "); //TODO in controls - check against this int (FRAMESIZE_SXGA, etc) and stop producing