#include <WiFi.h>
#include <WebServer.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Preferences.h>
#include <RTClib.h>
#include <LiquidCrystal_I2C.h>
// Автополив и фитолампа на ESP32: веб-админка и MQTT.
//
// Третий вариант устройства. Как у версии с веб-админкой: фитолампа
// 230 В по расписанию, два насоса 12 В со своими периодами, DS3231,
// HTU21D, LCD 16x2, ручка энкодера и страница в браузере (кнопка
// «Веб-интерфейс» над схемой). Добавлен MQTT: прибор сообщает состояние
// брокеру и принимает команды — из Home Assistant, Node-RED, телефона
// или вкладки «MQTT» внизу. Home Assistant находит его сам
// (MQTT discovery): датчики, лампа и насосы-выключатели.
//
// Темы (BASE = pinport/greenhouse):
//
// BASE/status online / offline — завещание, retain
// BASE/climate {"t":23.5,"h":45} — раз в 10 секунд
// BASE/lamp on / off — при смене, retain
// BASE/pump/1 on / off — при смене, retain
// BASE/state всё состояние в JSON — при смене и раз в минуту
//
// BASE/pump/1/set on — полить сейчас, off — остановить
// BASE/settings/set поля формы: lampOff=20:00&s1=120&left2=0
// BASE/time/set секунды местного времени (epoch)
//
// Страница показывает часы, климат, состояние лампы и насосов (во время
// полива — обратный отсчёт), даёт поменять расписание, полить или
// остановить полив и поставить часы по времени компьютера.
//
// GET / страница
// GET /api/state всё состояние в JSON
// POST /api/settings расписание (поля формы)
// POST /api/water p=1 — полить сейчас, stop=1 — остановить
// POST /api/time epoch=… — поставить часы
//
// Экран и ручка — как у версии на Nano: верхняя строка — часы и климат,
// нижняя листается поворотом:
//
// L 07:00-21:00 ON фитолампа
// 1 100s 3d(2) 20h насос P1: 100 с раз в 3 дня, осталось 2, в 20:00
// 2 60s 2d(1) 08h насос P2
// 192.168.4.2 адрес страницы
//
// Нажатие — правка поля, долгое нажатие во время правки — отмена, на
// экране насоса — полить сейчас. Настройки хранятся в NVS (Preferences)
// и переживают выключение.
// --- сеть -------------------------------------------------------------
// Своя сеть. Не подключилась за 15 секунд — плата поднимает свою точку
// доступа «Greenhouse» (пароль ниже), и страница открывается по адресу
// 192.168.4.1. В модели сеть PinPort-GUEST есть всегда.
const char *SSID = "PinPort-GUEST", *PASSWORD = "";
const char *AP_SSID = "Greenhouse", *AP_PASSWORD = "greenhouse";
// Пароль на страницу (логин admin). Пусто — без пароля.
const char *ADMIN_PASSWORD = "";
// Брокер MQTT. В модели любой адрес ведёт на встроенный брокер PinPort
// (вкладка «MQTT»); на железе — адрес Mosquitto или брокера Home
// Assistant, логин и пароль пользователя MQTT.
const char *BROKER = "broker.hivemq.com";
const uint16_t BROKER_PORT = 1883;
const char *MQTT_USER = "", *MQTT_PASSWORD = "";
const char *BASE = "pinport/greenhouse";
const bool HA_DISCOVERY = true; // объявить себя Home Assistant
// --- выводы -----------------------------------------------------------
const byte ENC_A = 18, ENC_B = 19, ENC_KEY = 5; // энкодер S1, S2, KEY
const byte RELAY_LAMP = 25; // реле 1 — фитолампа
const byte RELAY_PUMP[] = { 26, 27 }; // реле 2 и 3 — насосы P1, P2
// Запасные реле модуля — выводы 32, 33 и 14. Третий насос — третий
// вывод здесь и третья строка в DEFAULT_PUMPS: экран, страница,
// настройки и полив подхватят его сами.
const byte PUMPS = sizeof(RELAY_PUMP);
// Модуль реле в модели включается высоким уровнем. Шестиканальные
// модули с оптронами часто включаются низким — поменяйте константу.
const byte RELAY_ON = HIGH;
const byte RELAY_OFF = RELAY_ON == HIGH ? LOW : HIGH;
const byte HTU21D = 0x40;
// --- настройки --------------------------------------------------------
struct Pump {
uint16_t seconds; // сколько секунд качать
uint8_t period; // раз в сколько дней
uint8_t left; // сколько дней осталось; 0 — сегодня
uint8_t hour; // в котором часу
};
struct Settings {
uint8_t version;
uint16_t lampOn, lampOff; // минуты от полуночи
Pump pump[PUMPS];
uint32_t day; // номер дня последнего пересчёта
};
// Нажатие на ручку. Объявлено выше всех функций: Arduino вставляет
// прототипы перед первой из них.
enum Press { NONE, SHORT, LONG };
const uint8_t VERSION = 1; // поменяйте — и настройки заполнятся заново
const Pump DEFAULT_PUMPS[] = { { 100, 3, 2, 20 }, { 60, 2, 1, 8 } };
Settings cfg;
Preferences prefs;
void loadSettings() {
prefs.begin("greenhouse", false);
size_t got = prefs.getBytes("cfg", &cfg, sizeof cfg);
prefs.end();
if (got == sizeof cfg && cfg.version == VERSION) return;
cfg.version = VERSION;
cfg.lampOn = 7 * 60;
cfg.lampOff = 21 * 60;
for (byte i = 0; i < PUMPS; i++) cfg.pump[i] = DEFAULT_PUMPS[i];
cfg.day = 0;
saveSettings();
}
void saveSettings() {
prefs.begin("greenhouse", false);
prefs.putBytes("cfg", &cfg, sizeof cfg);
prefs.end();
}
// --- железо -----------------------------------------------------------
RTC_DS3231 rtc;
LiquidCrystal_I2C lcd(0x27, 16, 2);
WebServer server(80);
volatile int clicks = 0;
void IRAM_ATTR onEncoder() {
if (digitalRead(ENC_B)) clicks++; else clicks--;
}
// HTU21D без библиотеки: команда «измерь», ожидание, три байта — старший,
// младший и CRC-8; два младших бита служебные.
bool htuRead(byte cmd, float &out) {
Wire.beginTransmission(HTU21D);
Wire.write(cmd);
if (Wire.endTransmission() != 0) return false;
delay(55);
if (Wire.requestFrom(HTU21D, (uint8_t)3) != 3) return false;
byte msb = Wire.read(), lsb = Wire.read(), crc = Wire.read();
byte c = 0;
byte data[2] = { msb, lsb };
for (byte i = 0; i < 2; i++) {
c ^= data[i];
for (byte k = 0; k < 8; k++) c = (c & 0x80) ? (c << 1) ^ 0x31 : c << 1;
}
if (c != crc) return false;
uint16_t raw = ((uint16_t)msb << 8 | lsb) & 0xFFFC;
out = cmd == 0xF3 ? -46.85 + 175.72 * raw / 65536.0 : -6.0 + 125.0 * raw / 65536.0;
return true;
}
float temperature = NAN, humidity = NAN;
void readClimate() {
float t, h;
temperature = htuRead(0xF3, t) ? t : NAN;
humidity = htuRead(0xF5, h) ? constrain(h, 0, 100) : NAN;
}
// --- каналы -----------------------------------------------------------
bool lampLit = false;
bool running[PUMPS];
unsigned long startedAt[PUMPS];
bool lampShouldBeOn(uint16_t minute) {
if (cfg.lampOn == cfg.lampOff) return false;
if (cfg.lampOn < cfg.lampOff) return minute >= cfg.lampOn && minute < cfg.lampOff;
return minute >= cfg.lampOn || minute < cfg.lampOff; // через полночь
}
WiFiClient net;
PubSubClient mqtt(net);
bool stateDirty = true; // есть что сообщить брокеру
String topic(const char *tail) { return String(BASE) + "/" + tail; }
void publishState();
void startPump(byte i, const char *why) {
running[i] = true;
startedAt[i] = millis();
Serial.printf("P%u: полив %u с (%s)\n", i + 1, cfg.pump[i].seconds, why);
mqtt.publish(topic(("pump/" + String(i + 1)).c_str()).c_str(), "on", true);
stateDirty = true;
}
void stopPump(byte i) {
running[i] = false;
Serial.printf("P%u: полив закончен\n", i + 1);
mqtt.publish(topic(("pump/" + String(i + 1)).c_str()).c_str(), "off", true);
stateDirty = true;
}
unsigned remaining(byte i) {
if (!running[i]) return 0;
unsigned long gone = (millis() - startedAt[i]) / 1000;
return gone >= cfg.pump[i].seconds ? 0 : cfg.pump[i].seconds - gone;
}
// Смена суток: «осталось дней» уменьшается у каждого насоса. Номер дня
// хранится вместе с настройками — пропущенные сутки тоже засчитываются.
void newDay(uint32_t today) {
if (cfg.day != 0 && today > cfg.day) {
uint32_t passed = today - cfg.day;
for (byte i = 0; i < PUMPS; i++) {
cfg.pump[i].left = cfg.pump[i].left > passed ? cfg.pump[i].left - passed : 0;
}
}
cfg.day = today;
saveSettings();
}
void control(const DateTime &now, bool editing) {
uint32_t today = now.unixtime() / 86400UL;
if (today != cfg.day) newDay(today);
bool lamp = lampShouldBeOn(now.hour() * 60 + now.minute());
if (lamp != lampLit) {
lampLit = lamp;
Serial.println(lamp ? "лампа включена" : "лампа выключена");
mqtt.publish(topic("lamp").c_str(), lamp ? "on" : "off", true);
stateDirty = true;
}
digitalWrite(RELAY_LAMP, lampLit ? RELAY_ON : RELAY_OFF);
for (byte i = 0; i < PUMPS; i++) {
Pump &p = cfg.pump[i];
// Полив сегодня и час настал. «Уже», а не «ровно»: прибор, включённый
// в 20:30, польёт, а не пропустит день.
if (!editing && !running[i] && p.left == 0 && now.hour() >= p.hour) {
startPump(i, "по расписанию");
p.left = p.period;
saveSettings();
}
if (running[i] && millis() - startedAt[i] >= p.seconds * 1000UL) stopPump(i);
digitalWrite(RELAY_PUMP[i], running[i] ? RELAY_ON : RELAY_OFF);
}
}
// --- экран и ручка ----------------------------------------------------
const byte SCREENS = 1 + PUMPS + 1; // лампа, насосы, адрес
byte screen = 0;
int8_t field = -1; // -1 — просмотр, иначе правимое поле
unsigned long touchedAt = 0;
String ipText = "нет сети";
byte fieldsOf(byte s) { return s == 0 ? 2 : s <= PUMPS ? 4 : 0; }
void adjust(int n) {
if (screen == 0) {
uint16_t &v = field == 0 ? cfg.lampOn : cfg.lampOff;
v = (v + 1440 + n * 10) % 1440;
return;
}
Pump &p = cfg.pump[screen - 1];
switch (field) {
case 0: p.seconds = constrain((int)p.seconds + n * 5, 5, 995); break;
case 1: p.period = constrain(p.period + n, 1, 9);
if (p.left > p.period) p.left = p.period; break;
case 2: p.left = constrain(p.left + n, 0, p.period); break;
case 3: p.hour = (p.hour + 24 + n) % 24; break;
}
}
void fieldSpan(byte &at, byte &width) {
static const byte LAMP[][2] = { { 2, 5 }, { 8, 5 } };
static const byte PUMP[][2] = { { 2, 4 }, { 7, 2 }, { 9, 3 }, { 13, 3 } };
const byte *f = screen == 0 ? LAMP[field] : PUMP[field];
at = f[0]; width = f[1];
}
void drawTop(const DateTime &now) {
char t[8] = "--.-", h[4] = "--";
if (!isnan(temperature)) snprintf(t, sizeof t, "%4.1f", temperature);
if (!isnan(humidity)) snprintf(h, sizeof h, "%d", min((int)(humidity + 0.5), 99));
char line[24];
snprintf(line, sizeof line, "%02d:%02d %5sC %2s%%", now.hour(), now.minute(), t, h);
line[16] = 0;
lcd.setCursor(0, 0);
lcd.print(line);
}
void drawBottom() {
char line[24];
if (screen == 0) {
snprintf(line, sizeof line, "L %02u:%02u-%02u:%02u %s",
cfg.lampOn / 60, cfg.lampOn % 60, cfg.lampOff / 60, cfg.lampOff % 60,
lampLit ? "ON" : "--");
} else if (screen <= PUMPS) {
byte i = screen - 1;
Pump &p = cfg.pump[i];
unsigned s = running[i] ? remaining(i) : p.seconds;
snprintf(line, sizeof line, "%u%c%3us %ud(%u) %02uh",
i + 1, running[i] ? '>' : ' ', s, p.period, p.left, p.hour);
} else {
// Адрес страницы — латиницей и цифрами: кириллицы в экране нет.
snprintf(line, sizeof line, "%s", WiFi.getMode() == WIFI_AP ? WiFi.softAPIP().toString().c_str()
: WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString().c_str() : "no network");
}
line[16] = 0;
if (field >= 0 && (millis() - touchedAt) > 600 && (millis() / 300) % 3 == 0) {
byte at, width;
fieldSpan(at, width);
for (byte k = 0; k < width; k++) line[at + k] = ' ';
}
lcd.setCursor(0, 1);
lcd.print(line);
for (byte k = strlen(line); k < 16; k++) lcd.print(' ');
}
Press readKey() {
static bool down = false, longFired = false;
static unsigned long since = 0;
bool now = digitalRead(ENC_KEY) == LOW;
if (now && !down) { down = true; longFired = false; since = millis(); }
if (now && down && !longFired && millis() - since >= 1000) { longFired = true; return LONG; }
if (!now && down) {
down = false;
if (!longFired && millis() - since >= 30) return SHORT;
}
return NONE;
}
void handleInput() {
noInterrupts();
int n = clicks;
clicks = 0;
interrupts();
if (n) {
touchedAt = millis();
if (field < 0) screen = (screen + SCREENS + (n > 0 ? 1 : -1)) % SCREENS;
else adjust(n);
}
Press k = readKey();
if (k != NONE) touchedAt = millis();
if (k == SHORT && fieldsOf(screen)) {
if (field < 0) field = 0;
else if (++field >= fieldsOf(screen)) {
field = -1;
saveSettings();
stateDirty = true;
Serial.println("настройки сохранены");
}
} else if (k == LONG) {
if (field >= 0) {
field = -1;
loadSettings();
Serial.println("правка отменена");
} else if (screen > 0 && screen <= PUMPS) {
byte i = screen - 1;
if (running[i]) stopPump(i); else startPump(i, "вручную");
}
}
if (field >= 0 && millis() - touchedAt > 30000UL) {
field = -1;
saveSettings();
}
}
// --- веб-админка ------------------------------------------------------
const char PAGE[] = R"HTML(<!doctype html><html lang="ru"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Автополив</title>
<style>
:root{--bg:#fffaf5;--card:#fff;--line:#ece3d8;--text:#241c14;--dim:#8a7c6d;--acc:#c2410c;--ok:#15803d}
body{font-family:system-ui,sans-serif;margin:0;background:var(--bg);color:var(--text)}
main{max-width:720px;margin:0 auto;padding:16px}h1{font-size:22px;margin:4px 0 12px}
.row{display:flex;gap:12px;flex-wrap:wrap}.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;flex:1;min-width:150px}
small{display:block;color:var(--dim);font-size:12px;margin-bottom:2px}.big{font-size:26px;font-weight:600}
h2{font-size:16px;margin:18px 0 8px}.on{color:var(--ok)}label{display:block;font-size:13px;margin:6px 0 2px;color:var(--dim)}
input{font:inherit;padding:6px 8px;border:1px solid var(--line);border-radius:6px;width:100%;box-sizing:border-box}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}button{font:inherit;padding:8px 14px;border-radius:8px;border:1px solid var(--line);background:#fff;cursor:pointer}
button.main{background:var(--acc);color:#fff;border-color:var(--acc)}.bar{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
#msg{color:var(--ok);min-height:1.2em;margin-top:8px}
</style><main>
<h1>Автополив и фитолампа</h1>
<div class="row"><div class="card"><small>время</small><div class="big" id="time">…</div><small id="date"></small></div>
<div class="card"><small>температура</small><div class="big" id="t">…</div></div>
<div class="card"><small>влажность</small><div class="big" id="h">…</div></div>
<div class="card"><small>MQTT</small><div class="big" id="mq">…</div><small id="mqb"></small></div></div>
<div class="row" id="state" style="margin-top:12px"></div>
<form id="f"><h2>Фитолампа</h2><div class="grid" style="grid-template-columns:1fr 1fr">
<div><label>включать в</label><input type="time" name="lampOn" step="600"></div>
<div><label>выключать в</label><input type="time" name="lampOff" step="600"></div></div>
<div id="pumps"></div>
<div class="bar"><button class="main" type="submit">Сохранить</button>
<button type="button" id="sync">Часы по этому компьютеру</button></div><div id="msg"></div></form>
</main><script>
const $=s=>document.querySelector(s),hm=m=>String(m/60|0).padStart(2,'0')+':'+String(m%60).padStart(2,'0');
let built=false;
function build(d){let h='';d.pumps.forEach((p,i)=>{const n=i+1;h+='<h2>Насос P'+n+'</h2><div class="grid">'+
'<div><label>качать, с</label><input type="number" min="5" max="995" step="5" name="s'+n+'"></div>'+
'<div><label>раз в, дней</label><input type="number" min="1" max="9" name="period'+n+'"></div>'+
'<div><label>осталось дней</label><input type="number" min="0" max="9" name="left'+n+'"></div>'+
'<div><label>в котором часу</label><input type="number" min="0" max="23" name="hour'+n+'"></div></div>'});
$('#pumps').innerHTML=h;const f=$('#f');f.lampOn.value=hm(d.lamp.on);f.lampOff.value=hm(d.lamp.off);
d.pumps.forEach((p,i)=>{const n=i+1;f['s'+n].value=p.seconds;f['period'+n].value=p.period;f['left'+n].value=p.left;f['hour'+n].value=p.hour});built=true}
async function tick(){const d=await(await fetch('/api/state')).json();
$('#time').textContent=d.time;$('#date').textContent=d.date;
$('#mq').textContent=d.mqtt?'на связи':'нет связи';$('#mq').className='big '+(d.mqtt?'on':'');$('#mqb').textContent=d.broker;
$('#t').textContent=d.t==null?'—':d.t.toFixed(1)+' °C';$('#h').textContent=d.h==null?'—':Math.round(d.h)+' %';
let s='<div class="card"><small>фитолампа '+hm(d.lamp.on)+'–'+hm(d.lamp.off)+'</small><div class="big '+(d.lamp.lit?'on':'')+'">'+(d.lamp.lit?'горит':'выключена')+'</div></div>';
d.pumps.forEach((p,i)=>{s+='<div class="card"><small>насос P'+(i+1)+' · '+p.seconds+' с раз в '+p.period+' дн. в '+p.hour+':00</small><div class="big '+(p.run?'on':'')+'">'+
(p.run?'полив '+p.remain+' с':(p.left?'через '+p.left+' дн.':'сегодня'))+'</div><div class="bar">'+
(p.run?'<button onclick="water('+(i+1)+',1)">Остановить</button>':'<button onclick="water('+(i+1)+',0)">Полить сейчас</button>')+'</div></div>'});
$('#state').innerHTML=s;if(!built)build(d)}
async function post(url,body){const r=await fetch(url,{method:'POST',body:new URLSearchParams(body)});$('#msg').textContent=await r.text();built=false;tick()}
function water(p,stop){post('/api/water',stop?{p,stop:1}:{p})}
$('#f').onsubmit=e=>{e.preventDefault();post('/api/settings',new FormData($('#f')))};
$('#sync').onclick=()=>{const d=new Date();post('/api/time',{epoch:Math.floor(d.getTime()/1000)-d.getTimezoneOffset()*60})};
tick();setInterval(tick,2000);
</script></html>)HTML";
bool allowed() {
if (!*ADMIN_PASSWORD || server.authenticate("admin", ADMIN_PASSWORD)) return true;
server.requestAuthentication();
return false;
}
String stateJSON() {
DateTime now = rtc.now();
char buf[64];
String out = "{";
snprintf(buf, sizeof buf, "\"time\":\"%02d:%02d:%02d\",\"date\":\"%02d.%02d.%04d\",",
now.hour(), now.minute(), now.second(), now.day(), now.month(), now.year());
out += buf;
out += "\"mqtt\":" + String(mqtt.connected() ? "true" : "false") + ",\"broker\":\"" + String(BROKER) + "\",";
out += "\"t\":" + (isnan(temperature) ? String("null") : String(temperature, 1)) + ",";
out += "\"h\":" + (isnan(humidity) ? String("null") : String(humidity, 0)) + ",";
snprintf(buf, sizeof buf, "\"lamp\":{\"on\":%u,\"off\":%u,\"lit\":%s},", cfg.lampOn, cfg.lampOff, lampLit ? "true" : "false");
out += buf;
out += "\"pumps\":[";
for (byte i = 0; i < PUMPS; i++) {
Pump &p = cfg.pump[i];
snprintf(buf, sizeof buf, "%s{\"seconds\":%u,\"period\":%u,\"left\":%u,\"hour\":%u,",
i ? "," : "", p.seconds, p.period, p.left, p.hour);
out += buf;
snprintf(buf, sizeof buf, "\"run\":%s,\"remain\":%u}", running[i] ? "true" : "false", remaining(i));
out += buf;
}
out += "]}";
return out;
}
// «07:30» → минуты от полуночи; неверное — как было.
uint16_t parseHM(const String &s, uint16_t was) {
int h, m;
if (sscanf(s.c_str(), "%d:%d", &h, &m) != 2 || h < 0 || h > 23 || m < 0 || m > 59) return was;
return h * 60 + m;
}
// Одно поле настроек: из формы страницы и из темы settings/set — один
// разбор на оба пути, чтобы пределы не разошлись. Неверное — как было.
bool applySetting(const String &name, const String &value) {
if (value == "") return false;
if (name == "lampOn") { cfg.lampOn = parseHM(value, cfg.lampOn); return true; }
if (name == "lampOff") { cfg.lampOff = parseHM(value, cfg.lampOff); return true; }
for (byte i = 0; i < PUMPS; i++) {
Pump &p = cfg.pump[i];
String n(i + 1);
int v = value.toInt();
if (name == "s" + n) { p.seconds = constrain(v, 5, 995); return true; }
if (name == "period" + n) { p.period = constrain(v, 1, 9); if (p.left > p.period) p.left = p.period; return true; }
if (name == "left" + n) { p.left = constrain(v, 0, p.period); return true; }
if (name == "hour" + n) { p.hour = constrain(v, 0, 23); return true; }
}
return false;
}
void settingsChanged(const char *from) {
field = -1; // правка с ручки, если шла, уступает
saveSettings();
stateDirty = true;
Serial.printf("настройки сохранены (%s)\n", from);
}
void handleSettings() {
if (!allowed()) return;
// Период — раньше «осталось дней»: предел второго зависит от первого.
for (const char *first : { "period1", "period2", "period3" }) {
if (server.hasArg(first)) applySetting(first, server.arg(first));
}
for (int k = 0; k < server.args(); k++) applySetting(server.argName(k), server.arg(k));
settingsChanged("страница");
server.send(200, "text/plain; charset=utf-8", "Сохранено");
}
void handleWater() {
if (!allowed()) return;
int p = server.arg("p").toInt();
if (p < 1 || p > PUMPS) { server.send(400, "text/plain; charset=utf-8", "нет такого насоса"); return; }
byte i = p - 1;
if (server.hasArg("stop")) { if (running[i]) stopPump(i); }
else if (!running[i]) startPump(i, "со страницы");
server.send(200, "text/plain; charset=utf-8", running[i] ? "Полив идёт" : "Полив остановлен");
}
void handleTime() {
if (!allowed()) return;
uint32_t epoch = strtoul(server.arg("epoch").c_str(), nullptr, 10);
if (epoch < 946684800UL) { server.send(400, "text/plain; charset=utf-8", "неверное время"); return; }
rtc.adjust(DateTime(epoch));
Serial.println("время установлено (страница)");
stateDirty = true;
server.send(200, "text/plain; charset=utf-8", "Часы установлены");
}
// --- MQTT -------------------------------------------------------------
void onMessage(char *t, byte *payload, unsigned int len) {
String msg;
for (unsigned i = 0; i < len && i < 200; i++) msg += (char)payload[i];
msg.trim();
String tp(t);
String base = String(BASE) + "/";
if (!tp.startsWith(base)) return;
tp = tp.substring(base.length());
if (tp.startsWith("pump/") && tp.endsWith("/set")) {
int n = tp.substring(5, tp.length() - 4).toInt();
if (n < 1 || n > PUMPS) return;
byte i = n - 1;
if (msg.equalsIgnoreCase("on") && !running[i]) startPump(i, "по MQTT");
else if (msg.equalsIgnoreCase("off") && running[i]) stopPump(i);
} else if (tp == "settings/set") {
// Те же поля, что у формы: «lampOff=20:00&s1=120». Период — первым.
int changed = 0;
for (int pass = 0; pass < 2; pass++) {
int from = 0;
while (from < (int)msg.length()) {
int amp = msg.indexOf('&', from);
if (amp < 0) amp = msg.length();
String pair = msg.substring(from, amp);
int eq = pair.indexOf('=');
if (eq > 0) {
String name = pair.substring(0, eq);
if ((pass == 0) == name.startsWith("period") && applySetting(name, pair.substring(eq + 1))) changed++;
}
from = amp + 1;
}
}
if (changed) settingsChanged("MQTT");
} else if (tp == "time/set") {
uint32_t epoch = strtoul(msg.c_str(), nullptr, 10);
if (epoch >= 946684800UL) {
rtc.adjust(DateTime(epoch));
Serial.println("время установлено (MQTT)");
stateDirty = true;
}
}
}
// Home Assistant: одно сообщение-объявление на сущность, с retain —
// после перезапуска HA найдёт прибор сам.
void announce() {
String dev = "\"dev\":{\"ids\":[\"pinport_greenhouse\"],\"name\":\"Автополив\",\"mdl\":\"ESP32\",\"mf\":\"PinPort\"}";
String avail = "\"avty_t\":\"" + topic("status") + "\"";
auto send = [&](const char *kind, const char *id, const String &body) {
String t = String("homeassistant/") + kind + "/pinport_greenhouse/" + id + "/config";
mqtt.publish(t.c_str(), ("{" + body + "," + avail + "," + dev + "}").c_str(), true);
};
send("sensor", "t", "\"name\":\"Температура\",\"uniq_id\":\"pg_t\",\"dev_cla\":\"temperature\",\"unit_of_meas\":\"°C\",\"stat_t\":\"" + topic("climate") + "\",\"val_tpl\":\"{{value_json.t}}\"");
send("sensor", "h", "\"name\":\"Влажность\",\"uniq_id\":\"pg_h\",\"dev_cla\":\"humidity\",\"unit_of_meas\":\"%\",\"stat_t\":\"" + topic("climate") + "\",\"val_tpl\":\"{{value_json.h}}\"");
send("binary_sensor", "lamp", "\"name\":\"Фитолампа\",\"uniq_id\":\"pg_lamp\",\"dev_cla\":\"light\",\"stat_t\":\"" + topic("lamp") + "\",\"pl_on\":\"on\",\"pl_off\":\"off\"");
for (byte i = 0; i < PUMPS; i++) {
String n(i + 1);
send("switch", ("pump" + n).c_str(), "\"name\":\"Насос P" + n + "\",\"uniq_id\":\"pg_pump" + n + "\",\"stat_t\":\"" +
topic(("pump/" + n).c_str()) + "\",\"cmd_t\":\"" + topic(("pump/" + n + "/set").c_str()) +
"\",\"pl_on\":\"on\",\"pl_off\":\"off\",\"icon\":\"mdi:water-pump\"");
}
}
void publishClimate() {
char buf[48];
if (isnan(temperature) || isnan(humidity)) snprintf(buf, sizeof buf, "{\"t\":null,\"h\":null}");
else snprintf(buf, sizeof buf, "{\"t\":%.1f,\"h\":%.0f}", temperature, humidity);
mqtt.publish(topic("climate").c_str(), buf);
}
void publishState() {
mqtt.publish(topic("state").c_str(), stateJSON().c_str(), true);
stateDirty = false;
}
void connectMqtt() {
static unsigned long lastTry = 0;
static bool tried = false;
if (mqtt.connected() || WiFi.status() != WL_CONNECTED) return;
if (tried && millis() - lastTry < 5000) return; // повтор раз в 5 с
tried = true;
lastTry = millis();
String id = "greenhouse-" + String((uint32_t)ESP.getEfuseMac(), HEX);
bool ok = *MQTT_USER
? mqtt.connect(id.c_str(), MQTT_USER, MQTT_PASSWORD, topic("status").c_str(), 0, true, "offline")
: mqtt.connect(id.c_str(), topic("status").c_str(), 0, true, "offline");
if (!ok) {
Serial.printf("MQTT: брокер не ответил (код %d), повтор через 5 с\n", mqtt.state());
return;
}
Serial.printf("MQTT: подключено к %s\n", BROKER);
mqtt.publish(topic("status").c_str(), "online", true);
mqtt.subscribe(topic("pump/+/set").c_str());
mqtt.subscribe(topic("settings/set").c_str());
mqtt.subscribe(topic("time/set").c_str());
if (HA_DISCOVERY) announce();
mqtt.publish(topic("lamp").c_str(), lampLit ? "on" : "off", true);
for (byte i = 0; i < PUMPS; i++)
mqtt.publish(topic(("pump/" + String(i + 1)).c_str()).c_str(), running[i] ? "on" : "off", true);
publishClimate();
publishState();
}
void mqttLoop() {
static unsigned long lastClimate = 0, lastState = 0;
connectMqtt();
if (!mqtt.connected()) return;
mqtt.loop();
if (millis() - lastClimate >= 10000) { lastClimate = millis(); publishClimate(); }
// Состояние — при смене, но не чаще раза в секунду, и раз в минуту
// на всякий случай: у подписчика, пропустившего сообщение, оно само
// выправится.
if ((stateDirty && millis() - lastState >= 1000) || millis() - lastState >= 60000) {
lastState = millis();
publishState();
}
}
void startNetwork() {
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) delay(250);
if (WiFi.status() == WL_CONNECTED) {
ipText = WiFi.localIP().toString();
} else {
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASSWORD);
ipText = WiFi.softAPIP().toString();
Serial.printf("сети %s нет — своя точка доступа %s\n", SSID, AP_SSID);
}
server.on("/", [] { if (allowed()) server.send(200, "text/html; charset=utf-8", PAGE); });
server.on("/api/state", [] { if (allowed()) server.send(200, "application/json", stateJSON()); });
server.on("/api/settings", HTTP_POST, handleSettings);
server.on("/api/water", HTTP_POST, handleWater);
server.on("/api/time", HTTP_POST, handleTime);
server.begin();
Serial.printf("автополив: http://%s/\n", ipText.c_str());
mqtt.setServer(BROKER, BROKER_PORT);
mqtt.setCallback(onMessage);
mqtt.setBufferSize(1024); // объявления Home Assistant длиннее 256 байт
}
// --- монитор порта ----------------------------------------------------
void handleSerial() {
static String buf;
while (Serial.available()) {
char c = Serial.read();
if (c != '\n' && c != '\r') { if (buf.length() < 32) buf += c; continue; }
if (!buf.length()) continue;
DateTime now = rtc.now();
int a, b, c2 = 0;
if (sscanf(buf.c_str(), "time %d:%d:%d", &a, &b, &c2) >= 2) {
rtc.adjust(DateTime(now.year(), now.month(), now.day(), a, b, c2));
Serial.println("время установлено");
} else if (sscanf(buf.c_str(), "date %d.%d.%d", &a, &b, &c2) == 3) {
rtc.adjust(DateTime(c2, b, a, now.hour(), now.minute(), now.second()));
Serial.println("дата установлена");
} else if (buf == "status") {
Serial.println(stateJSON());
} else {
Serial.println("команды: time ЧЧ:ММ[:СС], date ДД.ММ.ГГГГ, status");
}
buf = "";
}
}
// --- setup и loop -----------------------------------------------------
void setup() {
Serial.begin(115200);
pinMode(RELAY_LAMP, OUTPUT);
digitalWrite(RELAY_LAMP, RELAY_OFF);
for (byte i = 0; i < PUMPS; i++) {
pinMode(RELAY_PUMP[i], OUTPUT);
digitalWrite(RELAY_PUMP[i], RELAY_OFF);
}
pinMode(ENC_A, INPUT);
pinMode(ENC_B, INPUT);
pinMode(ENC_KEY, INPUT_PULLUP);
attachInterrupt(ENC_A, onEncoder, FALLING);
Wire.begin(21, 22);
lcd.init();
lcd.backlight();
lcd.setCursor(0, 0);
lcd.print("Greenhouse");
lcd.setCursor(0, 1);
lcd.print("WiFi...");
Wire.beginTransmission(HTU21D);
Wire.write(0xFE);
Wire.endTransmission();
delay(15);
if (!rtc.begin()) Serial.println("DS3231 молчит: проверьте D21 и D22");
if (rtc.lostPower()) rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
loadSettings();
readClimate();
startNetwork();
}
void loop() {
static unsigned long lastClimate = 0, lastDraw = 0;
server.handleClient();
mqttLoop();
handleInput();
handleSerial();
DateTime now = rtc.now();
control(now, field >= 0);
if (millis() - lastClimate >= 5000) {
lastClimate = millis();
readClimate();
}
if (millis() - lastDraw >= 100) {
lastDraw = millis();
drawTop(now);
drawBottom();
}
delay(2);
}
Автополив и фитолампа на ESP32: веб-админка и MQTT
Третий вариант прибора: к веб-админке ESP32 добавлен MQTT. Состояние — климат, лампа, насосы, всё расписание — уходит брокеру, команды «полить», «остановить», новое расписание и время приходят из тем …/set, а Home Assistant находит прибор сам. Экран, ручка и страница в браузере остались.
Что на схеме
Плата ESP32 DevKit v1, к ней подключены:
- Реле —
rl1 - Реле —
rl2 - Реле —
rl3 - Ввод 230 В —
in - Автоматический выключатель —
qf1 - Выключатель одноклавишный —
sw1 - Светильник 230 В —
la1, тип: импульсный - Блок питания 12 В —
ps - Тумблер —
sw2 - Тумблер —
sw3 - Мотор 12 В —
m1 - Мотор 12 В —
m2 - LCD 16x2 (I2C) —
lcd1 - Часы реального времени DS3231 (I2C) —
rtc1 - Датчик HTU21D: температура и влажность (I2C) —
ht1, влажность 45 %, температура 23.5 °C - Энкодер KY-040 (ручка с кнопкой) —
enc1
Задействованы выводы платы: 3V3, GND, D21, D22, D18, D19, D5, D25, D26, D27. Всего соединений на схеме: 43.
MQTT
Прибор подключается к брокеру и держит там своё состояние; команды принимает из тем …/set. В модели любой адрес брокера ведёт на встроенный брокер PinPort — всё, что уходит и приходит, видно на вкладке «MQTT» внизу, и оттуда же можно публиковать. На железе впишите в BROKER адрес своего Mosquitto или брокера Home Assistant, а в MQTT_USER и MQTT_PASSWORD — пользователя MQTT.
Что прибор публикует (BASE = pinport/greenhouse):
BASE/status—online, а при обрыве связи брокер сам ставитoffline(завещание, retain);BASE/climate—{"t":23.5,"h":45}раз в 10 секунд;BASE/lamp—on/offпри смене, retain;BASE/pump/1,BASE/pump/2—on/offпри смене, retain;BASE/state— всё состояние в JSON (то же, что/api/stateу страницы): при каждой смене и раз в минуту.
Какие команды принимает:
BASE/pump/1/set—onполить сейчас,offостановить;BASE/settings/set— те же поля, что у формы страницы:lampOff=20:00,s1=120&period1=2,left2=0; меняется только то, что передано;BASE/time/set— секунды местного времени, как у кнопки «Часы по этому компьютеру».
Поля настроек страница и MQTT разбирают одной функцией (applySetting), поэтому пределы у них одинаковые: секунды 5…995, период 1…9 дней, «осталось» не больше периода.
Home Assistant
С HA_DISCOVERY = true при каждом подключении прибор публикует объявления в homeassistant/…/config (retain), и Home Assistant заводит устройство «Автополив» сам: датчики температуры и влажности, фитолампу (вкл/выкл) и два выключателя «Насос P1», «Насос P2». Выключатель насоса в Home Assistant — это полить сейчас или остановить; расписание по-прежнему живёт в приборе и работает без Home Assistant и без сети.
Веб-админка, экран и ручка
Всё как у варианта «с веб-админкой»: страница с состоянием, расписанием, «Полить сейчас» и часами по времени браузера — теперь ещё и с карточкой «MQTT: на связи». Экран и ручка — как у версии на Nano; в модели ими удобнее управлять с пульта в углу схемы. Настройки, изменённые с ручки, со страницы или по MQTT, сохраняются в NVS и сразу уходят брокеру в BASE/state.
Как считается полив
«Осталось дней» уменьшается при смене суток (номер суток хранится в NVS — пропущенные выключенным прибором сутки засчитываются). Счётчик дошёл до нуля и наступил час — насос качает заданное число секунд, счётчик снова равен периоду. Включённый позже часа полива прибор польёт сразу.
Силовая часть и отличия от железа
Схема та же: лампа — от сети через qf1, sw1, rl1; насосы — от блока 12 В через sw2/sw3 и rl2/rl3; цепи 230 В и 12 В считаются раздельно. В модели LCD и реле питаются от 3V3 — на железе берите для них 5 В с VIN; реле с оптронами часто включаются низким уровнем — константа RELAY_ON. Сеть не подключилась за 15 секунд — точка доступа Greenhouse (страница на 192.168.4.1); MQTT тогда молчит, а расписание работает.
Библиотеки
Пример собирается с библиотеками:
- WiFi
- WebServer
- Preferences
- PubSubClient
- LiquidCrystal I2C
- RTClib
- Adafruit BusIO
Они уже установлены на сервере сборки — отмечать их в проекте вручную не нужно, список приезжает вместе с примером.
Как это устроено в коде
Основное, чем пользуется скетч:
WebServer— веб-сервер прямо на плате: страницы и обработчики адресовPreferences— хранение настроек во flash, переживает перезапускRTClib— работа с часами реального времениLiquidCrystal_I2C— текстовый экран 16×2 по двум проводамDS3231— точные часы с собственным термокомпенсированным генераторомdigitalRead()— читает уровень на выводеdelay()— пауза, на время которой ядро занято ожиданиемconstrain()— зажимает значение в границахWiFiClient— TCP-соединение с серверомmillis()— миллисекунды с запуска: на них строят работу без остановкиSerial.printf()— печатает в монитор порта с форматированиемSerial.println()— печатает строку в монитор порта
В скетче 750 строк; свои функции: loadSettings(), saveSettings(), onEncoder(), htuRead(), readClimate(), lampShouldBeOn(), startPump(), stopPump(); подключено заголовков: 7. Сам скетч открыт на соседней вкладке — его можно менять и запускать заново.
Что попробовать изменить
- Нажмите «Запустить»: сценарий дождётся сети, поставит часы на 19:59:40 и переведёт P1 на «полить сегодня»; после 20:00 он включит P2 командой MQTT и погасит лампу, поменяв расписание через pinport/greenhouse/settings/set.
- Откройте вкладку «MQTT»: там pinport/greenhouse/status, climate раз в 10 секунд, lamp, pump/1, pump/2 и state, а в homeassistant/… — объявления для Home Assistant.
- Опубликуйте в pinport/greenhouse/pump/1/set текст off — полив P1 остановится, мотор m1 на схеме встанет, в pump/1 уйдёт off.
- Откройте «Веб-интерфейс»: карточка «MQTT: на связи»; поменяйте в форме время насоса — новое состояние тут же уйдёт в pinport/greenhouse/state.
- Опубликуйте в pinport/greenhouse/settings/set строку s2=30&period2=1&left2=0 — насос P2 станет поливать каждый день по 30 секунд, и сегодня польёт сразу, если час уже настал.
Смотрите также
- Подключение Модуль реле — Реле для управления нагрузкой
- Подключение LCD 1602 с I2C — Символьный дисплей 16×2
- Подключение DS3231 — Модуль часов реального времени
- Подключение KY-040 — Энкодер с кнопкой
- Сейф с кодом: клавиатура, сервозамок и OLED — Код набирается на клавиатуре, «#» — ввод, «*» — стереть. Верный код отводит ригель сервоприводом на пять секунд. Три оши…
- Метеостанция с тенденцией давления — Часы, температура, влажность и давление на одном экране — и стрелка, куда идёт давление за последний час. Три устройства…
- Кодовый замок с блокировкой — Клавиатура, экран и реле электрозамка. Код звёздочками, три ошибки подряд — блокировка на полминуты, смена кода с подтве…
- Будильник на DS3231 и энкодере — Время на индикаторе TM1637, будильник настраивается одной ручкой: нажатие — часы, минуты, выход; долгое нажатие — включи…
- Змейка на OLED и энкодере — Классическая змейка на экране 128×64: ручка поворачивает направо и налево, нажатие — пауза. Змейка хранится кольцом по б…
- Термостат с гистерезисом — DHT22 меряет, реле греет, ручка задаёт уставку. Гистерезис в полградуса и выдержка в десять секунд берегут реле. Темпера…
Сборка ещё не запускалась.
Печатайте в Serial числа — они станут графиком.
Serial.println(t); — один ряд,
Serial.printf("t:%.1f h:%.1f\n", t, h); — два с подписями.
Формат тот же, что понимает плоттер Arduino IDE.