Начальный каркас: прошивка ESP32 для зоны "Гроубокс"

Реализованы 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).
This commit is contained in:
2026-08-11 18:46:37 +05:00
commit a645737927
14 changed files with 482 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
config.h
build/
*.bin
+68
View File
@@ -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.
+33
View File
@@ -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
+72
View File
@@ -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;
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <Arduino.h>
#include <ArduinoJson.h>
// Инициализирует пины реле (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);
+28
View File
@@ -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();
}
+99
View File
@@ -0,0 +1,99 @@
#include "mqtt_link.h"
#include <ArduinoJson.h>
#include <PubSubClient.h>
#include <WiFi.h>
#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<JsonObject>();
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);
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <Arduino.h>
// Подключение к 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);
+40
View File
@@ -0,0 +1,40 @@
#include "net.h"
#include <Arduino.h>
#include <WiFi.h>
#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());
}
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
// Подключение к Wi-Fi и переподключение при обрыве.
void netBegin();
void netEnsureConnected();
+42
View File
@@ -0,0 +1,42 @@
#include "telemetry.h"
#include <DHT.h>
#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);
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
// Инициализация DHT22.
void telemetryBegin();
// Вызывать на каждой итерации loop(): сама решает, пора ли публиковать
// очередное показание (см. TELEMETRY_INTERVAL_MS в config.h).
void telemetryTick();
+47
View File
@@ -0,0 +1,47 @@
#include "time_sync.h"
#include <time.h>
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);
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <Arduino.h>
// ingest-service требует валидный RFC3339 timestamp в телеметрии, поэтому
// время синхронизируется по NTP один раз при старте (см. README).
void timeSyncBegin();
bool timeSyncIsReady();
// Текущее UTC время в формате RFC3339, например "2026-08-11T12:34:56Z".
String nowRFC3339();