Давно хотел в 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. Но это не точно