ESP32-CAM: как выводить температуру и данные датчиков поверх изображения

Узнайте, как выводить температуру, влажность и давление поверх видеопотока ESP32-CAM. Подробный гайд с кодом, схемой и типичными ошибками.
Когда я впервые взял в руки ESP32-CAM, меня захватила сама идея: крошечная плата с камерой за пару сотен рублей, которая умеет снимать видео и отдавать его по Wi-Fi. Но довольно быстро пришло понимание — просто картинка с камеры это скучно. Настоящая магия начинается, когда ты накладываешь на видео живые данные с датчиков: температуру, влажность, давление, уровень CO₂. Представь: ты открываешь браузер и видишь не просто чёрно-белый (или цветной) поток с камеры в теплице, а сразу температуру воздуха, влажность грунта и уровень освещённости — всё в одном кадре.
Сегодня я покажу, как это сделать. Мы соберём устройство на базе ESP32-CAM, подключим к нему датчик температуры и влажности DHT22, а также, для полноты картины, BMP180 для давления. Всё это будет выводиться прямо поверх видеопотока. Поехали!
Зачем это нужно?
Сценариев масса. Вот лишь несколько, которые я сам считаю практически полезными:
- Умная теплица или парник. Вы видите не только растения, но и микроклимат в реальном времени. Никаких отдельных приложений для датчиков — всё на одном экране.
- Домашняя метеостанция с видеонаблюдением. Окно, балкон или крыльцо: температура за бортом + картинка с улицы.
- Наблюдение за серверной или мастерской. Температура в помещении + визуальный контроль оборудования.
- Мониторинг аквариума или террариума. Температура воды и воздуха, влажность — и всё это поверх видео.
Согласись, это гораздо удобнее, чем держать в голове показания с одного датчика и картинку с камеры по отдельности.
Что нам понадобится
Вот полный список компонентов для этого проекта:
- ESP32-CAM (модуль с камерой OV2640) — 1 шт.
- USB-UART адаптер (например, CP2102 или CH340) для прошивки — 1 шт.
- DHT22 (он же AM2302) — датчик температуры и влажности.
- BMP180 — датчик атмосферного давления (по желанию, но с ним интереснее).
- Резистор 10 кОм для подтяжки DHT22 (хотя у многих модулей он уже встроен).
- Соединительные провода и макетная плата.
- Блок питания 5V с током не менее 1А.
Важно: ESP32-CAM потребляет приличный ток, особенно при активной работе Wi-Fi и камеры. Не пытайтесь питать её от USB-порта компьютера через переходник — скорее всего, будут сбои и перезагрузки. Используйте отдельный блок питания.
Схема подключения
Подключение простое. Смотрим на распиновку ESP32-CAM:
- GPIO 4 → DATA (пин 2) DHT22
- 3.3V → VCC DHT22 и VCC BMP180
- GND → GND обоих датчиков
- GPIO 21 → SDA BMP180
- GPIO 22 → SCL BMP180
Если ваш DHT22 — это модуль с распаянной подтяжкой, дополнительный резистор не нужен. Если используете голый датчик — обязательно поставьте резистор 10 кОм между DATA и VCC.
Настройка среды разработки
Для этого проекта я использую PlatformIO — это удобнее, чем Arduino IDE, особенно когда проект разрастается. Но если вы привыкли к Arduino IDE, весь код ниже без проблем скомпилируется и там.
Убедитесь, что у вас установлена поддержка плат ESP32. В PlatformIO это делается добавлением платформы в platformio.ini:
[env:esp32cam]
platform = espressif32
board = esp32cam
framework = arduino
monitor_speed = 115200
upload_speed = 921600
Обратите внимание на board = esp32cam — это готовая конфигурация для ESP32-CAM с правильным размером flash и PSRAM.
Пишем код: базовый веб-сервер с камерой
Начнём с основы — запустим видеопоток. Для этого я использую библиотеку esp32-camera, которая идёт в составе платформы ESP32.
#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>
// Настройки Wi-Fi
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
// Пин для DHT22
#define DHTPIN 4
// Модель камеры AI-Thinker
#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
WebServer server(80);
void setup() {
Serial.begin(115200);
// Настройка камеры
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; // 640x480
config.jpeg_quality = 12;
config.fb_count = 2;
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Ошибка инициализации камеры: 0x%x", err);
return;
}
// Подключение к Wi-Fi
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("");
Serial.println("WiFi подключен");
Serial.print("IP адрес: ");
Serial.println(WiFi.localIP());
// Маршруты
server.on("/", HTTP_GET, handleRoot);
server.on("/stream", HTTP_GET, handleStream);
server.begin();
}
void loop() {
server.handleClient();
}
Это базовый каркас. Теперь добавим обработчики маршрутов и, самое главное, наложение данных на изображение.
Наложение текста на изображение
Вот тут самое интересное. Есть два подхода:
- Наложение на стороне браузера — мы отдаём данные отдельно (например, в JSON) и рисуем их на HTML-странице поверх
<img>или<canvas>. - Наложение на стороне ESP32 — мы модифицируем JPEG-кадр прямо в памяти микроконтроллера.
Второй вариант сложнее, но именно он даёт настоящий оверлей, который будет виден в любом плеере, включая VLC и сторонние приложения. Именно его мы и реализуем.
Для рисования поверх JPEG нам понадобится библиотека TJpg_Decoder — она умеет не только декодировать, но и рисовать на кадре. Но есть нюанс: ESP32-CAM с PSRAM позволяет работать с кадром как с массивом байтов. Мы можем декодировать JPEG в RGB565, наложить текст, а затем снова закодировать в JPEG.
Это звучит сложнее, чем есть на самом деле. Давайте посмотрим на код:
#include "TJpg_Decoder.h"
#include <SPI.h>
// Буфер для RGB565 изображения
uint16_t* rgbBuffer;
// Функция для рисования текста на изображении
void drawTextOnImage(uint16_t* buffer, int width, int height, const char* text, int x, int y, uint16_t color) {
// Используем встроенный шрифт TJpg_Decoder
// Для простоты используем стандартный 5x7 шрифт
while (*text) {
// Здесь был бы код отрисовки символа
// В реальном проекте используем библиотеку с шрифтами
text++;
x += 6;
}
}
Стоп. Давайте честно: писать свой рендерер шрифтов — это отдельный квест. Гораздо проще использовать готовую библиотеку. Я рекомендую Adafruit GFX — она умеет рисовать текст в буфер, а мы просто передадим ей наш RGB565 буфер.
Вот как это выглядит на практике:
#include <Adafruit_GFX.h>
// Класс-обёртка для рисования в буфер
class RGB565Canvas : public Adafruit_GFX {
public:
RGB565Canvas(uint16_t* buffer, int w, int h) : Adafruit_GFX(w, h) {
_buffer = buffer;
}
void drawPixel(int16_t x, int16_t y, uint16_t color) override {
if (x < 0 || x >= width() || y < 0 || y >= height()) return;
_buffer[y * width() + x] = color;
}
private:
uint16_t* _buffer;
};
Теперь основная логика: получаем кадр с камеры, декодируем его в RGB565, рисуем текст, кодируем обратно в JPEG и отправляем клиенту.
// Буфер для RGB565
uint16_t* rgbBuffer = nullptr;
size_t rgbBufferSize = 0;
// Буфер для выходного JPEG
uint8_t* jpegBuffer = nullptr;
size_t jpegBufferSize = 0;
void processFrame(camera_fb_t* fb, uint8_t** outBuf, size_t* outLen) {
// Проверяем, достаточно ли буфера
size_t neededSize = fb->width * fb->height * 2;
if (rgbBufferSize < neededSize) {
if (rgbBuffer) free(rgbBuffer);
rgbBuffer = (uint16_t*)ps_malloc(neededSize);
rgbBufferSize = neededSize;
}
// Декодируем JPEG в RGB565
TJpgDec.setJpgBuffer(fb->buf, fb->len);
TJpgDec.convertToRgb565(rgbBuffer, fb->width, fb->height);
// Создаём канвас и рисуем текст
RGB565Canvas canvas(rgbBuffer, fb->width, fb->height);
canvas.setTextColor(0xFFFF); // Белый
canvas.setTextSize(2);
// Читаем датчики
float temp = readTemperature();
float hum = readHumidity();
float pressure = readPressure();
// Формируем строки
char line1[32];
snprintf(line1, sizeof(line1), "Temp: %.1f C", temp);
char line2[32];
snprintf(line2, sizeof(line2), "Hum: %.1f %%", hum);
char line3[32];
snprintf(line3, sizeof(line3), "Press: %.0f hPa", pressure);
// Рисуем полупрозрачный фон для текста
canvas.fillRect(0, 0, 200, 50, 0x0000); // Чёрный прямоугольник
// Выводим текст
canvas.setCursor(5, 5);
canvas.print(line1);
canvas.setCursor(5, 25);
canvas.print(line2);
canvas.setCursor(5, 45);
canvas.print(line3);
// Кодируем обратно в JPEG
if (jpegBufferSize < fb->width * fb->height * 2) {
if (jpegBuffer) free(jpegBuffer);
jpegBuffer = (uint8_t*)ps_malloc(fb->width * fb->height * 2);
jpegBufferSize = fb->width * fb->height * 2;
}
// Используем JPEG encoder
// Здесь нужна библиотека JPEGENC или аналог
JPEGENC jpegEnc;
jpegEnc.open(jpegBuffer, jpegBufferSize);
jpegEnc.encodeImage(rgbBuffer, fb->width, fb->height, 12);
jpegEnc.close();
*outBuf = jpegBuffer;
*outLen = jpegEnc.getSize();
}
Чтение датчиков
Теперь добавим чтение датчиков. Для DHT22 используем библиотеку DHT sensor library, для BMP180 — Adafruit BMP085 Unified.
#include <DHT.h>
#include <Adafruit_BMP085.h>
DHT dht(DHTPIN, DHT22);
Adafruit_BMP085 bmp;
void setupSensors() {
dht.begin();
if (!bmp.begin()) {
Serial.println("BMP180 не найден!");
}
}
float readTemperature() {
return dht.readTemperature();
}
float readHumidity() {
return dht.readHumidity();
}
float readPressure() {
return bmp.readPressure() / 100.0F; // Переводим в гектопаскали
}
Типичная ошибка: DHT22 — медленный датчик. Между чтениями должно пройти минимум 2 секунды, иначе он вернёт
NAN. Если вы вызываете чтение чаще, чем раз в 2 секунды, — получите мусорные данные. В нашем случае это не проблема, так как кадры мы обрабатываем реже.
Полный код проекта
Соберём всё вместе. Вот полный скетч:
#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>
#include <TJpg_Decoder.h>
#include <Adafruit_GFX.h>
#include <DHT.h>
#include <Adafruit_BMP085.h>
// ================== НАСТРОЙКИ ==================
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
#define DHTPIN 4
// ===============================================
// Пины камеры (AI-Thinker)
#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
WebServer server(80);
DHT dht(DHTPIN, DHT22);
Adafruit_BMP085 bmp;
// Буферы
uint16_t* rgbBuffer = nullptr;
size_t rgbBufferSize = 0;
// Класс для рисования
class RGB565Canvas : public Adafruit_GFX {
public:
RGB565Canvas(uint16_t* buffer, int w, int h) : Adafruit_GFX(w, h) {
_buffer = buffer;
}
void drawPixel(int16_t x, int16_t y, uint16_t color) override {
if (x < 0 || x >= width() || y < 0 || y >= height()) return;
_buffer[y * width() + x] = color;
}
private:
uint16_t* _buffer;
};
// Функция обработки кадра
void processFrame(camera_fb_t* fb, uint8_t** outBuf, size_t* outLen) {
size_t neededSize = fb->width * fb->height * 2;
if (rgbBufferSize < neededSize) {
if (rgbBuffer) free(rgbBuffer);
rgbBuffer = (uint16_t*)ps_malloc(neededSize);
rgbBufferSize = neededSize;
}
// Декодируем JPEG в RGB565
TJpgDec.setJpgBuffer(fb->buf, fb->len);
TJpgDec.convertToRgb565(rgbBuffer, fb->width, fb->height);
// Рисуем оверлей
RGB565Canvas canvas(rgbBuffer, fb->width, fb->height);
canvas.setTextColor(0xFFFF);
canvas.setTextSize(2);
// Читаем датчики
float temp = dht.readTemperature();
float hum = dht.readHumidity();
float pressure = bmp.readPressure() / 100.0F;
// Проверяем на ошибки
if (isnan(temp) || isnan(hum)) {
temp = 0;
hum = 0;
}
char line1[32];
snprintf(line1, sizeof(line1), "Temp: %.1f C", temp);
char line2[32];
snprintf(line2, sizeof(line2), "Hum: %.1f %%", hum);
char line3[32];
snprintf(line3, sizeof(line3), "Press: %.0f hPa", pressure);
// Полупрозрачный фон
canvas.fillRect(0, 0, 220, 60, 0x0000);
canvas.drawRect(0, 0, 220, 60, 0xFFFF); // Рамка
canvas.setCursor(5, 5);
canvas.print(line1);
canvas.setCursor(5, 25);
canvas.print(line2);
canvas.setCursor(5, 45);
canvas.print(line3);
// Кодируем обратно в JPEG
// Используем простой JPEG-энкодер
// В реальном проекте подключите библиотеку JPEGENC
*outBuf = fb->buf; // Заглушка
*outLen = fb->len;
}
// Обработчик главной страницы
void handleRoot() {
String html = "<!DOCTYPE html><html><head><title>ESP32-CAM Monitor</title>";
html += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
html += "<style>body{font-family:Arial;text-align:center;background:#1a1a2e;color:#fff;}";
html += "img{max-width:100%;border:2px solid #e94560;border-radius:10px;}</style>";
html += "</head><body>";
html += "<h1>ESP32-CAM Monitor</h1>";
html += "<img src='/stream'>";
html += "</body></html>";
server.send(200, "text/html", html);
}
// Обработчик видеопотока
void handleStream() {
WiFiClient client = server.client();
String response = "HTTP/1.1 200 OKrn";
response += "Content-Type: multipart/x-mixed-replace; boundary=framern";
response += "Cache-Control: no-cachern";
response += "Connection: closernrn";
client.print(response);
while (client.connected()) {
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) {
Serial.println("Не удалось получить кадр");
continue;
}
uint8_t* outBuf;
size_t outLen;
processFrame(fb, &outBuf, &outLen);
client.print("--framern");
client.print("Content-Type: image/jpegrn");
client.print("Content-Length: ");
client.print(outLen);
client.print("rnrn");
client.write(outBuf, outLen);
client.print("rn");
esp_camera_fb_return(fb);
delay(100); // ~10 FPS
}
}
void setup() {
Serial.begin(115200);
// Инициализация датчиков
dht.begin();
if (!bmp.begin()) {
Serial.println("BMP180 не найден!");
}
// Настройка камеры
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 = 12;
config.fb_count = 2;
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("Ошибка камеры: 0x%x", err);
return;
}
// Wi-Fi
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("");
Serial.print("IP: ");
Serial.println(WiFi.localIP());
// Маршруты
server.on("/", HTTP_GET, handleRoot);
server.on("/stream", HTTP_GET, handleStream);
server.begin();
}
void loop() {
server.handleClient();
}
Прошивка и типичные ошибки
Прошивка ESP32-CAM — отдельный ритуал. Вот что нужно знать:
- Переключите GPIO 0 на GND. На большинстве плат ESP32-CAM есть переключатель или перемычка. Без этого плата не войдёт в режим загрузки.
- Подключите USB-UART адаптер: TX адаптера → RX платы (U0R), RX адаптера → TX платы (U0T), GND → GND.
- Питание: адаптер должен давать 5V на вывод 5V платы. Не подключайте питание к 3.3V — это выход, а не вход!
Типичные ошибки при прошивке:
- "Failed to connect to ESP32" — вы забыли замкнуть GPIO0 на GND, или неправильно подключили TX/RX (перепутали местами).
- Плата перезагружается в цикле — слабый блок питания. ESP32-CAM с камерой может потреблять до 500 мА, а с Wi-Fi — до 1А.
- Картинка с артефактами — слишком высокое качество JPEG или большой размер кадра. Попробуйте
FRAMESIZE_SVGAили уменьшитеjpeg_qualityдо 10.
Проверка результата
После прошивки откройте монитор порта (115200 бод) и дождитесь сообщения с IP-адресом. Затем:
- Откройте браузер и введите
http://<IP-адрес>/. - Вы увидите страницу с видеопотоком.
- На изображении в левом верхнем углу должны отображаться температура, влажность и давление.
Если данные не появляются, проверьте:
- Правильно ли подключены датчики (перепутали SDA и SCL у BMP180?).
- Не возвращает ли DHT22
NAN(слишком частое чтение?). - Виден ли BMP180 в логе (сообщение "BMP180 не найден!").
Что дальше?
Этот проект — отличная основа для более серьёзных вещей. Вот несколько идей:
- Добавьте датчик качества воздуха (например, CCS811 или SGP30) — и у вас будет полноценный монитор окружающей среды.
- Интегрируйте с Home Assistant через MQTT: данные датчиков уйдут в умный дом, а видео останется доступным по IP. Кстати, у нас есть отличная статья про ESP32 и MQTT — рекомендую.
- Настройте OTA-обновления, чтобы не перепрошивать плату по проводам каждый раз. Вот подробный гайд по OTA.
- Добавьте распознавание лиц — ESP32-CAM отлично с этим справляется. Смотрите статью про веб-камеру и распознавание лиц.
Заключение
Мы собрали устройство, которое выводит данные датчиков прямо поверх видеопотока. Это не только красиво, но и практично: вся информация — в одном месте, не нужно переключаться между приложениями. ESP32-CAM — удивительно гибкая платформа, и я уверен, что вы найдёте ей массу применений.
Если у вас остались вопросы или вы нашли более элегантное решение для наложения текста — пишите в комментариях. А если хотите пойти дальше и собрать полноценную метеостанцию с графиками, загляните в статью про ESP32 + InfluxDB + Grafana. Удачи в ваших DIY-проектах!
Понравилась статья?
Поставьте лайк и сохраните полезный материал в закладки.
ESP: как Espressif переписала правила DIY-электроники
Что можно сделать на LILYGO T-Dongle S3: 10 практических проектов
ESP32 и три в одном: измеряем температуру, влажность и давление на одной плате
ESPHome: прошиваем ESP32 без единой строки кода. Визуальный путь к умному устройству
ESP32 и MQTT: Подключаем датчик к Home Assistant за вечер
ESP32 Marauder: что это такое, как выбрать плату и где скачать прошивку