Создаём умный полив на ESP8266: полное руководство по коду, веб-интерфейсу и OTA-обновлениям

В этой статье мы разработаем полноценную систему автоматического полива на базе ESP8266. Проект включает в себя:
- Управление насосом (реле) по данным датчика влажности почвы.
- Визуальную индикацию состояния с помощью RGB LED (WS2812B).
- Веб-интерфейс для мониторинга и настройки (адаптивный, работает с любого устройства).
- Сохранение настроек в EEPROM.
- Возможность обновления прошивки «по воздуху» (OTA) и загрузки файлов интерфейса через браузер.
Всё это реализовано в одном проекте, и сегодня мы разберём его устройство от и до.
Содержание
- Что умеет система
- Необходимые компоненты
- Схема подключения
- Разбор кода ESP8266
- Библиотеки и настройки
- Управление LED (эффекты)
- Чтение датчика влажности
- Работа с EEPROM (сохранение настроек)
- Веб-сервер и обработчики
- Логика автоматического полива
- OTA-обновление прошивки
- Веб-интерфейс (HTML/CSS/JS)
- Структура страницы
- Стили
- Клиентская логика (обмен данными, OTA)
- Загрузка файлов в LittleFS
- Итоги и возможные улучшения
Что умеет система
- Автоматический полив: система регулярно измеряет влажность почвы и при падении ниже заданного порога включает насос на определённое время.
- Защита от частых поливов: задаётся минимальный интервал между поливами (10 минут) и максимальное количество поливов подряд (3), чтобы избежать перелива.
- Веб-интерфейс:
- Отображение текущей влажности, статуса полива, информации об устройстве (версия прошивки, аптайм, RSSI, IP).
- Настройка порога срабатывания и времени работы насоса.
- Кнопка ручного полива.
- Раздел для обновления прошивки (OTA) и загрузки файлов интерфейса (LittleFS).
- Визуальная индикация:
- RGB LED показывает состояние: готовность, низкая влажность, полив, ошибка датчика, OTA-обновление.
- Энергонезависимое хранение: настройки сохраняются в EEPROM с контрольной суммой.
Необходимые компоненты
- Плата ESP8266 (NodeMCU, Wemos D1 mini и т.п.)
- Реле (для управления насосом/электроклапаном)
- Датчик влажности почвы (ёмкостной или резистивный)
- LED лента или модуль на WS2812B (один светодиод)
- Блок питания 5В для ESP и отдельно для насоса (с учётом пускового тока)
- Провода, корпус, трубочки для полива (по желанию)
Схема подключения
| Компонент | ESP8266 пин |
|---|---|
| Реле (IN) | D1 (GPIO5) |
| Датчик влажности | A0 (ADC0) |
| WS2812B (DI) | D6 (GPIO12) |
Питание: ESP8266 питается от USB или внешнего 5В. Датчик и реле могут питаться от 3.3В или 5В, смотрите спецификации.
Разбор кода ESP8266
Библиотеки и настройки
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <EEPROM.h>
#include <LittleFS.h>
#include <FastLED.h>
#include <Updater.h>
- FastLED – для управления RGB светодиодом.
- Updater – для OTA-обновлений.
- LittleFS – файловая система для хранения веб-страниц.
Константы задают:
- SSID и пароль Wi-Fi.
- Пины реле, датчика, LED.
- Калибровочные значения датчика (сухо/влажно) – подбираются экспериментально.
- Время опроса датчика (10 секунд), интервал между поливами (10 минут), максимальное количество поливов подряд (3).
Управление LED (эффекты)
Для одного светодиода WS2812B реализованы эффекты:
- Нормальное свечение (LED_NORMAL)
- Мигание (LED_BLINK, LED_FAST_BLINK)
- Плавное дыхание (LED_BREATH)
Цвета задаются в зависимости от состояния:
- Голубой – подключение к Wi-Fi (мигание)
- Зелёный – влажность в норме (дыхание)
- Жёлтый – низкая влажность, ожидание полива (дыхание)
- Синий – идёт полив (быстрое мигание)
- Фиолетовый – достигнут лимит поливов (можно использовать)
- Красный – ошибка датчика (мигание)
- Пурпурный – OTA-обновление (быстрое мигание)
Функция updateLED() выбирает режим в зависимости от глобальных переменных (pumpRunning, moisture, setpoint, otaInProgress).
Чтение датчика влажности
int readSoil() {
int raw = analogRead(SOIL_PIN);
raw = constrain(raw, SOIL_WET, SOIL_DRY);
return map(raw, SOIL_DRY, SOIL_WET, 0, 100);
}
Сырое значение АЦП преобразуется в проценты влажности. Значения SOIL_DRY и SOIL_WET нужно заменить на реальные, полученные при погружении датчика в сухую и влажную почву.
Работа с EEPROM (сохранение настроек)
Настройки (порог setpoint и время полива pumpTime) сохраняются с контрольной суммой для проверки целостности. При загрузке, если сумма не совпадает или значения выходят за пределы, применяются значения по умолчанию (40% и 5 секунд).
Веб-сервер и обработчики
Сервер работает на порту 80 и обслуживает следующие маршруты:
GET /– отдаётindex.htmlиз LittleFS.GET /status– возвращает JSON со всеми данными (влажность, уставка, статус полива, версия, аптайм, RSSI, IP).POST /set– принимает параметрыspиpt, сохраняет настройки в EEPROM.POST /manual– запускает ручной полив.POST /firmware– принимает бинарный файл прошивки и выполняет OTA-обновление.POST /upload– принимает файл для записи в LittleFS.GET /style.css,/script.js– статические файлы из LittleFS.
Для OTA используется объект Update. Во время загрузки устанавливается флаг otaInProgress, который меняет поведение светодиода.
Логика автоматического полива
В основном цикле loop() с интервалом SOIL_INTERVAL (10 секунд) выполняется:
- Чтение датчика.
- Проверка корректности значения (0–100). Если датчик неисправен, автоматический полив отключается, LED мигает красным.
- Если влажность ниже уставки и прошло достаточно времени с последнего полива, и количество поливов подряд не превысило лимит, запускается насос.
- При включении насоса запоминается время старта, увеличивается счётчик поливов.
- По истечении времени работы (pumpTime секунд) насос отключается.
- Когда влажность возвращается в норму, счётчик поливов сбрасывается.
OTA-обновление прошивки
Обработчик /firmware принимает бинарный файл прошивки, записывает его через Update и перезагружает ESP8266. Для безопасности можно добавить проверку размера и контрольной суммы, но в данном примере используется базовая реализация.
Веб-интерфейс (HTML/CSS/JS)
Теперь перейдём к клиентской части. Все файлы веб-интерфейса хранятся в папке data проекта и загружаются в LittleFS.
Структура страницы (index.html)
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Умный полив</title>
<link rel="stylesheet" href="style.css">
<style>
.collapsible {
cursor: pointer;
padding: 10px;
background-color: #f1f1f1;
border: none;
text-align: left;
outline: none;
font-size: 16px;
border-radius: 6px;
margin-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.collapsible:hover {
background-color: #e0e0e0;
}
.collapsible:after {
content: '\25BC';
font-size: 14px;
transition: transform 0.3s;
}
.collapsible.active:after {
transform: rotate(180deg);
}
.content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
background-color: #f9f9f9;
border-radius: 0 0 6px 6px;
}
.content.active {
max-height: 600px;
padding: 10px;
margin-top: -6px;
border-top: 1px solid #ddd;
}
#otaProgress {
margin-top: 10px;
display: none;
}
#otaBar {
height: 100%;
width: 0%;
background: #4CAF50;
transition: width 0.2s;
}
#otaPercent {
margin-top: 5px;
font-size: 14px;
text-align: center;
}
</style>
</head>
<body>
<div class="card">
<h1>🌱 Влажность почвы</h1>
<div id="moisture">--%</div>
<label>Уставка (%)</label>
<input type="number" id="setpoint">
<label>Время полива (сек)</label>
<input type="number" id="pumpTime">
<button onclick="save()">💾 Сохранить</button>
<button class="water" onclick="manual()">🚿 Полить сейчас</button>
<p id="status"></p>
<div id="deviceInfo" style="
margin-top:10px;
padding:10px;
background:#f5f5f5;
border-radius:6px;
font-size:14px;
">
<div>📦 Версия: <span id="fwVersion">—</span></div>
<div>⏱ Uptime: <span id="uptime">—</span></div>
<div>📶 Wi-Fi: <span id="rssi">—</span></div>
<div>🌐 IP: <span id="ip">—</span></div>
</div>
<hr>
<!-- КНОПКА СВОРАЧИВАНИЯ -->
<button class="collapsible" onclick="toggleUpdateSection()">
<span>⚙ Обновление устройства</span>
</button>
<!-- СЕКЦИЯ ОБНОВЛЕНИЯ -->
<div class="content" id="updateSection">
<label>Прошивка (.bin)</label>
<input type="file" id="fw">
<button onclick="uploadFW()">⬆ Обновить прошивку</button>
<label>Файлы сайта (SPIFFS)</label>
<input type="file" id="fs">
<button onclick="uploadFS()">⬆ Обновить файлы</button>
<p id="otaStatus"></p>
<!-- ПРОГРЕСС OTA -->
<div id="otaProgress">
<div style="height:20px; background:#ddd; border-radius:6px; overflow:hidden;">
<div id="otaBar"></div>
</div>
<div id="otaPercent">0%</div>
</div>
</div>
</div>
<!-- ОСНОВНОЙ JS -->
<script src="script.js"></script>
<!-- ЛОГИКА СВОРАЧИВАНИЯ -->
<script>
function toggleUpdateSection() {
const content = document.getElementById('updateSection');
const button = document.querySelector('.collapsible');
content.classList.toggle('active');
button.classList.toggle('active');
}
</script>
</body>
</html>
HTML-документ представляет собой карточку с адаптивным дизайном. Ключевые элементы:
- Индикатор влажности – обновляется динамически.
- Поля ввода уставки и времени полива.
- Кнопки «Сохранить» и «Полить сейчас».
- Блок статуса – отображает текущее состояние.
- Информация об устройстве – версия, аптайм, RSSI, IP.
- Сворачиваемая секция обновления – позволяет загружать прошивку и файлы сайта.
Особенность – реализация сворачивания на чистом CSS с переходом max-height.
Стили (style.css)
.info-item {
padding: 4px 0;
border-bottom: 1px solid #ddd;
}
.info-item:last-child {
border-bottom: none;
}
body {
background: #eef2f3;
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
.card {
max-width: 350px;
margin: 40px auto;
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
text-align: center;
}
h1 {
margin-bottom: 10px;
}
#moisture {
font-size: 48px;
margin: 15px 0;
color: #4CAF50;
}
label {
display: block;
margin-top: 10px;
text-align: left;
font-weight: bold;
}
input {
width: 100%;
padding: 8px;
margin-top: 5px;
margin-bottom: 10px;
font-size: 16px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 10px;
margin-top: 5px;
font-size: 16px;
border: none;
border-radius: 6px;
background: #4CAF50;
color: white;
cursor: pointer;
}
button.water {
background: #2196F3;
}
button:hover {
opacity: 0.9;
}
hr {
margin: 20px 0;
border: none;
border-top: 1px solid #eee;
}
#status {
margin: 15px 0;
padding: 10px;
border-radius: 6px;
background-color: #f5f5f5;
}
#otaStatus {
margin-top: 10px;
font-size: 14px;
padding: 8px;
border-radius: 4px;
background-color: #f0f0f0;
}
CSS минималистичен, но включает всё необходимое:
- Центрирование карточки, адаптивная ширина.
- Оформление кнопок, полей ввода.
- Цветовое выделение индикатора влажности и статуса.
- Базовые стили для прогресс-бара OTA.
Клиентская логика (script.js)
let lastStatus = "";
let waterTimer = null;
// Форматирование времени
function formatTime(ms) {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
// Обновление статуса каждые 2 секунды
setInterval(() => {
fetch('/status')
.then(r => r.json())
.then(data => {
document.getElementById('moisture').innerText = data.moisture + "%";
document.getElementById('setpoint').value = data.setpoint;
document.getElementById('pumpTime').value = data.pumpTime;
// Информация об устройстве
document.getElementById('fwVersion').innerText = data.version;
document.getElementById('uptime').innerText =
formatUptime(data.uptime);
document.getElementById('rssi').innerText =
data.rssi + " dBm";
document.getElementById('ip').innerText =
data.ip;
const statusElem = document.getElementById('status');
if (lastStatus !== data.status) {
lastStatus = data.status;
console.log("Статус изменился:", data.status);
}
updateStatusDisplay(data, statusElem);
})
.catch(e => {
console.error('Ошибка:', e);
document.getElementById('status').innerText = "❌ Ошибка связи";
document.getElementById('status').style.color = "#f44336";
});
}, 2000);
function formatUptime(sec) {
const d = Math.floor(sec / 86400);
sec %= 86400;
const h = Math.floor(sec / 3600);
sec %= 3600;
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${d}д ${h}ч ${m}м ${s}с`;
}
// ===== ОТОБРАЖЕНИЕ СТАТУСА =====
function updateStatusDisplay(data, statusElem) {
let color = "#4CAF50";
let icon = "✅";
let text = data.status;
switch (data.status) {
case "Полив идет":
color = "#2196F3";
icon = "💧";
break;
case "Низкая влажность":
color = "#FF9800";
icon = "⚠️";
break;
case "Лимит поливов":
color = "#F44336";
icon = "⛔";
text += ` (${data.wateringCount || 0}/3)`;
break;
case "Ошибка датчика":
color = "#F44336";
icon = "❌";
break;
case "Ожидание перерыва":
color = "#9C27B0";
icon = "⏳";
break;
}
statusElem.innerHTML = `${icon} ${text}`;
statusElem.style.color = color;
showAdditionalInfo(data);
}
// ===== ДОП. ИНФОРМАЦИЯ =====
function showAdditionalInfo(data) {
const infoDiv = document.getElementById('additionalInfo') || createAdditionalInfoDiv();
let html = "";
if (data.moisture < data.setpoint && data.autoWaterEnabled === "true") {
html += `<div>📉 Влажность ниже уставки</div>`;
}
if (data.wateringCount > 0) {
html += `<div>🔄 Поливов подряд: ${data.wateringCount}/3</div>`;
}
if (data.canWater === "false") {
html += `<div>⏰ Ожидание перерыва между поливами</div>`;
}
infoDiv.innerHTML = html;
}
function createAdditionalInfoDiv() {
const div = document.createElement('div');
div.id = 'additionalInfo';
div.style.marginTop = '10px';
div.style.padding = '10px';
div.style.background = '#f5f5f5';
div.style.borderRadius = '6px';
div.style.fontSize = '14px';
document.getElementById('status').after(div);
return div;
}
// ===== СОХРАНЕНИЕ НАСТРОЕК =====
function save() {
let sp = document.getElementById('setpoint').value;
let pt = document.getElementById('pumpTime').value;
if (sp < 0 || sp > 100) return alert("Уставка 0–100%");
if (pt < 1 || pt > 60) return alert("Время 1–60 сек");
fetch('/set', {
method: 'POST',
body: new URLSearchParams({ sp, pt })
})
.then(r => r.text())
.then(txt => alert(txt))
.catch(e => alert("Ошибка: " + e));
}
// ===== РУЧНОЙ ПОЛИВ =====
function manual() {
if (!confirm("Запустить ручной полив?")) return;
fetch('/manual', { method: 'POST' })
.then(r => r.text())
.then(txt => alert(txt))
.catch(e => alert("Ошибка: " + e));
}
// ==================================================
// ================= OTA ОБНОВЛЕНИЕ =================
// ==================================================
function uploadFW() {
const file = document.getElementById('fw').files[0];
if (!file) return alert("Выберите .bin файл");
if (!file.name.endsWith('.bin')) return alert("Файл должен быть .bin");
if (!confirm("Обновить прошивку? Устройство перезагрузится автоматически.")) return;
const form = new FormData();
form.append('file', file);
const status = document.getElementById('otaStatus');
const progressBox = document.getElementById('otaProgress');
const bar = document.getElementById('otaBar');
const percent = document.getElementById('otaPercent');
progressBox.style.display = "block";
bar.style.width = "0%";
percent.innerText = "0%";
status.innerText = "⏳ Загрузка прошивки...";
status.style.color = "#FF9800";
const xhr = new XMLHttpRequest();
xhr.open("POST", "/firmware", true);
// 📊 Прогресс загрузки
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
const p = Math.round((e.loaded / e.total) * 100);
bar.style.width = p + "%";
percent.innerText = p + "%";
}
};
// ✅ Успех
xhr.onload = () => {
if (xhr.status === 200) {
bar.style.width = "100%";
percent.innerText = "100%";
status.innerText = "✅ Прошивка загружена. Перезагрузка...";
status.style.color = "#4CAF50";
// 🔁 авто-перезагрузка страницы
setTimeout(() => {
location.reload();
}, 8000);
} else {
status.innerText = "❌ Ошибка прошивки";
status.style.color = "#F44336";
}
};
xhr.onerror = () => {
status.innerText = "❌ Ошибка соединения";
status.style.color = "#F44336";
};
xhr.send(form);
}
function uploadFS() {
const file = document.getElementById('fs').files[0];
if (!file) return alert("Выберите файл");
const allowed = ['.html','.css','.js','.png','.jpg','.ico'];
if (!allowed.some(e => file.name.endsWith(e))) {
return alert("Недопустимый файл");
}
const form = new FormData();
form.append('file', file);
const status = document.getElementById('otaStatus');
const progressBox = document.getElementById('otaProgress');
const bar = document.getElementById('otaBar');
const percent = document.getElementById('otaPercent');
progressBox.style.display = "block";
bar.style.width = "0%";
percent.innerText = "0%";
status.innerText = "⏳ Загрузка файлов...";
status.style.color = "#FF9800";
const xhr = new XMLHttpRequest();
xhr.open("POST", "/upload", true);
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
const p = Math.round((e.loaded / e.total) * 100);
bar.style.width = p + "%";
percent.innerText = p + "%";
}
};
xhr.onload = () => {
status.innerText = "✅ Файлы обновлены";
status.style.color = "#4CAF50";
};
xhr.onerror = () => {
status.innerText = "❌ Ошибка загрузки";
status.style.color = "#F44336";
};
xhr.send(form);
}
JavaScript отвечает за динамическое обновление страницы и отправку запросов на ESP8266.
Периодическое обновление статуса
setInterval(() => {
fetch('/status')
.then(r => r.json())
.then(data => {
document.getElementById('moisture').innerText = data.moisture + "%";
document.getElementById('setpoint').value = data.setpoint;
document.getElementById('pumpTime').value = data.pumpTime;
document.getElementById('fwVersion').innerText = data.version;
document.getElementById('uptime').innerText = formatUptime(data.uptime);
document.getElementById('rssi').innerText = data.rssi + " dBm";
document.getElementById('ip').innerText = data.ip;
updateStatusDisplay(data, document.getElementById('status'));
})
.catch(e => console.error('Ошибка:', e));
}, 2000);
Каждые 2 секунды получаем свежие данные и обновляем интерфейс.
Отображение статуса
Функция updateStatusDisplay выбирает иконку и цвет в зависимости от поля data.status. Например:
- «Полив идет» → 💧 синий
- «Низкая влажность» → ⚠️ оранжевый
- «Лимит поливов» → ⛔ красный (выводится счётчик)
- «Ошибка датчика» → ❌ красный
- «Ожидание перерыва» → ⏳ фиолетовый
Дополнительная информация (например, «Влажность ниже уставки») выводится в отдельном блоке, который создаётся при необходимости.
Сохранение настроек и ручной полив
function save() {
let sp = document.getElementById('setpoint').value;
let pt = document.getElementById('pumpTime').value;
if (sp < 0 || sp > 100) return alert("Уставка 0–100%");
if (pt < 1 || pt > 60) return alert("Время 1–60 сек");
fetch('/set', { method: 'POST', body: new URLSearchParams({ sp, pt }) })
.then(r => r.text())
.then(txt => alert(txt));
}
function manual() {
if (!confirm("Запустить ручной полив?")) return;
fetch('/manual', { method: 'POST' })
.then(r => r.text())
.then(txt => alert(txt));
}
OTA-обновление прошивки и файлов
Функция uploadFW():
- Проверяет, выбран ли .bin файл.
- Отображает прогресс-бар.
- Отправляет файл через
XMLHttpRequestна/firmware. - При успехе показывает сообщение и через 8 секунд перезагружает страницу.
Функция uploadFS() аналогична, но отправляет файлы на /upload и проверяет расширение (html, css, js, png, jpg, ico).
Загрузка файлов в LittleFS
Чтобы веб-интерфейс заработал, нужно поместить файлы index.html, style.css, script.js в папку data вашего проекта в Arduino IDE. Затем выполнить загрузку LittleFS:
- Установите поддержку ESP8266 в Arduino IDE (через менеджер плат).
- Установите инструмент загрузки LittleFS (в менеджере библиотек есть плагин ESP8266 LittleFS Data Upload).
- В меню Инструменты → ESP8266 Sketch Data Upload запустите загрузку. После этого все файлы из
dataбудут записаны в файловую систему ESP8266.
При старте скетча LittleFS монтируется, и веб-сервер может отдавать эти файлы.
Итоги и возможные улучшения
Мы создали полноценную систему умного полива с веб-управлением, индикацией и возможностью обновления «по воздуху». Код ESP8266 надёжно обрабатывает ошибки датчика, защищает от частых поливов и сохраняет настройки. Веб-интерфейс удобен как на компьютере, так и на смартфоне.
Идеи для доработок
- Авторизация: добавить Basic Auth на веб-страницу, чтобы доступ к настройкам был защищён.
- График влажности: сохранять историю измерений и выводить график на странице.
- Уведомления: отправлять сообщения в Telegram при низкой влажности или ошибке.
- Калибровка датчика через интерфейс: сделать поля для ввода сухого и влажного значения АЦП.
- Интеграция с Home Assistant: добавить MQTT-клиент.
Все эти улучшения могут быть реализованы с минимальными изменениями в основном коде.
/************************************************************
* ESP8266 УМНЫЙ ПОЛИВ
************************************************************/
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <EEPROM.h>
#include <LittleFS.h>
#include <FastLED.h>
#include <Updater.h>
/* ================= НАСТРОЙКИ ================= */
const char* ssid = "Home";
const char* password = "123123123";
const char* webUser = "admin";
const char* webPass = "admin123";
#define FW_VERSION "1.0.1"
#define RELAY_PIN 5
#define SOIL_PIN A0
#define LED_PIN 12
#define NUM_LEDS 1
#define LED_TYPE WS2812B
#define COLOR_ORDER GRB
#define SOIL_DRY 711
#define SOIL_WET 288
#define EEPROM_SIZE 32
#define ADDR_SETPOINT 0
#define ADDR_PUMPTIME 4
#define ADDR_CHECKSUM 30
const unsigned long SOIL_INTERVAL = 10000;
const unsigned long LED_UPDATE_INTERVAL = 50;
const uint32_t MIN_WATER_INTERVAL = 10UL * 60UL * 1000UL;
const int MAX_WATERING_COUNT = 3;
/* ================= ЦВЕТА ================= */
CRGB errorColor = CRGB::Red;
CRGB wateringColor = CRGB::Blue;
CRGB waitingColor = CRGB::Yellow;
CRGB readyColor = CRGB::Green;
CRGB limitColor = CRGB::Purple;
CRGB wifiColor = CRGB::Cyan;
CRGB otaColor = CRGB::Magenta;
/* ================= ПЕРЕМЕННЫЕ ================= */
ESP8266WebServer server(80);
CRGB leds[NUM_LEDS];
File uploadFile;
int moisture = 0;
int setpoint = 50;
int pumpTime = 5;
bool pumpRunning = false;
bool autoWaterEnabled = true;
bool otaInProgress = false;
String currentStatus = "Ожидание";
unsigned long pumpStart = 0;
unsigned long lastSoilCheck = 0;
unsigned long lastWatering = 0;
unsigned long lastLEDUpdate = 0;
unsigned long lastLEDBlink = 0;
int wateringCount = 0;
uint8_t breathingBrightness = 30;
bool breathingDirection = true;
bool ledBlinkState = false;
/* ================= LED ================= */
enum LEDState {
LED_NORMAL,
LED_BLINK,
LED_FAST_BLINK,
LED_BREATH
};
LEDState currentLEDState = LED_BREATH;
CRGB currentColor = readyColor;
void setLED(CRGB c, uint8_t b = 255) {
leds[0] = c;
leds[0].nscale8(b);
FastLED.show();
yield();
}
void blinkLED(CRGB c, unsigned long interval) {
if (millis() - lastLEDBlink > interval) {
lastLEDBlink = millis();
ledBlinkState = !ledBlinkState;
setLED(ledBlinkState ? c : CRGB::Black);
}
}
void breathLED(CRGB c) {
breathingBrightness += breathingDirection ? 2 : -2;
if (breathingBrightness >= 200) breathingDirection = false;
if (breathingBrightness <= 5) breathingDirection = true;
setLED(c, breathingBrightness);
}
void updateLED() {
if (otaInProgress) {
blinkLED(otaColor, 200);
return;
}
switch (currentLEDState) {
case LED_NORMAL: setLED(currentColor); break;
case LED_BLINK: blinkLED(currentColor, 1000); break;
case LED_FAST_BLINK: blinkLED(currentColor, 300); break;
case LED_BREATH: breathLED(currentColor); break;
}
}
/* ================= ЛОГИКА ================= */
int readSoil() {
int raw = analogRead(SOIL_PIN);
raw = constrain(raw, SOIL_WET, SOIL_DRY);
return map(raw, SOIL_DRY, SOIL_WET, 0, 100);
}
bool sensorValid(int v) { return v >= 0 && v <= 100; }
bool canWater() { return millis() - lastWatering > MIN_WATER_INTERVAL; }
void startPump() {
pumpRunning = true;
pumpStart = millis();
lastWatering = millis();
wateringCount++;
digitalWrite(RELAY_PIN, HIGH);
}
void stopPump() {
pumpRunning = false;
digitalWrite(RELAY_PIN, LOW);
}
/* ================= EEPROM ================= */
void saveSettings() {
EEPROM.put(ADDR_SETPOINT, setpoint);
EEPROM.put(ADDR_PUMPTIME, pumpTime);
uint16_t cs = setpoint + pumpTime;
EEPROM.put(ADDR_CHECKSUM, cs);
EEPROM.commit();
}
void loadSettings() {
EEPROM.get(ADDR_SETPOINT, setpoint);
EEPROM.get(ADDR_PUMPTIME, pumpTime);
uint16_t csStored, csCalc;
EEPROM.get(ADDR_CHECKSUM, csStored);
csCalc = setpoint + pumpTime;
if (csStored != csCalc || setpoint > 100 || pumpTime > 60) {
setpoint = 40;
pumpTime = 5;
saveSettings();
}
}
/* ================= HTTP ОБРАБОТЧИКИ ================= */
// Обработчик для получения статуса
void handleStatus() {
// Определяем статус
String statusText = "Ожидание";
if (pumpRunning) {
statusText = "Полив идет";
} else if (!autoWaterEnabled) {
statusText = "Ошибка датчика";
} else if (wateringCount >= MAX_WATERING_COUNT) {
statusText = "Лимит поливов";
} else if (moisture < setpoint && canWater()) {
statusText = "Низкая влажность";
} else if (moisture < setpoint && !canWater()) {
statusText = "Ожидание перерыва";
}
String json = "{";
json += "\"moisture\":" + String(moisture) + ",";
json += "\"setpoint\":" + String(setpoint) + ",";
json += "\"pumpTime\":" + String(pumpTime) + ",";
json += "\"pumpRunning\":" + String(pumpRunning ? "true" : "false") + ",";
json += "\"autoWaterEnabled\":" + String(autoWaterEnabled ? "true" : "false") + ",";
json += "\"wateringCount\":" + String(wateringCount) + ",";
json += "\"canWater\":" + String(canWater() ? "true" : "false") + ",";
json += "\"status\":\"" + statusText + "\",";
json += "\"version\":\"" FW_VERSION "\",";
json += "\"uptime\":" + String(millis() / 1000) + ",";
json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
json += "\"ip\":\"" + WiFi.localIP().toString() + "\"";
json += "}";
server.send(200, "application/json", json);
}
// Обработчик для сохранения настроек
void handleSetSettings() {
if (server.hasArg("sp")) {
setpoint = server.arg("sp").toInt();
setpoint = constrain(setpoint, 0, 100);
}
if (server.hasArg("pt")) {
pumpTime = server.arg("pt").toInt();
pumpTime = constrain(pumpTime, 1, 60);
}
saveSettings();
server.send(200, "text/plain", "Настройки сохранены");
}
// Обработчик для ручного полива
void handleManualWater() {
if (!pumpRunning) {
// Сбрасываем счетчик для ручного полива
wateringCount = 0;
lastWatering = millis() - MIN_WATER_INTERVAL;
startPump();
server.send(200, "text/plain", "Ручной полив запущен");
} else {
server.send(200, "text/plain", "Насос уже работает");
}
}
/* ================= OTA ПРОШИВКА ================= */
void handleFirmwareOTA() {
HTTPUpload& u = server.upload();
if (u.status == UPLOAD_FILE_START) {
otaInProgress = true;
Serial.println("Начало обновления прошивки");
uint32_t size = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
if (!Update.begin(size, U_FLASH)) {
Update.printError(Serial);
otaInProgress = false;
server.send(500, "text/plain", "Ошибка начала обновления");
}
}
else if (u.status == UPLOAD_FILE_WRITE) {
if (Update.write(u.buf, u.currentSize) != u.currentSize) {
Update.printError(Serial);
}
}
else if (u.status == UPLOAD_FILE_END) {
otaInProgress = false;
if (Update.end(true)) {
Serial.println("Обновление завершено успешно");
server.send(200, "text/plain", "Обновление завершено. Перезагрузка...");
delay(1000);
ESP.restart();
} else {
Update.printError(Serial);
server.send(500, "text/plain", "Ошибка обновления");
}
}
else if (u.status == UPLOAD_FILE_ABORTED) {
otaInProgress = false;
Update.end();
Serial.println("Обновление прервано");
}
}
/* ================= UPLOAD ФАЙЛОВ ================= */
void handleFileUpload() {
HTTPUpload& upload = server.upload();
if (upload.status == UPLOAD_FILE_START) {
String filename = upload.filename;
if (!filename.startsWith("/")) filename = "/" + filename;
Serial.println("Загрузка файла: " + filename);
uploadFile = LittleFS.open(filename, "w");
}
else if (upload.status == UPLOAD_FILE_WRITE) {
if (uploadFile) {
uploadFile.write(upload.buf, upload.currentSize);
}
}
else if (upload.status == UPLOAD_FILE_END) {
if (uploadFile) {
uploadFile.close();
Serial.println("Файл загружен: " + upload.filename);
}
}
}
/* ================= HTTP КОРНЕВОЙ ЗАПРОС ================= */
void handleRoot() {
File f = LittleFS.open("/index.html", "r");
if (!f) {
server.send(200, "text/plain", "index.html not found");
return;
}
server.streamFile(f, "text/html");
f.close();
}
/* ================= SETUP ================= */
void setup() {
Serial.begin(115200);
Serial.println("\n\nЗапуск системы умного полива...");
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, LOW);
FastLED.addLeds<LED_TYPE, LED_PIN, COLOR_ORDER>(leds, NUM_LEDS);
FastLED.setBrightness(100);
WiFi.begin(ssid, password);
Serial.print("Подключение к WiFi");
while (WiFi.status() != WL_CONNECTED) {
blinkLED(wifiColor, 500);
delay(100);
Serial.print(".");
}
Serial.println("\nПодключено!");
Serial.print("IP адрес: ");
Serial.println(WiFi.localIP());
EEPROM.begin(EEPROM_SIZE);
loadSettings();
if (!LittleFS.begin()) {
Serial.println("Ошибка монтирования LittleFS");
if (!LittleFS.format()) {
Serial.println("Ошибка форматирования LittleFS");
}
}
// Регистрация обработчиков
server.on("/", handleRoot);
server.on("/status", HTTP_GET, handleStatus);
server.on("/set", HTTP_POST, handleSetSettings);
server.on("/manual", HTTP_POST, handleManualWater);
// Для загрузки файлов (SPIFFS)
server.on("/upload", HTTP_POST, []() {
server.send(200, "text/plain", "OK");
}, handleFileUpload);
// Для OTA прошивки
server.on("/firmware", HTTP_POST, []() {
server.send(200, "text/plain", "OK");
}, handleFirmwareOTA);
// Статические файлы
server.serveStatic("/style.css", LittleFS, "/style.css");
server.serveStatic("/script.js", LittleFS, "/script.js");
server.begin();
Serial.println("HTTP сервер запущен");
lastWatering = millis() - MIN_WATER_INTERVAL;
// Первое чтение датчика
moisture = readSoil();
Serial.printf("Начальное значение влажности: %d%%\n", moisture);
}
/* ================= LOOP ================= */
void loop() {
server.handleClient();
unsigned long now = millis();
if (now - lastSoilCheck > SOIL_INTERVAL) {
lastSoilCheck = now;
moisture = readSoil();
// Обновляем LED состояние в зависимости от влажности
if (pumpRunning) {
currentLEDState = LED_FAST_BLINK;
currentColor = wateringColor;
} else if (moisture < setpoint) {
currentLEDState = LED_BREATH;
currentColor = waitingColor;
} else {
currentLEDState = LED_BREATH;
currentColor = readyColor;
}
autoWaterEnabled = sensorValid(moisture);
if (moisture >= setpoint) wateringCount = 0;
if (autoWaterEnabled && moisture < setpoint &&
!pumpRunning && canWater() &&
wateringCount < MAX_WATERING_COUNT) {
startPump();
}
}
if (pumpRunning && now - pumpStart > pumpTime * 1000UL) {
stopPump();
}
if (now - lastLEDUpdate > LED_UPDATE_INTERVAL) {
lastLEDUpdate = now;
updateLED();
}
}