Давно хотел в Home Assistant завести CO2 датчики, но на этапе уточнения цены душила жаба. С недавних пор увлекся поделками на ESP32, еще раз погрузился в тему с учетом новых знаний и оказалось что можно жабу не будить и сделать все не просто просто, a очень просто и очень не дорого. В процессе так же захотелось что бы LED на плате показывал концентрацию CO2 цветом от зеленого до красного мигающего, а не только отсылал данные через Zigbee

Итак, нам понадобится:

  • ESP32-H2 Super mini. Цена на маркетплейсах ~350 р. Рекомендованный из-за размеров и цены вариант. Так же для этого варианта есть готовый минималистический корпус (если умеете FreeCAD и захотите допилить, файл FreeCAD там тоже есть). Можно ESP32-C5. Дороже, больше, и при сборке проекта в Arduino придется чуть поправить скетч под правильные для него пины. В целом можно вообще ESP32 без Zigbee, будет цветом светодиода показывать уровень CO2. Но придется в скетче выпиливать Zigbee вручную, в нынешнеей версии он будет ждать коннекта к Home Assistant прежде чем мерять CO2.

  • SCD40/41. Цена на маркетплейсах ~1000 р. Правда что ты в итоге покупаешь 40 или 41 не всегда понятно. 41 от 40 отличается тем что может мерить CO2 до 5000ppm (40 - до 2000). Оба так же умеют температуру и влажность. Оба умеют автокалибровку CO2, надо только простой советский раз в неделю проветривать комнату, где он стоит, от души, до уличных 400ppm. Что вы купите - не всегда понятно, как их внешне различать я не знаю. Иногда на плате пишут SCD40, а иногда нет.

  • Припой паяльник и проводочки. Рекомендую канифольку жидкую ЛТИ-120, припой ПОС 61, 26AWG провод в мягкой силиконовой изоляции, и паяльник TS101. Особо большого опыты не надо, там всего 4 провода запаять

Выставляем такие вот настройки в Arduino IDE

Скрытый текст

Заливаем вот такой скетч (последняя версия всегда есть на github). Для H2 править ничего не надо, для C5 меняем I2C_SDA, I2C_SCL и WS2812_GPIO. Если Zigbee не надо то выкидываем все что к нему относится и оставляем только работу с датчиком и LED. По дефолту LED работает так: до 800ppm - зеленый, от 801 до 1200 - желтый, от 1201 до 1800 - оранжевый, от 1801 до 1999 - красный, выше 1999 - красный мигающий. Так же светодиод меняет яркость, чем ближе к красному - тем ярче.

Скрытый текст
#ifndef ZIGBEE_MODE_ED
#error "Zigbee End Device mode is not selected (Tools -> Zigbee mode -> End Device)."
#endif

#include <Wire.h>
#include <Zigbee.h>
#include <math.h>

#include <Adafruit_NeoPixel.h>
#include <SensirionI2cScd4x.h>
#include <SensirionCore.h>   // for errorToString()

// ---------- Pins ----------
// #define I2C_SDA 2 // esp32-c5 dev board
// #define I2C_SCL 3 // esp32-c5 dev board
#define I2C_SDA 10 // esp32-h2 Super Mini 
#define I2C_SCL 11 // esp32-h2 Super Mini


// #define WS2812_GPIO 27 // esp32-c5 dev board
#define WS2812_GPIO 8 // esp32-h2 Super Mini
#define WS2812_LEDS 1

// ---------- SCD4x ----------
static constexpr uint8_t SCD4X_I2C_ADDR = 0x62;

// ---------- Zigbee endpoints ----------
#define EP_TEMP_HUM 10
#define EP_CO2      11
#define EP_LED_DIM  12
#define EP_ALARM    13

// ------------ Common -------------------
static bool g_zclReady = false;
static uint32_t g_connectedAt = 0;

// ------------ LED ----------------------
static bool g_ledEnabled = true;
static uint8_t g_ledLevel100 = 40;  // стартовая яркость в %, 0..100

// ------------- Binary ------------------
static bool g_alarm = false; 


// ------------- For blink ---------------
static uint16_t g_lastCO2 = 0;
static bool g_hasCO2 = false;

// ---------- Timing ----------
static constexpr uint32_t SENSOR_POLL_MS = 1000;   
static constexpr uint32_t ZB_REPORT_MS   = 30000;

// ----------- PPM Limits -----------------
static uint16_t green_ppm = 800;
static uint16_t yellow_ppm = 1200;
static uint16_t orange_ppm = 1800;
static uint16_t red_ppm = 1999; // for SCD40

// ---------- Objects ----------
Adafruit_NeoPixel pixels(WS2812_LEDS, WS2812_GPIO, NEO_GRB + NEO_KHZ800);

SensirionI2cScd4x scd4x;

ZigbeeTempSensor zbTempHum(EP_TEMP_HUM);
ZigbeeCarbonDioxideSensor zbCO2(EP_CO2);
ZigbeeDimmableLight zbLedDim = ZigbeeDimmableLight(EP_LED_DIM);
ZigbeeBinary zbAlarm(EP_ALARM);


// ---------- State ----------
static uint32_t lastPoll = 0;
static uint32_t lastReport = 0;

static const uint8_t button = BOOT_PIN;

// ---------- LED ----------
static void setLedRGB(uint8_t r, uint8_t g, uint8_t b) {
  pixels.setPixelColor(0, pixels.Color(r, g, b));
  pixels.show();
}

static void setLedByCO2(uint16_t ppm) {
  if (ppm < green_ppm)   { setLedRGB(0, 80, 0); return; }     // green
  if (ppm < yellow_ppm)  { setLedRGB(80, 80, 0); return; }    // yellow
  if (ppm < orange_ppm)  { setLedRGB(120, 40, 0); return; }   // orange 
  if (ppm < red_ppm)     { setLedRGB(140, 0, 0); return; }    // red
  setLedRGB(80, 0, 120);                                // purple
}


// LED blink
static void updateLedByCO2(uint16_t ppm) {
  if (!g_ledEnabled) {
    pixels.clear();
    pixels.show();
    return;
  }

  // --- 1) Maximum brightness from HA (0..100% -> 0..255) ---
  uint8_t maxBr = (uint8_t)((uint16_t)g_ledLevel100 * 255 / 100);

  // --- 2) Exponential brightness scale based on CO2 ---
  //  - below ~800 ppm: almost dark
  //  - around 1500 ppm: sharp increase
  //  - >= 2000 ppm: near maximum
  const float CO2_MIN = 400.0f;
  const float CO2_MAX = (float)red_ppm;

  float x;
  if (ppm <= CO2_MIN) {
    x = 0.0f;
  } else if (ppm >= CO2_MAX) {
    x = 1.0f;
  } else {
    x = (float)(ppm - CO2_MIN) / (CO2_MAX - CO2_MIN); // 0..1
  }

  // Exponent: the higher gamma, the sharper the "flash"
  const float gamma = 3.0f;   // 2.0 is softer, 3.0 is sharp, 4.0 is very sharp
  float k = powf(x, gamma);  // 0..1

  // minimum backlight so the LED doesn't completely "disappear"
  const float k_min = 0.08f; // 8% from max
  k = k_min + (1.0f - k_min) * k;

  uint8_t br = (uint8_t)(maxBr * k);
  pixels.setBrightness(br);

  // --- 3) blinking at >= 1999 ppm --- ---
  if (ppm >= red_ppm) {
    bool on = ((millis() / 500) % 2) == 0; // 1 Гц
    if (on) pixels.setPixelColor(0, pixels.Color(160, 0, 0));
    else    pixels.setPixelColor(0, pixels.Color(0, 0, 0));
    pixels.show();
    return;
  }

  // --- 4) color steps ---
  uint8_t r = 0, g = 0, b = 0;

  if (ppm < 800) {
    r = 0;   g = 120; b = 0;   // green
  } else if (ppm < yellow_ppm) {
    r = 120; g = 120; b = 0;   // yellow
  } else if (ppm < orange_ppm) {
    r = 160; g = 60;  b = 0;   // orange
  } else {
    r = 160; g = 0;   b = 0;   // red
  }

  pixels.setPixelColor(0, pixels.Color(r, g, b));
  pixels.show();
}



static void onLedChange(bool state, uint8_t level) {
  g_ledEnabled = state;
  if (level > 100) level = 100;
  g_ledLevel100 = level;

  // apply brightness immediately (0..100 -> 0..255)
  uint8_t b = (uint8_t)((uint16_t)g_ledLevel100 * 255 / 100);
  pixels.setBrightness(b);

  // if turned off — switch off
  if (!g_ledEnabled) {
    pixels.clear();
    pixels.show();
  }
}

// ----------- Alarm --------------
static void updateCo2Alarm(uint16_t ppm) {
  if (!g_zclReady) return;

  bool newAlarm = (ppm >= red_ppm);
  if (newAlarm == g_alarm) return;

  g_alarm = newAlarm;

  zbAlarm.setBinaryInput(g_alarm);
}

// ---------- SCD4x init ----------
static bool initScd4x() {
  Wire.begin(I2C_SDA, I2C_SCL);

  scd4x.begin(Wire, SCD4X_I2C_ADDR);

  uint16_t err = 0;
  char errMsg[64];

  (void)scd4x.stopPeriodicMeasurement();
  delay(50);

  // ---- ASC target (fresh air reference) ----
  // Usually 400 ppm for outdoor air
  err = scd4x.setAutomaticSelfCalibrationTarget(400);
  if (err) {
    errorToString(err, errMsg, sizeof(errMsg));
    Serial.printf("SCD4x setASC target failed: %s\n", errMsg);
  }

  err = scd4x.startPeriodicMeasurement();
  if (err) {
    errorToString(err, errMsg, sizeof(errMsg));
    Serial.printf("SCD4x startPeriodicMeasurement failed: %s\n", errMsg);
    return false;
  }

  return true;
}

// ---------- SCD4x read ----------
static bool readScd4x(uint16_t &co2ppm, float &tempC, float &rh) {
  int16_t err = 0;
  char errMsg[64];

  bool dataReady = false;
  err = scd4x.getDataReadyStatus(dataReady);
  if (err) {
    errorToString(err, errMsg, sizeof(errMsg));
    Serial.printf("SCD4x getDataReadyStatus failed: %s\n", errMsg);
    return false;
  }
  if (!dataReady) return false;

  err = scd4x.readMeasurement(co2ppm, tempC, rh);
  if (err) {
    errorToString(err, errMsg, sizeof(errMsg));
    Serial.printf("SCD4x readMeasurement failed: %s\n", errMsg);
    return false;
  }

  if (co2ppm == 0) return false;
  return true;
}


// ---------- Zigbee reset / manual report ----------
static void handleButton() {
  if (digitalRead(button) != LOW) return;

  delay(100);
  uint32_t start = millis();

  while (digitalRead(button) == LOW) {
    delay(50);
    if (millis() - start > 3000) {
      Serial.println("Factory reset Zigbee + reboot...");
      setLedRGB(120, 0, 0);
      delay(300);
      Zigbee.factoryReset();
      ESP.restart();
    }
  }

  Serial.println("Manual report()");
  zbTempHum.report();
  zbCO2.report();
}

// ---------- Arduino ----------
void setup() {
  Serial.begin(115200);
  delay(200);

  pinMode(button, INPUT_PULLUP);
  
  pixels.setBrightness(40);  
  pixels.begin();
  pixels.clear();
  pixels.show();

  setLedRGB(0, 0, 60); // boot blue

  if (!initScd4x()) {
    setLedRGB(80, 0, 80); // sensor error purple
  }

  // Zigbee endpoints
  zbTempHum.setManufacturerAndModel("Custom", "ESP32C5_SCD4x");
  zbTempHum.setMinMaxValue(-10, 60);
  zbTempHum.setTolerance(0.2f);
  zbTempHum.addHumiditySensor(0, 100, 1.0f);

  zbCO2.setManufacturerAndModel("Custom", "ESP32C5_SCD4x");
  zbCO2.setMinMaxValue(0, 10000);
  zbCO2.setTolerance(50);
  // Zigbee LED dimmer endpoint (brightness control from HA)
  zbLedDim.setManufacturerAndModel("Custom", "ESP32C5_SCD4x_LED");
  zbLedDim.onLightChange(onLedChange);  // callback state+level :contentReference[oaicite:1]{index=1}

  Zigbee.addEndpoint(&zbAlarm);
  Zigbee.addEndpoint(&zbLedDim);
  Zigbee.addEndpoint(&zbTempHum);
  Zigbee.addEndpoint(&zbCO2);

  Serial.println("Starting Zigbee...");
  if (!Zigbee.begin()) {
    Serial.println("Zigbee failed to start -> reboot");
    setLedRGB(120, 0, 0);
    delay(500);
    ESP.restart();
  }

  while (!Zigbee.connected()) delay(100);
  Serial.println("Zigbee connected.");
  g_connectedAt = millis();   // timestamp, then we wait

  delay(500);
  if (!g_zclReady && Zigbee.connected() && g_connectedAt && (millis() - g_connectedAt > 2000)) {
    g_zclReady = true;

    // --- Alarm endpoint (now the lock is already ready) ---
    zbAlarm.addBinaryInput();
    zbAlarm.setBinaryInputApplication(BINARY_INPUT_APPLICATION_TYPE_SECURITY_CARBON_DIOXIDE_DETECTION);
    zbAlarm.setBinaryInputDescription("CO2 alarm");
    zbAlarm.setBinaryInput(false);
    zbLedDim.setLight(g_ledEnabled, g_ledLevel100); // безопаснее после ready

    // apply brightness/on locally
    onLedChange(g_ledEnabled, g_ledLevel100);
    zbLedDim.restoreLight();
  }

  
  // reporting
  zbTempHum.setReporting(10, 300, 0.2f);
  zbTempHum.setHumidityReporting(10, 300, 1.0f);
  zbCO2.setReporting(0, 30, 0);
  setLedRGB(0, 60, 0); // ready green (before first CO2)
}

void loop() {
  handleButton();

  const uint32_t now = millis();

  if (now - lastPoll >= SENSOR_POLL_MS) {
    lastPoll = now;

    uint16_t co2ppm = 0;
    float tempC = NAN;
    float rh = NAN;

    if (readScd4x(co2ppm, tempC, rh)) {
      Serial.printf("SCD4x: CO2=%u ppm, T=%.2f C, RH=%.2f %%\n", co2ppm, tempC, rh);
      g_lastCO2 = co2ppm;
      g_hasCO2 = true;
      zbCO2.setCarbonDioxide((float)co2ppm);
      zbTempHum.setTemperature(tempC);
      zbTempHum.setHumidity(rh);
      
      updateLedByCO2(co2ppm);
      updateCo2Alarm(co2ppm);

      if (now - lastReport >= ZB_REPORT_MS) {
        lastReport = now;
        zbCO2.report();
        zbTempHum.report();
        Serial.println("Zigbee report sent.");
      }
    }
  }
  if (g_hasCO2) {
    updateLedByCO2(g_lastCO2);   // will blink steadily, even if the sensor updates rarely
  }
  delay(20);
}

А так же паяем SDA датчика на 10 пин платы, SDL на 11, GND на GND, а VDD на 3v3.

Подаем питание через USB.

Если вы все сделали правильно, то после прошивки скетча LED должен загореться синим, ожидая когда его пустят в HA. Идем в настройки Zigbee2MQTT в HA, жмем смело Permit to join. Если вы и тут все сделали правильно, светодиод должен поменять цвет с синего, в консоли ESP32 должны пойти логи об измерении а в HA должно появится эдакое

Внимание, кнопочки для управление миганием и яркостью вроде есть, но я их особо не тестил. А вот влажность, температура и CO2 соответcтвует другим датчикам.

About выглядит стремненько, иконка дефолтная, горит желтая меточка "Not supported" но на это можно смело забить, все работает

Если у вас есть 3d принтер то можно еще распечатать и упаковать в корпус. Нужны еще 2 самореза M2x6.

В целом выглядит так что при небольшой модификации скетча (например выкинув из скетча работу с Zigbee) можно из этого сделать переносной дeвайс контроля CO2 с питанием от USB в любом месте, например там где вы работаете в данный момент.

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

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


  1. Arhammon
    27.01.2026 09:02

    Припой паяльник и проводочки. Рекомендую канифольку жидкую ЛТИ-120, припой ПОС 61, 26AWG провод в мягкой силиконовой изоляции

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


    1. sergeygals Автор
      27.01.2026 09:02

      ну на фото третий вариант корпуса, да время примерок с вытаскиванием / втаскиванием, а так же вытаскиванием для фото ничего не оторвалось и не отломилось :) опять же, монтажную плату пришлось бы обрезать, и мне показалось что с ней будет толще , потому что гребёнка + плата + провода перемычек.


      1. Arhammon
        27.01.2026 09:02

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


  1. longmaster
    27.01.2026 09:02

    В названии датчика буквы перепутаны: не SDC40/41, а SCD40/41


    1. sergeygals Автор
      27.01.2026 09:02

      спасибо, поправил


  1. riky
    27.01.2026 09:02

    А через esphome просто нельзя было сделать? Необходимость Проветривания для калибровки конечно грустно. Легко забыть особенно если во все комнаты ставить.


    1. sergeygals Автор
      27.01.2026 09:02

      Ну теоретически можно нo:

      1. Наврядли игру с цветом и яркостью там удалось организовать

      2. Дополнительная идея в том что выкинув из скетча Zigbee код можно организовать себе в любом месте сенсор CO2 который будет работать от USB и показывать его концентрацию прям в том месте где сегодня работаешь. Это, скорее всего значит что HA а так же esphome нет.

      А с провертриванием проблем не вижу, в целом же датчик для того и ставится, что бы поменьше CO2 было.


      1. riky
        27.01.2026 09:02

        у меня если честно до esphome все руки не доходят, но и с дисплеями и со светодиодами там проблем нет насколько я знаю
        https://esphome.io/components/display/
        https://esphome.io/components/light/rgb/


        1. sergeygals Автор
          27.01.2026 09:02

          ну что моргать и светить оно умеет понятно, но я не уверен что логика зависимости свечение и моргания от уровня CO2 в yamlы esphome просто запилить не получится. если получится вообще


          1. riky
            27.01.2026 09:02

            там есть привязка к данным датчиков.
            https://grok.com/share/c2hhcmQtNQ_f2fa5b59-fb4d-4374-8163-6a37da4b4ac4

            причем скорее всего можно сделать чтобы на одной esp меряло, а на другой на работчем столе например мигало.


            1. riky
              27.01.2026 09:02

              в общем понял нюанс. так он будет работать через wifi а не zigbee. но т.к. питание не от батареи то мне лично все равно. зато можно по-быстрому конфиг поменять и в 2 клика прошить, не надо искать прошивку которую пару лет назад как то сделал.

              посомтрел датчики, выбрал sense air s8, судя по всему постабильнее будут. по опыту эксплуатации своего датчика понимаю что калибровкой заниматься никакого желания нету. (текущий только выводит на экран, без HA).

              идея сразу родилась по датчику можно сделать подобие датчика присутствия. понятно что с инерцией но так УД может отслеживать наличие людей в доме (с учетом датчиков открытия окон).


              1. sergeygals Автор
                27.01.2026 09:02

                sense air s8 вроде подороже и никаких i2C, только UART, шину не сделаешь :)


                1. riky
                  27.01.2026 09:02

                  подороже, да, 1400 примерно. (зато ESP возьму самую дешевую, т.к. зигби не надо, ESP8266 можно даже)
                  шину для чего? uart та же шина, те же 2 провода, только не SCK/SDA а TX/RX.


                  1. sergeygals Автор
                    27.01.2026 09:02

                    это не шина, это point-to-point :) в моем понимании шина это когда я могу на "один провод" и пару пинов повесить несколько устройств


                    1. riky
                      27.01.2026 09:02

                      uart это позволяет, но не все конечные устройства это поддерживают (протокол с указанием адреса/ид устройства).
                      у гайвера была библиотека про организацию сети на uart при паралдлельном подключении.
                      модуль энергомониторинга PZEM-004t так могут работать.
                      но для данного проекта это всё не надо.

                      спросил у гпт пишет что можно тоже адреса поменять.

                      SenseAir S8 позволяет менять Modbus-адрес (регистр HR20, адрес 0x0014 или около того — смотри в datasheet TDE2067 или PSP103).
                      По умолчанию — 0xFE (broadcast) или 0x68.
                      Можно записать любой адрес от 1 до 247 (стандарт Modbus).
                      

                      но это надо тестить, мне это не надо...


      1. riky
        27.01.2026 09:02

        по провертриванию. у меня датчик стоит и зимой до 400ppm никогда не доходит, хотя и провертриваем постоянно. обычно 500-600 минимум. в таких условиях с таким датчиком мне бы оптимистично показывало 400. как вариант разве что отклчюать автокалибровку (возможно?) и делать это вручную раз в месяц, проводить пятничный ритуал собирать эти датчики со всего дома в корзинку и нести к окну...


        1. sergeygals Автор
          27.01.2026 09:02

          Датчик умеет perform_forced_recalibration (3.7 пункт в Datasheet), вот тут описан способ принудительной калибровки https://esphome.io/components/sensor/scd4x/ основываясь на глобальном урове CO2 https://gml.noaa.gov/ccgg/trends/global.html