Давно хотел в 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)

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

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

riky
27.01.2026 09:02у меня если честно до esphome все руки не доходят, но и с дисплеями и со светодиодами там проблем нет насколько я знаю
https://esphome.io/components/display/
https://esphome.io/components/light/rgb/
sergeygals Автор
27.01.2026 09:02ну что моргать и светить оно умеет понятно, но я не уверен что логика зависимости свечение и моргания от уровня CO2 в yamlы esphome просто запилить не получится. если получится вообще

riky
27.01.2026 09:02там есть привязка к данным датчиков.
https://grok.com/share/c2hhcmQtNQ_f2fa5b59-fb4d-4374-8163-6a37da4b4ac4причем скорее всего можно сделать чтобы на одной esp меряло, а на другой на работчем столе например мигало.

riky
27.01.2026 09:02в общем понял нюанс. так он будет работать через wifi а не zigbee. но т.к. питание не от батареи то мне лично все равно. зато можно по-быстрому конфиг поменять и в 2 клика прошить, не надо искать прошивку которую пару лет назад как то сделал.
посомтрел датчики, выбрал sense air s8, судя по всему постабильнее будут. по опыту эксплуатации своего датчика понимаю что калибровкой заниматься никакого желания нету. (текущий только выводит на экран, без HA).
идея сразу родилась по датчику можно сделать подобие датчика присутствия. понятно что с инерцией но так УД может отслеживать наличие людей в доме (с учетом датчиков открытия окон).

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

riky
27.01.2026 09:02подороже, да, 1400 примерно. (зато ESP возьму самую дешевую, т.к. зигби не надо, ESP8266 можно даже)
шину для чего? uart та же шина, те же 2 провода, только не SCK/SDA а TX/RX.
sergeygals Автор
27.01.2026 09:02это не шина, это point-to-point :) в моем понимании шина это когда я могу на "один провод" и пару пинов повесить несколько устройств

riky
27.01.2026 09:02uart это позволяет, но не все конечные устройства это поддерживают (протокол с указанием адреса/ид устройства).
у гайвера была библиотека про организацию сети на uart при паралдлельном подключении.
модуль энергомониторинга PZEM-004t так могут работать.
но для данного проекта это всё не надо.спросил у гпт пишет что можно тоже адреса поменять.
SenseAir S8 позволяет менять Modbus-адрес (регистр HR20, адрес 0x0014 или около того — смотри в datasheet TDE2067 или PSP103). По умолчанию — 0xFE (broadcast) или 0x68. Можно записать любой адрес от 1 до 247 (стандарт Modbus).но это надо тестить, мне это не надо...

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

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
Arhammon
А потом эти провода никуда не влазят, ломаются на стыке лужения итп. Под корпус и платы с пустым брюхом всё-таки макетка напрашивается.
sergeygals Автор
ну на фото третий вариант корпуса, да время примерок с вытаскиванием / втаскиванием, а так же вытаскиванием для фото ничего не оторвалось и не отломилось :) опять же, монтажную плату пришлось бы обрезать, и мне показалось что с ней будет толще , потому что гребёнка + плата + провода перемычек.
Arhammon
Гребенки можно опустить, они там ничего полезного делать не будут, паять начиная перемычку с верхней платы. Если брать одностороннюю гетинаксовую дешмань, она тонкая и нет проблем от стеклянной пыли. Правда сейчас задумался, чего-то они мне в последнее время не попадались... может не искал за ненадобностью, тк. есть кастомные макетки которые можно в каких то случаях собирать даже без перемычек за счёт сетки дорожек в двух направлениях между отверстиями.