XIAO ESP32S3 Sense: Simple Web Photo App (Robust Camera Init + Cached Download)
Works on the Seeed XIAO ESP32S3 Sense. Captures a fresh JPEG in the browser and lets you download the exact same frame—no surprises, no stale images.
Why this?
A lot of ESP32-CAM demos snap an image for preview, then take a different image when you click Download. That’s annoying when you’re testing lighting or motion. This tiny web app fixes that by caching the most recent frame in RAM: the Download button delivers the exact JPEG you just saw.
- Robust camera init: tries PSRAM → falls back to DRAM → last-ditch QVGA @ 16 MHz.
- Single framebuffer +
CAMERA_GRAB_WHEN_EMPTY
for stable captures. -
/capture
caches the frame;/download
returns that same buffer. - No base64 bloat—serves raw
image/jpeg
to keep RAM usage sane.
![]()
Example:
What you’ll need
- Seeed XIAO ESP32S3 Sense (camera version)
- USB-C cable and a stable 5 V source
- Arduino IDE (or PlatformIO) with ESP32 board support
Step-by-step
1) Set up Arduino for ESP32 S3
- Install the ESP32 board package via Boards Manager.
- Select your board (e.g., “XIAO ESP32S3”).
- (Optional) Toggle PSRAM in Tools → PSRAM. This sketch works either way; it auto-detects and falls back.
2) Paste the sketch
Use the full sketch below. Update ssid
/password
to your Wi-Fi.
3) Flash it
- Compile & upload.
- Open Serial Monitor @
115200
. Wait for “Web server started” and note the IP address.
4) Use the web app
- Visit
http://<device-ip>/
. - Click Take & Show Photo → you’ll land on
/snap
which pulls a fresh frame from/capture
. - Click Download This Photo. That file is the same image you’re seeing.
5) That’s it
Reliable captures, predictable downloads, tiny footprint.
Troubleshooting
-
cam_dma_config ... frame buffer malloc failed
: You were likely forcing PSRAM before; this sketch tries PSRAM then falls back to DRAM automatically. Also ensure a stable 5 V supply. - All black / streaky frames: Lower XCLK (this sketch already tries 16–20 MHz) and ensure the camera ribbon is seated firmly.
-
Browser shows an old image: We add a cache-busting param on
/capture
and setCache-Control: no-store
. If you hard-refresh and still see it, your network proxy might be caching.
Full Sketch
Click to collapse/expand
// XIAO ESP32S3 Sense – Simple Web Photo App (robust camera init + cached download)
// Routes:
// / -> Home page with Take Photo & Download buttons
// /snap -> Page that shows the latest captured image (calls /capture under the hood)
// /capture -> Captures a fresh JPEG (image/jpeg) AND caches it in RAM
// /download -> Returns the last cached JPEG (exact image shown on /snap). If none, captures once.
// --- Includes ---
#include "esp_camera.h"
#include "esp_heap_caps.h"
#include <WiFi.h>
#include <WebServer.h>
// -------------------- Pin Map (XIAO ESP32S3 Sense) --------------------
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 10
#define SIOD_GPIO_NUM 40 // SDA
#define SIOC_GPIO_NUM 39 // SCL
#define Y9_GPIO_NUM 48
#define Y8_GPIO_NUM 11
#define Y7_GPIO_NUM 12
#define Y6_GPIO_NUM 14
#define Y5_GPIO_NUM 16
#define Y4_GPIO_NUM 18
#define Y3_GPIO_NUM 17
#define Y2_GPIO_NUM 15
#define VSYNC_GPIO_NUM 38
#define HREF_GPIO_NUM 47
#define PCLK_GPIO_NUM 13
// ---------------------------------------------------------------------
// ===== WiFi =====
const char* ssid = "Your_Internet_Name";
const char* password = "Your_Internet_Password";
WebServer server(80);
// ===== Cached photo (last capture) =====
static uint8_t* g_last_jpg = nullptr;
static size_t g_last_len = 0;
static uint32_t g_last_ms = 0;
static void free_cached_photo() {
if (g_last_jpg) {
free(g_last_jpg);
g_last_jpg = nullptr;
g_last_len = 0;
g_last_ms = 0;
}
}
static void cache_photo(const uint8_t* buf, size_t len) {
// Replace existing cache
free_cached_photo();
g_last_jpg = (uint8_t*)malloc(len);
if (g_last_jpg) {
memcpy(g_last_jpg, buf, len);
g_last_len = len;
g_last_ms = millis();
} else {
// Allocation failed; keep cache empty
g_last_len = 0;
g_last_ms = 0;
}
}
// ===== Forward decls =====
static bool try_init_camera(framesize_t fs, bool use_psram, int xclk_mhz);
static bool init_camera_best();
static bool capture_into_cache(); // helper used by /download when no cache yet
// ====== Camera init helpers (robust) ======
static bool try_init_camera(framesize_t fs, bool use_psram, int xclk_mhz) {
camera_config_t c = {};
c.ledc_channel = LEDC_CHANNEL_0;
c.ledc_timer = LEDC_TIMER_0;
c.pin_d0 = Y2_GPIO_NUM; c.pin_d1 = Y3_GPIO_NUM; c.pin_d2 = Y4_GPIO_NUM; c.pin_d3 = Y5_GPIO_NUM;
c.pin_d4 = Y6_GPIO_NUM; c.pin_d5 = Y7_GPIO_NUM; c.pin_d6 = Y8_GPIO_NUM; c.pin_d7 = Y9_GPIO_NUM;
c.pin_xclk = XCLK_GPIO_NUM;
c.pin_pclk = PCLK_GPIO_NUM;
c.pin_vsync = VSYNC_GPIO_NUM;
c.pin_href = HREF_GPIO_NUM;
c.pin_sscb_sda = SIOD_GPIO_NUM;
c.pin_sscb_scl = SIOC_GPIO_NUM;
c.pin_pwdn = PWDN_GPIO_NUM;
c.pin_reset = RESET_GPIO_NUM;
c.xclk_freq_hz = xclk_mhz * 1000000; // 16 or 20 MHz
c.pixel_format = PIXFORMAT_JPEG;
c.frame_size = fs;
c.jpeg_quality = 15; // smaller = better quality; 15 is reasonable
c.fb_count = 1; // single FB = less memory
c.fb_location = use_psram ? CAMERA_FB_IN_PSRAM : CAMERA_FB_IN_DRAM;
c.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
esp_err_t err = esp_camera_init(&c);
if (err != ESP_OK) {
Serial.printf("esp_camera_init failed @fs=%d psram=%d xclk=%dMHz -> 0x%x\n",
(int)fs, (int)use_psram, xclk_mhz, err);
return false;
}
return true;
}
static bool init_camera_best() {
bool have_psram = false;
#ifdef BOARD_HAS_PSRAM
have_psram = psramFound();
#endif
have_psram = have_psram || (heap_caps_get_free_size(MALLOC_CAP_SPIRAM) > 0);
Serial.printf("Free heap: %u, PSRAM free: %u, have_psram=%d\n",
(unsigned)esp_get_free_heap_size(),
(unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM),
have_psram ? 1 : 0);
framesize_t candidates[] = { FRAMESIZE_VGA, FRAMESIZE_QVGA };
for (framesize_t fs : candidates) {
if (have_psram && try_init_camera(fs, true, 20)) { Serial.println("Camera OK (PSRAM, 20MHz)"); return true; }
if (try_init_camera(fs, false, 20)) { Serial.println("Camera OK (DRAM, 20MHz)"); return true; }
esp_camera_deinit();
delay(50);
}
if (try_init_camera(FRAMESIZE_QVGA, false, 16)) {
Serial.println("Camera OK (DRAM, 16MHz, QVGA fallback)");
return true;
}
esp_camera_deinit();
return false;
}
// ====== HTTP Handlers ======
static void handleRoot() {
String html = R"HTML(
<!DOCTYPE html>
<html>
<head><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32-CAM Photo App</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:20px}
button{padding:10px 16px;border-radius:8px;border:1px solid #ccc;cursor:pointer}
.row{margin:12px 0}
</style>
</head>
<body>
<h1>ESP32-CAM Photo App</h1>
<div class="row"><a href="/snap"><button>Take & Show Photo</button></a></div>
<div class="row"><a href="/download"><button>Download Last Photo</button></a></div>
</body>
</html>
)HTML";
server.send(200, "text/html", html);
}
// Simple page that shows the latest captured photo inline
static void handleSnapPage() {
// Add a timestamp query param to bust cache on /capture
String html = R"HTML(
<!DOCTYPE html>
<html>
<head><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Photo Captured</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial;margin:20px}
img{max-width:100%;height:auto;border:1px solid #ddd;border-radius:8px}
button{padding:10px 16px;border-radius:8px;border:1px solid #ccc;cursor:pointer}
.row{margin:12px 0}
</style>
</head>
<body>
<h1>Photo</h1>
<div class="row"><img src="/capture?t=)HTML";
html += String(millis());
html += R"HTML(" alt="latest photo"></div>
<div class="row">
<a href="/download"><button>Download This Photo</button></a>
<a href="/"><button>Back</button></a>
<a href="/snap"><button>Retake</button></a>
</div>
</body>
</html>
)HTML";
server.send(200, "text/html", html);
}
// Captures a fresh JPEG AND caches it; returns image/jpeg
static void handleCapture() {
// Flush a frame or two so we get a fresh one
for (int i = 0; i < 2; ++i) {
camera_fb_t* drop = esp_camera_fb_get();
if (drop) esp_camera_fb_return(drop);
delay(15);
}
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) {
server.send(500, "text/plain", "Camera capture failed");
return;
}
// Cache this exact image so /download returns the same one user sees
cache_photo(fb->buf, fb->len);
server.sendHeader("Cache-Control", "no-store");
server.send_P(200, "image/jpeg", (const char*)fb->buf, fb->len);
esp_camera_fb_return(fb);
}
// Returns the last cached JPEG (exactly what /capture produced). If no cache, captures once.
static void handleDownload() {
if (!g_last_jpg || g_last_len == 0) {
// No cached photo yet -> capture once and cache
if (!capture_into_cache()) {
server.send(500, "text/plain", "No photo available and capture failed");
return;
}
}
// Filename with capture time (ms since boot) for convenience
char name[48];
snprintf(name, sizeof(name), "photo_%lu.jpg", (unsigned long)g_last_ms);
server.sendHeader("Content-Type", "image/jpeg");
server.sendHeader("Content-Disposition", String("attachment; filename=") + name);
server.sendHeader("Cache-Control", "no-store");
server.send_P(200, "image/jpeg", (const char*)g_last_jpg, g_last_len);
}
// Helper: capture a photo and cache it (used if /download is called first)
static bool capture_into_cache() {
// Flush frames
for (int i = 0; i < 2; ++i) {
camera_fb_t* drop = esp_camera_fb_get();
if (drop) esp_camera_fb_return(drop);
delay(15);
}
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) return false;
cache_photo(fb->buf, fb->len);
esp_camera_fb_return(fb);
return g_last_jpg && g_last_len > 0;
}
// ===== Arduino setup/loop =====
void setup() {
Serial.begin(115200);
delay(300);
if (!init_camera_best()) {
Serial.println("FATAL: Camera init failed at all fallbacks.");
while (true) { delay(1000); }
}
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
Serial.printf("Connecting to WiFi '%s' ...\n", ssid);
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) {
delay(300);
Serial.print(".");
}
Serial.println();
if (WiFi.status() != WL_CONNECTED) {
Serial.println("WiFi failed (continuing anyway).");
} else {
Serial.println("WiFi connected!");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
}
server.on("/", handleRoot);
server.on("/snap", handleSnapPage);
server.on("/capture", handleCapture);
server.on("/download", handleDownload);
server.begin();
Serial.println("Web server started");
}
void loop() {
server.handleClient();
}
Where to go next
- Add a PIR and take burst photos on motion (we’ve published that pattern too).
- Upload frames to S3 via presigned URLs for remote viewing and retention.
- Expose a tiny JSON status endpoint for health checks and fleet monitoring.
Wrap-up
That’s a clean, reliable way to preview and download the same camera frame from your XIAO ESP32S3 Sense. If you want this wired into a full pipeline—alerts, storage, dashboards, the works—hire ShillehTek. We build this stuff every day.
Questions or stuck on a detail? Reach out—we’re happy to help on consulting or custom builds.