From a6457379274846e18e4843e4d101128014a681be Mon Sep 17 00:00:00 2001 From: Dmitry Gammel Date: Tue, 11 Aug 2026 18:46:37 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B0=D1=87=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81:=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=88=D0=B8=D0=B2=D0=BA=D0=B0=20ESP32=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B7=D0=BE=D0=BD=D1=8B=20"=D0=93=D1=80=D0=BE?= =?UTF-8?q?=D1=83=D0=B1=D0=BE=D0=BA=D1=81"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализованы sensor-1 (DHT22, телеметрия temperature/humidity) и реле-актуаторы light-1/pump-1/fan-1 (turn_on/turn_off/set_level) по протоколу MQTT платформы home_automatization (devices/{id}/telemetry|commands|ack). --- growbox/.gitignore | 3 ++ growbox/README.md | 68 +++++++++++++++++++++++++++ growbox/config.h.example | 33 ++++++++++++++ growbox/devices.cpp | 72 +++++++++++++++++++++++++++++ growbox/devices.h | 14 ++++++ growbox/growbox.ino | 28 ++++++++++++ growbox/mqtt_link.cpp | 99 ++++++++++++++++++++++++++++++++++++++++ growbox/mqtt_link.h | 12 +++++ growbox/net.cpp | 40 ++++++++++++++++ growbox/net.h | 5 ++ growbox/telemetry.cpp | 42 +++++++++++++++++ growbox/telemetry.h | 8 ++++ growbox/time_sync.cpp | 47 +++++++++++++++++++ growbox/time_sync.h | 11 +++++ 14 files changed, 482 insertions(+) create mode 100644 growbox/.gitignore create mode 100644 growbox/README.md create mode 100644 growbox/config.h.example create mode 100644 growbox/devices.cpp create mode 100644 growbox/devices.h create mode 100644 growbox/growbox.ino create mode 100644 growbox/mqtt_link.cpp create mode 100644 growbox/mqtt_link.h create mode 100644 growbox/net.cpp create mode 100644 growbox/net.h create mode 100644 growbox/telemetry.cpp create mode 100644 growbox/telemetry.h create mode 100644 growbox/time_sync.cpp create mode 100644 growbox/time_sync.h diff --git a/growbox/.gitignore b/growbox/.gitignore new file mode 100644 index 0000000..3fe04cc --- /dev/null +++ b/growbox/.gitignore @@ -0,0 +1,3 @@ +config.h +build/ +*.bin diff --git a/growbox/README.md b/growbox/README.md new file mode 100644 index 0000000..cfffddc --- /dev/null +++ b/growbox/README.md @@ -0,0 +1,68 @@ +# growbox (ESP32-контроллер) + +Прошивка для реального ESP32, обслуживающая зону "Гроубокс" платформы +[home_automatization](../../home_automatization). Одна плата владеет четырьмя +устройствами: `sensor-1` (DHT22) и три реле-актуатора — `light-1`, `pump-1`, +`fan-1`. + +## Протокол (MQTT, mosquitto из docker-compose платформы, порт 1883) + +- `devices/{device_id}/telemetry` — публикует контроллер, JSON + `{"device_id","zone_id","sensor_type","value","timestamp"}`, timestamp — + RFC3339 UTC. `sensor-1` шлёт по одному сообщению на `temperature` и + `humidity` раз в `TELEMETRY_INTERVAL_MS`. +- `devices/{device_id}/commands` — подписывается контроллер (wildcard + `devices/+/commands`, фильтрует по своим 3 актуаторам), JSON + `{"action":"turn_on"|"turn_off"|"set_level","level"?}`. +- `devices/{device_id}/ack` — публикует контроллер после применения команды, + JSON `{"state":{"power":"on"|"off","level"?}}`. + +Реального диммирования нет (обычные реле), поэтому `set_level` трактуется как +порог: `level > 0` включает реле, `level <= 0` выключает. + +device_id в конфиге должны совпадать с `devices.external_id`, засеянными в +Laravel (`DemoGrowboxSeeder` использует `sensor-1`/`fan-1`), а `zone_id` — с +`zones.id` зоны "Гроубокс" в Postgres. + +## Железо + +| Назначение | Пин (по умолчанию) | +|-----------------|---------------------| +| DHT22 data | GPIO4 | +| Реле — light-1 | GPIO16 | +| Реле — pump-1 | GPIO17 | +| Реле — fan-1 | GPIO18 | + +Реле-модули по умолчанию считаются активными по LOW (`RELAY_ACTIVE_LOW` в +`config.h`) — типично для дешёвых китайских модулей на оптопаре. Если ваши +реле активны по HIGH — поменяйте флаг. + +DHT22 требует подтягивающий резистор ~10кОм между data и VCC, если он не +встроен в конкретный модуль. + +## Библиотеки (Arduino Library Manager) + +- `PubSubClient` (Nick O'Leary) — MQTT-клиент +- `ArduinoJson` (Benoit Blanchon, v7) — сериализация/парсинг JSON +- `DHT sensor library` (Adafruit) + зависимость `Adafruit Unified Sensor` +- Board support: `esp32` (Espressif Systems) через Boards Manager + +## Настройка и прошивка + +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 и каждой публикации телеметрии/команды. + +## Проверка end-to-end + +При работающем `docker compose` в `home_automatization` и засеянных +Postgres-данных (зона + `sensor-1`/`light-1`/`pump-1`/`fan-1`): + +- телеметрия должна доходить до ClickHouse через ingest-service; +- команда через Laravel UI (или напрямую через `grpcurl` в + device-control-service) должна прийти на плату и вызвать щелчок реле, а + затем ack — обновить `reported_state`/`last_seen` в Redis device shadow. diff --git a/growbox/config.h.example b/growbox/config.h.example new file mode 100644 index 0000000..83896b0 --- /dev/null +++ b/growbox/config.h.example @@ -0,0 +1,33 @@ +// Скопировать в config.h и заполнить своими значениями. +// config.h в .gitignore — секреты (Wi-Fi, адрес брокера) в репозиторий не попадают. +#pragma once + +// --- Wi-Fi --- +#define WIFI_SSID "your-wifi-ssid" +#define WIFI_PASSWORD "your-wifi-password" + +// --- MQTT (mosquitto из home_automatization/docker-compose.yml, порт 1883, без auth в dev) --- +#define MQTT_BROKER_HOST "192.168.1.100" +#define MQTT_BROKER_PORT 1883 +#define MQTT_CLIENT_ID "growbox-esp32" + +// --- Зона и устройства --- +// zone_id — id зоны "Гроубокс" в Postgres (laravel-app, таблица zones). +// device_id должны совпадать с devices.external_id, засеянными в Laravel. +#define ZONE_ID "1" +#define DEVICE_ID_SENSOR "sensor-1" +#define DEVICE_ID_LIGHT "light-1" +#define DEVICE_ID_PUMP "pump-1" +#define DEVICE_ID_FAN "fan-1" + +// --- Пины (ESP32 DevKitC) --- +#define PIN_DHT22 4 +#define PIN_RELAY_LIGHT 16 +#define PIN_RELAY_PUMP 17 +#define PIN_RELAY_FAN 18 + +// Большинство дешёвых реле-модулей активны по LOW (сигнал LOW = реле включено). +#define RELAY_ACTIVE_LOW true + +// --- Телеметрия --- +#define TELEMETRY_INTERVAL_MS 30000UL diff --git a/growbox/devices.cpp b/growbox/devices.cpp new file mode 100644 index 0000000..6ada863 --- /dev/null +++ b/growbox/devices.cpp @@ -0,0 +1,72 @@ +#include "devices.h" + +#include "config.h" + +struct Actuator { + const char* deviceId; + uint8_t pin; + bool on; +}; + +static Actuator actuators[] = { + {DEVICE_ID_LIGHT, PIN_RELAY_LIGHT, false}, + {DEVICE_ID_PUMP, PIN_RELAY_PUMP, false}, + {DEVICE_ID_FAN, PIN_RELAY_FAN, false}, +}; +static const size_t actuatorCount = sizeof(actuators) / sizeof(actuators[0]); + +static void relayWrite(uint8_t pin, bool on) { + bool level = RELAY_ACTIVE_LOW ? !on : on; + digitalWrite(pin, level ? HIGH : LOW); +} + +void devicesBegin() { + for (size_t i = 0; i < actuatorCount; i++) { + pinMode(actuators[i].pin, OUTPUT); + relayWrite(actuators[i].pin, false); + } +} + +static Actuator* findActuator(const String& deviceId) { + for (size_t i = 0; i < actuatorCount; i++) { + if (deviceId == actuators[i].deviceId) { + return &actuators[i]; + } + } + return nullptr; +} + +// Реального диммирования у нас нет (обычные реле on/off), поэтому +// set_level трактуется как порог: level > 0 включает реле, level <= 0 +// выключает. Ack всегда содержит и power, и level, чтобы отражать +// реальное состояние железа. +bool devicesApplyCommand(const String& deviceId, const String& action, double level, JsonDocument& outState) { + Actuator* actuator = findActuator(deviceId); + if (actuator == nullptr) { + return false; + } + + if (action == "turn_on") { + actuator->on = true; + } else if (action == "turn_off") { + actuator->on = false; + } else if (action == "set_level") { + actuator->on = level > 0; + outState["level"] = level; + } else { + Serial.print("[devices] unknown action for "); + Serial.print(deviceId); + Serial.print(": "); + Serial.println(action); + return false; + } + + relayWrite(actuator->pin, actuator->on); + outState["power"] = actuator->on ? "on" : "off"; + + Serial.print("[devices] "); + Serial.print(deviceId); + Serial.print(" -> "); + Serial.println(actuator->on ? "on" : "off"); + return true; +} diff --git a/growbox/devices.h b/growbox/devices.h new file mode 100644 index 0000000..fa935c6 --- /dev/null +++ b/growbox/devices.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +// Инициализирует пины реле (light/pump/fan), выключает всё при старте. +void devicesBegin(); + +// Применяет команду {"action": "turn_on"|"turn_off"|"set_level", "level"?} +// к устройству deviceId, если это одно из известных реле-актуаторов этой +// платы (light-1/pump-1/fan-1 из config.h). Заполняет outState фактическим +// применённым состоянием для последующей публикации в devices/{id}/ack. +// Возвращает false, если deviceId этой плате не принадлежит. +bool devicesApplyCommand(const String& deviceId, const String& action, double level, JsonDocument& outState); diff --git a/growbox/growbox.ino b/growbox/growbox.ino new file mode 100644 index 0000000..1ff32dc --- /dev/null +++ b/growbox/growbox.ino @@ -0,0 +1,28 @@ +// Контроллер зоны "Гроубокс" для платформы home_automatization. +// Одна плата ESP32 владеет четырьмя устройствами зоны: +// sensor-1 (DHT22, телеметрия temperature/humidity) +// light-1, pump-1, fan-1 (реле, turn_on/turn_off/set_level) +// Протокол общения с device-control-service/ingest-service описан в README.md. +#include "config.h" +#include "devices.h" +#include "mqtt_link.h" +#include "net.h" +#include "telemetry.h" +#include "time_sync.h" + +void setup() { + Serial.begin(115200); + + devicesBegin(); + netBegin(); + timeSyncBegin(); + mqttBegin(); + telemetryBegin(); +} + +void loop() { + netEnsureConnected(); + mqttEnsureConnected(); + mqttLoop(); + telemetryTick(); +} diff --git a/growbox/mqtt_link.cpp b/growbox/mqtt_link.cpp new file mode 100644 index 0000000..4efb0af --- /dev/null +++ b/growbox/mqtt_link.cpp @@ -0,0 +1,99 @@ +#include "mqtt_link.h" + +#include +#include +#include + +#include "config.h" +#include "devices.h" +#include "time_sync.h" + +static WiFiClient wifiClient; +static PubSubClient mqttClient(wifiClient); + +// devices/{device_id}/commands -> device_id, или "" если топик не подходит. +static String deviceIdFromCommandTopic(const String& topic) { + if (!topic.startsWith("devices/") || !topic.endsWith("/commands")) { + return ""; + } + return topic.substring(8, topic.length() - 9); // strip "devices/" and "/commands" +} + +static void publishAck(const String& deviceId, JsonDocument& state) { + JsonDocument ackDoc; + ackDoc["state"] = state.as(); + + char buf[256]; + size_t len = serializeJson(ackDoc, buf, sizeof(buf)); + + String topic = "devices/" + deviceId + "/ack"; + mqttClient.publish(topic.c_str(), (const uint8_t*)buf, len, false); +} + +static void onMessage(char* topic, byte* payload, unsigned int length) { + String deviceId = deviceIdFromCommandTopic(String(topic)); + if (deviceId.isEmpty()) { + return; + } + + JsonDocument cmd; + DeserializationError err = deserializeJson(cmd, payload, length); + if (err) { + Serial.print("[mqtt] invalid command payload on "); + Serial.println(topic); + return; + } + + String action = cmd["action"] | ""; + double level = cmd["level"] | 0.0; + if (action.isEmpty()) { + Serial.print("[mqtt] command missing action on "); + Serial.println(topic); + return; + } + + JsonDocument state; + if (devicesApplyCommand(deviceId, action, level, state)) { + publishAck(deviceId, state); + } +} + +void mqttBegin() { + mqttClient.setServer(MQTT_BROKER_HOST, MQTT_BROKER_PORT); + mqttClient.setCallback(onMessage); +} + +void mqttEnsureConnected() { + if (mqttClient.connected()) { + return; + } + + Serial.print("[mqtt] connecting to "); + Serial.print(MQTT_BROKER_HOST); + if (mqttClient.connect(MQTT_CLIENT_ID)) { + Serial.println(" connected"); + mqttClient.subscribe("devices/+/commands", 1); + } else { + Serial.print(" failed, rc="); + Serial.println(mqttClient.state()); + } +} + +void mqttLoop() { + mqttClient.loop(); +} + +void mqttPublishTelemetry(const String& deviceId, const String& sensorType, double value) { + JsonDocument doc; + doc["device_id"] = deviceId; + doc["zone_id"] = ZONE_ID; + doc["sensor_type"] = sensorType; + doc["value"] = value; + doc["timestamp"] = nowRFC3339(); + + char buf[256]; + size_t len = serializeJson(doc, buf, sizeof(buf)); + + String topic = "devices/" + deviceId + "/telemetry"; + mqttClient.publish(topic.c_str(), (const uint8_t*)buf, len, false); +} diff --git a/growbox/mqtt_link.h b/growbox/mqtt_link.h new file mode 100644 index 0000000..f042012 --- /dev/null +++ b/growbox/mqtt_link.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +// Подключение к mosquitto, подписка на devices/+/commands, диспетчеризация +// команд в devices.cpp и публикация ack. См. протокол в README.md. +void mqttBegin(); +void mqttEnsureConnected(); +void mqttLoop(); + +// Публикует одно показание телеметрии в devices/{deviceId}/telemetry. +void mqttPublishTelemetry(const String& deviceId, const String& sensorType, double value); diff --git a/growbox/net.cpp b/growbox/net.cpp new file mode 100644 index 0000000..8bdf533 --- /dev/null +++ b/growbox/net.cpp @@ -0,0 +1,40 @@ +#include "net.h" + +#include +#include + +#include "config.h" + +void netBegin() { + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + + Serial.print("[net] connecting to "); + Serial.print(WIFI_SSID); + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print("."); + } + Serial.print("\n[net] connected, ip="); + Serial.println(WiFi.localIP()); +} + +// Вызывается из loop() на каждой итерации — переподключает Wi-Fi, +// если связь пропала (роутер перезагрузился, слабый сигнал и т.д.). +void netEnsureConnected() { + if (WiFi.status() == WL_CONNECTED) { + return; + } + Serial.println("[net] connection lost, reconnecting"); + WiFi.disconnect(); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + + unsigned long start = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) { + delay(250); + } + if (WiFi.status() == WL_CONNECTED) { + Serial.print("[net] reconnected, ip="); + Serial.println(WiFi.localIP()); + } +} diff --git a/growbox/net.h b/growbox/net.h new file mode 100644 index 0000000..5e7cd18 --- /dev/null +++ b/growbox/net.h @@ -0,0 +1,5 @@ +#pragma once + +// Подключение к Wi-Fi и переподключение при обрыве. +void netBegin(); +void netEnsureConnected(); diff --git a/growbox/telemetry.cpp b/growbox/telemetry.cpp new file mode 100644 index 0000000..33d7575 --- /dev/null +++ b/growbox/telemetry.cpp @@ -0,0 +1,42 @@ +#include "telemetry.h" + +#include + +#include "config.h" +#include "mqtt_link.h" +#include "time_sync.h" + +static DHT dht(PIN_DHT22, DHT22); +static unsigned long lastPublish = 0; + +void telemetryBegin() { + dht.begin(); +} + +void telemetryTick() { + unsigned long now = millis(); + if (now - lastPublish < TELEMETRY_INTERVAL_MS && lastPublish != 0) { + return; + } + // Без синхронизированного времени ingest-service отклонит timestamp + // (или он будет ложным), поэтому просто пропускаем цикл и ждём. + if (!timeSyncIsReady()) { + return; + } + lastPublish = now; + + float humidity = dht.readHumidity(); + float temperature = dht.readTemperature(); + if (isnan(humidity) || isnan(temperature)) { + Serial.println("[telemetry] DHT22 read failed, skipping this cycle"); + return; + } + + 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); +} diff --git a/growbox/telemetry.h b/growbox/telemetry.h new file mode 100644 index 0000000..48a020c --- /dev/null +++ b/growbox/telemetry.h @@ -0,0 +1,8 @@ +#pragma once + +// Инициализация DHT22. +void telemetryBegin(); + +// Вызывать на каждой итерации loop(): сама решает, пора ли публиковать +// очередное показание (см. TELEMETRY_INTERVAL_MS в config.h). +void telemetryTick(); diff --git a/growbox/time_sync.cpp b/growbox/time_sync.cpp new file mode 100644 index 0000000..e61a7de --- /dev/null +++ b/growbox/time_sync.cpp @@ -0,0 +1,47 @@ +#include "time_sync.h" + +#include + +static bool timeReady = false; + +void timeSyncBegin() { + // UTC, без летнего времени — платформа хранит и парсит время в UTC. + configTime(0, 0, "pool.ntp.org", "time.nist.gov"); + + Serial.print("[time] syncing via NTP"); + time_t now = time(nullptr); + unsigned long start = millis(); + // 8 * 3600 — грубая нижняя граница правдоподобного unix-времени, + // пока NTP не ответит time() возвращает эпоху около 0. + while (now < 8 * 3600 && millis() - start < 15000) { + delay(250); + Serial.print("."); + now = time(nullptr); + } + Serial.println(); + + timeReady = now >= 8 * 3600; + if (timeReady) { + Serial.println("[time] synced"); + } else { + Serial.println("[time] sync timed out, will retry in background"); + } +} + +bool timeSyncIsReady() { + if (!timeReady) { + time_t now = time(nullptr); + timeReady = now >= 8 * 3600; + } + return timeReady; +} + +String nowRFC3339() { + time_t now = time(nullptr); + struct tm tmStruct; + gmtime_r(&now, &tmStruct); + + char buf[21]; + strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tmStruct); + return String(buf); +} diff --git a/growbox/time_sync.h b/growbox/time_sync.h new file mode 100644 index 0000000..8e335c5 --- /dev/null +++ b/growbox/time_sync.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +// ingest-service требует валидный RFC3339 timestamp в телеметрии, поэтому +// время синхронизируется по NTP один раз при старте (см. README). +void timeSyncBegin(); +bool timeSyncIsReady(); + +// Текущее UTC время в формате RFC3339, например "2026-08-11T12:34:56Z". +String nowRFC3339();