#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Preferences.h>
#include <RTClib.h>
#include <LiquidCrystal_I2C.h>
// Автополив и фитолампа на ESP32 с веб-админкой.
//
// То же устройство, что на Arduino Nano: фитолампа 230 В по расписанию,
// два насоса 12 В со своими периодами полива, часы DS3231, датчик
// HTU21D, экран LCD 16x2 и ручка энкодера. Разница — в сети: все
// настройки доступны ещё и со страницы в браузере, прямо из прошивки.
// Откройте её кнопкой «Веб-интерфейс» над схемой.
//
// Страница показывает часы, климат, состояние лампы и насосов (во время
// полива — обратный отсчёт), даёт поменять расписание, полить или
// остановить полив и поставить часы по времени компьютера.
//
// 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 = "";
// --- выводы -----------------------------------------------------------
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; // через полночь
}
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);
}
void stopPump(byte i) {
running[i] = false;
Serial.printf("P%u: полив закончен\n", i + 1);
}
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 ? "лампа включена" : "лампа выключена");
}
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();
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>
<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;
$('#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 += "\"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;
}
// Число из формы в пределах; пустое поле — прежнее значение.
int formInt(const String &name, int was, int lo, int hi) {
if (!server.hasArg(name) || server.arg(name) == "") return was;
return constrain(server.arg(name).toInt(), lo, hi);
}
void handleSettings() {
if (!allowed()) return;
cfg.lampOn = parseHM(server.arg("lampOn"), cfg.lampOn);
cfg.lampOff = parseHM(server.arg("lampOff"), cfg.lampOff);
for (byte i = 0; i < PUMPS; i++) {
Pump &p = cfg.pump[i];
String n(i + 1);
p.seconds = formInt("s" + n, p.seconds, 5, 995);
p.period = formInt("period" + n, p.period, 1, 9);
p.left = formInt("left" + n, p.left, 0, p.period);
p.hour = formInt("hour" + n, p.hour, 0, 23);
}
field = -1; // правка с ручки, если шла, уступает странице
saveSettings();
Serial.println("настройки сохранены (страница)");
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("время установлено (страница)");
server.send(200, "text/plain; charset=utf-8", "Часы установлены");
}
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());
}
// --- монитор порта ----------------------------------------------------
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();
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 с веб-админкой
То же устройство, что на Arduino Nano — фитолампа 230 В по расписанию и два насоса 12 В со своим периодом полива, — но с веб-админкой в прошивке: состояние, расписание, «полить сейчас» и часы по времени браузера прямо со страницы. Экран LCD и ручка энкодера остались; настройки хранятся в NVS.
Что на схеме
Плата 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.
Веб-админка
Страница лежит в самой прошивке — откройте её кнопкой «Веб-интерфейс» над схемой (на железе — по адресу, который показывает последний экран LCD). Она раз в две секунды забирает /api/state и показывает:
- часы, температуру и влажность;
- лампу — горит ли и по какому расписанию;
- каждый насос — через сколько дней полив, а во время полива — обратный отсчёт; кнопки «Полить сейчас» и «Остановить».
Ниже — форма расписания: время включения и выключения лампы, у каждого насоса секунды, период, «осталось дней» и час. «Сохранить» пишет всё в NVS сразу. Кнопка «Часы по этому компьютеру» ставит DS3231 по времени браузера — на железе это самый простой способ завести часы.
Всё, что делает страница, — пять адресов, их можно дёргать и без неё (из Home Assistant, curl, скрипта):
GET /api/state— состояние в JSON;POST /api/settings— поля формы:lampOn=07:00,lampOff=21:00,s1,period1,left1,hour1и то же для насоса 2;POST /api/water—p=1полить,p=1&stop=1остановить;POST /api/time—epoch=…, секунды местного времени.
Пароль на страницу — константа ADMIN_PASSWORD (логин admin); пустая строка — без пароля. Если своя сеть не подключилась за 15 секунд, плата поднимает точку доступа Greenhouse (пароль greenhouse), и страница открывается по адресу 192.168.4.1.
Экран и ручка
Всё как у версии на Arduino Nano. Верхняя строка — часы, температура, влажность; нижняя листается поворотом:
L 07:00-21:00 ON— фитолампа;1 100s 3d(2) 20h— насос P1: качать 100 секунд, раз в 3 дня, осталось 2 дня (0 — сегодня), в 20:00;2 60s 2d(1) 08h— насос P2;- адрес страницы.
Нажатие — правка: мигает поле, поворот меняет его, следующее нажатие — следующее поле, после последнего настройки сохраняются. Долгое нажатие во время правки — отмена; на экране насоса — полить сейчас или остановить. Правка, начатая на странице, вытесняет правку с ручки — сохраняется то, что нажали последним.
В модели ручкой удобнее управлять с пульта в правом нижнем углу схемы: щёлкните по нему — стрелки ← → поворачивают, пробел нажимает.
Как считается полив
«Осталось дней» уменьшается при смене суток; номер последних суток хранится в NVS вместе с настройками, поэтому сутки, пропущенные выключенным прибором, тоже засчитываются. Когда счётчик дошёл до нуля и наступил час полива, реле насоса включается на заданное число секунд, а счётчик снова становится равен периоду. Прибор, включённый позже часа полива, польёт сразу, а не пропустит день.
Силовая часть
Фитолампа — от сети через автомат qf1, выключатель канала sw1 и реле rl1. Насосы — от блока 12 В через свои выключатели (sw2, sw3) и реле (rl2, rl3). Выключатели стоят последовательно с контактами реле: выключенный канал не заработает, что бы ни решила прошивка. Цепи 230 В и 12 В считаются раздельно, у каждой свой разбор на вкладке «Силовая часть».
Свободные реле модуля — на выводах 32, 33 и 14. Третий насос — третий вывод в RELAY_PUMP и строка в DEFAULT_PUMPS: экран, страница, настройки и полив подхватят его сами.
Чем модель отличается от устройства
- Питание. Модули I2C и реле в модели запитаны от 3V3. На железе LCD 1602 и модуль реле обычно просят 5 В: питайте их от VIN (5 В от USB или понижающего преобразователя), а входы IN реле и шина I2C 3,3 В им подходят — у модулей свои подтяжки и оптроны.
- Уровень включения реле. В модели — высокий. Модули с оптронами часто включаются низким — поменяйте
RELAY_ON. - Автомат
qf1в модели добавлен: линию 230 В без защиты не стоит оставлять и в устройстве. - HTU21D в модели отвечает сразу; на железе измерение занимает до 50 мс, и скетч это время ждёт.
Библиотеки
Пример собирается с библиотеками:
- WiFi
- WebServer
- Preferences
- LiquidCrystal I2C
- RTClib
- Adafruit BusIO
Они уже установлены на сервере сборки — отмечать их в проекте вручную не нужно, список приезжает вместе с примером.
Как это устроено в коде
Основное, чем пользуется скетч:
WebServer— веб-сервер прямо на плате: страницы и обработчики адресовPreferences— хранение настроек во flash, переживает перезапускRTClib— работа с часами реального времениLiquidCrystal_I2C— текстовый экран 16×2 по двум проводамDS3231— точные часы с собственным термокомпенсированным генераторомdigitalRead()— читает уровень на выводеdelay()— пауза, на время которой ядро занято ожиданиемconstrain()— зажимает значение в границахmillis()— миллисекунды с запуска: на них строят работу без остановкиSerial.printf()— печатает в монитор порта с форматированиемSerial.println()— печатает строку в монитор портаdigitalWrite()— выставляет на выводе высокий или низкий уровень
В скетче 572 строки; свои функции: loadSettings(), saveSettings(), onEncoder(), htuRead(), readClimate(), lampShouldBeOn(), startPump(), stopPump(); подключено заголовков: 6. Сам скетч открыт на соседней вкладке — его можно менять и запускать заново.
Что попробовать изменить
- Нажмите «Запустить»: сценарий дождётся сети, поставит часы на 19:59:40, переведёт ручкой насос P1 на «полить сегодня» — в 20:00 насос на схеме закрутится.
- Откройте «Веб-интерфейс»: время, климат, лампа и оба насоса с обратным отсчётом полива. Нажмите «Полить сейчас» у насоса P2 — на схеме закрутится второй мотор.
- Поменяйте в форме время выключения лампы на ближайшее и нажмите «Сохранить» — лампа погаснет, а экран LCD покажет новое расписание.
- Нажмите «Часы по этому компьютеру»: DS3231 встанет на время вашего браузера — так часы заводят и на железе.
- Поставьте пароль в ADMIN_PASSWORD и запустите снова: страница спросит логин admin и пароль.
Смотрите также
- Подключение Модуль реле — Реле для управления нагрузкой
- Подключение 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.