#include <WiFi.h>
#include <Wire.h>
#include <LittleFS.h>
#include <Preferences.h>
#include <RTClib.h>
#include <LiquidCrystal_I2C.h>
#include <SettingsESP.h>
// Автополив и фитолампа на ESP32: панель на GyverLibs/Settings.
//
// То же устройство, что в примере с веб-админкой: фитолампа 230 В по
// расписанию, два насоса 12 В со своими периодами полива, часы DS3231,
// датчик HTU21D, экран LCD 16x2 и ручка энкодера. Разница в том, как
// сделана страница: вместо своего HTML, JavaScript и пяти адресов —
// библиотека Settings (https://github.com/GyverLibs/Settings). Скетч
// только перечисляет в build(), что стоит на странице, а саму страницу,
// обмен с ней и обновления на лету библиотека берёт на себя.
// Откройте её кнопкой «Веб-интерфейс» над схемой.
//
// На странице: часы, климат, лампа и насосы с обратным отсчётом (раз в
// две секунды, через onUpdate), расписание лампы, у каждого насоса —
// секунды, период, «осталось дней», час и кнопки «Полить» и «Стоп»,
// часы DS3231 и журнал событий. В меню страницы — файлы LittleFS платы.
//
// Экран и ручка — как у остальных версий: верхняя строка — часы и
// климат, нижняя листается поворотом:
//
// 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";
// Пароль на страницу. Пусто — без пароля. С паролем Settings сперва
// спрашивает его и без него ничего не показывает.
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);
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;
}
// --- журнал ----------------------------------------------------------
// Каждое событие — в монитор порта и в журнал на странице: виджет Log
// показывает буфер, onUpdate досылает новые строки.
sets::Logger journal(1500);
void note(const char *fmt, ...) {
char line[64];
va_list ap;
va_start(ap, fmt);
vsnprintf(line, sizeof line, fmt, ap);
va_end(ap);
Serial.println(line);
DateTime now = rtc.now();
journal.printf("%02d:%02d:%02d %s\n", now.hour(), now.minute(), now.second(), line);
}
// --- каналы -----------------------------------------------------------
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();
note("P%u: полив %u с (%s)", i + 1, cfg.pump[i].seconds, why);
}
void stopPump(byte i) {
running[i] = false;
note("P%u: полив закончен", 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;
note(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();
note("настройки сохранены");
}
} else if (k == LONG) {
if (field >= 0) {
field = -1;
loadSettings();
note("правка отменена");
} 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();
}
}
// --- страница: GyverLibs/Settings -------------------------------------
SettingsESP sett("Автополив");
// Номера виджетов, которые обновляются на лету. Остальным номера
// раздаёт сама библиотека (вниз от UINT32_MAX), поэтому свои берём
// маленькие — не пересекутся.
enum : size_t { W_CLOCK = 1, W_CLIMATE, W_LAMP, W_JOURNAL, W_PUMP = 10 };
String pumpText(byte i) {
Pump &p = cfg.pump[i];
char s[48];
if (running[i]) snprintf(s, sizeof s, "полив, ещё %u с", remaining(i));
else if (p.left == 0) snprintf(s, sizeof s, "сегодня в %02u:00", p.hour);
else snprintf(s, sizeof s, "через %u дн. в %02u:00", p.left, p.hour);
return s;
}
String clockText() {
DateTime now = rtc.now();
char s[24];
snprintf(s, sizeof s, "%02d.%02d.%04d %02d:%02d:%02d", now.day(), now.month(), now.year(),
now.hour(), now.minute(), now.second());
return s;
}
String climateText() {
if (isnan(temperature) || isnan(humidity)) return "датчик молчит";
return String(temperature, 1) + " °C, " + String(humidity, 0) + " %";
}
// Страница целиком. build() зовётся и чтобы нарисовать страницу, и
// чтобы применить действие с неё: тронутый виджет возвращает true и к
// этому моменту уже записал новое значение по указателю.
void build(sets::Builder &b) {
bool changed = false;
if (b.beginGroup("Сейчас")) {
b.Label(W_CLOCK, "Часы", clockText());
b.Label(W_CLIMATE, "Климат", climateText());
b.LED(W_LAMP, "Фитолампа", lampLit);
for (byte i = 0; i < PUMPS; i++) b.Label(W_PUMP + i, String("Насос P") + (i + 1), pumpText(i));
b.endGroup();
}
if (b.beginGroup("Фитолампа")) {
// Time хранит секунды от полуночи, расписание — минуты.
uint32_t on = cfg.lampOn * 60UL, off = cfg.lampOff * 60UL;
if (b.Time("Включение", &on)) { cfg.lampOn = (on / 60) % 1440; changed = true; }
if (b.Time("Выключение", &off)) { cfg.lampOff = (off / 60) % 1440; changed = true; }
b.endGroup();
}
for (byte i = 0; i < PUMPS; i++) {
Pump &p = cfg.pump[i];
if (b.beginGroup(String("Насос P") + (i + 1))) {
if (b.Slider("Качать", 5, 995, 5, " с", &p.seconds)) changed = true;
if (b.Spinner("Раз в, дней", 1, 9, 1, &p.period)) changed = true;
if (b.Spinner("Осталось дней", 0, 9, 1, &p.left)) changed = true;
if (b.Spinner("Час полива", 0, 23, 1, &p.hour)) changed = true;
if (p.left > p.period) p.left = p.period;
if (b.beginButtons()) {
if (b.Button("Полить", sets::Colors::Aqua) && !running[i]) startPump(i, "со страницы");
if (b.Button("Стоп", sets::Colors::Red) && running[i]) stopPump(i);
b.endButtons();
}
b.endGroup();
}
}
if (b.beginGroup("Часы DS3231")) {
// DS3231 идёт по местному времени, а DateTime по умолчанию считает
// значение всемирным и сдвигает на пояс браузера. Пояс 0 — показать
// и принять как есть.
uint32_t t = rtc.now().unixtime();
if (b.DateTime("Дата и время", &t, 0)) {
rtc.adjust(DateTime(t));
note("время установлено (страница)");
}
b.endGroup();
}
b.Log(W_JOURNAL, journal, "Журнал");
if (changed) {
field = -1; // правка с ручки, если шла, уступает странице
saveSettings();
note("настройки сохранены (страница)");
}
}
// Раз в две секунды страница спрашивает, что поменялось.
void update(sets::Updater &u) {
u.update(W_CLOCK, clockText());
u.update(W_CLIMATE, climateText());
u.update(W_LAMP, lampLit);
for (byte i = 0; i < PUMPS; i++) u.update(W_PUMP + i, pumpText(i));
u.update(W_JOURNAL, journal);
}
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);
}
// Файлы в меню страницы — это LittleFS. На чистой флеш-памяти
// begin(true) её размечает; LittleFS при этом пишет в порт, что
// раздел испорчен, — при первом включении так и должно быть.
if (!LittleFS.begin(true)) Serial.println("LittleFS не поднялась");
if (*ADMIN_PASSWORD) sett.setPass(ADMIN_PASSWORD);
sett.begin();
sett.onBuild(build);
sett.onUpdate(update);
sett.setUpdatePeriod(2000);
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));
note("время установлено");
} 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()));
note("дата установлена");
} else if (buf == "status") {
Serial.println(clockText() + ", " + climateText() + ", лампа " + (lampLit ? "горит" : "не горит"));
for (byte i = 0; i < PUMPS; i++) Serial.printf("P%u: %s\n", i + 1, pumpText(i).c_str());
} 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;
sett.tick();
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: панель на GyverLibs/Settings
Четвёртый вариант прибора: веб-админка не написана руками, а собрана библиотекой GyverLibs/Settings — скетч только перечисляет виджеты. Часы, климат, лампа и насосы обновляются на лету, расписание правится слайдерами и полями времени, есть журнал событий и файлы LittleFS. Экран и ручка остались.
Что на схеме
Плата 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.
Панель на Settings
В примере с веб-админкой страница написана руками: HTML, JavaScript и пять адресов /api/… — около сотни строк, которые к самому поливу отношения не имеют. Здесь их нет. Страницу рисует библиотека GyverLibs/Settings, а скетч только перечисляет в функции build(), что на ней стоит:
if (b.beginGroup("Фитолампа")) {
uint32_t on = cfg.lampOn * 60UL;
if (b.Time("Включение", &on)) { cfg.lampOn = on / 60; changed = true; }
...
}
Та же функция и принимает действия со страницы: виджет, который тронули, возвращает true и уже записал новое значение по указателю. Кнопка — тоже виджет: if (b.Button("Полить")) startPump(…).
Откройте панель кнопкой «Веб-интерфейс» над схемой (на железе — по адресу с последнего экрана LCD). На ней:
- Сейчас — часы, климат, лампа (индикатор) и оба насоса: через сколько дней полив, а во время полива — обратный отсчёт. Эти поля обновляются сами раз в две секунды: страница спрашивает,
onUpdateотвечает тем, что поменялось; - Фитолампа — время включения и выключения;
- Насос P1, P2 — слайдер секунд, период, «осталось дней», час полива и кнопки «Полить» и «Стоп»;
- Часы DS3231 — дата и время в поясе браузера: поменяли — часы встали;
- Журнал — всё, что прибор пишет в монитор порта: включения лампы, поливы, сохранения.
Любая правка сразу пишется в NVS — там же, где её сохраняет ручка, так что экран и страница показывают одно и то же. В меню панели (☰) есть файловый менеджер: это LittleFS платы.
Пароль — константа ADMIN_PASSWORD; пустая строка — без пароля. Если своя сеть не подключилась за 15 секунд, плата поднимает точку доступа Greenhouse (пароль greenhouse), и панель открывается по адресу 192.168.4.1.
Библиотека в модели
Settings — сложная библиотека: страница приходит сжатой (gzip), общается с платой двоичным BSON, держит свои настройки в localStorage браузера и пользуется LittleFS. Всё это в PinPort работает с 1.8.44. Ставить её не нужно — вместе с зависимостями (GyverDB, GTL, StringUtils, GyverHTTP, BSON, Stamp, Table, StreamIO, FOR_MACRO) она уже на сервере сборки.
При первом запуске в мониторе порта будут строки Corrupted dir pair и mount failed — это LittleFS находит чистую флеш-память и размечает её (LittleFS.begin(true)). На новой плате будет то же самое.
Модель медленнее настоящей платы, а панель ждёт ответа две секунды. Если на медленном компьютере она показывает «нет связи», прибавьте sett.config.requestTout в startNetwork().
Экран и ручка
Всё как у остальных версий. Верхняя строка — часы, температура, влажность; нижняя листается поворотом:
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. Свободные реле модуля — на выводах 32, 33 и 14: третий насос — третий вывод в RELAY_PUMP и строка в DEFAULT_PUMPS, и панель сама покажет для него ещё одну группу.
Чем модель отличается от устройства
- Питание. Модули I2C и реле в модели запитаны от 3V3. На железе LCD 1602 и модуль реле обычно просят 5 В: питайте их от VIN, а входы IN реле и шина I2C 3,3 В им подходят.
- Уровень включения реле. В модели — высокий. Модули с оптронами часто включаются низким — поменяйте
RELAY_ON. - Строки об ошибке LittleFS при первом запуске. «Corrupted dir pair» и «mount failed (-84)» в мониторе порта — это нормально: флеш-память чистая,
LittleFS.begin(true)сама её размечает и монтирует заново. На настоящей плате при первой прошивке выводится то же самое; при следующем запуске строк уже нет. - Память. Прошивка с Settings занимает около 930 КБ — на ESP32 с обычной разметкой (1,2 МБ под программу) места хватает, но OTA-разметку «минимальный SPIFFS» выбирать не придётся.
Библиотеки
Пример собирается с библиотеками:
- WiFi
- Preferences
- LittleFS
- LiquidCrystal I2C
- RTClib
- Adafruit BusIO
- Settings
Они уже установлены на сервере сборки — отмечать их в проекте вручную не нужно, список приезжает вместе с примером.
Как это устроено в коде
Основное, чем пользуется скетч:
Preferences— хранение настроек во flash, переживает перезапускRTClib— работа с часами реального времениLiquidCrystal_I2C— текстовый экран 16×2 по двум проводамDS3231— точные часы с собственным термокомпенсированным генераторомdigitalRead()— читает уровень на выводеdelay()— пауза, на время которой ядро занято ожиданиемconstrain()— зажимает значение в границахSerial.println()— печатает строку в монитор портаmillis()— миллисекунды с запуска: на них строят работу без остановкиdigitalWrite()— выставляет на выводе высокий или низкий уровеньWiFi.begin()— подключается к точке доступа виртуального Wi-FiWiFi.softAP()— поднимает собственную точку доступа
В скетче 560 строк; свои функции: loadSettings(), saveSettings(), onEncoder(), htuRead(), readClimate(), note(), lampShouldBeOn(), startPump(); подключено заголовков: 7. Сам скетч открыт на соседней вкладке — его можно менять и запускать заново.
Что попробовать изменить
- Нажмите «Запустить»: сценарий дождётся сети, поставит часы на 19:59:40, переведёт ручкой насос P1 на «полить сегодня» — в 20:00 насос на схеме закрутится.
- Откройте «Веб-интерфейс»: панель Settings с часами, климатом, лампой и насосами. Нажмите «Полить» у насоса P2 — на схеме закрутится второй мотор, а в «Сейчас» пойдёт обратный отсчёт.
- Сдвиньте время выключения лампы на ближайшее — лампа погаснет, экран LCD покажет новое расписание, а в журнале появится «настройки сохранены (страница)».
- Поверните ручку на пульте и поменяйте период насоса — после сохранения панель покажет новое значение.
- Впишите пароль в ADMIN_PASSWORD и запустите снова: панель сначала спросит его.

Смотрите также
- Подключение Модуль реле — Реле для управления нагрузкой
- Подключение 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.