From bb85c617a4420760ef2f4e17c4d6102e668bc5a0 Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Tue, 11 Aug 2026 19:27:41 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=B2=D0=B5=D0=B1-=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=20=D0=B8=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B2=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Плата поднимает страницу + 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). --- growbox/README.md | 77 +++++++++++++++++++- growbox/config.h.example | 6 ++ growbox/devices.cpp | 7 ++ growbox/devices.h | 5 ++ growbox/growbox.ino | 7 ++ growbox/mqtt_link.cpp | 4 +- growbox/mqtt_link.h | 8 +++ growbox/telemetry.cpp | 17 +++++ growbox/telemetry.h | 5 ++ growbox/web_ui.cpp | 148 +++++++++++++++++++++++++++++++++++++++ growbox/web_ui.h | 8 +++ 11 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 growbox/web_ui.cpp create mode 100644 growbox/web_ui.h diff --git a/growbox/README.md b/growbox/README.md index cfffddc..1d53829 100644 --- a/growbox/README.md +++ b/growbox/README.md @@ -40,13 +40,68 @@ Laravel (`DemoGrowboxSeeder` использует `sensor-1`/`fan-1`), а `zone_ DHT22 требует подтягивающий резистор ~10кОм между data и VCC, если он не встроен в конкретный модуль. -## Библиотеки (Arduino Library Manager) +### Схема подключения + +``` + ESP32 DevKitC + ┌───────────────────────┐ + DHT22 DATA ───────┤ GPIO4 │ + (+10к подтяжка │ │ + DATA -> 3V3) │ │ + │ │ + Реле LIGHT IN ────┤ GPIO16 │ + Реле PUMP IN ────┤ GPIO17 │ + Реле FAN IN ────┤ GPIO18 │ + │ │ + 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 + +⚠️ Реле коммутируют цепь самой нагрузки (свет/насос/вентилятор, обычно + 12V/220V) через контакты COM/NO/NC — это ОТДЕЛЬНАЯ силовая цепь со своим + источником питания, к GPIO ESP32 она не подключается. К плате идёт + только сигнальный IN каждого реле-модуля. +``` + +## Библиотеки + +Через Arduino Library Manager (Sketch → Include Library → Manage Libraries): - `PubSubClient` (Nick O'Leary) — MQTT-клиент - `ArduinoJson` (Benoit Blanchon, v7) — сериализация/парсинг JSON - `DHT sensor library` (Adafruit) + зависимость `Adafruit Unified Sensor` - Board support: `esp32` (Espressif Systems) через Boards Manager +`WebServer` (веб-интерфейс) и `WiFi` идут в комплекте с board support +`esp32` — отдельно ставить не нужно. + +## Веб-интерфейс + +Каждая плата поднимает у себя простую страницу и JSON API на порту 80 — +удобно наблюдать/управлять локально, не дожидаясь Laravel/MQTT: + +- `GET /` — HTML-страница: показания DHT22 + кнопки Вкл/Выкл для + light-1/pump-1/fan-1. +- `GET /api/state` — JSON `{"sensor":{"has_reading","temperature","humidity"},"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. + ## Настройка и прошивка 1. `cp config.h.example config.h`, заполнить Wi-Fi SSID/пароль, IP хоста, на @@ -55,7 +110,25 @@ DHT22 требует подтягивающий резистор ~10кОм ме 2. Открыть `growbox.ino` в Arduino IDE, выбрать плату (ESP32 Dev Module или конкретную модель), порт. 3. Прошить. Открыть Serial Monitor на 115200 бод — там лог подключения к - Wi-Fi/NTP/MQTT и каждой публикации телеметрии/команды. + Wi-Fi/NTP/MQTT/веб-сервера и каждой публикации телеметрии/команды, а + также IP платы для похода в веб-интерфейс. + +### Сборка через `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 diff --git a/growbox/config.h.example b/growbox/config.h.example index 83896b0..dcb8431 100644 --- a/growbox/config.h.example +++ b/growbox/config.h.example @@ -31,3 +31,9 @@ // --- Телеметрия --- #define TELEMETRY_INTERVAL_MS 30000UL + +// --- Веб-интерфейс --- +// 1 — поднимать HTTP-сервер на порту 80 (страница + /api/state, /api/command). +// 0 — полностью выключить (сервер не поднимается вообще, порт не слушается). +// Без авторизации — только для локальной сети, см. README.md. +#define ENABLE_WEB_UI 1 diff --git a/growbox/devices.cpp b/growbox/devices.cpp index 6ada863..402eab7 100644 --- a/growbox/devices.cpp +++ b/growbox/devices.cpp @@ -70,3 +70,10 @@ bool devicesApplyCommand(const String& deviceId, const String& action, double le Serial.println(actuator->on ? "on" : "off"); return true; } + +void devicesGetState(JsonObject& out) { + for (size_t i = 0; i < actuatorCount; i++) { + JsonObject deviceState = out[actuators[i].deviceId].to(); + deviceState["power"] = actuators[i].on ? "on" : "off"; + } +} diff --git a/growbox/devices.h b/growbox/devices.h index fa935c6..6866cf0 100644 --- a/growbox/devices.h +++ b/growbox/devices.h @@ -12,3 +12,8 @@ void devicesBegin(); // применённым состоянием для последующей публикации в devices/{id}/ack. // Возвращает false, если deviceId этой плате не принадлежит. 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); diff --git a/growbox/growbox.ino b/growbox/growbox.ino index 1ff32dc..fb44130 100644 --- a/growbox/growbox.ino +++ b/growbox/growbox.ino @@ -9,6 +9,7 @@ #include "net.h" #include "telemetry.h" #include "time_sync.h" +#include "web_ui.h" void setup() { Serial.begin(115200); @@ -18,6 +19,9 @@ void setup() { timeSyncBegin(); mqttBegin(); telemetryBegin(); +#if ENABLE_WEB_UI + webUiBegin(); +#endif } void loop() { @@ -25,4 +29,7 @@ void loop() { mqttEnsureConnected(); mqttLoop(); telemetryTick(); +#if ENABLE_WEB_UI + webUiHandle(); +#endif } diff --git a/growbox/mqtt_link.cpp b/growbox/mqtt_link.cpp index 4efb0af..f552601 100644 --- a/growbox/mqtt_link.cpp +++ b/growbox/mqtt_link.cpp @@ -19,7 +19,7 @@ static String deviceIdFromCommandTopic(const String& topic) { 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; ackDoc["state"] = state.as(); @@ -54,7 +54,7 @@ static void onMessage(char* topic, byte* payload, unsigned int length) { JsonDocument state; if (devicesApplyCommand(deviceId, action, level, state)) { - publishAck(deviceId, state); + mqttPublishAck(deviceId, state); } } diff --git a/growbox/mqtt_link.h b/growbox/mqtt_link.h index f042012..e07b8f7 100644 --- a/growbox/mqtt_link.h +++ b/growbox/mqtt_link.h @@ -1,6 +1,7 @@ #pragma once #include +#include // Подключение к mosquitto, подписка на devices/+/commands, диспетчеризация // команд в devices.cpp и публикация ack. См. протокол в README.md. @@ -10,3 +11,10 @@ void mqttLoop(); // Публикует одно показание телеметрии в devices/{deviceId}/telemetry. 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); diff --git a/growbox/telemetry.cpp b/growbox/telemetry.cpp index 33d7575..ba41a5c 100644 --- a/growbox/telemetry.cpp +++ b/growbox/telemetry.cpp @@ -9,6 +9,10 @@ static DHT dht(PIN_DHT22, DHT22); static unsigned long lastPublish = 0; +static bool hasReading = false; +static float lastTemperature = 0; +static float lastHumidity = 0; + void telemetryBegin() { dht.begin(); } @@ -32,6 +36,10 @@ void telemetryTick() { return; } + lastTemperature = temperature; + lastHumidity = humidity; + hasReading = true; + mqttPublishTelemetry(DEVICE_ID_SENSOR, "temperature", temperature); mqttPublishTelemetry(DEVICE_ID_SENSOR, "humidity", humidity); @@ -40,3 +48,12 @@ void telemetryTick() { Serial.print(" humidity="); Serial.println(humidity); } + +bool telemetryGetLast(float& temperature, float& humidity) { + if (!hasReading) { + return false; + } + temperature = lastTemperature; + humidity = lastHumidity; + return true; +} diff --git a/growbox/telemetry.h b/growbox/telemetry.h index 48a020c..eac163c 100644 --- a/growbox/telemetry.h +++ b/growbox/telemetry.h @@ -6,3 +6,8 @@ void telemetryBegin(); // Вызывать на каждой итерации loop(): сама решает, пора ли публиковать // очередное показание (см. TELEMETRY_INTERVAL_MS в config.h). void telemetryTick(); + +// Последнее успешно прочитанное показание DHT22 (для веб-интерфейса — +// показывать данные не дожидаясь следующего цикла публикации). +// Возвращает false, если ни одного успешного чтения ещё не было. +bool telemetryGetLast(float& temperature, float& humidity); diff --git a/growbox/web_ui.cpp b/growbox/web_ui.cpp new file mode 100644 index 0000000..3e8418b --- /dev/null +++ b/growbox/web_ui.cpp @@ -0,0 +1,148 @@ +#include "web_ui.h" + +#include +#include + +#include "devices.h" +#include "mqtt_link.h" +#include "telemetry.h" + +static WebServer server(80); + +static const char PAGE_HTML[] PROGMEM = R"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(); + sensor["has_reading"] = hasReading; + if (hasReading) { + sensor["temperature"] = temperature; + sensor["humidity"] = humidity; + } + + JsonObject devicesObj = doc["devices"].to(); + 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(); +} diff --git a/growbox/web_ui.h b/growbox/web_ui.h new file mode 100644 index 0000000..9338ed4 --- /dev/null +++ b/growbox/web_ui.h @@ -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();