M5Stack Timer Camera F (OV3660) → HTTP Snapshot Server
Design & Ops Guide
Monochrome, print-ready documentation. Emphasis on camera wiring, JPEG tuning, HTTP endpoints, and admin controls. Targets infrequent, high-quality captures.
Contents
- Overview
- Hardware & Camera
- Pin Map (Timer Camera F)
- Build & Flash (Arduino IDE)
- Wi-Fi & Boot
- Network & Routes
- Admin Parameters
- Quality, Framesize & PSRAM
- Usage Examples
- Runtime Behavior
- Expected Serial Logs
- Troubleshooting
- Validation Checklist
- Security & Hardening
- Extensions & Variations
- Full Sketch
1) Overview
HTTP server on ESP32 for single-shot JPEGs from the OV3660 sensor in the M5Stack Timer Camera F. Routes include a snapshot endpoint and admin controls to read status, adjust framesize/quality, flip/mirror, toggle the LED, and reboot or re-init the camera. Design favors larger frames and higher quality because captures are infrequent.
2) Hardware & Camera
| Item | Notes |
| M5Stack Timer Camera F | OV3660 sensor module, ESP32, on-board LED on GPIO 2 |
| Power | Stable 5 V input via USB. Avoid brownouts during Wi-Fi TX + capture spikes. |
| PSRAM | Recommended. Enables higher frames and double frame buffers. |
| Wi-Fi | 2.4 GHz STA mode. Credentials set in sketch. |
3) Pin Map (Timer Camera F)
Camera signals
| Signal | GPIO | Signal | GPIO |
| D0..D7 | 32,35,34,5,39,18,36,19 | VSYNC | 22 |
| HREF | 26 | PCLK | 21 |
| XCLK | 27 | SCCB SDA/SCL | 25 / 23 |
| PWDN | -1 | RESET | 15 |
Misc
- LED GPIO: 2 (on-board status LED).
- XCLK: 20 MHz for OV3660 stability.
- Brownout detect disabled in code to prevent resets under load.
4) Build & Flash (Arduino IDE)
- Install ESP32 core by Espressif via Boards Manager.
- Board: ESP32 Dev Module. PSRAM: Enabled. Partition: Default or Huge APP.
- Libraries: bundled esp32-camera (from ESP32 core).
- Set Wi-Fi credentials in WIFI_SSID/WIFI_PASS.
- Compile, upload, open Serial at 115200.
5) Wi-Fi & Boot
Behavior
- STA join with 30 s timeout. On timeout, device restarts.
- On connect, logs IP and RSSI. Starts HTTP on port 80.
- PSRAM present → higher default framesize and double buffering.
Defaults
| Key | Default | Notes |
| framesize | UXGA (1600×1200) | QXGA (2048×1536) if defined and PSRAM present |
| quality | 10 | Lower is better quality in driver (range 5..63) |
| fb_count | 2 | With PSRAM. Else 1. |
| pixel_format | JPEG | Single image per request |
6) Network & Routes
| Method | Path | Purpose | Response |
| GET | / | Tiny test page with live image | text/html |
| GET | /snapshot | Fresh JPEG | image/jpeg, no-cache headers |
| GET | /admin/status | Device + camera status | application/json |
| GET | /admin/set | Set params | text/plain summary |
| GET | /admin/led?on=0|1 | LED control | text/plain |
| GET | /admin/reboot | Soft reboot | text/plain |
| GET | /admin/restart_camera | Deinit + reinit sensor | text/plain |
7) Admin Parameters
/admin/set query keys
| Key | Type | Values | Notes |
| framesize | enum | QQVGA, QCIF, HQVGA, QVGA, CIF, VGA, SVGA, XGA, SXGA, UXGA, QXGA* | *QXGA only if defined by core and supported |
| quality | int | 5..63 | Lower = better JPEG quality, larger file |
| vflip | bool | 0 or 1 | Vertical flip |
| hmirror | bool | 0 or 1 | Horizontal mirror |
Status JSON fields
{
"ip": "192.168.1.xxx",
"rssi_dbm": -62,
"uptime_ms": 1234567,
"free_heap": 183000,
"free_psram": 2123456,
"framesize": "UXGA",
"quality": 10,
"vflip": 0,
"hmirror": 0
}
Use this for remote health checks and to confirm current optics settings.
8) Quality, Framesize & PSRAM
Guidance
- Enable PSRAM for large frames and double buffering.
- If capture fails at high res, firmware steps down one tier and retries.
- Use quality=10..14 for inspection, 18..24 for smaller files.
Approximate frames
| Framesize | Pixels | Typical use |
| QVGA | 320×240 | Quick checks, low bandwidth |
| VGA | 640×480 | General preview |
| SVGA/XGA | 800×600 / 1024×768 | Readable detail |
| SXGA | 1280×1024 | High detail without PSRAM |
| UXGA | 1600×1200 | High detail with PSRAM |
| QXGA* | 2048×1536 | Max detail if supported |
9) Usage Examples
Shell
# Snapshot to file
curl -sL http://<ip>/snapshot -o snap.jpg
# Status JSON
curl -s http://<ip>/admin/status | jq .
# Set framesize + quality
curl "http://<ip>/admin/set?framesize=UXGA&quality=12"
# Flip and mirror
curl "http://<ip>/admin/set?vflip=1&hmirror=1"
# LED
curl "http://<ip>/admin/led?on=1"
Browser
http://<ip>/ opens a tiny page with a refresh button.
http://<ip>/snapshot?ts=<unixms> busts caches.
Responses set Cache-Control: no-store and Pragma: no-cache.
10) Runtime Behavior
State Flow
- Boot: Choose defaults → init camera → connect Wi-Fi → start HTTP.
- Snapshot: Capture fb → stream JPEG to client → return buffer.
- Fallback: If capture fails, step down framesize once and retry.
Design Notes
- Direct write of JPEG to client to reduce copies.
- Double buffer when PSRAM present improves responsiveness.
- Admin camera restart avoids full reboot when sensor hangs.
11) Expected Serial Logs
[wifi] connecting to 'YOUR_WIFI_SSID'
....
[wifi] connected: ip=192.168.1.xxx rssi=-60 dBm
[cam] initializing...
[cam] apply framesize=UXGA quality=10 vflip=0 hmirror=0
[cam] sensor PID=0x3660
[cam] OK
[http] server started on :80
[http] /snapshot
[http] snapshot sent
12) Troubleshooting
Capture fails
- Reduce framesize via
/admin/set?framesize=SXGA.
- Increase quality value (e.g.,
quality=14) to shrink JPEG.
- Confirm PSRAM is enabled in board settings.
Reboots or brownouts
- Use a stable 5 V USB supply. Avoid long thin cables.
- Avoid simultaneous heavy Wi-Fi traffic during capture.
Rotated/mirrored image
- Fix orientation:
/admin/set?vflip=1 or hmirror=1.
No web page
- Check
/admin/status for IP and heap.
- Verify port 80 reachable from your LAN.
13) Validation Checklist
- Serial shows camera init OK and Wi-Fi IP.
/snapshot returns a valid JPEG with Content-Type: image/jpeg.
/admin/status JSON fields populate and update over time.
/admin/set echo shows changed framesize/quality.
14) Security & Hardening
- LAN only by default. If exposing, put behind TLS reverse proxy.
- Restrict admin routes by IP at the proxy. Add a shared key header if you extend firmware.
- Disable root page in production if not needed.
15) Extensions & Variations
- Add /admin/autocycle to store periodic snapshots to SPIFFS/SD.
- Expose a multipart MJPEG stream endpoint for preview.
- Embed EXIF-like JSON in a sidecar route (time, framesize, quality).
16) Full Sketch
/*
M5Stack Timer Camera F (OV3660) — HTTP snapshot server with admin routes
- High-quality JPEG tuned for infrequent (15 min) captures.
- Routes:
GET / -> tiny test page with <img src="assets/latest.jpg">
GET /snapshot -> fresh JPEG (no cache)
GET /admin/status -> JSON: ip, rssi, uptime, heap, psram, framesize, quality, flips
GET /admin/set -> set params, e.g. /admin/set?framesize=UXGA&quality=10&vflip=0&hmirror=1
GET /admin/led?on=1 -> LED on/off
GET /admin/reboot -> ESP.restart()
GET /admin/restart_camera -> deinit + reinit camera
Build (Arduino IDE):
Board: "ESP32 Dev Module"
PSRAM: "Enabled" (recommended for large frames + double buffers)
Partition: "Default" or "Huge APP"
Upload Speed: any stable
Libraries:
- ESP32 core by Espressif
- esp32-camera (bundled with core or via library manager)
Set your Wi-Fi credentials below and flash. Open serial @115200 for logs.
Hit: http://<device-ip>/snapshot
*/
#include <WiFi.h>
#include <WebServer.h>
#include "esp_camera.h"
#include "soc/soc.h" // brownout disable
#include "soc/rtc_cntl_reg.h"
// ========= Wi-Fi =========
// TODO: set these
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
// ========= LED / Status =========
#define LED_GPIO 2 // TimerCam F onboard LED
// ========= Timer Camera F (OV3660) pin map =========
// Matches M5Stack Timer Camera F documentation
#define CAM_PIN_PWDN -1
#define CAM_PIN_RESET 15
#define CAM_PIN_XCLK 27
#define CAM_PIN_SIOD 25
#define CAM_PIN_SIOC 23
#define CAM_PIN_D7 19
#define CAM_PIN_D6 36
#define CAM_PIN_D5 18
#define CAM_PIN_D4 39
#define CAM_PIN_D3 5
#define CAM_PIN_D2 34
#define CAM_PIN_D1 35
#define CAM_PIN_D0 32
#define CAM_PIN_VSYNC 22
#define CAM_PIN_HREF 26
#define CAM_PIN_PCLK 21
WebServer server(80);
// ========= Runtime settings (mutable via /admin/set) =========
// Target: better quality since captures are infrequent.
static framesize_t g_framesize = FRAMESIZE_UXGA; // 1600x1200 default
static int g_quality = 10; // 10 = high quality (lower number = higher quality)
static int g_vflip = 0;
static int g_hmirror = 0;
static unsigned long g_boot_ms = 0;
// ========= Utilities =========
static const char* framesizeToStr(framesize_t fs) {
switch (fs) {
case FRAMESIZE_QQVGA: return "QQVGA";
case FRAMESIZE_QCIF: return "QCIF";
case FRAMESIZE_HQVGA: return "HQVGA";
case FRAMESIZE_QVGA: return "QVGA";
case FRAMESIZE_CIF: return "CIF";
case FRAMESIZE_VGA: return "VGA";
case FRAMESIZE_SVGA: return "SVGA";
case FRAMESIZE_XGA: return "XGA";
case FRAMESIZE_SXGA: return "SXGA";
case FRAMESIZE_UXGA: return "UXGA";
#ifdef FRAMESIZE_QXGA
case FRAMESIZE_QXGA: return "QXGA";
#endif
default: return "UNKNOWN";
}
}
static bool strToFramesize(const String& s, framesize_t& out) {
String up = s; up.toUpperCase();
if (up == "QQVGA") out = FRAMESIZE_QQVGA;
else if (up == "QCIF") out = FRAMESIZE_QCIF;
else if (up == "HQVGA") out = FRAMESIZE_HQVGA;
else if (up == "QVGA") out = FRAMESIZE_QVGA;
else if (up == "CIF") out = FRAMESIZE_CIF;
else if (up == "VGA") out = FRAMESIZE_VGA;
else if (up == "SVGA") out = FRAMESIZE_SVGA;
else if (up == "XGA") out = FRAMESIZE_XGA;
else if (up == "SXGA") out = FRAMESIZE_SXGA;
else if (up == "UXGA") out = FRAMESIZE_UXGA;
#ifdef FRAMESIZE_QXGA
else if (up == "QXGA") out = FRAMESIZE_QXGA;
#endif
else return false;
return true;
}
static void chooseDefaultsForQuality() {
// Push quality and resolution higher when PSRAM is present.
if (psramFound()) {
#ifdef FRAMESIZE_QXGA
g_framesize = FRAMESIZE_QXGA; // 2048x1536 if supported
#else
g_framesize = FRAMESIZE_UXGA; // 1600x1200 fallback
#endif
g_quality = 10; // high quality
} else {
g_framesize = FRAMESIZE_SXGA; // 1280x1024 safer without PSRAM
g_quality = 12;
}
Serial.printf("[cfg] defaults: framesize=%s quality=%d (psram=%d)\n",
framesizeToStr(g_framesize), g_quality, (int)psramFound());
}
static void applySensorSettings() {
sensor_t* s = esp_camera_sensor_get();
if (!s) return;
s->set_framesize(s, g_framesize);
s->set_quality(s, g_quality);
s->set_vflip(s, g_vflip);
s->set_hmirror(s, g_hmirror);
Serial.printf("[cam] apply framesize=%s quality=%d vflip=%d hmirror=%d\n",
framesizeToStr(g_framesize), g_quality, g_vflip, g_hmirror);
}
static void startWiFi() {
Serial.printf("[wifi] connecting to '%s'\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED) {
delay(300);
Serial.print('.');
if (millis() - t0 > 30000) {
Serial.println("\n[wifi] timeout, rebooting");
ESP.restart();
}
}
Serial.printf("\n[wifi] connected: ip=%s rssi=%d dBm\n",
WiFi.localIP().toString().c_str(), WiFi.RSSI());
}
static bool initCamera() {
// Disable brownout to avoid resets under Wi-Fi + camera spikes.
WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0);
camera_config_t config = {};
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = CAM_PIN_D0;
config.pin_d1 = CAM_PIN_D1;
config.pin_d2 = CAM_PIN_D2;
config.pin_d3 = CAM_PIN_D3;
config.pin_d4 = CAM_PIN_D4;
config.pin_d5 = CAM_PIN_D5;
config.pin_d6 = CAM_PIN_D6;
config.pin_d7 = CAM_PIN_D7;
config.pin_xclk = CAM_PIN_XCLK;
config.pin_pclk = CAM_PIN_PCLK;
config.pin_vsync = CAM_PIN_VSYNC;
config.pin_href = CAM_PIN_HREF;
config.pin_sccb_sda = CAM_PIN_SIOD;
config.pin_sccb_scl = CAM_PIN_SIOC;
config.pin_pwdn = CAM_PIN_PWDN;
config.pin_reset = CAM_PIN_RESET;
config.xclk_freq_hz = 20000000; // stable for OV3660
config.pixel_format = PIXFORMAT_JPEG; // we return JPEGs
config.grab_mode = CAMERA_GRAB_LATEST;
// Frame buffer allocation
if (psramFound()) {
config.frame_size = g_framesize;
config.jpeg_quality = g_quality;
config.fb_count = 2; // double buffer
config.fb_location = CAMERA_FB_IN_PSRAM;
} else {
config.frame_size = g_framesize;
config.jpeg_quality = g_quality;
config.fb_count = 1;
}
Serial.println("[cam] initializing...");
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("[cam] init failed: 0x%X\n", (unsigned)err);
return false;
}
applySensorSettings();
sensor_t* s = esp_camera_sensor_get();
if (s) {
Serial.printf("[cam] sensor PID=0x%X\n", s->id.PID);
}
Serial.println("[cam] OK");
return true;
}
// ========= HTTP Handlers =========
static void handleRoot() {
String html;
html.reserve(1200);
html += "<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>";
html += "<title>TimerCamF Snapshot</title></head><body style='font-family:sans-serif'>";
html += "<h3>TimerCam-F</h3>";
html += "<p><img id='img' src='assets/latest.jpg' style='max-width:100%'></p>";
html += "<button onclick="document.getElementById('img').src='assets/latest.jpg?ts='+Date.now()">Refresh</button>";
html += "<p><a href='/admin/status'>status</a> | <a href='/admin/reboot'>reboot</a> | <a href='/admin/restart_camera'>restart_camera</a></p>";
html += "<p>Example: /admin/set?framesize=UXGA&quality=10&vflip=0&hmirror=1</p>";
html += "</body></html>";
server.send(200, "text/html", html);
}
static bool sendJpeg(camera_fb_t* fb) {
// Stream response to avoid extra copies
WiFiClient client = server.client();
client.printf(
"HTTP/1.1 200 OK\r\n"
"Content-Type: image/jpeg\r\n"
"Content-Length: %u\r\n"
"Cache-Control: no-store, no-cache, must-revalidate, max-age=0\r\n"
"Pragma: no-cache\r\n"
"Connection: close\r\n\r\n",
(unsigned)fb->len);
size_t remaining = fb->len;
const uint8_t* p = fb->buf;
while (remaining) {
size_t n = client.write(p, remaining);
if (n == 0) return false; // client gone
remaining -= n;
p += n;
}
return true;
}
static void handleSnapshot() {
Serial.println("[http] /snapshot");
digitalWrite(LED_GPIO, HIGH);
// Capture a frame. If it fails at high res, step down once and retry.
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) {
Serial.println("[http] capture failed, stepping down resolution then retry");
framesize_t prev = g_framesize;
if (g_framesize == FRAMESIZE_QXGA) g_framesize = FRAMESIZE_UXGA;
else if (g_framesize == FRAMESIZE_UXGA) g_framesize = FRAMESIZE_SXGA;
else if (g_framesize == FRAMESIZE_SXGA) g_framesize = FRAMESIZE_XGA;
applySensorSettings();
fb = esp_camera_fb_get();
if (!fb) {
digitalWrite(LED_GPIO, LOW);
server.send(500, "text/plain", "Camera capture failed");
g_framesize = prev; // restore original
applySensorSettings();
return;
}
Serial.printf("[http] fallback framesize now %s\n", framesizeToStr(g_framesize));
}
digitalWrite(LED_GPIO, LOW);
bool ok = sendJpeg(fb);
esp_camera_fb_return(fb);
if (!ok) {
Serial.println("[http] client write failed");
}
Serial.println("[http] snapshot sent");
}
static void handleStatus() {
char buf[512];
uint32_t heap = ESP.getFreeHeap();
uint32_t ps = ESP.getFreePsram();
long rssi = WiFi.RSSI();
unsigned long up_ms = millis() - g_boot_ms;
snprintf(buf, sizeof(buf),
"{"
"\"ip\":\"%s\","
"\"rssi_dbm\":%ld,"
"\"uptime_ms\":%lu,"
"\"free_heap\":%u,"
"\"free_psram\":%u,"
"\"framesize\":\"%s\","
"\"quality\":%d,"
"\"vflip\":%d,"
"\"hmirror\":%d"
"}",
WiFi.localIP().toString().c_str(),
rssi, up_ms, (unsigned)heap, (unsigned)ps,
framesizeToStr(g_framesize), g_quality, g_vflip, g_hmirror);
server.send(200, "application/json", buf);
}
static void handleSet() {
bool changed = false;
if (server.hasArg("framesize")) {
framesize_t f;
if (strToFramesize(server.arg("framesize"), f)) {
g_framesize = f;
changed = true;
}
}
if (server.hasArg("quality")) {
// Valid range 5..63 in driver. Lower = better quality.
int q = server.arg("quality").toInt();
q = constrain(q, 5, 63);
g_quality = q;
changed = true;
}
if (server.hasArg("vflip")) {
g_vflip = server.arg("vflip").toInt() ? 1 : 0;
changed = true;
}
if (server.hasArg("hmirror")) {
g_hmirror = server.arg("hmirror").toInt() ? 1 : 0;
changed = true;
}
if (changed) {
applySensorSettings();
}
String msg = "OK ";
msg += framesizeToStr(g_framesize);
msg += " q=" + String(g_quality);
msg += " vflip=" + String(g_vflip);
msg += " hmirror=" + String(g_hmirror);
server.send(200, "text/plain", msg);
}
static void handleLed() {
int on = server.hasArg("on") ? server.arg("on").toInt() : 0;
digitalWrite(LED_GPIO, on ? HIGH : LOW);
server.send(200, "text/plain", on ? "LED=ON" : "LED=OFF");
}
static void handleReboot() {
server.send(200, "text/plain", "Rebooting...");
Serial.println("[admin] reboot requested");
delay(150);
ESP.restart();
}
static void handleRestartCamera() {
server.send(200, "text/plain", "Restarting camera...");
Serial.println("[admin] camera restart requested");
delay(50);
esp_camera_deinit();
delay(50);
if (!initCamera()) {
Serial.println("[admin] camera restart failed");
}
}
// ========= Arduino entry points =========
void setup() {
Serial.begin(115200);
delay(200);
pinMode(LED_GPIO, OUTPUT);
digitalWrite(LED_GPIO, LOW);
g_boot_ms = millis();
chooseDefaultsForQuality();
if (!initCamera()) {
Serial.println("[fatal] camera init failed. Halt.");
while (true) { delay(1000); }
}
startWiFi();
// Routes
server.on("/", HTTP_GET, handleRoot);
server.on("/snapshot", HTTP_GET, handleSnapshot);
server.on("/admin/status", HTTP_GET, handleStatus);
server.on("/admin/set", HTTP_GET, handleSet);
server.on("/admin/led", HTTP_GET, handleLed);
server.on("/admin/reboot", HTTP_GET, handleReboot);
server.on("/admin/restart_camera", HTTP_GET, handleRestartCamera);
server.onNotFound([] { server.send(404, "text/plain", "Not found"); });
server.begin();
Serial.println("[http] server started on :80");
}
void loop() {
server.handleClient();
}
This document is black-and-white for clean printing and photocopying.