Дисклеймер: не читайте эту статью, если вы фанат традиционных ценностей и от заголовка уже хотите написать в комментах, что мне стоит изучить базу. Читайте эту статью только если вы прогрессивный, веселый, любите инженерный угар и работающие проекты ♥
Про корпус я писала тут и тут. В этой статье я расскажу про бэк и мои решения!
Кстати, самым сложным в проекте было придумать, какие железки заказать. Что такое реле и что означает буква V я узнала дай бог три месяца назад. Предупреждая вопросы: да, в школе я читала Генри Миллера, а учебники не читала, институт посещала гораздо реже, чем вечеринки, через годы стала управляющей на ювелирном производстве, а сейчас веду микро-бизнес вообще в не связанной со всем этим области. Всё нижеизложенное — чистый фан.

Сборка: адресная матрица 8х8 / 4-канальное реле / 2 понижайки / погружная туманная мембрана / простой кулер / датчик температуры и влажности dht22 / 2 помпы / esp32-devkit. управление через веб (Vanilla JS SPA)
Питание: вся конструкция запитана от сети через блок 24в. Напрямую от него питается сама туманная мембрана и понижайки. Понижайки получают 24в и раздают 12в для помп и 5в для матрицы и кулера, датчик dht22 питается от контроллера. Реле управляет кулером, туманной мембраной, доливом через помпу в озеро и верхним дождевым контуром.
Стек: C++ / PlatformIO / FastLED / DHT / ESPAsyncWebServer / ArduinoJson / NTPClient
Корпус: FDM-печать / PETG
Мелочь: 4 стекла 20х20 / 1 стекло 10х10 под матрицу / куча WAGO клеммников / 5 метров ШВВП 2х0.75 / 4 силиконовые трубки (2 в донорский резервуар с водой внизу, 2 на долив озера и верхний дождевой контур)
Отправная точка была нулевая: я хотела лес в корпусе, который живёт сам. Поддерживает световой день, развлекает меня имитирующими натуральные рассветы и закаты анимациями, мистически туманит по расписанию или по показаниям датчика влажности, делает дождик, ну и я хотела просто нажимать кнопки в телефоне, если мне что-то нужно. Вот и всё ТЗ.
Так что сначала я накидала свою шизу в Гемини с просьбой составить внятный промпт, а сам промпт уже выкатила Клоду. Он объяснил концепцию: ты купила контроллер, он может держать веб-сервер прямо внутри себя, браузер подключается к нему по Wi-Fi и отдаёт команды или получает инфу. Мне понравилась лаконичность, так что неработающий МВП был сделан в лучших традициях вайбкодинга (под пиво в пятницу вечером)
Дальше я составила список вопросов первоклассника, и получила всю нужную информацию!
Как сделать чтобы свет плавно менялся как настоящий закат? — агент объяснил про smoothstep и цветовые фазы.
Как сделать чтобы туман включался сам, когда DHT говорит, что становится сухо, но и вручную я включать его тоже хочу? — агент объяснил про планировщик задач.
Почему оно иногда зависает когда я нажимаю кнопку? — агент объяснил про два потока FreeRTOS и гонку данных, которую я случайно создала своим же запросом на асинхронный сервер.
Каждый раз я не знала слова для того, что мне нужно, но я продолжала чисто на вайбике и для развлечения. Архитектура, которая получилась в итоге — не моя в смысле «я её придумала технически и напечатала руками весь код». Кто-то вообще до сих пор печатает код руками? В общем, эта архитектура моя концептуально, я знаю, почему, что и где существует, а еще с горем пополам я смогла собрать железо.
Короче! Вот что получилось внутри.
Главный принцип — будь психом и никогда не останавливайся
Весь бэкенд построен вокруг одного запрета: нет delay().
delay(500) буквально замораживает весь чип на полсекунды — в это время ESP не может ответить браузеру, не может обновить LED, не может поймать команду с кнопки. Для систем реального времени это катастрофа. Я (как и все остальные проблемы) обнаружила это эмпирически: первые версии кода зависали при одновременном нажатии кнопки в браузере и обновлении LED. Оказалось, delay() — это не пауза, это полная заморозка чипа.
Вместо delay всё работает через millis() счётчик миллисекунд с момента запуска. Логика везде одна: запомни, когда что-то случилось, и в каждом обходе loop() проверь, прошло ли достаточно времени. Если прошло — действуй, в ином случае пропусти и иди дальше. Это называется «неблокирующий код», но вы это и без меня знаете.
loop() крутится сотни раз в секунду и каждый раз делает ровно четыре дела:
1. Обновляет NTP-время (timeClient.update())
2. Передаёт текущее время в LED-движок (leds.setTime(totalSec))
3. Разгребает очередь команд от веб-сервера
4. Вызывает .tick() у планировщика задач, датчика и LED-контроллера
Никакого while(true) внутри, никаких ожиданий. Каждая подсистема получает свой кусочек времени процессора за каждый оборот. Именно поэтому туман, LED и веб-сервер работают «одновременно», хотя на самом деле они просто очень быстро чередуются.
Самописный планировщик задач
Мне нужно было чтобы туманная мембрана включалась по расписанию, но также мне было нужно запускать/останавливать её вручную. По изначальной логике я хотела, чтобы после работы тумана автоматически запускался долив озерца через одну из двух помп. Туманная мембрана испаряет сумасшедшее количество воды, это не УЗВ-шный китайский столбик. Ещё хотела, чтобы кулер включался по датчику, если становится слишком влажно и длится это слишком долго. Стандартный ответ на такую задачу — RTOS-таймеры или отдельные задачи. Но агент предложил проще (проще не для меня?): кооперативный шедулер на статическом массиве из 16 слотов, так как это дешевле, предсказуемее, и никакой дополнительной синхронизации не нужно, потому что всё выполняется в одном потоке loop().
Каждый слот — структура Task: коллбек, интервал в мс, метка последнего запуска, флаг oneShot. Вся магия в методе tick(), который вызывается каждую итерацию loop():
void TaskManager::tick() { unsigned long now = millis(); for (int i = 0; i < _count; i++) { Task& t = _tasks[i]; if (!t.enabled) continue; if ((now - t.lastRun) >= t.intervalMs) { t.lastRun = now; t.callback(); if (t.oneShot) { t.enabled = false; Serial.printf("[TaskManager] One-shot '%s' fired & disabled.\n", t.name); } } } }
Два типа задач:
1. Periodic запускается каждые N миллисекунд, пока не отключить. Например, проверка расписания каждые 60 секунд.
2. OneShot запускается один раз через N миллисекунд и сам себя отключает. Именно так работают таймеры остановки: запустили туман → создали задачу «остановить туман через 60 000 мс» → она сработала, выключила реле и исчезла.
Задача хранится в массиве навсегда (слот не освобождается), но при повторном запуске того же сценария она не создаётся заново, а включается методом enable(idx), который сбрасывает lastRun на millis(). Так можно повторно запускать туман сколько угодно без утечки слотов. Если бы я создавала новую задачу каждый раз, то за 16 запусков массив был бы полон и планировщик перестал бы принимать новые задачи.
Проблема двух потоков, а не задача трёх тел
Это самое нетривиальное место во всей архитектуре. ESP32 работает на FreeRTOS, и AsyncWebServer выполняет свои коллбеки в отдельной задаче FreeRTOS — параллельно loop(). Два потока, одновременно.
Если напрямую вызвать relay.on() из коллбека, то начинается гонка данных. String в C++ — это не просто строка, это объект с указателем на heap. Если один поток пишет в String пока другой читает, то указатель может оказаться в квантовом состоянии. Итог: heap corruption и краши, которые невозможно воспроизвести стабильно. Я именно это и получала на ранних версиях: работало час, потом зависало без причины.
Почему не добавить мьютекс, спросите вы? Можно. Но мьютекс блокирует AsyncWebServer-поток пока loop() держит лок, а значит веб-сервер перестаёт принимать запросы в этот момент. И наоборот: если loop() встанет на мьютексе, то перестанет обновляться LED. Решение через мьютекс решает одну проблему и создаёт другую. В лучших традициях вайбкодинга!
Решение, предложенное Клодом, чище: кольцевой буфер команд из char[ ]-полей. char[ ] — это POD (plain old data), примитивные байты без конструкторов. Запись одного int атомарна на ESP32, гарантирует сам процессор. Никакой блокировки и никакого ожидания!
Решение: кольцевой буфер команд из POD-структур:
// POD-структура для обмена командами между потоками веб-сервера и loop() struct PendingCmd { char channel[8]; // "fan" | "rain" | "lake" | "fog" | "" char action[8]; // "on" | "off" | "toggle" | "" char scenario[8]; // "fog" | "rain" | "" }; static const int CMD_QUEUE_SIZE = 4; static PendingCmd _cmdQueue[CMD_QUEUE_SIZE]; static volatile int _cmdHead = 0; // Пишет поток веб-сервера (AsyncWebServer) static volatile int _cmdTail = 0; // Читает основной поток (loop)
AsyncWebServer записывает команду в cmdQueue[cmdHead] и сдвигает cmdHead. loop() читает из cmdQueue[_cmdTail] и сдвигает _cmdTail. char[ ] — POD, запись/чтение 4-байтного int на ESP32 атомарно, mutex не нужен.
Каждый loop() сначала разгребает всю очередь:
while (_cmdTail != _cmdHead) { PendingCmd cmd = _cmdQueue[_cmdTail]; _cmdTail = (_cmdTail + 1) % CMD_QUEUE_SIZE; // разбор и выполнение команды }
(В реальном коде, конечно, стоит проверка валидности канала, чтобы не получить undefined behavior при мусорном значении enum, но для читаемости примера я этот огород убрала).
Браузер получает ответ мгновенно (API возвращает «оптимистичное» состояние), реальное переключение реле происходит через несколько микросекунд в следующем обороте loop().
Кольцевой буфер логов от обладательницы буферов
Каждое включение реле, каждый авто-запуск, каждая ошибка пишется в лог. Почему в RAM, а не во flash-память, спросите вы? Потому что flash у ESP32 выдерживает ограниченное число циклов записи — порядка 100 000. При активном логировании flash деградирует за несколько месяцев. RAM перезаписывается бесконечно.
В RAM хранится кольцевой буфер на 15 строк. addLog(msg) пишет сообщение с меткой времени, сдвигает logHead по модулю 15. Когда буфер полон — старые записи заменяются новыми в рамках фиксированного количества слотов, что не дает куче бесконечно разрастаться.
const int MAX_LOGS = 15; String logMessages[MAX_LOGS]; int logHead = 0; int logCount = 0; void addLog(const String& msg) { String ts = timeClient.getFormattedTime(); if (ts.length() < 5) ts = "00:00"; String fullMsg = "[" + ts + "] " + msg; logMessages[logHead] = fullMsg; logHead = (logHead + 1) % MAX_LOGS; if (logCount < MAX_LOGS) logCount++; Serial.println(fullMsg); }
Весь буфер отдаётся браузеру внутри /api/state как JSON-массив; никакого отдельного эндпоинта для логов нет. Браузер кэширует их в localStorage, поэтому история не пропадает при перезагрузке страницы даже если ESP перезапустился.
Сценарии: туман как сквозной пример
startFogSequence() — хорошая иллюстрация того, как все слои работают вместе:
1. Браузер → POST /api/scenario?name=fog
2. AsyncWebServer → _enqueueScenario("fog") — запись в ring buffer
3. loop() → достаёт команду из очереди → startFogSequence()
4. startFogSequence() → relay.on(RELAY_FOG) + fogActive = true + создаёт OneShot-задачу "fogOff" на FOG_DURATION_MS мс
5. Через N мс → TaskManager.tick() → stopFog() → реле выключается, лог пишется
Никакого delay(). ESP всё это время отвечает на HTTP-запросы, обновляет LED и читает датчик.
Вайб-стабильность: собака-наблюдатель и контроль памяти
После первых недель тестов стало понятно, что чистый код это конечно прекрасно, но суровая реальность вносит свои коррективы. ESP32 может потерять Wi-Fi, а стек протоколов AsyncWebServer при высокой нагрузке может фрагментировать кучу (heap memory). Чтобы флорариум не превращался в глупую банку во время моего отсутствия, Клод добавил систему защиты:
1. WiFi Watchdog: Раз в 30 секунд фоновая задача проверяет статус подключения. Если соединение разорвано, ESP пытается тихо переподключиться без прерывания основной программы и перезагрузки.
2. Heap Watchdog: Раз в 10 секунд мы замеряем свободную память. Если она падает ниже критических 20 КБ (что предвещает неминуемый краш), контроллер делает контролируемый мягкий рестарт (`ESP.restart()`). Да, при этом реле сбрасываются в дефолтное выключенное состояние, но это безопаснее, чем намертво зависнуть с открытым клапаном полива или включенным на 24V туманом.
3. UI-индикация: В статус-бар дашборда вывели цветные индикаторы памяти и уровня сигнала (RSSI), так что состояние системы всегда на виду.
Архитектура одним блоком:
┌─────────────────────────────────────────────┐ │ Браузер (Vanilla JS SPA / LittleFS) │ ├─────────────────────────────────────────────┤ │ ESPAsyncWebServer (FreeRTOS task) │ │ REST API: /api/state /api/relay ... │ ├──────────────────┬──────────────────────────┤ │ Ring buffer │ Ring buffer │ │ команд │ логов (15 строк) │ ├──────────────────┴──────────────────────────┤ │ loop() — главный поток │ │ ├── TaskManager.tick() │ │ ├── DHTSensor.tick() │ │ └── LEDController.tick() │ ├─────────────────────────────────────────────┤ │ Железо: реле, WS2812B, DHT22, GPIO │ └─────────────────────────────────────────────┘
Три подсистемы (TaskManager, DHTSensor, LEDController) изолированы в отдельные классы с единственной точкой входа — .tick(). Каждая сама знает когда ей что-то делать. main.cpp просто вызывает их в нужном порядке.
* **GET** `/api/climate` — температура и влажность %
* **GET** `/api/state` — состояние реле, NTP-время, фаза света, логи, свободный heap и WiFi RSSI
* **POST** `/api/relay` (параметры: `channel`, `action`) — ручное переключение реле
* **POST** `/api/scenario` (параметры: `name` — `fog` или `rain`) — запуск сценария (туман/дождь)
* **POST** `/api/light` (параметры: `brightness` от 0 до 255) — яркость светодиодной матрицы
* **POST** `/api/light/phase` (параметры: `phase` — название фазы или `auto`) — демо-режим фаз освещения
Всё в одном /api/state — и состояние реле, и текущая фаза освещения, и последние 15 логов. Фронтенд делает один запрос каждые 5 секунд и получает всю картину сразу.
Благодаря совместным усилиям нашего тандема с Клодом, теперь у меня рядом с рабочим столом стоит закрытый садик, там растут мхи, фиттонии, сингониум, декоративный перчик. Понемногу система стабилизируется, разрастётся, и я смогу трогать настоящую траву, не отходя от компьютера. Пока я писала статью, ко мне приехал мох дикранум.
Мои другие проекты, шутки ниже пояса и пост с картинками про мой UI/UX — в тг-канале! Он называется g-code & g-spot, можете догадаться, почему ♥
Oeaoo
А улиточку туда запустить можно?
Вообще, для меня это пример того, каким должно быть программирование и технологии. Очень жизнеутверждающе!