8 Commits
Author SHA1 Message Date
cacto 4c44adc4a3 Мёрж feature/mqtt-reconnect-throttle: не блокировать loop() без брокера + доки по автономной работе 2026-08-18 13:09:59 +05:00
cacto 988c574183 Не блокировать loop() бесконечными попытками подключения к MQTT
mqttClient.connect() блокирует на время TCP-таймаута, если брокер
недоступен. Без паузы между попытками это происходило на каждой итерации
loop() — веб-интерфейс и опрос датчиков/реле (не зависят от MQTT и должны
работать даже без платформы) подвисали. Теперь переподключение пробуется не
чаще раза в 5 секунд.

В README добавлен раздел "Автономная работа без платформы" — что именно
работает локально без docker compose, а что требует брокера.
2026-08-18 13:09:15 +05:00
cacto 138caef3ee Мёрж feature/soil-moisture-sensor: датчик влажности почвы + второй релейный модуль 2026-08-18 13:04:17 +05:00
cacto 6a7d512b22 Датчик почвенной влажности soil-1 + второй релейный модуль под fan-1
По фото: у нас два одинаковых 2-канальных релейных модуля (не один) —
второй закрывает fan-1 по-настоящему (GPIO18), больше не "пока не куплено".

Добавлен soil-1: компараторный модуль почвенной влажности, читаем A0 через
ADC (GPIO34, input-only), публикуем sensor_type=soil_moisture в процентах.
Raw->% калибруется через SOIL_RAW_DRY/SOIL_RAW_WET в config.h — заводские
значения приблизительные, точную калибровку раньше сделать было нельзя (не
было физического доступа к датчику), нужно измерить на месте.

soil-1 в Postgres/Laravel платформы ещё не заведён (не трогаем
home_automatization) — телеметрия будет идти в ClickHouse, но в Laravel UI
устройство не появится до отдельной регистрации device_type/devices-записи.

Веб-интерфейс и /api/state дополнены полем "soil".
2026-08-18 12:57:49 +05:00
cacto 0456d4e447 Мёрж feature/arduino-ide-guide: инструкция по Arduino IDE и уточнения по реальному железу 2026-08-18 04:23:07 +05:00
cacto 4fcca2701b Уточнить README по фото реального комплекта железа
По факту: 30-контактная ESP32 (GPIO16/17 подписаны RX2/TX2, не номерами),
готовый модуль DHT22 с проводами оранжевый/жёлтый/зелёный (VCC/DATA/GND),
один 2-канальный релейный модуль (SRD-05VDC-SL-C×2, общий разъём
GND/IN1/IN2/VCC) — закрывает sensor-1/light-1/pump-1. Канал под fan-1 в
прошивке заложен (GPIO18), но физически ещё не куплен — явно отмечено.

Пины в config.h.example не менялись, только комментарий про RX2/TX2.
2026-08-18 04:18:18 +05:00
cacto 51c46bf8d6 Пошаговая инструкция по заливке через Arduino IDE в README
Разбита на конкретные шаги (Board Manager URL, ядро, библиотеки, конфиг,
выбор платы/порта, заливка) + типовые проблемы с портом/заливкой и как их
решить. Раньше раздел был в три пункта и не хватало конкретики для первой
прошивки.
2026-08-18 03:59:24 +05:00
cacto f4150ac1de Мёрж feature/web-ui: веб-интерфейс и распиновка в README 2026-08-11 19:28:18 +05:00
6 changed files with 214 additions and 58 deletions
+117 -40
View File
@@ -1,9 +1,9 @@
# growbox (ESP32-контроллер)
Прошивка для реального ESP32, обслуживающая зону "Гроубокс" платформы
[home_automatization](../../home_automatization). Одна плата владеет четырьмя
устройствами: `sensor-1` (DHT22) и три реле-актуатора — `light-1`, `pump-1`,
`fan-1`.
[home_automatization](../../home_automatization). Одна плата владеет пятью
устройствами: `sensor-1` (DHT22, температура/влажность воздуха), `soil-1`
(влажность почвы) и три реле-актуатора — `light-1`, `pump-1`, `fan-1`.
## Протокол (MQTT, mosquitto из docker-compose платформы, порт 1883)
@@ -22,51 +22,86 @@
device_id в конфиге должны совпадать с `devices.external_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 за ним никто не ходит).
## Железо
| Назначение | Пин (по умолчанию) |
|-----------------|---------------------|
| DHT22 data | GPIO4 |
| Реле — light-1 | GPIO16 |
| Реле — pump-1 | GPIO17 |
| Реле — fan-1 | GPIO18 |
По фото реального комплекта: 30-контактная ESP32 DevKit, готовый модуль
DHT22 с распаянными проводами, компараторный модуль почвенной влажности
(потенциометр + разъём `A0 D0 GND VCC`, используем только `A0`) и **два
одинаковых 2-канальных** релейных модуля (SRD-05VDC-SL-C×2 каждый, общий
разъём `GND IN1 IN2 VCC`) — вместе четыре канала на три реле, один канал
второй платы свободен про запас.
Реле-модули по умолчанию считаются активными по LOW (`RELAY_ACTIVE_LOW` в
`config.h`) — типично для дешёвых китайских модулей на оптопаре. Если ваши
реле активны по HIGH — поменяйте флаг.
| Назначение | Пин (по умолчанию) | Примечание |
|--------------------------|---------------------|------------|
| DHT22 DATA | GPIO4 | провод жёлтый на нашем модуле |
| Почва, A0 | GPIO34 | ADC1_CH6, input-only; нужна калибровка `SOIL_RAW_DRY`/`SOIL_RAW_WET` |
| Реле №1, IN1 — light-1 | GPIO16 | **на шёлкографии платы подписан `RX2`**, не «16» |
| Реле №1, IN2 — pump-1 | GPIO17 | **на шёлкографии платы подписан `TX2`**, не «17» |
| Реле №2, IN1 — fan-1 | GPIO18 | второй такой же модуль |
| Реле №2, IN2 | — | свободный канал, не задействован |
DHT22 требует подтягивающий резистор ~10кОм между data и VCC, если он не
встроен в конкретный модуль.
На этой конкретной 30-пиновой плате GPIO16/17 не подписаны номерами — они
выведены как пины UART2 и промаркированы `RX2`/`TX2`. Физически это те же
GPIO, `config.h`/прошивку менять не нужно, важно только не перепутать их при
пайке/монтаже на плате.
Оба релейных модуля (2-канальные, SRD-05VDC-SL-C) активны по LOW —
`RELAY_ACTIVE_LOW` в `config.h` уже стоит в `true`. Перемычка `JD-VCC` на
каждом модуле по умолчанию установлена (питание катушек реле — от той же
VCC, без гальванической развязки) — для этого проекта этого достаточно,
снимать её незачем.
DHT22 требует подтягивающий резистор ~10кОм между DATA и VCC — на нашем
модуле, похоже, уже встроен (компактная плата-переходник с 3 проводами), но
если показания не читаются — проверьте отдельным резистором.
Модуль почвенной влажности — сырое ADC-значение на `A0` **уменьшается** с
ростом влажности (суше — выше сопротивление датчика — выше напряжение).
Заводские `SOIL_RAW_DRY`/`SOIL_RAW_WET` в `config.h.example` — грубая оценка,
не калибровка: замерьте raw-значение на воздухе и после погружения щупа в
воду (печатается в Serial-логе `telemetryTick()`) и подставьте свои числа,
иначе проценты будут врать.
### Схема подключения
```
ESP32 DevKitC
ESP32 (30 pin)
┌───────────────────────┐
DHT22 DATA ───────┤ GPIO4 │
(+10к подтяжка │ │
DATA -> 3V3) │
(жёлтый провод) │ │
Почва A0 ─────────┤ GPIO34
│ │
Реле LIGHT IN ────┤ GPIO16 │
Реле PUMP IN ────┤ GPIO17 │
Реле FAN IN ────┤ GPIO18 │
Реле №1 IN1 (light-1) ┤ GPIO16 (на плате: RX2)
Реле №1 IN2 (pump-1) ┤ GPIO17 (на плате: TX2)
Реле №2 IN1 (fan-1) ┤ GPIO18
Реле №2 IN2 (свободно) ┤ —
│ │
3V3 ────┤ 3V3 │
GND ────┤ GND │
USB/5V ─┤ VIN │
└───────────────────────┘
DHT22: VCC -> 3V3 GND -> GND DATA -> GPIO4
Реле-модуль: VCC -> 5V (VIN) или 3V3 (смотри маркировку модуля)
GND -> GND (общий с ESP32)
IN -> GPIO16 / GPIO17 / GPIO18
DHT22 (готовый модуль, 3 провода):
оранжевый(+/VCC) -> 3V3 жёлтый(out/DATA) -> GPIO4 зелёный(-/GND) -> GND
Почва (компараторный модуль, разъём A0 D0 GND VCC — используем A0):
VCC -> 3V3 GND -> GND A0 -> GPIO34
Реле, два одинаковых 2-канальных модуля (разъём GND IN1 IN2 VCC):
VCC -> VIN (5V) GND -> GND (общий с ESP32)
Модуль №1: IN1 -> GPIO16/RX2 (light-1) IN2 -> GPIO17/TX2 (pump-1)
Модуль №2: IN1 -> GPIO18 (fan-1) IN2 -> свободен
⚠️ Реле коммутируют цепь самой нагрузки (свет/насос/вентилятор, обычно
12V/220V) через контакты COM/NO/NC — это ОТДЕЛЬНАЯ силовая цепь со своим
источником питания, к GPIO ESP32 она не подключается. К плате идёт
только сигнальный IN каждого реле-модуля.
12V/220V) через винтовые клеммы COM/NO/NC каждого канала — это
ОТДЕЛЬНАЯ силовая цепь со своим источником питания, к GPIO ESP32 она не
подключается. К плате идёт только сигнальный IN каждого канала.
```
## Библиотеки
@@ -86,9 +121,9 @@ DHT22: VCC -> 3V3 GND -> GND DATA -> GPIO4
Каждая плата поднимает у себя простую страницу и JSON API на порту 80 —
удобно наблюдать/управлять локально, не дожидаясь Laravel/MQTT:
- `GET /` — HTML-страница: показания DHT22 + кнопки Вкл/Выкл для
- `GET /` — HTML-страница: показания DHT22 и почвы + кнопки Вкл/Выкл для
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"?}`, тот же
формат, что и в MQTT-команде.
@@ -102,16 +137,56 @@ DHT22: VCC -> 3V3 GND -> GND DATA -> GPIO4
авторизации — только для локальной сети, как и mosquitto платформы; для
выхода за пределы LAN сначала нужно добавить хотя бы Basic Auth.
## Настройка и прошивка
## Автономная работа без платформы
1. `cp config.h.example config.h`, заполнить Wi-Fi SSID/пароль, IP хоста, на
котором поднят `docker compose` платформы (mosquitto слушает `0.0.0.0:1883`
— доступен по IP машины в локальной сети), `zone_id` и `device_id`.
2. Открыть `growbox.ino` в Arduino IDE, выбрать плату (ESP32 Dev Module или
конкретную модель), порт.
3. Прошить. Открыть Serial Monitor на 115200 бод — там лог подключения к
Wi-Fi/NTP/MQTT/веб-сервера и каждой публикации телеметрии/команды, а
также IP платы для похода в веб-интерфейс.
Плате не нужен запущенный `docker compose` (`home_automatization`), чтобы
показывать датчики и включать/выключать свет/насос/вентилятор — для этого
достаточно Wi-Fi и веб-интерфейса выше (`http://<ip-платы>/`). MQTT-брокер
нужен только для синхронизации с платформой (телеметрия в ClickHouse,
команды из Laravel/rule-engine, device shadow в Redis) — если он недоступен,
эта синхронизация просто не происходит, но локальное управление продолжает
работать.
Важная деталь реализации: `mqttEnsureConnected()` пробует переподключиться к
брокеру не чаще раза в 5 секунд (`RECONNECT_INTERVAL_MS` в `mqtt_link.cpp`)
— без этой паузы блокирующий `mqttClient.connect()` при недоступном брокере
съедал бы почти весь `loop()` на каждой итерации, и веб-интерфейс с опросом
датчиков подвисали бы. Serial-лог в этом случае пишет `failed, rc=...` раз в
5 секунд — это ожидаемо, если платформа сейчас не поднята, а не поломка.
## Настройка и прошивка через Arduino IDE
1. **Поддержка ESP32.** Arduino IDE → Settings → «Additional boards manager
URLs» → добавить:
`https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json`
2. **Ядро.** Tools → Board → Boards Manager → найти `esp32` → установить
пакет `esp32 by Espressif Systems`.
3. **Библиотеки.** Tools → Manage Libraries → поставить по одной:
`PubSubClient`, `ArduinoJson` (v7), `DHT sensor library` (IDE предложит
доустановить зависимость `Adafruit Unified Sensor` — согласиться).
4. **Скетч.** File → Open → `controllers/growbox/growbox.ino` — остальные
`.h`/`.cpp` подхватятся вкладками автоматически.
5. **Конфиг.** `cp config.h.example config.h`, заполнить Wi-Fi SSID/пароль,
IP хоста с поднятым `docker compose` платформы (mosquitto слушает
`0.0.0.0:1883` — доступен по IP машины в локальной сети), `zone_id` и
`device_id`. Если IDE уже была открыта — переоткрыть скетч.
6. **Плата и порт.** Подключить ESP32 по USB (кабель именно с передачей
данных). Tools → Board → esp32 → `ESP32 Dev Module` (или точная модель).
Tools → Port → появившийся `/dev/cu.usbserial-*` /
`/dev/cu.SLAB_USBtoUART`.
7. **Заливка.** Кнопка Upload. Часть плат просит зажать кнопку `BOOT` на
плате до появления точек соединения в логе.
8. **Проверка.** Serial Monitor, 115200 бод — лог подключения к
Wi-Fi/NTP/MQTT/веб-сервера, публикации телеметрии/команд и IP платы для
похода в веб-интерфейс.
**Если не заливается:**
- порта нет в списке — нужен драйвер USB-UART моста платы (CP2102 обычно
ставится сам на современной macOS, CH340 — вручную);
- `Failed to connect to ESP32` / `No serial data received` — зажать `BOOT`
на плате на время заливки;
- заливка обрывается или очень долгая — Tools → Upload Speed → понизить с
921600 до 115200.
### Сборка через `arduino-cli` (если не через IDE)
@@ -133,7 +208,9 @@ Arduino IDE (GUI-приложение) это обычно не требуетс
## Проверка end-to-end
При работающем `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;
- команда через Laravel UI (или напрямую через `grpcurl` в
+16
View File
@@ -14,14 +14,23 @@
// --- Зона и устройства ---
// zone_id — id зоны "Гроубокс" в Postgres (laravel-app, таблица zones).
// 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 DEVICE_ID_SENSOR "sensor-1"
#define DEVICE_ID_SOIL "soil-1"
#define DEVICE_ID_LIGHT "light-1"
#define DEVICE_ID_PUMP "pump-1"
#define DEVICE_ID_FAN "fan-1"
// --- Пины (ESP32 DevKitC) ---
// На 30-контактных платах GPIO16/17 часто подписаны на шёлкографии как
// RX2/TX2 (пины UART2), а не номерами — это те же GPIO, см. README.md.
#define PIN_DHT22 4
#define PIN_SOIL_MOISTURE 34 // ADC1_CH6, input-only — годится для чистого аналогового чтения
#define PIN_RELAY_LIGHT 16
#define PIN_RELAY_PUMP 17
#define PIN_RELAY_FAN 18
@@ -29,6 +38,13 @@
// Большинство дешёвых реле-модулей активны по LOW (сигнал LOW = реле включено).
#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
+16 -1
View File
@@ -63,11 +63,25 @@ void mqttBegin() {
mqttClient.setCallback(onMessage);
}
// mqttClient.connect() блокирует на время TCP-таймаута, если брокер
// недоступен. Без паузы между попытками это съедало бы почти весь loop() —
// а значит и веб-интерфейс, и опрос датчиков/реле, которые от MQTT не
// зависят и обязаны работать даже без связи с платформой (см. README,
// "Автономная работа без платформы").
static unsigned long lastConnectAttempt = 0;
static const unsigned long RECONNECT_INTERVAL_MS = 5000;
void mqttEnsureConnected() {
if (mqttClient.connected()) {
return;
}
unsigned long now = millis();
if (now - lastConnectAttempt < RECONNECT_INTERVAL_MS && lastConnectAttempt != 0) {
return;
}
lastConnectAttempt = now;
Serial.print("[mqtt] connecting to ");
Serial.print(MQTT_BROKER_HOST);
if (mqttClient.connect(MQTT_CLIENT_ID)) {
@@ -75,7 +89,8 @@ void mqttEnsureConnected() {
mqttClient.subscribe("devices/+/commands", 1);
} else {
Serial.print(" failed, rc=");
Serial.println(mqttClient.state());
Serial.print(mqttClient.state());
Serial.println(" (это ок, если платформа сейчас не поднята — веб-интерфейс и реле работают локально)");
}
}
+37 -4
View File
@@ -13,10 +13,25 @@ static bool hasReading = false;
static float lastTemperature = 0;
static float lastHumidity = 0;
static bool hasSoilReading = false;
static float lastSoilPercent = 0;
void telemetryBegin() {
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() {
unsigned long now = millis();
if (now - lastPublish < TELEMETRY_INTERVAL_MS && lastPublish != 0) {
@@ -32,10 +47,8 @@ void telemetryTick() {
float humidity = dht.readHumidity();
float temperature = dht.readTemperature();
if (isnan(humidity) || isnan(temperature)) {
Serial.println("[telemetry] DHT22 read failed, skipping this cycle");
return;
}
Serial.println("[telemetry] DHT22 read failed, skipping this sensor this cycle");
} else {
lastTemperature = temperature;
lastHumidity = humidity;
hasReading = true;
@@ -49,6 +62,18 @@ void telemetryTick() {
Serial.println(humidity);
}
int soilRaw = analogRead(PIN_SOIL_MOISTURE);
lastSoilPercent = rawToSoilPercent(soilRaw);
hasSoilReading = true;
mqttPublishTelemetry(DEVICE_ID_SOIL, "soil_moisture", lastSoilPercent);
Serial.print("[telemetry] soil_moisture=");
Serial.print(lastSoilPercent);
Serial.print("% (raw=");
Serial.print(soilRaw);
Serial.println(")");
}
bool telemetryGetLast(float& temperature, float& humidity) {
if (!hasReading) {
return false;
@@ -57,3 +82,11 @@ bool telemetryGetLast(float& temperature, float& humidity) {
humidity = lastHumidity;
return true;
}
bool telemetryGetLastSoil(float& percent) {
if (!hasSoilReading) {
return false;
}
percent = lastSoilPercent;
return true;
}
+3
View File
@@ -11,3 +11,6 @@ void telemetryTick();
// показывать данные не дожидаясь следующего цикла публикации).
// Возвращает false, если ни одного успешного чтения ещё не было.
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>
<div class="card">
<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>
<div class="card">
<div class="row"><span>Влажность почвы</span><span id="soil" class="sensor-value">—</span></div>
</div>
<div class="card" id="actuators"></div>
<script>
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('hum').textContent = state.sensor.has_reading ? state.sensor.humidity.toFixed(0) + ' %' : '';
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');
box.innerHTML = '';
@@ -100,6 +104,14 @@ static void handleState() {
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>();
devicesGetState(devicesObj);