Hardware — čo potrebuješ
- ESP32-CAM modul (AI-Thinker variant s OV2640 kamerou) — ~5€
- FTDI programátor (USB-Serial, 3,3V) alebo ESP32-CAM-MB (doska s USB) — ~3€
- Jumper drôty, napájanie 5V/1A (kamera spotrebuje viac ako bežný ESP32)
- Voliteľne: microSD karta pre lokálne ukladanie záberov
Nahranie CameraWebServer príkladu
Arduino IDE obsahuje hotový príklad File → Examples → ESP32 → Camera → CameraWebServer. Tento príklad spustí MJPEG stream na porte 80 a foto capture na porte 81.
// V CameraWebServer.ino nastav: #define CAMERA_MODEL_AI_THINKER // ESP32-CAM AI-Thinker const char* ssid = "WIFI_SSID"; const char* password = "WIFI_PASS"; // Po nahratí otvor Serial Monitor (115200 baud) // vypíše: "Camera Ready! Use 'http://192.168.1.XXX' to connect"
Detekcia pohybu — porovnanie snímok
ESP32-CAM nemá hardvérový PIR senzor, ale pohyb vieme detekovať softvérovo — porovnaním jasu pixelov dvoch po sebe nasledujúcich snímok. Kľúčový detail: pre porovnanie musíme použiť formát PIXFORMAT_GRAYSCALE, kde fb->buf obsahuje surové pixely (1 bajt = 1 pixel, hodnota jasu 0–255). Pri formáte JPEG by sme porovnávali komprimované bajty, čo nedáva zmysel a nefunguje spoľahlivo. Keď detekujeme pohyb, kameru prepneme do JPEG režimu pre kvalitný záber a odošleme ho na server.
#include "esp_camera.h"
#include <WiFi.h>
#include <HTTPClient.h>
#define CAMERA_MODEL_AI_THINKER
#include "camera_pins.h"
const char* WIFI_SSID = "WIFI_SSID";
const char* WIFI_PASS = "WIFI_PASS";
const char* SERVER_URL = "https://api.gear.sk/api/motion";
const char* API_KEY = "tajny-kluc";
const char* DEVICE_ID = "cam-01";
const float THRESHOLD = 0.10; // 10 % zmenených pixelov = pohyb
const int DELTA = 25; // rozdiel jasu > 25 = pixel sa zmenil
uint8_t* prevGray = nullptr;
size_t prevLen = 0;
bool initCamera(pixformat_t fmt, framesize_t size) {
camera_config_t cfg;
cfg.ledc_channel = LEDC_CHANNEL_0; cfg.ledc_timer = LEDC_TIMER_0;
cfg.pin_d0 = Y2_GPIO_NUM; cfg.pin_d1 = Y3_GPIO_NUM;
cfg.pin_d2 = Y4_GPIO_NUM; cfg.pin_d3 = Y5_GPIO_NUM;
cfg.pin_d4 = Y6_GPIO_NUM; cfg.pin_d5 = Y7_GPIO_NUM;
cfg.pin_d6 = Y8_GPIO_NUM; cfg.pin_d7 = Y9_GPIO_NUM;
cfg.pin_xclk = XCLK_GPIO_NUM; cfg.pin_pclk = PCLK_GPIO_NUM;
cfg.pin_vsync = VSYNC_GPIO_NUM; cfg.pin_href = HREF_GPIO_NUM;
cfg.pin_sscb_sda = SIOD_GPIO_NUM; cfg.pin_sscb_scl = SIOC_GPIO_NUM;
cfg.pin_pwdn = PWDN_GPIO_NUM; cfg.pin_reset = RESET_GPIO_NUM;
cfg.xclk_freq_hz = 20000000;
cfg.pixel_format = fmt;
cfg.frame_size = size;
cfg.jpeg_quality = 12;
cfg.fb_count = 1;
cfg.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
return esp_camera_init(&cfg) == ESP_OK;
}
// Porovnávame surové pixely (GRAYSCALE) — každý bajt = jas jedného pixelu
bool detectMotion(camera_fb_t* fb) {
if (!prevGray || prevLen != fb->len) {
free(prevGray);
prevGray = (uint8_t*)malloc(fb->len);
prevLen = fb->len;
memcpy(prevGray, fb->buf, fb->len);
return false;
}
int changed = 0, step = 4, total = fb->len / step;
for (int i = 0; i < (int)fb->len; i += step) {
if (abs((int)fb->buf[i] - (int)prevGray[i]) > DELTA) changed++;
}
memcpy(prevGray, fb->buf, fb->len);
return (float)changed / total > THRESHOLD;
}
void sendPhotoToServer() {
// Prepni kameru na JPEG pre kvalitný záber
esp_camera_deinit();
if (!initCamera(PIXFORMAT_JPEG, FRAMESIZE_VGA)) return;
delay(300); // kamera sa stabilizuje
camera_fb_t* fb = esp_camera_fb_get();
if (fb) {
HTTPClient http;
http.begin(SERVER_URL);
http.addHeader("X-API-Key", API_KEY);
http.addHeader("Content-Type", "image/jpeg");
http.addHeader("X-Device-ID", DEVICE_ID);
http.POST(fb->buf, fb->len);
http.end();
esp_camera_fb_return(fb);
}
// Vráť sa do detekčného GRAYSCALE režimu
esp_camera_deinit();
prevLen = 0; // invaliduj buffer — zmenil sa formát
initCamera(PIXFORMAT_GRAYSCALE, FRAMESIZE_QVGA);
}
void setup() {
Serial.begin(115200);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.println("WiFi: " + WiFi.localIP().toString());
if (!initCamera(PIXFORMAT_GRAYSCALE, FRAMESIZE_QVGA)) {
Serial.println("CHYBA: kamera sa nespustila!");
while (true) delay(1000);
}
Serial.println("Kamera OK — monitorujem pohyb...");
}
void loop() {
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) { delay(100); return; }
if (detectMotion(fb)) {
Serial.println("Pohyb detekovaný!");
esp_camera_fb_return(fb); // uvoľni GRAYSCALE frame pred reinitom
sendPhotoToServer(); // pošle JPEG, potom prepne späť na GRAYSCALE
delay(5000); // cooldown 5 sekúnd
return;
}
esp_camera_fb_return(fb);
delay(200); // ~5 FPS
}
Laravel endpoint — príjem snímky a notifikácia
// app/Http/Controllers/MotionController.php
class MotionController extends Controller
{
public function receive(Request $request): JsonResponse
{
if ($request->header('X-API-Key') !== config('iot.motion_key')) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$deviceId = $request->header('X-Device-ID', 'unknown');
$jpeg = $request->getContent();
$filename = 'motion/' . $deviceId . '/' . now()->format('Y-m-d_H-i-s') . '.jpg';
// Ulož snímku do private storage (nie public)
Storage::put($filename, $jpeg);
// Zaloguj udalosť
MotionEvent::create([
'device_id' => $deviceId,
'image_path' => $filename,
'detected_at' => now(),
]);
// Odošli notifikáciu asynchrónne (queue job)
SendMotionAlert::dispatch($deviceId, $filename);
return response()->json(['status' => 'received']);
}
}
Job — e-mailová notifikácia
// app/Jobs/SendMotionAlert.php
class SendMotionAlert implements ShouldQueue
{
public function __construct(
public string $deviceId,
public string $imagePath,
) {}
public function handle(): void
{
$imageData = Storage::get($this->imagePath);
Mail::to(config('iot.alert_email'))
->send(new MotionDetectedMail(
$this->deviceId,
$imageData,
now(),
));
}
}
Ukladanie na SD kartu
#include "SD_MMC.h"
void saveToSD(camera_fb_t* fb) {
if (!SD_MMC.begin()) return;
String path = "/motion_" + String(millis()) + ".jpg";
File file = SD_MMC.open(path, FILE_WRITE);
if (file) {
file.write(fb->buf, fb->len);
file.close();
}
}
Praktické tipy
- Napájaj ESP32-CAM z 5V/1A zdroja — pri WiFi + kamera spotreba skáče na ~300 mA, čo nestíha bežný 3,3V regulátor
- Pre nočné snímanie použi ESP32-CAM s infra LED prisvietením alebo externý IR cut filter
- Zníž rozlíšenie na QVGA (320×240) ak potrebuješ rýchlejšiu detekciu pohybu
- Cooldown interval medzi notifikáciami zabraňuje zahlteniu schránky pri dlhom pohybe
- GDPR: ak kamera sníma verejný priestor alebo priestor s tretími osobami, informuj o tom viditeľnou tabuľkou