Author SHA1 Message Date
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
cacto bb85c617a4 Добавлен локальный веб-интерфейс и распиновка в README
Плата поднимает страницу + JSON API (/api/state, /api/command) на порту 80
для наблюдения и управления актуаторами независимо от MQTT/Laravel.
Команда с веб-UI публикует тот же ack в devices/{id}/ack, что и MQTT-команда,
поэтому device shadow платформы не расходится с реальным состоянием.
Включается/выключается флагом ENABLE_WEB_UI в config.h.

В README добавлены ASCII-схема подключения DHT22/реле и раздел про сборку
через arduino-cli (включая обходной путь для проблемы с правами на
~/Documents/Arduino на macOS).
2026-08-11 19:27:41 +05:00
11 changed files with 447 additions and 39 deletions
+158 -25
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,45 +22,178 @@
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, готовый модуль
|-----------------|---------------------| DHT22 с распаянными проводами, компараторный модуль почвенной влажности
| DHT22 data | GPIO4 | (потенциометр + разъём `A0 D0 GND VCC`, используем только `A0`) и **два
| Реле — light-1 | GPIO16 | одинаковых 2-канальных** релейных модуля (SRD-05VDC-SL-C×2 каждый, общий
| Реле — pump-1 | GPIO17 | разъём `GND IN1 IN2 VCC`) — вместе четыре канала на три реле, один канал
| Реле — fan-1 | GPIO18 | второй платы свободен про запас.
Реле-модули по умолчанию считаются активными по 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`/прошивку менять не нужно, важно только не перепутать их при
пайке/монтаже на плате.
## Библиотеки (Arduino Library Manager) Оба релейных модуля (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 (30 pin)
┌───────────────────────┐
DHT22 DATA ───────┤ GPIO4 │
(жёлтый провод) │ │
Почва A0 ─────────┤ GPIO34 │
│ │
Реле №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 (готовый модуль, 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 каждого канала.
```
## Библиотеки
Через Arduino Library Manager (Sketch → Include Library → Manage Libraries):
- `PubSubClient` (Nick O'Leary) — MQTT-клиент - `PubSubClient` (Nick O'Leary) — MQTT-клиент
- `ArduinoJson` (Benoit Blanchon, v7) — сериализация/парсинг JSON - `ArduinoJson` (Benoit Blanchon, v7) — сериализация/парсинг JSON
- `DHT sensor library` (Adafruit) + зависимость `Adafruit Unified Sensor` - `DHT sensor library` (Adafruit) + зависимость `Adafruit Unified Sensor`
- Board support: `esp32` (Espressif Systems) через Boards Manager - Board support: `esp32` (Espressif Systems) через Boards Manager
## Настройка и прошивка `WebServer` (веб-интерфейс) и `WiFi` идут в комплекте с board support
`esp32` — отдельно ставить не нужно.
1. `cp config.h.example config.h`, заполнить Wi-Fi SSID/пароль, IP хоста, на ## Веб-интерфейс
котором поднят `docker compose` платформы (mosquitto слушает `0.0.0.0:1883`
— доступен по IP машины в локальной сети), `zone_id` и `device_id`. Каждая плата поднимает у себя простую страницу и JSON API на порту 80 —
2. Открыть `growbox.ino` в Arduino IDE, выбрать плату (ESP32 Dev Module или удобно наблюдать/управлять локально, не дожидаясь Laravel/MQTT:
конкретную модель), порт.
3. Прошить. Открыть Serial Monitor на 115200 бод — там лог подключения к - `GET /` — HTML-страница: показания DHT22 и почвы + кнопки Вкл/Выкл для
Wi-Fi/NTP/MQTT и каждой публикации телеметрии/команды. light-1/pump-1/fan-1.
- `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-команде.
Команда с веб-UI применяется через тот же код и публикует тот же ack в
`devices/{id}/ack`, что и команда из device-control-service — device shadow
платформы (Redis) не расходится с реальным состоянием железа независимо от
того, кто его переключил.
Включается/выключается флагом `ENABLE_WEB_UI` в `config.h` (`1`/`0`) — при
`0` HTTP-сервер не поднимается вообще, порт 80 не слушается. У сервера нет
авторизации — только для локальной сети, как и mosquitto платформы; для
выхода за пределы LAN сначала нужно добавить хотя бы Basic Auth.
## Настройка и прошивка через 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)
```bash
arduino-cli core install esp32:esp32
arduino-cli lib install "PubSubClient" "ArduinoJson" "DHT sensor library"
arduino-cli compile --fqbn esp32:esp32:esp32 growbox
arduino-cli upload --fqbn esp32:esp32:esp32 -p /dev/cu.usbserial-XXXX growbox
```
На macOS `arduino-cli` по умолчанию ставит библиотеки в
`~/Documents/Arduino/libraries` — если этот каталог защищён sandbox'ом
(Operation not permitted при установке библиотек, встречалось в среде этого
агента), перенаправьте sketchbook в доступное место:
`arduino-cli config set directories.user ~/путь/без/ограничений`. Через саму
Arduino IDE (GUI-приложение) это обычно не требуется — она сама запрашивает
нужные разрешения у macOS.
## Проверка 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` в
+22
View File
@@ -14,14 +14,23 @@
// --- Зона и устройства --- // --- Зона и устройства ---
// 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"
// --- Пины (ESP32 DevKitC) --- // --- Пины (ESP32 DevKitC) ---
// На 30-контактных платах GPIO16/17 часто подписаны на шёлкографии как
// RX2/TX2 (пины UART2), а не номерами — это те же GPIO, см. README.md.
#define PIN_DHT22 4 #define PIN_DHT22 4
#define PIN_SOIL_MOISTURE 34 // ADC1_CH6, input-only — годится для чистого аналогового чтения
#define PIN_RELAY_LIGHT 16 #define PIN_RELAY_LIGHT 16
#define PIN_RELAY_PUMP 17 #define PIN_RELAY_PUMP 17
#define PIN_RELAY_FAN 18 #define PIN_RELAY_FAN 18
@@ -29,5 +38,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
// --- Веб-интерфейс ---
// 1 — поднимать HTTP-сервер на порту 80 (страница + /api/state, /api/command).
// 0 — полностью выключить (сервер не поднимается вообще, порт не слушается).
// Без авторизации — только для локальной сети, см. README.md.
#define ENABLE_WEB_UI 1
+7
View File
@@ -70,3 +70,10 @@ bool devicesApplyCommand(const String& deviceId, const String& action, double le
Serial.println(actuator->on ? "on" : "off"); Serial.println(actuator->on ? "on" : "off");
return true; return true;
} }
void devicesGetState(JsonObject& out) {
for (size_t i = 0; i < actuatorCount; i++) {
JsonObject deviceState = out[actuators[i].deviceId].to<JsonObject>();
deviceState["power"] = actuators[i].on ? "on" : "off";
}
}
+5
View File
@@ -12,3 +12,8 @@ void devicesBegin();
// применённым состоянием для последующей публикации в devices/{id}/ack. // применённым состоянием для последующей публикации в devices/{id}/ack.
// Возвращает false, если deviceId этой плате не принадлежит. // Возвращает false, если deviceId этой плате не принадлежит.
bool devicesApplyCommand(const String& deviceId, const String& action, double level, JsonDocument& outState); bool devicesApplyCommand(const String& deviceId, const String& action, double level, JsonDocument& outState);
// Заполняет out текущим состоянием всех актуаторов этой платы:
// {"light-1":{"power":"on"},"pump-1":{"power":"off"},"fan-1":{"power":"off"}}.
// Нужно веб-интерфейсу, чтобы показать состояние без ожидания следующей команды.
void devicesGetState(JsonObject& out);
+7
View File
@@ -9,6 +9,7 @@
#include "net.h" #include "net.h"
#include "telemetry.h" #include "telemetry.h"
#include "time_sync.h" #include "time_sync.h"
#include "web_ui.h"
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
@@ -18,6 +19,9 @@ void setup() {
timeSyncBegin(); timeSyncBegin();
mqttBegin(); mqttBegin();
telemetryBegin(); telemetryBegin();
#if ENABLE_WEB_UI
webUiBegin();
#endif
} }
void loop() { void loop() {
@@ -25,4 +29,7 @@ void loop() {
mqttEnsureConnected(); mqttEnsureConnected();
mqttLoop(); mqttLoop();
telemetryTick(); telemetryTick();
#if ENABLE_WEB_UI
webUiHandle();
#endif
} }
+2 -2
View File
@@ -19,7 +19,7 @@ static String deviceIdFromCommandTopic(const String& topic) {
return topic.substring(8, topic.length() - 9); // strip "devices/" and "/commands" return topic.substring(8, topic.length() - 9); // strip "devices/" and "/commands"
} }
static void publishAck(const String& deviceId, JsonDocument& state) { void mqttPublishAck(const String& deviceId, JsonDocument& state) {
JsonDocument ackDoc; JsonDocument ackDoc;
ackDoc["state"] = state.as<JsonObject>(); ackDoc["state"] = state.as<JsonObject>();
@@ -54,7 +54,7 @@ static void onMessage(char* topic, byte* payload, unsigned int length) {
JsonDocument state; JsonDocument state;
if (devicesApplyCommand(deviceId, action, level, state)) { if (devicesApplyCommand(deviceId, action, level, state)) {
publishAck(deviceId, state); mqttPublishAck(deviceId, state);
} }
} }
+8
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include <ArduinoJson.h>
// Подключение к mosquitto, подписка на devices/+/commands, диспетчеризация // Подключение к mosquitto, подписка на devices/+/commands, диспетчеризация
// команд в devices.cpp и публикация ack. См. протокол в README.md. // команд в devices.cpp и публикация ack. См. протокол в README.md.
@@ -10,3 +11,10 @@ void mqttLoop();
// Публикует одно показание телеметрии в devices/{deviceId}/telemetry. // Публикует одно показание телеметрии в devices/{deviceId}/telemetry.
void mqttPublishTelemetry(const String& deviceId, const String& sensorType, double value); void mqttPublishTelemetry(const String& deviceId, const String& sensorType, double value);
// Публикует ack {"state": ...} для deviceId. Используется и обработчиком
// MQTT-команд, и веб-интерфейсом — команда, отданная локально через веб-UI,
// должна так же обновлять device shadow платформы (Redis), как и команда
// из device-control-service, иначе состояние в UI Laravel разойдётся с
// реальным железом.
void mqttPublishAck(const String& deviceId, JsonDocument& state);
+53 -3
View File
@@ -9,10 +9,29 @@
static DHT dht(PIN_DHT22, DHT22); static DHT dht(PIN_DHT22, DHT22);
static unsigned long lastPublish = 0; static unsigned long lastPublish = 0;
static bool hasReading = false;
static float lastTemperature = 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) {
@@ -28,9 +47,11 @@ 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, "temperature", temperature);
mqttPublishTelemetry(DEVICE_ID_SENSOR, "humidity", humidity); mqttPublishTelemetry(DEVICE_ID_SENSOR, "humidity", humidity);
@@ -40,3 +61,32 @@ void telemetryTick() {
Serial.print(" humidity="); Serial.print(" humidity=");
Serial.println(humidity); 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;
}
temperature = lastTemperature;
humidity = lastHumidity;
return true;
}
bool telemetryGetLastSoil(float& percent) {
if (!hasSoilReading) {
return false;
}
percent = lastSoilPercent;
return true;
}
+8
View File
@@ -6,3 +6,11 @@ void telemetryBegin();
// Вызывать на каждой итерации loop(): сама решает, пора ли публиковать // Вызывать на каждой итерации loop(): сама решает, пора ли публиковать
// очередное показание (см. TELEMETRY_INTERVAL_MS в config.h). // очередное показание (см. TELEMETRY_INTERVAL_MS в config.h).
void telemetryTick(); void telemetryTick();
// Последнее успешно прочитанное показание DHT22 (для веб-интерфейса —
// показывать данные не дожидаясь следующего цикла публикации).
// Возвращает false, если ни одного успешного чтения ещё не было.
bool telemetryGetLast(float& temperature, float& humidity);
// Последнее показание почвенной влажности в процентах (0-100).
bool telemetryGetLastSoil(float& percent);
+160
View File
@@ -0,0 +1,160 @@
#include "web_ui.h"
#include <ArduinoJson.h>
#include <WebServer.h>
#include "devices.h"
#include "mqtt_link.h"
#include "telemetry.h"
static WebServer server(80);
static const char PAGE_HTML[] PROGMEM = R"HTML(<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Гроубокс</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 420px; margin: 2rem auto; padding: 0 1rem; background:#0f172a; color:#e2e8f0; }
h1 { font-size: 1.25rem; }
.card { background:#1e293b; border-radius: 12px; padding: 1rem; margin-bottom: 1rem; }
.row { display:flex; justify-content:space-between; align-items:center; margin: .5rem 0; }
button { border:0; border-radius:8px; padding:.5rem 1.1rem; font-size:1rem; cursor:pointer; }
.on { background:#22c55e; color:#052e16; }
.off { background:#334155; color:#e2e8f0; }
.sensor-value { font-size:1.4rem; font-weight:600; }
.muted { color:#94a3b8; font-size:.85rem; }
</style>
</head>
<body>
<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="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 = [
{ id: 'light-1', label: 'Свет' },
{ id: 'pump-1', label: 'Насос' },
{ id: 'fan-1', label: 'Вентилятор' },
];
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 = '';
DEVICES.forEach(function (d) {
const on = state.devices[d.id] && state.devices[d.id].power === 'on';
const row = document.createElement('div');
row.className = 'row';
const label = document.createElement('span');
label.textContent = d.label;
const btn = document.createElement('button');
btn.className = on ? 'on' : 'off';
btn.textContent = on ? 'Вкл' : 'Выкл';
btn.onclick = function () { sendCommand(d.id, on ? 'turn_off' : 'turn_on'); };
row.appendChild(label);
row.appendChild(btn);
box.appendChild(row);
});
}
function sendCommand(deviceId, action) {
fetch('/api/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: deviceId, action: action }),
}).then(refresh);
}
function refresh() {
fetch('/api/state').then(function (r) { return r.json(); }).then(render);
}
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
)HTML";
static void handleRoot() {
server.send_P(200, "text/html", PAGE_HTML);
}
static void handleState() {
JsonDocument doc;
float temperature, humidity;
bool hasReading = telemetryGetLast(temperature, humidity);
JsonObject sensor = doc["sensor"].to<JsonObject>();
sensor["has_reading"] = hasReading;
if (hasReading) {
sensor["temperature"] = temperature;
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);
String body;
serializeJson(doc, body);
server.send(200, "application/json", body);
}
static void handleCommand() {
JsonDocument cmd;
DeserializationError parseErr = deserializeJson(cmd, server.arg("plain"));
if (parseErr) {
server.send(400, "application/json", "{\"error\":\"invalid json\"}");
return;
}
String deviceId = cmd["device_id"] | "";
String action = cmd["action"] | "";
double level = cmd["level"] | 0.0;
JsonDocument state;
// Тот же путь применения команды и публикации ack, что и у MQTT-команд
// (см. mqtt_link.cpp) — device shadow платформы остаётся консистентным
// независимо от того, кто отдал команду: Laravel/rule-engine или веб-UI.
if (!devicesApplyCommand(deviceId, action, level, state)) {
server.send(404, "application/json", "{\"error\":\"unknown device or action\"}");
return;
}
mqttPublishAck(deviceId, state);
String body;
serializeJson(state, body);
server.send(200, "application/json", body);
}
void webUiBegin() {
server.on("/", HTTP_GET, handleRoot);
server.on("/api/state", HTTP_GET, handleState);
server.on("/api/command", HTTP_POST, handleCommand);
server.begin();
Serial.println("[web] UI started on port 80");
}
void webUiHandle() {
server.handleClient();
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
// Локальный веб-интерфейс на самой плате: страница со статусом и кнопками
// управления + JSON API (GET /api/state, POST /api/command). Включается
// флагом ENABLE_WEB_UI в config.h (0, чтобы полностью выключить — тогда
// webUiBegin()/webUiHandle() не вызываются вовсе, HTTP-сервер не поднимается).
void webUiBegin();
void webUiHandle();