Своими рукамипрактическая инструкция источник: Фермозавр

Первый датчик для хозяйства: знакомимся с ESP32 на практике

Редакция Фермозавра·9 апреля 2026 г.·15 мин чтения

Пусть теплица пришлёт вам первое сообщение. Подключим датчик к ESP32, посмотрим на показания и попробуем ещё один опыт — наблюдение за влажностью грунта.

Первый датчик для хозяйства: знакомимся с ESP32 на практике

Хочется заглянуть в теплицу, пока вы заняты в другом месте? Начните с одного датчика и одного понятного вопроса: какая сейчас температура возле растений? ESP32 прочитает показание и отправит его вам. Когда первый результат появится на телефоне, будет проще разобраться, как устроен весь путь от провода до сообщения.

Здесь два самостоятельных лабораторных проекта: температура и влажность воздуха, затем почвенный датчик с уведомлениями в Telegram. Выберите один и соберите его на столе. Проверим показания и связь, а потом подберём место в хозяйстве.

Выбираем задачу для первого опыта

  • Микроклимат. Датчик показывает условия в своей точке установки. Пороговое оповещение может сообщить о холоде или перегреве, но не предсказывает заморозок заранее. Владелец должен понимать, кто и как отреагирует на сообщение.
  • Состояние почвы. Аналоговый датчик помогает наблюдать изменение сигнала между поливами. Для решения о поливе нужны подходящие опоры, место установки и проверка на своём грунте; простое преобразование шкалы АЦП не даёт объёмную влажность в процентах.
  • Местоположение техники. Приёмник GPS определяет координаты, а передатчик доставляет их на сервер. По одному треку нельзя достоверно установить работу двигателя, расход топлива или причину остановки.
  • Взвешивание. Электронная запись связывает массу с партией, временем и участком. Для этого нужны не только датчик и связь, но и калибровка весов, устойчивое показание, идентификатор операции и подтверждение сохранения.

Готовим комплект и считаем всю сборку

Для первого проекта потребуются плата ESP32, датчик, подходящий источник питания, кабель, соединения и макетная плата. Для установки на месте добавятся корпус, кабельные вводы, крепления и работа по монтажу. Для удалённого участка — питание и канал связи. Смету составляйте по конкретным изделиям и предложениям поставщиков на момент покупки.

Составьте смету вместе с корпусом, установкой и обслуживанием. Затем спросите себя, какое действие изменится благодаря показаниям. Это поможет сравнить самодельный узел с готовой станцией по нужным вам точности, защите, диагностике и поддержке.

Подготовка ESP32 и Telegram

Оба примера рассчитаны на классический ESP32, например ESP32-DevKitC V4. Для них проверены Arduino-ESP32 3.3.12 и ArduinoJson 7.4.3. Для воздушного датчика дополнительно нужны DHT sensor library 1.4.7 и Adafruit Unified Sensor 1.1.15. Установите поддержку платы по инструкции Espressif и выберите ESP32 Dev Module. Для другой разновидности ESP32 проверьте выводы и параметры АЦП отдельно.

Создайте своего бота через BotFather, напишите ему /start и получите message.chat.id из getUpdates своего бота. Заполните SSID, PASSWORD, BOT_TOKEN и CHAT_ID. Токен не публикуйте и не передавайте посторонним ботам. Сведения о запросах и ответах есть в официальном Bot API.

Каждый скетч сохраняйте в отдельном проекте: у каждого свои setup() и loop(). Не вставляйте их один за другим в один файл. Подключения меняйте при отключённом питании. Для ESP32-DevKitC V4 выберите один способ питания платы; производитель запрещает одновременно питать её через разные входы.

Первое сообщение о температуре

Подключите совместимый с питанием 3,3 В DHT22. У отдельного датчика и модуля порядок контактов может различаться: ориентируйтесь на маркировку. Если подтяжки DATA на модуле нет, установите её отдельно. В этом примере используется резистор 10 кОм:

ESP32 3V3 → DHT22 VCC
ESP32 GND → DHT22 GND
ESP32 GPIO4 → DHT22 DATA
DHT22 DATA → 10 кОм → ESP32 3V3

Это соединение для сигнального уровня 3,3 В. Не переводите питание датчика на 5 В, оставляя его линию DATA подтянутой к этому питанию: на вход ESP32 попадёт недопустимый уровень. Подключение DHT описано в руководстве Adafruit; схему нужно согласовать именно со своей платой.

Для опыта зададим холод ниже 5 °C, жару выше 35 °C и влажность выше 90%. Возврат происходит при 6 °C, 34 °C и 85% соответственно. Попробуйте изменение возле границы: зазор между входом и выходом удерживает состояние от частых переключений. Ошибка чтения тоже создаёт событие. Рабочие пороги затем выберите для культуры и стадии роста.

#include <Arduino.h>
#include <WiFi.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <DHT.h>
#include <time.h>
#include <math.h>

constexpr char SSID[] = "CHANGE_ME", PASSWORD[] = "CHANGE_ME";
constexpr char BOT_TOKEN[] = "CHANGE_ME", CHAT_ID[] = "CHANGE_ME";
constexpr int DHT_PIN = 4;
constexpr uint32_t SAMPLE_MS = 10000, RETRY_MS = 60000, REPORT_MS = 3600000;
constexpr uint8_t COLD = 1, HOT = 2, WET = 4, SENSOR_BAD = 8;
constexpr size_t CAPACITY = 32;
struct Event { uint32_t uptime; uint8_t state; float t, h; };
Event events[CAPACITY];
size_t head = 0, count = 0;
uint32_t dropped = 0, sampledAt = 0, attemptedAt = 0, reportedAt = 0, reconnectAt = 0;
uint32_t retryMs = RETRY_MS;
bool sampled = false, attempted = false;
uint8_t state = SENSOR_BAD;
float temperature = NAN, humidity = NAN;
DHT dht(DHT_PIN, DHT22);

uint8_t classify(float t, float h, uint8_t previous) {
  if (!isfinite(t) || !isfinite(h) || h < 0 || h > 100) return SENSOR_BAD;
  uint8_t next = 0;
  // Учебные пороги; подбираются по регламенту хозяйства.
  if (t < ((previous & COLD) ? 6.0f : 5.0f)) next |= COLD;
  if (t > ((previous & HOT) ? 34.0f : 35.0f)) next |= HOT;
  if (h > ((previous & WET) ? 85.0f : 90.0f)) next |= WET;
  return next;
}
void enqueue(Event e) {
  if (count == CAPACITY) { head = (head + 1) % CAPACITY; --count; ++dropped; }
  events[(head + count) % CAPACITY] = e;
  ++count;
}
String describe(Event e) {
  String s = "Теплица. Измерение через " + String(e.uptime / 1000) + " с после запуска.\n";
  if (e.state & SENSOR_BAD) return s + "Ошибка датчика: условия неизвестны.";
  s += "T=" + String(e.t,1) + " °C; RH=" + String(e.h,1) + " %.\n";
  if (!e.state) s += "Показатели в заданных пределах.";
  if (e.state & COLD) s += "Ниже порога температуры. ";
  if (e.state & HOT) s += "Выше порога температуры. ";
  if (e.state & WET) s += "Выше порога влажности.";
  return s;
}
bool sendTelegram(const String& text) {
  if (WiFi.status() != WL_CONNECTED || time(nullptr) < 1735689600) return false;
  if (String(BOT_TOKEN) == "CHANGE_ME" || String(CHAT_ID) == "CHANGE_ME") return false;
  NetworkClientSecure client;
  client.useBuiltinCACertBundle();
  client.setHandshakeTimeout(5);
  HTTPClient http;
  http.setConnectTimeout(3000);
  http.setTimeout(5000);
  const String url = String("https://api.telegram.org/bot") + BOT_TOKEN + "/sendMessage";
  if (!http.begin(client, url)) return false;
  JsonDocument request;
  request["chat_id"] = CHAT_ID;
  request["text"] = text;
  String payload;
  serializeJson(request, payload);
  http.addHeader("Content-Type", "application/json");
  const int code = http.POST(payload);
  String response = http.getString();
  http.end();
  JsonDocument result;
  const bool parsed = !deserializeJson(result, response);
  if (code == 429 && parsed) {
    uint32_t seconds = result["parameters"]["retry_after"] | 60U;
    if (seconds < 60) seconds = 60;
    if (seconds > 86400) seconds = 86400;
    retryMs = seconds * 1000U;
  } else retryMs = RETRY_MS;
  const bool ok = code == 200 && parsed && result["ok"].is<bool>() && result["ok"].as<bool>();
  Serial.printf("Telegram HTTP=%d accepted=%d\n",code,ok); // Не печатаем токен и тело ответа.
  return ok;
}
void setup() {
  Serial.begin(115200);
  dht.begin();
  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);
  WiFi.begin(SSID, PASSWORD);
  configTime(0, 0, "pool.ntp.org", "time.cloudflare.com");
  delay(2000); // Начальная пауза для датчика; сеть не ожидаем.
  // Ожидания Wi-Fi нет: измерения продолжаются и без сети.
}
void loop() {
  uint32_t now = millis();
  if (WiFi.status() != WL_CONNECTED && uint32_t(now-reconnectAt) >= 30000) {
    reconnectAt = now;
    WiFi.reconnect();
  }
  if (!sampled || uint32_t(now-sampledAt) >= SAMPLE_MS) {
    sampledAt = now;
    temperature = dht.readTemperature(); humidity = dht.readHumidity();
    uint8_t next = classify(temperature, humidity, state);
    if (!sampled || next != state) enqueue({now,next,temperature,humidity});
    state = next; sampled = true;
  }
  if (attempted && uint32_t(now-attemptedAt) < retryMs) return;
  if (WiFi.status() != WL_CONNECTED) return;
  bool hourly = uint32_t(now-reportedAt) >= REPORT_MS;
  if (!count && !dropped && !hourly) return;
  attempted = true; attemptedAt = now;
  if (dropped) {
    String s = "Очередь переполнилась: пропущено переходов " + String(dropped) + ".\n";
    s += describe({now,state,temperature,humidity});
    if (sendTelegram(s)) dropped = 0;
  } else if (count) {
    if (sendTelegram(describe(events[head]))) { head=(head+1)%CAPACITY; --count; }
  } else if (sendTelegram("Периодический отчёт.\n" + describe({now,state,temperature,humidity}))) {
    reportedAt = now;
  }
}

Второй опыт: как меняется сигнал в грунте

В качестве конкретного примера возьмём ёмкостный DFRobot SEN0193. По документации он питается от 3,3–5,5 В и выдаёт аналоговый сигнал до 3,0 В. Здесь питаем его от 3,3 В, соединяем земли, а выход подключаем к GPIO34. У других модулей проверьте питание, диапазон сигнала и распиновку до подключения.

ESP32 3V3 → SEN0193 VCC
ESP32 GND → SEN0193 GND
ESP32 GPIO34 → SEN0193 AOUT

На классическом ESP32 вывод GPIO34 подключён к ADC1. В скетче выбраны 12 бит и ослабление ADC_11db. Если берёте другую разновидность платы, сверьте доступные выводы и параметры АЦП в справочнике Espressif.

Сначала оставьте DRY_RAW и WET_RAW равными −1, а токен Telegram — заглушкой. Загрузите скетч и откройте монитор порта на 115200 бод: он выводит исходное значение ADC. Получите две воспроизводимые опорные точки на своём образце грунта — относительно сухую и более влажную. Сохраняйте одинаковые питание, глубину и положение зонда; электронную часть не погружайте в воду.

Внесите свои опорные измерения в DRY_RAW и WET_RAW и загрузите скетч снова. Теперь сухой точке соответствуют 0 баллов, влажной — 100. Понаблюдайте за промежуточными значениями. Это условный индекс выбранной настройки; после переноса датчика перепроверьте опоры.

Попробуйте также неверную настройку. Если опоры не заданы, разделены менее чем 100 отсчётами или показание выходит из допустимого интервала, программа покажет ошибку. Для знакомства порог задан ниже 30 баллов с возвратом при 35. По этому переходу удобно проверить сообщение; порог реального полива предстоит выбирать отдельно.

#include <Arduino.h>
#include <WiFi.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
#include <math.h>

constexpr char SSID[] = "CHANGE_ME", PASSWORD[] = "CHANGE_ME";
constexpr char BOT_TOKEN[] = "CHANGE_ME", CHAT_ID[] = "CHANGE_ME";
constexpr int SOIL_PIN = 34;
// Замените после измерения опор на своей плате. -1 запрещает расчёт индекса.
constexpr int DRY_RAW = -1, WET_RAW = -1;
constexpr uint32_t SAMPLE_MS = 10000, RETRY_MS = 60000, REPORT_MS = 3600000;
constexpr uint8_t DRY = 1, SENSOR_BAD = 2, CAL_BAD = 4;
constexpr size_t CAPACITY = 32;
struct Event { uint32_t uptime; uint8_t state; int raw; float index; };
Event events[CAPACITY];
size_t head = 0, count = 0;
uint32_t dropped = 0, sampledAt = 0, attemptedAt = 0, reportedAt = 0, reconnectAt = 0;
uint32_t retryMs = RETRY_MS;
bool sampled = false, attempted = false;
uint8_t state = CAL_BAD;
int raw = -1;
float soilIndex = NAN;

int readSoil() {
  uint32_t total = 0;
  for (unsigned i = 0; i < 16; ++i) {
    int value = analogRead(SOIL_PIN);
    if (value <= 0 || value >= 4095) return -1;
    total += value;
    delay(1);
  }
  return int(total / 16);
}
uint8_t classifySoil(int value, int dry, int wet, uint8_t previous, float &index) {
  index = NAN;
  if (value <= 0 || value >= 4095) return SENSOR_BAD;
  if (dry <= 0 || dry >= 4095 || wet <= 0 || wet >= 4095 || abs(dry - wet) < 100)
    return CAL_BAD;
  float calculated = 100.0f * (dry - value) / (dry - wet);
  if (calculated < 0 || calculated > 100) return SENSOR_BAD;
  index = calculated;
  // Учебный относительный индекс, не объёмная влажность почвы.
  return index < ((previous & DRY) ? 35.0f : 30.0f) ? DRY : 0;
}
void enqueue(Event e) {
  if (count == CAPACITY) { head = (head + 1) % CAPACITY; --count; ++dropped; }
  events[(head + count) % CAPACITY] = e;
  ++count;
}
String describe(Event e) {
  String s = "Почвенный датчик. Измерение через " + String(e.uptime / 1000) + " с после запуска.\n";
  s += "ADC=" + String(e.raw) + ".\n";
  if (e.state & CAL_BAD) return s + "Опорные значения не настроены или слишком близки.";
  if (e.state & SENSOR_BAD) return s + "Сигнал вне проверяемого диапазона. Проверьте датчик и опоры.";
  s += "Относительный индекс: " + String(e.index, 1) + " из 100.\n";
  return s + ((e.state & DRY) ? "Индекс ниже заданного порога." : "Индекс в заданных пределах.");
}
bool sendTelegram(const String& text) {
  if (WiFi.status() != WL_CONNECTED || time(nullptr) < 1735689600) return false;
  if (String(BOT_TOKEN) == "CHANGE_ME" || String(CHAT_ID) == "CHANGE_ME") return false;
  NetworkClientSecure client;
  client.useBuiltinCACertBundle();
  client.setHandshakeTimeout(5);
  HTTPClient http;
  http.setConnectTimeout(3000);
  http.setTimeout(5000);
  const String url = String("https://api.telegram.org/bot") + BOT_TOKEN + "/sendMessage";
  if (!http.begin(client, url)) return false;
  JsonDocument request;
  request["chat_id"] = CHAT_ID;
  request["text"] = text;
  String payload;
  serializeJson(request, payload);
  http.addHeader("Content-Type", "application/json");
  const int code = http.POST(payload);
  String response = http.getString();
  http.end();
  JsonDocument result;
  const bool parsed = !deserializeJson(result, response);
  if (code == 429 && parsed) {
    uint32_t seconds = result["parameters"]["retry_after"] | 60U;
    if (seconds < 60) seconds = 60;
    if (seconds > 86400) seconds = 86400;
    retryMs = seconds * 1000U;
  } else retryMs = RETRY_MS;
  const bool ok = code == 200 && parsed && result["ok"].is<bool>() && result["ok"].as<bool>();
  Serial.printf("Telegram HTTP=%d accepted=%d\n",code,ok); // Не печатаем токен и тело ответа.
  return ok;
}
void setup() {
  Serial.begin(115200);
  analogReadResolution(12);
  analogSetPinAttenuation(SOIL_PIN, ADC_11db);
  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);
  WiFi.begin(SSID, PASSWORD);
  configTime(0, 0, "pool.ntp.org", "time.cloudflare.com");
  delay(2000); // Начальная пауза для датчика; сеть не ожидаем.
  // Ожидания Wi-Fi нет: измерения продолжаются и без сети.
}
void loop() {
  uint32_t now = millis();
  if (WiFi.status() != WL_CONNECTED && uint32_t(now-reconnectAt) >= 30000) {
    reconnectAt = now;
    WiFi.reconnect();
  }
  if (!sampled || uint32_t(now-sampledAt) >= SAMPLE_MS) {
    sampledAt = now;
    raw = readSoil();
    uint8_t next = classifySoil(raw, DRY_RAW, WET_RAW, state, soilIndex);
    Serial.printf("ADC=%d index=%.1f state=%u\n", raw, soilIndex, next);
    if (!sampled || next != state) enqueue({now,next,raw,soilIndex});
    state = next; sampled = true;
  }
  if (attempted && uint32_t(now-attemptedAt) < retryMs) return;
  if (WiFi.status() != WL_CONNECTED) return;
  bool hourly = uint32_t(now-reportedAt) >= REPORT_MS;
  if (!count && !dropped && !hourly) return;
  attempted = true; attemptedAt = now;
  if (dropped) {
    String s = "Очередь переполнилась: пропущено переходов " + String(dropped) + ".\n";
    s += describe({now,state,raw,soilIndex});
    if (sendTelegram(s)) dropped = 0;
  } else if (count) {
    if (sendTelegram(describe(events[head]))) { head=(head+1)%CAPACITY; --count; }
  } else if (sendTelegram("Периодический отчёт.\n" + describe({now,state,raw,soilIndex}))) {
    reportedAt = now;
  }
}

После первых показаний изучите ошибки подключения. Обрыв иногда даёт правдоподобное промежуточное число, а выход за опоры может означать как изменение грунта, так и настройку. Поэтому здесь наблюдаем и сравниваем с почвой; управление насосом потребует отдельной диагностики и защиты.

Наблюдаем, как событие доходит до телефона

Показания опрашиваются раз в десять секунд. Каждое изменение состояния попадает в очередь, а когда она свободна, раз в час приходит обычный отчёт. Проверьте разные события по очереди: температурное предупреждение оставляет возможность заметить влажность и ошибку датчика.

Отключите Wi-Fi на короткое время и верните связь. Последние 32 изменения ждут в оперативной памяти; при переполнении старые уступают место новым, а в отчёт добавляется счётчик пропусков. Перезапуск очищает очередь. Время после запуска внутри сообщения поможет узнать задержавшееся событие.

Уведомление удаляется из очереди только после HTTP 200 и логического ok=true в ответе Telegram. При сбое предусмотрена повторная попытка не ранее чем через минуту; ответ с ограничением частоты учитывает retry_after. Если API принял сообщение, а ответ потерялся, повтор может создать дубликат. Подтверждение API не означает, что человек прочитал сообщение.

Для HTTPS используется проверка сертификата сервера и установленное через NTP время. Пока времени или связи нет, отправка не считается успешной. Бесконечного ожидания Wi-Fi при старте нет; измерения продолжаются без сети. Однако синхронный сетевой запрос способен задержать следующий опрос — гарантированного времени реакции у этих примеров нет.

Что захочется собрать после первых показаний

У GPS-трекера отдельно решаются получение координат, питание и передача данных. Например, u-blox NEO-6 — GPS-приёмник с интерфейсами обмена, а не самостоятельный сотовый передатчик. Производитель относит эту серию к предыдущим поколениям; для новой разработки нужно отдельно выбирать актуальный модуль. В поле потребуются доступный канал связи и хранение точек на время его пропадания. При оценке положения проверяют возраст и достоверность координат; один факт получения сообщения не делает старую точку текущей.

Для проекта весов HX711 соединит тензодатчик с контроллером. Начните с механики, диапазона нагрузки и калибровки, затем добейтесь устойчивого показания. При передаче в учёт сохраняйте идентификатор операции, чтобы повтор после сбоя относился к тому же взвешиванию.

Wi-Fi подходит там, где проверено покрытие. BLE требует подходящего приёмника или шлюза. В Zigbee сообщения пересылают маршрутизаторы; не каждый датчик расширяет сеть. Поддержку радиопротоколов нужно проверять у конкретного чипа: например, ESP32-C6 имеет радио IEEE 802.15.4, необходимое для Zigbee, но это не свойство любой платы ESP32.

Для удалённой точки можно попробовать прямой обмен двух модулей LoRa. LoRaWAN использует другую организацию сети со шлюзами. Выбрав вариант, проверьте связь на месте с вашими антеннами и настройками: это отдельный интересный опыт с размещением приёмника.

Переносим датчик со стола к растениям

Выберите для воздушного датчика место с доступом воздуха, защищённое от прямого солнца и воды. Вынесите его из нагревающегося корпуса ESP32, чтобы наблюдать за выбранной точкой теплицы. Для электроники подготовьте соединения, кабельные вводы и доступ для обслуживания.

Если нужна батарея, измерьте расход всей сборки в разных режимах: платы, датчика, стабилизатора и радио. По этим данным подберите аккумулятор, зарядное устройство и панель с учётом условий установки. Потребление готовой платы особенно интересно сравнить с её режимом сна.

Перед установкой сравните датчик с контрольным прибором, пройдите через пороги и попробуйте ошибку чтения. Затем отключите Wi-Fi, верните связь, проверьте неверный токен и перезапуск. Запишите, что увидели на телефоне в каждом случае: получится ваша первая памятка по обслуживанию узла.

Полностью обесточенное устройство не сможет само сообщить о своей остановке. Для наблюдения за его работоспособностью нужен независимый получатель периодических сообщений, а для критичных условий — местная сигнализация и заранее определённый порядок действий.

Собираем историю на общем сервере

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

Если данные нужно объединять с работой бригад и складом, сравните возможности платформ с конкретным процессом хозяйства. Например, на сайте ТерраКвант заявлены операционный учёт, работа с весами и IoT-мониторинг. Совместимость конкретного самодельного устройства, формат обмена и обработку сбоев следует проверять отдельно — наличие названия функции на сайте не заменяет испытание интеграции.

Дальнейшие материалы раздела: метеостанция для теплицы, датчик почвы и автополив, весы с записью результатов, GPS-трекер и дашборд Grafana. Выберите продолжение, которое отвечает на следующий вопрос вашего хозяйства. Один понятный и проверенный узел станет хорошей отправной точкой.

Источник: Фермозавр

💬 Комментарии

Чтобы оставить комментарий, войдите или зарегистрируйтесь

Загрузка комментариев...

Похожие статьи