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