Дисклеймер: не читайте эту статью, если вы фанат традиционных ценностей и от заголовка уже хотите написать в комментах, что мне стоит изучить базу. Читайте эту статью только если вы прогрессивный, веселый, любите инженерный угар и работающие проекты ♥

Про корпус я писала тут и тут. В этой статье я расскажу про бэк и мои решения!

Кстати, самым сложным в проекте было придумать, какие железки заказать. Что такое реле и что означает буква 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, можете догадаться, почему ♥