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

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

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

Комментарии (8)


  1. Oeaoo
    26.07.2026 18:02

    теперь у меня рядом с рабочим столом стоит закрытый садик, там растут мхи, фиттонии, сингониум, декоративный перчик

    А улиточку туда запустить можно?

    Вообще, для меня это пример того, каким должно быть программирование и технологии. Очень жизнеутверждающе!