Turn an ESP32-CAM into a DIY Wi-Fi camera with a custom 3D-printed case! Follow the build from hardware and Arduino IDE setup to viewing the live camera feed in a browser and assembling an enclosure designed in FreeCAD. In this Alex3DWorks project, I use an ESP32-CAM with an OV2640 camera and an ESP32-CAM-MB USB programming board. I also show how I bring electronics and functional 3D printing together with my own enclosure design.
Build a compact Wi-Fi camera using an ESP32-CAM and an original enclosure designed in FreeCAD. Connect your phone or computer directly to the camera’s Wi-Fi network and view the live feed in your browser—no cloud account required.
Parts used:
• ESP32-CAM with OV2640 camera
• ESP32-CAM-MB USB programming board
• Compatible USB data cable
• USB power supply
• 3D-printed enclosure
Software:
• Arduino IDE
• FreeCAD
• Slicing software
Hardware Setup
With the power disconnected, fit the ESP32-CAM into the compatible ESP32-CAM-MB programming board, checking the orientation and pin alignment.
Connect the programming board to your computer using a USB data cable to upload the Arduino sketch.
Test the camera before installing it in the printed enclosure. During assembly, keep the lens clear and avoid bending or trapping the camera ribbon cable..
soon available
/*
Open:
http://192.168.4.1
IMPORTANT:
Change AP_PASSWORD to a unique, strong private password before use.
This is a local Wi-Fi access point, with no router/Internet connection.
Anyone with that Wi-Fi password can view the camera (no separate web login).
HTTP is not HTTPS; do not expose this server to the Internet.
Keep the ESP32 board package / firmware updated. Bluetooth is not enabled.
*/
#include <Arduino.h>
#include <WiFi.h>
#include "esp_camera.h"
#include "esp_http_server.h"
// =====================================================
// WIFI SETTINGS
// =====================================================
const char* AP_SSID = "Alex3DWorks-CAM";
// CHANGE THIS BEFORE USING/PUBLISHING THE PROJECT
const char* AP_PASSWORD = "CHANGE_THIS_PASSWORD";
IPAddress localIP(192, 168, 4, 1);
IPAddress gateway(192, 168, 4, 1);
IPAddress subnet(255, 255, 255, 0);
// =====================================================
// AI-THINKER ESP32-CAM PINS
// =====================================================
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
#define FLASH_LED_PIN 4
// =====================================================
// STREAM SETTINGS
// =====================================================
// 200 ms = approximately 5 frames per second.
// This is more than enough to monitor a 3D printer
// and reduces continuous workload.
const unsigned long FRAME_DELAY_MS = 200;
httpd_handle_t streamServer = nullptr;
httpd_handle_t pageServer = nullptr;
bool flashEnabled = false;
// =====================================================
// MJPEG STREAM
// =====================================================
static const char* STREAM_CONTENT_TYPE =
"multipart/x-mixed-replace;boundary=frame";
static const char* STREAM_BOUNDARY =
"\r\n--frame\r\n";
static const char* STREAM_PART =
"Content-Type: image/jpeg\r\n"
"Content-Length: %u\r\n\r\n";
// =====================================================
// ROTATED BROWSER VIEW
// =====================================================
// Rotation is performed locally in the browser; raw JPEG pixels are unchanged.
static const char INDEX_HTML[] PROGMEM = R"HTML(
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<title>Alex3DWorks Camera</title>
<style>
* { box-sizing:border-box; }
html,body { margin:0; width:100%; height:100%; background:#111; color:#eee; }
body { height:100dvh; display:flex; flex-direction:column; font-family:Arial,sans-serif; }
header { flex:none; display:flex; justify-content:center; align-items:center;
flex-wrap:wrap; gap:12px; padding:12px; }
button { background:#2874d0; color:white; border:0; border-radius:8px;
padding:12px 18px; font-size:16px; cursor:pointer; }
button:focus-visible { outline:3px solid white; outline-offset:2px; }
#viewer { flex:1; min-height:0; position:relative; overflow:hidden; }
#camera { position:absolute; left:50%; top:50%; display:block;
max-width:none; transform-origin:center; }
</style>
</head>
<body>
<header>
<button id="rotate" type="button">Rotate 90° clockwise</button>
<button id="flash" type="button" aria-pressed="false" disabled>Flash LED: Off</button>
<span id="angle" aria-live="polite">Rotation: 90°</span>
</header>
<main id="viewer">
<img id="camera" src="" width="800" height="600" alt="Printer camera">
</main>
<script>
const camera = document.getElementById('camera');
const streamURL = new URL('/stream', window.location.href);
streamURL.port = '81';
camera.src = streamURL.href;
const viewer = document.getElementById('viewer');
const flashButton = document.getElementById('flash');
let flashOn = false;
function showFlash(on) {
flashOn = on;
flashButton.textContent = 'Flash LED: ' + (on ? 'On' : 'Off');
flashButton.setAttribute('aria-pressed', String(on));
}
async function readFlash() {
const response = await fetch('/flash', {cache:'no-store'});
if (!response.ok) throw new Error('Cannot read LED state');
showFlash((await response.json()).on);
}
flashButton.addEventListener('click', async () => {
flashButton.disabled = true;
try {
const response = await fetch('/flash?state=' + (flashOn ? '0' : '1'), {
method:'POST', cache:'no-store'
});
if (!response.ok) throw new Error('Cannot change LED');
showFlash((await response.json()).on);
} catch (_) {
flashButton.textContent = 'LED connection error - tap to retry';
} finally {
flashButton.disabled = false;
}
});
readFlash().catch(() => {
flashButton.textContent = 'LED connection error - tap to retry';
}).finally(() => { flashButton.disabled = false; });
const angleLabel = document.getElementById('angle');
let rotation = 90;
try {
const saved = localStorage.getItem('alex3dworks-rotation');
if (saved !== null && [0,90,180,270].includes(Number(saved))) {
rotation = Number(saved);
}
} catch (_) { /* Storage is optional. */ }
function fitView() {
// Both configured camera resolutions have a 4:3 aspect ratio.
const sideways = rotation === 90 || rotation === 270;
const width = viewer.clientWidth;
const height = viewer.clientHeight;
const scale = Math.max(0, Math.min(
width / (sideways ? 600 : 800),
height / (sideways ? 800 : 600)
));
camera.style.width = (800 * scale) + 'px';
camera.style.height = (600 * scale) + 'px';
camera.style.transform = 'translate(-50%, -50%) rotate(' + rotation + 'deg)';
angleLabel.textContent = 'Rotation: ' + rotation + '°';
}
document.getElementById('rotate').addEventListener('click', () => {
rotation = (rotation + 90) % 360;
try { localStorage.setItem('alex3dworks-rotation', String(rotation)); } catch (_) {}
fitView();
});
window.addEventListener('resize', fitView);
if ('ResizeObserver' in window) new ResizeObserver(fitView).observe(viewer);
fitView();
</script>
</body>
</html>
)HTML";
static esp_err_t indexHandler(httpd_req_t* req)
{
httpd_resp_set_type(req, "text/html; charset=utf-8");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, INDEX_HTML, HTTPD_RESP_USE_STRLEN);
}
// =====================================================
// STREAM HANDLER
// =====================================================
static esp_err_t streamHandler(httpd_req_t* req)
{
esp_err_t result =
httpd_resp_set_type(req, STREAM_CONTENT_TYPE);
if (result != ESP_OK) {
return result;
}
httpd_resp_set_hdr(
req,
"Cache-Control",
"no-store"
);
Serial.println("Viewer connected.");
while (true) {
unsigned long frameStart = millis();
camera_fb_t* frame = esp_camera_fb_get();
if (frame == nullptr) {
Serial.println("Camera capture failed.");
break;
}
// Camera is configured directly for JPEG,
// so no software conversion is required.
if (frame->format != PIXFORMAT_JPEG) {
esp_camera_fb_return(frame);
Serial.println(
"Unexpected camera format."
);
break;
}
char header[64];
size_t headerLength =
snprintf(
header,
sizeof(header),
STREAM_PART,
frame->len
);
result = httpd_resp_send_chunk(
req,
STREAM_BOUNDARY,
strlen(STREAM_BOUNDARY)
);
if (result == ESP_OK) {
result = httpd_resp_send_chunk(
req,
header,
headerLength
);
}
if (result == ESP_OK) {
result = httpd_resp_send_chunk(
req,
reinterpret_cast<const char*>(frame->buf),
frame->len
);
}
// IMPORTANT:
// Always return the framebuffer.
esp_camera_fb_return(frame);
if (result != ESP_OK) {
// Normally means phone/browser disconnected.
break;
}
// ---------------------------------------------
// LIMIT FRAME RATE
// ---------------------------------------------
unsigned long elapsed =
millis() - frameStart;
if (elapsed < FRAME_DELAY_MS) {
delay(
FRAME_DELAY_MS - elapsed
);
}
}
Serial.println("Viewer disconnected.");
return ESP_OK;
}
// =====================================================
// CAMERA INITIALIZATION
// =====================================================
bool initializeCamera()
{
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;
// Standard OV2640 clock
config.xclk_freq_hz = 20000000;
// Camera creates JPEG directly.
// This avoids software JPEG conversion.
config.pixel_format = PIXFORMAT_JPEG;
// =================================================
// IMAGE QUALITY SETTINGS
// =================================================
const bool hasPSRAM = psramFound();
// 800x600 with PSRAM; QVGA fallback without it.
config.frame_size = hasPSRAM ? FRAMESIZE_SVGA : FRAMESIZE_QVGA;
// Lower value means less JPEG compression. Larger frames use more bandwidth.
config.jpeg_quality = hasPSRAM ? 8 : 14;
// VERY IMPORTANT:
// ONLY ONE framebuffer.
config.fb_count = 1;
// Do not continuously capture frames.
config.grab_mode =
CAMERA_GRAB_WHEN_EMPTY;
if (hasPSRAM) {
Serial.println("PSRAM detected.");
config.fb_location =
CAMERA_FB_IN_PSRAM;
} else {
Serial.println(
"PSRAM not detected - using DRAM."
);
config.fb_location =
CAMERA_FB_IN_DRAM;
}
esp_err_t error =
esp_camera_init(&config);
if (error != ESP_OK) {
Serial.printf(
"Camera initialization failed: 0x%x\n",
error
);
return false;
}
sensor_t* sensor =
esp_camera_sensor_get();
if (sensor != nullptr) {
// Change these only if required.
sensor->set_vflip(sensor, 0);
sensor->set_hmirror(sensor, 0);
}
Serial.println("Camera initialized.");
return true;
}
// =====================================================
// WEB SERVER
// =====================================================
static esp_err_t flashHandler(httpd_req_t* req)
{
if (req->method == HTTP_POST) {
char query[32] = {};
char state[8] = {};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK ||
httpd_query_key_value(query, "state", state, sizeof(state)) != ESP_OK ||
(strcmp(state, "0") != 0 && strcmp(state, "1") != 0)) {
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "state must be 0 or 1");
}
flashEnabled = strcmp(state, "1") == 0;
digitalWrite(FLASH_LED_PIN, flashEnabled ? HIGH : LOW);
}
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_sendstr(req, flashEnabled ? R"({"on":true})" : R"({"on":false})");
}
bool startStreamServer()
{
// A continuous MJPEG handler occupies its server task.
// Keep the page and LED controls on a separate server so they stay responsive.
httpd_config_t pageConfig = HTTPD_DEFAULT_CONFIG();
pageConfig.server_port = 80;
pageConfig.ctrl_port = 32768;
pageConfig.max_uri_handlers = 3;
pageConfig.lru_purge_enable = true;
if (httpd_start(&pageServer, &pageConfig) != ESP_OK) return false;
httpd_uri_t indexUri = {};
indexUri.uri = "/";
indexUri.method = HTTP_GET;
indexUri.handler = indexHandler;
httpd_uri_t flashGet = {};
flashGet.uri = "/flash";
flashGet.method = HTTP_GET;
flashGet.handler = flashHandler;
httpd_uri_t flashPost = flashGet;
flashPost.method = HTTP_POST;
if (httpd_register_uri_handler(pageServer, &indexUri) != ESP_OK ||
httpd_register_uri_handler(pageServer, &flashGet) != ESP_OK ||
httpd_register_uri_handler(pageServer, &flashPost) != ESP_OK) {
httpd_stop(pageServer);
pageServer = nullptr;
return false;
}
httpd_config_t streamConfig = HTTPD_DEFAULT_CONFIG();
streamConfig.server_port = 81;
streamConfig.ctrl_port = 32769;
streamConfig.max_uri_handlers = 1;
streamConfig.lru_purge_enable = true;
if (httpd_start(&streamServer, &streamConfig) != ESP_OK) {
httpd_stop(pageServer);
pageServer = nullptr;
return false;
}
httpd_uri_t streamUri = {};
streamUri.uri = "/stream";
streamUri.method = HTTP_GET;
streamUri.handler = streamHandler;
if (httpd_register_uri_handler(streamServer, &streamUri) != ESP_OK) {
httpd_stop(streamServer);
httpd_stop(pageServer);
streamServer = nullptr;
pageServer = nullptr;
return false;
}
return true;
}
// =====================================================
// =====================================================
// SETUP
// =====================================================
void setup()
{
Serial.begin(115200);
delay(1000);
Serial.println();
Serial.println(
"Alex3DWorks ESP32-CAM V1.5"
);
// ---------------------------------------------
// KEEP FLASH LED OFF
// ---------------------------------------------
pinMode(
FLASH_LED_PIN,
OUTPUT
);
digitalWrite(
FLASH_LED_PIN,
LOW
);
// ---------------------------------------------
// CAMERA
// ---------------------------------------------
if (!initializeCamera()) {
Serial.println(
"Camera failed."
);
Serial.println(
"Disconnect power and check "
"the OV2640 ribbon cable."
);
while (true) {
delay(1000);
}
}
// ---------------------------------------------
// WIFI
// ---------------------------------------------
WiFi.mode(WIFI_AP);
/*
IMPORTANT:
We DO NOT use:
WiFi.setSleep(false);
The previous program forced Wi-Fi to remain
fully awake.
We leave the ESP32 Wi-Fi power-management
behavior at its normal/default configuration.
*/
if (
!WiFi.softAPConfig(
localIP,
gateway,
subnet
)
) {
Serial.println(
"AP IP configuration failed."
);
}
// Channel 6
// SSID visible
// MAXIMUM ONE CLIENT
bool apStarted =
WiFi.softAP(
AP_SSID,
AP_PASSWORD,
6,
false,
1
);
if (!apStarted) {
Serial.println(
"Wi-Fi AP failed."
);
while (true) {
delay(1000);
}
}
// ---------------------------------------------
// SERVER
// ---------------------------------------------
if (!startStreamServer()) {
Serial.println(
"Stream server failed."
);
while (true) {
delay(1000);
}
}
Serial.println();
Serial.println("READY");
Serial.println();
Serial.print("Wi-Fi: ");
Serial.println(AP_SSID);
Serial.println(
"Open in browser:"
);
Serial.println(
"http://192.168.4.1"
);
Serial.println();
}
// =====================================================
// LOOP
// =====================================================
void loop()
{
// HTTP server handles everything.
// Nothing needs to run continuously here.
delay(1000);
}
#include <WiFi.h>
#include <WebServer.h>
#include <SimpleDHT.h>
// =====================
// ESP32-C3 WIFI ACCESS POINT
// =====================
const char* apSSID = "Alex3dWorks-Weather";
const char* apPassword = "esp32weather";
// =====================
// DHT11 SENSOR
// =====================
int pinDHT11 = 2;
SimpleDHT11 dht11(pinDHT11);
// =====================
// WEB SERVER
// =====================
WebServer server(80);
// =====================
// WEBPAGE
// =====================
const char MAIN_page[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32-C3 Weather Station</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: Arial, sans-serif;
}
body {
min-height: 100vh;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: white;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.dashboard {
width: 100%;
max-width: 900px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 28px;
padding: 28px;
box-shadow: 0 20px 40px rgba(0,0,0,0.35);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.logo {
display: flex;
align-items: center;
gap: 14px;
}
.icon {
width: 56px;
height: 56px;
border-radius: 18px;
background: linear-gradient(135deg, #38bdf8, #22c55e);
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
}
h1 {
font-size: 32px;
line-height: 1.1;
}
.subtitle {
color: #94a3b8;
margin-top: 4px;
}
.live {
background: rgba(34,197,94,0.15);
border: 1px solid rgba(34,197,94,0.35);
color: #bbf7d0;
padding: 10px 16px;
border-radius: 999px;
font-weight: bold;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 18px;
}
.card {
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 24px;
padding: 24px;
}
.label {
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
margin-bottom: 14px;
}
.value {
font-size: 54px;
font-weight: bold;
line-height: 1;
}
.unit {
font-size: 22px;
color: #94a3b8;
}
.desc {
color: #cbd5e1;
margin-top: 14px;
font-size: 15px;
}
.bar {
width: 100%;
height: 14px;
background: rgba(255,255,255,0.12);
border-radius: 999px;
overflow: hidden;
margin-top: 18px;
}
.fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #38bdf8, #22c55e);
transition: width 0.4s ease;
}
.footer {
margin-top: 24px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.tag {
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.12);
padding: 10px 14px;
border-radius: 999px;
color: #cbd5e1;
font-size: 14px;
}
@media(max-width: 600px) {
h1 {
font-size: 26px;
}
.value {
font-size: 44px;
}
.dashboard {
padding: 22px;
}
}
</style>
</head>
<body>
<div class="dashboard">
<div class="header">
<div class="logo">
<div class="icon">☁️</div>
<div>
<h1>ESP32-C3 Weather Station</h1>
<div class="subtitle">Live temperature and humidity dashboard</div>
</div>
</div>
<div class="live" id="status">LIVE</div>
</div>
<div class="cards">
<div class="card">
<div class="label">Temperature</div>
<div class="value"><span id="temperature">--</span><span class="unit"> °C</span></div>
<div class="desc" id="tempDesc">Waiting for data...</div>
</div>
<div class="card">
<div class="label">Humidity</div>
<div class="value"><span id="humidity">--</span><span class="unit"> %</span></div>
<div class="bar"><div class="fill" id="humidityBar"></div></div>
<div class="desc" id="humDesc">Waiting for data...</div>
</div>
<div class="card">
<div class="label">Last Update</div>
<div class="value" style="font-size:36px;"><span id="time">--:--:--</span></div>
<div class="desc">Updates every 2 seconds</div>
</div>
</div>
<div class="footer">
<div class="tag">ESP32-C3 Access Point</div>
<div class="tag">DHT11 Sensor</div>
<div class="tag">Alex3dWorks Project</div>
</div>
</div>
<script>
function tempText(t) {
if (t < 15) return "Cold environment";
if (t < 23) return "Fresh and comfortable";
if (t < 28) return "Comfortable temperature";
return "Warm environment";
}
function humText(h) {
if (h < 30) return "Air is dry";
if (h < 60) return "Good humidity level";
if (h < 75) return "Humid air";
return "Very humid environment";
}
async function updateData() {
try {
const response = await fetch("/data");
const data = await response.json();
document.getElementById("temperature").innerText = data.temperature;
document.getElementById("humidity").innerText = data.humidity;
document.getElementById("humidityBar").style.width = data.humidity + "%";
document.getElementById("tempDesc").innerText = tempText(data.temperature);
document.getElementById("humDesc").innerText = humText(data.humidity);
document.getElementById("time").innerText = new Date().toLocaleTimeString();
document.getElementById("status").innerText = "LIVE";
} catch (error) {
document.getElementById("status").innerText = "OFFLINE";
}
}
updateData();
setInterval(updateData, 2000);
</script>
</body>
</html>
)rawliteral";
// =====================
// ROUTES
// =====================
void handleRoot() {
server.send(200, "text/html", MAIN_page);
}
void handleData() {
byte temperature = 0;
byte humidity = 0;
int err = SimpleDHTErrSuccess;
err = dht11.read(&temperature, &humidity, NULL);
if (err != SimpleDHTErrSuccess) {
server.send(500, "application/json", "{\"error\":\"DHT11 read failed\"}");
return;
}
String json = "{";
json += "\"temperature\":" + String((int)temperature) + ",";
json += "\"humidity\":" + String((int)humidity);
json += "}";
server.send(200, "application/json", json);
}
// =====================
// SETUP
// =====================
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println();
Serial.println("Starting ESP32-C3 Weather Station...");
WiFi.softAP(apSSID, apPassword);
Serial.print("WiFi name: ");
Serial.println(apSSID);
Serial.print("WiFi password: ");
Serial.println(apPassword);
Serial.print("Open this address: ");
Serial.println(WiFi.softAPIP());
server.on("/", handleRoot);
server.on("/data", handleData);
server.begin();
Serial.println("Web server started.");
}
// =====================
// LOOP
// =====================
void loop() {
server.handleClient();
}