Давно чесались руки чтобы сделать устройство с нуля. Друзья попросили в качестве подарка на день рождение устройство для измерения уровня CO2 в помещении дома. Так, зародилась идея.

Поиск на маркетплейсах

Первое, с чего я начал, это поиск девайса на маркетплейсах. Да, лень двигатель прогресса! Можно найти в продаже готовые решения, но в них скорее всего нет интеграции с умным домом (например, от Яндекса) или же само устройство собрано на основе сомнительного датчика.

Найти способ передать данные

Следующий шаг — это сбор требований к устройству. Основное требование заключалось в интеграции с умным домом от Яндекса (УДЯ). Следовательно получаем, что:

  1. Есть интеграция с УДЯ;

  2. Устройство можно поставить на стол и/или прикрепить на стену;

  3. Простой и читаемый интерфейс для пользователя с дисплеем и/или веб‑панелью;

  4. Не использовать говно и палки (по возможности).

Главное что меня интересовало это как связать готовое устройство с УДЯ. Какие существуют способы интеграции с УДЯ?

  • ZigBee;

  • Wi‑Fi Matter;

  • Свой бэкенд‑мост.

ZigBee и Wi-Fi Matter требуют промежуточное устройство (шлюз), а написание кастомного моста еще более затратный по времени способ. Я начал копать в сторону других вариантов интеграции. И они есть. Сам Яндекс предлагает подключение IoT устройств с использованием «облачных функций» (ОФ), их еще называют «лямбды». Даже есть пример устройства на основе ESP8266 или же пример моста. Но как код, так и сам подход мне не понравились из‑за зависимости от специфики API Яндекса. В итоге выбор пал на интеграцию через wqtt.ru — понятный интерфейс и подробная документация с примерами. Если коротко, то наше будущее устройство будет отправлять данные в wqtt.ru по протоколу MQTT. Далее между wqtt.ru и УДЯ есть готовая интеграция и мы получим доступ к данным в виде виртуального устройства. Даже если wqtt.ru упадет, то всегда есть план Б. Например, можно поднять свой брокер MQTT + HomeAssistant. Или же сделать дашборд на Grafana подключив Prometheus в качестве транспортера данных из MQTT брокера и Node‑RED для скриптов.

Найти корпус или сделать его самому?

Прежде чем выдумывать свое, я начал искать уже готовые решения, которые можно модифицировать под себя? И да, такое тоже есть. По запросу GeekMagic можно найти маленькие коробочки с дисплеем и микроконтроллером (в обычной версии ESP12F или ESP32 если pro версия).

GeekMagic

Это неплохой вариант, т.к. менее чем за 1к рублей (условно) можно получить и корпус, и дисплей и программируемый микроконтроллер. Вот кстати пример статьи в котором автор кастомизировал такое устройство. Но нужно ждать доставку и есть неудобства с перепрошивкой, поэтому оставим этот вариант для другого проекта. И опять же выглядит так, что нужно кастомизировать свой бэкенд. "Может быть в другой раз, но точно не сегодня" - сказал я себе.

"Что из себя будет представлять моё будущее устройство?". Оно должно быть:

  • Компактным и иметь внешний интерфейс (дисплей и/или веб-панель);

  • С беспроводным подключением к сети;

  • Выводить показания на дисплей даже если нет интернет-соединения.

  • Была идея сделать его еще и автономным, т.е. добавить аккумулятор, но тогда бы это усложнило как схему, так и код.

Начнем с корпуса. Впервые решил попробовал сделать 3D модель для печати. На освоение CAD (Autodesk Fusion 360) ушло пару дней, а ещё несколько часов — на создание прототипа для сборки устройства. Естественно, получилось не с первой попытки. В первой модели не хватало вентиляционных отверстий, а в другой проводка с трудом помещалась внутрь корпуса и прочие мелкие недочеты проектирования. А еще я спутал масштаб модели и пришлось на этапе печати уменьшать размер. В общем, проектирование не только творческий процесс, но и сложная инженерная задача.

3D модель корпуса
Вид в сборе
Вид в сборе
Основной корпус (вид со стороны крышки)
Основной корпус (вид со стороны крышки)
Крышка
Крышка
Крышка внутри
Крышка внутри

Сборка устройства

Корпус рассчитан на установку одного ESP-подобного микроконтроллера, датчика уровня CO2 и еще одного датчика (например, температуры), RGB светодиода. Его можно установить на MagSafe держатель. Как-то так случайно вышло, что я угадал с размерами и магнитное кольцо идеально село на заднюю крышку. При большом желании можно добавить дополнительно датчик (например, температуры и давления) т.к. есть свободное место. В итоге, аппаратная часть формировалась параллельно с проектирование корпуса и включает:

  • Микроконтроллер — ESP32 Live Mini. Выбор МК ничем не обоснован, разве что он подходит по форм фактору под ESP8266 Wemos Mini. Чуть позже я узнал о существовании ESP 32 S2, который по характеристикам точно не хуже и у него более современный интерфейс usb type-c. Саму прошивку удалось адаптировать под данный МК тоже.

ESP32 Live Mini
ESP32 S2 Mini
  • Датчик качества воздуха — CCS811. Данный датчик был выбран методом тыка, а точнее выбран по принципу цена-размер. Есть разные варианты типов датчиков для отслеживания качества воздуха, например, Winsen MH-Z19B. У меня есть такой датчик, но он нагревается при работе. Датчик типа PMS-A003 определяет концентрацию частиц в воздухе. Он подходит больше для мониторинга воздуха вне рабочего кабинета. Есть так же датчики формальдегидов типа MQ-135, но опять мимо. В любом случае даже CCS811 здесь только для относительных показаний, чтобы дать понять пользователю, что пора бы проветрить помещение. Еще заказал ENS160 + AHT21, чтобы сравнить и выбрать лучший.

CCS811
  • Датчик параметров окружающей среды - BME280. Данный датчик я уже добавил после того, как был закончен проект. Просто потому что осталось место.

BME280
  • RGB светодиод — WS2815. В целом подойдет и обычный RGB светодиод. Он нужен номинально, так как предполагалось выключать дисплей и отображать индикацию через него

WS2815
  • Дисплей — GC9A01. Вообще, идея корпуса возникла в тот момент, когда я нашел этот дисплей. Мне показалось, что круглая форма привлекает внимание.

GC9A01

Вот схема подключения:

Схема
Как устройство выглядит внутри
Я старался, честно
Я старался, честно

Пишем код

Определившись с аппаратной частью, распечатав корпус и собрав это все воедино наконец-то можно начать писать код. Писать код кажется самая интересная часть проекта, потому что тут настоящий полет фантазии. Но не нужно забывать про основные требования иначе разработка никогда не завершится. Даже сейчас, когда я пишу эту статью, я не уверен что реализовал все что хотел. Раньше под Arduino программировали в специальной IDE. Каково было мое удивление, что появился удобный фреймворк (если его можно так назвать) - Platformio. Я начал разбираться в нем. По сути, все что мне нужно есть - это возможность указать зависимости в проекте, собрать бинарник (прошибку) и загрузить в устройство. Репозиторий с проектом можно найти тут. Давайте разберем подробнее программу. Начнем с файла входа в программу -main.cpp. Как и любая другая программа на Arduino она состоит из двух функций setup и loop.

main.cpp
#include "Arduino.h"
#include <Looper.h>

#include "db/settings_db.h"
#include "model/co2_data.h"
#include "configs/config.h"
#include "connections/mqtt_conn.h"
#include "connections/wifi_conn.h"
#include "connections/wifi_connector_adapter.cpp"
#include "sensors/sensor_base.h"
#include "sensors/co2.h"
#include "sensors/tph.h"
#include "hmi/display.h"
#include "hmi/web.h"
#include "controllers/rgb.h"
#include "services/logger.h"
#include "services/publisher.h"
#include "services/ota.h"

/**
 * @brief Initialization of all main components: logging, database, WiFi and MQTT connections, data publishing, display, RGB, and web interface
 */
void setup()
{
  /**
   * @note Logging initialization
   */
  Serial.begin(SERIAL_SPEED);
  SET_LOG_LEVEL(APP_LOG_LEVEL);
  LOG_INFO("init...");

  /**
   * @note Database initialization
   */
  SettingsDB *sdb = new SettingsDB();

  /**
   * @note WiFi connection initialization
   */
  WiFiAdapter *wifia = new WiFiConnectorAdapter(
      WIFI_AP_NAME,
      WIFI_AP_PASS,
      WIFI_CONN_RETRY_TIMEOUT,
      false);
  WiFiConn *wifi = new WiFiConn(*sdb, *wifia);

  /**
   * @note OTA firmware update initialization
   */
  OTA *ota = new OTA(*wifi);

  /**
   * @note MQTT connection initialization
   */
  MQTTConn *mqtt = new MQTTConn(*sdb, *wifi);

  /**
   * @note Sensors initialization
   */
  CO2Sensor *co2 = new CO2Sensor(SEC_30);
  TPHSensor *tph = new TPHSensor(SEC_30);

  /**
   * @note Enable test mode for sensors (data emulation)
   */
#ifdef ENABLE_TEST
  co2->enableTest();
  tph->enableTest();
#endif

/**
 * @note Disable sending to MQTT to prevent broke data
 */
#ifndef ENABLE_TEST
  /**
   * @note Configure CO2 value publishing to topic [device_id]/co2
   */
  MQTTPublisher *co2p = new MQTTPublisher(SEC_30, *mqtt, MQTT_DEFAULT_CO2_TOPIC);
  co2p->setValueCb([co2]() -> float
                   { return co2->getCO2(); });

  /**
   * @note Configure TVOC value publishing to topic [device_id]/tvoc
   */
  MQTTPublisher *tvocp = new MQTTPublisher(SEC_30, *mqtt, MQTT_DEFAULT_TVOC_TOPIC);
  tvocp->setValueCb([co2]() -> float
                    { return co2->getTVOC(); });

  /**
   * @note Configure temperature publishing to topic [device_id]/temp
   */
  MQTTPublisher *tempp = new MQTTPublisher(SEC_30, *mqtt, MQTT_DEFAULT_TEMP_TOPIC);
  tempp->setValueCb([tph]() -> float
                    { return tph->getTemperature(); });

  /**
   * @note Configure pressure publishing to topic [device_id]/pressure
   */
  MQTTPublisher *pp = new MQTTPublisher(SEC_30, *mqtt, MQTT_DEFAULT_PRESSURE_TOPIC);
  pp->setValueCb([tph]() -> float
                 { return tph->getPressure(); });

  /**
   * @note Configure humidity publishing to topic [device_id]/humidity
   */
  MQTTPublisher *hp = new MQTTPublisher(SEC_30, *mqtt, MQTT_DEFAULT_HUMIDITY_TOPIC);
  hp->setValueCb([tph]() -> float
                 { return tph->getHumidity(); });
#endif

  /**
   * @note Display initialization
   */
  Display *display = new Display(
    SEC_1, 
    *sdb, 
    *co2, 
    *tph, 
    *wifi,
    *mqtt, 
    *ota);

  /**
   * @note RGB controller initialization for CO2 level visualization
   */
  RGBController *rgb = new RGBController(MS_500, *sdb);
  rgb->setUpdaterCb([co2]() -> float
                    { return co2->getCO2(); });

  /**
   * @note Web interface initialization
   */
  WebPanel *wp = new WebPanel(
      *sdb,
      *wifi,
      *ota,
      *mqtt,
      *rgb,
      *display,
      *co2,
      *tph);

  LOG_INFO("init ok!");
}

/**
 * @brief Main loop. Handles all tickers and timers.
 */
void loop()
{
  Looper.loop();
}

В setup функции мы инициализируем несколько основных компонентов программы:

  • Логирование чтобы выводить человекочитаемые сообщения в консоль и на веб-панель - класс Logger;

  • Локальную базу данных (в файле), чтобы сохранять параметры, которые не сбросятся при перезапуске устройства (например, настройки Wi-Fi) - класс SettingsDB;

  • Wi-Fi адаптер (обертка над библиотекой) и соединение к сети, которые содержат логику (пере)подключения - классы WiFiConnectorAdapter и WiFiConn;

  • OTA (Over-the-Air) беспроводное обновление устройства - класс OTA;

  • MQTT подключение и издатели для топиков (eCO2, TVOC, temperature, pressure) - классы MQTTConn и MQTTPublisher;

  • Логика отображения на UI дисплее реализована в классе Display;

  • Логика отображения на UI веб-панели реализована в классе WebPanel;

  • За чтение данных eCO2 и TVOC датчика CCS811 отвечает класс CO2Sensor;

  • За чтение данных температуры, давления и влажности датчика BME280 - класс TPHSensor;

  • В качестве индикации используется RGB светодиод - класс RGBController.

Функция loop обслуживает Looper - библиотека, которая упрощает организацию и вызов компонентов программы в цикле опроса (pool based). Начнем разбирать функцию main. Она начинается с инициализации логгера. Описан класс Logger который дает API для фиксации лога, форматирует его (добавляет префикс с уровнем логирования и цвет) и выводит в консоль или на веб-панель. Можно удобно подключить к компонентам программы.

src/services/logger.h
#pragma once
#include <Arduino.h>
#include <SettingsGyver.h>

#include "configs/config.h"

/**
 * @enum LogLevel
 * @brief Log level: DEBUG - all logs, INFO - informational, WARN - warnings, ERROR - only critical messages
 */
enum class LogLevel
{
    DEBUG, ///< Debug messages
    INFO,  ///< Informational messages
    WARN,  ///< Warning messages
    ERROR  ///< Error messages
};

/**
 * @class Logger
 * @brief Implements formatting and serial printing for logging
 */
class Logger
{
public:
    /**
     * @brief Get singleton instance of Logger
     * @return Logger&
     */
    static Logger &getInstance();

    /**
     * @brief Set global log level
     * @param level Log level as string
     */
    void setLevel(const String &level);
    /**
     * @brief Print log message with level and component prefix
     * @param level Log level
     * @param component Component name
     * @param message Log message
     */
    void log(LogLevel level, const char *component, const String &message);
    /**
     * @brief Set external web UI logger
     * @param wl Reference to web logger
     */
    void initWebLogger(sets::Logger &wl);

private:
    Logger();
    Logger(const Logger &) = delete;
    Logger &operator=(const Logger &) = delete;

    sets::Logger *_wl;        ///< Pointer to web logger
    LogLevel _current_level;  ///< Current log level
};

/**
 * @def LOG_COMPONENT
 * @brief Default log component name (application name)
 */
#ifndef LOG_COMPONENT
#define LOG_COMPONENT APP_NAME
#endif

/**
 * @def LOGGER
 * @brief Singleton logger instance
 */
#define LOGGER Logger::getInstance()
/**
 * @def LOG_DEBUG
 * @brief Log debug message
 */
#define LOG_DEBUG(msg) LOGGER.log(LogLevel::DEBUG, LOG_COMPONENT, msg)
/**
 * @def LOG_INFO
 * @brief Log info message
 */
#define LOG_INFO(msg) LOGGER.log(LogLevel::INFO, LOG_COMPONENT, msg)
/**
 * @def LOG_WARN
 * @brief Log warning message
 */
#define LOG_WARN(msg) LOGGER.log(LogLevel::WARN, LOG_COMPONENT, msg)
/**
 * @def LOG_ERROR
 * @brief Log error message
 */
#define LOG_ERROR(msg) LOGGER.log(LogLevel::ERROR, LOG_COMPONENT, msg)
/**
 * @def SET_LOG_LEVEL
 * @brief Set global log level
 */
#define SET_LOG_LEVEL(level) LOGGER.setLevel(level)
src/services/logger.cpp
#include "logger.h"

Logger &Logger::getInstance()
{
    static Logger instance;
    return instance;
}

Logger::Logger() : _current_level(LogLevel::DEBUG), _wl(nullptr) {}

void Logger::initWebLogger(sets::Logger &wl)
{
    _wl = &wl;
}

void Logger::setLevel(const String &level)
{
    String levelUpper = level;
    levelUpper.toUpperCase();

    if (levelUpper == "DEBUG" || levelUpper == "0")
    {
        _current_level = LogLevel::DEBUG;
    }
    else if (levelUpper == "INFO" || levelUpper == "1")
    {
        _current_level = LogLevel::INFO;
    }
    else if (levelUpper == "WARN" || levelUpper == "2")
    {
        _current_level = LogLevel::WARN;
    }
    else if (levelUpper == "ERROR" || levelUpper == "3")
    {
        _current_level = LogLevel::ERROR;
    }
    else
    {
        log(LogLevel::ERROR, "Logger", "invalid log level: " + level);
    }
}

void Logger::log(LogLevel level, const char *component, const String &message)
{
    if (level < _current_level)
        return;

    String level_str;
    String color_code;
    switch (level)
    {
    case LogLevel::DEBUG:
        level_str = "DEBUG";
        color_code = "\033[36m"; // Cyan color for DEBUG level
        break;
    case LogLevel::INFO:
        level_str = "INFO";
        color_code = "\033[32m"; // Green color for INFO level
        break;
    case LogLevel::WARN:
        level_str = "WARN";
        color_code = "\033[33m"; // Yellow color for WARN level
        break;
    case LogLevel::ERROR:
        level_str = "ERROR";
        color_code = "\033[31m"; // Red color for ERROR level
        break;
    }
    String reset_code = "\033[0m";
    Serial.println(color_code + "[" + level_str + "][" + component + "] " + message + reset_code);
    if (_wl)
    {
        _wl->println("[" + level_str + "][" + component + "] " + message);
    }
}

Далее создаем класс работы с базой данных. В качестве локальной базы на устройстве используется файл. Реализация написана поверх библиотеки. В ней мы будем хранить настройки устройства. Описание класса SettingsDB - в базе мы храним данные о настройках подключения к Wi-Fi, MQTT, параметры о высоком уровне CO2 и прочее.

src/db/settings_db.h
#pragma once
#include <GyverDBFile.h>
#include <LittleFS.h>
#include <Looper.h>

#include "configs/config.h"

#define LOG_COMPONENT "SettingsDB"
#include "services/logger.h"

/**
 * @enum kk
 * @brief Database keys, used as map keys for settings storage
 */
enum kk : size_t
{
    wifi_ssid,        ///< WiFi SSID
    wifi_pass,        ///< WiFi password
    mqtt_enabled,     ///< MQTT enabled flag
    mqtt_server,      ///< MQTT server address
    mqtt_port,        ///< MQTT server port
    mqtt_username,    ///< MQTT username
    mqtt_pass,        ///< MQTT password
    mqtt_device_id,   ///< MQTT device ID
    co2_scale_type,   ///< CO2 scale type
    co2_alarm_lvl,    ///< CO2 alarm level
    rgb_enabled,      ///< RGB enabled flag
    use_dark_theme,   ///< Use dark theme flag
    rotation_display, ///< Rotation display
    log_lvl           ///< Log level
};

/**
 * @var co2_scale_types
 * @brief List of available CO2 scale types for UI
 */
extern String co2_scale_types;
/**
 * @var log_levels
 * @brief List of available log levels for UI
 */
extern String log_levels;

/**
 * @class SettingsDB
 * @brief Implements database logic for application settings
 */
class SettingsDB : public LoopTickerBase
{
public:
    /**
     * @brief Constructor
     */
    SettingsDB();

    /**
     * @brief Initialize database dependencies
     */
    void setup();

    /**
     * @brief Handle database updates
     */
    void exec() override;

    /**
     * @brief Return database instance
     * @return GyverDBFile reference
     */
    GyverDBFile &db();

private:
    GyverDBFile _db; ///< Internal database instance
};
src/db/settings_db.cpp
#include "configs/config.h"
#include "settings_db.h"

/**
 * @var co2_scale_types
 * @brief Colors CO2: 3 or 4 color ranges
 */
String co2_scale_types = "3 color;4 color";
/**
 * @var log_levels
 * @brief Log levels
 */
String log_levels = "DEBUG;INFO;WARN;ERROR";

SettingsDB::SettingsDB() : LoopTickerBase(), _db(&LittleFS, DB_NAME)
{
    LOG_INFO("init...");
    bool fsInitialized = true;

#ifdef ESP32
    fsInitialized = LittleFS.begin(true);
#else
    fsInitialized = LittleFS.begin();
#endif

    if (!fsInitialized)
    {
        LOG_ERROR("init littlefs failed!");
        return;
    }

    _db.begin();

    /**
     * @note Сброс базы данных к заводским настройкам, если определён RESET_DB
     */
#ifdef RESET_DB
    _db.reset();
#endif

    /**
     * @note Инициализация разделов настроек: APP, WIFI, MQTT, CO2
     */
    // ============================== APP ==============================
    _db.init(kk::rgb_enabled, RGB_ENABLED);
    _db.init(kk::use_dark_theme, APP_DARK_THEME);
    _db.init(kk::log_lvl, APP_LOG_LEVEL);
    _db.init(kk::rotation_display, TFT_ROTATION_0);

    // ============================== WIFI ==============================
    _db.init(kk::wifi_ssid, WIFI_SSID);
    _db.init(kk::wifi_pass, WIFI_PASS);

    // ============================== MQTT ==============================
    _db.init(kk::mqtt_enabled, MQTT_ENABLED);
    _db.init(kk::mqtt_server, MQTT_SERVER);
    _db.init(kk::mqtt_port, MQTT_PORT);
    _db.init(kk::mqtt_username, MQTT_USERNAME);
    _db.init(kk::mqtt_pass, MQTT_PASS);
    _db.init(kk::mqtt_device_id, MQTT_DEFAULT_DEVICE_ID);

    // ============================== CO2 ==============================
    _db.init(kk::co2_scale_type, "4 color");
    _db.init(kk::co2_alarm_lvl, RGB_DEFAULT_ALERT_TRHLD);

    /**
     * @note Вывод содержимого базы данных в сериал лог
     */
    _db.dump(Serial);

    LOG_INFO("init ok!");

    this->addLoop();
}

void SettingsDB::exec()
{
    _db.tick();
}

GyverDBFile &SettingsDB::db()
{
    return _db;
}

Далее нам необходимо подключится к сети. Для этого реализованы класс WiFiConnectorAdapter, а также класс WiFiConn. Данные классы реализуют логику подключение/переподключения к сети Wi‑Fi, создание собственной точки (hotspot). Опять же, WiFiConnectorAdapter это обертка над готовой библиотекой, которая предоставляет удобный API над стандартной библиотекой ESP8266WiFi.h или WiFi.h. В любой момент можно переключится на свою реализацию. Реализация подключения к Wi-Fi:

src/connections/wifi_conn.h
#pragma once
#include <Arduino.h>
#include <WiFiConnector.h>

#include "db/settings_db.h"
#include "configs/config.h"

#define LOG_COMPONENT "WiFiConn"
#include "services/logger.h"

/**
 * @class WiFiAdapter
 * @brief Abstract class for WiFi connectors
 */
class WiFiAdapter
{
public:
    /**
     * @brief Constructor
     * @param APname Access point name
     * @param APpass Access point password
     * @param timeout Connection timeout in seconds
     * @param closeAP Close AP after connection
     */
    WiFiAdapter(const String &APname = "AQM_AP",
                const String &APpass = "",
                uint16_t timeout = 60,
                bool closeAP = false) {}
    virtual ~WiFiAdapter() {}

    /**
     * @brief Try to (re)connect to WiFi network
     * @param ssid WiFi name
     * @param pass WiFi password
     */
    virtual void connect(const String &ssid, const String &pass = "") = 0;
    /**
     * @brief Check if in connecting mode
     * @return true if connecting
     */
    virtual bool connecting() = 0;
    /**
     * @brief Check if connected to network
     * @return true if connected
     */
    virtual bool connected() = 0;
    /**
     * @brief Handle (re)connect logic
     * @return true if successful
     */
    virtual bool exec() = 0;
    /**
     * @brief Return softAP or local IP
     * @return Device IP as string
     */
    virtual String ip() = 0;

private:
    bool _is_initialized = false; ///< Initialization flag
};

/**
 * @class WiFiConn
 * @brief Implements WiFi connection logic
 */
class WiFiConn : public LoopTickerBase
{
public:
    /**
     * @brief Constructor
     * @param settingsDb Reference to settings database
     * @param wifiAdapter Reference to WiFi adapter
     */
    WiFiConn(SettingsDB &settingsDb, WiFiAdapter &wifiAdapter);

    /**
     * @brief Connect to network
     */
    void connect();

    /**
     * @brief Return connection status
     * @return true if connected
     */
    bool connected();
    /**
     * @brief Check if class is initialized
     * @return true if initialized
     */
    bool isInitialized()
    {
        return _is_initialized;
    };
    /**
     * @brief Handle connection loop
     */
    void exec() override;
    /**
     * @brief Return softAP or local IP
     * @return Device IP as string
     */
    String ip();

private:
    /**
     * @brief Internal connect logic calling WiFiAdapter
     * @param ssid WiFi SSID
     * @param pass WiFi password
     */
    void _connect(const String &ssid, const String &pass);

    GyverDBFile *_db;           ///< Pointer to database
    WiFiAdapter *_wifi_adapter; ///< Pointer to WiFi adapter
    bool _is_initialized = false; ///< Initialization flag
};
src/connections/wifi_conn.cpp
#include "wifi_conn.h"

/**
 * @brief Constructor for WiFiConn
 * @param settingsDb Reference to settings database
 * @param wifiAdapter Reference to WiFi adapter
 */
WiFiConn::WiFiConn(SettingsDB &settingsDb, WiFiAdapter &wifiAdapter) : LoopTickerBase(), _db(&settingsDb.db()), _wifi_adapter(&wifiAdapter), _is_initialized(false)
{
    LOG_INFO("init...");

    if (!connected())
    {
        connect();
    }

    LOG_INFO("init ok!");

    this->addLoop();
    _is_initialized = true;
}

void WiFiConn::exec()
{
    _wifi_adapter->exec();
}

void WiFiConn::connect()
{
    _connect((*_db)[kk::wifi_ssid], (*_db)[kk::wifi_pass]);
}

bool WiFiConn::connected()
{
    return _wifi_adapter->connected();
}

String WiFiConn::ip()
{
    return _wifi_adapter->ip();
}

/**
 * @brief Internal connect logic calling WiFiAdapter
 * @param ssid WiFi SSID
 * @param pass WiFi password
 */
void WiFiConn::_connect(const String &ssid, const String &pass)
{
    if (ssid.length() == 0)
        return;
    LOG_INFO("connecting to " + ssid);
    _wifi_adapter->connect(ssid, pass);
}

Реализация менеджера соединения:

src/connections/wifi_connector_adapter.cpp
#include "wifi_conn.h"
#include "configs/config.h"

#define LOG_COMPONENT "WiFiConn"
#include "services/logger.h"

/**
 * @class WiFiConnectorAdapter
 * @brief Implements connection to WiFi using gyverlibs/WiFiConnector
 */
class WiFiConnectorAdapter : public WiFiAdapter
{
public:
    /**
     * @brief Constructor
     * @param APname Access point name
     * @param APpass Access point password
     * @param timeout Connection timeout in seconds
     * @param closeAP Close AP after connection
     */
    WiFiConnectorAdapter(
        const String &APname = "AQM_AP",
        const String &APpass = "",
        uint16_t timeout = 60,
        bool closeAP = false)
    {
        LOG_INFO("init...");
        _wifiConnector = new WiFiConnectorClass(APname, APpass, timeout, closeAP);

        _wifiConnector->onConnect([this]()
                                  { LOG_INFO("connected!"); });

        _wifiConnector->onError([this]()
                                { LOG_WARN("connection error"); });

        _is_initialized = true;
        LOG_INFO("init ok!");
    }

    void connect(const String &ssid, const String &pass = "") override
    {
        _wifiConnector->connect(ssid, pass);
    }

    bool connecting() override
    {
        return _wifiConnector->connecting();
    }

    bool connected() override
    {
        return _wifiConnector->connected();
    }

    String ip() override
    {
        if (!_wifiConnector->connected())
        {
            return WiFi.softAPIP().toString();
        }
        return WiFi.localIP().toString();
    }

    bool exec() override
    {
        return _wifiConnector->tick();
    }

private:
    WiFiConnectorClass *_wifiConnector = nullptr; ///< Pointer to WiFiConnectorClass instance
    bool _is_initialized = false; ///< Initialization flag
};

Автообновление прошивки реализованное в классе OTA. Это удобно, т.к. отдав устройство друзьям, можно удаленно изменить прошивку, если вдруг появятся баги или же допилить новые фичи в будущем.

src/services/ota.h
#pragma once
#include <AutoOTA.h>
#include <Looper.h>

#include "configs/config.h"
#include "connections/wifi_conn.h"

#define LOG_COMPONENT "OTA"
#include "services/logger.h"

/**
 * @name OTA
 * @details Implement logic to fetch firmware from remote source
 */
class OTA : public LoopTickerBase
{
public:
    OTA(WiFiConn &wifiConn);

    /**
     * @brief Execute OTA checks
     */
    void exec() override;
    /**
     * @details Check if new updates available
     * @return bool
     */
    bool hasUpdate();
    /**
     * @details Init async/sync update
     * @return bool
     */
    bool update(bool async = true);

    /**
     * @details Return current version
     * @return String
     */
    String version();

private:
    AutoOTA _ota = AutoOTA(APP_VERSION, PROJECT_PATH);
    /**
     * @name _wifi_conn
     * @details Pointer to WiFi connection
     */
    WiFiConn *_wifi_conn;

    bool _is_initialized = false;
    String _ver = "";
    String _notes = "";
};
src/services/ota.cpp
#include "ota.h"

OTA::OTA(WiFiConn &wifiConn) : LoopTickerBase(), _wifi_conn(&wifiConn)
{
    LOG_INFO("init...");

    _ver = APP_VERSION;
    _notes = "";

    LOG_INFO("init ok!");
    _is_initialized = true;
    this->addLoop();
};

bool OTA::hasUpdate()
{ 
    if (!_wifi_conn->connected()) return false;

    String _remote_ver = "";
    _ota.checkUpdate(&_remote_ver, &_notes);
    return !String(APP_VERSION).equals(_remote_ver);
}

bool OTA::update(bool now)
{
    if (!_wifi_conn->connected()) return false;
    
    if (_ota.checkUpdate(&_ver, &_notes))
    {
        LOG_INFO("update to " + _ver);
        if (!_notes.isEmpty())
            LOG_INFO("notes: " + _notes);

        if (!now)
        {
            _ota.update();
            return true;
        }

        return _ota.updateNow();
    }
    else
    {
        LOG_INFO("no updates");
        return true;
    }

    LOG_ERROR("update failed");

    return false;
};

String OTA::version()
{
    return _ver;
}

void OTA::exec()
{
    _ota.tick();
}

В качестве интеграции с УДЯ выбран MQTT. Реализация (пере)подключения к брокеру находится в классе MQTTConn. Пример взят из документации wqtt.ru и доработан.

src/connections/mqtt_conn.h
#pragma once
#include <Arduino.h>
#include <PubSubClient.h>

#if defined(ESP8266)
#include <ESP8266WiFi.h>
#elif defined(ESP32)
#include <WiFi.h>
#endif

#include "db/settings_db.h"
#include "configs/config.h"
#include "connections/wifi_conn.h"

#define LOG_COMPONENT "MQTTConn"
#include "services/logger.h"

extern WiFiClient _espClient;
extern PubSubClient _pub_client;

/**
 * @class MQTTConn
 * @brief Implements MQTT connection logic
 */
class MQTTConn : public LoopTickerBase
{
public:
    /**
     * @brief Constructor
     * @param settingsDb Reference to settings database
     * @param wifiConn Reference to WiFi connection
     */
    MQTTConn(SettingsDB &settingsDb, WiFiConn &wifiConn);

    /**
     * @brief Handle (re)connect logic
     */
    void exec() override;
    /**
     * @brief Connect to broker
     */
    void connect();
    /**
     * @brief Publish data to topic
     * @param topic MQTT topic
     * @param payload Data to publish
     */
    void publish(const String &topic, const String &payload);
    /**
     * @brief Set device ID for topic prefixes
     * @param id Device ID
     */
    void setDeviceID(const String &id);
    /**
     * @brief Check if publishing is enabled
     * @return true if enabled
     */
    bool isEnabled() const;
    /**
     * @brief Check if still connected to MQTT
     * @return true if connected
     */
    bool connected() const;
    /**
     * @brief Check if class is initialized
     * @return true if initialized
     */
    bool isInitialized() const;

private:
    /**
     * @brief Internal connection logic
     * @param mqtt_server MQTT host
     * @param mqtt_port MQTT port
     * @param mqtt_user MQTT user
     * @param mqtt_password MQTT password
     */
    void _connectToMQTT(const String &mqtt_server, uint16_t mqtt_port, const String &mqtt_user, const String &mqtt_password);

    GyverDBFile *_db = nullptr; ///< Pointer to database
    WiFiConn *_wifi = nullptr;  ///< Pointer to WiFi connection
    String _device_id = "";    ///< Device ID
    uint32_t _tmr = 0, _tout;
    bool _is_initialized = false; ///< Initialization flag
};
src/connections/mqtt_conn.cpp
#include "mqtt_conn.h"

WiFiClient _espClient;
PubSubClient _pub_client(_espClient);

/**
 * @brief Constructor for MQTTConn
 * @param settingsDb Reference to settings database
 * @param wifiConn Reference to WiFi connection
 */
MQTTConn::MQTTConn(SettingsDB &settingsDb, WiFiConn &wifiConn) : LoopTickerBase(), _db(&settingsDb.db()), _wifi(&wifiConn)
{
    LOG_INFO("init...");

    _tout = (15 * 1000ul);

    _device_id = (*_db)[kk::mqtt_device_id].toString();

    _connectToMQTT(
        (*_db)[kk::mqtt_server],
        (*_db)[kk::mqtt_port].toInt16(),
        (*_db)[kk::mqtt_username],
        (*_db)[kk::mqtt_pass]);

    LOG_INFO("init ok!");

    _is_initialized = true;
    this->addLoop();
}

void MQTTConn::exec()
{
    if (!isEnabled() || !_wifi->connected())
        return;

    bool pub_connected = _pub_client.loop();
    if (!pub_connected && (millis() - _tmr) >= _tout)
    {
        connect();
    }
}

void MQTTConn::connect()
{
    if (!isEnabled() || !_wifi->connected())
        return;

    if (!connected())
    {
        LOG_INFO("connect to server...");

        _connectToMQTT(
            (*_db)[kk::mqtt_server],
            (*_db)[kk::mqtt_port].toInt16(),
            (*_db)[kk::mqtt_username],
            (*_db)[kk::mqtt_pass]);
    }
}

void MQTTConn::publish(const String &topic, const String &payload)
{
    if (!isEnabled() || !_wifi->connected())
        return;

    String topicPrefix = "";
    if (!_device_id.isEmpty())
    {
        topicPrefix = _device_id + "/";
    }

    LOG_DEBUG("pub to topic: " + _device_id + "/" + topic + " value: " + payload);

    String t = topicPrefix + topic;

    bool ok = _pub_client.publish(t.c_str(), payload.c_str(), false);
    if (!ok)
    {
        LOG_ERROR("publish failed");
    }
}

void MQTTConn::setDeviceID(const String &id)
{
    if (id.isEmpty())
        return;

    _device_id = id;
}

bool MQTTConn::isEnabled() const
{
    return (*_db)[kk::mqtt_enabled].toBool();
}

bool MQTTConn::connected() const
{
    return _pub_client.connected();
}

bool MQTTConn::isInitialized() const
{
    return _is_initialized;
}

/**
 * @brief Internal connection logic
 * @param mqtt_server MQTT host
 * @param mqtt_port MQTT port
 * @param mqtt_user MQTT user
 * @param mqtt_password MQTT password
 */
void MQTTConn::_connectToMQTT(const String &mqtt_server, uint16_t mqtt_port, const String &mqtt_user, const String &mqtt_password)
{
    _tmr = millis();

    if (!isEnabled() || !_wifi->connected())
        return;

    if (mqtt_server.isEmpty())
    {
        LOG_ERROR("server address is empty");
        return;
    }

    if (mqtt_user.isEmpty() || mqtt_password.isEmpty())
    {
        LOG_ERROR("server creds is empty");
        return;
    }

    if (mqtt_port == 0 || mqtt_port > 65535)
    {
        LOG_ERROR("invalid port");
        return;
    }

    LOG_INFO("connecting to " + mqtt_server);

    _pub_client.setKeepAlive(60);
    _pub_client.setSocketTimeout(30);
    _pub_client.setServer(mqtt_server.c_str(), mqtt_port);

    String client_id = "AQM-" + WiFi.macAddress() + String(random(0xffff), HEX);

    if (client_id.isEmpty())
    {
        LOG_ERROR("failed to generate client ID");
        return;
    }

    LOG_DEBUG("client_id: " + client_id);
    LOG_DEBUG("mqtt_user: " + mqtt_user);
    LOG_DEBUG("mqtt_password: " + mqtt_password);
    LOG_DEBUG("mqtt_server: " + mqtt_server);
    LOG_DEBUG("mqtt_port: " + String(mqtt_port));

    LOG_DEBUG("attempting connection client...");

    if (_pub_client.connect(client_id.c_str(), mqtt_user.c_str(), mqtt_password.c_str()))
    {
        LOG_INFO("connected");
        return;
    }
    else
    {
        LOG_ERROR("failed, rc=" + String(_pub_client.state()) + " try again...");
        return;
    }
}

Реализация логики чтения параметров датчика CCS811 и его калибровка находится в классе CO2Sensor. Там же описана работа со шкалой для удобства в отдельном классе CO2Scale. В классе CO2Sensor реализована state машина, чтобы удобно переключаться между основным режимом работы и режимом калибровки.

src/sensors/co2.h
#pragma once
#include <Arduino.h>
#include <Wire.h>
#include <SparkFunCCS811.h>

#include "sensor_base.h"
#include "configs/config.h"
#include "connections/mqtt_conn.h"
#include "model/co2_data.h"
#include "db/settings_db.h"

#define LOG_COMPONENT "CO2Sensor"
#include "services/logger.h"

/**
 * @name CO2Sensor
 * @details Class for working with CCS811 sensor: reading data, calibration, state management
 */
class CO2Sensor : public SensorBase
{
public:
    /**
     * @name CO2Sensor
     * @param ms - polling period in milliseconds
     * @details CO2Sensor class constructor
     */
    CO2Sensor(uint32_t ms);

    /**
     * @name setup
     * @details Initialize CCS811 dependencies
     */
    void setup() override;
    /**
     * @name exec
     * @details Main polling and data processing loop for the sensor
     */
    void exec() override;
    /**
     * @name getCO2Min
     * @return float - minimum CO2 value
     */
    float getCO2Min();
    /**
     * @name getCO2Max
     * @return float - maximum CO2 value
     */
    float getCO2Max();
    /**
     * @name getCO2
     * @return float - current CO2 value
     */
    float getCO2();
    /**
     * @name getTVOCMin
     * @return float - minimum TVOC value
     */
    float getTVOCMin();
    /**
     * @name getTVOCMax
     * @return float - maximum TVOC value
     */
    float getTVOCMax();
    /**
     * @name getTVOC
     * @return float - current TVOC value
     */
    float getTVOC();
    /**
     * @name getType
     * @return const char* - sensor type
     */
    const char *getType() const override;

    /**
     * @name isCalibrating
     * @return bool - whether the sensor is in calibration mode
     */
    bool isCalibrating()
    {
        return _state == CO2Sensor_CALIBRATING;
    }

    /**
     * @name startCalibration
     * @details Force start sensor calibration
     */
    void startCalibration()
    {
        LOG_DEBUG("force start calibration");

        if (_data.current_baseline == 0x01)
        {
            _data.current_baseline = 0x00;
        }

        _state = CO2Sensor_CALIBRATING;

        if (_data.current_baseline == 0x00)
        {
            _data.current_baseline = _sensor.getBaseline();
        }

        delay(5000);
    };

    /**
     * @name forceStopCalibration
     * @details Force stop sensor calibration and write baseline
     */
    void forceStopCalibration()
    {
        if (_state != CO2Sensor_CALIBRATING || _data.current_baseline == 0x01)
        {
            return;
        }

        LOG_DEBUG("force stop calibration");

        CCS811Core::CCS811_Status_e errorStatus = _sensor.setBaseline(_data.current_baseline);
        if (errorStatus == CCS811Core::CCS811_Stat_SUCCESS)
        {
            LOG_DEBUG("baseline written to sensor");
            LOG_INFO("calibration success");
        }
        else
        {
            LOG_DEBUG("set baseline failed!");
            LOG_DEBUG(_sensor.statusString(errorStatus));
            LOG_INFO("calibration failed");
        }

        _data.current_baseline = 0x01;

        delay(5000);

        _state = CO2Sensor_RUNNING;
    };

private:
    /**
     * @name _sensor
     * @details CCS811 driver instance
     */
    CCS811 _sensor;
    /**
     * @name _data
     * @details Structure for storing current sensor data
     */
    CO2Data _data;
    /**
     * @name _state
     * @details Current sensor state (initialization, running, calibration)
     */
    CO2Sensor_State _state;

    /**
     * @name _init
     * @details Internal sensor initialization
     * @return bool - initialization success
     */
    bool _init();
    /**
     * @name _check_data
     * @details Check and update sensor data
     */
    void _check_data();
    /**
     * @name _print_data
     * @details Print current data to log
     */
    void _print_data();
        /**
     * @name _mock_data
     * @details Mock data for scale and alarm testing
     */
    void _mock_data();
};

/**
 * @name CO2Scale
 * @details Class for working with CO2 color scale and thresholds
 */
class CO2Scale
{
public:
    /**
     * @name getInstance
     * @return CO2Scale& - singleton instance of the class
     */
    static CO2Scale &getInstance();

    /**
     * @name init
     * @param db - pointer to database object
     * @details Initialize scale from database
     */
    void init(GyverDBFile *db);
    /**
     * @name getColor
     * @param value - CO2 value
     * @param r,g,b - color components
     * @details Get color by CO2 value
     */
    void getColor(uint16_t value, uint8_t &r, uint8_t &g, uint8_t &b);
    /**
     * @name getScale
     * @details Get color zone boundaries of the scale
     */
    void getScale(uint16_t &rs, uint16_t &re, uint16_t &os, uint16_t &oe, uint16_t &ys, uint16_t &ye, uint16_t &gs, uint16_t &ge);

    /**
     * @name getMin
     * @return float - minimum scale value
     */
    float getMin();
    /**
     * @name getMax
     * @return float - maximum scale value
     */
    float getMax();
    /**
     * @name getHumanMax
     * @return float - maximum value for human comfort
     */
    float getHumanMax();
    /**
     * @name needAlarm
     * @param value - CO2 value
     * @return bool - whether alarm is needed
     */
    bool needAlarm(uint16_t value);

private:
    /**
     * @name CO2Scale
     * @details Private constructor for singleton
     */
    CO2Scale();
    /**
     * @name _initScales
     * @details Internal initialization of color scales
     */
    void _initScales();

    GyverDBFile *_db;
    ColorThreshold _default_scale[5];
    ColorThreshold _easy_scale[3];
    float _min = 400.0f;
    float _max = 8000.0f;
    float _human_max = 1500.0f;
};
src/sensors/co2.cpp
#include "co2.h"

CO2Sensor::CO2Sensor(uint32_t ms) : SensorBase(ms), _state(CO2Sensor_INIT)
{
    LOG_INFO("init...");
    _data.co2 = 0.0;
    _data.tvoc = 0.0;
    _data.current_baseline = 0x00;

    if (!_enable_test && !_init())
    {
        LOG_ERROR("init failed! please check your wiring.");
        this->addLoop();
        return;
    }

    _is_initialized = true;
    _state = CO2Sensor_RUNNING;

    LOG_INFO("init ok!");
    exec();
    this->addLoop();
}

void CO2Sensor::setup() {}

void CO2Sensor::exec()
{
    if (!_is_initialized)
    {
        _init();
        return;
    }

    if (_state != CO2Sensor_CALIBRATING)
    {
        _state = CO2Sensor_RUNNING;
        _check_data();
    }
}

float CO2Sensor::getCO2() { return _data.co2; }
float CO2Sensor::getTVOC() { return _data.tvoc; }

const char *CO2Sensor::getType() const
{
    return "co2";
}

float CO2Sensor::getCO2Min()
{
    return 400.0f;
}

float CO2Sensor::getCO2Max()
{
    return 8192.0f;
}

float CO2Sensor::getTVOCMin()
{
    return 0.0f;
}

float CO2Sensor::getTVOCMax()
{
    return 1187.0f;
}

bool CO2Sensor::_init()
{
    if (_enable_test)
    {
        return true;
    }

    _sensor = CCS811(CCS811_ADDR);

    Wire.begin();
    if (_sensor.beginWithStatus() != CCS811Core::CCS811_Stat_SUCCESS)
    {
        LOG_ERROR("init failed!");
        return false;
    }

    _sensor.setDriveMode(2); // Set measurement interval: 1 - every 1s, 2 - every 10s, 3 - every 60s

    return true;
}

void CO2Sensor::_check_data()
{
    if (_enable_test)
    {
        _mock_data();
        _print_data();
        return;
    }

    if (_sensor.dataAvailable())
    {
        _sensor.readAlgorithmResults();

        _data.co2 = static_cast<float>(_sensor.getCO2());
        if (_data.co2 >= getCO2Max())
        {
            _data.co2 = getCO2Max();
        }
        if (_data.co2 <= getCO2Min())
        {
            _data.co2 = getCO2Min();
        }

        _data.tvoc = static_cast<float>(_sensor.getTVOC());
        if (_data.tvoc >= getTVOCMax())
        {
            _data.tvoc = getTVOCMax();
        }
        if (_data.tvoc <= getTVOCMin())
        {
            _data.tvoc = getTVOCMin();
        }

        _print_data();
    }
    else if (_sensor.checkForStatusError())
    {
        uint8_t error = _sensor.getErrorRegister();

        if (error == 0xFF) // comm error
        {
            LOG_ERROR("failed to get ERROR_ID register.");
        }
        else
        {
            String errMsg = "Error: ";
            if (error & 1 << 5)
                errMsg += "HeaterSupply";
            if (error & 1 << 4)
                errMsg += " HeaterFault ";
            if (error & 1 << 3)
                errMsg += " MaxResistance ";
            if (error & 1 << 2)
                errMsg += " MeasModeInvalid ";
            if (error & 1 << 1)
                errMsg += " ReadRegInvalid ";
            if (error & 1 << 0)
                errMsg += "MsgInvalid";
            if (!errMsg.isEmpty())
                LOG_ERROR(errMsg);
        }
    }
}

void CO2Sensor::_mock_data()
{
    _data.tvoc = 3000.1;
    _data.co2 += 100.0;
    if (_data.co2 >= 1500.0) _data.co2 = 0.0;
}

void CO2Sensor::_print_data()
{
    LOG_DEBUG("CO2: " + String(_data.co2) + " ppm, TVOC: " + String(_data.tvoc) + " ppb");
}

// --- CO2Scale ---
CO2Scale &CO2Scale::getInstance()
{
    static CO2Scale instance;
    return instance;
}

CO2Scale::CO2Scale() = default;

void CO2Scale::init(GyverDBFile *db)
{
    _db = db;
    _initScales();
}

void CO2Scale::getScale(uint16_t &rs, uint16_t &re, uint16_t &os, uint16_t &oe, uint16_t &ys, uint16_t &ye, uint16_t &gs, uint16_t &ge)
{
    if ((*_db)[kk::co2_scale_type].toString() == "1")
    {
        rs = 75;
        re = 100;
        os = 50;
        oe = 75;
        ys = 25;
        ye = 50;
        gs = 0;
        ge = 25;
    }
    else
    {
        rs = 66;
        re = 100;
        os = -1;
        oe = -1;
        ys = 33;
        ye = 66;
        gs = 0;
        ge = 33;
    }

    return;
}

void CO2Scale::getColor(uint16_t value, uint8_t &r, uint8_t &g, uint8_t &b)
{
    const ColorThreshold *scale;
    size_t size;

    if ((*_db)[kk::co2_scale_type].toString() == "1")
    {
        scale = _default_scale;
        size = 4;
    }
    else
    {
        scale = _easy_scale;
        size = 3;
    }

    // try find by color in scale
    for (size_t i = 0; i < size; ++i)
    {
        if (value <= scale[i].threshold)
        {
            r = scale[i].r;
            g = scale[i].g;
            b = scale[i].b;
            return;
        }
    }

    // if not in scale choose second default color
    r = scale[size - 1].r;
    g = scale[size - 1].g;
    b = scale[size - 1].b;
}

float CO2Scale::getMin() { return _min; }
float CO2Scale::getMax() { return _max; }
float CO2Scale::getHumanMax() { return _human_max; }

bool CO2Scale::needAlarm(uint16_t value)
{
    float co2_lvl = (*_db)[kk::co2_alarm_lvl].toFloat();
    if (co2_lvl <= 0)
    {
        return false;
    }

    return value >= co2_lvl + 10;
}

void CO2Scale::_initScales()
{
    _default_scale[0] = {390, 0, 255, 0}; // green
    _default_scale[1] = {790, 255, 255, 0}; // yellow
    _default_scale[2] = {1150, 255, 128, 0}; // orange
    _default_scale[3] = {1500, 255, 0, 0}; // red

    _easy_scale[0] = {590, 0, 255, 0}; // green
    _easy_scale[1] = {1090, 255, 255, 0}; // yellow
    _easy_scale[2] = {1500, 255, 0, 0}; // red
}

Похожим образом реализована работа с BME280. Но у него нет режимов калибровки, т.к. ее нужно выполнять опытным путем - сравнивать с чем-то и поверять.

tph.h
#pragma once
#include <Arduino.h>
#include <Wire.h>
#include <GyverBME280.h>

#include "sensor_base.h"
#include "connections/mqtt_conn.h"
#include "model/tph_data.h"
#include "db/settings_db.h"
#include "configs/config.h"

#define LOG_COMPONENT "TPHSensor"
#include "services/logger.h"

/**
 * @class TPHSensor
 * @brief Class for working with BME280 sensor: reading temperature, pressure, and humidity data
 */
class TPHSensor : public SensorBase
{
public:
    /**
     * @brief Constructor
     * @param ms Polling period in milliseconds
     */
    TPHSensor(uint32_t ms);

    /**
     * @brief Initialize BME280 dependencies
     */
    void setup() override;
    /**
     * @brief Main polling and data processing loop for the sensor
     */
    void exec() override;
    /**
     * @brief Get minimum temperature value
     * @return float Minimum temperature
     */
    float getTemperatureMin();
    /**
     * @brief Get maximum temperature value
     * @return float Maximum temperature
     */
    float getTemperatureMax();
    /**
     * @brief Get current temperature value
     * @return float Current temperature
     */
    float getTemperature();
    /**
     * @brief Get minimum pressure value
     * @return float Minimum pressure
     */
    float getPressureMin();
    /**
     * @brief Get maximum pressure value
     * @return float Maximum pressure
     */
    float getPressureMax();
    /**
     * @brief Get current pressure value
     * @return float Current pressure
     */
    float getPressure();
    /**
     * @brief Get minimum humidity value
     * @return float Minimum humidity
     */
    float getHumidityMin();
    /**
     * @brief Get maximum humidity value
     * @return float Maximum humidity
     */
    float getHumidityMax();
    /**
     * @brief Get current humidity value
     * @return float Current humidity
     */
    float getHumidity();
    /**
     * @brief Get sensor type as string
     * @return const char* Sensor type
     */
    const char *getType() const override;

private:
    GyverBME280 _sensor; ///< BME280 driver instance
    TPHData _data;       ///< Structure for storing current sensor data

    /**
     * @brief Internal sensor initialization
     * @return bool Initialization success
     */
    bool _init();
    /**
     * @brief Check and update sensor data
     */
    void _check_data();
    /**
     * @brief Print current data to log
     */
    void _print_data();
};
tph.cpp
#include "tph.h"

TPHSensor::TPHSensor(uint32_t ms) 
    : SensorBase(ms) {
    LOG_INFO("init...");

    _data.pressure = 0.0;
    _data.temp = 0.0;
    
    if (!_enable_test && !_init()) {
        LOG_ERROR("init failed! please check your wiring.");
        this->addLoop();
        return;
    }

    _is_initialized = true;
    LOG_INFO("init ok!");
    exec();
    this->addLoop();
}

void TPHSensor::setup() {}

void TPHSensor::exec() {
    if (!_is_initialized) {
        _init();
        return;
    }
    
    _check_data();
}

float TPHSensor::getTemperature() { 
    return _data.temp; 
}

float TPHSensor::getPressure() { 
    return _data.pressure; 
}

float TPHSensor::getHumidity() { 
    return _data.humidity; 
}

const char* TPHSensor::getType() const { 
    return "tph_sensor"; 
}

float TPHSensor::getTemperatureMin() { 
    return -40.0f; 
}

float TPHSensor::getTemperatureMax() { 
    return 85.0f; 
}

float TPHSensor::getPressureMin() { 
    return 30000.0f;  // Minimum pressure: 300 hPa in Pa
}

float TPHSensor::getPressureMax() { 
    return 110000.0f; // Maximum pressure: 1100 hPa in Pa
}

float TPHSensor::getHumidityMin() { 
    return 0.0f; // Maximum pressure: 0 % in RH
}

float TPHSensor::getHumidityMax() { 
    return 100.0f; // Maximum pressure: 100 % in RH
}

bool TPHSensor::_init() {
    if (!_sensor.begin(BME280_ADDR)) {
        LOG_ERROR("init failed! please check your wiring.");
        return false;
    }

    _sensor.setFilter(FILTER_COEF_4);
    
    return true;
}

void TPHSensor::_check_data() {
    if (_enable_test) {
        _data.temp = 25.0f;
        _data.pressure = 1.0f;
        _data.humidity = 100.0f;
        _print_data();
        return;
    }

    _data.temp = constrain(_sensor.readTemperature(), getTemperatureMin(), getTemperatureMax());
    _data.pressure = constrain(_sensor.readPressure(), getPressureMin(), getPressureMax());
    _data.humidity = constrain(_sensor.readHumidity(), getHumidityMin(), getHumidityMax());

    _print_data();
}

void TPHSensor::_print_data() {
    LOG_DEBUG(String("temp: ") + _data.temp + " °C, " +
             "pressure: " + (_data.pressure / 100.0f) + " hPa, " +
            "humidity: " + _data.humidity + " %");
}

Устройство шлет данные с переодичностью в 30 сек брокеру MQTT с помощью класса MQTTPublisher. Всего инициируется 4 экземпляра. Один экземпляр обслуживает одно значение. Например, топик для TVOC (common/aqm/tvoc). Пример реализации представлен ниже.

src/services/publisher.h
#pragma once
#include <Arduino.h>
#include "Looper.h"

#include "connections/mqtt_conn.h"

/**
 * @name MQTTPublisher
 * @details Class for periodic publishing of values to an MQTT topic
 */
class MQTTPublisher : public LoopTimerBase
{
public:
    /**
     * @brief Callback to get the value for publishing
     */
    using ValueCallback = std::function<float()>;

    /**
     * @brief Constructor
     * @param ms Publish interval in milliseconds
     * @param mqtt Reference to MQTTConn object
     * @param topic MQTT topic for publishing (default is empty)
     */
    MQTTPublisher(uint32_t ms, MQTTConn &mqtt, const String &topic = "")
        : LoopTimerBase(ms), _mqtt(mqtt), _enabled(true), _topic(topic)
    {
        this->addLoop();
    }

    /**
     * @brief Main publish loop
     */
    void exec() override
    {
        if (!_enabled || !_mqtt.connected() || !_cb)
            return;
        publish();
    }

    /**
     * @brief Set MQTT topic
     * @param topic New topic
     */
    void setTopic(const String &topic)
    {
        if (!topic.isEmpty())
            _topic = topic;
    }

    /**
     * @brief Set callback to get the value
     * @param cb Callback function
     */
    void setValueCb(ValueCallback cb)
    {
        _cb = cb;
    }

    /**
     * @brief Enable publishing
     */
    void enable() { _enabled = true; }
    /**
     * @brief Disable publishing
     */
    void disable() { _enabled = false; }

private:
    MQTTConn &_mqtt;   ///< Reference to MQTT connection
    bool _enabled;     ///< Publishing enabled flag
    String _topic;     ///< MQTT topic
    ValueCallback _cb; ///< Callback to get the value

    /**
     * @brief Publish value to MQTT
     */
    void publish()
    {
        if (_topic.isEmpty())
        {
            return;
        }

        float value = _cb();
        String payload = String(value, 2);
        _mqtt.publish(_topic, payload);
    }
};

Логика вывода данных пользователю на дисплей и в веб-панель разделена на два класса - Display и WebPanel. В случае дисплея мы выводим показания датчиков, адрес веб-панели, статус подключения к сети, а также обслуживаем две темы (светлую и темную). В случае веб-панели с помощью библиотеки выводим данные о настройках и позволяем их изменять.

src/hmi/display.h
#pragma once
#include <Looper.h>
#include "SPI.h"
#include <TFT_eSPI.h>

#include "model/display_data.h"
#include "widgets/meter.h"
#include "configs/config.h"
#include "sensors/co2.h"
#include "sensors/tph.h"
#include "connections/wifi_conn.h"
#include "connections/mqtt_conn.h"
#include "services/ota.h"

#define LOG_COMPONENT "Display"
#include "services/logger.h"

/**
 * @name Display
 * @details Class for managing the TFT display, rendering widgets and sensor data
 */
class Display : public LoopTimerBase
{
public:
    /**
     * @name Display
     * @param ms - update interval in milliseconds
     * @param settingsDb - reference to settings database
     * @param co2_sensor - reference to CO2 sensor
     * @param tph_sensor - reference to temperature/pressure sensor
     * @param wifiConn - reference to WiFi connection
     * @param mqttConn - reference to MQTT connection
     * @param ota - reference to OTA update service
     * @details Display class constructor, initializes display and widgets
     */
    Display(
        uint32_t ms,
        SettingsDB &settingsDb,
        CO2Sensor &co2_sensor,
        TPHSensor &tph_sensor,
        WiFiConn &wifiConn,
        MQTTConn &mqttConn,
        OTA &ota)
        : LoopTimerBase(ms),
          _db(&settingsDb.db()),
          _co2_sensor(co2_sensor),
          _tph_sensor(tph_sensor),
          _co2_meter(nullptr),
          _co2_scale(&CO2Scale::getInstance()),
          _wifi(&wifiConn),
          _mqtt(&mqttConn),
          _ota(&ota)
    {
        _tft_rotate = (*_db)[kk::rotation_display].toInt();
        _force_redraw = true;
        _state.dark_theme = (*_db)[kk::use_dark_theme].toBool();
        _state.last_co2_value = -1;
        _state.last_wifi_state = false;
        _state.last_mqtt_state = false;
        _state.last_co2_sensor_state = false;
        _state.last_render_time = 0;
        _state.last_fw_ver = _ota->version();

        LOG_INFO("init tft...");

        _tft.init();
        _tft.setRotation(_tft_rotate);
        _init_theme(true);
        LOG_INFO("init tft ok!");

        LOG_INFO("init widgets...");
        _co2_meter = MeterWidget(&_tft);

        _co2_scale->init(_db);
        uint16_t rs, re, os, oe, ys, ye, gs, ge;
        _co2_scale->getScale(rs, re, os, oe, ys, ye, gs, ge);
        _co2_meter.setZones(rs, re, os, oe, ys, ye, gs, ge);
        _co2_meter.setTheme(_state.dark_theme);
        _co2_meter.analogMeter(0, 0, _co2_scale->getHumanMax(), "CO2", "", "", "", "", "");
        LOG_INFO("init widgets ok!");

        _render();
        this->addLoop();
    }

    /**
     * @name exec
     * @details Main display update loop, triggers rendering
     */
    void exec()
    {
        _render();
    }

    /**
     * @name setTheme
     * @param dark - true for dark theme, false for light theme
     * @details Change display theme and force redraw
     */
    void setTheme(bool dark)
    {
        if (_state.dark_theme == dark)
            return;

        _state.dark_theme = dark;
        _init_theme(true);

        _co2_meter.setTheme(dark);
        uint16_t rs, re, os, oe, ys, ye, gs, ge;
        _co2_scale->getScale(rs, re, os, oe, ys, ye, gs, ge);
        _co2_meter.setZones(rs, re, os, oe, ys, ye, gs, ge);
        _co2_meter.analogMeter(0, 0, _co2_scale->getHumanMax(), "CO2", "", "", "", "", "");

        _force_redraw = true;
        _render();
    }

    /**
     * @name moveRotation
     * @details Change display rotation
     */
    void moveRotation()
    {
        _tft_rotate += 1;
        if (_tft_rotate > 3)
            _tft_rotate = TFT_ROTATION_360;

        (*_db)[kk::rotation_display] = _tft_rotate;

        _tft.setRotation(_tft_rotate);

        if (_state.dark_theme)
        {
            _tft.fillScreen(TFT_BLACK);
        }
        else
        {
            _tft.fillScreen(TFT_WHITE);
        }

        _co2_meter.setTheme(_state.dark_theme);
        uint16_t rs, re, os, oe, ys, ye, gs, ge;
        _co2_scale->getScale(rs, re, os, oe, ys, ye, gs, ge);
        _co2_meter.setZones(rs, re, os, oe, ys, ye, gs, ge);
        _co2_meter.analogMeter(0, 0, _co2_scale->getHumanMax(), "CO2", "", "", "", "", "");

        _force_redraw = true;
        _render();
    }

    /**
     * @name forceRender
     * @details Re-render display data
     */
    void forceRender()
    {
        _co2_meter.setTheme(_state.dark_theme);
        uint16_t rs, re, os, oe, ys, ye, gs, ge;
        _co2_scale->getScale(rs, re, os, oe, ys, ye, gs, ge);
        _co2_meter.setZones(rs, re, os, oe, ys, ye, gs, ge);
        _co2_meter.analogMeter(0, 0, _co2_scale->getHumanMax(), "CO2", "", "", "", "", "");

        _force_redraw = true;
        _render();
    }

private:
    /**
     * @name _tft
     * @details TFT display driver instance
     */
    TFT_eSPI _tft = TFT_eSPI();
    /**
     * @name _db
     * @details Pointer to settings database
     */
    GyverDBFile *_db;
    /**
     * @name _co2_meter
     * @details CO2 meter widget instance
     */
    MeterWidget _co2_meter;
    /**
     * @name _co2_sensor
     * @details Reference to CO2 sensor
     */
    CO2Sensor &_co2_sensor;
    /**
     * @name _co2_scale
     * @details Pointer to CO2 scale instance
     */
    CO2Scale *_co2_scale;
    /**
     * @name _tph_sensor
     * @details Reference to temperature/pressure sensor
     */
    TPHSensor &_tph_sensor;
    /**
     * @name _wifi
     * @details Pointer to WiFi connection
     */
    WiFiConn *_wifi;
    /**
     * @name _mqtt
     * @details Pointer to MQTT connection
     */
    MQTTConn *_mqtt;
    /**
     * @name _ota
     * @details Pointer to OTA update service
     */
    OTA *_ota;
    /**
     * @name _state
     * @details Display state structure
     */
    DisplayState _state;
    /**
     * @name _force_redraw
     * @details Flag to force display redraw
     */
    bool _force_redraw = false;
    /**
     * @name _tft_rotate
     * @details Set display orientation
     */
    int _tft_rotate = TFT_ROTATION_0;

    /**
     * @name _render
     * @details Render all display widgets and info
     */
    void _render()
    {
        if (!_should_render())
        {
            return;
        }

        LOG_DEBUG("render started...");
        _print_wifi_info();
        _print_mqtt_info();
        _print_gauge();
        _print_sensor_state();
        _print_fw_version();
        LOG_DEBUG("rendered ok!");

        _force_redraw = false;
    }

    /**
     * @name _should_render
     * @details Check if display needs to be updated
     * @return bool - true if update is needed
     */
    bool _should_render()
    {
        if (_force_redraw)
        {
            return true;
        }

        bool current_wifi_state = _wifi->connected();
        if (current_wifi_state != _state.last_wifi_state)
        {
            _state.last_wifi_state = current_wifi_state;
            return true;
        }

        bool current_mqtt_state = _mqtt->connected();
        if (current_mqtt_state != _state.last_mqtt_state)
        {
            _state.last_mqtt_state = current_mqtt_state;
            return true;
        }

        bool current_co2_sensor_state = _co2_sensor.isCalibrating();
        if (current_co2_sensor_state != _state.last_co2_sensor_state)
        {
            _state.last_co2_sensor_state = current_co2_sensor_state;
            _force_redraw = true;
            return true;
        }

        float current_co2 = _co2_sensor.getCO2();
        if (abs(current_co2 - _state.last_co2_value) > 5.0)
        {
            _state.last_co2_value = current_co2;
            return true;
        }

        float current_temp = _tph_sensor.getTemperature();
        if (abs(current_temp - _state.last_temp_value) > 1.0)
        {
            _state.last_temp_value = current_temp;
            return true;
        }

        float current_pressure = _tph_sensor.getPressure();
        if (abs(current_pressure - _state.last_pressure_value) > 1.0)
        {
            _state.last_pressure_value = current_pressure;
            return true;
        }

        float current_humidity = _tph_sensor.getHumidity();
        if (abs(current_humidity - _state.last_humidity_value) > 5.0)
        {
            _state.last_humidity_value = current_humidity;
            return true;
        }

        String current_fw_ver = _ota->version();
        if (current_fw_ver != _state.last_fw_ver)
        {
            _state.last_fw_ver = current_fw_ver;
            return true;
        }

        bool current_has_updates = _ota->hasUpdate();
        if (current_has_updates != _state.has_updates)
        {
            _state.has_updates = current_has_updates;
            return true;
        }

        if ((millis() - _state.last_render_time) > SEC_5)
        {
            _state.last_render_time = millis();
            _force_redraw = true;
            return true;
        }

        return false;
    }

    /**
     * @name _print_fw_version
     * @details Print firmware version
     */
    void _print_fw_version()
    {
        if (_force_redraw)
        {
            // show current fw version
            _tft.setCursor(100, 185);
            if (_state.dark_theme)
            {
                _tft.fillRect(100, 185, 60, 10, TFT_BLACK);
            }
            else
            {
                _tft.fillRect(100, 185, 60, 10, TFT_WHITE);
            }
            _tft.setTextColor(TFT_LIGHTGREY);
            _tft.print(F("v "));
            _tft.println(_state.last_fw_ver);

            // show little green round dot as updates notification
            _tft.setCursor(145, 185);
            if (_state.dark_theme)
            {
                if (_state.has_updates)
                {
                    _tft.drawSmoothCircle(145, 185, 2, TFT_GREENYELLOW, TFT_BLACK);
                }
                else
                {
                    _tft.drawSmoothCircle(145, 185, 2, TFT_BLACK, TFT_BLACK);
                }
            }
            else
            {
                if (_state.has_updates)
                {
                    _tft.drawSmoothCircle(145, 185, 2, TFT_GREEN, TFT_WHITE);
                }
                else
                {
                    _tft.drawSmoothCircle(145, 185, 2, TFT_WHITE, TFT_WHITE);
                }
            }
        }
    }

    /**
     * @name _print_mqtt_info
     * @details Print MQTT info
     */
    void _print_mqtt_info()
    {
        if (_force_redraw)
        {
            _tft.setCursor(130, 145);
            if (_state.dark_theme)
            {
                _tft.fillRect(130, 145, 60, 10, TFT_BLACK);
            }
            else
            {
                _tft.fillRect(130, 145, 60, 10, TFT_WHITE);
            }

            if (!_state.last_mqtt_state)
            {
                _tft.setTextColor(TFT_RED);
                _tft.println(F("MQTT"));
                LOG_ERROR("mqtt not connected");
            }
            else
            {
                _tft.setTextColor(TFT_GREEN);
                _tft.println(F("MQTT"));
            }
        }
    }

    /**
     * @name _print_wifi_info
     * @details Print WiFi info and firmware version to display
     */
    void _print_wifi_info()
    {
        if (_force_redraw)
        {
            _init_theme(false);

            _tft.setCursor(20, 130);
            if (_state.dark_theme)
            {
                _tft.fillRect(20, 130, 200, 10, TFT_BLACK);
            }
            else
            {
                _tft.fillRect(20, 130, 200, 10, TFT_WHITE);
            }

            LOG_DEBUG("admin panel: http://" + _wifi->ip());

            _tft.setTextColor(TFT_LIGHTGREY);
            _tft.print(F("admin panel: http://"));
            _tft.println(_wifi->ip());

            _tft.setCursor(90, 145);
            if (_state.dark_theme)
            {
                _tft.fillRect(90, 145, 60, 10, TFT_BLACK);
            }
            else
            {
                _tft.fillRect(90, 145, 60, 10, TFT_WHITE);
            }

            if (!_state.last_wifi_state)
            {
                _tft.setTextColor(TFT_RED);
                _tft.println(F("WIFI"));
                LOG_ERROR("wifi not connected");
            }
            else
            {
                _tft.setTextColor(TFT_GREEN);
                _tft.println(F("WIFI"));
            }
        }
    }

    /**
     * @name _print_gauge
     * @details Print and update CO2 gauge widget
     */
    void _print_gauge()
    {
        if (!_co2_sensor.isInitialized())
            return;

        float value = static_cast<float>(_co2_sensor.getCO2());
        if (value > _co2_scale->getHumanMax())
        {
            value = _co2_scale->getHumanMax();
        }

        _init_theme(false);

        LOG_DEBUG("update gauge value: " + String(value));
        _tft.setCursor(0, 0);
        _co2_meter.updateNeedle(value, 10);
    }

    /**
     * @name _print_sensor_state
     * @details Print sensor state (e.g. calibration) to display
     */
    void _print_sensor_state()
    {
        if (_force_redraw)
        {
            _init_theme(false);

            _tft.setCursor(90, 165);
            if (_state.dark_theme)
            {
                _tft.fillRect(90, 165, 80, 10, TFT_BLACK);
            }
            else
            {
                _tft.fillRect(90, 165, 80, 10, TFT_WHITE);
            }

            _tft.setTextColor(TFT_CYAN);
            if (_state.last_co2_sensor_state)
            {
                _tft.println(F("CALIBRATION"));
            }
            else
            {
                if (_state.dark_theme)
                {
                    _tft.fillRect(90, 165, 80, 10, TFT_BLACK);
                }
                else
                {
                    _tft.fillRect(90, 165, 80, 10, TFT_WHITE);
                }
            }
        }
    }

    /**
     * @name _init_theme
     * @param fill - whether to fill the background
     * @details Initialize display theme (dark/light)
     */
    void _init_theme(bool fill)
    {
        _tft.setTextSize(1);

        if (_state.dark_theme)
        {
            if (fill)
                _tft.fillScreen(TFT_BLACK);
            _tft.setTextColor(TFT_WHITE);
            return;
        }

        if (fill)
            _tft.fillScreen(TFT_WHITE);
        _tft.setTextColor(TFT_BLACK);
    }
};
src/hmi/web.h
#pragma once
#include <SettingsGyver.h>

#include "configs/config.h"
#include "db/settings_db.h"
#include "connections/mqtt_conn.h"
#include "sensors/co2.h"
#include "sensors/tph.h"
#include "controllers/rgb.h"
#include "connections/wifi_conn.h"
#include "services/publisher.h"
#include "services/ota.h"
#include "display.h"

#define LOG_COMPONENT "WebPannel"
#include "services/logger.h"

/**
 * @name WebPanel
 * @details Class for managing the web interface panel, settings, and integration with other modules
 */
class WebPanel : public LoopTickerBase {
public:
    /**
     * @name WebPanel
     * @param settingsDb - reference to settings database
     * @param wifiConn - reference to WiFi connection
     * @param ota - reference to OTA update service
     * @param mqttConn - reference to MQTT connection
     * @param rgbCtrl - reference to RGB controller
     * @param hmi - reference to display/HMI
     * @param co2sensor - reference to CO2 sensor
     * @param tphSensor - reference to TPH sensor
     * @details Full-featured constructor for WebPanel
     */
    WebPanel(SettingsDB& settingsDb, 
        WiFiConn& wifiConn, 
        OTA& ota,
        MQTTConn& mqttConn, 
        RGBController& rgbCtrl,
        Display& hmi,
        CO2Sensor& co2sensor,
        TPHSensor& tphSensor
    );
    /**
     * @name WebPanel
     * @param settingsDb - reference to settings database
     * @param wifiConn - reference to WiFi connection
     * @details Minimal constructor for WebPanel (WiFi only)
     */
    WebPanel(SettingsDB& settingsDb, 
        WiFiConn& wifiConn
    );

    /**
     * @name exec
     * @details Main loop for web panel logic
     */
    void exec() override;

private:
    /**
     * @name _init
     * @details Internal initialization of web panel and dependencies
     */
    void _init();
    /**
     * @name _update
     * @param u - settings updater
     * @details Update settings from web interface
     */
    void _update(sets::Updater& u);
    /**
     * @name _build
     * @param b - settings builder
     * @details Build web interface structure and settings
     */
    void _build(sets::Builder& b);

    SettingsGyver _sett;              ///< Settings manager instance
    GyverDBFile* _db;                 ///< Pointer to database file
    WiFiConn* _wifi_conn;             ///< Pointer to WiFi connection
    OTA* _ota;                        ///< Pointer to OTA update service
    MQTTConn* _mqtt_conn;             ///< Pointer to MQTT connection
    RGBController* _rgb_controller;   ///< Pointer to RGB controller
    Display* _display;                ///< Pointer to display/HMI
    CO2Sensor* _co2_sensor;           ///< Pointer to CO2 sensor
    TPHSensor* _tph_sensor;           ///< Pointer to TPH sensor

    bool _is_initialized;             ///< Initialization flag
};
src/hmi/web.cpp
#include "web.h"

sets::Logger webLogger(1024);
bool cfm_fw = false;

WebPanel::WebPanel(
    SettingsDB &settingsDb,
    WiFiConn &wifiConn)
    : LoopTickerBase(),
      _sett(String(APP_NAME) + " v" + String(APP_VERSION), &settingsDb.db()),
      _db(&settingsDb.db()),
      _wifi_conn(&wifiConn),
      _is_initialized(false)
{
    _init();
}

WebPanel::WebPanel(
    SettingsDB &settingsDb,
    WiFiConn &wifiConn,
    OTA &ota,
    MQTTConn &mqttConn,
    RGBController &rgbController,
    Display &display,
    CO2Sensor &co2sensor,
    TPHSensor &tphSeonsor)
    : LoopTickerBase(),
      _sett(String(APP_NAME) + " v" + ota.version(), &settingsDb.db()),
      _db(&settingsDb.db()),
      _wifi_conn(&wifiConn),
      _ota(&ota),
      _mqtt_conn(&mqttConn),
      _rgb_controller(&rgbController),
      _display(&display),
      _co2_sensor(&co2sensor),
      _tph_sensor(&tphSeonsor),
      _is_initialized(false)
{
    _init();
}

void WebPanel::exec()
{
    if (!_is_initialized)
    {
        LOG_ERROR("call setup first!");
        return;
    }

    _sett.tick();
}

void WebPanel::_init()
{
    LOG_INFO("init...");
    Logger::getInstance().initWebLogger(webLogger);
    _sett.config.requestTout = SEC_10;
    _sett.config.pingTout = SEC_30;
    _sett.config.updateTout = 0;
    _sett.config.theme = sets::Colors::Green;
    _sett.begin(false);
    _sett.onUpdate([this](sets::Updater &u)
                   { this->_update(u); });
    _sett.onBuild([this](sets::Builder &b)
                  { this->_build(b); });
    _sett.onFocusChange([this]()
                        { LOG_DEBUG("browser connected!"); });
    LOG_INFO("init ok!");

    _is_initialized = true;
    this->addLoop();
}

void WebPanel::_update(sets::Updater &u)
{
#ifdef WEB_PANEL_DASHBOARD
    u.update(SH("eco2_gauge"), _co2_sensor->getCO2());
    u.update("tvoc_gauge"_h, _co2_sensor->getTVOC());
    u.update("temp_gauge"_h, _tph_sensor->getTemperature());
    u.update("pressure_gauge"_h, _tph_sensor->getPressure());
    u.update("humidity_gauge"_h, _tph_sensor->getHumidity());
#endif

    u.update(H(log), webLogger);

    if (_ota && _ota->hasUpdate())
        u.update("update"_h, "New updates available. Try update firmware?");
}

void WebPanel::_build(sets::Builder &b)
{
#ifdef WEB_PANEL_DASHBOARD
    SUB_BUILD_BEGIN
    sets::Group g(b, "Dashboard");
    b.LinearGauge(SH("eco2_gauge"), "eCO2", _co2_sensor->getCO2Min(), _co2_sensor->getCO2Max(), "ppm", 0.0f);
    b.LinearGauge("tvoc_gauge"_h, "TVOC", _co2_sensor->getTVOCMin(), _co2_sensor->getTVOCMax(), "ppb", 0.0f);
    b.LinearGauge("temp_gauge"_h, "Temp", _tph_sensor->getTemperatureMin(), _tph_sensor->getTemperatureMax(), "°C", 0.0f);
    b.LinearGauge("pressure_gauge"_h, "Pressure", _tph_sensor->getPressureMin(), _tph_sensor->getPressureMax(), "hPa", 0.0f);
    b.LinearGauge("humidity_gauge"_h, "Humidity", _tph_sensor->getHumidityMin(), _tph_sensor->getHumidityMax(), "%", 0.0f);
    SUB_BUILD_END
#endif

    sets::Group g(b, "Settings");

    SUB_BUILD_BEGIN
    sets::Menu m(b, "Wi-Fi");
    b.Input(kk::wifi_ssid, "SSID");
    b.Pass(kk::wifi_pass, "Password");
    b.Button(SH("wifi_save"), "Save");
    SUB_BUILD_END

    SUB_BUILD_BEGIN
    sets::Menu m(b, "MQTT");
    b.Switch(kk::mqtt_enabled, "Enabled");
    b.Input(kk::mqtt_server, "Server");
    b.Number(kk::mqtt_port, "Port");
    b.Input(kk::mqtt_username, "Username");
    b.Pass(kk::mqtt_pass, "Password");
    b.Input(kk::mqtt_device_id, "Device ID");
    b.Button(SH("mqtt_save"), "Save");
    SUB_BUILD_END

    SUB_BUILD_BEGIN
    sets::Menu m(b, "CO2");
    b.Number(kk::co2_alarm_lvl, "Alarm value", nullptr, 0, 8000);
    if (b.Select(kk::co2_scale_type, "Scale type", co2_scale_types))
    {
        _display->forceRender();
    }

    sets::Group g(b, "Calibration");
    if (b.beginButtons())
    {
        if (b.Button(SH("co2_calibrate_run"), "Run", sets::Colors::Green))
        {
            LOG_DEBUG("co2_calibrate_run pressed");
            _co2_sensor->startCalibration();
        }
        if (b.Button(SH("co2_calibrate_stop"), "Stop", sets::Colors::Red))
        {
            LOG_DEBUG("co2_calibrate_stop pressed");
            _co2_sensor->forceStopCalibration();
        }
        b.endButtons();
    }
    SUB_BUILD_END

    SUB_BUILD_BEGIN
    sets::Menu m(b, "System");
    if (b.Switch(kk::rgb_enabled, "RGB Enabled"))
    {
        _rgb_controller->toggle((*_db)[kk::rgb_enabled].toBool());
    }
    if (b.Switch(kk::use_dark_theme, "Use dark theme"))
    {
        _display->setTheme((*_db)[kk::use_dark_theme].toBool());
    }
    if (b.Select(kk::log_lvl, "Log", log_levels))
    {
        SET_LOG_LEVEL((*_db)[kk::log_lvl].toString());
    }
    if (b.Button(SH("rotate_display"), "Rotate display"))
    {
        if (_display)
            _display->moveRotation();
    }
    b.Log(H(log), webLogger);
    if (b.Button(SH("update_fw"), "Update firmware") || b.Confirm("update"_h))
    {
        if (_ota)
        {
            LOG_INFO("ota update start");
            _ota->update(true);
        }
    }
    SUB_BUILD_END

    SUB_BUILD_BEGIN
    b.Link("User Manual", USER_MANUAL_URL);
    SUB_BUILD_END

    SUB_BUILD_BEGIN
    if (b.build.isAction())
    {   
        switch (b.build.id)
        {
        case SH("wifi_save"):
            LOG_DEBUG("wifi_save pressed");

            if (_db && _db->update() && _wifi_conn)
            {
                _wifi_conn->connect();
                return;
            }

            break;

        case SH("mqtt_save"):
            LOG_DEBUG("mqtt_save pressed");

            if (_db && _db->update() && _mqtt_conn)
            {
                _mqtt_conn->setDeviceID((*_db)[kk::mqtt_device_id].toString());
                _mqtt_conn->connect();
                return;
            }

            break;
        default:
            break;
        }
    }
    SUB_BUILD_END
}

Данные о CO2 выводятся на переписанный виджет из этой библиотеки. Я добавил поддержку темной темы и внес небольшие фиксы (например, убрал окантовку по контуру)
Так же в ходе разработки и отладки заметил жуткие лаги при выводе информации на дисплей. Как оказалось, обновление данных на дисплее довольно дорогая операция и нет смысла часто обновлять значения, поэтому в display.h (функция shouldrender), так и в meter.cpp я сохраняю все данные и сравниваю их с новыми, чтобы лишний раз не триггерить перерисовку.

meter.h
#ifndef meter_h
#define meter_h

#include "Arduino.h"
#include "TFT_eSPI.h"

// Meter class
class MeterWidget
{
public:
  MeterWidget(TFT_eSPI *tft);

  // Set widget theme
  void setTheme(bool dark);
  // Set red, orange, yellow and green start+end zones as a percentage of full scale
  void setZones(uint16_t rs, uint16_t re, uint16_t os, uint16_t oe, uint16_t ys, uint16_t ye, uint16_t gs, uint16_t ge);
  // Draw meter meter at x, y and define full scale range plus the scale labels
  void analogMeter(uint16_t x, uint16_t y, float fullScale, const char *units, const char *s0, const char *s1, const char *s2, const char *s3, const char *s4);
  // Draw meter meter at x, y and define full scale range plus the scale labels
  void analogMeter(uint16_t x, uint16_t y, float startScale, float endScale, const char *units, const char *s0, const char *s1, const char *s2, const char *s3, const char *s4);
  // Move needle to new position
  void updateNeedle(float value, uint32_t ms_delay);

private:
  // Pointer to TFT_eSPI class functions
  TFT_eSPI *ntft;

  float ltx;         // x delta of needle start
  uint16_t osx, osy; // Saved x & y coords of needle end
  int old_analog;    // Value last displayed
  int old_value;     // Value last displayed

  bool dark_theme;

  // x, y coord of top left corner of meter graphic
  uint16_t mx;
  uint16_t my;

  // Scale factor
  float factor;
  float scaleStart;

  // Scale label
  char mlabel[9];

  // Scale values
  char ms0[5];
  char ms1[5];
  char ms2[5];
  char ms3[5];
  char ms4[5];

  // Scale colour zone start end end values
  int16_t redStart;
  int16_t redEnd;
  int16_t orangeStart;
  int16_t orangeEnd;
  int16_t yellowStart;
  int16_t yellowEnd;
  int16_t greenStart;
  int16_t greenEnd;
};

#endif
meter.cpp
#include "Arduino.h"

#include "meter.h"

// #########################################################################
// Meter constructor
// #########################################################################
MeterWidget::MeterWidget(TFT_eSPI *tft)
{
  ltx = 0;              // Saved x coord of bottom of needle
  osx = 120, osy = 120; // Saved x & y coords
  old_analog = -999;    // Value last displayed
  old_value = -999;     // Value last displayed

  mx = 0;
  my = 0;

  factor = 1.0;
  scaleStart = 0.0;

  memset(mlabel, 0, sizeof(mlabel));

  // Defaults
  strncpy(ms0, "0", 4);
  strncpy(ms1, "25", 4);
  strncpy(ms2, "50", 4);
  strncpy(ms3, "75", 4);
  strncpy(ms4, "100", 4);

  redStart = 0;
  redEnd = 0;
  orangeStart = 0;
  orangeEnd = 0;
  yellowStart = 0;
  yellowEnd = 0;
  greenStart = 0;
  greenEnd = 0;

  ntft = tft;
}

// #########################################################################
// Draw meter meter at x, y and define full scale range & the scale labels
// #########################################################################
void MeterWidget::analogMeter(uint16_t x, uint16_t y, float fullScale, const char *units, const char *s0, const char *s1, const char *s2, const char *s3, const char *s4)
{
  analogMeter(x, y, 0.0, fullScale, units, s0, s1, s2, s3, s4);
}

void MeterWidget::analogMeter(uint16_t x, uint16_t y, float startScale, float endScale, const char *units, const char *s0, const char *s1, const char *s2, const char *s3, const char *s4)
{
  // Save offsets for needle plotting
  mx = x;
  my = y;
  factor = 100.0 / (endScale - startScale);
  scaleStart = startScale;

  strncpy(mlabel, units, sizeof(mlabel) - 1);
  mlabel[sizeof(mlabel) - 1] = '\0';

  strncpy(ms0, s0, 4);
  strncpy(ms1, s1, 4);
  strncpy(ms2, s2, 4);
  strncpy(ms3, s3, 4);
  strncpy(ms4, s4, 4);

  // Meter outline
  if (dark_theme)
  {
    ntft->fillRect(x + 5, y + 3, 230, 119, TFT_BLACK);
    ntft->setTextColor(TFT_WHITE); // Text colour
  }
  else
  {
    ntft->fillRect(x + 5, y + 3, 230, 119, TFT_WHITE);
    ntft->setTextColor(TFT_BLACK); // Text colour
  }

  // Draw ticks every 5 degrees from -50 to +50 degrees (100 deg. FSD swing)
  for (int i = -50; i < 51; i += 5)
  {
    // Long scale tick length
    int tl = 15;

    // Coordinates of tick to draw
    float sx = cos((i - 90) * 0.0174532925);
    float sy = sin((i - 90) * 0.0174532925);
    uint16_t x0 = x + sx * (100 + tl) + 120;
    uint16_t y0 = y + sy * (100 + tl) + 140;
    uint16_t x1 = x + sx * 100 + 120;
    uint16_t y1 = y + sy * 100 + 140;

    // Coordinates of next tick for zone fill
    float sx2 = cos((i + 5 - 90) * 0.0174532925);
    float sy2 = sin((i + 5 - 90) * 0.0174532925);
    int x2 = x + sx2 * (100 + tl) + 120;
    int y2 = y + sy2 * (100 + tl) + 140;
    int x3 = x + sx2 * 100 + 120;
    int y3 = y + sy2 * 100 + 140;

    // Red zone limits
    if (redEnd > redStart)
    {
      if (i >= redStart && i < redEnd)
      {
        ntft->fillTriangle(x0, y0, x1, y1, x2, y2, TFT_RED);
        ntft->fillTriangle(x1, y1, x2, y2, x3, y3, TFT_RED);
      }
    }

    // Orange zone limits
    if (orangeEnd > orangeStart)
    {
      if (i >= orangeStart && i < orangeEnd)
      {
        ntft->fillTriangle(x0, y0, x1, y1, x2, y2, TFT_ORANGE);
        ntft->fillTriangle(x1, y1, x2, y2, x3, y3, TFT_ORANGE);
      }
    }

    // Yellow zone limits
    if (yellowEnd > yellowStart)
    {
      if (i >= yellowStart && i < yellowEnd)
      {
        ntft->fillTriangle(x0, y0, x1, y1, x2, y2, TFT_YELLOW);
        ntft->fillTriangle(x1, y1, x2, y2, x3, y3, TFT_YELLOW);
      }
    }

    // Green zone limits
    if (greenEnd > greenStart)
    {
      if (i >= greenStart && i < greenEnd)
      {
        ntft->fillTriangle(x0, y0, x1, y1, x2, y2, TFT_GREEN);
        ntft->fillTriangle(x1, y1, x2, y2, x3, y3, TFT_GREEN);
      }
    }

    // Short scale tick length
    if (i % 25 != 0)
      tl = 8;

    // Recalculate coords in case tick length changed
    x0 = x + sx * (100 + tl) + 120;
    y0 = y + sy * (100 + tl) + 140;
    x1 = x + sx * 100 + 120;
    y1 = y + sy * 100 + 140;

    // Draw tick
    if (dark_theme)
    {
      ntft->drawLine(x0, y0, x1, y1, TFT_WHITE);
    }
    else
    {
      ntft->drawLine(x0, y0, x1, y1, TFT_BLACK);
    }

    // Check if labels should be drawn, with position tweaks
    if (i % 25 == 0)
    {
      // Calculate label positions
      x0 = x + sx * (100 + tl + 10) + 120;
      y0 = y + sy * (100 + tl + 10) + 140;
      switch (i / 25)
      {
      case -2:
        ntft->drawCentreString(ms0, x0, y0 - 12, 2);
        break;
      case -1:
        ntft->drawCentreString(ms1, x0, y0 - 9, 2);
        break;
      case 0:
        ntft->drawCentreString(ms2, x0, y0 - 6, 2);
        break;
      case 1:
        ntft->drawCentreString(ms3, x0, y0 - 9, 2);
        break;
      case 2:
        ntft->drawCentreString(ms4, x0, y0 - 12, 2);
        break;
      }
    }

    // Now draw the arc of the scale
    sx = cos((i + 5 - 90) * 0.0174532925);
    sy = sin((i + 5 - 90) * 0.0174532925);
    x0 = x + sx * 100 + 120;
    y0 = y + sy * 100 + 140;
    // Draw scale arc, don't draw the last part
    if (i < 50)
    {
      if (dark_theme)
      {
        ntft->drawLine(x0, y0, x1, y1, TFT_WHITE);
      }
      else
      {
        ntft->drawLine(x0, y0, x1, y1, TFT_BLACK);
      }
    }
  }

  ntft->drawString(mlabel, x + 5 + 230 - 40, y + 119 - 20, 2); // Units at bottom right
  // ntft->drawCentreString(mlabel, x + 120, y + 70, 4);          // Comment out to avoid font 4
  // ntft->drawRect(x + 5, y + 3, 230, 119, TFT_WHITE);           // Draw bezel line

  updateNeedle(0, 0);
}

// #########################################################################
// Update needle position
// This function is blocking while needle moves, time depends on ms_delay
// 10ms minimises needle flicker if text is drawn within needle sweep area
// Smaller values OK if text not in sweep area, zero for instant movement but
// does not look realistic... (note: 100 increments for full scale deflection)
// #########################################################################
void MeterWidget::updateNeedle(float val, uint32_t ms_delay)
{
  int value = (val - scaleStart) * factor;
  old_value = value;
  char buf[8];
  dtostrf(val, 6, 1, buf);

  char *p = buf;
  while (*p == ' ')
    p++;

  if (dark_theme)
  {
    ntft->setTextColor(TFT_WHITE, TFT_BLACK);
  }
  else
  {
    ntft->setTextColor(TFT_BLACK, TFT_WHITE);
  }

  int clearWidth = 60;
  int clearHeight = 20;

  uint16_t bgColor = dark_theme ? TFT_BLACK : TFT_WHITE;

  ntft->fillRect(mx + 50 - clearWidth, my + 119 - 20, clearWidth, clearHeight, bgColor);
  ntft->drawRightString(p, mx + 50, my + 119 - 20, 2);

  if (value < -10)
    value = -10; // Limit value to emulate needle end stops
  if (value > 110)
    value = 110;

  int value_diff = abs(value - old_analog);
  if (value_diff <= 5)
  {
    ms_delay = 0; // Instant update for small changes
  }
  else if (value_diff <= 15)
  {
    ms_delay = ms_delay / 2; // Faster animation for medium changes
  }

  // Move the needle until new value reached
  while (value != old_analog)
  {
    if (old_analog < value)
      old_analog++;
    else
      old_analog--;

    if (ms_delay == 0)
      old_analog = value; // Update immediately if delay is 0

    float sdeg = map(old_analog, -10, 110, -150, -30); // Map value to angle
    // Calculate tip of needle coords
    float sx = cos(sdeg * 0.0174532925);
    float sy = sin(sdeg * 0.0174532925);

    // Calculate x delta of needle start (does not start at pivot point)
    float tx = tan((sdeg + 90) * 0.0174532925);

    // Erase old needle image
    if (dark_theme)
    {
      ntft->drawLine(mx + 120 + 20 * ltx - 1, my + 140 - 20, mx + osx - 1, my + osy, TFT_BLACK);
      ntft->drawLine(mx + 120 + 20 * ltx, my + 140 - 20, mx + osx, my + osy, TFT_BLACK);
      ntft->drawLine(mx + 120 + 20 * ltx + 1, my + 140 - 20, mx + osx + 1, my + osy, TFT_BLACK);
      ntft->setTextColor(TFT_WHITE);
    }
    else
    {
      ntft->drawLine(mx + 120 + 20 * ltx - 1, my + 140 - 20, mx + osx - 1, my + osy, TFT_WHITE);
      ntft->drawLine(mx + 120 + 20 * ltx, my + 140 - 20, mx + osx, my + osy, TFT_WHITE);
      ntft->drawLine(mx + 120 + 20 * ltx + 1, my + 140 - 20, mx + osx + 1, my + osy, TFT_WHITE);
      ntft->setTextColor(TFT_BLACK);
    }

    // Re-plot text under needle
    // ntft->drawCentreString(mlabel, mx + 120, my + 70, 4); // // Comment out to avoid font 4

    // Store new needle end coords for next erase
    ltx = tx;
    osx = sx * 98 + 120;
    osy = sy * 98 + 140;

    // Draw the needle in the new position, magenta makes needle a bit bolder
    // draws 3 lines to thicken needle
    ntft->drawLine(mx + 120 + 20 * ltx - 1, my + 140 - 20, mx + osx - 1, my + osy, TFT_RED);
    ntft->drawLine(mx + 120 + 20 * ltx, my + 140 - 20, mx + osx, my + osy, TFT_MAGENTA);
    ntft->drawLine(mx + 120 + 20 * ltx + 1, my + 140 - 20, mx + osx + 1, my + osy, TFT_RED);

    // Slow needle down slightly as it approaches new position
    if (abs(old_analog - value) < 10)
      ms_delay += ms_delay / 5;

    // Wait before next update (only if delay > 0)
    if (ms_delay > 0)
    {
      delay(ms_delay);
    }
  }
}

// #########################################################################
// Set red, orange, yellow and green start+end zones as a % of full scale
// #########################################################################
void MeterWidget::setZones(uint16_t rs, uint16_t re, uint16_t os, uint16_t oe, uint16_t ys, uint16_t ye, uint16_t gs, uint16_t ge)
{
  // Meter scale is -50 to +50
  redStart = rs - 50;
  redEnd = re - 50;
  orangeStart = os - 50;
  orangeEnd = oe - 50;
  yellowStart = ys - 50;
  yellowEnd = ye - 50;
  greenStart = gs - 50;
  greenEnd = ge - 50;
}

// #########################################################################
// Set widget theme
// #########################################################################
void MeterWidget::setTheme(bool dark)
{
  dark_theme = dark;
  updateNeedle(old_value, 0);
}

Финальным компонентом прошивки устройства является сигнализация через RGB светодиод. Сейчас он бесполезный, потому что предупреждение так же можно показывать на экране устройства. Но например, с далека по нему можно понять какой сейчас уровень CO2.

src/controllers/rgb.h
#pragma once
#include <Adafruit_NeoPixel.h>

#include "controller_base.h"
#include "configs/config.h"
#include "sensors/co2.h"

#define LOG_COMPONENT "RGBController"
#include "services/logger.h"

/**
 * @name RGBController
 * @details Class for controlling RGB LED strip based on CO2 levels
 */
class RGBController : public ControllerBase
{
public:
    /**
     * @name UpdaterCallback
     * @details Callback type for updating CO2 value
     * @return uint16_t - current CO2 value
     */
    using UpdaterCallback = std::function<uint16_t()>;

    /**
     * @name RGBController
     * @param ms - update interval in milliseconds
     * @param settingsDb - reference to settings database
     * @details Constructor for RGBController
     */
    RGBController(uint32_t ms, SettingsDB &settingsDb);
    /**
     * @name ~RGBController
     * @details Destructor for RGBController
     */
    ~RGBController();

    /**
     * @name exec
     * @details Main update loop for RGB controller
     */
    void exec() override;
    /**
     * @name toggle
     * @param value - enable or disable RGB
     * @details Enable or disable RGB output
     */
    void toggle(bool value) override;

    /**
     * @name setUpdaterCb
     * @param cb - callback to provide CO2 value
     * @details Set callback for updating CO2 value
     */
    void setUpdaterCb(UpdaterCallback cb);
    /**
     * @name renderLevel
     * @param value - CO2 value
     * @details Render color based on CO2 value
     */
    void renderLevel(float value);
    /**
     * @name clear
     * @details Clear all LEDs (turn off)
     */
    void clear();
    /**
     * @name getType
     * @return const char* - controller type
     * @details Return controller type string
     */
    const char *getType() const override;

private:
    /**
     * @name _leds
     * @details Pointer to Adafruit_NeoPixel instance
     */
    Adafruit_NeoPixel *_leds;
    /**
     * @name _db
     * @details Pointer to settings database file
     */
    GyverDBFile *_db;
    /**
     * @name _u_cb
     * @details Callback for updating CO2 value
     */
    UpdaterCallback _u_cb;
    /**
     * @name _co2_scale
     * @details Pointer to CO2 scale instance for color mapping
     */
    CO2Scale *_co2_scale;
    /**
     * @name _blink
     * @details State for alarm blinking
     */
    bool _blink;
    /**
     * @name _pin
     * @details Pin number for RGB strip
     */
    uint8_t _pin;
    /**
     * @name _num_leds
     * @details Number of LEDs in the strip
     */
    uint8_t _num_leds;
    /**
     * @name _default_period
     * @details Default update period
     */
    uint16_t _default_period;
    /**
     * @name _curr_period
     * @details Current update period
     */
    uint16_t _curr_period;

    /**
     * @name _renderAlarm
     * @param value - CO2 value
     * @details Render alarm state (blinking red) if CO2 is above threshold
     */
    void _renderAlarm(float value);
};
src/controllers/rgb.cpp
#include "rgb.h"

RGBController::RGBController(uint32_t ms, SettingsDB &settingsDb)
    : ControllerBase(ms),
      _pin(RGB_PIN),
      _num_leds(RGB_NUMPIXELS),
      _leds(nullptr),
      _db(&settingsDb.db()),
      _co2_scale(&CO2Scale::getInstance()),
      _blink(false)
{
    LOG_INFO("init...");

    _leds = new Adafruit_NeoPixel(_num_leds, _pin, NEO_GRB + NEO_KHZ800);
    _leds->begin();
    _leds->setBrightness(150);
    clear();

    _co2_scale->init(_db);
    _default_period = this->getPeriod();
    _enabled = (*_db)[kk::rgb_enabled].toBool();

    LOG_INFO("init ok!");
    _is_initialized = true;
    this->addLoop();
}

RGBController::~RGBController()
{
    if (_leds)
    {
        delete _leds;
        _leds = nullptr;
    }
}

void RGBController::setUpdaterCb(UpdaterCallback cb)
{
    _u_cb = cb;
}

void RGBController::exec()
{
    _curr_period = this->getPeriod();

    if (!_is_initialized)
    {
        return;
    }

    if (!_u_cb)
    {
        return;
    }

    uint16_t co2_value = _u_cb();
    if (_co2_scale->needAlarm(co2_value))
    {
        _renderAlarm(co2_value);
        return;
    }

    if (_curr_period != _default_period)
    {
        this->updateInterval(_default_period);
    }

    _blink = false;
    renderLevel(co2_value);
}

void RGBController::toggle(bool value)
{
    _enabled = value;

    if (!_enabled)
        clear();

    if (value)
    {
        LOG_DEBUG("enabled");
        return;
    }

    LOG_DEBUG("disabled");
}

void RGBController::renderLevel(float value)
{
    if (!_enabled || _num_leds <= 0 || !_is_initialized || _leds == nullptr)
        return;

    uint8_t r, g, b;
    _co2_scale->getColor(value, r, g, b);

    for (int i = 0; i < _num_leds; i++)
    {
        _leds->setPixelColor(i, r, g, b);
    }

    _leds->show();
}

void RGBController::_renderAlarm(float value)
{
    if (!_enabled || _num_leds <= 0 || !_is_initialized || _leds == nullptr)
        return;

    uint8_t r = 255;
    uint8_t g = 0;
    uint8_t b = 0;

    if (_curr_period != SEC_1)
    {
        this->updateInterval(SEC_1);
    }

    if (_blink)
    {
        _blink = false;
        clear();
        return;
    }

    _blink = true;
    for (int i = 0; i < _num_leds; i++)
    {
        _leds->setPixelColor(i, r, g, b);
    }

    _leds->show();
}

void RGBController::clear()
{
    if (_leds != nullptr)
    {
        LOG_DEBUG("cleared");
        _leds->clear();
        _leds->show();
    }
}

const char *RGBController::getType() const
{
    return "rgb";
}

Осталось лишь описать конфигурацию прошивки. Она выполнена в отдельных заголовочных файлах config.h и secrets.h по пути src/configs. Первый файл хранит все доп настройки. В секретах мы храним креды к Wi-Fi сети и MQTT серверу. Файл добавлен в .gitignore и нужен только для локальной проверки. В целом можно использовать secrets.example.h, а настройки задать в веб-панели.

src/configs/config.h
#pragma once
#include <Arduino.h>
// #include "configs/secrets.h" // HINT: for development only
#include "configs/secrets.example.h"

#define STRINGIZER(arg) #arg
#define STR_VALUE(arg) STRINGIZER(arg)

// app
#define APP_NAME "AirQualityMonitor"
#define APP_VERSION STR_VALUE(BUILD_VERSION) // Change version via project.json!
#define APP_LOG_LEVEL "DEBUG"                 // DEBUG, ERROR, WARN, INFO
// #define ENABLE_TEST // Enable mock sensor reading
#define APP_DARK_THEME false // Select color theme
// app

// maint
// #define DB_RESET // Factory reset database
#define DB_NAME "/settings.db"
#define PROJECT_PATH "WildEgor/AirQualityMonitor/master/project.json"
#define USER_MANUAL_URL "https://github.com/WildEgor/AirQualityMonitor/blob/master/docs/en/UserManual.md"

// feature flags
// #define WEB_PANEL_DASHBOARD
// maint

// System constants (do not change)
#define CCS811_ADDR 0x5A
#define BME280_ADDR 0x76 // 0x77 or 0x76
#define SERIAL_SPEED 115200
#define MS_100 100
#define MS_500 500
#define SEC_1 1000
#define SEC_3 3000
#define SEC_5 5000
#define SEC_10 10000
#define SEC_30 30000
// system

// MQTT service for interaction with Yandex (see wqtt.ru)
#define MQTT_ENABLED false
#define MQTT_SERVER "m8.wqtt.ru"
#define MQTT_PORT 20336
#define MQTT_DEFAULT_DEVICE_ID "common/aqm" // Used as topic prefix for uniqueness
#define MQTT_DEFAULT_CO2_TOPIC "co2"
#define MQTT_DEFAULT_TVOC_TOPIC "tvoc"
#define MQTT_DEFAULT_TEMP_TOPIC "temp"
#define MQTT_DEFAULT_PRESSURE_TOPIC "pressure"
#define MQTT_DEFAULT_HUMIDITY_TOPIC "humidity"
// mqtt

// WiFi settings (see also secrets.example.h)
#define WIFI_AP_NAME "AQM_AP" // Prefix for Wi-Fi access point with settings
#define WIFI_AP_PASS "adminadmin"
#define WIFI_CONN_RETRY_TIMEOUT 15 // seconds
// wifi

// RGB settings
#define RGB_ENABLED false
#define RGB_PIN 19
#define RGB_NUMPIXELS 4              // Number of LEDs in the strip. Min: 1, Max: 255
#define RGB_DEFAULT_ALERT_TRHLD 1200 // CO2 threshold for red blinking
// rgb settings

// hmi
#define TFT_WIDTH 240
#define TFT_HEIGHT 240
#define TFT_ROTATION_0 2 // start position
#define TFT_ROTATION_360 0

/**
 * NOTE: Make changes here
 * To change pins: .pio/libdeps/mhetesp32devkit/TFT_eSPI/User_Setups/Setup200_GC9A01.h
 * Uncomment the correct driver: .pio/libdeps/mhetesp32devkit/TFT_eSPI/User_Setup_Select.h
 */
#define GC9A01_DRIVER

// esp_32_live_mini
#define TFT_MOSI 23 // On some display driver boards, it might be labeled as "SDA" etc.
#define TFT_SCLK 18
#define TFT_CS 5   // Chip select control pin
#define TFT_DC 16  // Data/Command control pin
#define TFT_RST 17 // Reset pin (can be connected to Arduino RESET pin)
// esp_32_s2_mini
// #define TFT_MOSI 9
// #define TFT_SCLK 11
// #define TFT_CS   5
// #define TFT_DC   7
// #define TFT_RST  3

#define SPI_FREQUENCY 27000000
#define SPI_READ_FREQUENCY 5000000

#define LOAD_GLCD  // Font 1. Original Adafruit 8 pixel font needs ~1820 bytes in FLASH
#define LOAD_FONT2 // Font 2. Small 16 pixel high font, needs ~3534 bytes in FLASH, 96 characters
#define LOAD_FONT4 // Font 4. Medium 26 pixel high font, needs ~5848 bytes in FLASH, 96 characters
#define LOAD_FONT6 // Font 6. Large 48 pixel font, needs ~2666 bytes in FLASH, only characters 1234567890:-.apm
#define LOAD_FONT7 // Font 7. 7 segment 48 pixel font, needs ~2438 bytes in FLASH, only characters 1234567890:.
#define LOAD_FONT8 // Font 8. Large 75 pixel font needs ~3256 bytes in FLASH, only characters 1234567890:-.
#define LOAD_GFXFF // FreeFonts. Include access to the 48 Adafruit_GFX free fonts FF1 to FF48 and custom fonts
#define SMOOTH_FONT
// hmi
src/configs/secrets.example.h
// your wifi network creds. Also can be configured using web ui
#define WIFI_SSID "*****"
#define WIFI_PASS "*****"

// your mqtt broker creds
#define MQTT_USERNAME "*****"
#define MQTT_PASS "*****"

Основная часть по коду закончена. Не обращайте (или жду в комментах) на кучу комментариев по коду. Я психанул и попросил курсор сгенерить доку. Конечно, есть еще несколько вспомогательных классов, но мне кажется я и так же добавил кучу кода. Кому интересно, прошу переходить в репозиторий.
Хочу показать файл с зависимостями и доп скрипты. Файл с зависимости в Platformio описывается в корне проекта в platformio.ini. Из интересного это секция extra_scripts в которой мы можем подключать pre и post hook скрипты на Python. Мне например помогло это чтобы настроить версионирование для прошивки.

platformio.ini
[platformio]
extra_configs = ./**/*platformio.ini

[env]
framework = arduino
lib_deps =
    gyverlibs/AutoOTA@1.2.0
    gyverlibs/GyverDB@1.3.0
    gyverlibs/WiFiConnector@1.0.4
    gyverlibs/Settings@1.3.5
    gyverlibs/GSON@1.7.0
    gyverlibs/Looper@1.1.7
    gyverlibs/GyverBME280@1.5.3
    knolleary/PubSubClient@2.8
    sparkfun/SparkFun CCS811 Arduino Library@2.0.3
    bodmer/TFT_eSPI@2.5.43
    adafruit/Adafruit NeoPixel@1.14.0
extra_scripts = 
	pre:scripts/get_version.py
    scripts/copy_fw_files.py

[env:mhetesp32devkit]
platform = espressif32
board = mhetesp32devkit
monitor_speed = 115200
build_type = release # debug
board_build.filesystem = littlefs
monitor_raw = true

[env:lolin_s2_mini]
platform = espressif32
board = lolin_s2_mini
board_build.mcu = esp32s2
monitor_speed = 115200
build_type = release # debug
board_build.filesystem = littlefs
monitor_raw = true
scripts/copy_fw_files.py
Import("env")
import os, zipfile, shutil
from pathlib import Path
import json

# Get the version number from the build environment.
firmware_version = os.environ.get('VERSION', "")

if firmware_version == "":
    try:
        with open('project.json', 'r', encoding='utf-8') as f:
            project_data = json.load(f)
            firmware_version = project_data.get('version', "0.0.1")
    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        firmware_version = "0.0.1"

firmware_version = firmware_version.lstrip("v")
firmware_version = firmware_version.strip(".")

def copy_fw_files(source, target, env):
    fw_file_name=str(target[0])
    
    if fw_file_name[-3:] == "bin":
        fw_file_name=fw_file_name[0:-3] + "bin"

    shutil.copy(fw_file_name, "./bin" + "/firmware.bin")
    createCommunityZipFile(source, target, env)

def createCommunityZipFile(source, target, env):
    original_folder_path = "./bin/"
    zip_file_path = './dist/' + "fw_" + firmware_version + '.zip'
    createZIP(original_folder_path, zip_file_path)

def createZIP(original_folder_path, zip_file_path):
    if os.path.exists("./dist") == False:
        os.mkdir("./dist")
    with zipfile.ZipFile(zip_file_path, 'w') as zipf:
        for root, dirs, files in os.walk(original_folder_path):
            for file in files:
                # Create a new path in the ZIP file
                new_path = os.path.join("", os.path.relpath(os.path.join(root, file), original_folder_path))
                # Add the file to the ZIP file
                zipf.write(os.path.join(root, file), new_path)

env.AddPostAction("$BUILD_DIR/${PROGNAME}.hex", copy_fw_files)
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", copy_fw_files)
scripts/get_version.py
Import("env")
import os
import json

firmware_version = os.environ.get('VERSION', "")

if firmware_version == "":
    try:
        with open('project.json', 'r', encoding='utf-8') as f:
            project_data = json.load(f)
            firmware_version = project_data.get('version', "0.0.1")
    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        firmware_version = "0.0.1"

firmware_version = firmware_version.lstrip("v")
firmware_version = firmware_version.strip(".")

print(f'Using version {firmware_version} for the build')

env.Append(CPPDEFINES=[
  f'BUILD_VERSION={firmware_version}'
])

env.Replace(PROGNAME=f'{env["PIOENV"]}_{firmware_version.replace(".", "_")}')

Для тестирование подключения к MQTT брокеру так же был написан небольшой скрипт на языке Go. Так я смог проверить формат сообщения и все ли будет работать, пока не написал прошивку для устроства. Скрипт генерирует рандомное float значение и отправляет в топик (так же сам подписывается на него для дебага).

scripts/mqtt_tester/main.go
package main

import (
	"crypto/rand"
	"fmt"
	"log"
	"math/big"
	"net/url"
	"strconv"
	"sync"
	"time"

	mqtt "github.com/eclipse/paho.mqtt.golang"
)

type MQTTConnectionConfig struct {
	ClientID string
	Server   string
	Port     int
	Username string
	Password string
}

var (
	mqttConfig = MQTTConnectionConfig{
		ClientID: "mqtt_tester",
		Server:   "m8.wqtt.ru",
		Port:     20336,
		Username: "",
		Password: "",
	}

	co2Topic  = "common/aqm/co2"
	tvocTopic = "common/aqm/tvoc"
)

func connect(clientId string, uri *url.URL) mqtt.Client {
	opts := createClientOptions(clientId, uri)
	client := mqtt.NewClient(opts)
	token := client.Connect()

	fmt.Println("try establish connection to MQTT...")

	for !token.WaitTimeout(3 * time.Second) {
	}

	if err := token.Error(); err != nil {
		log.Fatal(err)
	}

	fmt.Println("successfully connected to MQTT!")
	return client
}

func createClientOptions(clientId string, uri *url.URL) *mqtt.ClientOptions {
	opts := mqtt.NewClientOptions()
	opts.AddBroker(fmt.Sprintf("tcp://%s", uri.Host))
	opts.SetUsername(uri.User.Username())
	password, _ := uri.User.Password()
	opts.SetPassword(password)
	opts.SetClientID(clientId)
	return opts
}

func listen(uri *url.URL, topic string) {
	client := connect(fmt.Sprintf("%s_sub", mqttConfig.ClientID), uri)

	client.Subscribe(topic, 0, func(client mqtt.Client, msg mqtt.Message) {
		fmt.Printf("* [%s] %s\n", msg.Topic(), string(msg.Payload()))
	})
}

func main() {
	connectionString := fmt.Sprintf("mqtt://%s:%s@%s:%d",
		mqttConfig.Username,
		mqttConfig.Password,
		mqttConfig.Server,
		mqttConfig.Port,
	)

	uri, err := url.Parse(connectionString)
	if err != nil {
		log.Fatal(err)
	}

	topics := []string{
		co2Topic,
		tvocTopic,
	}

	wg := sync.WaitGroup{}
	for _, topic := range topics {
		wg.Add(1)

		go func() {
			defer wg.Done()
			listen(uri, topic)
		}()
	}

	wg.Wait()

	client := connect(fmt.Sprintf("%s_pub", mqttConfig.ClientID), uri)
	timer := time.NewTicker(5 * time.Second)

	for {
		select {
		case <-timer.C:
			value := GetRandFloat(400, 1500)
			client.Publish(co2Topic, 0, false, strconv.FormatFloat(value, 'f', 6, 64))
			value = GetRandFloat(0, 250)
			client.Publish(tvocTopic, 0, false, strconv.FormatFloat(value, 'f', 6, 64))
		default:
		}
	}
}

const floatPrecision = 100

func GetRandInt(min, max int) int {
	nBig, _ := rand.Int(rand.Reader, big.NewInt(int64(max+1-min)))
	n := nBig.Int64()
	return int(n) + min
}

func GetRandFloat(min, max float64) float64 {
	minInt := int(min * floatPrecision)
	maxInt := int(max * floatPrecision)

	return float64(GetRandInt(minInt, maxInt)) / floatPrecision
}

В репозитории настроен CI/CD для автоматической сборки бинарного файла прошивки и сохранения его в артифайты пайпа. На этом подробно останавливаться не буду. Оставлю лишь файл, по которому обновляется версия. В проекте есть возможность сбилдить прошивку под разные МК, но я пока это не успел доделать. Хотел бы обратить внимание еще на то, как парсится версия прошивки. Она берется из специального json файла в проекте и подставляется скриптом.

project.json
{
    "name": "AirQualityMonitor",
    "about": "Умный монитор качества воздуха",
    "version": "1.0.0",
    "notes": "",
    "builds": [
      {
        "chipFamily": "ESP32",
        "parts": [
          {
            "path": "https://raw.githubusercontent.com/WildEgor/AirQualityMonitor/master/bin/firmware.bin",
            "offset": 0
          }
        ]
      }
    ]
  }

Не случайно я везде указал полный путь до файлов. Я не знал, как лучше структурировать проект. Обычно многие пишут всю реализацию в main.cpp или делают плоскую файловую структуру. Мне это было непривычно, поэтому я сделал как мне показалось удобнее - разбил код по фичам.

Готовое устройство в работе

При первом включение устройства нужно будет настроить подключение к сети через Wi-Fi и брокеру MQTT. Можно и не настраивать и использовать устройство "как есть". Я не буду подробно останавливаться на этом, т.к. оформил мануал в репозитории. Поверхностно покажу что в итоге получилось. Можно будет получать данные об CO2 на экране устройства, но нужно будет настроить Wi-Fi и подключение к брокеру MQTT (wqtt.ru). Для этого нужно пройти в браузере по адресу указанному на экране.

Устройство ждет, пока настроем выход в интернет

По адресу откроета веб-панель настроек устройства. На главном меню отображается название устройства и текущая версия прошивки. Так же в виде иконки есть ли подключение устройству или нет. Меню разделено на одноименные пункты

Главное меню

Чтобы настроить Wi-Fi нужно перейти в пункт меню (кэп) WiFi. Нужно будет указать имя сети и пароль. Кнопка Save подключит устройство к сети или покажет ошибку.

Пункт WiFi

Подключение к MQTT можно так же найти в пунктах главного меню. Можно в целом вырубить его и тогда устройство не будет пытаться подключиться и отсылать данные в топики. Пункт Device ID нужен, чтоб создать префикс для топиков. Его можно оставить пустым или если один брокер обслуживает N таких устройств, то обозвать его как-то логично (например, home1/hall и т.д.). Главное, чтоб эти префиксы не пересекались в рамках одного сервера. Остальные данные можно получить со страницы брокера.

Пункт MQTT

В пункте CO2 можно настроить значение, когда сработает визуальная сигнализация, а также выбрать тип отображаемой шкалы (например, 3-х или 4-х цветная). Так же из этого пункта меню можно начать/закончить калибровку. CCS811 не требует калибровки. Однако ему нужно время на «burn-in». Это означает, что примерно через неделю датчик становится более стабильным. Однако внутренний контроллер учитывает этот период приработки и компенсирует его.

Пункт CO2

Через системный (System) пункт меню можно отключить RGB подсветку, а также сменить тему интерфейса на дисплее. Для отладочной информации выведен лог и можно переключить уровень логгирования. С помощью отдельной кнопки можно повернуть экран, чтобы удобно установить устройство. Здесь же можно обновить прошивку устройства.

Пункт System
Светлая тема
Светлая тема
Повернутый экран на 90 градусов
Повернутый экран на 90 градусов

Что касается обновлений, то в репозитории проекта в релизах можно найти прошивки разных версий. Версия собирается из актуального в master ветке.

Обновление прошивки
По индикатору на дисплее (внизу) можно узнать, что есть новая версия прошивки
По индикатору на дисплее (внизу) можно узнать, что есть новая версия прошивки
После обновления
После обновления

Уведомление демонстрационное, т.к. новой версии не было

По кнопке в конце меню открывается инструкция как настроить устройство и подключить его к УДЯ. В целом все сводиться к тому, что нужно зарегистрироваться на wqtt.ru и указать топики устройства, а дальше подтянуть его в приложении. Поэтому более подробнее не буду на этом останавливаться.

Устройство в УДЯ

Итог

Получившиеся устройство вышло за рамки требований в ходе разработки. Основные функции:

  • Измеряет eCO2 и TVOC и публикует данные в топики раз в 30 сек;

  • Измеряет температуру, давление и влажность и публикует данные в топики раз в 30 сек;

  • Выводит на дисплей:

    • Текущее значение eCO2 в единицах измерения (ppm);

    • Адрес веб-панели (точки доступа или локальный в сети);

    • Состояние подключения к Wi-Fi сети;

    • Состояние подключения к MQTT брокеру;

    • Состояние калибровки;

    • Версию прошивки устройства, а также уведомление об доступности новой прошивки.

  • Сигнализирует об текущем значении и превышении уровня CO2 в помещении через RGB светодиод;

  • Предоставляет веб-интерфейс для настроек устройства:

    • Можно настроить подключение к Wi-Fi и MQTT;

    • Настроить верхнюю границу при которой срабатывает световая сигнализация;

    • Можно изменить цвет шкалы на дисплее;

    • Отключить светодовую индикацию;

    • Сменить отображаемую дисплеем тему;

    • Повернуть дисплей на угол кратный 90;

Что хотелось бы улучшить:

  • Провести калибровку датчиков. Если с датчиком CCS811 грубо говоря все понятно - попробовать откалибровать на свежем воздухе, то непонятно как калибровать датчик BME280 так как нужен эталон по которому можно поверить устройство;

  • Есть баг, когда устройство при запуске не отображает часть информации. Нужно копать в сторону логики написанной в display.h. Пока что это лечится перезапуском устройства;

  • При программном повороте дисплея данные на дисплее отображаются относительно отверстия на корпусе со сдвигом. Думаю можно полечить, если добавить сдвиг еще и программно;

  • Сам корпус не идеален, например держалки дисплея и задней крышки хрупкие. Кроме того, чтобы установить дисплей внутрь приходиться расширять отверстия;

  • Cуществует другой способ, как можно выводить данные на дисплей с кастомным интерфейсом используя LVGL и есть поддержка TFT_eSPI. Хотелось бы попробовать сделать более красивый дизайн;

  • Добавить функцию отключения дисплея через веб-панель;

  • Добавить кнопку сброса к заводским настройкам;

  • Добавить на веб-панель дашборд с вывод данных с датчиков.

  • И много еще чего...

Надеюсь, что мой опыт вас заинтересовал или может быть даже вдохновил собрать или доделать DIY проект. Повторю ссылку на репозиторий.

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


  1. ErshoffPeter
    23.07.2025 18:06

    Проект супер! А подробный рассказ ещё лучше! Но по-большому счету, прощу прощения, изобретать велосипед не очень хочется.

    У меня Mi home с несколькими десятками устройств уже более четырёх лет и он меня практически во всём устраивает, особенно в содружестве с Алисами.

    Единственное серьёзного чего нет ни в Mi home ни в УДЯ - это банального регистра памяти как устройства, такого чьё состояние можно было бы читать в сценариях и на который можно вешать триггеры.

    Меня сейчас каждый второй пошлёт в Home Assistance, но от добра добра не ищут, точнее лёгкость и удобство настройки сценариев сложно заменить кодированием.

    Поэтому я всё больше и больше думаю о реализации такого виртуального устройства как регистр памяти - так, что бы его можно было бы добавить как устройство и юзать всячески как устройство.


    1. ColdPhoenix
      23.07.2025 18:06

      А можете рассказать как это должен быть?

      Просто блок переменных?


      1. ErshoffPeter
        23.07.2025 18:06

        Не совсем.

        Сценарий исполнения такой.

        1. Регистрируем устройство и задаём имя, название (дом и так далее).

        2. У этого устройства есть одна, как минимум, операция - считать значение с некого датчика. Можно и другие операции - инкремент, декремент и так далее.

        3. У этого устройство есть значение - фактически чтение того, что туда занесли.

        4. Плюс есть триггер - который срабатывает при попадании значения в заданные условия.

        Как-то так.


        1. waxtah
          23.07.2025 18:06

          Посмотри в сторону home assistant + node red.


          1. ErshoffPeter
            23.07.2025 18:06

            Спасибо, но хочется остаться по-максимуму в рамках Mi home.


      1. ErshoffPeter
        23.07.2025 18:06

        Суть такого устройства - переменной в том, что бы либо собирать и агреггировать значения с разных датчиков (например освещенность на разных сторонах дома) либо какой-то сумматор / счётчик неких событий.


        1. xSVPx
          23.07.2025 18:06

          Еще чуток и изобретется mqtt