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