Мёрж feature/soil-moisture-sensor: датчик влажности почвы + второй релейный модуль

This commit is contained in:
2026-08-18 13:04:17 +05:00
5 changed files with 127 additions and 44 deletions
+48 -27
View File
@@ -1,9 +1,9 @@
# growbox (ESP32-контроллер) # growbox (ESP32-контроллер)
Прошивка для реального ESP32, обслуживающая зону "Гроубокс" платформы Прошивка для реального ESP32, обслуживающая зону "Гроубокс" платформы
[home_automatization](../../home_automatization). Одна плата владеет четырьмя [home_automatization](../../home_automatization). Одна плата владеет пятью
устройствами: `sensor-1` (DHT22) и три реле-актуатора — `light-1`, `pump-1`, устройствами: `sensor-1` (DHT22, температура/влажность воздуха), `soil-1`
`fan-1`. (влажность почвы) и три реле-актуатора — `light-1`, `pump-1`, `fan-1`.
## Протокол (MQTT, mosquitto из docker-compose платформы, порт 1883) ## Протокол (MQTT, mosquitto из docker-compose платформы, порт 1883)
@@ -22,39 +22,52 @@
device_id в конфиге должны совпадать с `devices.external_id`, засеянными в device_id в конфиге должны совпадать с `devices.external_id`, засеянными в
Laravel (`DemoGrowboxSeeder` использует `sensor-1`/`fan-1`), а `zone_id`с Laravel (`DemoGrowboxSeeder` использует `sensor-1`/`fan-1`), а `zone_id`с
`zones.id` зоны "Гроубокс" в Postgres. `zones.id` зоны "Гроубокс" в Postgres. `soil-1` — новое устройство, в
Laravel/Postgres пока не заведено (нужен `device_type` и строка в `devices`
— это в этом репозитории не делаем); до тех пор его телеметрия просто ни к
чему не привязана на стороне платформы, ingest-service это не блокирует
(zone_id едет прямо в payload, в Postgres за ним никто не ходит).
## Железо ## Железо
По фото реального комплекта: 30-контактная ESP32 DevKit, готовый модуль По фото реального комплекта: 30-контактная ESP32 DevKit, готовый модуль
DHT22 с распаянными проводами и **один 2-канальный** релейный модуль DHT22 с распаянными проводами, компараторный модуль почвенной влажности
(SRD-05VDC-SL-C×2, общий разъём `GND IN1 IN2 VCC`) — закрывает sensor-1, (потенциометр + разъём `A0 D0 GND VCC`, используем только `A0`) и **два
light-1, pump-1. Для fan-1 нужен ещё один канал реле (третий модуль или одинаковых 2-канальных** релейных модуля (SRD-05VDC-SL-C×2 каждый, общий
4-канальная плата) — в прошивке/config.h он уже заложен на GPIO18, физически разъём `GND IN1 IN2 VCC`) — вместе четыре канала на три реле, один канал
пока не подключён. второй платы свободен про запас.
| Назначение | Пин (по умолчанию) | Примечание | | Назначение | Пин (по умолчанию) | Примечание |
|---------------------|---------------------|------------| |--------------------------|---------------------|------------|
| DHT22 DATA | GPIO4 | провод жёлтый на нашем модуле | | DHT22 DATA | GPIO4 | провод жёлтый на нашем модуле |
| Реле IN1 — light-1 | GPIO16 | **на шёлкографии платы подписан `RX2`**, не «16» | | Почва, A0 | GPIO34 | ADC1_CH6, input-only; нужна калибровка `SOIL_RAW_DRY`/`SOIL_RAW_WET` |
| Реле IN2pump-1 | GPIO17 | **на шёлкографии платы подписан `TX2`**, не «17» | | Реле №1, IN1light-1 | GPIO16 | **на шёлкографии платы подписан `RX2`**, не «16» |
| Реле IN — fan-1 | GPIO18 | нужен третий канал реле, пока не подключено | | Реле №1, IN2pump-1 | GPIO17 | **на шёлкографии платы подписан `TX2`**, не «17» |
| Реле №2, IN1 — fan-1 | GPIO18 | второй такой же модуль |
| Реле №2, IN2 | — | свободный канал, не задействован |
На этой конкретной 30-пиновой плате GPIO16/17 не подписаны номерами — они На этой конкретной 30-пиновой плате GPIO16/17 не подписаны номерами — они
выведены как пины UART2 и промаркированы `RX2`/`TX2`. Физически это те же выведены как пины UART2 и промаркированы `RX2`/`TX2`. Физически это те же
GPIO, `config.h`/прошивку менять не нужно, важно только не перепутать их при GPIO, `config.h`/прошивку менять не нужно, важно только не перепутать их при
пайке/монтаже на плате. пайке/монтаже на плате.
Реле-модуль (2-канальный, SRD-05VDC-SL-C) активен по LOW — Оба релейных модуля (2-канальные, SRD-05VDC-SL-C) активны по LOW —
`RELAY_ACTIVE_LOW` в `config.h` уже стоит в `true`. Перемычка `JD-VCC` на `RELAY_ACTIVE_LOW` в `config.h` уже стоит в `true`. Перемычка `JD-VCC` на
модуле по умолчанию установлена (питание катушек реле — от той же VCC, без каждом модуле по умолчанию установлена (питание катушек реле — от той же
гальванической развязки) — для этого проекта этого достаточно, снимать её VCC, без гальванической развязки) — для этого проекта этого достаточно,
незачем. снимать её незачем.
DHT22 требует подтягивающий резистор ~10кОм между DATA и VCC — на нашем DHT22 требует подтягивающий резистор ~10кОм между DATA и VCC — на нашем
модуле, похоже, уже встроен (компактная плата-переходник с 3 проводами), но модуле, похоже, уже встроен (компактная плата-переходник с 3 проводами), но
если показания не читаются — проверьте отдельным резистором. если показания не читаются — проверьте отдельным резистором.
Модуль почвенной влажности — сырое ADC-значение на `A0` **уменьшается** с
ростом влажности (суше — выше сопротивление датчика — выше напряжение).
Заводские `SOIL_RAW_DRY`/`SOIL_RAW_WET` в `config.h.example` — грубая оценка,
не калибровка: замерьте raw-значение на воздухе и после погружения щупа в
воду (печатается в Serial-логе `telemetryTick()`) и подставьте свои числа,
иначе проценты будут врать.
### Схема подключения ### Схема подключения
``` ```
@@ -62,10 +75,12 @@ DHT22 требует подтягивающий резистор ~10кОм ме
┌───────────────────────┐ ┌───────────────────────┐
DHT22 DATA ───────┤ GPIO4 │ DHT22 DATA ───────┤ GPIO4 │
(жёлтый провод) │ │ (жёлтый провод) │ │
Почва A0 ─────────┤ GPIO34 │
│ │ │ │
Реле IN1 (light-1)┤ GPIO16 (на плате: RX2) Реле №1 IN1 (light-1) ┤ GPIO16 (на плате: RX2)
Реле IN2 (pump-1) ┤ GPIO17 (на плате: TX2) Реле №1 IN2 (pump-1) ┤ GPIO17 (на плате: TX2)
Реле IN (fan-1) ──┤ GPIO18 ← пока не подключено Реле №2 IN1 (fan-1) ┤ GPIO18
Реле №2 IN2 (свободно) ┤ —
│ │ │ │
3V3 ────┤ 3V3 │ 3V3 ────┤ 3V3 │
GND ────┤ GND │ GND ────┤ GND │
@@ -75,9 +90,13 @@ DHT22 требует подтягивающий резистор ~10кОм ме
DHT22 (готовый модуль, 3 провода): DHT22 (готовый модуль, 3 провода):
оранжевый(+/VCC) -> 3V3 жёлтый(out/DATA) -> GPIO4 зелёный(-/GND) -> GND оранжевый(+/VCC) -> 3V3 жёлтый(out/DATA) -> GPIO4 зелёный(-/GND) -> GND
Реле, 2-канальный модуль (разъём GND IN1 IN2 VCC): Почва (компараторный модуль, разъём A0 D0 GND VCC — используем A0):
VCC -> 3V3 GND -> GND A0 -> GPIO34
Реле, два одинаковых 2-канальных модуля (разъём GND IN1 IN2 VCC):
VCC -> VIN (5V) GND -> GND (общий с ESP32) VCC -> VIN (5V) GND -> GND (общий с ESP32)
IN1 -> GPIO16/RX2 (light-1) IN2 -> GPIO17/TX2 (pump-1) Модуль №1: IN1 -> GPIO16/RX2 (light-1) IN2 -> GPIO17/TX2 (pump-1)
Модуль №2: IN1 -> GPIO18 (fan-1) IN2 -> свободен
⚠️ Реле коммутируют цепь самой нагрузки (свет/насос/вентилятор, обычно ⚠️ Реле коммутируют цепь самой нагрузки (свет/насос/вентилятор, обычно
12V/220V) через винтовые клеммы COM/NO/NC каждого канала — это 12V/220V) через винтовые клеммы COM/NO/NC каждого канала — это
@@ -102,9 +121,9 @@ DHT22 (готовый модуль, 3 провода):
Каждая плата поднимает у себя простую страницу и JSON API на порту 80 — Каждая плата поднимает у себя простую страницу и JSON API на порту 80 —
удобно наблюдать/управлять локально, не дожидаясь Laravel/MQTT: удобно наблюдать/управлять локально, не дожидаясь Laravel/MQTT:
- `GET /` — HTML-страница: показания DHT22 + кнопки Вкл/Выкл для - `GET /` — HTML-страница: показания DHT22 и почвы + кнопки Вкл/Выкл для
light-1/pump-1/fan-1. light-1/pump-1/fan-1.
- `GET /api/state` — JSON `{"sensor":{"has_reading","temperature","humidity"},"devices":{"light-1":{"power"},...}}`. - `GET /api/state` — JSON `{"sensor":{"has_reading","temperature","humidity"},"soil":{"has_reading","percent"},"devices":{"light-1":{"power"},...}}`.
- `POST /api/command` — JSON-тело `{"device_id","action","level"?}`, тот же - `POST /api/command` — JSON-тело `{"device_id","action","level"?}`, тот же
формат, что и в MQTT-команде. формат, что и в MQTT-команде.
@@ -172,7 +191,9 @@ Arduino IDE (GUI-приложение) это обычно не требуетс
## Проверка end-to-end ## Проверка end-to-end
При работающем `docker compose` в `home_automatization` и засеянных При работающем `docker compose` в `home_automatization` и засеянных
Postgres-данных (зона + `sensor-1`/`light-1`/`pump-1`/`fan-1`): Postgres-данных (зона + `sensor-1`/`light-1`/`pump-1`/`fan-1`; `soil-1` пока
не засеян — его телеметрию ClickHouse примет, но в Laravel UI устройство не
появится, пока не завести `devices`-запись):
- телеметрия должна доходить до ClickHouse через ingest-service; - телеметрия должна доходить до ClickHouse через ingest-service;
- команда через Laravel UI (или напрямую через `grpcurl` в - команда через Laravel UI (или напрямую через `grpcurl` в
+18 -4
View File
@@ -14,8 +14,14 @@
// --- Зона и устройства --- // --- Зона и устройства ---
// zone_id — id зоны "Гроубокс" в Postgres (laravel-app, таблица zones). // zone_id — id зоны "Гроубокс" в Postgres (laravel-app, таблица zones).
// device_id должны совпадать с devices.external_id, засеянными в Laravel. // device_id должны совпадать с devices.external_id, засеянными в Laravel.
// soil-1 — новое устройство, в Laravel/Postgres платформы ещё не заведено
// (device_type "sensor_soil_moisture" + строка в devices нужно добавить
// отдельно, это в this репозитории не трогаем) — до тех пор его телеметрия
// просто не будет ни к чему привязана на стороне платформы, ingest-service
// это переживёт (zone_id едет прямо в payload).
#define ZONE_ID "1" #define ZONE_ID "1"
#define DEVICE_ID_SENSOR "sensor-1" #define DEVICE_ID_SENSOR "sensor-1"
#define DEVICE_ID_SOIL "soil-1"
#define DEVICE_ID_LIGHT "light-1" #define DEVICE_ID_LIGHT "light-1"
#define DEVICE_ID_PUMP "pump-1" #define DEVICE_ID_PUMP "pump-1"
#define DEVICE_ID_FAN "fan-1" #define DEVICE_ID_FAN "fan-1"
@@ -23,14 +29,22 @@
// --- Пины (ESP32 DevKitC) --- // --- Пины (ESP32 DevKitC) ---
// На 30-контактных платах GPIO16/17 часто подписаны на шёлкографии как // На 30-контактных платах GPIO16/17 часто подписаны на шёлкографии как
// RX2/TX2 (пины UART2), а не номерами — это те же GPIO, см. README.md. // RX2/TX2 (пины UART2), а не номерами — это те же GPIO, см. README.md.
#define PIN_DHT22 4 #define PIN_DHT22 4
#define PIN_RELAY_LIGHT 16 #define PIN_SOIL_MOISTURE 34 // ADC1_CH6, input-only — годится для чистого аналогового чтения
#define PIN_RELAY_PUMP 17 #define PIN_RELAY_LIGHT 16
#define PIN_RELAY_FAN 18 #define PIN_RELAY_PUMP 17
#define PIN_RELAY_FAN 18
// Большинство дешёвых реле-модулей активны по LOW (сигнал LOW = реле включено). // Большинство дешёвых реле-модулей активны по LOW (сигнал LOW = реле включено).
#define RELAY_ACTIVE_LOW true #define RELAY_ACTIVE_LOW true
// Калибровка компараторного модуля почвенной влажности (analogRead 0-4095):
// значение на воздухе (сухо) и в воде (максимально мокро). Заводские числа
// ниже — грубая оценка, откалибровать по месту и подставить свои — иначе
// проценты будут врать. Смотреть Serial-лог telemetryTick() (печатает raw).
#define SOIL_RAW_DRY 3200
#define SOIL_RAW_WET 1300
// --- Телеметрия --- // --- Телеметрия ---
#define TELEMETRY_INTERVAL_MS 30000UL #define TELEMETRY_INTERVAL_MS 30000UL
+45 -12
View File
@@ -13,10 +13,25 @@ static bool hasReading = false;
static float lastTemperature = 0; static float lastTemperature = 0;
static float lastHumidity = 0; static float lastHumidity = 0;
static bool hasSoilReading = false;
static float lastSoilPercent = 0;
void telemetryBegin() { void telemetryBegin() {
dht.begin(); dht.begin();
} }
// Компараторный модуль почвенной влажности отдаёт на A0 сырое ADC-значение,
// которое УМЕНЬШАЕТСЯ с ростом влажности (суше — выше сопротивление датчика
// — выше напряжение на A0). SOIL_RAW_DRY/SOIL_RAW_WET в config.h — заводские
// значения-плейсхолдеры, откалибровать по месту (замерить raw на воздухе и
// в воде) через Serial-лог этой функции.
static float rawToSoilPercent(int raw) {
long percent = map(raw, SOIL_RAW_DRY, SOIL_RAW_WET, 0, 100);
if (percent < 0) percent = 0;
if (percent > 100) percent = 100;
return (float)percent;
}
void telemetryTick() { void telemetryTick() {
unsigned long now = millis(); unsigned long now = millis();
if (now - lastPublish < TELEMETRY_INTERVAL_MS && lastPublish != 0) { if (now - lastPublish < TELEMETRY_INTERVAL_MS && lastPublish != 0) {
@@ -32,21 +47,31 @@ void telemetryTick() {
float humidity = dht.readHumidity(); float humidity = dht.readHumidity();
float temperature = dht.readTemperature(); float temperature = dht.readTemperature();
if (isnan(humidity) || isnan(temperature)) { if (isnan(humidity) || isnan(temperature)) {
Serial.println("[telemetry] DHT22 read failed, skipping this cycle"); Serial.println("[telemetry] DHT22 read failed, skipping this sensor this cycle");
return; } else {
lastTemperature = temperature;
lastHumidity = humidity;
hasReading = true;
mqttPublishTelemetry(DEVICE_ID_SENSOR, "temperature", temperature);
mqttPublishTelemetry(DEVICE_ID_SENSOR, "humidity", humidity);
Serial.print("[telemetry] temperature=");
Serial.print(temperature);
Serial.print(" humidity=");
Serial.println(humidity);
} }
lastTemperature = temperature; int soilRaw = analogRead(PIN_SOIL_MOISTURE);
lastHumidity = humidity; lastSoilPercent = rawToSoilPercent(soilRaw);
hasReading = true; hasSoilReading = true;
mqttPublishTelemetry(DEVICE_ID_SOIL, "soil_moisture", lastSoilPercent);
mqttPublishTelemetry(DEVICE_ID_SENSOR, "temperature", temperature); Serial.print("[telemetry] soil_moisture=");
mqttPublishTelemetry(DEVICE_ID_SENSOR, "humidity", humidity); Serial.print(lastSoilPercent);
Serial.print("% (raw=");
Serial.print("[telemetry] temperature="); Serial.print(soilRaw);
Serial.print(temperature); Serial.println(")");
Serial.print(" humidity=");
Serial.println(humidity);
} }
bool telemetryGetLast(float& temperature, float& humidity) { bool telemetryGetLast(float& temperature, float& humidity) {
@@ -57,3 +82,11 @@ bool telemetryGetLast(float& temperature, float& humidity) {
humidity = lastHumidity; humidity = lastHumidity;
return true; return true;
} }
bool telemetryGetLastSoil(float& percent) {
if (!hasSoilReading) {
return false;
}
percent = lastSoilPercent;
return true;
}
+3
View File
@@ -11,3 +11,6 @@ void telemetryTick();
// показывать данные не дожидаясь следующего цикла публикации). // показывать данные не дожидаясь следующего цикла публикации).
// Возвращает false, если ни одного успешного чтения ещё не было. // Возвращает false, если ни одного успешного чтения ещё не было.
bool telemetryGetLast(float& temperature, float& humidity); bool telemetryGetLast(float& temperature, float& humidity);
// Последнее показание почвенной влажности в процентах (0-100).
bool telemetryGetLastSoil(float& percent);
+13 -1
View File
@@ -31,9 +31,12 @@ static const char PAGE_HTML[] PROGMEM = R"HTML(<!DOCTYPE html>
<h1>Гроубокс</h1> <h1>Гроубокс</h1>
<div class="card"> <div class="card">
<div class="row"><span>Температура</span><span id="temp" class="sensor-value">—</span></div> <div class="row"><span>Температура</span><span id="temp" class="sensor-value">—</span></div>
<div class="row"><span>Влажность</span><span id="hum" class="sensor-value">—</span></div> <div class="row"><span>Влажность воздуха</span><span id="hum" class="sensor-value">—</span></div>
<div class="muted" id="updated">нет данных</div> <div class="muted" id="updated">нет данных</div>
</div> </div>
<div class="card">
<div class="row"><span>Влажность почвы</span><span id="soil" class="sensor-value">—</span></div>
</div>
<div class="card" id="actuators"></div> <div class="card" id="actuators"></div>
<script> <script>
const DEVICES = [ const DEVICES = [
@@ -46,6 +49,7 @@ function render(state) {
document.getElementById('temp').textContent = state.sensor.has_reading ? state.sensor.temperature.toFixed(1) + ' °C' : ''; document.getElementById('temp').textContent = state.sensor.has_reading ? state.sensor.temperature.toFixed(1) + ' °C' : '';
document.getElementById('hum').textContent = state.sensor.has_reading ? state.sensor.humidity.toFixed(0) + ' %' : ''; document.getElementById('hum').textContent = state.sensor.has_reading ? state.sensor.humidity.toFixed(0) + ' %' : '';
document.getElementById('updated').textContent = state.sensor.has_reading ? 'данные с DHT22 получены' : 'нет данных с DHT22'; document.getElementById('updated').textContent = state.sensor.has_reading ? 'данные с DHT22 получены' : 'нет данных с DHT22';
document.getElementById('soil').textContent = state.soil.has_reading ? state.soil.percent.toFixed(0) + ' %' : '';
const box = document.getElementById('actuators'); const box = document.getElementById('actuators');
box.innerHTML = ''; box.innerHTML = '';
@@ -100,6 +104,14 @@ static void handleState() {
sensor["humidity"] = humidity; sensor["humidity"] = humidity;
} }
float soilPercent;
bool hasSoilReading = telemetryGetLastSoil(soilPercent);
JsonObject soil = doc["soil"].to<JsonObject>();
soil["has_reading"] = hasSoilReading;
if (hasSoilReading) {
soil["percent"] = soilPercent;
}
JsonObject devicesObj = doc["devices"].to<JsonObject>(); JsonObject devicesObj = doc["devices"].to<JsonObject>();
devicesGetState(devicesObj); devicesGetState(devicesObj);