Подглядываем а при желании подслушиваем =) / ESP32-Cam-Spy + Source Code

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
:smile10: Всем привет

У меня это первая статья для этого раздела, и решил я сделать безделушку, при помощи которой вы сможете подсмотреть что-то, что вам нужно, и подслушать, что вам нужно.

Идея такова: чтобы сделать скрытую камеру с возможностью накатывать свою прошивку, какие-то свои изменения добавлять и чтобы она была скрытной. С этим есть небольшие проблемы, но я дам вам пару идей, как это можно сделать.

Для этого нам понадобиться установить Arduino IDE

1740776985548.png


— в моем случае это версия 2.3.2 — и установить туда поддержку плат ESP.

Что потребуется из компонентов?

Спойлер: Ужас, Кошмары, Безобразие
1740777345938.png



Собственно, сама ESP-32 Cam. Я буду лично использовать Ai Thinker ESP-32 Cam, более подробно по ней можете почитать тут –

https://github.com/prusa3d/Prusa-Firmware-ESP32-Cam/blob/master/doc/AI_Thinker-ESP32-cam/README.md

1740776842803.png



Также, в необязательных компонентах — какая-то платка зарядки по типу 03962A (как в моем примере) / TP4065 и аккумулятор для неё, компактный Li-On (в примере у меня Li-Po, так как ничего компактного, кроме как его, не нашел), и если будете скрывать камеру, как я в статейке, то — какой-то дешевый блок питания для телефона, который будет не жалко.
Спойлер: Акумчик
1740776207989.png

Приступим к самому разбору того, что нам нужно сделать =)

Начну для начала с того, какие идеи о том, где это можно скрыть, ко мне приходили, и почему я пришел к такому выбору.

В первую очередь нужно понимать, что наша цель — это скрыть нашу камеру, но в тот же момент нужно понимать, что она долго не проживёт на своём аккумуляторе, ну, например, в какой-то изолированной банке с дыркой для камеры. Поэтому нам нужен какой-то источник электричества, который будет постоянно питать наше чудо и
давать возможность вести наблюдение. Из тех вариантов, что мне пришли в голову, — это использовать зарядку для смартфона, так как то, что она в розетке, постоянно никого не смутит. Ну, и ещё были такие варианты, как различные другие приборы, например, кофемашинка (у меня такая модель, куда можно было бы впихнуть легко камеру, но не думаю, что вариант такой уж универсальный).

Решил не выдумывать велосипед с кофемашиной и остановиться на варианте с зарядкой.

Напряжение у зарядки должно быть 5V, но если такой зарядки, подходящей по форм-фактору, у вас нет, то можете взять что-то побольше и просто достать плату из такой китайской зарядки и внедрить её куда-то еще в другой корпус. В моем случае это адаптер на 12V 3A,
что будет многовато для нашей платы зарядки, поэтому выкорчевываем мозги из этого адаптера и подпаиваемся контактами платы зарядки с 220V на 5V к площадке более большого блока.

Когда этот шаг выполнен, то крайний левый контакт и крайний правый контакт с USB нашей зарядки на 5V подпаиваем к + и – нашей платы зарядки 03962A (проверьте мультиметром, перед тем как подпаивать, где у вас +, а где –; хотя даже такое обычно пишут на платках зарядки).

Теперь, когда вы подпаялись к нашей плате зарядки, вы должны подпаять внутренние контакты ± к контактам аккумулятора 3.7V Li-On/Li-Po (зачем это нужно, если у нас зарядка постоянно в розетке? Чтобы, если её вытянули, то хоть какое-то время она продолжала работать; но это ход не обязательный,
и вы можете, вместо того чтобы играться с этим, сразу подпаять контакты зарядки 5V к –/GND, +/+5V на ESP32).

Затем, как вы подпаяли контакты к аккумулятору, нужно подпаять внутренние контакты платы зарядки с маркировкой OUTPUT ± к самой ESP-32 Cam. Вы найдёте на ней много пинов, в том числе VCC, 3V3, 5V, и тут внимательно: если вы пропустили шаг с аккумулятором и платой зарядки аккумулятора и подпаялись сразу к плате зарядки телефона, то вам нужно подключить
5V от зарядки к –/GND, +/+5V на ESP32, а если вы выполнили шаги с платой зарядки аккумулятора, то –/GND, +/+3V3 на ESP32.

Схему того, как это должно выглядеть, приложу. (Надеюсь стало как-то понятнее немного)
1740776476580.png



Теперь, когда у нас готово с этой частью, мы переходим к коду и логике работы этого дела:

Как мы будем получать данные?

ESP-32 создаёт точку доступа с непримечательным названием; я, например, выбрал название случайного роутера от TP-Link с сайта DNS — TP-Link TL-WR841N, но вы можете использовать, само собой, что угодно.
1740776755247.png


(Ну и пароль для подключения к точке тоже можете видеть :zns6:)

Спойлер: TP-Link
1740776149014.png



Вам нужно подключиться к точке доступа и перейти на её локальный адрес 192.168.4.1, и если вы всё сделаете правильно, то увидите своё личико с камеры =)

(Также код сохраняет данные на SD-карту)

https://randomnerdtutorials.com/installing-esp32-arduino-ide-2-0/

Выбираем вашу плату среди подключённых устройств к COM — в моем случае это COM6 — и заливаем туда код ниже, который мне уже банально лень объяснять.

1740776720345.png



C-подобный: Скопировать в буфер обмена
Код:
#include <WiFi.h>
#include <WebServer.h>
#include <esp_camera.h>
#include <FS.h>
#include <SPI.h>
#include <SD.h>

const char* AP_SSID = "TP-Link TL-WR841N";
const char* AP_PASS = "1234567891";

#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 SD_CS 13
#define SD_MISO 2
#define SD_MOSI 15
#define SD_SCLK 14

#define LED_PIN 4

WebServer server(80);

unsigned long lastFrameTime = 0;
unsigned long videoStartTime = 0;
unsigned long dayLength = 24UL * 3600UL * 1000UL;
String currentFolder = "";
int dayCounter = 0;
unsigned long frameInterval = 2000;

bool cameraInitialized = false;
bool sdInitialized = false;
bool autoDeleteIfFull = false;

sensor_t * s = NULL;

bool initCamera() {
  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_sscb_sda = SIOD_GPIO_NUM;
  config.pin_sscb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;
  config.frame_size = FRAMESIZE_VGA;
  config.jpeg_quality = 10;
  config.fb_count = 2;

  esp_err_t err = esp_camera_init(&config);
  if (err == ESP_OK) {
    s = esp_camera_sensor_get();
    return true;
  } else {
    return false;
  }
}

uint64_t sumDirectorySize(File dir) {
  uint64_t total = 0;
  while (true) {
    File entry = dir.openNextFile();
    if (!entry) break;
    if (entry.isDirectory()) {
      total += sumDirectorySize(entry);
    } else {
      total += entry.size();
    }
    entry.close();
  }
  return total;
}

float getSDUsage() {
  if (!sdInitialized) return 0;
  uint64_t cardSize = SD.cardSize();
  if (cardSize == 0) return 0;
  File root = SD.open("/");
  uint64_t usedSize = sumDirectorySize(root);
  root.close();
  return ((float)usedSize / (float)cardSize) * 100.0;
}

void createNewVideoFolder() {
  currentFolder = "/video_day_" + String(dayCounter);
  if (!SD.exists(currentFolder)) {
    SD.mkdir(currentFolder);
  }
}

void checkDailyRollOver() {
  unsigned long now = millis();
  if (now - videoStartTime >= dayLength) {
    dayCounter++;
    createNewVideoFolder();
    videoStartTime = now;
  }
}

void recordFrame() {
  if (!cameraInitialized || !sdInitialized) return;
  unsigned long now = millis();
  if (now - lastFrameTime >= frameInterval) {
    camera_fb_t * fb = esp_camera_fb_get();
    if (fb) {
      String filename = currentFolder + "/" + String(millis()) + ".jpg";
      File f = SD.open(filename, FILE_WRITE);
      if (f) {
        f.write(fb->buf, fb->len);
        f.close();
      }
      esp_camera_fb_return(fb);
    }
    lastFrameTime = now;
  }
}

void setResolution(framesize_t size) {
  if (s) s->set_framesize(s, size);
}

void setQuality(int quality) {
  if (s) s->set_quality(s, quality);
}

void setPixelFormat(pixformat_t format) {
  if (format == s->pixformat) return;
  esp_camera_deinit();
  camera_config_t cconfig;
  cconfig.ledc_channel = LEDC_CHANNEL_0;
  cconfig.ledc_timer = LEDC_TIMER_0;
  cconfig.pin_d0 = Y2_GPIO_NUM;
  cconfig.pin_d1 = Y3_GPIO_NUM;
  cconfig.pin_d2 = Y4_GPIO_NUM;
  cconfig.pin_d3 = Y5_GPIO_NUM;
  cconfig.pin_d4 = Y6_GPIO_NUM;
  cconfig.pin_d5 = Y7_GPIO_NUM;
  cconfig.pin_d6 = Y8_GPIO_NUM;
  cconfig.pin_d7 = Y9_GPIO_NUM;
  cconfig.pin_xclk = XCLK_GPIO_NUM;
  cconfig.pin_pclk = PCLK_GPIO_NUM;
  cconfig.pin_vsync = VSYNC_GPIO_NUM;
  cconfig.pin_href = HREF_GPIO_NUM;
  cconfig.pin_sscb_sda = SIOD_GPIO_NUM;
  cconfig.pin_sscb_scl = SIOC_GPIO_NUM;
  cconfig.pin_pwdn = PWDN_GPIO_NUM;
  cconfig.pin_reset = RESET_GPIO_NUM;
  cconfig.xclk_freq_hz = 20000000;
  cconfig.pixel_format = format;
  cconfig.frame_size = s->status.framesize;
  cconfig.jpeg_quality = s->status.quality;
  cconfig.fb_count = 2;
  if (esp_camera_init(&cconfig) == ESP_OK) {
    s = esp_camera_sensor_get();
  } else {
    esp_camera_deinit();
    initCamera();
  }
}

void setLED(bool on) {
  digitalWrite(LED_PIN, on ? HIGH : LOW);
}

void cleanupIfNeeded() {
  if (!autoDeleteIfFull || !sdInitialized) return;
  float usage = getSDUsage();
  if (usage < 97.0) return;
  int oldest = 0;
  while (true) {
    String folder = "/video_day_" + String(oldest);
    if (SD.exists(folder)) {
      File dir = SD.open(folder);
      while (true) {
        File entry = dir.openNextFile();
        if (!entry) break;
        String fname = String(entry.name());
        entry.close();
        SD.remove(fname);
      }
      dir.close();
      SD.rmdir(folder);
      usage = getSDUsage();
      if (usage < 97.0) break;
    } else {
      break;
    }
    oldest++;
  }
}

framesize_t framesizeFromString(const String &val) {
  if (val == "qqvga") return FRAMESIZE_QQVGA;
  if (val == "qvga") return FRAMESIZE_QVGA;
  if (val == "vga") return FRAMESIZE_VGA;
  if (val == "svga") return FRAMESIZE_SVGA;
  if (val == "sxga") return FRAMESIZE_SXGA;
  if (val == "uxga") return FRAMESIZE_UXGA;
  return FRAMESIZE_VGA;
}

void handleRoot() {
  String html = "<!DOCTYPE html><html><head><meta charset='UTF-8'>"
                "<meta name='viewport' content='width=device-width, initial-scale=1.0'>"
                "<style>"
                "body { font-family: sans-serif; background: #f0f0f0; color: #333; margin: 20px; }"
                "a.btn { background:#0066cc; color:#fff; padding:10px; border-radius:5px; text-decoration:none; margin:5px; display:inline-block;}"
                "a.btn:hover { background:#005bb5; }"
                "</style>"
                "<title>ESP32-CAM</title></head><body>";
  html += "<h1>ESP32-CAM Наблюдение</h1>";
  if (!cameraInitialized) html += "<p style='color:red;'>Камера не инициализирована!</p>";
  if (!sdInitialized) html += "<p style='color:red;'>SD-карта не инициализирована!</p>";
  html += "<p><a href='/live' class='btn'>Прямой эфир и управление</a></p>";
  html += "<p><a href='/files' class='btn'>Просмотр файлов SD</a></p>";
  html += "<p><a href='/settings' class='btn'>Настройки</a></p>";
  html += "</body></html>";
  server.send(200, "text/html", html);
}

void handleLive() {
  String html = "<!DOCTYPE html><html><head><meta charset='UTF-8'>"
                "<meta name='viewport' content='width=device-width'>"
                "<style>"
                "body { font-family:sans-serif; background:#f0f0f0; color:#333; padding:20px;}"
                "a.btn, button.btn { background:#0066cc; color:#fff; padding:5px 10px; border-radius:5px; text-decoration:none; margin:5px; display:inline-block;}"
                "a.btn:hover, button.btn:hover { background:#005bb5; }"
                "select { margin:5px; }"
                "</style>"
                "<title>Live Stream</title></head><body>";
  html += "<h1>Прямой эфир</h1>";
  if (!cameraInitialized) {
    html += "<p style='color:red;'>Камера не инициализирована!</p>";
  } else {
    html += "<img src='/stream' style='width:100%; max-width:600px; display:block; margin-bottom:20px; border:2px solid #333;'>";
    html += "<h2>Управление камерой</h2>";
    html += "<p><b>Вспышка:</b> ";
    html += "<a href='/control?led=on&return=live' class='btn'>Включить</a> ";
    html += "<a href='/control?led=off&return=live' class='btn'>Выключить</a></p>";
    html += "<p><b>Разрешение:</b> ";
    html += "<a class='btn' href='/control?res=qqvga&return=live'>QQVGA</a>";
    html += "<a class='btn' href='/control?res=qvga&return=live'>QVGA</a>";
    html += "<a class='btn' href='/control?res=vga&return=live'>VGA</a>";
    html += "<a class='btn' href='/control?res=svga&return=live'>SVGA</a>";
    html += "<a class='btn' href='/control?res=sxga&return=live'>SXGA</a>";
    html += "<a class='btn' href='/control?res=uxga&return=live'>UXGA</a></p>";
    html += "<p><b>Качество (JPEG):</b> ";
    html += "<a class='btn' href='/control?quality=5&return=live'>Q=5</a>";
    html += "<a class='btn' href='/control?quality=10&return=live'>Q=10</a>";
    html += "<a class='btn' href='/control?quality=15&return=live'>Q=15</a>";
    html += "<a class='btn' href='/control?quality=30&return=live'>Q=30</a></p>";
    html += "<p><b>Формат пикселей:</b> ";
    html += "<a class='btn' href='/control?format=jpeg&return=live'>JPEG</a></p>";
  }
  html += "<p><a href='/' class='btn'>Главная</a> <a href='/settings' class='btn'>Настройки</a></p>";
  html += "</body></html>";
  server.send(200, "text/html", html);
}

void handleCapture() {
  if (!cameraInitialized) {
    server.send(500, "text/plain", "ЖОПА опять Камера не инициализирована");
    return;
  }
  camera_fb_t * fb = esp_camera_fb_get();
  if (!fb) {
    server.send(500, "text/plain", "Не удалось получить кадр");
    return;
  }
  server.sendHeader("Content-Type", "image/jpeg");
  server.sendHeader("Content-Length", String(fb->len));
  server.send(200);
  server.client().write((const char*)fb->buf, fb->len);
  esp_camera_fb_return(fb);
}

void handleStream() {
  if (!cameraInitialized) {
    server.send(500, "text/plain", "Камера не инициализирована");
    return;
  }
  WiFiClient client = server.client();
  String response = "HTTP/1.1 200 OK\r\n"
                    "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n"
                    "Connection: keep-alive\r\n"
                    "Access-Control-Allow-Origin: *\r\n"
                    "\r\n";
  client.print(response);
  while (client.connected()) {
    camera_fb_t * fb = esp_camera_fb_get();
    if (!fb) {
      delay(10);
      continue;
    }
    client.print("--frame\r\n");
    client.print("Content-Type: image/jpeg\r\n");
    client.print("Content-Length: " + String(fb->len) + "\r\n\r\n");
    client.write(fb->buf, fb->len);
    client.print("\r\n");
    esp_camera_fb_return(fb);
    delay(30);
  }
}

String getJSONValue(const String &json, const String &key) {
  String searchKey = "\"" + key + "\":";
  int keyPos = json.indexOf(searchKey);
  if (keyPos < 0) return "";
  int start = keyPos + searchKey.length();
  while (start < (int)json.length() && (json[start] == ' ' || json[start] == '\"')) start++;
  int end = start;
  while (end < (int)json.length() && json[end] != '\"' && json[end] != ',' && json[end] != '}') end++;
  return json.substring(start, end);
}

String listFilesJSON(const String &dirname) {
  File dir = SD.open(dirname);
  if (!dir || !dir.isDirectory()) {
    return "[]";
  }
  String output = "[";
  bool first = true;
  while (true) {
    File entry = dir.openNextFile();
    if (!entry) break;
    if (!first) output += ",";
    output += "{\"name\":\"" + String(entry.name()) + "\",\"size\":" + String(entry.size()) + (entry.isDirectory() ? ",\"type\":\"dir\"}" : ",\"type\":\"file\"}");
    first = false;
    entry.close();
  }
  output += "]";
  dir.close();
  return output;
}

void handleFiles() {
  if (!sdInitialized) {
    server.send(500, "text/plain", "SD не инициализирован");
    return;
  }
  String path = server.hasArg("dir") ? server.arg("dir") : "/";
  String fileList = listFilesJSON(path);
  String html = "<!DOCTYPE html><html><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width'>"
                "<style>"
                "body { font-family:sans-serif; background:#f0f0f0; color:#333; padding:20px;}"
                "a {color:#0066cc;}"
                "ul {list-style:none; padding:0;}"
                "li {margin:5px 0;}"
                "a.btn { background:#0066cc; color:#fff; padding:5px 10px; border-radius:5px; text-decoration:none; margin:5px; }"
                "a.btn:hover { background:#005bb5; }"
                "</style>"
                "<title>File Browser</title></head><body>";
  html += "<h1>Содержимое SD (" + path + ")</h1>";
  html += "<p><a href='?dir=/'>Корень</a></p><ul>";
  int startIndex = 0;
  while (true) {
    int openBrace = fileList.indexOf('{', startIndex);
    if (openBrace < 0) break;
    int closeBrace = fileList.indexOf('}', openBrace);
    if (closeBrace < 0) break;
    String fileObj = fileList.substring(openBrace, closeBrace + 1);
    startIndex = closeBrace + 1;
    String name = getJSONValue(fileObj, "name");
    String type = getJSONValue(fileObj, "type");
    if (type == "dir") {
      html += "<li>[DIR] <a href='?dir=" + name + "'>" + name + "</a></li>";
    } else {
      html += "<li>[FILE] " + name + " ("
              "<a href='/download?file=" + name + "' class='btn'>Скачать[HIDE][GROUPS=5,6,7,8,9][/GROUPS][/HIDE]
 
Сверху Снизу