Static snapshot — not a live app. These are saved files, not the running program. Every reading is simulated demo data, frozen at the moment of capture, and the charts are drawn from saved API responses. Buttons, forms and the 5-second live refresh do nothing here. What is for sale is the Flask source you run yourself → · All demos

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

  1. Overview
  2. Hardware & Camera
  3. Pin Map (Timer Camera F)
  4. Build & Flash (Arduino IDE)
  5. Wi-Fi & Boot
  6. Network & Routes
  7. Admin Parameters
  8. Quality, Framesize & PSRAM
  9. Usage Examples
  10. Runtime Behavior
  11. Expected Serial Logs
  12. Troubleshooting
  13. Validation Checklist
  14. Security & Hardening
  15. Extensions & Variations
  16. 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

ItemNotes
M5Stack Timer Camera FOV3660 sensor module, ESP32, on-board LED on GPIO 2
PowerStable 5 V input via USB. Avoid brownouts during Wi-Fi TX + capture spikes.
PSRAMRecommended. Enables higher frames and double frame buffers.
Wi-Fi2.4 GHz STA mode. Credentials set in sketch.

3) Pin Map (Timer Camera F)

Camera signals

SignalGPIOSignalGPIO
D0..D732,35,34,5,39,18,36,19VSYNC22
HREF26PCLK21
XCLK27SCCB SDA/SCL25 / 23
PWDN-1RESET15

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)

  1. Install ESP32 core by Espressif via Boards Manager.
  2. Board: ESP32 Dev Module. PSRAM: Enabled. Partition: Default or Huge APP.
  3. Libraries: bundled esp32-camera (from ESP32 core).
  4. Set Wi-Fi credentials in WIFI_SSID/WIFI_PASS.
  5. 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

KeyDefaultNotes
framesizeUXGA (1600×1200)QXGA (2048×1536) if defined and PSRAM present
quality10Lower is better quality in driver (range 5..63)
fb_count2With PSRAM. Else 1.
pixel_formatJPEGSingle image per request

6) Network & Routes

MethodPathPurposeResponse
GET/Tiny test page with live imagetext/html
GET/snapshotFresh JPEGimage/jpeg, no-cache headers
GET/admin/statusDevice + camera statusapplication/json
GET/admin/setSet paramstext/plain summary
GET/admin/led?on=0|1LED controltext/plain
GET/admin/rebootSoft reboottext/plain
GET/admin/restart_cameraDeinit + reinit sensortext/plain

7) Admin Parameters

/admin/set query keys

KeyTypeValuesNotes
framesizeenumQQVGA, QCIF, HQVGA, QVGA, CIF, VGA, SVGA, XGA, SXGA, UXGA, QXGA**QXGA only if defined by core and supported
qualityint5..63Lower = better JPEG quality, larger file
vflipbool0 or 1Vertical flip
hmirrorbool0 or 1Horizontal 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

FramesizePixelsTypical use
QVGA320×240Quick checks, low bandwidth
VGA640×480General preview
SVGA/XGA800×600 / 1024×768Readable detail
SXGA1280×1024High detail without PSRAM
UXGA1600×1200High detail with PSRAM
QXGA*2048×1536Max 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

  1. Boot: Choose defaults → init camera → connect Wi-Fi → start HTTP.
  2. Snapshot: Capture fb → stream JPEG to client → return buffer.
  3. 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

14) Security & Hardening

15) Extensions & Variations

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.